修改前端包结构.
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- 按钮组 -->
|
||||
<a-button-group class="mb4">
|
||||
<!-- 全选 -->
|
||||
<a-button type="text" size="mini" @click="toggleChecked">
|
||||
{{ checkedKeys?.length === allCheckedKeys?.length ? '反选' : '全选' }}
|
||||
</a-button>
|
||||
<!-- 展开 -->
|
||||
<a-button type="text" size="mini" @click="toggleExpanded">
|
||||
{{ expandedKeys?.length ? '折叠' : '展开' }}
|
||||
</a-button>
|
||||
</a-button-group>
|
||||
<!-- 菜单树 -->
|
||||
<a-tree
|
||||
checked-strategy="child"
|
||||
:checkable="true"
|
||||
:animation="false"
|
||||
:only-check-leaf="true"
|
||||
v-model:checked-keys="checkedKeys"
|
||||
v-model:expanded-keys="expandedKeys"
|
||||
:data="treeData" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
name: 'menu-selector-tree'
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import { TreeNodeData } from '@arco-design/web-vue';
|
||||
import { useCacheStore } from '@/store';
|
||||
|
||||
const treeData = ref<Array<TreeNodeData>>([]);
|
||||
|
||||
const allCheckedKeys = ref<Array<number>>([]);
|
||||
const allExpandedKeys = ref<Array<number>>([]);
|
||||
|
||||
const checkedKeys = ref<Array<number>>([]);
|
||||
const expandedKeys = ref<Array<number>>([]);
|
||||
|
||||
// 修改选中状态
|
||||
const toggleChecked = () => {
|
||||
checkedKeys.value = checkedKeys.value.length === allCheckedKeys.value.length ? [] : allCheckedKeys.value;
|
||||
};
|
||||
|
||||
// 修改折叠状态
|
||||
const toggleExpanded = () => {
|
||||
expandedKeys.value = expandedKeys?.value.length ? [] : allExpandedKeys.value;
|
||||
};
|
||||
|
||||
// 循环选中的 key
|
||||
const eachAllCheckKeys = (arr: Array<any>) => {
|
||||
arr.forEach((item) => {
|
||||
allCheckedKeys.value.push(item.key);
|
||||
if (item.children && item.children.length) {
|
||||
eachAllCheckKeys(item.children);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 循环展开的 key
|
||||
const eachAllExpandKeys = (arr: Array<any>) => {
|
||||
arr.forEach((item) => {
|
||||
if (item.children && item.children.length) {
|
||||
allExpandedKeys.value.push(item.key);
|
||||
eachAllExpandKeys(item.children);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 渲染数据
|
||||
const init = (keys: Array<number>) => {
|
||||
// 初始化数据
|
||||
allCheckedKeys.value = [];
|
||||
allExpandedKeys.value = [];
|
||||
checkedKeys.value = keys;
|
||||
expandedKeys.value = [];
|
||||
|
||||
// 渲染菜单
|
||||
const cacheStore = useCacheStore();
|
||||
let render = (arr: any[]): TreeNodeData[] => {
|
||||
return arr.map((s) => {
|
||||
// 当前节点
|
||||
const node = {
|
||||
key: s.id,
|
||||
title: s.name,
|
||||
children: undefined as unknown
|
||||
} as TreeNodeData;
|
||||
// 子节点
|
||||
if (s.children && s.children.length) {
|
||||
node.children = render(s.children);
|
||||
}
|
||||
return node;
|
||||
}).filter(Boolean);
|
||||
};
|
||||
|
||||
// 加载菜单
|
||||
treeData.value = render([...cacheStore.menus]);
|
||||
// 加载所有选中的key
|
||||
eachAllCheckKeys(treeData.value);
|
||||
// 加载所有展开的key
|
||||
eachAllExpandKeys(treeData.value);
|
||||
};
|
||||
init([]);
|
||||
|
||||
// 获取值
|
||||
const getValue = () => {
|
||||
if (!checkedKeys.value.length) {
|
||||
return [];
|
||||
}
|
||||
// 查询子节点上级父节点
|
||||
const mixed: number[] = [];
|
||||
const findParent = (arr: Array<TreeNodeData>, key: number) => {
|
||||
for (let node of arr) {
|
||||
// 是子节点 并且相同
|
||||
if (node.key === key) {
|
||||
mixed.push(key);
|
||||
return true;
|
||||
}
|
||||
if (node.children?.length) {
|
||||
const isFind = findParent(node.children, key);
|
||||
if (isFind) {
|
||||
mixed.push(node.key as number);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// 设置所有节点
|
||||
for (let key of checkedKeys.value) {
|
||||
findParent(treeData.value, key);
|
||||
}
|
||||
return new Set(mixed);
|
||||
};
|
||||
|
||||
defineExpose({ init, getValue });
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>;
|
||||
194
orion-ops-ui/src/components/system/menu/tree/index.vue
Normal file
194
orion-ops-ui/src/components/system/menu/tree/index.vue
Normal file
@@ -0,0 +1,194 @@
|
||||
<script lang="tsx">
|
||||
import { compile, computed, defineComponent, h, ref } from 'vue';
|
||||
import type { RouteMeta } from 'vue-router';
|
||||
import { RouteRecordRaw, useRoute, useRouter } from 'vue-router';
|
||||
import { useAppStore } from '@/store';
|
||||
import { listenerRouteChange } from '@/utils/route-listener';
|
||||
import { openWindow, regexUrl } from '@/utils';
|
||||
import useMenuTree from './use-menu-tree';
|
||||
|
||||
export default defineComponent({
|
||||
emit: ['collapse'],
|
||||
setup() {
|
||||
const appStore = useAppStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const { menuTree } = useMenuTree();
|
||||
const collapsed = computed({
|
||||
get() {
|
||||
if (appStore.device === 'desktop') return appStore.menuCollapse;
|
||||
return false;
|
||||
},
|
||||
set(value: boolean) {
|
||||
appStore.updateSettings({ menuCollapse: value });
|
||||
},
|
||||
});
|
||||
|
||||
const topMenu = computed(() => appStore.topMenu);
|
||||
const openKeys = ref<string[]>([]);
|
||||
const selectedKey = ref<string[]>([]);
|
||||
|
||||
/**
|
||||
* 跳转
|
||||
*/
|
||||
const goto = (e: any, item: RouteRecordRaw) => {
|
||||
// 打开外链
|
||||
if (regexUrl.test(item.path)) {
|
||||
openWindow(item.path);
|
||||
selectedKey.value = [item.name as string];
|
||||
return;
|
||||
}
|
||||
// 新页面打开
|
||||
if (e.ctrlKey) {
|
||||
const { href } = router.resolve({
|
||||
name: item.name,
|
||||
});
|
||||
openWindow(href);
|
||||
selectedKey.value = [item.name as string];
|
||||
return;
|
||||
}
|
||||
// 设置 selectedKey
|
||||
const { hideInMenu, activeMenu } = item.meta as RouteMeta;
|
||||
if (route.name === item.name && !hideInMenu && !activeMenu) {
|
||||
selectedKey.value = [item.name as string];
|
||||
return;
|
||||
}
|
||||
// 触发跳转
|
||||
router.push({
|
||||
name: item.name,
|
||||
});
|
||||
};
|
||||
|
||||
const findMenuOpenKeys = (target: string) => {
|
||||
const result: string[] = [];
|
||||
let isFind = false;
|
||||
const backtrack = (item: RouteRecordRaw, keys: string[]) => {
|
||||
if (item.name === target) {
|
||||
isFind = true;
|
||||
result.push(...keys);
|
||||
return;
|
||||
}
|
||||
if (item.children?.length) {
|
||||
item.children.forEach((el) => {
|
||||
backtrack(el, [...keys, el.name as string]);
|
||||
});
|
||||
}
|
||||
};
|
||||
menuTree.value.forEach((el: RouteRecordRaw) => {
|
||||
if (isFind) return;
|
||||
backtrack(el, [el.name as string]);
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* 监听路由 设置打开的 key
|
||||
*/
|
||||
listenerRouteChange((newRoute) => {
|
||||
const { activeMenu, hideInMenu } = newRoute.meta;
|
||||
if (!hideInMenu || activeMenu) {
|
||||
const menuOpenKeys = findMenuOpenKeys(
|
||||
(activeMenu || newRoute.name) as string
|
||||
);
|
||||
|
||||
const keySet = new Set([...menuOpenKeys, ...openKeys.value]);
|
||||
openKeys.value = [...keySet];
|
||||
|
||||
selectedKey.value = [
|
||||
activeMenu || menuOpenKeys[menuOpenKeys.length - 1],
|
||||
];
|
||||
}
|
||||
}, true);
|
||||
|
||||
// 展开菜单
|
||||
const setCollapse = (val: boolean) => {
|
||||
if (appStore.device === 'desktop')
|
||||
appStore.updateSettings({ menuCollapse: val });
|
||||
};
|
||||
|
||||
// 渲染菜单
|
||||
const renderSubMenu = () => {
|
||||
function travel(_route: RouteRecordRaw[], nodes = []) {
|
||||
if (_route) {
|
||||
_route.forEach((element) => {
|
||||
// This is demo, modify nodes as needed
|
||||
const icon = element?.meta?.icon
|
||||
? () => h(compile(`<${element?.meta?.icon}/>`))
|
||||
: null;
|
||||
const node =
|
||||
element?.children && element?.children.length !== 0 ? (
|
||||
<a-sub-menu
|
||||
key={element?.name}
|
||||
v-slots={{
|
||||
icon,
|
||||
// 去除国际化 title: () => h(compile(t(element?.meta?.locale || ''))),
|
||||
title: () => h(compile(element?.meta?.locale || '')),
|
||||
}}
|
||||
>
|
||||
{travel(element?.children)}
|
||||
</a-sub-menu>
|
||||
) : (
|
||||
<a-menu-item
|
||||
key={element?.name}
|
||||
v-slots={{ icon }}
|
||||
onClick={($event: any) => goto($event, element)}
|
||||
>
|
||||
{element?.meta?.locale || ''}
|
||||
</a-menu-item>
|
||||
);
|
||||
nodes.push(node as never);
|
||||
});
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
return travel(menuTree.value);
|
||||
};
|
||||
|
||||
return () => (
|
||||
<a-menu
|
||||
mode={topMenu.value ? 'horizontal' : 'vertical'}
|
||||
v-model:collapsed={collapsed.value}
|
||||
v-model:open-keys={openKeys.value}
|
||||
show-collapse-button={appStore.device !== 'mobile'}
|
||||
auto-open={false}
|
||||
selected-keys={selectedKey.value}
|
||||
auto-open-selected={true}
|
||||
level-indent={34}
|
||||
style="height: 100%; width:100%;"
|
||||
onCollapse={setCollapse}
|
||||
>
|
||||
{renderSubMenu()}
|
||||
</a-menu>
|
||||
);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
:deep(.arco-menu-inner) {
|
||||
.arco-menu-inline-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.arco-icon {
|
||||
&:not(.arco-icon-down) {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.arco-menu-icon {
|
||||
margin-right: 10px !important;
|
||||
}
|
||||
|
||||
.arco-menu-indent-list {
|
||||
width: 28px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.arco-menu-title {
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,66 @@
|
||||
import { computed } from 'vue';
|
||||
import { RouteRecordRaw, RouteRecordNormalized } from 'vue-router';
|
||||
import { useMenuStore } from '@/store';
|
||||
import { cloneDeep } from 'lodash';
|
||||
|
||||
export default function useMenuTree() {
|
||||
const menuStore = useMenuStore();
|
||||
const appRoute = computed(() => {
|
||||
return menuStore.appMenus;
|
||||
});
|
||||
const menuTree = computed(() => {
|
||||
const copyRouter = cloneDeep(appRoute.value) as RouteRecordNormalized[];
|
||||
copyRouter.sort((a: RouteRecordNormalized, b: RouteRecordNormalized) => {
|
||||
return (a.meta.order || 0) - (b.meta.order || 0);
|
||||
});
|
||||
|
||||
function travel(_routes: RouteRecordRaw[], layer: number) {
|
||||
if (!_routes) return null;
|
||||
|
||||
const collector: any = _routes.map((element) => {
|
||||
// 隐藏子目录
|
||||
if (element.meta?.hideChildrenInMenu || !element.children) {
|
||||
element.children = [];
|
||||
|
||||
if (element.meta?.hideInMenu) {
|
||||
// 如果隐藏菜单 则不显示
|
||||
return null;
|
||||
} else {
|
||||
return element;
|
||||
}
|
||||
}
|
||||
|
||||
// 过滤不显示的菜单
|
||||
element.children = element.children.filter(
|
||||
(x) => x.meta?.hideInMenu !== true
|
||||
);
|
||||
|
||||
// 关联子节点
|
||||
const subItem = travel(element.children, layer + 1);
|
||||
|
||||
if (subItem.length) {
|
||||
element.children = subItem;
|
||||
return element;
|
||||
}
|
||||
// 第二层 (子目录)
|
||||
if (layer > 1) {
|
||||
element.children = subItem;
|
||||
return element;
|
||||
}
|
||||
|
||||
// 是否隐藏目录
|
||||
if (element.meta?.hideInMenu === false) {
|
||||
return element;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
return collector.filter(Boolean);
|
||||
}
|
||||
|
||||
return travel(copyRouter, 0);
|
||||
});
|
||||
|
||||
return {
|
||||
menuTree,
|
||||
};
|
||||
}
|
||||
132
orion-ops-ui/src/components/system/message-box/index.vue
Normal file
132
orion-ops-ui/src/components/system/message-box/index.vue
Normal file
@@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<a-spin style="display: block" :loading="loading">
|
||||
<a-tabs v-model:activeKey="messageType" type="rounded" destroy-on-hide>
|
||||
<a-tab-pane v-for="item in tabList" :key="item.key">
|
||||
<template #title>
|
||||
<span> {{ item.title }}{{ formatUnreadLength(item.key) }} </span>
|
||||
</template>
|
||||
<a-result v-if="!renderList.length" status="404">
|
||||
<template #subtitle>暂无内容</template>
|
||||
</a-result>
|
||||
<List :render-list="renderList"
|
||||
:unread-count="unreadCount"
|
||||
@item-click="handleItemClick" />
|
||||
</a-tab-pane>
|
||||
<template #extra>
|
||||
<a-button type="text" @click="emptyList">
|
||||
清空
|
||||
</a-button>
|
||||
</template>
|
||||
</a-tabs>
|
||||
</a-spin>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, toRefs, computed } from 'vue';
|
||||
import {
|
||||
queryMessageList,
|
||||
setMessageStatus,
|
||||
MessageRecord,
|
||||
MessageListType,
|
||||
} from '@/api/system/message';
|
||||
import useLoading from '@/hooks/loading';
|
||||
import List from './list.vue';
|
||||
|
||||
interface TabItem {
|
||||
key: string;
|
||||
title: string;
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
const { loading, setLoading } = useLoading(true);
|
||||
const messageType = ref('message');
|
||||
const messageData = reactive<{
|
||||
renderList: MessageRecord[];
|
||||
messageList: MessageRecord[];
|
||||
}>({
|
||||
renderList: [],
|
||||
messageList: [],
|
||||
});
|
||||
toRefs(messageData);
|
||||
const tabList: TabItem[] = [
|
||||
{
|
||||
key: 'message',
|
||||
title: '消息',
|
||||
},
|
||||
{
|
||||
key: 'notice',
|
||||
title: '通知',
|
||||
},
|
||||
{
|
||||
key: 'todo',
|
||||
title: '待办',
|
||||
},
|
||||
];
|
||||
|
||||
async function fetchSourceData() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await queryMessageList();
|
||||
messageData.messageList = data;
|
||||
} catch (err) {
|
||||
// you can report use errorHandler or other
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function readMessage(data: MessageListType) {
|
||||
const ids = data.map((item) => item.id);
|
||||
await setMessageStatus({ ids });
|
||||
await fetchSourceData();
|
||||
}
|
||||
|
||||
const renderList = computed(() => {
|
||||
return messageData.messageList.filter(
|
||||
(item) => messageType.value === item.type
|
||||
);
|
||||
});
|
||||
const unreadCount = computed(() => {
|
||||
return renderList.value.filter((item) => !item.status).length;
|
||||
});
|
||||
const getUnreadList = (type: string) => {
|
||||
const list = messageData.messageList.filter(
|
||||
(item) => item.type === type && !item.status
|
||||
);
|
||||
return list;
|
||||
};
|
||||
const formatUnreadLength = (type: string) => {
|
||||
const list = getUnreadList(type);
|
||||
return list.length ? `(${list.length})` : '';
|
||||
};
|
||||
const handleItemClick = (items: MessageListType) => {
|
||||
if (renderList.value.length) readMessage([...items]);
|
||||
};
|
||||
const emptyList = () => {
|
||||
messageData.messageList = [];
|
||||
};
|
||||
fetchSourceData();
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
:deep(.arco-popover-popup-content) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:deep(.arco-list-item-meta) {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
:deep(.arco-tabs-nav) {
|
||||
padding: 14px 0 12px 16px;
|
||||
border-bottom: 1px solid var(--color-neutral-3);
|
||||
}
|
||||
|
||||
:deep(.arco-tabs-content) {
|
||||
padding-top: 0;
|
||||
|
||||
.arco-result-subtitle {
|
||||
color: rgb(var(--gray-6));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
162
orion-ops-ui/src/components/system/message-box/list.vue
Normal file
162
orion-ops-ui/src/components/system/message-box/list.vue
Normal file
@@ -0,0 +1,162 @@
|
||||
<template>
|
||||
<a-list :bordered="false">
|
||||
<a-list-item
|
||||
v-for="item in renderList"
|
||||
:key="item.id"
|
||||
action-layout="vertical"
|
||||
:style="{
|
||||
opacity: item.status ? 0.5 : 1,
|
||||
}"
|
||||
>
|
||||
<template #extra>
|
||||
<a-tag v-if="item.messageType === 0" color="gray">未开始</a-tag>
|
||||
<a-tag v-else-if="item.messageType === 1" color="green">已开通</a-tag>
|
||||
<a-tag v-else-if="item.messageType === 2" color="blue">进行中</a-tag>
|
||||
<a-tag v-else-if="item.messageType === 3" color="red">即将到期</a-tag>
|
||||
</template>
|
||||
<div class="item-wrap" @click="onItemClick(item)">
|
||||
<a-list-item-meta>
|
||||
<template v-if="item.avatar" #avatar>
|
||||
<a-avatar shape="circle">
|
||||
<img v-if="item.avatar" :src="item.avatar" />
|
||||
<icon-desktop v-else />
|
||||
</a-avatar>
|
||||
</template>
|
||||
<template #title>
|
||||
<a-space :size="4">
|
||||
<span>{{ item.title }}</span>
|
||||
<a-typography-text type="secondary">
|
||||
{{ item.subTitle }}
|
||||
</a-typography-text>
|
||||
</a-space>
|
||||
</template>
|
||||
<template #description>
|
||||
<div>
|
||||
<a-typography-paragraph
|
||||
:ellipsis="{
|
||||
rows: 1,
|
||||
}"
|
||||
>{{ item.content }}
|
||||
</a-typography-paragraph
|
||||
>
|
||||
<a-typography-text
|
||||
v-if="item.type === 'message'"
|
||||
class="time-text"
|
||||
>
|
||||
{{ item.time }}
|
||||
</a-typography-text>
|
||||
</div>
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
</div>
|
||||
</a-list-item>
|
||||
<template #footer>
|
||||
<a-space
|
||||
fill
|
||||
:size="0"
|
||||
:class="{ 'add-border-top': renderList.length < showMax }"
|
||||
>
|
||||
<div class="footer-wrap">
|
||||
<a-link @click="allRead">全部已读</a-link>
|
||||
</div>
|
||||
<div class="footer-wrap">
|
||||
<a-link>查看更多</a-link>
|
||||
</div>
|
||||
</a-space>
|
||||
</template>
|
||||
<div
|
||||
v-if="renderList.length && renderList.length < 3"
|
||||
:style="{ height: (showMax - renderList.length) * 86 + 'px' }"
|
||||
></div>
|
||||
</a-list>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { PropType } from 'vue';
|
||||
import { MessageRecord, MessageListType } from '@/api/system/message';
|
||||
|
||||
const props = defineProps({
|
||||
renderList: {
|
||||
type: Array as PropType<MessageListType>,
|
||||
required: true,
|
||||
},
|
||||
unreadCount: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
const emit = defineEmits(['itemClick']);
|
||||
const allRead = () => {
|
||||
emit('itemClick', [...props.renderList]);
|
||||
};
|
||||
|
||||
const onItemClick = (item: MessageRecord) => {
|
||||
if (!item.status) {
|
||||
emit('itemClick', [item]);
|
||||
}
|
||||
};
|
||||
const showMax = 3;
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
:deep(.arco-list) {
|
||||
.arco-list-item {
|
||||
min-height: 86px;
|
||||
border-bottom: 1px solid rgb(var(--gray-3));
|
||||
}
|
||||
|
||||
.arco-list-item-extra {
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
}
|
||||
|
||||
.arco-list-item-meta-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.item-wrap {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.time-text {
|
||||
font-size: 12px;
|
||||
color: rgb(var(--gray-6));
|
||||
}
|
||||
|
||||
.arco-empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.arco-list-footer {
|
||||
padding: 0;
|
||||
height: 50px;
|
||||
line-height: 50px;
|
||||
border-top: none;
|
||||
|
||||
.arco-space-item {
|
||||
width: 100%;
|
||||
border-right: 1px solid rgb(var(--gray-3));
|
||||
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
|
||||
.add-border-top {
|
||||
border-top: 1px solid rgb(var(--gray-3));
|
||||
}
|
||||
}
|
||||
|
||||
.footer-wrap {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.arco-typography {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.add-border {
|
||||
border-top: 1px solid rgb(var(--gray-3));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user