mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-10-04 01:10:36 +00:00
refactor: 重构路由与部署脚本,更新静态资源路径
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
<!-- 留言墙(大抽屉,替代独立路由) -->
|
||||
<template>
|
||||
<ElDrawer
|
||||
v-model="visible"
|
||||
title="留言墙"
|
||||
direction="rtl"
|
||||
size="min(960px, 96vw)"
|
||||
append-to-body
|
||||
destroy-on-close
|
||||
class="article-comment-wall-drawer"
|
||||
>
|
||||
<p class="mt-0 mb-8 text-g-600">每一份留言都记录了您的想法,也为我们提供了珍贵的回忆</p>
|
||||
|
||||
<ul
|
||||
class="grid grid-cols-5 gap-5 max-2xl:grid-cols-4 max-xl:grid-cols-3 max-lg:grid-cols-2 max-sm:grid-cols-1 mb-5 list-none p-0 m-0"
|
||||
>
|
||||
<li
|
||||
class="relative p-4 c-p aspect-16/12 duration-300 hover:-translate-y-1.5 rounded-custom-sm"
|
||||
:style="{ background: item.color }"
|
||||
v-for="item in commentsWithColors"
|
||||
:key="item.id"
|
||||
@click="openCardDrawer(item)"
|
||||
>
|
||||
<p class="text-g-600 text-sm">{{ item.date }}</p>
|
||||
<p class="mt-4 text-sm text-gray-800">{{ item.content }}</p>
|
||||
<div class="absolute bottom-4 left-0 px-4 flex-cb w-full">
|
||||
<div class="flex-c">
|
||||
<div class="flex-c mr-5 text-xs text-g-600">
|
||||
<FaSvgIcon icon="ri:heart-line" class="mr-1 text-base" />
|
||||
<span>{{ item.collection }}</span>
|
||||
</div>
|
||||
<div class="flex-c mr-5 text-xs text-g-600">
|
||||
<FaSvgIcon icon="ri:message-3-line" class="mr-1 text-base" />
|
||||
<span>{{ item.comment }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-sm text-gray-700">{{ item.userName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<ElDrawer
|
||||
v-model="cardDrawerOpen"
|
||||
:lock-scroll="false"
|
||||
:size="360"
|
||||
append-to-body
|
||||
modal-class="comment-modal"
|
||||
>
|
||||
<template #header>
|
||||
<h4 class="m-0">详情</h4>
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="drawer-default">
|
||||
<div
|
||||
class="relative p-4 aspect-16/12 rounded-md"
|
||||
:style="{ background: clickItem.color }"
|
||||
>
|
||||
<p class="text-g-500 text-sm">{{ clickItem.date }}</p>
|
||||
<p class="mt-4 text-sm text-gray-800">{{ clickItem.content }}</p>
|
||||
<div class="absolute bottom-4 left-0 px-4 flex-cb w-full">
|
||||
<div class="flex-c">
|
||||
<div class="flex-c mr-5 text-xs text-g-600">
|
||||
<FaSvgIcon icon="ri:heart-line" class="mr-1 text-base" />
|
||||
<span>{{ clickItem.collection }}</span>
|
||||
</div>
|
||||
<div class="flex-c mr-5 text-xs text-g-600">
|
||||
<FaSvgIcon icon="ri:message-3-line" class="mr-1 text-base" />
|
||||
<span>{{ clickItem.comment }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-sm text-gray-700">{{ clickItem.userName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ElDrawer>
|
||||
</ElDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { commentList } from "@/mock/temp/commentList";
|
||||
|
||||
defineOptions({ name: "ArticleCommentWallDrawer" });
|
||||
|
||||
interface CommentItem {
|
||||
id: number;
|
||||
date: string;
|
||||
content: string;
|
||||
collection: number;
|
||||
comment: number;
|
||||
userName: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [boolean];
|
||||
}>();
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v: boolean) => emit("update:modelValue", v),
|
||||
});
|
||||
|
||||
const COLOR_LIST = ["#D8F8FF", "#FDDFD9", "#FCE6F0", "#D3F8F0", "#FFEABC", "#F5E1FF", "#E1E6FE"];
|
||||
|
||||
const cardDrawerOpen = ref(false);
|
||||
const clickItem = ref<CommentItem>({
|
||||
id: 1,
|
||||
date: "2024-9-3",
|
||||
content: "加油!学好Node 自己写个小Demo",
|
||||
collection: 5,
|
||||
comment: 8,
|
||||
userName: "匿名",
|
||||
color: COLOR_LIST[0],
|
||||
});
|
||||
|
||||
const commentsWithColors = computed(() => {
|
||||
let lastColorIndex = -1;
|
||||
|
||||
return commentList.map((item) => {
|
||||
let newIndex: number;
|
||||
|
||||
do {
|
||||
newIndex = Math.floor(Math.random() * COLOR_LIST.length);
|
||||
} while (newIndex === lastColorIndex && COLOR_LIST.length > 1);
|
||||
|
||||
lastColorIndex = newIndex;
|
||||
|
||||
return {
|
||||
...item,
|
||||
color: COLOR_LIST[newIndex],
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const openCardDrawer = (item: CommentItem) => {
|
||||
cardDrawerOpen.value = true;
|
||||
clickItem.value = item;
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,194 @@
|
||||
<!-- 文章详情(抽屉,无独立路由) -->
|
||||
<template>
|
||||
<ElDrawer
|
||||
v-model="visible"
|
||||
:title="drawerTitle"
|
||||
direction="rtl"
|
||||
size="min(900px, 100vw)"
|
||||
destroy-on-close
|
||||
append-to-body
|
||||
class="article-detail-drawer"
|
||||
>
|
||||
<div v-loading="loading" class="article-detail-drawer-inner">
|
||||
<ElAlert v-if="error" :title="error" type="error" show-icon :closable="false" />
|
||||
<div
|
||||
v-else-if="articleHtml"
|
||||
class="markdown-body article-detail-markdown"
|
||||
v-highlight
|
||||
v-html="articleHtml"
|
||||
/>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex flex-wrap justify-end gap-2">
|
||||
<ElButton v-if="articleId != null" @click="emitOpenCommentWall">
|
||||
<FaSvgIcon icon="ri:message-3-line" class="mr-1" />
|
||||
留言讨论
|
||||
</ElButton>
|
||||
<ElButton @click="visible = false">关闭</ElButton>
|
||||
<ElButton v-if="articleId != null" v-auth="'edit'" type="primary" @click="onEditClick">
|
||||
编辑
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</ElDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import "@styles/core/md.scss";
|
||||
import "@styles/custom/one-dark-pro.scss";
|
||||
import axios from "axios";
|
||||
|
||||
defineOptions({ name: "ArticleDetailDrawer" });
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean;
|
||||
articleId?: number | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [boolean];
|
||||
edit: [id: number];
|
||||
"open-comment-wall": [];
|
||||
}>();
|
||||
|
||||
const emitOpenCommentWall = () => {
|
||||
emit("open-comment-wall");
|
||||
};
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v: boolean) => emit("update:modelValue", v),
|
||||
});
|
||||
|
||||
interface ArticleResponse {
|
||||
code: number;
|
||||
data: {
|
||||
title: string;
|
||||
html_content: string;
|
||||
};
|
||||
}
|
||||
|
||||
const articleTitle = ref("");
|
||||
const articleHtml = shallowRef("");
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
const drawerTitle = computed(() => articleTitle.value || "文章详情");
|
||||
|
||||
const fetchDetail = async () => {
|
||||
const id = props.articleId;
|
||||
if (id == null) return;
|
||||
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
articleTitle.value = "";
|
||||
articleHtml.value = "";
|
||||
|
||||
try {
|
||||
const { data } = await axios.get<ArticleResponse>(
|
||||
"https://www.qiniu.lingchen.kim/blog_detail.json"
|
||||
);
|
||||
|
||||
if (data.code === 200) {
|
||||
articleTitle.value = data.data.title;
|
||||
articleHtml.value = data.data.html_content;
|
||||
} else {
|
||||
error.value = "文章加载失败";
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = "文章加载失败";
|
||||
console.error("获取文章详情失败:", err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onEditClick = () => {
|
||||
if (props.articleId != null) {
|
||||
emit("edit", props.articleId);
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => [props.modelValue, props.articleId] as const,
|
||||
([open, id]) => {
|
||||
if (open && id != null) {
|
||||
nextTick(() => fetchDetail());
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.article-detail-drawer-inner {
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.article-detail-markdown {
|
||||
margin-top: 8px;
|
||||
|
||||
:deep(img) {
|
||||
width: 100%;
|
||||
border: 1px solid var(--fa-gray-200);
|
||||
}
|
||||
|
||||
:deep(pre) {
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
.copy-button {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50px;
|
||||
width: 1px;
|
||||
height: 100%;
|
||||
content: "";
|
||||
background: #0a0a0e;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.code-wrapper) {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
:deep(.line-number) {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
box-sizing: border-box;
|
||||
display: inline-block;
|
||||
width: 50px;
|
||||
margin-right: 10px;
|
||||
font-size: 14px;
|
||||
color: #9e9e9e;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
:deep(.copy-button) {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 20px;
|
||||
line-height: 40px;
|
||||
color: #999;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
background-color: #000;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
opacity: 0;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,377 @@
|
||||
<!-- 文章新增 / 编辑(抽屉,替代独立发布页) -->
|
||||
<template>
|
||||
<ElDrawer
|
||||
v-model="visible"
|
||||
:title="drawerTitle"
|
||||
direction="rtl"
|
||||
size="min(920px, 100vw)"
|
||||
destroy-on-close
|
||||
append-to-body
|
||||
class="article-publish-drawer"
|
||||
>
|
||||
<div class="article-publish-drawer-body">
|
||||
<template v-if="resultPhase === 'form'">
|
||||
<div>
|
||||
<ElRow :gutter="10">
|
||||
<ElCol :span="18">
|
||||
<ElInput
|
||||
v-model.trim="articleName"
|
||||
placeholder="请输入文章标题(最多100个字符)"
|
||||
maxlength="100"
|
||||
/>
|
||||
</ElCol>
|
||||
<ElCol :span="6">
|
||||
<ElSelect v-model="articleType" placeholder="请选择文章类型" filterable>
|
||||
<ElOption
|
||||
v-for="item in articleTypes"
|
||||
:key="item.id"
|
||||
:label="item.name"
|
||||
:value="item.id"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
|
||||
<FaWangEditor class="mt-2.5" v-model="editorHtml" />
|
||||
|
||||
<div class="p-5 mt-5 fa-card-xs">
|
||||
<h2 class="mb-5 text-xl font-medium">发布设置</h2>
|
||||
<ElForm>
|
||||
<ElFormItem label="封面">
|
||||
<div class="mt-2.5">
|
||||
<ElUpload
|
||||
:action="uploadImageUrl"
|
||||
:headers="uploadHeaders"
|
||||
:show-file-list="false"
|
||||
:on-success="onSuccess"
|
||||
:on-error="onError"
|
||||
:before-upload="beforeUpload"
|
||||
>
|
||||
<div
|
||||
v-if="!cover"
|
||||
class="flex-cc flex-col w-65 h-40 border border-dashed border-[#d9d9d9] rounded-md"
|
||||
>
|
||||
<ElIcon class="!text-xl !text-g-600"><Plus /></ElIcon>
|
||||
<div class="mt-2 text-sm text-g-600">点击上传封面</div>
|
||||
</div>
|
||||
<img v-else :src="cover" class="block w-65 h-40 object-cover" />
|
||||
</ElUpload>
|
||||
<div class="mt-2 text-xs text-g-700">建议尺寸 16:9,jpg/png 格式</div>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="可见">
|
||||
<ElSwitch v-model="visibleFlag" />
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<ElButton type="primary" @click="submit" class="w-25">
|
||||
{{ pageMode === PageModeEnum.Edit ? "保存" : "发布" }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<FaResultPage
|
||||
v-else-if="resultPhase === 'success'"
|
||||
type="success"
|
||||
:title="successTitle"
|
||||
message="文章操作已完成。简单反馈也可用全局 Message;此处用于完整结果反馈,下方灰色区域可展示补充说明。"
|
||||
icon-code="ri:check-fill"
|
||||
>
|
||||
<template #content>
|
||||
<p v-if="pageMode === PageModeEnum.Add">文章已发布,读者可在列表与详情中查看。</p>
|
||||
<p v-else>修改已保存,内容已更新。</p>
|
||||
</template>
|
||||
<template #buttons>
|
||||
<ElButton type="primary" v-ripple @click="backToForm">继续编辑</ElButton>
|
||||
<ElButton v-ripple @click="finishAndClose">关闭并返回列表</ElButton>
|
||||
<ElButton v-if="pageMode === PageModeEnum.Add" v-ripple @click="publishAgain">
|
||||
新建一篇
|
||||
</ElButton>
|
||||
</template>
|
||||
</FaResultPage>
|
||||
|
||||
<FaResultPage
|
||||
v-else
|
||||
type="fail"
|
||||
:title="failTitle"
|
||||
message="请核对并修改以下信息后,再重新提交。"
|
||||
icon-code="ri:close-fill"
|
||||
>
|
||||
<template #content>
|
||||
<p>本次提交未成功,可能原因如下:</p>
|
||||
<p>
|
||||
<FaSvgIcon icon="ri:close-circle-line" class="text-red-500 mr-1" />
|
||||
<span>网络或服务暂时不可用,请稍后重试</span>
|
||||
</p>
|
||||
<p>
|
||||
<FaSvgIcon icon="ri:close-circle-line" class="text-red-500 mr-1" />
|
||||
<span>请确认必填项与封面已正确填写</span>
|
||||
</p>
|
||||
</template>
|
||||
<template #buttons>
|
||||
<ElButton type="primary" v-ripple @click="backToForm">返回修改</ElButton>
|
||||
<ElButton v-ripple @click="finishAndClose">关闭并返回列表</ElButton>
|
||||
</template>
|
||||
</FaResultPage>
|
||||
</div>
|
||||
</ElDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Plus } from "@element-plus/icons-vue";
|
||||
import { ApiStatus } from "@utils/http";
|
||||
import { useUserStore } from "@stores/modules/user.store";
|
||||
import { EmojiText } from "@utils/ui";
|
||||
import { PageModeEnum } from "@/enums/formEnum";
|
||||
import axios from "axios";
|
||||
|
||||
defineOptions({ name: "ArticlePublishDrawer" });
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: boolean;
|
||||
/** 编辑的文章 id;不传或 null 为新增 */
|
||||
editId?: number | null;
|
||||
}>(),
|
||||
{ editId: null }
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [boolean];
|
||||
success: [];
|
||||
}>();
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v: boolean) => emit("update:modelValue", v),
|
||||
});
|
||||
|
||||
interface ArticleType {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface UploadResponse {
|
||||
data: {
|
||||
url: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ArticleDetailResponse {
|
||||
code: number;
|
||||
data: {
|
||||
title: string;
|
||||
blog_class: string;
|
||||
html_content: string;
|
||||
};
|
||||
}
|
||||
|
||||
const MAX_IMAGE_SIZE = 2;
|
||||
const EMPTY_EDITOR_CONTENT = "<p><br></p>";
|
||||
|
||||
const userStore = useUserStore();
|
||||
const { accessToken } = userStore;
|
||||
|
||||
const uploadImageUrl = `${import.meta.env.VITE_API_URL}/api/common/upload`;
|
||||
const uploadHeaders = { Authorization: accessToken };
|
||||
|
||||
type ResultPhase = "form" | "success" | "fail";
|
||||
const resultPhase = ref<ResultPhase>("form");
|
||||
|
||||
const pageMode = ref<PageModeEnum>(PageModeEnum.Add);
|
||||
const articleName = ref("");
|
||||
const articleType = ref<number>();
|
||||
const articleTypes = ref<ArticleType[]>([]);
|
||||
const editorHtml = ref("");
|
||||
const cover = ref("");
|
||||
/** 与 ElSwitch `visible` 避免命名冲突 */
|
||||
const visibleFlag = ref(true);
|
||||
|
||||
const drawerTitle = computed(() =>
|
||||
pageMode.value === PageModeEnum.Edit ? "编辑文章" : "新增文章"
|
||||
);
|
||||
|
||||
const successTitle = computed(() =>
|
||||
pageMode.value === PageModeEnum.Edit ? "保存成功" : "发布成功"
|
||||
);
|
||||
const failTitle = computed(() => (pageMode.value === PageModeEnum.Edit ? "保存失败" : "发布失败"));
|
||||
|
||||
const backToForm = () => {
|
||||
resultPhase.value = "form";
|
||||
};
|
||||
|
||||
const finishAndClose = () => {
|
||||
emit("success");
|
||||
visible.value = false;
|
||||
};
|
||||
|
||||
const publishAgain = () => {
|
||||
pageMode.value = PageModeEnum.Add;
|
||||
articleName.value = "";
|
||||
articleType.value = undefined;
|
||||
editorHtml.value = "";
|
||||
cover.value = "";
|
||||
visibleFlag.value = true;
|
||||
resultPhase.value = "form";
|
||||
};
|
||||
|
||||
const initWhenOpen = () => {
|
||||
resultPhase.value = "form";
|
||||
pageMode.value = props.editId != null ? PageModeEnum.Edit : PageModeEnum.Add;
|
||||
|
||||
if (pageMode.value === PageModeEnum.Edit) {
|
||||
getArticleDetail();
|
||||
} else {
|
||||
articleName.value = "";
|
||||
articleType.value = undefined;
|
||||
editorHtml.value = "";
|
||||
cover.value = "";
|
||||
visibleFlag.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const getArticleTypes = async () => {
|
||||
try {
|
||||
const { data } = await axios.get("https://www.qiniu.lingchen.kim/classify.json");
|
||||
if (data.code === 200) {
|
||||
articleTypes.value = data.data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取文章分类失败:", error);
|
||||
ElMessage.error("获取文章分类失败");
|
||||
}
|
||||
};
|
||||
|
||||
const getArticleDetail = async () => {
|
||||
try {
|
||||
const { data } = await axios.get<ArticleDetailResponse>(
|
||||
"https://www.qiniu.lingchen.kim/blog_list.json"
|
||||
);
|
||||
|
||||
if (data.code === ApiStatus.success) {
|
||||
const { title, blog_class, html_content } = data.data;
|
||||
articleName.value = title;
|
||||
articleType.value = Number(blog_class);
|
||||
editorHtml.value = html_content;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取文章详情失败:", error);
|
||||
ElMessage.error("获取文章详情失败");
|
||||
}
|
||||
};
|
||||
|
||||
const validateArticle = (): boolean => {
|
||||
if (!articleName.value.trim()) {
|
||||
ElMessage.error("请输入文章标题");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!articleType.value) {
|
||||
ElMessage.error("请选择文章类型");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!editorHtml.value || editorHtml.value === EMPTY_EDITOR_CONTENT) {
|
||||
ElMessage.error("请输入文章内容");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!cover.value) {
|
||||
ElMessage.error("请上传封面图片");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const cleanCodeContent = (content: string): string => {
|
||||
return content.replace(/(\s*)<\/code>/g, "</code>");
|
||||
};
|
||||
|
||||
const addArticle = async () => {
|
||||
if (!validateArticle()) return;
|
||||
|
||||
try {
|
||||
const cleanedContent = cleanCodeContent(editorHtml.value);
|
||||
|
||||
console.log("新增文章:", { cleanedContent });
|
||||
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 400));
|
||||
resultPhase.value = "success";
|
||||
} catch (error) {
|
||||
console.error("发布文章失败:", error);
|
||||
resultPhase.value = "fail";
|
||||
}
|
||||
};
|
||||
|
||||
const editArticle = async () => {
|
||||
if (!validateArticle()) return;
|
||||
|
||||
try {
|
||||
const cleanedContent = cleanCodeContent(editorHtml.value);
|
||||
|
||||
console.log("编辑文章:", { cleanedContent });
|
||||
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 400));
|
||||
resultPhase.value = "success";
|
||||
} catch (error) {
|
||||
console.error("保存文章失败:", error);
|
||||
resultPhase.value = "fail";
|
||||
}
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
if (pageMode.value === PageModeEnum.Edit) {
|
||||
editArticle();
|
||||
} else {
|
||||
addArticle();
|
||||
}
|
||||
};
|
||||
|
||||
const onSuccess = (response: UploadResponse) => {
|
||||
cover.value = response.data.url;
|
||||
ElMessage.success(`图片上传成功 ${EmojiText[200]}`);
|
||||
};
|
||||
|
||||
const onError = () => {
|
||||
ElMessage.error(`图片上传失败 ${EmojiText[500]}`);
|
||||
};
|
||||
|
||||
const beforeUpload = (file: File): boolean => {
|
||||
const isImage = file.type.startsWith("image/");
|
||||
const isLt2M = file.size / 1024 / 1024 < MAX_IMAGE_SIZE;
|
||||
|
||||
if (!isImage) {
|
||||
ElMessage.error("只能上传图片文件");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isLt2M) {
|
||||
ElMessage.error(`图片大小不能超过 ${MAX_IMAGE_SIZE}MB`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(open) => {
|
||||
if (open) {
|
||||
nextTick(() => {
|
||||
initWhenOpen();
|
||||
getArticleTypes();
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.article-publish-drawer-body {
|
||||
padding-bottom: env(safe-area-inset-bottom, 0);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,271 @@
|
||||
<!-- 文章列表(挂载于 /dashboard/article,原 widgets/article) -->
|
||||
<template>
|
||||
<div class="page-content !mb-5">
|
||||
<ElRow justify="space-between" :gutter="10">
|
||||
<ElCol :lg="6" :md="6" :sm="14" :xs="16">
|
||||
<ElInput
|
||||
v-model="searchVal"
|
||||
:prefix-icon="Search"
|
||||
clearable
|
||||
placeholder="输入文章标题查询"
|
||||
@keyup.enter="searchArticle"
|
||||
/>
|
||||
</ElCol>
|
||||
<ElCol :lg="12" :md="12" :sm="0" :xs="0">
|
||||
<div class="custom-segmented">
|
||||
<ElSegmented v-model="yearVal" :options="YEAR_OPTIONS" @change="searchArticleByYear" />
|
||||
</div>
|
||||
</ElCol>
|
||||
<ElCol
|
||||
:lg="6"
|
||||
:md="6"
|
||||
:sm="10"
|
||||
:xs="6"
|
||||
style="display: flex; flex-wrap: wrap; gap: 8px; align-items: center; justify-content: end"
|
||||
>
|
||||
<ElButton @click="commentWallOpen = true">
|
||||
<FaSvgIcon icon="ri:message-3-line" class="mr-1" />
|
||||
留言墙
|
||||
</ElButton>
|
||||
<ElButton type="primary" @click="toAddArticle" v-auth="'add'">新增文章</ElButton>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
|
||||
<div class="mt-5">
|
||||
<div
|
||||
class="grid grid-cols-5 gap-5 max-2xl:grid-cols-4 max-xl:grid-cols-3 max-lg:grid-cols-2 max-sm:grid-cols-1"
|
||||
>
|
||||
<div
|
||||
class="group c-p overflow-hidden border border-g-300/60 rounded-custom-sm"
|
||||
v-for="item in articleList"
|
||||
:key="item.id"
|
||||
@click="toDetail(item)"
|
||||
>
|
||||
<div class="relative aspect-[16/9.5]">
|
||||
<ElImage
|
||||
class="flex align-center justify-center w-full h-full object-cover bg-gray-200"
|
||||
:src="item.home_img"
|
||||
lazy
|
||||
fit="cover"
|
||||
></ElImage>
|
||||
|
||||
<span class="absolute top-1 right-1 bg-black/50 rounded text-xs px-1 py-0.5 text-white">
|
||||
{{ item.type_name }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="px-2 py-1">
|
||||
<h2 class="text-base text-g-800 font-medium">{{ item.title }}</h2>
|
||||
<div class="flex-b w-full h-6 mt-1">
|
||||
<div class="flex-c text-g-500">
|
||||
<FaSvgIcon icon="ri:time-line" class="mr-1 text-sm" />
|
||||
<span class="text-sm">{{ useDateFormat(item.create_time, "YYYY-MM-DD") }}</span>
|
||||
<div class="w-px h-3 bg-g-400 mx-3.5"></div>
|
||||
<FaSvgIcon icon="ri:eye-line" class="mr-1 text-sm" />
|
||||
<span class="text-sm">{{ item.count }}</span>
|
||||
</div>
|
||||
<ElButton
|
||||
class="opacity-0 group-hover:opacity-100"
|
||||
v-auth="'edit'"
|
||||
size="small"
|
||||
@click.stop="toEdit(item)"
|
||||
>
|
||||
编辑
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 16vh" v-if="showEmpty">
|
||||
<ElEmpty :description="`未找到相关数据 ${EmojiText[0]}`" />
|
||||
</div>
|
||||
|
||||
<div style="display: flex; justify-content: center; margin-top: 20px">
|
||||
<ElPagination
|
||||
size="default"
|
||||
background
|
||||
v-model:current-page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:pager-count="9"
|
||||
layout="prev, pager, next, total,jumper"
|
||||
:total="total"
|
||||
:hide-on-single-page="true"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ArticleDetailDrawer
|
||||
v-model="detailDrawerOpen"
|
||||
:article-id="detailArticleId"
|
||||
@edit="onDetailEdit"
|
||||
@open-comment-wall="onOpenCommentFromDetail"
|
||||
/>
|
||||
|
||||
<ArticleCommentWallDrawer v-model="commentWallOpen" />
|
||||
|
||||
<ArticlePublishDrawer
|
||||
v-model="publishDrawerOpen"
|
||||
:edit-id="publishEditId"
|
||||
@success="onPublishSuccess"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Search } from "@element-plus/icons-vue";
|
||||
import ArticleCommentWallDrawer from "../components/ArticleCommentWallDrawer.vue";
|
||||
import ArticleDetailDrawer from "../components/ArticleDetailDrawer.vue";
|
||||
import ArticlePublishDrawer from "../components/ArticlePublishDrawer.vue";
|
||||
import { useDateFormat } from "@vueuse/core";
|
||||
import { EmojiText } from "@utils/ui";
|
||||
import { ArticleList } from "@/mock/temp/articleList";
|
||||
import { useCommon } from "@/hooks/core/useCommon";
|
||||
|
||||
defineOptions({ name: "ArticleList" });
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
interface Article {
|
||||
id: number;
|
||||
home_img: string;
|
||||
type_name: string;
|
||||
title: string;
|
||||
create_time: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface GetArticleListOptions {
|
||||
backTop?: boolean;
|
||||
}
|
||||
|
||||
const YEAR_OPTIONS = ["All", "2024", "2023", "2022", "2021", "2020", "2019"];
|
||||
const PAGE_SIZE = 40;
|
||||
|
||||
const yearVal = ref("All");
|
||||
const searchVal = ref("");
|
||||
const articleList = ref<Article[]>([]);
|
||||
const currentPage = ref(1);
|
||||
const pageSize = ref(PAGE_SIZE);
|
||||
const total = ref(0);
|
||||
const isLoading = ref(true);
|
||||
|
||||
const showEmpty = computed(() => articleList.value.length === 0 && !isLoading.value);
|
||||
|
||||
const detailDrawerOpen = ref(false);
|
||||
const detailArticleId = ref<number | null>(null);
|
||||
|
||||
const publishDrawerOpen = ref(false);
|
||||
const publishEditId = ref<number | null>(null);
|
||||
|
||||
const commentWallOpen = ref(false);
|
||||
|
||||
const onPublishSuccess = () => {
|
||||
getArticleList({ backTop: false });
|
||||
};
|
||||
|
||||
const syncCommentWallQuery = () => {
|
||||
const q = { ...route.query } as Record<string, string | string[]>;
|
||||
if (q.commentWall === "1") {
|
||||
commentWallOpen.value = true;
|
||||
delete q.commentWall;
|
||||
router.replace({ path: route.path, query: q });
|
||||
}
|
||||
};
|
||||
|
||||
const onOpenCommentFromDetail = () => {
|
||||
detailDrawerOpen.value = false;
|
||||
nextTick(() => {
|
||||
commentWallOpen.value = true;
|
||||
});
|
||||
};
|
||||
|
||||
const onDetailEdit = (id: number) => {
|
||||
detailDrawerOpen.value = false;
|
||||
nextTick(() => {
|
||||
publishEditId.value = id;
|
||||
publishDrawerOpen.value = true;
|
||||
});
|
||||
};
|
||||
|
||||
const getArticleList = async ({ backTop = false }: GetArticleListOptions = {}) => {
|
||||
isLoading.value = true;
|
||||
|
||||
try {
|
||||
if (searchVal.value) {
|
||||
yearVal.value = "All";
|
||||
}
|
||||
|
||||
// TODO: 替换为真实 API 调用
|
||||
// const params = {
|
||||
// page: currentPage.value,
|
||||
// size: pageSize.value,
|
||||
// searchVal: searchVal.value,
|
||||
// year: yearVal.value === 'All' ? '' : yearVal.value
|
||||
// }
|
||||
// const res = await ArticleService.getArticleList(params)
|
||||
|
||||
articleList.value = ArticleList as Article[];
|
||||
|
||||
if (backTop) {
|
||||
useCommon().scrollToTop();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取文章列表失败:", error);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const searchArticle = () => {
|
||||
currentPage.value = 1;
|
||||
getArticleList({ backTop: true });
|
||||
};
|
||||
|
||||
const searchArticleByYear = () => {
|
||||
currentPage.value = 1;
|
||||
getArticleList({ backTop: true });
|
||||
};
|
||||
|
||||
const handleCurrentChange = (val: number) => {
|
||||
currentPage.value = val;
|
||||
getArticleList({ backTop: true });
|
||||
};
|
||||
|
||||
const toDetail = (item: Article) => {
|
||||
detailArticleId.value = item.id;
|
||||
detailDrawerOpen.value = true;
|
||||
};
|
||||
|
||||
const toEdit = (item: Article) => {
|
||||
publishEditId.value = item.id;
|
||||
publishDrawerOpen.value = true;
|
||||
};
|
||||
|
||||
const toAddArticle = () => {
|
||||
publishEditId.value = null;
|
||||
publishDrawerOpen.value = true;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
getArticleList();
|
||||
syncCommentWallQuery();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => route.query.commentWall,
|
||||
(v) => {
|
||||
if (v === "1") syncCommentWallQuery();
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.custom-segmented .el-segmented {
|
||||
height: 40px;
|
||||
padding: 6px;
|
||||
|
||||
--el-border-radius-base: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,46 @@
|
||||
<!-- 项目版本更新日志(mock) -->
|
||||
<template>
|
||||
<div class="mx-auto pt-5 mb-5">
|
||||
<h3 class="text-2xl font-medium text-g-900 mb-8">{{ $t("menus.changelog.title") }}</h3>
|
||||
|
||||
<div class="space-y-5">
|
||||
<div
|
||||
v-for="item in upgradeLogList"
|
||||
:key="item.version"
|
||||
class="fa-card-sm rounded-lg p-6 transition-shadow max-md:p-4"
|
||||
>
|
||||
<div class="flex-cb gap-3 mb-4 flex-wrap">
|
||||
<span class="px-3 py-1 bg-theme/10 text-theme text-sm font-medium rounded-full">
|
||||
{{ item.version }}
|
||||
</span>
|
||||
<span class="text-sm text-g-500">{{ item.date }}</span>
|
||||
</div>
|
||||
|
||||
<h4 class="text-lg font-medium text-g-900 mb-3">{{ item.title }}</h4>
|
||||
|
||||
<ul v-if="item.detail?.length" class="space-y-2 mb-4">
|
||||
<li
|
||||
v-for="(detail, index) in item.detail"
|
||||
:key="index"
|
||||
class="flex-c gap-2 text-sm text-g-600"
|
||||
>
|
||||
<span class="mt-0.5">•</span>
|
||||
<span class="text-g-700">{{ detail }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div v-if="item.remark" class="text-sm text-g-800 bg-g-300/60 rounded p-3 mb-3">
|
||||
{{ item.remark }}
|
||||
</div>
|
||||
|
||||
<ElTag v-if="item.requireReLogin" type="warning" size="small">需要重新登录</ElTag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { upgradeLogList } from "@/mock/upgrade/changeLog";
|
||||
|
||||
defineOptions({ name: "SystemChangeLog" });
|
||||
</script>
|
||||
@@ -0,0 +1,620 @@
|
||||
<!-- 个人中心(Art 模版布局 + 当前用户接口) -->
|
||||
<template>
|
||||
<div class="w-full h-full p-0 bg-transparent border-none shadow-none">
|
||||
<div class="relative flex-b mt-2.5 max-md:block max-md:mt-1">
|
||||
<!-- 左侧卡片 -->
|
||||
<div class="w-112 mr-5 max-md:w-full max-md:mr-0">
|
||||
<div class="fa-card-sm relative p-9 pb-6 overflow-hidden text-center">
|
||||
<img
|
||||
class="absolute top-0 left-0 w-full h-50 object-cover"
|
||||
src="@imgs/user/bg.webp"
|
||||
alt=""
|
||||
/>
|
||||
|
||||
<div class="relative z-10 mt-30 mx-auto">
|
||||
<div class="relative inline-block">
|
||||
<img
|
||||
v-if="infoFormState.avatar"
|
||||
class="w-20 h-20 object-cover border-2 border-white rounded-full"
|
||||
:src="infoFormState.avatar"
|
||||
alt=""
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
class="w-20 h-20 object-cover border-2 border-white rounded-full"
|
||||
src="@imgs/user/avatar.webp"
|
||||
alt=""
|
||||
/>
|
||||
<ElUpload
|
||||
ref="uploadRef"
|
||||
v-model:file-list="fileList"
|
||||
class="profile-avatar-upload"
|
||||
name="file"
|
||||
:show-file-list="false"
|
||||
:before-upload="handleBeforeUpload"
|
||||
:http-request="handleUpload"
|
||||
:limit="1"
|
||||
:auto-upload="false"
|
||||
@change="handleAvatarFileChange"
|
||||
>
|
||||
<template #trigger>
|
||||
<ElButton
|
||||
type="primary"
|
||||
:icon="Camera"
|
||||
circle
|
||||
size="small"
|
||||
class="upload-trigger"
|
||||
/>
|
||||
</template>
|
||||
</ElUpload>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="relative z-10 mt-3 text-sm text-g-600">{{ greeting }}</p>
|
||||
<h2 class="relative z-10 mt-1 text-xl font-normal">{{ infoFormState.name || "—" }}</h2>
|
||||
<p class="relative z-10 mt-2 text-sm text-g-500">
|
||||
{{ infoFormState.roles?.map((r) => r.name).join("、") || " " }}
|
||||
</p>
|
||||
|
||||
<div class="relative z-10 w-75 mx-auto mt-7.5 text-left">
|
||||
<div class="mt-2.5 flex items-start">
|
||||
<FaSvgIcon icon="ri:mail-line" class="text-g-700 shrink-0 mt-0.5" />
|
||||
<span class="ml-2 text-sm break-all">{{ infoFormState.email || "—" }}</span>
|
||||
</div>
|
||||
<div class="mt-2.5 flex items-start">
|
||||
<FaSvgIcon icon="ri:user-3-line" class="text-g-700 shrink-0 mt-0.5" />
|
||||
<span class="ml-2 text-sm">{{ infoFormState.username || "—" }}</span>
|
||||
</div>
|
||||
<div class="mt-2.5 flex items-start">
|
||||
<FaSvgIcon icon="ri:map-pin-line" class="text-g-700 shrink-0 mt-0.5" />
|
||||
<span class="ml-2 text-sm">{{ infoFormState.dept?.name || "—" }}</span>
|
||||
</div>
|
||||
<div class="mt-2.5 flex items-start">
|
||||
<FaSvgIcon icon="ri:briefcase-line" class="text-g-700 shrink-0 mt-0.5" />
|
||||
<span class="ml-2 text-sm">
|
||||
{{ infoFormState.positions?.map((p) => p.name).join("、") || "—" }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="roleTagList.length" class="relative z-10 mt-10">
|
||||
<h3 class="text-sm font-medium">角色</h3>
|
||||
<div class="flex flex-wrap justify-center mt-3.5">
|
||||
<div
|
||||
v-for="item in roleTagList"
|
||||
:key="item"
|
||||
class="py-1 px-1.5 mr-2.5 mb-2.5 text-xs border border-g-300 rounded"
|
||||
>
|
||||
{{ item }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ElDialog
|
||||
v-model="avatarCropVisible"
|
||||
title="裁剪头像"
|
||||
width="640px"
|
||||
append-to-body
|
||||
destroy-on-close
|
||||
@closed="onAvatarCropDialogClosed"
|
||||
>
|
||||
<FaCutterImg
|
||||
v-if="avatarCropVisible && avatarCropSrc"
|
||||
:key="avatarCropSrc"
|
||||
:img-url="avatarCropSrc"
|
||||
:box-width="420"
|
||||
:box-height="340"
|
||||
:cut-width="240"
|
||||
:cut-height="240"
|
||||
:quality="0.92"
|
||||
:tool="true"
|
||||
:show-preview="true"
|
||||
:original-graph="false"
|
||||
file-type="jpeg"
|
||||
title="调整头像"
|
||||
preview-title="预览"
|
||||
@update:img-url="onAvatarCropConfirm"
|
||||
@error="onAvatarCropImgError"
|
||||
/>
|
||||
</ElDialog>
|
||||
|
||||
<!-- 右侧表单 -->
|
||||
<div class="flex-1 overflow-hidden max-md:w-full max-md:mt-3.5">
|
||||
<div class="fa-card-sm">
|
||||
<h1 class="p-4 text-xl font-normal border-b border-g-300">基本设置</h1>
|
||||
|
||||
<ElForm
|
||||
ref="infoFormRef"
|
||||
:model="infoFormState"
|
||||
class="box-border p-5 [&>.el-row_.el-form-item]:w-[calc(50%-10px)] [&>.el-row_.el-input]:w-full [&>.el-row_.el-select]:w-full"
|
||||
:rules="rules"
|
||||
label-width="86px"
|
||||
label-position="top"
|
||||
>
|
||||
<ElRow>
|
||||
<ElFormItem label="姓名" prop="name">
|
||||
<ElInput
|
||||
v-model="infoFormState.name"
|
||||
:disabled="!isEdit"
|
||||
placeholder="请输入姓名"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="性别" prop="gender" class="ml-5">
|
||||
<ElSelect
|
||||
v-model="infoFormState.gender"
|
||||
placeholder="请选择"
|
||||
:disabled="!isEdit"
|
||||
class="w-full"
|
||||
>
|
||||
<ElOption
|
||||
v-for="item in dictDataStore['sys_user_sex']"
|
||||
:key="String(item.dict_value)"
|
||||
:label="item.dict_label"
|
||||
:value="normalizeGenderValue(item.dict_value)"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElRow>
|
||||
|
||||
<ElRow>
|
||||
<ElFormItem label="账号" prop="username">
|
||||
<ElInput v-model="infoFormState.username" disabled placeholder="登录账号" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="邮箱" prop="email" class="ml-5">
|
||||
<ElInput
|
||||
v-model="infoFormState.email"
|
||||
:disabled="!isEdit"
|
||||
placeholder="请输入邮箱"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElRow>
|
||||
|
||||
<ElRow>
|
||||
<ElFormItem label="手机" prop="mobile">
|
||||
<ElInput
|
||||
v-model="infoFormState.mobile"
|
||||
:disabled="!isEdit"
|
||||
placeholder="请输入手机号码"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="部门" class="ml-5">
|
||||
<ElInput :model-value="infoFormState.dept?.name || '—'" disabled />
|
||||
</ElFormItem>
|
||||
</ElRow>
|
||||
|
||||
<div class="flex-c justify-end [&_.el-button]:!w-27.5">
|
||||
<ElButton
|
||||
type="primary"
|
||||
class="w-22.5"
|
||||
:loading="infoSubmitting"
|
||||
v-ripple
|
||||
@click="onBasicToggleSave"
|
||||
>
|
||||
{{ isEdit ? "保存" : "编辑" }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</ElForm>
|
||||
</div>
|
||||
|
||||
<div class="fa-card-sm my-5">
|
||||
<h1 class="p-4 text-xl font-normal border-b border-g-300">更改密码</h1>
|
||||
|
||||
<ElForm
|
||||
ref="passwordFormRef"
|
||||
:model="passwordFormState"
|
||||
class="box-border p-5"
|
||||
:rules="resetPasswordRules"
|
||||
label-width="86px"
|
||||
label-position="top"
|
||||
>
|
||||
<ElFormItem label="当前密码" prop="old_password">
|
||||
<ElInput
|
||||
v-model="passwordFormState.old_password"
|
||||
type="password"
|
||||
:disabled="!isEditPwd"
|
||||
show-password
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="新密码" prop="new_password">
|
||||
<ElInput
|
||||
v-model="passwordFormState.new_password"
|
||||
type="password"
|
||||
:disabled="!isEditPwd"
|
||||
show-password
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="确认新密码" prop="confirm_password">
|
||||
<ElInput
|
||||
v-model="passwordFormState.confirm_password"
|
||||
type="password"
|
||||
:disabled="!isEditPwd"
|
||||
show-password
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<div class="flex-c justify-end [&_.el-button]:!w-27.5">
|
||||
<ElButton
|
||||
type="primary"
|
||||
class="w-22.5"
|
||||
:loading="passwordChanging"
|
||||
v-ripple
|
||||
@click="onPasswordToggleSave"
|
||||
>
|
||||
{{ isEditPwd ? "保存" : "编辑" }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</ElForm>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { FormInstance, UploadRequestOptions, UploadFile } from "element-plus";
|
||||
import type { ElUpload } from "element-plus";
|
||||
import UserAPI, { type InfoFormState, type PasswordFormState } from "@/api/module_system/user";
|
||||
import { useUserStore, useDictStore } from "@stores";
|
||||
import { Camera } from "@element-plus/icons-vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { redirectToLogin } from "@utils/auth";
|
||||
import FaSvgIcon from "@/components/base/fa-svg-icon/index.vue";
|
||||
import FaCutterImg from "@/components/media/fa-cutter-img/index.vue";
|
||||
import { dataURLToFile } from "@utils/file/dataUrl";
|
||||
|
||||
defineOptions({ name: "UserProfile" });
|
||||
|
||||
const { t } = useI18n();
|
||||
const userStore = useUserStore();
|
||||
const dictStore = useDictStore();
|
||||
const infoFormRef = ref<FormInstance>();
|
||||
const passwordFormRef = ref<FormInstance>();
|
||||
|
||||
const infoSubmitting = ref(false);
|
||||
const passwordChanging = ref(false);
|
||||
|
||||
const isEdit = ref(false);
|
||||
const isEditPwd = ref(false);
|
||||
|
||||
const dictDataStore = computed(() => dictStore.dictData);
|
||||
|
||||
const greeting = ref("");
|
||||
|
||||
const roleTagList = computed(() =>
|
||||
(infoFormState.roles ?? [])
|
||||
.map((r) => r.name)
|
||||
.filter((n): n is string => !!n && n.trim().length > 0)
|
||||
);
|
||||
|
||||
const infoFormState = reactive<InfoFormState>({
|
||||
name: undefined,
|
||||
gender: 1,
|
||||
mobile: undefined,
|
||||
email: undefined,
|
||||
username: undefined,
|
||||
dept_name: undefined,
|
||||
dept: {},
|
||||
positions: [],
|
||||
roles: [],
|
||||
avatar: undefined,
|
||||
created_time: undefined,
|
||||
});
|
||||
|
||||
const passwordFormState = reactive<PasswordFormState>({
|
||||
old_password: "",
|
||||
new_password: "",
|
||||
confirm_password: "",
|
||||
});
|
||||
|
||||
const fileList = ref<any[]>([]);
|
||||
const uploadRef = ref<InstanceType<typeof ElUpload>>();
|
||||
|
||||
const avatarCropVisible = ref(false);
|
||||
const avatarCropSrc = ref("");
|
||||
|
||||
function revokeAvatarCropSrc() {
|
||||
if (avatarCropSrc.value.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(avatarCropSrc.value);
|
||||
}
|
||||
avatarCropSrc.value = "";
|
||||
}
|
||||
|
||||
function onAvatarCropDialogClosed() {
|
||||
revokeAvatarCropSrc();
|
||||
}
|
||||
|
||||
function onAvatarCropImgError() {
|
||||
ElMessage.error("图片加载失败,请换一张图重试");
|
||||
}
|
||||
|
||||
async function onAvatarCropConfirm(dataURL: string) {
|
||||
try {
|
||||
const file = dataURLToFile(dataURL, "avatar.jpg");
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const response = await UserAPI.uploadCurrentUserAvatar(formData);
|
||||
|
||||
if (response.data.code === 0 && response.data.data) {
|
||||
const fileUrl = response.data.data.file_url;
|
||||
updateAvatar(fileUrl);
|
||||
uploadRef.value?.clearFiles();
|
||||
fileList.value = [];
|
||||
ElMessage.success("头像已更新,请保存基本设置以同步资料(如需)");
|
||||
avatarCropVisible.value = false;
|
||||
} else {
|
||||
ElMessage.error(response.data.msg || "上传失败");
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error("头像上传失败,请重试");
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeGenderValue(v: string | number | undefined): number {
|
||||
if (v === undefined || v === null || v === "") return 1;
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : 1;
|
||||
}
|
||||
|
||||
const getOptions = async () => {
|
||||
await dictStore.getDict(["sys_user_sex"]);
|
||||
};
|
||||
|
||||
const rules = {
|
||||
name: [{ required: true, message: "请输入姓名", trigger: "blur" }],
|
||||
mobile: [
|
||||
{
|
||||
validator: (_: unknown, v: string, cb: (e?: Error) => void) => {
|
||||
const s = v != null ? String(v).trim() : "";
|
||||
if (!s) return cb();
|
||||
if (!/^1[3-9]\d{9}$/.test(s)) {
|
||||
cb(new Error("请输入有效的手机号格式"));
|
||||
return;
|
||||
}
|
||||
cb();
|
||||
},
|
||||
trigger: "blur",
|
||||
},
|
||||
],
|
||||
email: [
|
||||
{
|
||||
validator: (_: unknown, v: string, cb: (e?: Error) => void) => {
|
||||
const s = v != null ? String(v).trim() : "";
|
||||
if (!s) return cb();
|
||||
if (!/\w[-\w.+]*@([A-Za-z0-9][-A-Za-z0-9]+\.)+[A-Za-z]{2,14}/.test(s)) {
|
||||
cb(new Error("请输入有效的邮箱格式"));
|
||||
return;
|
||||
}
|
||||
cb();
|
||||
},
|
||||
trigger: "blur",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const resetPasswordRules = {
|
||||
old_password: [
|
||||
{
|
||||
required: true,
|
||||
trigger: "blur",
|
||||
message: t("login.message.password.currentRequired"),
|
||||
},
|
||||
],
|
||||
new_password: [
|
||||
{
|
||||
required: true,
|
||||
trigger: "blur",
|
||||
message: t("login.message.password.required"),
|
||||
},
|
||||
{
|
||||
min: 6,
|
||||
message: t("login.message.password.min"),
|
||||
trigger: "blur",
|
||||
},
|
||||
],
|
||||
confirm_password: [
|
||||
{
|
||||
required: true,
|
||||
trigger: "blur",
|
||||
message: t("login.message.password.required"),
|
||||
},
|
||||
{
|
||||
min: 6,
|
||||
message: t("login.message.password.min"),
|
||||
trigger: "blur",
|
||||
},
|
||||
{
|
||||
validator: (_: unknown, value: string) => {
|
||||
return value === passwordFormState.new_password;
|
||||
},
|
||||
trigger: "blur",
|
||||
message: t("login.message.password.inconformity"),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function refreshGreeting() {
|
||||
const h = new Date().getHours();
|
||||
if (h >= 6 && h < 9) greeting.value = "早上好";
|
||||
else if (h >= 9 && h < 11) greeting.value = "上午好";
|
||||
else if (h >= 11 && h < 13) greeting.value = "中午好";
|
||||
else if (h >= 13 && h < 18) greeting.value = "下午好";
|
||||
else if (h >= 18 && h < 24) greeting.value = "晚上好";
|
||||
else greeting.value = "很晚了,早点休息";
|
||||
}
|
||||
|
||||
const handleBeforeUpload = (file: File) => {
|
||||
const isImage = file.type.startsWith("image/");
|
||||
const isLt2M = file.size / 1024 / 1024 < 2;
|
||||
|
||||
if (!isImage) {
|
||||
ElMessage.error("只能上传图片文件");
|
||||
return false;
|
||||
}
|
||||
if (!isLt2M) {
|
||||
ElMessage.error("上传图片大小不能超过 2MB!");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleUpload = async (options: UploadRequestOptions) => {
|
||||
try {
|
||||
const file = options.file;
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const response = await UserAPI.uploadCurrentUserAvatar(formData);
|
||||
|
||||
if (response.data.code === 0 && response.data.data) {
|
||||
const fileUrl = response.data.data.file_url;
|
||||
updateAvatar(fileUrl);
|
||||
options.onSuccess(response);
|
||||
uploadRef.value?.clearFiles();
|
||||
fileList.value = [];
|
||||
ElMessage.success("头像已更新,请保存基本设置以同步资料(如需)");
|
||||
} else {
|
||||
const errorMsg = response.data.msg || "上传失败";
|
||||
ElMessage.error(errorMsg);
|
||||
options.onError({
|
||||
...new Error(errorMsg),
|
||||
status: response.status || 500,
|
||||
method: "POST",
|
||||
url: "/system/user/current/avatar/upload",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error("头像上传失败,请重试");
|
||||
const errorObj = error instanceof Error ? error : new Error(String(error));
|
||||
options.onError({
|
||||
...errorObj,
|
||||
status: 500,
|
||||
method: "POST",
|
||||
url: "/system/user/current/avatar/upload",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleAvatarFileChange = (file: UploadFile) => {
|
||||
if (!file.raw) {
|
||||
return;
|
||||
}
|
||||
if (!handleBeforeUpload(file.raw)) {
|
||||
uploadRef.value?.clearFiles();
|
||||
fileList.value = [];
|
||||
return;
|
||||
}
|
||||
revokeAvatarCropSrc();
|
||||
avatarCropSrc.value = URL.createObjectURL(file.raw);
|
||||
avatarCropVisible.value = true;
|
||||
uploadRef.value?.clearFiles();
|
||||
fileList.value = [];
|
||||
};
|
||||
|
||||
const updateAvatar = (fileUrl: string) => {
|
||||
if (fileUrl) {
|
||||
infoFormState.avatar = fileUrl;
|
||||
} else {
|
||||
ElMessage.error("无效的头像URL");
|
||||
}
|
||||
};
|
||||
|
||||
const initInfoForm = () => {
|
||||
const basicInfo = userStore.basicInfo;
|
||||
Object.assign(infoFormState, { ...basicInfo });
|
||||
};
|
||||
|
||||
const initPasswordForm = () => {
|
||||
Object.assign(passwordFormState, {
|
||||
old_password: "",
|
||||
new_password: "",
|
||||
confirm_password: "",
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
infoSubmitting.value = true;
|
||||
const valid = await infoFormRef.value?.validate().catch(() => false);
|
||||
if (!valid) {
|
||||
return false;
|
||||
}
|
||||
const response = await UserAPI.updateCurrentUserInfo({ ...infoFormState });
|
||||
await userStore.setUserInfo(response.data.data);
|
||||
initInfoForm();
|
||||
ElMessage.success("个人资料已保存");
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return false;
|
||||
} finally {
|
||||
infoSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasswordChange = async () => {
|
||||
try {
|
||||
passwordChanging.value = true;
|
||||
const valid = await passwordFormRef.value?.validate().catch(() => false);
|
||||
if (!valid) {
|
||||
return false;
|
||||
}
|
||||
const response = await UserAPI.changeCurrentUserPassword(passwordFormState);
|
||||
initPasswordForm();
|
||||
await redirectToLogin(response.data.msg);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return false;
|
||||
} finally {
|
||||
passwordChanging.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
async function onBasicToggleSave() {
|
||||
if (!isEdit.value) {
|
||||
isEdit.value = true;
|
||||
return;
|
||||
}
|
||||
const ok = await handleSave();
|
||||
if (ok) {
|
||||
isEdit.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onPasswordToggleSave() {
|
||||
if (!isEditPwd.value) {
|
||||
isEditPwd.value = true;
|
||||
initPasswordForm();
|
||||
return;
|
||||
}
|
||||
await handlePasswordChange();
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
refreshGreeting();
|
||||
await getOptions();
|
||||
initInfoForm();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.profile-avatar-upload {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 2;
|
||||
|
||||
:deep(.el-upload) {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.upload-trigger {
|
||||
box-shadow: 0 1px 4px rgb(0 0 0 / 15%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,149 @@
|
||||
<template>
|
||||
<div class="pt-24 px-20 max-md:!pt-15 max-sm:!px-5 max-md:!px-8 bg-transparent !border-none">
|
||||
<div class="mb-10 text-center">
|
||||
<h1
|
||||
class="mb-2 text-4xl font-medium max-sm:text-3xl flex flex-wrap justify-center items-baseline gap-x-2 gap-y-1"
|
||||
>
|
||||
<span>超过</span>
|
||||
<FaCountTo
|
||||
class="text-4xl font-medium max-sm:text-3xl"
|
||||
:target="53476"
|
||||
:duration="2000"
|
||||
separator=","
|
||||
/>
|
||||
<span>位信赖的开发者</span>
|
||||
</h1>
|
||||
<h2 class="mb-2.5 text-2xl font-normal text-g-600 max-sm:text-2xl">以及众多科技巨头的选择</h2>
|
||||
<div class="gap-2 flex-cc mt-2.5 max-sm:mt-3.5 max-sm:!block">
|
||||
<p class="text-sm italic text-g-600 max-sm:text-sm">
|
||||
本项目基于 MIT 协议开源免费,当前页面为定价模板,仅作演示用途
|
||||
</p>
|
||||
<ElTag type="success" size="large" round class="max-sm:mt-3.5">免费商用</ElTag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-20 max-md:mt-0">
|
||||
<ElRow :gutter="20" justify="center">
|
||||
<ElCol v-for="plan in pricingPlans" :key="plan.type" :xs="24" :sm="12" :md="6" class="mb-5">
|
||||
<ElCard class="flex flex-col h-full rounded-xl">
|
||||
<div class="mb-5">
|
||||
<h3 class="mb-2.5 text-xl font-medium">{{ plan.title }}</h3>
|
||||
<p
|
||||
class="h-10 pb-5 mb-5 overflow-hidden text-sm text-g-600 text-ellipsis border-b border-g-300/80 line-clamp-2"
|
||||
>
|
||||
{{ plan.description }}
|
||||
</p>
|
||||
<div class="mt-7.5 flex flex-wrap items-baseline gap-x-1">
|
||||
<FaCountTo
|
||||
class="text-3xl font-medium"
|
||||
:target="plan.price"
|
||||
:duration="1400"
|
||||
prefix="¥"
|
||||
:decimals="0"
|
||||
/>
|
||||
<span class="ml-2 text-sm text-g-600">/一次性付款</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-grow mb-5">
|
||||
<div
|
||||
v-for="(feature, index) in plan.features"
|
||||
:key="index"
|
||||
class="flex-c mb-2.5 text-sm"
|
||||
>
|
||||
<ElIcon class="mr-2.5" :class="feature.available ? '!text-theme' : '!text-danger'">
|
||||
<Check v-if="feature.available" />
|
||||
<Close v-else />
|
||||
</ElIcon>
|
||||
<span>{{ feature.text }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-auto text-center">
|
||||
<ElButton type="primary" class="w-full h-10" v-ripple>立即购买</ElButton>
|
||||
</div>
|
||||
</ElCard>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { Check, Close } from "@element-plus/icons-vue";
|
||||
|
||||
defineOptions({ name: "DashboardPricing" });
|
||||
|
||||
interface Feature {
|
||||
text: string;
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
interface PricingPlan {
|
||||
type: string;
|
||||
title: string;
|
||||
description: string;
|
||||
price: number;
|
||||
features: Feature[];
|
||||
}
|
||||
|
||||
const pricingPlans = ref<PricingPlan[]>([
|
||||
{
|
||||
type: "single",
|
||||
title: "单次使用版",
|
||||
description: "适用于单个最终产品,最终用户无需付费。",
|
||||
price: 349,
|
||||
features: [
|
||||
{ text: "完整源代码", available: true },
|
||||
{ text: "技术文档", available: true },
|
||||
{ text: "SaaS应用授权", available: false },
|
||||
{ text: "单个项目使用", available: true },
|
||||
{ text: "一年技术支持", available: true },
|
||||
{ text: "一年免费更新", available: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "multiple",
|
||||
title: "多次使用版",
|
||||
description: "适用于无限个最终产品,最终用户无需付费。",
|
||||
price: 629,
|
||||
features: [
|
||||
{ text: "完整源代码", available: true },
|
||||
{ text: "技术文档", available: true },
|
||||
{ text: "SaaS应用授权", available: false },
|
||||
{ text: "无限项目使用", available: true },
|
||||
{ text: "一年技术支持", available: true },
|
||||
{ text: "一年免费更新", available: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "extended",
|
||||
title: "扩展授权版",
|
||||
description: "适用于单个最终产品,最终用户需要付费。",
|
||||
price: 2099,
|
||||
features: [
|
||||
{ text: "完整源代码", available: true },
|
||||
{ text: "技术文档", available: true },
|
||||
{ text: "SaaS应用授权", available: true },
|
||||
{ text: "单个项目使用", available: true },
|
||||
{ text: "一年技术支持", available: true },
|
||||
{ text: "一年免费更新", available: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "unlimited",
|
||||
title: "无限授权版",
|
||||
description: "适用于无限个最终产品,最终用户需要付费。",
|
||||
price: 3499,
|
||||
features: [
|
||||
{ text: "完整源代码", available: true },
|
||||
{ text: "技术文档", available: true },
|
||||
{ text: "SaaS应用授权", available: true },
|
||||
{ text: "无限项目使用", available: true },
|
||||
{ text: "一年技术支持", available: true },
|
||||
{ text: "一年免费更新", available: true },
|
||||
],
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,574 @@
|
||||
/**
|
||||
* 功能验收手册:除「用户 / 角色」在 `index.vue` 中详述外,其余页用「完整性验收」结构展示。
|
||||
*
|
||||
* **何为完整性**:`notes` 与当前页**已实现**的界面与操作项对应,用于验收时逐条核对**是否漏项**(不评价交互好坏)。
|
||||
* **与后端菜单对齐**:标题与 `sys_menu.json` 中菜单页 `component_path` 一致;壳层/静态页见各条说明。
|
||||
* **`path` 字段**:默认可视为 `src/views/` 下相对路径;`../layouts` 与 `locales` 等见各条。
|
||||
*/
|
||||
export type CompactManualPage = {
|
||||
anchor: string;
|
||||
title: string;
|
||||
path: string;
|
||||
notes: string[];
|
||||
};
|
||||
|
||||
export type ManualModuleSection = {
|
||||
anchor: string;
|
||||
heading: string;
|
||||
pkgTag?: string;
|
||||
pages: CompactManualPage[];
|
||||
};
|
||||
|
||||
/** 系统管理模块内、接在「角色管理」后的页面 */
|
||||
export const MANUAL_SYSTEM_TAIL_PAGES: CompactManualPage[] = [
|
||||
{
|
||||
anchor: "page-menu",
|
||||
title: "菜单管理",
|
||||
path: "module_system/menu/index.vue",
|
||||
notes: [
|
||||
"检索:FaSearchBar(菜单名称等)。",
|
||||
"分区:Tab「PC 桌面菜单管理 / APP 移动端菜单管理」两套树数据。",
|
||||
"主区:树形表格(勾选、行点击);展开/收起工具按钮。",
|
||||
"工具栏:新增、批量删除、批量更多(patch)、刷新、列配置、显示搜索栏。",
|
||||
"弹层:详情 Descriptions;新增/编辑 Drawer(目录 / 菜单 / 按钮 / 外链等类型与路由、图标、权限标识)。",
|
||||
"权限:`module_system:menu:create|delete|patch` + 行级 detail/update/delete。",
|
||||
],
|
||||
},
|
||||
{
|
||||
anchor: "page-dept",
|
||||
title: "部门管理",
|
||||
path: "module_system/dept/index.vue",
|
||||
notes: [
|
||||
"检索:FaSearchBar。",
|
||||
"主区:树形表格(勾选);展开/收起。",
|
||||
"工具栏:新增、批量删除、批量更多(patch)、刷新、列配置、搜索栏切换。",
|
||||
"弹层:详情;新增/编辑 Dialog(名称、编码、上级、状态等)。",
|
||||
"权限:`module_system:dept:create|delete|patch` + 行 detail/update/delete。",
|
||||
],
|
||||
},
|
||||
{
|
||||
anchor: "page-position",
|
||||
title: "岗位管理",
|
||||
path: "module_system/position/index.vue",
|
||||
notes: [
|
||||
"检索:FaSearchBar(含创建人 UserTableSelect 槽)。",
|
||||
"主区:分页表格。",
|
||||
"工具栏:新增、导出、批量删除、批量更多(patch)、刷新、列配置。",
|
||||
"弹层:详情;新增/编辑 Dialog;导出 FaExportDialog。",
|
||||
"权限:`module_system:position:create|export|delete|patch` + 行 detail/update/delete。",
|
||||
],
|
||||
},
|
||||
{
|
||||
anchor: "page-dict",
|
||||
title: "字典管理",
|
||||
path: "module_system/dict/index.vue",
|
||||
notes: [
|
||||
"检索:字典类型 FaSearchBar + 分页表。",
|
||||
"工具栏:新增类型、导出类型、批量删除、批量更多(patch)、刷新、列配置。",
|
||||
"类型弹层:详情 / 新增编辑 Dialog。",
|
||||
"子层:`DataDrawer` 维护某字典类型下的字典数据项(二级)。",
|
||||
"权限:`module_system:dict_type:*` 与字典数据接口权限(见行内按钮)。",
|
||||
],
|
||||
},
|
||||
{
|
||||
anchor: "page-param",
|
||||
title: "参数配置",
|
||||
path: "module_system/param/index.vue",
|
||||
notes: [
|
||||
"检索:FaSearchBar + 分页表。",
|
||||
"工具栏:新增、导出、批量删除、刷新、列配置(无 patch 批量入口)。",
|
||||
"弹层:详情;新增/编辑 Dialog;内置参数标识与表单校验。",
|
||||
"权限:`module_system:param:create|export|delete` + 行级权限。",
|
||||
],
|
||||
},
|
||||
{
|
||||
anchor: "page-notice",
|
||||
title: "通知公告",
|
||||
path: "module_system/notice/index.vue",
|
||||
notes: [
|
||||
"检索:FaSearchBar(含创建人 UserTableSelect)。",
|
||||
"工具栏:新增、导出、批量删除、批量更多(patch)、刷新、列配置。",
|
||||
"弹层:详情;新增/编辑 Dialog;正文为富文本 `FaWangEditor`;HTML 预览。",
|
||||
"权限:`module_system:notice:create|export|delete|patch` + 行级操作。",
|
||||
],
|
||||
},
|
||||
{
|
||||
anchor: "page-tenant",
|
||||
title: "租户管理",
|
||||
path: "module_system/tenant/index.vue",
|
||||
notes: [
|
||||
"检索:FaSearchBar + 分页表。",
|
||||
"工具栏:新增、批量删除、刷新、列配置(无导出/更多 patch 于顶栏)。",
|
||||
"弹层:详情;新增/编辑 Dialog(名称、编码、起止时间、状态、描述等)。",
|
||||
"权限:`module_system:tenant:create|delete` + 行级 update/detail/delete。",
|
||||
],
|
||||
},
|
||||
{
|
||||
anchor: "page-log",
|
||||
title: "操作日志",
|
||||
path: "module_system/log/index.vue",
|
||||
notes: [
|
||||
"检索:FaSearchBar(含创建人 UserTableSelect);登录/操作日志类型在列或详情中区分。",
|
||||
"工具栏:导出、批量删除、刷新、列配置(无新增)。",
|
||||
"弹层:详情 Dialog(请求路径/方法、IP、状态码、耗时、入参出参等大字段)。",
|
||||
"权限:`module_system:log:export|delete` + 行级查看/删除。",
|
||||
],
|
||||
},
|
||||
{
|
||||
anchor: "page-login",
|
||||
title: "登录页",
|
||||
path: "module_system/auth/login/index.vue",
|
||||
notes: [
|
||||
"面板切换:登录 / 注册 / 忘记密码(`setAuthPanel`)。",
|
||||
"登录:账号表单;验证码;记住我;登录成功后 `redirect` 解析跳转。",
|
||||
"静态路由:`/login`;与路由守卫白名单一致。",
|
||||
"其他:注册与忘记密码提交接口;左侧背景与主题组件(视模板)。",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const MANUAL_MODULES_AFTER_SYSTEM: ManualModuleSection[] = [
|
||||
{
|
||||
anchor: "mod-monitor",
|
||||
heading: "二、监控管理",
|
||||
pkgTag: "module_monitor",
|
||||
pages: [
|
||||
{
|
||||
anchor: "page-online",
|
||||
title: "在线用户",
|
||||
path: "module_monitor/online/index.vue",
|
||||
notes: [
|
||||
"检索:FaSearchBar(IP、用户名、登录地点等)。",
|
||||
"工具栏:「强退所有」(权限 `module_monitor:online:delete`);刷新、列配置、搜索栏。",
|
||||
"行操作:单条会话「强退」。",
|
||||
"主区:分页表格(session_id 等列);无批量勾选删除入口(仅有单条强退)。",
|
||||
],
|
||||
},
|
||||
{
|
||||
anchor: "page-cache",
|
||||
title: "缓存管理",
|
||||
path: "module_monitor/cache/index.vue",
|
||||
notes: [
|
||||
"Tab「监控信息」:Redis 监控指标 Descriptions(版本、内存、CPU、Key 数量等)。",
|
||||
"Tab「缓存管理」:缓存列表/键值查看与按规则清理(与后端接口一致)。",
|
||||
"完整性:两 Tab 均需点开核对是否加载成功、清理是否有二次确认。",
|
||||
],
|
||||
},
|
||||
{
|
||||
anchor: "page-resource",
|
||||
title: "文件管理",
|
||||
path: "module_monitor/resource/index.vue",
|
||||
notes: [
|
||||
"检索:FaSearchBar;路径面包屑导航进入子目录。",
|
||||
"工具栏:上传、新建文件夹、下载、删除等(`module_monitor:resource:*` 按钮权限);刷新、列配置。",
|
||||
"主区:文件表格;与后端资源浏览一致(菜单名「文件管理」,component `resource`)。",
|
||||
],
|
||||
},
|
||||
{
|
||||
anchor: "page-server",
|
||||
title: "服务监控",
|
||||
path: "module_monitor/server/index.vue",
|
||||
notes: [
|
||||
"布局:多卡片 Dashboard(CPU、内存、磁盘、JVM/服务器信息等,依接口返回)。",
|
||||
"完整性:各卡片指标是否有数值;加载失败时占位或提示。",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
anchor: "mod-task",
|
||||
heading: "三、任务管理",
|
||||
pkgTag: "module_task",
|
||||
pages: [
|
||||
{
|
||||
anchor: "page-cronjob",
|
||||
title: "调度器监控",
|
||||
path: "module_task/cronjob/job/index.vue",
|
||||
notes: [
|
||||
"顶部状态区:调度器运行状态、任务数量;按钮:启动/暂停/恢复/关闭/清空任务/控制台/同步/刷新(各 `module_task:cronjob:job:*`)。",
|
||||
"主区:任务卡片列表 — `getSchedulerJobs` 全量返回,名称与任务状态为前端筛选;单卡触发类型(Cron/间隔等)与暂停、恢复、调试、移除等行操作。",
|
||||
"抽屉:当前任务的执行日志 — `useTable` + `getJobLogList` 分页,日志检索栏,批量删日志等(见页面)。",
|
||||
],
|
||||
},
|
||||
{
|
||||
anchor: "page-cronnode",
|
||||
title: "节点管理",
|
||||
path: "module_task/cronjob/node/index.vue",
|
||||
notes: [
|
||||
"检索:FaSearchBar + 分页表。",
|
||||
"工具栏:新增、批量删除、刷新、列配置。",
|
||||
"弹层:FaDialog + 分栏(表单 + 扩展配置区);编辑节点连接与执行参数。",
|
||||
"权限:`module_task:cronjob:node:create|delete` + 行级 update/detail/delete。",
|
||||
],
|
||||
},
|
||||
{
|
||||
anchor: "page-workflow",
|
||||
title: "流程编排",
|
||||
path: "module_task/workflow/definition/index.vue",
|
||||
notes: [
|
||||
"检索:FaSearchBar(可展开)。",
|
||||
"工具栏:新增、批量删除、刷新、列配置。",
|
||||
"行操作:草稿「发布」;已发布「执行」下拉;「编辑」打开 `WorkflowDesignDrawer` 画布;删除。",
|
||||
"权限:`module_task:workflow:definition:create|delete|update|execute` 等(见行内 v-hasPerm)。",
|
||||
],
|
||||
},
|
||||
{
|
||||
anchor: "page-nodetype",
|
||||
title: "编排节点类型",
|
||||
path: "module_task/workflow/node-type/index.vue",
|
||||
notes: [
|
||||
"检索:FaSearchBar(可展开)。",
|
||||
"工具栏:新增、批量删除、刷新、列配置。",
|
||||
"行操作:编辑(打开表单/脚本配置)、删除等(见 `node-type-operation` 槽)。",
|
||||
"权限:`module_task:workflow:node-type:create|delete|update` + 行级操作。",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
anchor: "mod-ai",
|
||||
heading: "四、AI 模块",
|
||||
pkgTag: "module_ai",
|
||||
pages: [
|
||||
{
|
||||
anchor: "page-ai-chat",
|
||||
title: "AI智能助手",
|
||||
path: "module_ai/chat/index.vue",
|
||||
notes: [
|
||||
"布局:左侧 `Sidebar`(会话列表、新建会话);右侧 `ChatNavbar` + `ChatMessages` + `ChatInput`。",
|
||||
"连接:`toggleConnection`、连接状态展示;清空对话、侧栏折叠。",
|
||||
"完整性:发消息、收消息列表滚动、错误条展示与关闭;流式与否依赖后端/WebSocket 对接。",
|
||||
],
|
||||
},
|
||||
{
|
||||
anchor: "page-ai-fachat",
|
||||
title: "会话聊天",
|
||||
path: "module_ai/fachat/index.vue",
|
||||
notes: [
|
||||
"布局:左侧联系人列表(搜索、排序下拉、在线点);右侧头部「Art Bot」状态与语音/视频等图标按钮;中部消息气泡列表;底部输入框与发送。",
|
||||
"完整性:选人切换会话、发送消息、滚动消息区;当前多为演示数据与本地发送逻辑。",
|
||||
],
|
||||
},
|
||||
{
|
||||
anchor: "page-ai-memory",
|
||||
title: "会话记忆",
|
||||
path: "module_ai/memory/index.vue",
|
||||
notes: [
|
||||
"检索:FaSearchBar + 分页表。",
|
||||
"工具栏:新增、批量删除、刷新、列配置;权限锚点 `module_ai:chat:create|delete`(与后端标识一致)。",
|
||||
"弹层:详情与表单 Drawer(会话记忆字段);完整性:CRUD 与分页均需验证。",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
anchor: "mod-generator",
|
||||
heading: "五、代码生成器",
|
||||
pkgTag: "module_generator",
|
||||
pages: [
|
||||
{
|
||||
anchor: "page-gencode",
|
||||
title: "代码生成",
|
||||
path: "module_generator/gencode/index.vue",
|
||||
notes: [
|
||||
"检索:FaSearchBar + 库表分页列表;勾选表格行。",
|
||||
"工具栏:创建表、导入库表、批量删除、批量生成(`module_generator:gencode:*`)。",
|
||||
"弹层:`CreateTableDialog`、导入 DB、`GenCodeDrawer` 多步(导入字段、生成配置、预览代码)。",
|
||||
"完整性:预览、下载 zip、生成到磁盘等按钮是否随权限显示且可点。",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
anchor: "mod-app",
|
||||
heading: "六、应用管理",
|
||||
pkgTag: "module_application",
|
||||
pages: [
|
||||
{
|
||||
anchor: "page-portal",
|
||||
title: "插件市场",
|
||||
path: "module_application/portal/index.vue",
|
||||
notes: [
|
||||
"检索:FaSearchBar(含创建人、更新人 UserTableSelect)。",
|
||||
"工具栏:标题「应用市场」+「创建应用」(`module_application:portal:create`);刷新、搜索栏。",
|
||||
"主区:卡片网格(图标、名称、状态、描述);卡片脚编辑/删除(update/delete);底部分页器。",
|
||||
"完整性:空列表 ElEmpty;卡片点击进入内部打开逻辑(见 `openAppInternal`)。",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
anchor: "mod-example",
|
||||
heading: "七、示例模块",
|
||||
pkgTag: "module_example",
|
||||
pages: [
|
||||
{
|
||||
anchor: "page-demo",
|
||||
title: "示例管理",
|
||||
path: "module_example/demo/index.vue",
|
||||
notes: [
|
||||
"标准列表 CRUD 示例页:检索、分页表、工具栏、行操作、弹窗表单(与本项目其它模块同一套 Art 组件)。",
|
||||
"完整性:按页面可见按钮与表单字段逐项点验即可。",
|
||||
],
|
||||
},
|
||||
{
|
||||
anchor: "page-demo01",
|
||||
title: "三级菜单",
|
||||
path: "module_example/demo01/index.vue",
|
||||
notes: [
|
||||
"占位或简单展示的叶子页,用于验证多级菜单路由与 KeepAlive。",
|
||||
"完整性:侧栏进入后路由正确、页面无报错即可。",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
anchor: "mod-dashboard",
|
||||
heading: "八、仪表盘",
|
||||
pkgTag: "dashboard",
|
||||
pages: [
|
||||
{
|
||||
anchor: "page-home",
|
||||
title: "首页",
|
||||
path: "dashboard/index.vue",
|
||||
notes: [
|
||||
"路由 `/home`:样式类 `workplace-page` — 问候与用户信息卡片、模块入口网格(按权限灰显)、其它工作台分区(与文件内组件一致)。",
|
||||
"完整性:模块卡片点击跳转、无权限入口禁用或提示;接口失败时有容错。",
|
||||
],
|
||||
},
|
||||
{
|
||||
anchor: "page-profile",
|
||||
title: "个人中心",
|
||||
path: "current/profile.vue",
|
||||
notes: [
|
||||
"路由 `/profile`;左侧头像上传(ElUpload)、资料展示;右侧「基本设置」表单与「更改密码」三块。",
|
||||
"完整性:保存基本信息、修改密码、头像上传回调与校验均已接线。",
|
||||
],
|
||||
},
|
||||
{
|
||||
anchor: "page-changelog",
|
||||
title: "更新日志",
|
||||
path: "changelog/index.vue",
|
||||
notes: [
|
||||
"静态路由 `/changelog`;列表遍历 mock `upgradeLogList`(版本、日期、明细、备注、需重登标签)。",
|
||||
"另有 `module_system/changelog` 视图未挂路由;完整性以本页为准。",
|
||||
],
|
||||
},
|
||||
{
|
||||
anchor: "page-db-workplace",
|
||||
title: "工作台",
|
||||
path: "dashboard/workplace/index.vue",
|
||||
notes: [
|
||||
"仪表盘子路由 `dashboard/workplace`:独立 `dashboard-container` 布局(含 Github 角标、问候区、天气文案、统计卡片与图表等,以页面为准)。",
|
||||
"与 `/home` 所用 `dashboard/index.vue` 不是同一文件;完整性需单独打开该路由逐项看区块是否渲染。",
|
||||
],
|
||||
},
|
||||
{
|
||||
anchor: "page-db-console",
|
||||
title: "控制台",
|
||||
path: "dashboard/console/index.vue",
|
||||
notes: [
|
||||
"控制台仪表盘分区(卡片/图表);meta.fixedTab 等影响标签钉扎行为。",
|
||||
"完整性:各区块渲染与路由切换无报错。",
|
||||
],
|
||||
},
|
||||
{
|
||||
anchor: "page-db-analysis",
|
||||
title: "分析页",
|
||||
path: "dashboard/analysis/index.vue",
|
||||
notes: ["图表与卡片布局演示页;完整性:各图表组件 mount 成功、无控制台报错。"],
|
||||
},
|
||||
{
|
||||
anchor: "page-db-ecommerce",
|
||||
title: "电子商务",
|
||||
path: "dashboard/ecommerce/index.vue",
|
||||
notes: ["电商风格区块演示;完整性:列表/卡片区块均可滚动与展示。"],
|
||||
},
|
||||
{
|
||||
anchor: "page-db-map",
|
||||
title: "地图",
|
||||
path: "dashboard/map/index.vue",
|
||||
notes: ["地图 SDK 示例;完整性:地图实例初始化、标记与交互事件可用。"],
|
||||
},
|
||||
{
|
||||
anchor: "page-db-pricing",
|
||||
title: "定价",
|
||||
path: "dashboard/pricing/index.vue",
|
||||
notes: ["定价页模板;完整性:价格表、按钮或跳转链接展示齐全。"],
|
||||
},
|
||||
{
|
||||
anchor: "page-db-article",
|
||||
title: "文章管理",
|
||||
path: "dashboard/article/list/index.vue",
|
||||
notes: [
|
||||
"文章列表(当前多为 mock `ArticleList`);路由 path `dashboard/article/article-list`。",
|
||||
"meta.authList:新增/编辑类权限标记;完整性:列表、分页、抽屉或编辑入口覆盖文章 CRUD 流程。",
|
||||
],
|
||||
},
|
||||
{
|
||||
anchor: "page-db-tutorial",
|
||||
title: "教程",
|
||||
path: "dashboard/tutorial/index.vue",
|
||||
notes: [
|
||||
"视频 Tab + 功能验收 Tab;正文含用户/角色详述与其余模块完整性清单。",
|
||||
"完整性:目录筛选、锚点滚动、手册内链跳转可用。",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
anchor: "mod-layout",
|
||||
heading: "九、布局与通用功能",
|
||||
pkgTag: "layouts",
|
||||
pages: [
|
||||
{
|
||||
anchor: "layout-main",
|
||||
title: "主布局",
|
||||
path: "../layouts/index.vue",
|
||||
notes: [
|
||||
"RootLayout:嵌套 RouterView;组合侧栏、顶栏、工作区、设置抽屉等子布局组件。",
|
||||
"完整性:登录后进主壳、子路由 outlet 渲染、响应式收缩侧栏。",
|
||||
],
|
||||
},
|
||||
{
|
||||
anchor: "layout-sidebar",
|
||||
title: "侧栏菜单",
|
||||
path: "../layouts/art-menus/**",
|
||||
notes: ["菜单折叠、搜索、递归子菜单;与路由 meta.icon/title 绑定。"],
|
||||
},
|
||||
{
|
||||
anchor: "layout-header",
|
||||
title: "顶栏",
|
||||
path: "../layouts/art-header-bar/**",
|
||||
notes: ["面包屑、快速入口、全屏、通知铃铛区域。"],
|
||||
},
|
||||
{
|
||||
anchor: "layout-worktab",
|
||||
title: "标签页",
|
||||
path: "../layouts/art-work-tab/**",
|
||||
notes: ["多标签打开、固定钉住、关闭其他;与 keep-alive 协同。"],
|
||||
},
|
||||
{
|
||||
anchor: "layout-settings",
|
||||
title: "设置面板",
|
||||
path: "../layouts/art-settings-panel/**",
|
||||
notes: ["主题、色弱、标签栏、菜单布局等本地偏好持久化。"],
|
||||
},
|
||||
{
|
||||
anchor: "layout-notification",
|
||||
title: "通知",
|
||||
path: "../layouts/art-notification/**",
|
||||
notes: ["通知下拉列表;已读状态;路由跳转。"],
|
||||
},
|
||||
{
|
||||
anchor: "layout-search",
|
||||
title: "全局搜索",
|
||||
path: "../layouts/art-global-search/**",
|
||||
notes: ["Ctrl/⌘+K 打开全局搜索(`art-global-search` 监听 keydown)。"],
|
||||
},
|
||||
{
|
||||
anchor: "layout-lock",
|
||||
title: "锁屏",
|
||||
path: "../layouts/art-screen-lock/index.vue",
|
||||
notes: [
|
||||
"锁屏全屏页;背景随明暗主题切换(`@imgs/lock` → `src/assets/images/lock`);顶栏用户菜单进入。",
|
||||
],
|
||||
},
|
||||
{
|
||||
anchor: "layout-user",
|
||||
title: "用户菜单",
|
||||
path: "../layouts/art-header-bar/**",
|
||||
notes: ["头像下拉:文档、教程、锁屏、退出等。"],
|
||||
},
|
||||
{
|
||||
anchor: "layout-theme",
|
||||
title: "主题切换",
|
||||
path: "../layouts/art-settings-panel/**",
|
||||
notes: ["明暗色、主题色;CSS 变量与 Element Plus 深色。"],
|
||||
},
|
||||
{
|
||||
anchor: "layout-lang",
|
||||
title: "语言切换",
|
||||
path: "../locales/langs/**",
|
||||
notes: ["`src/locales/langs/*.json` 与 i18n 实例;顶栏/设置内切换语言并持久化到本地存储。"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
anchor: "mod-exception",
|
||||
heading: "十、异常页",
|
||||
pkgTag: "exception",
|
||||
pages: [
|
||||
{
|
||||
anchor: "page-401",
|
||||
title: "401",
|
||||
path: "exception/401/index.vue",
|
||||
notes: [
|
||||
"静态路由 `/401`;页面含返回/登录入口(见模板)。",
|
||||
"完整性:守卫或 axios 拦截跳转至此页时按钮可用。",
|
||||
],
|
||||
},
|
||||
{
|
||||
anchor: "page-403",
|
||||
title: "403",
|
||||
path: "exception/403/index.vue",
|
||||
notes: [
|
||||
"静态路由 `/403`;无权限提示与返回首页等操作。",
|
||||
"完整性:从路由 meta 或业务手动跳转后页面可退出。",
|
||||
],
|
||||
},
|
||||
{
|
||||
anchor: "page-404",
|
||||
title: "404",
|
||||
path: "exception/404/index.vue",
|
||||
notes: [
|
||||
"独立 `/404` 与 CatchAll `CatchAll404` 共用组件文件(路由 name 不同)。",
|
||||
"完整性:非法路径落入 CatchAll、手动跳转 `/404` 均能展示。",
|
||||
],
|
||||
},
|
||||
{
|
||||
anchor: "page-500",
|
||||
title: "500",
|
||||
path: "exception/500/index.vue",
|
||||
notes: [
|
||||
"静态路由 `/500`;服务端错误提示页。",
|
||||
"完整性:错误文案、返回或重试按钮(若有)可点击。",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
anchor: "mod-swagger",
|
||||
heading: "十一、接口文档(API)",
|
||||
pkgTag: "module_swagger",
|
||||
pages: [
|
||||
{
|
||||
anchor: "page-swagger",
|
||||
title: "Swagger文档",
|
||||
path: "module_swagger/docs/index.vue",
|
||||
notes: [
|
||||
"父级菜单「接口管理」;默认 iframe `VITE_APP_BASE_API + /docs`,可由 `IframeRouteManager` 覆盖 link。",
|
||||
"完整性:iframe 加载、loading 消失;需后端文档地址可达。",
|
||||
],
|
||||
},
|
||||
{
|
||||
anchor: "page-redoc",
|
||||
title: "Redoc文档",
|
||||
path: "module_swagger/redoc/index.vue",
|
||||
notes: [
|
||||
"Redoc 文档 iframe 页;链接来自 env 或 iframe 路由 meta(与同模块 docs 一致范式)。",
|
||||
"完整性:页面全高 iframe、加载完成。",
|
||||
],
|
||||
},
|
||||
{
|
||||
anchor: "page-ljdoc",
|
||||
title: "LangJin文档",
|
||||
path: "module_swagger/ljdoc/index.vue",
|
||||
notes: [
|
||||
"LangJin 文档 iframe;sys_menu 中节点类型可为 4(菜单页)。",
|
||||
"完整性:同上,依赖后端或 meta 配置的文档 URL。",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* 功能验收手册 — 侧栏「筛选目录」扩展词
|
||||
* 含与 sys_menu 对齐前的俗称、简称,避免改名后搜不到。
|
||||
*/
|
||||
|
||||
/** 模块级补充检索词 */
|
||||
export const MANUAL_MODULE_SEARCH_ALIASES: Readonly<Record<string, readonly string[]>> = {
|
||||
"mod-system": ["平台管理", "系统管理", "用户", "权限"],
|
||||
"mod-monitor": ["监控"],
|
||||
"mod-task": ["任务", "调度", "工作流", "cron"],
|
||||
"mod-ai": ["AI", "智能", "会话", "记忆"],
|
||||
"mod-generator": ["代码生成", "生成器"],
|
||||
"mod-app": ["应用", "门户", "插件"],
|
||||
"mod-example": ["示例", "案例", "demo"],
|
||||
"mod-dashboard": ["仪表盘", "首页", "工作台", "教程"],
|
||||
"mod-layout": ["布局", "顶栏", "侧栏", "主题", "锁屏"],
|
||||
"mod-exception": ["错误页", "404", "403"],
|
||||
"mod-swagger": ["接口", "API", "文档", "swagger", "redoc"],
|
||||
};
|
||||
|
||||
/** 页面级补充检索词(anchor 与 MANUAL_TOC 一致) */
|
||||
export const MANUAL_PAGE_SEARCH_ALIASES: Readonly<Record<string, readonly string[]>> = {
|
||||
"page-resource": ["资源监控", "文件", "上传"],
|
||||
"page-cronjob": ["定时任务", "调度器", "job", "cron"],
|
||||
"page-cronnode": ["任务节点", "节点"],
|
||||
"page-workflow": ["工作流", "流程", "编排"],
|
||||
"page-nodetype": ["节点类型", "编排"],
|
||||
"page-ai-chat": ["AI 聊天", "聊天", "chat"],
|
||||
"page-ai-fachat": ["FA 聊天", "fachat"],
|
||||
"page-ai-memory": ["记忆管理", "memory"],
|
||||
"page-portal": ["应用门户", "portal"],
|
||||
"page-demo": ["示例一", "demo"],
|
||||
"page-demo01": ["示例二", "demo01", "三级"],
|
||||
"page-swagger": ["Swagger", "swagger"],
|
||||
"page-redoc": ["Redoc", "redoc"],
|
||||
"page-ljdoc": ["LJDoc", "ljdoc", "LangJin"],
|
||||
"page-db-article": ["文章", "article"],
|
||||
"page-db-tutorial": ["操作手册", "手册", "教程"],
|
||||
"page-login": ["登录", "auth"],
|
||||
"page-changelog": ["日志", "版本", "release"],
|
||||
"page-home": ["home", "入口"],
|
||||
"page-profile": ["个人中心", "profile", "账号"],
|
||||
};
|
||||
|
||||
function includesLoose(text: string, q: string): boolean {
|
||||
return text.toLowerCase().includes(q.toLowerCase());
|
||||
}
|
||||
|
||||
/** 模块是否在筛选下保留(标题、别名或任一子页命中) */
|
||||
export function manualModuleMatchesQuery(modTitle: string, modAnchor: string, q: string): boolean {
|
||||
if (includesLoose(modTitle, q)) return true;
|
||||
const ma = MANUAL_MODULE_SEARCH_ALIASES[modAnchor];
|
||||
if (ma?.some((a) => includesLoose(a, q))) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 子页是否在筛选下保留 */
|
||||
export function manualPageMatchesQuery(pageTitle: string, pageAnchor: string, q: string): boolean {
|
||||
if (includesLoose(pageTitle, q)) return true;
|
||||
const pa = MANUAL_PAGE_SEARCH_ALIASES[pageAnchor];
|
||||
if (pa?.some((a) => includesLoose(a, q))) return true;
|
||||
return false;
|
||||
}
|
||||
Reference in New Issue
Block a user