feat(site-layer): 学校站点分层方案C落地(docs/13)+栏目管理增强+管理端三修复+首页视觉升级管理端+审计复检T1~T5(docs/15)整合批次-URL站点段/site/{siteCode}无component镜像层(去name防重名,router-view跳过推进渲染)+身份条SiteSwitcher自绘(徽章#577afb/#ff9900+分组下拉✓+path次级列+Esc/外点收起)+URL唯一事实源syncSiteFromRoute(非法/跨校/主站码当子站段告警打回同页主站,localStorage降级刷新首帧兜底)+菜单剥段高亮stripSiteSeg/withSiteSeg补段+menuRoute镜像层防重影+跳转收敛adminPathOf(工作台进入/全部站点总览/站点管理行进入管理纯URL切站);栏目管理-上级栏目可选可改(禁选自身及后代防成环)+批量添加BatchAddCategory(行序写排序号单行失败不中断)+单页wangEditor富文本预览字数分区+categoryIds契约前端/types/mock三处统一+SchoolLayout高度链.layout-wrap修复+ArticleListPanel fixed模式;管理端三修复-皮肤预览Modal全屏(ThemePreviewFrame可选fill prop)+SiteManage补themeUnregistered声明+长图文编辑器随内容增高;管理端视觉升级配套-SiteConfigForm 12新键+BannerManage gallery展示位切换+栏目homeStyle下拉+PortalPreview侧栏同步;审计复检-新增siteRouter.spec真实路由集成7用例(镜像层matched3层/守卫子站码定向主站前缀/越权回工作台/旧链带id重定向)+sitePath空白容错+siteLayer持久化round-trip与身份条次级入口(docs/15);vitest 99/99+build通过+S1~S10 GUI走查全过
build / build (push) Successful in 2m43s
build / build (push) Successful in 2m43s
This commit is contained in:
@@ -1,9 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, reactive, ref, useTemplateRef, watch } from "vue";
|
||||
import { computed, nextTick, reactive, ref, shallowRef, useTemplateRef, watch } from "vue";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { Editor, Toolbar } from "@wangeditor/editor-for-vue";
|
||||
import "@wangeditor/editor/dist/css/style.css";
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
import CategoryTreePanel from "@/components/CategoryTreePanel.vue";
|
||||
import CategorySortModal from "@/components/CategorySortModal.vue";
|
||||
import BatchAddCategory from "@/components/BatchAddCategory.vue";
|
||||
import SvgIcon from "@/components/SvgIcon.vue";
|
||||
import ArticleListPanel from "@/components/article/ArticleListPanel.vue";
|
||||
import { categoryApi } from "@/api/module/cms";
|
||||
@@ -11,9 +14,10 @@ import { useCmsStore } from "@/store";
|
||||
import config from "@/assets/config/host.config";
|
||||
import { CategoryTreeNode } from "@/types/cms";
|
||||
import { rootLabelOf, wrapTreeWithRoot } from "@/utils/categoryTree";
|
||||
import { portalUrlOf } from "@/utils/sitePath";
|
||||
import { portalUrlOf, adminPathOf } from "@/utils/sitePath";
|
||||
|
||||
/* UserBase group/index 同款交互:图标工具栏(新增/编辑/子组排序/删除)+ ztree(W2:搜索/收起展开按钮移除) */
|
||||
/* UserBase group/index 同款交互:图标工具栏(新增/编辑/子组排序/删除)+ ztree(W2:搜索/收起展开按钮移除)。
|
||||
B5 栏目工作台:右侧类型化面板(列表=文章表格 / 单页=富文本预览 / 外链=直达)。 */
|
||||
const store = useCmsStore();
|
||||
|
||||
const refTreePanel = useTemplateRef("refTreePanel");
|
||||
@@ -29,12 +33,23 @@ const typeDict = [
|
||||
{ value: 2, label: "单页" },
|
||||
{ value: 3, label: "外链" },
|
||||
];
|
||||
const typeLabel = (t?: number) => typeDict.find(i => i.value === t)?.label || "未知";
|
||||
|
||||
/** 首页展示形态(docs/12;仅列表型栏目有意义,空=主题默认卡) */
|
||||
const homeStyleDict = [
|
||||
{ value: "", label: "默认卡片" },
|
||||
{ value: "news_grid", label: "双栏新闻卡" },
|
||||
{ value: "media_focus", label: "媒体聚焦列表" },
|
||||
];
|
||||
|
||||
const sortModal = ref(false);
|
||||
const sortParentId = ref<number | null>(null);
|
||||
const sortParentName = ref("");
|
||||
const sortChildrenSource = ref<any[]>([]);
|
||||
|
||||
const batchModal = ref(false);
|
||||
const batchDefaultType = ref<number | null>(null);
|
||||
|
||||
const modalVisible = ref(false);
|
||||
const saving = ref(false);
|
||||
|
||||
@@ -46,6 +61,7 @@ const form = reactive({
|
||||
type: 1,
|
||||
linkUrl: "",
|
||||
content: "",
|
||||
homeStyle: "",
|
||||
sortNo: 0,
|
||||
seoTitle: "",
|
||||
seoDescription: "",
|
||||
@@ -55,6 +71,7 @@ const form = reactive({
|
||||
const rules = {
|
||||
name: [{ required: true, message: "栏目名称不得为空", trigger: "blur" }],
|
||||
type: [{ required: true, type: "number", message: "请选择类型", trigger: "change" }],
|
||||
linkUrl: [{ required: true, message: "外链地址不得为空", trigger: "blur" }],
|
||||
};
|
||||
|
||||
/** 根节点 label(W2 虚拟根,D5):主站=学校名(缺省回退 mainSiteName),子站=子站名;随站点上下文响应 */
|
||||
@@ -95,6 +112,32 @@ function findNode(list: any[], id: number): any {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** target 是否 node 自身或其子孙(上级下拉排除,防把栏目挂到自己子树下成环) */
|
||||
function isSelfOrDescendant(node: any, targetId: number | undefined): boolean {
|
||||
if (!node || targetId == null) return false;
|
||||
if (node.id === targetId) return true;
|
||||
return (node.children || []).some((c: any) => isSelfOrDescendant(c, targetId));
|
||||
}
|
||||
|
||||
/** 上级栏目选项:虚拟根(0=rootLabel)+ 真实树拍平缩进;编辑态禁选自身及后代(后端仅防一级成环) */
|
||||
const parentOptions = computed(() => {
|
||||
const list: { id: number; label: string; disabled: boolean }[] = [
|
||||
{ id: 0, label: rootLabel.value || "根栏目", disabled: false },
|
||||
];
|
||||
const walk = (nodes: any[], depth: number) => {
|
||||
(nodes || []).forEach((n: any) => {
|
||||
list.push({
|
||||
id: n.id,
|
||||
label: `${" ".repeat(depth)}${n.name}`,
|
||||
disabled: isSelfOrDescendant(n, form.id),
|
||||
});
|
||||
walk(n.children || [], depth + 1);
|
||||
});
|
||||
};
|
||||
walk(treeData.value[0]?.children || [], 1);
|
||||
return list;
|
||||
});
|
||||
|
||||
function currentNode() {
|
||||
return selected.value || {};
|
||||
}
|
||||
@@ -110,6 +153,7 @@ async function showAddNode() {
|
||||
form.type = 1;
|
||||
form.linkUrl = "";
|
||||
form.content = "";
|
||||
form.homeStyle = "";
|
||||
form.sortNo = 0;
|
||||
form.seoTitle = "";
|
||||
form.seoDescription = "";
|
||||
@@ -132,6 +176,7 @@ function showEditNode() {
|
||||
type: node.type ?? 1,
|
||||
linkUrl: node.linkUrl || "",
|
||||
content: node.content || "",
|
||||
homeStyle: node.homeStyle || "",
|
||||
sortNo: node.sortNo ?? 0,
|
||||
seoTitle: node.seoTitle || "",
|
||||
seoDescription: node.seoDescription || "",
|
||||
@@ -186,6 +231,7 @@ async function save() {
|
||||
type: form.type,
|
||||
linkUrl: form.linkUrl || undefined,
|
||||
content: form.content || undefined,
|
||||
homeStyle: form.type === 1 && form.homeStyle ? form.homeStyle : undefined,
|
||||
sortNo: form.sortNo,
|
||||
seoTitle: form.seoTitle || undefined,
|
||||
seoDescription: form.seoDescription || undefined,
|
||||
@@ -204,17 +250,84 @@ function onSortSaved() {
|
||||
refTreePanel.value?.refreshTree(curTreeNode.value?.id);
|
||||
}
|
||||
|
||||
/* ===== B5 栏目工作台:右侧类型化内容面板 ===== */
|
||||
|
||||
/** 单页摘要:去 HTML 标签截断展示 */
|
||||
function summaryText(node: any): string {
|
||||
const text = String(node?.content || "").replace(/<[^>]+>/g, "").trim();
|
||||
return text.length > 200 ? text.slice(0, 200) + "…" : text || "(暂无内容,点击「编辑内容」补充)";
|
||||
/** 批量添加完成:刷新树并定位到最后创建成功的节点 */
|
||||
function onBatchSuccess(lastSavedId?: number) {
|
||||
refTreePanel.value?.refreshTree(lastSavedId ?? curTreeNode.value?.id);
|
||||
}
|
||||
|
||||
/** 新建文章:带 categoryId 预选跳编辑页(ArticleEdit onMounted 回填) */
|
||||
/** 新增模态框 → 批量添加(UserBase switchToBatch 同款,仅新增态开放) */
|
||||
function switchToBatch() {
|
||||
batchDefaultType.value = form.type;
|
||||
modalVisible.value = false;
|
||||
batchModal.value = true;
|
||||
}
|
||||
|
||||
/* ===== 单页富文本编辑(wangEditor,配置对齐 ArticleEdit;贴图 scene=category 标记引用) ===== */
|
||||
const editorRef = shallowRef();
|
||||
const editorReady = ref(false);
|
||||
const toolbarConfig = { excludeKeys: ["group-video", "fullScreen"] };
|
||||
const serviceBase = config.isMock ? "" : config.serviceHost;
|
||||
const editorConfig = {
|
||||
placeholder: "请输入单页内容,支持图文混排……",
|
||||
MENU_CONF: {
|
||||
uploadImage: {
|
||||
async customUpload(file: File, insertFn: (url: string, alt: string, href: string) => void) {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
fd.append("siteId", String(store.siteId));
|
||||
// scene=category:单页贴图计入栏目引用,附件管理不计为「未使用」(后端白名单)
|
||||
fd.append("scene", "category");
|
||||
try {
|
||||
const resp = await fetch(`${serviceBase}/api/attachment/upload`, {
|
||||
method: "POST",
|
||||
body: fd,
|
||||
credentials: "include",
|
||||
});
|
||||
const res = await resp.json();
|
||||
if (res.code === 0 && res.data) {
|
||||
insertFn(res.data.url, res.data.fileName || "", res.data.url);
|
||||
} else {
|
||||
Message.error(res.msg || "图片上传失败");
|
||||
}
|
||||
} catch {
|
||||
Message.error("图片上传失败");
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
function handleCreated(editor: any) {
|
||||
editorRef.value = editor;
|
||||
}
|
||||
|
||||
// wangEditor 不能在 display:none 容器内创建(宽度塌陷):模态框展开动画结束后再挂载,
|
||||
// 关闭即卸载销毁(@wangeditor/editor-for-vue 组件卸载时自动 destroy)
|
||||
watch(modalVisible, v => {
|
||||
if (v) {
|
||||
editorReady.value = false;
|
||||
setTimeout(() => {
|
||||
editorReady.value = form.type === 2;
|
||||
}, 320);
|
||||
} else {
|
||||
editorReady.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
/* ===== B5 栏目工作台:右侧类型化内容面板 ===== */
|
||||
|
||||
/** 单页正文纯文本(字数统计/空态判断用) */
|
||||
function plainText(html: string): string {
|
||||
return String(html || "")
|
||||
.replace(/<[^>]+>/g, "")
|
||||
.replace(/ /g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** 新建文章:带 categoryId 预选跳编辑页(ArticleEdit onMounted 回填);站点段跟随当前上下文 */
|
||||
function goNewArticle(categoryId: number) {
|
||||
window.location.hash = `#/sch/${store.schoolCode}/article/edit?categoryId=${categoryId}`;
|
||||
const code = store.schoolCode;
|
||||
if (!code) return;
|
||||
window.location.hash = `#${adminPathOf(code, store.siteSegCode, "article/edit")}?categoryId=${categoryId}`;
|
||||
}
|
||||
|
||||
/** 门户预览:新开 /portal/{path}/c/{id}(W4 嵌套路径) */
|
||||
@@ -277,7 +390,7 @@ function openLink(url: string) {
|
||||
<div class="panel-title">
|
||||
<h3>{{ selected.name }}</h3>
|
||||
<Tag :color="selected.type === 1 ? 'primary' : selected.type === 2 ? 'success' : 'warning'">
|
||||
{{ typeDict.find(t => t.value === selected!.type)?.label }}
|
||||
{{ typeLabel(selected.type) }}
|
||||
</Tag>
|
||||
<span class="panel-meta">排序 {{ selected.sortNo }} · {{ selected.status === 1 ? "显示" : "隐藏" }}</span>
|
||||
</div>
|
||||
@@ -302,12 +415,25 @@ function openLink(url: string) {
|
||||
v-if="selected.type === 1"
|
||||
class="panel-body"
|
||||
:category-id="selected.id"
|
||||
:extra-height="78"
|
||||
fixed-category
|
||||
/>
|
||||
|
||||
<!-- 单页型:内容摘要 + 编辑入口 -->
|
||||
<!-- 单页型:正文渲染预览 + 元信息 + 编辑入口 -->
|
||||
<div v-else-if="selected.type === 2" class="single-summary">
|
||||
<p class="summary-text">{{ summaryText(selected) }}</p>
|
||||
<div class="summary-meta">
|
||||
<span>共 {{ plainText(selected.content).length }} 字</span>
|
||||
<span>{{ selected.status === 1 ? "已显示" : "已隐藏" }}</span>
|
||||
</div>
|
||||
<!-- content 入库前经后端 jsoup 白名单清洗,回显渲染安全 -->
|
||||
<div v-if="plainText(selected.content)" class="rich-preview" v-html="selected.content"></div>
|
||||
<div v-else class="summary-empty">
|
||||
<p>暂无内容</p>
|
||||
<Button type="primary" ghost @click="showEditNode">
|
||||
<Icon type="ios-create" />
|
||||
去编写
|
||||
</Button>
|
||||
</div>
|
||||
<div class="summary-actions">
|
||||
<Button type="primary" @click="showEditNode">
|
||||
<Icon type="ios-create" />
|
||||
@@ -342,11 +468,16 @@ function openLink(url: string) {
|
||||
v-model="modalVisible"
|
||||
:title="form.id ? '编辑栏目' : '新增子栏目'"
|
||||
:mask-closable="false"
|
||||
width="560px"
|
||||
width="640px"
|
||||
>
|
||||
<Form ref="refFormData" :model="form" :rules="rules" :label-width="100">
|
||||
<FormItem label="上级栏目">
|
||||
<Input :value="form.parentId === 0 ? '根栏目' : '当前选中栏目下'" disabled />
|
||||
<Divider size="small" orientation="left">基本信息</Divider>
|
||||
<FormItem label="上级栏目" prop="parentId">
|
||||
<Select v-model="form.parentId" transfer>
|
||||
<Option v-for="opt in parentOptions" :key="opt.id" :value="opt.id" :disabled="opt.disabled">
|
||||
{{ opt.label }}
|
||||
</Option>
|
||||
</Select>
|
||||
</FormItem>
|
||||
<FormItem label="栏目名称" prop="name">
|
||||
<Input ref="refNodeNameInput" v-model="form.name" :maxlength="64" placeholder="栏目名称" />
|
||||
@@ -356,34 +487,66 @@ function openLink(url: string) {
|
||||
<Radio v-for="t in typeDict" :key="t.value" :label="t.value">{{ t.label }}</Radio>
|
||||
</RadioGroup>
|
||||
</FormItem>
|
||||
<FormItem v-if="form.type === 3" label="外链地址" prop="linkUrl">
|
||||
<Input v-model="form.linkUrl" :maxlength="500" placeholder="http(s):// 开头" />
|
||||
</FormItem>
|
||||
<FormItem v-if="form.type === 2" label="单页内容">
|
||||
<Input v-model="form.content" type="textarea" :rows="8" placeholder="单页 HTML 内容(保存时自动做安全清洗)" />
|
||||
</FormItem>
|
||||
<FormItem label="排序">
|
||||
<InputNumber v-model="form.sortNo" :min="0" />
|
||||
</FormItem>
|
||||
<FormItem label="SEO 标题">
|
||||
<Input v-model="form.seoTitle" :maxlength="200" placeholder="空=用栏目名" />
|
||||
</FormItem>
|
||||
<FormItem label="SEO 描述">
|
||||
<Input v-model="form.seoDescription" type="textarea" :rows="2" :maxlength="500" />
|
||||
</FormItem>
|
||||
<FormItem label="状态">
|
||||
<RadioGroup v-model="form.status">
|
||||
<Radio :label="1">显示</Radio>
|
||||
<Radio :label="0">隐藏</Radio>
|
||||
</RadioGroup>
|
||||
</FormItem>
|
||||
|
||||
<Divider size="small" orientation="left">内容设置</Divider>
|
||||
<FormItem v-if="form.type === 3" label="外链地址" prop="linkUrl">
|
||||
<Input v-model="form.linkUrl" :maxlength="500" placeholder="http(s):// 开头" />
|
||||
</FormItem>
|
||||
<FormItem v-if="form.type === 2" label="单页内容">
|
||||
<div class="editor-wrap">
|
||||
<template v-if="editorReady">
|
||||
<Toolbar class="editor-toolbar" :editor="editorRef" :defaultConfig="toolbarConfig" mode="default" />
|
||||
<Editor
|
||||
class="editor-content"
|
||||
v-model="form.content"
|
||||
:defaultConfig="editorConfig"
|
||||
mode="default"
|
||||
@onCreated="handleCreated"
|
||||
/>
|
||||
</template>
|
||||
<div v-else class="editor-placeholder">编辑器加载中……</div>
|
||||
</div>
|
||||
</FormItem>
|
||||
<FormItem v-if="form.type === 1" label="首页展示形态">
|
||||
<Select v-model="form.homeStyle" placeholder="默认卡片">
|
||||
<Option v-for="s in homeStyleDict" :key="s.value" :value="s.value">{{ s.label }}</Option>
|
||||
</Select>
|
||||
<p class="form-tip">首页该栏目区块的呈现形态;「双栏新闻卡/媒体聚焦列表」需皮肤包支持(如炫彩校园),旧主题按默认卡展示。</p>
|
||||
</FormItem>
|
||||
|
||||
<Divider size="small" orientation="left">SEO 设置</Divider>
|
||||
<FormItem label="SEO 标题">
|
||||
<Input v-model="form.seoTitle" :maxlength="200" placeholder="空=用栏目名" />
|
||||
</FormItem>
|
||||
<FormItem label="SEO 描述">
|
||||
<Input v-model="form.seoDescription" type="textarea" :rows="2" :maxlength="500" />
|
||||
</FormItem>
|
||||
</Form>
|
||||
<template #footer>
|
||||
<Button v-if="!form.id" type="link" @click="switchToBatch">切换至批量添加</Button>
|
||||
<Button @click="modalVisible = false">取消</Button>
|
||||
<Button type="primary" :loading="saving" @click="save">保存</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- 批量添加栏目(UserBase BatchAddGroup 同款:一行一个名称,前端循环调 save) -->
|
||||
<BatchAddCategory
|
||||
v-model:visible="batchModal"
|
||||
:default-pid="curTreeNode?.id ?? 0"
|
||||
:parent-name="curTreeNode?.id ? curTreeNode?.name : rootLabel"
|
||||
:default-type="batchDefaultType"
|
||||
@success="onBatchSuccess"
|
||||
/>
|
||||
|
||||
<!-- 子栏目排序(userbase GroupSort 同款拖拽) -->
|
||||
<CategorySortModal
|
||||
v-model:visible="sortModal"
|
||||
@@ -396,6 +559,13 @@ function openLink(url: string) {
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.form-tip {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* UserBase group/index 同款工具栏样式 */
|
||||
.opera-ul {
|
||||
display: flex;
|
||||
@@ -441,18 +611,6 @@ function openLink(url: string) {
|
||||
}
|
||||
}
|
||||
|
||||
.info-panel {
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
padding: 16px;
|
||||
h3 {
|
||||
margin-top: 0;
|
||||
}
|
||||
p {
|
||||
color: #555;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== B5 栏目工作台右侧面板 ===== */
|
||||
.workbench-main {
|
||||
display: flex;
|
||||
@@ -469,6 +627,7 @@ function openLink(url: string) {
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
flex-shrink: 0;
|
||||
|
||||
.panel-title {
|
||||
display: flex;
|
||||
@@ -510,18 +669,53 @@ function openLink(url: string) {
|
||||
border-radius: 4px;
|
||||
padding: 16px;
|
||||
margin-top: 10px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
|
||||
.summary-text {
|
||||
color: #333;
|
||||
.summary-meta {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.rich-preview {
|
||||
line-height: 1.8;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
margin-bottom: 14px;
|
||||
|
||||
:deep(img) {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
:deep(table) {
|
||||
border-collapse: collapse;
|
||||
|
||||
td,
|
||||
th {
|
||||
border: 1px solid #ccc;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.summary-empty {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
padding: 40px 0;
|
||||
|
||||
p {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.summary-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.seo-line {
|
||||
@@ -545,6 +739,32 @@ function openLink(url: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/* 单页富文本编辑器(模态框内):wangEditor v5 高度打在内层 .w-e-text-container */
|
||||
.editor-wrap {
|
||||
border: 1px solid #dcdee2;
|
||||
border-radius: 4px;
|
||||
z-index: 100;
|
||||
|
||||
.editor-toolbar {
|
||||
border-bottom: 1px solid #dcdee2;
|
||||
}
|
||||
|
||||
.editor-content {
|
||||
:deep(.w-e-text-container) {
|
||||
height: 320px !important;
|
||||
}
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.editor-placeholder {
|
||||
height: 320px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-tip {
|
||||
color: #999;
|
||||
text-align: center;
|
||||
|
||||
Reference in New Issue
Block a user