add help
This commit is contained in:
@@ -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, "协议配置加载失败"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: [
|
items: [
|
||||||
|
{
|
||||||
|
icon: "file-1",
|
||||||
|
path: "/management/help",
|
||||||
|
title: "使用说明",
|
||||||
|
visible: true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
icon: "info-circle",
|
icon: "info-circle",
|
||||||
path: "/management/about",
|
path: "/management/about",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user