🔖 项目重命名.
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<a-select v-model:model-value="value as any"
|
||||
:options="optionData"
|
||||
:allow-search="true"
|
||||
:loading="loading"
|
||||
:disabled="loading"
|
||||
:filter-option="labelFilter"
|
||||
:allow-create="allowCreate"
|
||||
placeholder="请选择配置项" />
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
name: 'dictKeySelector'
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { SelectOptionData } from '@arco-design/web-vue';
|
||||
import { computed, onBeforeMount, ref } from 'vue';
|
||||
import { useCacheStore } from '@/store';
|
||||
import { labelFilter } from '@/types/form';
|
||||
import useLoading from '@/hooks/loading';
|
||||
|
||||
const props = withDefaults(defineProps<Partial<{
|
||||
modelValue: number;
|
||||
allowCreate: boolean;
|
||||
}>>(), {
|
||||
allowCreate: false,
|
||||
});
|
||||
|
||||
const emits = defineEmits(['update:modelValue', 'change']);
|
||||
|
||||
const { loading, setLoading } = useLoading();
|
||||
const cacheStore = useCacheStore();
|
||||
|
||||
const value = computed({
|
||||
get() {
|
||||
return props.modelValue;
|
||||
},
|
||||
set(e) {
|
||||
if (typeof e === 'string') {
|
||||
// 创建的值 这里必须为 null, 否则 table 的重置不生效
|
||||
emits('update:modelValue', null);
|
||||
emits('change', {
|
||||
id: null,
|
||||
keyName: e
|
||||
});
|
||||
} else {
|
||||
// 已有的值
|
||||
emits('update:modelValue', e);
|
||||
const find = optionData.value.find(s => s.value === e);
|
||||
if (find) {
|
||||
emits('change', find.origin);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
const optionData = ref<Array<SelectOptionData>>([]);
|
||||
|
||||
// 初始化选项
|
||||
onBeforeMount(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const dictKeys = await cacheStore.loadDictKeys();
|
||||
optionData.value = dictKeys.map(s => {
|
||||
return {
|
||||
label: `${s.keyName} - ${s.description || ''}`,
|
||||
value: s.id,
|
||||
origin: s
|
||||
};
|
||||
});
|
||||
} catch (e) {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
|
||||
</style>
|
||||
213
orion-visor-ui/src/components/system/menu/grant-table/index.vue
Normal file
213
orion-visor-ui/src/components/system/menu/grant-table/index.vue
Normal file
@@ -0,0 +1,213 @@
|
||||
<template>
|
||||
<table class="grant-table" border="1">
|
||||
<thead>
|
||||
<tr class="header-row">
|
||||
<th class="parent-view-header">父页面</th>
|
||||
<th class="child-view-header">子页面</th>
|
||||
<th>功能</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<a-checkbox-group v-model="checkedKeys" style="display: contents;">
|
||||
<template v-for="parentMenu in menuData" :key="parentMenu.id">
|
||||
<!-- 有子菜单 -->
|
||||
<template v-if="parentMenu.children?.length">
|
||||
<tr v-for="(childrenMenu, i) in parentMenu.children"
|
||||
:key="childrenMenu.id">
|
||||
<!-- 父菜单 -->
|
||||
<td v-if="i === 0" :rowspan="parentMenu.children.length">
|
||||
<a-checkbox :value="parentMenu.id">
|
||||
{{ parentMenu.name }}
|
||||
</a-checkbox>
|
||||
</td>
|
||||
<!-- 子菜单 -->
|
||||
<td>
|
||||
<a-checkbox :value="childrenMenu.id">
|
||||
{{ childrenMenu.name }}
|
||||
</a-checkbox>
|
||||
</td>
|
||||
<!-- 功能 -->
|
||||
<td>
|
||||
<a-row v-if="childrenMenu.children && childrenMenu.children.length">
|
||||
<a-col v-for="item in childrenMenu.children"
|
||||
:key="item.id"
|
||||
:span="8">
|
||||
<a-checkbox :value="item.id">
|
||||
{{ item.name }}
|
||||
</a-checkbox>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<!-- 无子菜单 -->
|
||||
<template v-else>
|
||||
<tr>
|
||||
<!-- 父菜单 -->
|
||||
<td>
|
||||
<a-checkbox :value="parentMenu.id">
|
||||
{{ parentMenu.name }}
|
||||
</a-checkbox>
|
||||
</td>
|
||||
<!-- 子菜单 -->
|
||||
<td>
|
||||
</td>
|
||||
<!-- 功能 -->
|
||||
<td>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</template>
|
||||
</a-checkbox-group>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
name: 'menuGrantTable'
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { MenuQueryResponse } from '@/api/system/menu';
|
||||
import { useCacheStore } from '@/store';
|
||||
import { ref, watch } from 'vue';
|
||||
import { findNode, flatNodeKeys, flatNodes } from '@/utils/tree';
|
||||
|
||||
const cacheStore = useCacheStore();
|
||||
|
||||
const unTriggerChange = ref(false);
|
||||
const menuData = ref<Array<MenuQueryResponse>>([]);
|
||||
const checkedKeys = ref<Array<number>>([]);
|
||||
|
||||
// 初始化
|
||||
const init = (keys: Array<number>) => {
|
||||
unTriggerChange.value = true;
|
||||
checkedKeys.value = keys;
|
||||
if (!menuData.value.length) {
|
||||
cacheStore.loadMenus().then(menus => {
|
||||
menuData.value = [...menus];
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 获取值
|
||||
const getValue = () => {
|
||||
return checkedKeys.value;
|
||||
};
|
||||
|
||||
// 通过规则 选择/取消选择
|
||||
const checkOrUncheckByFilter = (filter: undefined | ((perm: string) => boolean), check: boolean) => {
|
||||
unTriggerChange.value = true;
|
||||
const nodes: Array<MenuQueryResponse> = [];
|
||||
flatNodes(menuData.value, nodes);
|
||||
if (filter) {
|
||||
const ruleNodes = nodes.filter(s => s.permission)
|
||||
.filter(s => filter(s?.permission))
|
||||
.map(s => s.id)
|
||||
.filter(Boolean);
|
||||
if (check) {
|
||||
// 选择
|
||||
checkedKeys.value = [...new Set([...checkedKeys.value, ...ruleNodes])];
|
||||
} else {
|
||||
// 取消选择
|
||||
checkedKeys.value = [...checkedKeys.value].filter(s => !ruleNodes.includes(s));
|
||||
}
|
||||
} else {
|
||||
if (check) {
|
||||
// 选择
|
||||
checkedKeys.value = nodes.map(s => s.id).filter(Boolean);
|
||||
} else {
|
||||
// 取消选择
|
||||
checkedKeys.value = [];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({ init, getValue, checkOrUncheckByFilter });
|
||||
|
||||
// 监听级联变化
|
||||
watch(checkedKeys, (after: Array<number>, before: Array<number>) => {
|
||||
if (unTriggerChange.value) {
|
||||
unTriggerChange.value = false;
|
||||
return;
|
||||
}
|
||||
const afterLength = after.length;
|
||||
const beforeLength = before.length;
|
||||
if (afterLength > beforeLength) {
|
||||
// 选择 一定是最后一个
|
||||
checkOrUncheckMenu(after[afterLength - 1], true);
|
||||
} else if (afterLength < beforeLength) {
|
||||
// 取消
|
||||
let uncheckedId = null;
|
||||
for (let i = 0; i < afterLength; i++) {
|
||||
if (after[i] !== before[i]) {
|
||||
uncheckedId = before[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (uncheckedId == null) {
|
||||
uncheckedId = before[beforeLength - 1];
|
||||
}
|
||||
checkOrUncheckMenu(uncheckedId, false);
|
||||
}
|
||||
});
|
||||
|
||||
// 级联选择/取消选择菜单
|
||||
const checkOrUncheckMenu = (id: number, check: boolean) => {
|
||||
unTriggerChange.value = true;
|
||||
// 查询当前节点
|
||||
const node = findNode(id, menuData.value, 'id');
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
const childrenId: number[] = [];
|
||||
// 获取所在子节点id
|
||||
flatNodeKeys(node.children, childrenId, 'id');
|
||||
if (check) {
|
||||
// 选中
|
||||
checkedKeys.value = [...new Set([...checkedKeys.value, ...childrenId])];
|
||||
} else {
|
||||
// 取消选择
|
||||
checkedKeys.value = checkedKeys.value.filter(s => !childrenId.includes(s));
|
||||
}
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
|
||||
.grant-table {
|
||||
width: 100%;
|
||||
border: 1px solid var(--color-fill-3);
|
||||
text-indent: initial;
|
||||
border-spacing: 2px;
|
||||
border-collapse: collapse;
|
||||
user-select: none;
|
||||
|
||||
tbody {
|
||||
td {
|
||||
padding: 6px 16px;
|
||||
border: 1px solid var(--color-fill-3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header-row {
|
||||
|
||||
th {
|
||||
font-size: 17px;
|
||||
padding: 4px;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
background-color: var(--color-fill-1);
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.parent-view-header, .child-view-header {
|
||||
width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<a-tree-select v-model:model-value="value"
|
||||
:data="treeData"
|
||||
:disabled="disabled"
|
||||
:loading="loading"
|
||||
:allow-search="true"
|
||||
:filter-tree-node="titleFilter"
|
||||
placeholder="请选择菜单" />
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
name: 'menuTreeSelector'
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { TreeNodeData } from '@arco-design/web-vue';
|
||||
import { useCacheStore } from '@/store';
|
||||
import { computed, onBeforeMount, ref } from 'vue';
|
||||
import { MenuType } from '@/views/system/menu/types/const';
|
||||
import { titleFilter } from '@/types/form';
|
||||
import useLoading from '@/hooks/loading';
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: number;
|
||||
disabled: boolean;
|
||||
}>();
|
||||
|
||||
const emits = defineEmits(['update:modelValue']);
|
||||
|
||||
const { loading, setLoading } = useLoading();
|
||||
const cacheStore = useCacheStore();
|
||||
|
||||
const treeData = ref<Array<TreeNodeData>>([]);
|
||||
|
||||
const value = computed({
|
||||
get() {
|
||||
return props.modelValue;
|
||||
},
|
||||
set(e) {
|
||||
emits('update:modelValue', e);
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeMount(async () => {
|
||||
let render = (arr: any[]): TreeNodeData[] => {
|
||||
return arr.map((s) => {
|
||||
// 为 function 返回空
|
||||
if (s.type === MenuType.FUNCTION) {
|
||||
return null as unknown as TreeNodeData;
|
||||
}
|
||||
// 当前节点
|
||||
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);
|
||||
};
|
||||
|
||||
// 加载数据
|
||||
try {
|
||||
setLoading(true);
|
||||
const menus = await cacheStore.loadMenus();
|
||||
treeData.value = [{
|
||||
key: 0,
|
||||
title: '根目录',
|
||||
children: render([...menus])
|
||||
}];
|
||||
} catch (e) {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
|
||||
</style>;
|
||||
186
orion-visor-ui/src/components/system/menu/tree/index.vue
Normal file
186
orion-visor-ui/src/components/system/menu/tree/index.vue
Normal file
@@ -0,0 +1,186 @@
|
||||
<template>
|
||||
<a-menu class="full"
|
||||
:mode="topMenu ? 'horizontal' : 'vertical'"
|
||||
v-model:collapsed="collapsed"
|
||||
v-model:open-keys="openKeys"
|
||||
v-model:selected-keys="selectedKey"
|
||||
:show-collapse-button="appStore.device !== 'mobile'"
|
||||
:auto-open="false"
|
||||
:auto-open-selected="true"
|
||||
:level-indent="34"
|
||||
@collapse="setCollapse">
|
||||
<template v-for="menu in menuTree">
|
||||
<!-- 一级菜单 -->
|
||||
<a-menu-item v-if="!menu.children?.length"
|
||||
:key="menu.name"
|
||||
@click="(e) => goto(e, menu)">
|
||||
<!-- 图标 -->
|
||||
<template #icon>
|
||||
<component v-if="menu.meta?.icon" :is="menu.meta?.icon" />
|
||||
</template>
|
||||
<!-- 名称 -->
|
||||
{{ menu.meta?.locale || '' }}
|
||||
</a-menu-item>
|
||||
<!-- 父菜单 -->
|
||||
<a-sub-menu v-else :key="menu.name">
|
||||
<!-- 图标 -->
|
||||
<template #icon>
|
||||
<component v-if="menu.meta?.icon" :is="menu.meta?.icon" />
|
||||
</template>
|
||||
<!-- 名称 -->
|
||||
<template #title>
|
||||
{{ menu.meta?.locale || '' }}
|
||||
</template>
|
||||
<!-- 子菜单 -->
|
||||
<a-menu-item v-for="child in menu.children"
|
||||
:key="child.name"
|
||||
@click="(e) => goto(e, child)">
|
||||
<!-- 图标 -->
|
||||
<template #icon v-if="child.meta?.icon">
|
||||
<component :is="child.meta?.icon" />
|
||||
</template>
|
||||
<!-- 名称 -->
|
||||
{{ child.meta?.locale || '' }}
|
||||
</a-menu-item>
|
||||
</a-sub-menu>
|
||||
</template>
|
||||
</a-menu>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
name: 'systemMenuTree'
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { RouteMeta, RouteRecordRaw } from 'vue-router';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useAppStore } from '@/store';
|
||||
import { listenerRouteChange } from '@/utils/route-listener';
|
||||
import { openWindow, regexUrl } from '@/utils';
|
||||
import { openNewRoute } from '@/router';
|
||||
import useMenuTree from './use-menu-tree';
|
||||
|
||||
const emits = defineEmits(['collapse']);
|
||||
|
||||
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);
|
||||
return;
|
||||
}
|
||||
const { hideInMenu, activeMenu, newWindow } = item.meta as RouteMeta;
|
||||
// 新页面打开
|
||||
if (newWindow || e.ctrlKey) {
|
||||
openNewRoute({
|
||||
name: item.name,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// 设置 selectedKey
|
||||
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 });
|
||||
};
|
||||
|
||||
</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,67 @@
|
||||
import type { RouteRecordNormalized, RouteRecordRaw } from 'vue-router';
|
||||
import { computed } from 'vue';
|
||||
import { useMenuStore } from '@/store';
|
||||
import { cloneDeep } from 'lodash';
|
||||
|
||||
export default function useMenuTree() {
|
||||
const menuStore = useMenuStore();
|
||||
const appRoute = computed(() => {
|
||||
return menuStore.appMenus;
|
||||
});
|
||||
const menuTree = computed<RouteRecordNormalized[]>(() => {
|
||||
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?.hideInMenu || !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,
|
||||
};
|
||||
}
|
||||
22
orion-visor-ui/src/components/system/message-box/const.ts
Normal file
22
orion-visor-ui/src/components/system/message-box/const.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
// 消息状态
|
||||
export const MessageStatus = {
|
||||
UNREAD: 0,
|
||||
READ: 1,
|
||||
};
|
||||
|
||||
export const MESSAGE_CONFIG_KEY = 'messageConfig';
|
||||
|
||||
// 查询数量
|
||||
export const messageLimit = 15;
|
||||
|
||||
// 默认消息分类 通知
|
||||
export const defaultClassify = 'NOTICE';
|
||||
|
||||
// 消息分类 字典项
|
||||
export const messageClassifyKey = 'messageClassify';
|
||||
|
||||
// 消息类型 字典项
|
||||
export const messageTypeKey = 'messageType';
|
||||
|
||||
// 加载的字典值
|
||||
export const dictKeys = [messageClassifyKey, messageTypeKey];
|
||||
266
orion-visor-ui/src/components/system/message-box/index.vue
Normal file
266
orion-visor-ui/src/components/system/message-box/index.vue
Normal file
@@ -0,0 +1,266 @@
|
||||
<template>
|
||||
<div class="full">
|
||||
<!-- 消息分类 -->
|
||||
<a-spin class="message-classify-container"
|
||||
:hide-icon="true"
|
||||
:loading="fetchLoading">
|
||||
<a-tabs v-model:activeKey="currentClassify"
|
||||
type="rounded"
|
||||
:hide-content="true"
|
||||
@change="loadClassifyMessage">
|
||||
<!-- 消息列表 -->
|
||||
<a-tab-pane v-for="item in toOptions(messageClassifyKey)"
|
||||
:key="item.value as string">
|
||||
<!-- 标题 -->
|
||||
<template #title>
|
||||
<span class="usn">{{ item.label }} ({{ classifyCount[item.value as any] || 0 }})</span>
|
||||
</template>
|
||||
<!-- 消息列表 -->
|
||||
</a-tab-pane>
|
||||
<!-- 右侧操作 -->
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<!-- 状态 -->
|
||||
<a-switch v-model="queryUnread"
|
||||
type="round"
|
||||
checked-text="未读"
|
||||
unchecked-text="全部"
|
||||
@change="reloadAllMessage" />
|
||||
<!-- 清空 -->
|
||||
<a-button class="header-button"
|
||||
type="text"
|
||||
size="small"
|
||||
@click="clearAllMessage">
|
||||
清空
|
||||
</a-button>
|
||||
<!-- 全部已读 -->
|
||||
<a-button class="header-button"
|
||||
type="text"
|
||||
size="small"
|
||||
@click="setAllRead">
|
||||
全部已读
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-tabs>
|
||||
</a-spin>
|
||||
<!-- 消息列表 -->
|
||||
<list :fetch-loading="fetchLoading"
|
||||
:message-loading="messageLoading"
|
||||
:has-more="hasMore"
|
||||
:message-list="messageList"
|
||||
@load="loadMessage"
|
||||
@click="clickMessage"
|
||||
@delete="deleteMessage" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
name: 'messageBox'
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { MessageRecordResponse } from '@/api/system/message';
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import {
|
||||
clearSystemMessage,
|
||||
deleteSystemMessage,
|
||||
getSystemMessageCount,
|
||||
getSystemMessageList,
|
||||
updateSystemMessageRead,
|
||||
updateSystemMessageReadAll
|
||||
} from '@/api/system/message';
|
||||
import useLoading from '@/hooks/loading';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { clearHtmlTag, replaceHtmlTag } from '@/utils';
|
||||
import { useDictStore } from '@/store';
|
||||
import { dictKeys, messageClassifyKey, messageTypeKey, defaultClassify, MESSAGE_CONFIG_KEY, messageLimit, MessageStatus } from './const';
|
||||
import List from './list.vue';
|
||||
|
||||
const { loading: fetchLoading, setLoading: setFetchLoading } = useLoading();
|
||||
const { loading: messageLoading, setLoading: setMessageLoading } = useLoading();
|
||||
const { loadKeys, toOptions, getDictValue } = useDictStore();
|
||||
const router = useRouter();
|
||||
|
||||
const currentClassify = ref(defaultClassify);
|
||||
const queryUnread = ref(false);
|
||||
const classifyCount = ref<Record<string, number>>({});
|
||||
const messageList = ref<Array<MessageRecordResponse>>([]);
|
||||
const hasMore = ref(true);
|
||||
|
||||
// 重新加载消息
|
||||
const reloadAllMessage = async () => {
|
||||
hasMore.value = true;
|
||||
messageList.value = [];
|
||||
// 查询数量
|
||||
queryMessageCount();
|
||||
// 加载列表
|
||||
await loadMessage();
|
||||
};
|
||||
|
||||
// 获取数量
|
||||
const queryMessageCount = async () => {
|
||||
setFetchLoading(true);
|
||||
try {
|
||||
const { data } = await getSystemMessageCount(queryUnread.value);
|
||||
classifyCount.value = data;
|
||||
} catch (ex) {
|
||||
} finally {
|
||||
setFetchLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 查询分类消息
|
||||
const loadClassifyMessage = async () => {
|
||||
hasMore.value = true;
|
||||
messageList.value = [];
|
||||
await loadMessage();
|
||||
};
|
||||
|
||||
// 加载消息
|
||||
const loadMessage = async () => {
|
||||
hasMore.value = true;
|
||||
setFetchLoading(true);
|
||||
try {
|
||||
const maxId = messageList.value.length
|
||||
? messageList.value[messageList.value.length - 1].id
|
||||
: undefined;
|
||||
// 查询数据
|
||||
const { data } = await getSystemMessageList({
|
||||
limit: messageLimit,
|
||||
classify: currentClassify.value,
|
||||
queryUnread: queryUnread.value,
|
||||
maxId,
|
||||
});
|
||||
data.forEach(s => {
|
||||
messageList.value.push({
|
||||
...s,
|
||||
content: clearHtmlTag(s.content),
|
||||
contentHtml: replaceHtmlTag(s.content),
|
||||
});
|
||||
});
|
||||
hasMore.value = data.length === messageLimit;
|
||||
} catch (ex) {
|
||||
} finally {
|
||||
setFetchLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 设置全部已读
|
||||
const setAllRead = async () => {
|
||||
setMessageLoading(true);
|
||||
try {
|
||||
// 设置为已读
|
||||
await updateSystemMessageReadAll(currentClassify.value);
|
||||
// 修改状态
|
||||
messageList.value.forEach(s => s.status = MessageStatus.READ);
|
||||
} catch (ex) {
|
||||
} finally {
|
||||
setMessageLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 清理已读消息
|
||||
const clearAllMessage = async () => {
|
||||
setMessageLoading(true);
|
||||
try {
|
||||
// 清理消息
|
||||
await clearSystemMessage(currentClassify.value);
|
||||
} catch (ex) {
|
||||
} finally {
|
||||
setMessageLoading(false);
|
||||
}
|
||||
// 查询消息
|
||||
await reloadAllMessage();
|
||||
};
|
||||
|
||||
// 点击消息
|
||||
const clickMessage = (message: MessageRecordResponse) => {
|
||||
// 设置为已读
|
||||
if (message.status === MessageStatus.UNREAD) {
|
||||
updateSystemMessageRead(message.id);
|
||||
message.status = MessageStatus.READ;
|
||||
}
|
||||
const redirectComponent = getDictValue(messageTypeKey, message.type, 'redirectComponent');
|
||||
if (redirectComponent && redirectComponent !== '0') {
|
||||
// 跳转组件
|
||||
router.push({ name: redirectComponent, query: { key: message.relKey } });
|
||||
}
|
||||
};
|
||||
|
||||
// 删除消息
|
||||
const deleteMessage = async (message: MessageRecordResponse) => {
|
||||
setMessageLoading(true);
|
||||
try {
|
||||
// 删除消息
|
||||
await deleteSystemMessage(message.id);
|
||||
// 减少数量
|
||||
classifyCount.value[currentClassify.value] -= 1;
|
||||
// 移除
|
||||
const index = messageList.value.findIndex(s => s.id === message.id);
|
||||
messageList.value.splice(index, 1);
|
||||
} catch (ex) {
|
||||
} finally {
|
||||
setMessageLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 加载字典值
|
||||
onMounted(() => {
|
||||
loadKeys(dictKeys);
|
||||
});
|
||||
|
||||
// 获取消息
|
||||
onMounted(() => {
|
||||
// 获取配置缓存
|
||||
const item = localStorage.getItem(MESSAGE_CONFIG_KEY);
|
||||
if (item) {
|
||||
const config = JSON.parse(item) as Record<string, any>;
|
||||
if (config?.currentClassify) {
|
||||
currentClassify.value = config.currentClassify;
|
||||
}
|
||||
if (config?.queryUnread) {
|
||||
queryUnread.value = config.queryUnread;
|
||||
}
|
||||
}
|
||||
// 查询数据
|
||||
reloadAllMessage();
|
||||
});
|
||||
|
||||
// 设置缓存配置
|
||||
onUnmounted(() => {
|
||||
localStorage.setItem(MESSAGE_CONFIG_KEY, JSON.stringify({
|
||||
currentClassify: currentClassify.value,
|
||||
queryUnread: queryUnread.value,
|
||||
}));
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
:deep(.arco-popover-popup-content) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:deep(.arco-tabs-nav) {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--color-neutral-3);
|
||||
}
|
||||
|
||||
:deep(.arco-tabs-content) {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.message-classify-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
|
||||
.header-button {
|
||||
padding: 0 6px;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
218
orion-visor-ui/src/components/system/message-box/list.vue
Normal file
218
orion-visor-ui/src/components/system/message-box/list.vue
Normal file
@@ -0,0 +1,218 @@
|
||||
<template>
|
||||
<!-- 消息列表 -->
|
||||
<a-spin class="message-list-container" :loading="messageLoading">
|
||||
<!-- 加载中 -->
|
||||
<div v-if="!messageList.length && fetchLoading">
|
||||
<!-- 加载中 -->
|
||||
<a-skeleton class="skeleton-wrapper" :animation="true">
|
||||
<a-skeleton-line :rows="3"
|
||||
:line-height="96"
|
||||
:line-spacing="8" />
|
||||
</a-skeleton>
|
||||
</div>
|
||||
<!-- 无数据 -->
|
||||
<div v-else-if="!messageList.length && !fetchLoading">
|
||||
<a-result status="404">
|
||||
<template #subtitle>暂无内容</template>
|
||||
</a-result>
|
||||
</div>
|
||||
<!-- 消息容器 -->
|
||||
<div v-else class="message-list-wrapper">
|
||||
<a-scrollbar style="overflow-y: auto; height: 100%;">
|
||||
<!-- 消息列表-->
|
||||
<div v-for="message in messageList"
|
||||
class="message-item"
|
||||
:class="[ message.status === MessageStatus.READ ? 'message-item-read' : 'message-item-unread' ]"
|
||||
@click="emits('click', message)">
|
||||
<!-- 标题 -->
|
||||
<div class="message-item-title">
|
||||
<!-- 标题 -->
|
||||
<div class="message-item-title-text text-ellipsis" :title="message.title">
|
||||
{{ message.title }}
|
||||
</div>
|
||||
<!-- tag -->
|
||||
<div class="message-item-title-status">
|
||||
<template v-if="getDictValue(messageTypeKey, message.type, 'tagVisible', false)">
|
||||
<a-tag size="small" :color="getDictValue(messageTypeKey, message.type, 'tagColor')">
|
||||
{{ getDictValue(messageTypeKey, message.type, 'tagLabel') }}
|
||||
</a-tag>
|
||||
</template>
|
||||
</div>
|
||||
<!-- 操作 -->
|
||||
<div class="message-item-title-actions">
|
||||
<!-- 删除 -->
|
||||
<a-button size="mini"
|
||||
type="text"
|
||||
status="danger"
|
||||
@click.stop="emits('delete', message)">
|
||||
删除
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 内容 -->
|
||||
<div v-html="message.contentHtml" class="message-item-content" />
|
||||
<!-- 时间 -->
|
||||
<div class="message-item-time">
|
||||
{{ dateFormat(new Date(message.createTime)) }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- 加载中 -->
|
||||
<a-skeleton v-if="fetchLoading"
|
||||
class="skeleton-wrapper"
|
||||
:animation="true">
|
||||
<a-skeleton-line :rows="3"
|
||||
:line-height="96"
|
||||
:line-spacing="8" />
|
||||
</a-skeleton>
|
||||
<!-- 加载更多 -->
|
||||
<div v-if="hasMore" class="load-more-wrapper">
|
||||
<a-button size="small"
|
||||
:fetchLoading="fetchLoading"
|
||||
@click="() => emits('load')">
|
||||
加载更多
|
||||
</a-button>
|
||||
</div>
|
||||
</a-scrollbar>
|
||||
</div>
|
||||
</a-spin>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
name: 'messageBoxList'
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { MessageRecordResponse } from '@/api/system/message';
|
||||
import { MessageStatus, messageTypeKey } from './const';
|
||||
import { useDictStore } from '@/store';
|
||||
import { dateFormat } from '@/utils';
|
||||
|
||||
const emits = defineEmits(['load', 'click', 'delete']);
|
||||
const props = defineProps<{
|
||||
fetchLoading: boolean;
|
||||
messageLoading: boolean;
|
||||
hasMore: boolean;
|
||||
messageList: Array<MessageRecordResponse>;
|
||||
}>();
|
||||
|
||||
const { getDictValue } = useDictStore();
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
@gap: 8px;
|
||||
@actions-width: 82px;
|
||||
|
||||
.skeleton-wrapper {
|
||||
padding: 8px 12px 0 12px;
|
||||
}
|
||||
|
||||
.message-list-container {
|
||||
width: 100%;
|
||||
height: 338px;
|
||||
display: block;
|
||||
|
||||
.message-list-wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.load-more-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 12px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.message-item {
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid var(--color-neutral-3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all .2s;
|
||||
|
||||
&-title {
|
||||
height: 22px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
|
||||
&-text {
|
||||
width: calc(100% - @actions-width - @gap);
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
text-overflow: clip;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
&-status {
|
||||
width: @actions-width;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
&-actions {
|
||||
width: @actions-width;
|
||||
display: none;
|
||||
justify-content: flex-end;
|
||||
align-items: flex-end;
|
||||
|
||||
button {
|
||||
padding: 0 6px !important;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-fill-3) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-content {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
&-time {
|
||||
height: 18px;
|
||||
margin-top: 4px;
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
}
|
||||
|
||||
.message-item:hover {
|
||||
background: var(--color-fill-1);
|
||||
|
||||
.message-item-title-status {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.message-item-title-actions {
|
||||
display: flex;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.message-item-read {
|
||||
.message-item-title-text, .message-item-title-status, .message-item-content, .message-item-time {
|
||||
opacity: .65;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.arco-scrollbar) {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
</style>
|
||||
44
orion-visor-ui/src/components/system/uploader/const.ts
Normal file
44
orion-visor-ui/src/components/system/uploader/const.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
// 上传操作类型
|
||||
export const UploadOperatorType = {
|
||||
// 开始上传
|
||||
START: 'start',
|
||||
// 上传完成
|
||||
FINISH: 'finish',
|
||||
// 上传失败
|
||||
ERROR: 'error',
|
||||
};
|
||||
|
||||
// 上传响应类型
|
||||
export const UploadReceiverType = {
|
||||
// 请求下一块数据
|
||||
NEXT: 'next',
|
||||
// 上传完成
|
||||
FINISH: 'finish',
|
||||
// 上传失败
|
||||
ERROR: 'error',
|
||||
};
|
||||
|
||||
// 请求消息体
|
||||
export interface RequestMessageBody {
|
||||
type: string;
|
||||
fileId: string;
|
||||
}
|
||||
|
||||
// 响应消息体
|
||||
export interface ResponseMessageBody {
|
||||
type: string;
|
||||
fileId: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
// 文件上传器 定义
|
||||
export interface IFileUploader {
|
||||
// 开始
|
||||
start(): Promise<void>;
|
||||
|
||||
// 设置 hook
|
||||
setHook(hook: Function): void;
|
||||
|
||||
// 关闭
|
||||
close(): void;
|
||||
}
|
||||
146
orion-visor-ui/src/components/system/uploader/file-uploader.ts
Normal file
146
orion-visor-ui/src/components/system/uploader/file-uploader.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import type { IFileUploader, ResponseMessageBody } from './const';
|
||||
import type { FileItem } from '@arco-design/web-vue';
|
||||
import { openFileUploadChannel } from '@/api/system/upload';
|
||||
import { UploadOperatorType, UploadReceiverType } from './const';
|
||||
|
||||
// 512 KB
|
||||
export const PART_SIZE = 512 * 1024;
|
||||
|
||||
// 文件上传器 实现
|
||||
export default class FileUploader implements IFileUploader {
|
||||
|
||||
private readonly token: string;
|
||||
|
||||
private readonly fileList: Array<FileItem>;
|
||||
|
||||
private currentIndex: number;
|
||||
|
||||
private currentFileItem: FileItem;
|
||||
|
||||
private currentFile: File;
|
||||
|
||||
private currentFileSize: number;
|
||||
|
||||
private currentPart: number;
|
||||
|
||||
private totalPart: number;
|
||||
|
||||
private client?: WebSocket;
|
||||
|
||||
private hook?: Function;
|
||||
|
||||
constructor(token: string, fileList: Array<FileItem>) {
|
||||
this.token = token;
|
||||
this.fileList = fileList;
|
||||
this.currentIndex = 0;
|
||||
this.currentFileItem = undefined as unknown as FileItem;
|
||||
this.currentFile = undefined as unknown as File;
|
||||
this.currentFileSize = 0;
|
||||
this.currentPart = 0;
|
||||
this.totalPart = 0;
|
||||
}
|
||||
|
||||
// 开始
|
||||
async start(): Promise<void> {
|
||||
try {
|
||||
// 打开管道
|
||||
this.client = await openFileUploadChannel(this.token);
|
||||
this.client.onclose = () => {
|
||||
this.hook && this.hook();
|
||||
};
|
||||
} catch (e) {
|
||||
// 修改状态
|
||||
this.fileList.forEach(s => s.status = 'error');
|
||||
throw e;
|
||||
}
|
||||
// 处理消息
|
||||
this.client.onmessage = this.resolveMessage.bind(this);
|
||||
// 打开后自动上传下一个文件
|
||||
this.uploadNextFile();
|
||||
}
|
||||
|
||||
// 上传下一个文件
|
||||
private uploadNextFile() {
|
||||
// 获取文件
|
||||
if (this.fileList.length > this.currentIndex) {
|
||||
this.currentFileItem = this.fileList[this.currentIndex++];
|
||||
this.currentFile = this.currentFileItem.file as File;
|
||||
this.currentFileSize = 0;
|
||||
this.currentPart = 0;
|
||||
this.totalPart = Math.ceil(this.currentFile.size / PART_SIZE);
|
||||
// 开始上传 发送开始上传信息
|
||||
this.client?.send(JSON.stringify({
|
||||
type: UploadOperatorType.START,
|
||||
fileId: this.currentFileItem.uid,
|
||||
}));
|
||||
} else {
|
||||
// 无文件关闭会话
|
||||
this.client?.close();
|
||||
}
|
||||
}
|
||||
|
||||
// 上传下一块数据
|
||||
private async uploadNextPart() {
|
||||
try {
|
||||
if (this.currentPart < this.totalPart) {
|
||||
// 有下一个分片则上传
|
||||
const start = this.currentPart++ * PART_SIZE;
|
||||
const end = Math.min(this.currentFile.size, start + PART_SIZE);
|
||||
const chunk = this.currentFile.slice(start, end);
|
||||
const reader = new FileReader();
|
||||
// 读取数据
|
||||
const arrayBuffer = await new Promise((resolve, reject) => {
|
||||
reader.onload = () => resolve(reader.result);
|
||||
reader.onerror = (error) => reject(error);
|
||||
reader.readAsArrayBuffer(chunk);
|
||||
});
|
||||
// 发送数据
|
||||
this.client?.send(arrayBuffer as ArrayBuffer);
|
||||
// 计算进度
|
||||
this.currentFileSize += (end - start);
|
||||
this.currentFileItem.percent = (this.currentFileSize / this.currentFile.size);
|
||||
} else {
|
||||
// 没有下一个分片则发送完成
|
||||
this.client?.send(JSON.stringify({
|
||||
type: UploadOperatorType.FINISH,
|
||||
fileId: this.currentFileItem.uid,
|
||||
}));
|
||||
}
|
||||
} catch (e) {
|
||||
// 读取文件失败
|
||||
this.client?.send(JSON.stringify({
|
||||
type: UploadOperatorType.ERROR,
|
||||
fileId: this.currentFileItem.uid,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// 接收消息
|
||||
private async resolveMessage(message: MessageEvent) {
|
||||
// 文本消息
|
||||
const data = JSON.parse(message.data) as ResponseMessageBody;
|
||||
if (data.type === UploadReceiverType.NEXT) {
|
||||
// 上传下一块数据
|
||||
await this.uploadNextPart();
|
||||
} else if (data.type === UploadReceiverType.FINISH) {
|
||||
this.currentFileItem.status = 'done';
|
||||
// 上传下一个文件
|
||||
this.uploadNextFile();
|
||||
} else if (data.type === UploadReceiverType.ERROR) {
|
||||
this.currentFileItem.status = 'error';
|
||||
// 上传下一个文件
|
||||
this.uploadNextFile();
|
||||
}
|
||||
}
|
||||
|
||||
// 设置 hook
|
||||
setHook(hook: Function): void {
|
||||
this.hook = hook;
|
||||
}
|
||||
|
||||
// 关闭
|
||||
close(): void {
|
||||
this.client?.close();
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user