add help
This commit is contained in:
@@ -71,6 +71,7 @@ const agreementItemList: AgreementItem[] = [
|
||||
title: "开源软件协议"
|
||||
}
|
||||
];
|
||||
const agreementCodeList = agreementItemList.map(item => item.code);
|
||||
|
||||
const settingMap = ref<Record<string, Setting>>({});
|
||||
|
||||
@@ -94,7 +95,7 @@ async function openAgreement(item: AgreementItem): Promise<void> {
|
||||
|
||||
async function loadSettings(): Promise<void> {
|
||||
try {
|
||||
settingMap.value = await SettingAPI.map("GAO_SYSTEM");
|
||||
settingMap.value = await SettingAPI.map("GAO_SYSTEM", undefined, agreementCodeList);
|
||||
} catch (error) {
|
||||
Message.error(getErrorMessage(error, "协议配置加载失败"));
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -239,6 +239,12 @@ const menuGroupList = computed<MenuGroup[]>(() => [
|
||||
},
|
||||
{
|
||||
items: [
|
||||
{
|
||||
icon: "file-1",
|
||||
path: "/management/help",
|
||||
title: "使用说明",
|
||||
visible: true
|
||||
},
|
||||
{
|
||||
icon: "info-circle",
|
||||
path: "/management/about",
|
||||
|
||||
Reference in New Issue
Block a user