This commit is contained in:
Timi
2026-09-14 23:22:10 +08:00
parent 6187f35a62
commit f7d144e440
6 changed files with 479 additions and 1 deletions
+2 -1
View File
@@ -71,6 +71,7 @@ const agreementItemList: AgreementItem[] = [
title: "开源软件协议" title: "开源软件协议"
} }
]; ];
const agreementCodeList = agreementItemList.map(item => item.code);
const settingMap = ref<Record<string, Setting>>({}); const settingMap = ref<Record<string, Setting>>({});
@@ -94,7 +95,7 @@ async function openAgreement(item: AgreementItem): Promise<void> {
async function loadSettings(): Promise<void> { async function loadSettings(): Promise<void> {
try { try {
settingMap.value = await SettingAPI.map("GAO_SYSTEM"); settingMap.value = await SettingAPI.map("GAO_SYSTEM", undefined, agreementCodeList);
} catch (error) { } catch (error) {
Message.error(getErrorMessage(error, "协议配置加载失败")); Message.error(getErrorMessage(error, "协议配置加载失败"));
} }
+333
View File
@@ -0,0 +1,333 @@
<template>
<div class="help-page empty-cell-title-margin">
<div class="search-wrap">
<t-input
v-model.trim="searchKeyword"
class="search"
type="search"
borderless
aria-label="搜索使用说明"
placeholder="搜索使用说明"
>
<template #prefix-icon>
<t-icon name="search" />
</template>
</t-input>
</div>
<t-cell-group v-if="visibleItemList.length" theme="card">
<t-cell
v-for="(entry, index) in visibleItemList"
:key="entry.path.join('-')"
:title="entry.item.title"
:description="entry.description"
arrow
v-entrance-transition="index * 30"
@click="openItem(entry)"
>
<template #leftIcon>
<t-icon :name="getItemIcon(entry.item)" size="1.75rem" />
</template>
</t-cell>
</t-cell-group>
<t-empty
v-else
:description="emptyDescription"
/>
</div>
</template>
<script setup lang="ts">
import { SettingAPI } from "timi-web-api";
import { useUserStore } from "timi-web-api/store";
import { getErrorMessage } from "@/utils/error";
import { Message } from "@/utils/message";
type HelpMenuItem = {
children: HelpMenuItem[];
contentId: string;
description: string;
type: HelpItemType;
title: string;
};
type HelpItemType = "guide" | "qa";
type HelpMenuItemSource = {
canReadRoleCodes?: unknown;
children?: unknown;
contentId?: unknown;
description?: unknown;
type?: unknown;
title?: unknown;
};
type HelpListEntry = {
description: string;
item: HelpMenuItem;
path: number[];
};
defineOptions({
name: "Help"
});
const router = useRouter();
const route = useRoute();
const userStore = useUserStore();
const helpMenu = shallowRef<HelpMenuItem[]>([]);
const isLoading = shallowRef(false);
const searchKeyword = shallowRef("");
const menuPath = computed(() => parseMenuPath(route.params.pathMatch));
const currentItemList = computed(() => getMenuItems(helpMenu.value, menuPath.value));
const isSearchMode = computed(() => !!normalizeSearchValue(searchKeyword.value));
const visibleItemList = computed<HelpListEntry[]>(() => {
if (!isSearchMode.value) {
return currentItemList.value.map((item, index) => createListEntry(
item,
[...menuPath.value, index]
));
}
const keyword = normalizeSearchValue(searchKeyword.value);
return getAllListEntries(helpMenu.value).filter(entry => [
entry.item.title,
entry.item.description,
entry.description
].some(value => normalizeSearchValue(value).includes(keyword)));
});
const emptyDescription = computed(() => {
if (isLoading.value) {
return "正在加载使用说明";
}
return isSearchMode.value ? "未找到匹配的使用说明" : "暂无可用使用说明";
});
function parseMenuPath(value: unknown): number[] {
const segmentList = Array.isArray(value) ? value : value === undefined ? [] : [value];
return segmentList
.flatMap(segment => String(segment).split("/"))
.map(segment => segment.trim())
.filter(Boolean)
.map(segment => Number(segment))
.filter(index => Number.isInteger(index) && !(index < 0));
}
function getMenuItems(itemList: HelpMenuItem[], path: number[]): HelpMenuItem[] {
let currentItemList = itemList;
for (const index of path) {
const item = currentItemList[index];
if (!item) {
return [];
}
currentItemList = item.children;
}
return currentItemList;
}
function normalizeSearchValue(value?: string): string {
return value?.trim().toLocaleLowerCase() || "";
}
function createListEntry(item: HelpMenuItem, path: number[], parentTitleList: string[] = []): HelpListEntry {
const description = parentTitleList.length
? [parentTitleList.join(" > "), item.description].filter(Boolean).join(" · ")
: item.description;
return { description, item, path };
}
function getAllListEntries(
itemList: HelpMenuItem[],
parentPath: number[] = [],
parentTitleList: string[] = []
): HelpListEntry[] {
return itemList.flatMap((item, index) => {
const path = [...parentPath, index];
const entry = createListEntry(item, path, parentTitleList);
return [
entry,
...getAllListEntries(item.children, path, [...parentTitleList, item.title])
];
});
}
function parseStringList(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return value
.filter((item): item is string => typeof item === "string")
.map(item => item.trim())
.filter(Boolean);
}
function hasRoleAccess(roleCodes: string[]): boolean {
const candidateRoleCodes = roleCodes.flatMap(roleCode => roleCode.includes(":")
? [roleCode]
: [roleCode, `GAO:${roleCode}`]);
return userStore.hasRoles(candidateRoleCodes);
}
function normalizeMenuItem(value: unknown): HelpMenuItem | undefined {
if (!value || typeof value !== "object") {
return undefined;
}
const source = value as HelpMenuItemSource;
const title = typeof source.title === "string" ? source.title.trim() : "";
if (!title) {
return undefined;
}
const canReadRoleCodes = parseStringList(source.canReadRoleCodes);
if (canReadRoleCodes.length && !hasRoleAccess(canReadRoleCodes)) {
return undefined;
}
const childSourceList = Array.isArray(source.children) ? source.children : [];
const hasConfiguredChildren = childSourceList.length > 0;
const children = hasConfiguredChildren
? childSourceList
.map(item => normalizeMenuItem(item))
.filter((item): item is HelpMenuItem => !!item)
: [];
const contentId = typeof source.contentId === "string" ? source.contentId.trim() : "";
const description = typeof source.description === "string" ? source.description.trim() : "";
const type: HelpItemType = source.type === "qa" ? "qa" : "guide";
if (hasConfiguredChildren && !children.length && !contentId) {
return undefined;
}
return {
children,
contentId,
description,
type,
title
};
}
function getItemIcon(item: HelpMenuItem): string {
if (item.type === "qa") {
return "file-unknown";
}
return item.children.length ? "layers" : "file-attachment";
}
function parseHelpMenu(value?: string): HelpMenuItem[] {
if (!value?.trim()) {
return [];
}
try {
const sourceList: unknown = JSON.parse(value);
if (!Array.isArray(sourceList)) {
return [];
}
return sourceList
.map(item => normalizeMenuItem(item))
.filter((item): item is HelpMenuItem => !!item);
} catch {
Message.error("使用说明配置格式错误");
return [];
}
}
function getRouteDepth(path: number[]): number {
return Number(route.meta.baseDepth ?? route.meta.depth ?? 0) + path.length + 1;
}
async function openItem(entry: HelpListEntry): Promise<void> {
const { item, path } = entry;
if (item.children.length) {
searchKeyword.value = "";
await router.push({
name: "HelpMenu",
params: {
pathMatch: path.map(String)
}
});
return;
}
if (!item.contentId) {
Message.warning(`${item.title}暂未配置`);
return;
}
await router.push({
name: "HelpArticle",
params: {
id: item.contentId
},
query: {
transitionDepth: String(getRouteDepth(path) + 1)
}
});
}
async function loadHelpMenu(): Promise<void> {
isLoading.value = true;
try {
const settingMap = await SettingAPI.map("GAO_SYSTEM", undefined, ["HELP_MENU"]);
const settingValue = settingMap.HELP_MENU?.value;
helpMenu.value = parseHelpMenu(settingValue);
} catch (error) {
Message.error(getErrorMessage(error, "使用说明配置加载失败"));
} finally {
isLoading.value = false;
}
}
onMounted(async () => {
await loadHelpMenu();
});
</script>
<style scoped lang="less">
.help-page {
gap: 1rem;
display: flex;
padding: var(--app-nav-height) 0 1rem;
flex-direction: column;
.search-wrap {
top: var(--app-nav-height);
left: 0;
right: 0;
z-index: 10;
display: flex;
padding: .75rem 1rem;
position: sticky;
background: #F5F5F8;
box-shadow: var(--td-shadow-1);
flex-direction: column;
.search {
--td-button-border-radius: .75rem;
--td-input-vertical-padding: .25rem 1rem;
flex: 1;
border: 1px solid var(--td-component-border);
z-index: 2;
background: var(--td-bg-color-container);
border-radius: 1rem;
}
}
}
:global(html.theme-dark) {
.help-page {
.search-wrap {
background: #10161f;
}
.search {
background: rgba(30, 41, 59, .9);
}
}
}
</style>
+104
View File
@@ -0,0 +1,104 @@
<template>
<div class="help-article-page">
<template v-if="article">
<article-view
:article="article"
empty-text="暂无使用说明正文"
v-entrance-transition
:resolve-attachment-url="resolveAttachmentUrl"
/>
</template>
<t-empty
v-else
:description="isLoading ? '正在加载使用说明' : '使用说明不存在'"
/>
</div>
</template>
<script setup lang="ts">
import { type Article, ArticleAPI, AttachmentAPI } from "timi-web-api";
import { ArticleView } from "timi-tdesign-mobile";
import { useNavBarStore } from "@/store/navBarStore";
import { getErrorMessage } from "@/utils/error";
import { Message } from "@/utils/message";
defineOptions({
name: "HelpArticle"
});
const route = useRoute();
const navBarStore = useNavBarStore();
const article = ref<Article>();
const isLoading = ref(false);
let loadToken = 0;
function resolveAttachmentUrl(attachmentId: string): string {
return AttachmentAPI.getReadURL(attachmentId);
}
function getRouteNavBarTitle(): string {
return typeof route.meta.navBarTitle === "string" ? route.meta.navBarTitle : "";
}
async function loadArticle(): Promise<void> {
const requestToken = loadToken + 1;
loadToken = requestToken;
article.value = undefined;
navBarStore.setTitle(getRouteNavBarTitle());
const articleId = String(route.params.id || "").trim();
if (!articleId) {
return;
}
isLoading.value = true;
try {
const result = await ArticleAPI.detail(articleId);
if (requestToken !== loadToken) {
return;
}
article.value = result;
navBarStore.setTitle(result.title?.trim() || getRouteNavBarTitle());
} catch (error) {
Message.error(getErrorMessage(error, "使用说明加载失败"));
} finally {
if (requestToken === loadToken) {
isLoading.value = false;
}
}
}
watch(() => route.params.id, async () => {
await loadArticle();
}, { immediate: true });
</script>
<style scoped lang="less">
.help-article-page {
padding: var(--app-nav-height) 0 1rem;
.header {
padding: .5rem 1rem;
.description {
margin-top: .5rem;
color: #52607a;
font-size: .875rem;
line-height: 1.7;
}
}
:deep(.content) {
border: none;
}
}
:global(html.theme-dark) {
.help-article-page {
.header {
.description {
color: #94a3b8;
}
}
}
}
</style>
+6
View File
@@ -239,6 +239,12 @@ const menuGroupList = computed<MenuGroup[]>(() => [
}, },
{ {
items: [ items: [
{
icon: "file-1",
path: "/management/help",
title: "使用说明",
visible: true
},
{ {
icon: "info-circle", icon: "info-circle",
path: "/management/about", path: "/management/about",
+29
View File
@@ -130,6 +130,35 @@ export const storeRoutes: RouteRecordRaw[] = [
]; ];
export const managementSettingRoutes: RouteRecordRaw[] = [ export const managementSettingRoutes: RouteRecordRaw[] = [
{
path: "/management/help/article/:id",
name: "HelpArticle",
component: () => import("@/pages/setting/HelpArticle.vue"),
meta: {
depth: 4,
navBarVisible: true,
navBarCanBack: true,
navBarTitle: "使用说明",
tabBarVisible: false,
bodyBackground: "#FFF",
darkBodyBackground: "#10161f"
}
},
{
path: "/management/help/:pathMatch(.*)*",
name: "HelpMenu",
component: () => import("@/pages/setting/Help.vue"),
meta: {
baseDepth: 2,
dynamicDepth: true,
navBarVisible: true,
navBarCanBack: true,
navBarTitle: "使用说明",
tabBarVisible: false,
bodyBackground: "#F5F5F8",
darkBodyBackground: "#10161f"
}
},
{ {
path: "/management/about", path: "/management/about",
name: "About", name: "About",
+5
View File
@@ -29,6 +29,10 @@ export const useNavBarStore = defineStore("nav-bar", () => {
height.value = value; height.value = value;
} }
function setTitle(value?: string): void {
title.value = value || "";
}
function setBackTo(value?: string): void { function setBackTo(value?: string): void {
backTo.value = value; backTo.value = value;
} }
@@ -106,6 +110,7 @@ export const useNavBarStore = defineStore("nav-bar", () => {
isShowing, isShowing,
canBack, canBack,
setHeight, setHeight,
setTitle,
setBackTo, setBackTo,
setRightIcon, setRightIcon,
setRightBadge, setRightBadge,