This commit is contained in:
itcsx committed 2026-09-19 10:24:21 +08:00
1 parent 9344b56334
commit 441d1e821e
37 files changed
+3706 -23

No files matched your search

@@ -0,0 +1,432 @@
<template>
<dragModal wrap-class-name="help-tl-modal" :width="760" v-model:open="visible" @cancel="onCancel">
<template #title>
<div class="htl-title">
<span class="htl-title-icon" :style="{ background: themeSoft, color: themeColor }">
<cust-icon-font :type="themeIcon" :size="18" />
</span>
<span class="htl-title-text">{{ props.title || "使用帮助" }}</span>
</div>
</template>
<div class="htl-body">
<a-spin :spinning="showLoading">
<!-- 功能简介 -->
<p class="htl-intro">{{ doc.intro }}</p>
<!-- 未编写专属文档时的提示,避免用户以为看到了正确流程 -->
<div v-if="!isSpecific" class="htl-notice">
<span class="htl-notice-tag">通用指引</span>
<span>该功能暂无专属帮助文档,下面是各功能页面的通用操作说明。</span>
</div>
<!-- 操作流程:按先后顺序 -->
<div class="htl-section-title">操作流程</div>
<a-timeline class="htl-steps">
<a-timeline-item v-for="(s, i) in doc.steps" :key="'step-' + i">
<template #dot>
<span class="htl-dot" :style="{ background: themeColor }">
<cust-icon-font v-if="isFontIcon(s.icon)" :type="s.icon" :size="13" />
<component v-else-if="s.icon" :is="antdIcon(s.icon)" />
<template v-else>{{ i + 1 }}</template>
</span>
</template>
<div class="htl-step">
<div class="htl-step-head">
<span class="htl-step-no">{{ i + 1 }}</span>
<span class="htl-step-title">{{ s.title }}</span>
</div>
<div class="htl-step-desc">{{ s.desc }}</div>
<div v-if="s.tips" class="htl-step-tips">
<span class="htl-step-tips-label">提示</span>
<span>{{ s.tips }}</span>
</div>
</div>
</a-timeline-item>
</a-timeline>
<!-- 功能点 / 图标说明 -->
<div class="htl-section-title">功能点与图标说明</div>
<div class="htl-features">
<div v-for="(f, i) in doc.features" :key="'feat-' + i" class="htl-feature">
<span class="htl-feature-icon" :style="{ background: themeSoft, color: themeColor }">
<cust-icon-font v-if="isFontIcon(f.icon)" :type="f.icon" :size="16" />
<!-- 其余一律按 Ant Design 图标组件名解析,保证与页面上看到的完全一致 -->
<component v-else :is="antdIcon(f.icon)" :style="{ color: f.color }" />
</span>
<div class="htl-feature-text">
<div class="htl-feature-name">{{ f.name }}</div>
<div class="htl-feature-desc">{{ f.desc }}</div>
</div>
</div>
</div>
</a-spin>
</div>
<!-- 只需要阅读,不需要确认按钮:传空 footer 占位隐藏默认按钮区 -->
<template #footer>
<span />
</template>
</dragModal>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref } from "vue";
import * as antdIcons from "@ant-design/icons-vue";
import { QuestionCircleOutlined } from "@ant-design/icons-vue";
import dragModal from "@/components/basic/DragModal.vue";
import { assembleOrDefault } from "@/data/help/helpDocAssembler";
import securityHelpFunctionService from "@/service/security/securityHelpFunctionService";
import { buildSeedHelpDocData } from "@/data/help/helpDocSeed";
import { SecurityHelpFunctionData } from "@/data/auto/securityHelpFunctionData";
const props = defineProps<{
open: boolean;
/** 目标功能的英文名,决定加载哪份帮助文档 */
functionName?: string;
/** 弹窗标题,一般为功能的本地化名称 */
title?: string;
/** 主题色,沿用所属分组的颜色保持视觉一致 */
color?: string;
soft?: string;
icon?: string;
}>();
const emit = defineEmits<{
(e: "update:open", value: boolean): void;
(e: "cancel"): void;
}>();
/** 双向绑定弹窗显示状态 */
const visible = computed({
get: () => props.open,
set: (v: boolean) => emit("update:open", v),
});
/**
* 接口还没就绪时就先用地上的种子数据(内容和建表数据一致),
* 这样弹窗永远有东西可看,不会白屏。
*/
const seedData: SecurityHelpFunctionData = buildSeedHelpDocData();
/** 当前实际使用的数据:默认种子数据,接口取到后整体替换 */
const rows = ref<SecurityHelpFunctionData>(seedData);
const loading = ref(false);
/** 从表里取数据:接口优先,取不到就继续用种子数据 */
const loadRows = async () => {
try {
loading.value = true;
const data = await securityHelpFunctionService.FindAll();
// 接口返回空说明表里还没灌数据,继续用种子数据
if (data && data.SecurityHelpFunctions && data.SecurityHelpFunctions.length > 0) {
rows.value = data;
}
} catch {
rows.value = seedData;
} finally {
loading.value = false;
}
};
onMounted(loadRows);
/** 把表行组合成页面要显示的文档;表里没有这个功能时退回通用兜底 */
const doc = computed(() => assembleOrDefault(rows.value, props.functionName));
/**
* false 表示表里没有这个功能的专属文档、拿到的是通用兜底,
* 这种情况要在弹窗顶部提示,免得用户以为看到的就是本功能的流程。
*/
const isSpecific = computed(
() =>
!!props.functionName &&
rows.value.SecurityHelpFunctions.some((f) => f.FunctionName === props.functionName)
);
/** 只有真的还没有内容时才显示加载态,避免种子数据明明有内容还挡一层遮罩 */
const showLoading = computed(() => loading.value && doc.value.steps.length === 0);
const themeColor = computed(() => props.color || "#1677ff");
const themeSoft = computed(() => props.soft || "#e6f4ff");
const themeIcon = computed(() => props.icon || "icon-help");
/**
* 图标分两类,判别规则很直白:
* 以 "icon-" 开头 → 项目自定义的 iconfont(如云盘的「预览」icon-qingbaojiankong)
* 其余 → Ant Design 官方图标组件名(如 DeleteOutlined)
* 这样帮助里出现的图标和用户在页面上真实看到的那个是同一个。
*/
const isFontIcon = (name?: string) => !!name && name.startsWith("icon-");
/**
* 按名字取出 antd 图标组件。
* 名字写错或组件库里没有时,退化成问号图标而不是渲染空白,避免出现一个空方块。
*/
const antdIcon = (name?: string) =>
(name && (antdIcons as Record<string, unknown>)[name]) || QuestionCircleOutlined;
const onCancel = () => {
emit("cancel");
};
</script>
<style lang="less" scoped>
.htl-title {
display: flex;
align-items: center;
gap: 8px;
}
.htl-title-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: 50%;
}
.htl-title-text {
font-size: 15px;
font-weight: 600;
}
.htl-body {
padding-right: 4px;
}
.htl-intro {
margin: 0 0 4px;
color: #303133;
font-size: 13px;
line-height: 1.8;
}
/* 兜底提示:温和提醒,不与主内容抢视线 */
.htl-notice {
display: flex;
align-items: flex-start;
gap: 8px;
margin: 10px 0 14px;
padding: 8px 12px;
border: 1px solid #ffe7ba;
border-radius: 8px;
background: #fff7e6;
color: #ad6800;
font-size: 12px;
line-height: 1.7;
}
.htl-notice-tag {
flex: none;
padding: 1px 6px;
border-radius: 4px;
background: #faad14;
color: #fff;
font-size: 11px;
line-height: 17px;
white-space: nowrap;
}
.htl-section-title {
position: relative;
margin: 14px 0 12px;
padding-left: 10px;
font-size: 14px;
font-weight: 600;
color: #1f2329;
&::before {
content: "";
position: absolute;
top: 50%;
left: 0;
width: 3px;
height: 13px;
border-radius: 2px;
background: #1677ff;
transform: translateY(-50%);
}
}
/* ==================== 时间轴步骤 ==================== */
.htl-steps {
padding-top: 2px;
margin-left:4px;
}
/* antd 默认把内容顶得很开,这里收紧,让「操作流程」更像一套连贯的步骤 */
.htl-steps :deep(.ant-timeline-item-content) {
margin-left: 0;
padding-left: 20px;
top: -4px;
}
.htl-steps :deep(.ant-timeline-item-tail) {
left: 10px;
}
.htl-dot {
display: inline-flex;
align-items: center;
justify-content: center;
width: 21px;
height: 21px;
border: 2px solid #fff;
border-radius: 50%;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.06);
color: #fff;
font-size: 11px;
font-weight: 600;
line-height: 1;
}
.htl-step {
padding-bottom: 2px;
}
.htl-step-head {
display: flex;
align-items: center;
gap: 7px;
margin-bottom: 3px;
}
.htl-step-no {
flex: none;
min-width: 17px;
height: 17px;
padding: 0 4px;
border-radius: 9px;
background: #f0f2f5;
color: #8a919f;
font-size: 11px;
font-weight: 600;
line-height: 17px;
text-align: center;
}
.htl-step-title {
font-size: 14px;
font-weight: 600;
color: #1f2329;
line-height: 1.5;
}
.htl-step-desc {
color: #5c6470;
font-size: 13px;
line-height: 1.8;
}
.htl-step-tips {
display: flex;
align-items: flex-start;
gap: 6px;
margin-top: 6px;
padding: 7px 10px;
border-left: 3px solid #faad14;
border-radius: 0 6px 6px 0;
background: #fffbe6;
color: #8a6116;
font-size: 12px;
line-height: 1.7;
}
.htl-step-tips-label {
flex: none;
font-weight: 600;
color: #ad6800;
white-space: nowrap;
}
/* ==================== 功能点网格 ==================== */
.htl-features {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
}
/* 宽屏时双列,减少纵向滚动 */
@media (min-width: 900px) {
.htl-features {
grid-template-columns: 1fr 1fr;
}
}
.htl-feature {
display: flex;
align-items: flex-start;
gap: 9px;
padding: 9px 11px;
border: 1px solid #f0f1f3;
border-radius: 9px;
background: #fafbfc;
transition: border-color 0.16s ease, background 0.16s ease;
&:hover {
border-color: #d6e4ff;
background: #fff;
}
}
.htl-feature-icon {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: 7px;
}
.htl-feature-text {
min-width: 0;
}
.htl-feature-name {
font-size: 13px;
font-weight: 600;
color: #1f2329;
line-height: 1.5;
}
.htl-feature-desc {
color: #6b7280;
font-size: 12px;
line-height: 1.7;
}
</style>
<!-- 非 scoped:这部分要作用于 DragModal teleport 出去的 antd Modal 自身结构,scoped 打不到。
全部写在 .help-tl-modal 这个 wrapClass 之下,只影响本组件,不会波及其它弹窗。 -->
<style lang="less">
.help-tl-modal {
/* 隐藏空的底部按钮区(发了空 footer 插槽占位,只是为了让 antd 不渲染默认按钮) */
.ant-modal-footer {
display: none;
}
/* 帮助文档通常比较长:限制高度并让内容区自己滚动 */
.ant-modal-body {
max-height: 68vh;
overflow-y: auto;
}
/* 窄按标题文字,留出右边三个操作图标的位置 */
.ant-modal-title {
padding-right: 48px;
}
@media (max-width: 640px) {
.ant-modal {
max-width: 100vw;
}
.ant-modal-body {
max-height: 74vh;
}
}
}
</style>
+11 -1
View File
@@ -26,7 +26,7 @@
type="text"
class="nav-btn"
:loading="loading"
@click="onBreadcrumbClick(breadcrumbItems[0], 0)"
@click="onRefresh"
>
<ReloadOutlined />
</a-button>
@@ -117,6 +117,16 @@ const onSearch = () => {
const onBreadcrumbClick = (item: BreadcrumbItem, index: number) => {
emit("breadcrumb-click", item, index);
};
/**
* 刷新:重新加载「当前所在目录」,也就是路径的最后一个节点。
* 不能取第一个节点,否则会把路径截回到根目录,变成跳转而不是刷新。
*/
const onRefresh = () => {
const lastIndex = breadcrumbItems.value.length - 1;
if (lastIndex < 0) return;
onBreadcrumbClick(breadcrumbItems.value[lastIndex], lastIndex);
};
</script>
<style lang="less">
+11 -1
View File
@@ -32,7 +32,7 @@
type="text"
class="nav-btn"
:loading="loading"
@click="onBreadcrumbClick(breadcrumbItems[0], 0)"
@click="onRefresh"
>
<ReloadOutlined />
</a-button> </a-tooltip
@@ -248,6 +248,16 @@ const onSearch = () => {
const onBreadcrumbClick = (item: BreadcrumbItem, index: number) => {
emit("breadcrumb-click", item, index);
};
/**
* 刷新:重新加载「当前所在目录」,也就是路径的最后一个节点。
* 不能取第一个节点,否则会把路径截回到根目录,变成跳转而不是刷新。
*/
const onRefresh = () => {
const lastIndex = breadcrumbItems.value.length - 1;
if (lastIndex < 0) return;
onBreadcrumbClick(breadcrumbItems.value[lastIndex], lastIndex);
};
</script>
<style lang="less">
@@ -17,8 +17,9 @@
<span class="row-title">{{ it.FunctionLocalName }}</span>
<span v-if="subtitleOf(it)" class="row-sub">{{ subtitleOf(it) }}</span>
</span>
<span class="row-glyph" :style="{ background: soft, color }">
<cust-icon-font :type="icon" :size="18" />
<span class="row-glyph" :style="{ background: soft, color }" title="使用帮助"
@click.stop="onHelp(it)">
<cust-icon-font type="icon-help" :size="18" />
</span>
</a>
</div>
@@ -50,6 +51,8 @@ const props = defineProps<{
const emit = defineEmits<{
(e: "navigate", route: string): void;
/** 点击行尾圆形图标:请求打开该功能的新手帮助 */
(e: "help", item: GroupCardItem): void;
}>();
const coverStyle = computed(() =>
@@ -59,11 +62,14 @@ const coverStyle = computed(() =>
);
/** 副标题优先取自定义 subtitle,其次回落英文名;都为空则不渲染该行 */
const subtitleOf = (it: GroupCardItem) => (it.subtitle || it.FunctionEngName || "").trim();
const subtitleOf = (it: GroupCardItem) => (it.subtitle || it.Remark || "").trim();
const onClick = (route?: string) => {
if (route) emit("navigate", route);
};
/** 点击行尾圆形图标:请求帮助(.stop 已阻止它冒泡到整行的路由跳转) */
const onHelp = (it: GroupCardItem) => emit("help", it);
</script>
<style lang="less" scoped>
@@ -196,6 +202,20 @@ const onClick = (route?: string) => {
width: 34px;
height: 34px;
border-radius: 50%;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition:
transform 0.16s ease,
box-shadow 0.16s ease;
/* 悬停 / 按下给出反馈,提示这里是打开「使用帮助」的入口 */
&:hover {
box-shadow: 0 0 0 4px rgba(0, 0, 0, 0.06);
}
&:active {
transform: scale(0.9);
}
}
@media (min-width: 1200px) {