Files
Docker/WebVue/src/views/dashboard.vue
T
2026-09-19 10:24:21 +08:00

272 lines
10 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<a-spin :spinning="loading" :delay="200">
<div class="page-container dash-page">
<!-- 顶部欢迎区:通栏,滚动时随内容一起上移 -->
<DashboardHero />
<!-- 功能分组:卡片外壳 / 标题字号沿用 ft-page 体系 -->
<div class="page-main ft-page">
<DashboardGroupCard v-for="grp in groups" :id="'grp-' + grp.key" :key="grp.key" :name="grp.name"
:color="grp.color" :soft="grp.soft" :icon="grp.icon" :layout="grp.layout" :cover="grp.cover"
:badge="grp.badge" :items="grp.items" @navigate="onpush" @help="onHelp" />
<a-empty v-if="!groups.length && !loading" description="没有匹配的功能" />
</div>
<!-- 新手帮助:统一挂在页面上,由行尾图标触发,按 FunctionName 分发不同文档 -->
<HelpTimelineModal v-model:open="helpOpen" :function-name="helpTarget?.FunctionName"
:title="helpTarget?.FunctionLocalName || helpTarget?.FunctionName" :color="helpColor" :soft="helpSoft"
:icon="helpIcon" />
<!-- 底部快捷栏:与其它页面一致,固定在底部 -->
<div class="page-footer dash-footer">
<DashboardBottomNav :items="navItems" @jump="onNavClick" />
</div>
</div>
</a-spin>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted } from "vue";
import { useRouter } from "vue-router";
import securityUserService from "@/service/security/securityUserService";
import type { SecurityFunctionDisplayData } from "@/data/auto/securityFunctionDisplayData";
import DashboardHero from "@/components/dashboard/DashboardHero.vue";
import DashboardGroupCard, { type GroupCardItem } from "@/components/dashboard/DashboardGroupCard.vue";
import DashboardBottomNav from "@/components/dashboard/DashboardBottomNav.vue";
import HelpTimelineModal from "@/components/basic/HelpTimelineModal.vue";
interface NavItem {
key: string;
label: string;
icon: string;
}
const router = useRouter();
const loading = ref(false);
const allItems = ref<SecurityFunctionDisplayData[]>([]);
/* ==================== 分组定义 ==================== */
type GroupKey = "disk" | "share" | "photo" | "audio" | "video" | "family" | "other";
const groupDefs: {
key: GroupKey;
name: string;
color: string;
soft: string;
icon: string;
layout: 2 | 3;
/** 封面图片地址(可选),缺省时由卡片用主题色渐变占位 */
cover?: string;
/** 封面左上角标签(可选),缺省时显示分组名称 */
badge?: string;
}[] = [
{ key: "disk", name: "网盘", color: "#1677ff", soft: "#e6f4ff", icon: "dbank", layout: 2},
{ key: "share", name: "共享中心", color: "#fa8c16", soft: "#fff7e6", icon: "dgroup", layout: 2},
// { key: "photo", name: "相册", color: "#722ed1", soft: "#f9f0ff", icon: "picture", layout: 3, cover: "/images/main/p4.png" },
{ key: "photo", name: "相册", color: "#722ed1", soft: "#f9f0ff", icon: "picture", layout: 3 },
{ key: "audio", name: "音乐", color: "#eb2f96", soft: "#fff0f6", icon: "foldermp3", layout: 3},
{ key: "video", name: "视频", color: "#13c2c2", soft: "#e6fffb", icon: "foldervideo", layout: 3 },
{ key: "family", name: "族谱管理", color: "#52c41a", soft: "#f6ffed", icon: "family", layout: 2},
{ key: "other", name: "更多", color: "#8a919f", soft: "#f5f6f8", icon: "gailan", layout: 3 },
];
const GROUP_OF: Record<string, GroupKey> = {
DmsFileEnquiry: "disk",
DmsFileDeleteEnquiry: "disk",
FamilyGroupShare: "share",
FamilyShareOperation: "share",
FamilyGroupOperation: "share",
FamilyGroupEnquiry: "share",
Preview: "photo",
PublishPhotoOperation: "photo",
PublishPhotoEnquiry: "photo",
PreviewAudio: "audio",
PublishAudioOperation: "audio",
PublishAudioEnquiry: "audio",
PreviewVideo: "video",
PublishVideoOperation: "video",
PublishVideoEnquiry: "video",
FamilyPersonEnquiry: "family",
FamilyPersonOperation: "family",
FamilyTreeEnquiry: "family",
FamilyTreeOperation: "family",
};
const resolveGroup = (it: SecurityFunctionDisplayData): GroupKey =>
GROUP_OF[it.FunctionName] ?? "other";
/* ==================== 自定义副标题 ==================== */
/** 留空时卡片会自动回退到 FunctionEngName */
const SUBTITLE_OF: Record<string, string> = {
DmsFileEnquiry: "",
DmsFileDeleteEnquiry: "",
FamilyGroupShare: "",
FamilyShareOperation: "",
FamilyGroupOperation: "",
FamilyGroupEnquiry: "",
Preview: "",
PublishPhotoOperation: "",
PublishPhotoEnquiry: "",
PreviewAudio: "",
PublishAudioOperation: "",
PublishAudioEnquiry: "",
PreviewVideo: "",
PublishVideoOperation: "",
PublishVideoEnquiry: "",
FamilyPersonEnquiry: "",
FamilyPersonOperation: "",
FamilyTreeEnquiry: "",
FamilyTreeOperation: "",
};
const resolveSubtitle = (it: SecurityFunctionDisplayData): string =>
SUBTITLE_OF[it.FunctionName] ?? "";
/* ==================== 分组结果 ==================== */
const groups = computed(() => {
const taken = new Set<string>();
const result: { key: GroupKey; name: string; color: string; soft: string; icon: string; layout: 2 | 3; cover?: string; badge?: string; items: GroupCardItem[] }[] = [];
for (const g of groupDefs) {
const items: GroupCardItem[] = [];
for (const it of allItems.value) {
if (resolveGroup(it) !== g.key) continue;
const label = it.FunctionLocalName || it.FunctionEngName;
if (taken.has(label)) continue;
taken.add(label);
items.push({ ...it, subtitle: resolveSubtitle(it) });
}
if (items.length) result.push({ ...g, items });
}
return result;
});
/** 底部快捷栏只保留五大高频域 */
const navItems = computed<NavItem[]>(() =>
groupDefs
.filter(g => ["disk", "photo", "audio", "video", "family"].includes(g.key))
.map(g => {
let label = g.name;
if (label === "共享中心") label = "共享";
if (label === "族谱管理") label = "族谱";
return { key: g.key, label, icon: g.icon };
})
);
/**
* 按左侧菜单的顺序排列。
*
* Orderby 只在「同一个父节点下」才是连续有意义的序号,跨父组直接比大小会错:
* 某个父组(比如从「有家网盘」拆出来的组)内部可能从 0 重新计数,
* 它的子项就会被平铺排序甩到全局最前面。
*
* 所以这里先按父节点自身的序号把「组」排好,再在组内按 Orderby 排,
* 平铺出来的先后就和菜单的树形展开顺序一致。
*/
const sortByMenuOrder = (list: SecurityFunctionDisplayData[]): SecurityFunctionDisplayData[] => {
// 每个节点自己的序号,用作它作为「父」时的排序依据
const parentOrder = new Map<string, number>();
list.forEach((it) => {
if (it.FunctionName) parentOrder.set(it.FunctionName, it.Orderby ?? 0);
});
/** 有父节点就跟着父走,没有(顶层)就用自己的序号 */
const groupOrder = (it: SecurityFunctionDisplayData): number => {
if (it.FunctionNameParent && parentOrder.has(it.FunctionNameParent)) {
return parentOrder.get(it.FunctionNameParent)!;
}
return it.Orderby ?? 0;
};
return [...list].sort(
(a, b) => groupOrder(a) - groupOrder(b) || (a.Orderby ?? 0) - (b.Orderby ?? 0)
);
};
/* ==================== 交互 ==================== */
const onpush = (apiRoute: string) => {
if (apiRoute) router.push(apiRoute);
};
const onNavClick = (key: string) => {
document.getElementById("grp-" + key)?.scrollIntoView({ behavior: "smooth", block: "start" });
};
/* ==================== 新手帮助弹窗 ==================== */
const helpOpen = ref(false);
/** 当前请求帮助的功能项,决定 HelpTimelineModal 加载哪份文档 */
const helpTarget = ref<GroupCardItem | null>(null);
/** 帮助弹窗沿用所属分组的主题色,与卡片保持视觉一致 */
const helpTheme = computed(() => {
const key = helpTarget.value ? resolveGroup(helpTarget.value) : undefined;
return groupDefs.find(g => g.key === key) ?? groupDefs[groupDefs.length - 1];
});
const helpColor = computed(() => helpTheme.value.color);
const helpSoft = computed(() => helpTheme.value.soft);
const helpIcon = computed(() => helpTheme.value.icon);
const onHelp = (it: GroupCardItem) => {
helpTarget.value = it;
helpOpen.value = true;
};
onMounted(async () => {
loading.value = true;
try {
const fd = await securityUserService.FindMenuByUsername();
const raw = fd?.SecurityFunctionDisplayDatas ?? [];
// 有些功能会挂在「不存在的父节点」下(例如测试残留的 abc),
// 它和正式记录同名却属于另一条,按 Orderby 排序时会窜到最前面,
// 表现为首页位置与左侧菜单对不上、改了后台顺序也不生效。
// 这里剔掉这类孤立记录,只保留父节点真实存在的。
const knownFunctions = new Set(raw.map(it => it.FunctionName));
allItems.value = sortByMenuOrder(
raw.filter(
it =>
it.FunctionIndc !== "N" &&
(it.PageUrl?.indexOf("master") ?? -1) > 0 &&
(!it.FunctionNameParent || knownFunctions.has(it.FunctionNameParent))
)
);
} finally {
loading.value = false;
}
});
</script>
<style lang="less" scoped>
/* a-spin 会插入两层容器,把高度透传下去,height: 100% 才会生效 */
.ant-spin-nested-loading {
height: 100%;
}
:deep(.ant-spin-container) {
height: 100%;
}
/* 独立外壳(layout/home.vue)下没有侧边菜单与面包屑,页面自己撑满整屏 */
.dash-page {
display: flex;
flex-direction: column;
height: 100%;
}
/* 卡片区独占剩余高度并内部滚动,底栏因此始终贴底 */
.dash-page .page-main {
flex: 1 1 auto;
min-height: 0;
/* 底栏已是列内固定项,不再需要为 fixed 预留的 96px */
padding-bottom: 12px;
}
/* 底部快捷栏:沿用 .page-footer,仅取消 fixed,改由 flex 列天然贴底 */
.dash-footer {
position: relative;
bottom: auto;
flex: none;
padding: 0;
}
</style>