refactor: 重构路由与部署脚本,更新静态资源路径

This commit is contained in:
zhangtao
2026-05-13 01:55:47 +08:00
parent db5f8430de
commit 63d76007cb
69 changed files with 1688 additions and 2794 deletions
@@ -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;
}