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

This commit is contained in:
chenxing
2026-09-10 11:11:23 +08:00
parent 9ba4cb135a
commit 6ee9ab5684
26 changed files with 1931 additions and 222 deletions
+13 -7
View File
@@ -7,7 +7,7 @@ import "@wangeditor/editor/dist/css/style.css";
import { articleApi, categoryApi, uploadUrl } from "@/api/module/cms";
import { useCmsStore } from "@/store";
import config from "@/assets/config/host.config";
import { portalUrlOf } from "@/utils/sitePath";
import { portalUrlOf, adminPathOf } from "@/utils/sitePath";
import { CategoryTreeNode } from "@/types/cms";
const route = useRoute();
@@ -190,7 +190,8 @@ async function saveDraft() {
if (id) {
Message.success("保存成功!");
const code = store.schoolCode;
router.replace(code ? `/sch/${code}/article/edit/${id}` : "/workbench");
// 站点段跟随当前上下文(docs/13):子站编辑页 URL 带 /site/{code}
router.replace(code ? adminPathOf(code, store.siteSegCode, `article/edit/${id}`) : "/workbench");
}
}
@@ -200,7 +201,7 @@ async function saveAndPublish() {
await articleApi.publish(store.siteId!, id).send();
Message.success("已提交发布!");
const code = store.schoolCode;
router.replace(code ? `/sch/${code}/article` : "/workbench");
router.replace(code ? adminPathOf(code, store.siteSegCode, "article") : "/workbench");
}
/** 预览:仅已发布文章在门户可见 */
@@ -330,12 +331,17 @@ watch(() => store.siteId, () => load());
border-bottom: 1px solid #dcdee2;
}
.editor-content {
// wangEditor v5 高度必须打在内层 .w-e-text-container(外层高度会被内部 100% 链路吞掉),
// Notice NewNotice.vue 同款修复(D6-5);视口自适应、下限 480px,430≈壳层(56+56)+表单区高度
// 长图文显示不全修复(2026-09-10):旧做法把 .w-e-text-container 钉在
// max(480px, 100vh-430px) 固定高,长文被关进内滚窗口且 Windows overlay
// 滚动条不常显,正文尾部看似被截断。改为容器高度随内容自动增长,
// 整篇平铺进页面由窗口滚动;空态/短文用 slate 区 min-height 铺满视口余量
//(430≈壳层(56+56)+表单区高度,下限 480px)。
:deep(.w-e-text-container) {
height: max(480px, calc(100vh - 430px)) !important;
height: auto !important;
}
:deep([data-slate-editor]) {
min-height: max(480px, calc(100vh - 430px)) !important;
}
overflow-y: hidden;
}
}
</style>
+21 -5
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { onMounted, reactive, ref, watch } from "vue";
import { computed, onMounted, reactive, ref, watch } from "vue";
import { Message } from "view-ui-plus";
import Utils from "@/utils/index";
import MyConfirm from "@/components/MyConfirm";
@@ -20,6 +20,14 @@ const linkTypeDict = [
];
const linkTypeText = (v: number) => linkTypeDict.find(i => i.value === v)?.label || "-";
/** 展示位(docs/12):home=首页轮播 / gallery=线上展厅图片画廊 */
const position = ref("home");
const positionDict = [
{ value: "home", label: "首页轮播", tip: "轮播图设计尺寸建议 1920×560(皮肤包约定)" },
{ value: "gallery", label: "线上展厅", tip: "画廊图片设计尺寸建议 800×520,多图横向轮播(炫彩校园皮肤消费)" },
];
const positionTip = computed(() => positionDict.find(p => p.value === position.value)?.tip || "");
const columns = [
{
title: "图片",
@@ -94,7 +102,7 @@ async function run() {
if (!store.siteId) return;
loading.value = true;
try {
rows.value = (await bannerApi.list(store.siteId, "home").send()) || [];
rows.value = (await bannerApi.list(store.siteId, position.value).send()) || [];
} finally {
loading.value = false;
}
@@ -151,7 +159,7 @@ async function save() {
.save({
id: form.id,
siteId: store.siteId!,
position: "home",
position: position.value,
title: form.title,
imageUrl: form.imageUrl,
linkType: form.linkType,
@@ -184,19 +192,23 @@ function doDelete(row: BannerVo) {
onMounted(run);
// 站点上下文切换即时重载(docs/09 §3.3)
// 站点上下文切换即时重载(docs/09 §3.3);展示位切换即时重载(docs/12)
watch(() => store.siteId, () => run());
watch(position, () => run());
</script>
<template>
<div class="page-wrapper">
<div class="main-container">
<div class="fix">
<RadioGroup v-model="position" class="fl position-switch" type="button">
<Radio v-for="p in positionDict" :key="p.value" :label="p.value">{{ p.label }}</Radio>
</RadioGroup>
<Button class="fl" type="primary" size="large" @click="openCreate">
<Icon type="ios-add" />
新增轮播
</Button>
<span class="size-tip fr">轮播图设计尺寸建议 1920×560(皮肤包约定)</span>
<span class="size-tip fr">{{ positionTip }}</span>
</div>
<Table :columns="columns" :data="rows" :height="tableHeight" :loading="loading" no-data-text="暂无轮播" />
@@ -264,6 +276,10 @@ watch(() => store.siteId, () => run());
</template>
<style scoped lang="scss">
.position-switch {
margin-right: 12px;
margin-top: 6px;
}
.size-tip {
color: #999;
line-height: 40px;
+265 -45
View File
@@ -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(/&nbsp;/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;
+143 -33
View File
@@ -21,7 +21,41 @@ const sites = ref<SiteVo[]>([]);
const loading = ref(false);
/** 当前查看的文章(点列表进入详情态) */
const currentArticle = ref<{ title: string; content: string; publishTime?: string; source?: string } | null>(null);
const currentArticle = ref<{ categoryId?: number; title: string; content: string; publishTime?: string; source?: string } | null>(null);
/** 自栏目树求 根→目标 路径(仅启用节点);查不到返回 null */
function findCategoryPath(tree: CategoryTreeNode[], id: number): CategoryTreeNode[] | null {
for (const root of tree) {
if (root.id === id) return [root];
if (!root.children?.length) continue;
const sub = findCategoryPath(root.children, id);
if (sub) return [root, ...sub];
}
return null;
}
/**
* 左侧栏目导航(docs/11 同款规则):模块有二级子栏目时,栏目/文章页左侧
* 展示"模块名 + 全部二级兄弟"。详情态按文章所属栏目定位并高亮;列表态为
* 全站混合列表无具体栏目,取第一个有二级子栏目的模块做形态演示(无高亮)。
*/
const sideNav = computed(() => {
const enabled = categories.value.filter(c => c.status === 1);
let path: CategoryTreeNode[] | null = null;
const currentCatId = currentArticle.value?.categoryId;
if (currentCatId != null) {
path = findCategoryPath(enabled, currentCatId);
}
if (!path) {
const demo = enabled.find(c => c.children?.length);
path = demo ? [demo] : null;
}
if (!path || !path[0].children?.length) return null;
const top = path[0];
const activeId = path.length === 2 ? path[1].id
: path.length > 2 ? path[1].id : null;
return { top, items: top.children.filter(c => c.status === 1), activeId };
});
/** 轮播 */
const bannerIndex = ref(0);
@@ -92,6 +126,7 @@ async function openArticle(item: ArticlePageItem) {
const detail = await articleApi.get(store.siteId!, item.id).send();
if (detail) {
currentArticle.value = {
categoryId: item.categoryId,
title: detail.title,
content: detail.content || "<p>(无正文)</p>",
publishTime: detail.publishTime,
@@ -117,7 +152,7 @@ onBeforeUnmount(stopCarousel);
<template>
<div class="portal-preview">
<div class="portal-tip">门户预览(简版):正式门户由后端 Thymeleaf 渲染于 /portal/{code}/</div>
<div class="portal-tip">门户预览(简版):正式门户由后端 Thymeleaf 渲染于 /portal/{code}/;有二级栏目的模块,其栏目页/文章页左侧展示兄弟栏目导航(此处同步演示)</div>
<!-- 门户头部 -->
<header class="portal-header">
@@ -169,39 +204,55 @@ onBeforeUnmount(stopCarousel);
</div>
</div>
<!-- 文章详情态 -->
<div class="article-detail" v-if="currentArticle">
<a class="back-link" href="javascript:;" @click="backToList">← 返回列表</a>
<h1>{{ currentArticle.title }}</h1>
<p class="meta">
发布时间:{{ currentArticle.publishTime || "-" }}
<template v-if="currentArticle.source"> 来源:{{ currentArticle.source }}</template>
</p>
<div class="article-content" v-html="currentArticle.content"></div>
</div>
<div class="content-cols">
<!-- 左侧栏目导航(docs/11 同款规则):有二级子栏目的模块展示"模块名 + 全部兄弟" -->
<aside class="module-nav" v-if="sideNav">
<div class="module-nav-head">{{ sideNav.top.name }}</div>
<ul>
<li v-for="s in sideNav.items" :key="s.id" :class="{ on: s.id === sideNav.activeId }">
<a
:href="s.type === 3 ? s.linkUrl : 'javascript:;'"
:target="s.type === 3 ? '_blank' : undefined"
rel="noopener noreferrer"
>{{ s.name }}</a>
</li>
</ul>
</aside>
<!-- 列表态 -->
<div class="portal-columns" v-else>
<div class="main-col">
<div class="col-head"><h2>文章列表</h2><span>仅展示「已发布」文章,置顶优先</span></div>
<ul class="article-list">
<li v-for="a in articles" :key="a.id">
<a href="javascript:;" @click="openArticle(a)">
<em v-if="a.topmost === 1">[置顶]</em>{{ a.title }}
</a>
<span class="date">{{ (a.publishTime || "").slice(0, 10) }}</span>
</li>
<li v-if="!articles.length" class="empty">该站点暂无已发布文章</li>
</ul>
<!-- 文章详情态 -->
<div class="article-detail" v-if="currentArticle">
<a class="back-link" href="javascript:;" @click="backToList">← 返回列表</a>
<h1>{{ currentArticle.title }}</h1>
<p class="meta">
发布时间:{{ currentArticle.publishTime || "-" }}
<template v-if="currentArticle.source"> 来源:{{ currentArticle.source }}</template>
</p>
<div class="article-content" v-html="currentArticle.content"></div>
</div>
<div class="side-col">
<div class="col-head"><h2>栏目导航</h2></div>
<ul class="cat-list">
<li v-for="c in topNav" :key="c.id">
{{ c.name }}
<em>{{ c.children?.length ? `${c.children.length} 个子栏目` : { 1: "文章列表", 2: "单页", 3: "外链" }[c.type] }}</em>
</li>
</ul>
<!-- 列表态 -->
<div class="portal-columns" v-else>
<div class="main-col">
<div class="col-head"><h2>文章列表</h2><span>仅展示「已发布」文章,置顶优先</span></div>
<ul class="article-list">
<li v-for="a in articles" :key="a.id">
<a href="javascript:;" @click="openArticle(a)">
<em v-if="a.topmost === 1">[置顶]</em>{{ a.title }}
</a>
<span class="date">{{ (a.publishTime || "").slice(0, 10) }}</span>
</li>
<li v-if="!articles.length" class="empty">该站点暂无已发布文章</li>
</ul>
</div>
<div class="side-col">
<div class="col-head"><h2>栏目导航</h2></div>
<ul class="cat-list">
<li v-for="c in topNav" :key="c.id">
{{ c.name }}
<em>{{ c.children?.length ? `${c.children.length} 个子栏目` : { 1: "文章列表", 2: "单页", 3: "外链" }[c.type] }}</em>
</li>
</ul>
</div>
</div>
</div>
</div>
@@ -388,6 +439,65 @@ onBeforeUnmount(stopCarousel);
padding: 0 16px;
}
/* 左侧栏目导航 + 内容两栏(docs/11 同款形态) */
.content-cols {
display: flex;
gap: 16px;
align-items: flex-start;
> .portal-columns,
> .article-detail {
flex: 1;
min-width: 0;
}
}
.module-nav {
flex: none;
width: 200px;
background: #fff;
border: 1px solid #e5e6eb;
border-top: 3px solid #2d8cf0;
.module-nav-head {
background: #2d8cf0;
color: #fff;
text-align: center;
font-weight: 700;
padding: 14px 10px;
letter-spacing: 2px;
}
ul {
list-style: none;
margin: 0;
padding: 0;
}
li + li {
border-top: 1px dashed #eee;
}
li a {
display: block;
padding: 10px 14px;
color: #333;
font-size: 14px;
text-decoration: none;
&:hover {
color: #2d8cf0;
background: #f2f6fc;
}
}
li.on a {
background: #2d8cf0;
color: #fff;
font-weight: 600;
}
}
.banner-box {
position: relative;
border-radius: 8px;
+5 -4
View File
@@ -6,7 +6,7 @@ import { schoolApi, siteApi, themeApi } from "@/api/module/cms";
import { useCmsStore } from "@/store";
import config from "@/assets/config/host.config";
import { SchoolCard, SchoolOption, ThemeVo } from "@/types/cms";
import { portalUrlOf } from "@/utils/sitePath";
import { portalUrlOf, adminPathOf } from "@/utils/sitePath";
import ThemePicker from "@/components/theme/ThemePicker.vue";
import WorkbenchHeader from "@/components/WorkbenchHeader.vue";
@@ -56,18 +56,19 @@ function enter(card: SchoolCard) {
(s: any) => s.id === card.mainSiteId || s.parentSiteId === card.mainSiteId
)
);
router.push(`/sch/${card.mainSiteCode}/article`);
// 进入学校 = 主站形态(docs/13:URL 没写站点段就是主站)
router.push(adminPathOf(card.mainSiteCode, null, "article"));
}
function openPortal(card: SchoolCard) {
window.open(`${config.serviceHost}${portalUrlOf({ path: card.mainSitePath, code: card.mainSiteCode })}`, "_blank");
}
/** 超管全局站点视图(借道当前/第一个学校上下文 + scope=all) */
/** 超管全局站点视图(借道当前/第一个学校上下文 + scope=all):跨校总览落主站形态 */
function goAllSites() {
const code = store.schoolCode || cards.value[0]?.mainSiteCode;
if (code) {
router.push({ path: `/sch/${code}/site`, query: { scope: "all" } });
router.push({ path: adminPathOf(code, null, "site"), query: { scope: "all" } });
}
}
+6 -4
View File
@@ -4,7 +4,7 @@ import { useRoute, useRouter } from "vue-router";
import { Message } from "view-ui-plus";
import Utils from "@/utils/index";
import { assetUrl, siteApi, themeApi } from "@/api/module/cms";
import { portalUrlOf } from "@/utils/sitePath";
import { portalUrlOf, adminPathOf } from "@/utils/sitePath";
import { useCmsStore } from "@/store";
import config from "@/assets/config/host.config";
import { SiteVo, ThemeVo } from "@/types/cms";
@@ -132,6 +132,8 @@ const form = reactive({
});
const editThemeParams = ref<FormModelValue>({});
const editTheme = computed(() => themeMeta.value[form.theme]);
/** 当前主题是否未注册(未注册保留原值但告警,保存将被服务端拒绝) */
const themeUnregistered = ref(false);
/** B3 自定义样式(config_json.custom_css):面板为唯一编辑入口,SiteConfigForm 侧该键为保留键 */
const customCss = ref("");
@@ -279,7 +281,8 @@ function openPortal(row: SiteVo) {
window.open(`${config.serviceHost}${portalUrlOf(row)}`, "_blank");
}
/** 行级「进入管理」(docs/09 §3.3 子站管理入口):切当前操作站点后落文章列表;
/** 行级「进入管理」(docs/13 §5):跳 URL 即完成站点切换——主站形态 /article,
* 院系子站带 /site/{code} 段,store 由 SchoolLayout.syncSiteFromRoute 按 URL 同步;
* 全局视图跨校行先重建学校上下文(面包屑/左侧菜单跟随),子站行向上找主站行定学校壳 */
function enterManage(row: SiteVo) {
const main = row.parentSiteId ? rows.value.find(s => s.id === row.parentSiteId) : row;
@@ -299,8 +302,7 @@ function enterManage(row: SiteVo) {
rows.value.filter(s => s.id === main.id || s.parentSiteId === main.id)
);
}
store.setCurrentSite({ id: row.id, code: row.code, path: row.path || row.code, name: row.name });
router.push(`/sch/${main.code}/article`);
router.push(adminPathOf(main.code, row.parentSiteId ? row.code : null, "article"));
}
function openEditPreview() {
+18 -10
View File
@@ -323,17 +323,19 @@ const builtinCount = computed(() => themes.value.filter(t => t.source === "built
</template>
</Modal>
<!-- 预览弹窗(复用 ThemePreviewFrame,内容源下拉同向导) -->
<Modal v-model="previewVisible" :title="`预览皮肤:${previewTheme?.title || ''}`" width="860px">
<div class="preview-source">
<label>内容源站点</label>
<Select :model-value="previewSitePath" style="width: 280px" @on-change="onContentSourceChange">
<Option v-for="s in contentSources" :key="s.id" :value="s.path || s.code">
{{ s.name }}({{ s.path || s.code }})
</Option>
</Select>
<!-- 预览弹窗(全屏展示,复用 ThemePreviewFrame,内容源下拉同向导) -->
<Modal v-model="previewVisible" :title="`预览皮肤:${previewTheme?.title || ''}`" fullscreen footer-hide>
<div class="preview-full">
<div class="preview-source">
<label>内容源站点</label>
<Select :model-value="previewSitePath" style="width: 280px" @on-change="onContentSourceChange">
<Option v-for="s in contentSources" :key="s.id" :value="s.path || s.code">
{{ s.name }}({{ s.path || s.code }})
</Option>
</Select>
</div>
<ThemePreviewFrame fill :site-path="previewSitePath" :theme="previewTheme?.name" :theme-params="{}" />
</div>
<ThemePreviewFrame :site-path="previewSitePath" :theme="previewTheme?.name" :theme-params="{}" />
</Modal>
<!-- 编辑弹窗(任务四:内置包仅显示名/描述;外部包另含参数 schema) -->
@@ -378,6 +380,12 @@ const builtinCount = computed(() => themes.value.filter(t => t.source === "built
overflow-y: auto;
padding: 0 24px 24px;
}
/* 全屏预览:弹窗 body 高度确定(top:51px/bottom:0),内容源行占自然高、预览框吃满余量 */
.preview-full {
height: 100%;
display: flex;
flex-direction: column;
}
.preview-source {
display: flex;
align-items: center;