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:
@@ -34,6 +34,11 @@ const ROUTE_ENTRIES: RouteEntry[] = [];
|
||||
if (raw.includes(":") && !isSchoolRoot && !underSchool) {
|
||||
return;
|
||||
}
|
||||
// 站点段前缀层(docs/13):无 component 的参数化嵌套层,其子记录只是主站清单的镜像。
|
||||
// 纳入清单会让名称/modId 匹配出现重影(双向包含匹配命中多个即放弃),故整枝跳过
|
||||
if (raw.includes(":") && !isSchoolRoot && !r.component && !r.redirect) {
|
||||
return;
|
||||
}
|
||||
const nextUnder = underSchool || isSchoolRoot;
|
||||
const sub = nextUnder && !raw.startsWith("/") ? "/" + raw : raw;
|
||||
if (!r.redirect && r.component) {
|
||||
|
||||
@@ -110,11 +110,38 @@ describe("siteConfigForm", () => {
|
||||
it("已知键清单与 docs/09 §0 键盘点表一致(防漂移)", () => {
|
||||
expect(SITE_CONFIG_FIELD_DEFS.map(d => d.key)).toEqual([
|
||||
"logo", "favicon", "slogan", "colleges_label",
|
||||
"contact", "copyright", "beian",
|
||||
"news_bg_image", "gallery_title", "gallery_subtitle",
|
||||
"contact", "footer_addresses", "qr_wechat", "qr_weibo",
|
||||
"social_wechat", "social_weibo", "social_douyin", "social_bilibili",
|
||||
"copyright", "beian",
|
||||
"seo_description",
|
||||
"domain", "public_base_url",
|
||||
]);
|
||||
});
|
||||
it("docs/12:页脚增强与首页视觉新键 parse/serialize/校验(qr_* asset、social_* url)", () => {
|
||||
const json = JSON.stringify({
|
||||
footer_addresses: "潭山校区:南湖路 237 号 邮编:464000\n淮河校区:大学路 1 号 邮编:464300",
|
||||
qr_wechat: "/cms/upload/qr-wechat.png",
|
||||
social_weibo: "https://weibo.com/xxx",
|
||||
social_douyin: "not-a-url",
|
||||
gallery_title: "线上展厅",
|
||||
});
|
||||
const m = parseSiteConfig(json);
|
||||
expect(m.fields.footer_addresses).toContain("淮河校区");
|
||||
expect(m.fields.qr_wechat).toBe("/cms/upload/qr-wechat.png");
|
||||
expect(m.fields.social_weibo).toBe("https://weibo.com/xxx");
|
||||
expect(m.extras).toEqual([]); // 新键均为已知键,不落扩展行
|
||||
const qrDef = SITE_CONFIG_FIELD_DEFS.find(d => d.key === "qr_weibo")!;
|
||||
const socialDef = SITE_CONFIG_FIELD_DEFS.find(d => d.key === "social_douyin")!;
|
||||
expect(validateFieldValue(qrDef, "javascript:alert(1)")).toContain("路径");
|
||||
expect(validateFieldValue(socialDef, "not-a-url")).toContain("http");
|
||||
// 空值统一剔除:不污染 config_json
|
||||
m.fields.social_bilibili = " ";
|
||||
const out = JSON.parse(serializeSiteConfig(m));
|
||||
expect(out.footer_addresses).toContain("邮编:464300");
|
||||
expect(out).not.toHaveProperty("social_bilibili");
|
||||
expect(out.gallery_title).toBe("线上展厅");
|
||||
});
|
||||
it("B3:custom_css 为保留键,不入扩展行,parse/serialize 无损往返", () => {
|
||||
const json = JSON.stringify({ custom_css: ".site-header{padding:16px 0}", my_ext: "v" });
|
||||
const m = parseSiteConfig(json);
|
||||
|
||||
@@ -39,10 +39,55 @@ export const SITE_CONFIG_FIELD_DEFS: SiteConfigFieldDef[] = [
|
||||
placeholder: "留空默认「专业院系」",
|
||||
tip: "门户右上「专业院系」下拉的文案;仅主站有效(下拉由院系子站生成)。",
|
||||
},
|
||||
{
|
||||
key: "news_bg_image", label: "新闻卡背景图", group: "display", kind: "asset", maxLength: 500,
|
||||
placeholder: "如 /yschcms/upload/…/campus.jpg(留空用主题渐变)",
|
||||
tip: "首页「双栏新闻卡」板块的全幅背景大图(建议 1920×700);仅展示型皮肤(campus-glow)消费。",
|
||||
},
|
||||
{
|
||||
key: "gallery_title", label: "展厅标题", group: "display", kind: "text", maxLength: 50,
|
||||
placeholder: "留空默认「线上展厅」",
|
||||
tip: "首页图片画廊文区头主标题。",
|
||||
},
|
||||
{
|
||||
key: "gallery_subtitle", label: "展厅英文副题", group: "display", kind: "text", maxLength: 100,
|
||||
placeholder: "留空默认 Online Exhibition Hall",
|
||||
tip: "首页图片画廊文区头英文副题。",
|
||||
},
|
||||
{
|
||||
key: "contact", label: "联系方式", group: "footer", kind: "text", maxLength: 200, multiline: true,
|
||||
placeholder: "如:电话 0571-88888888 · 地址杭州市 XX 路 100 号",
|
||||
},
|
||||
{
|
||||
key: "footer_addresses", label: "多校区地址", group: "footer", kind: "text", maxLength: 500, multiline: true,
|
||||
placeholder: "一行一个校区,如:\nXX校区:杭州市 XX 路 100 号 邮编:310000",
|
||||
tip: "逐行展示在页脚品牌区旁;换行即分行(最多 500 字)。",
|
||||
},
|
||||
{
|
||||
key: "qr_wechat", label: "微信二维码", group: "footer", kind: "asset", maxLength: 500,
|
||||
placeholder: "如 /yschcms/upload/…/qr-wechat.png(留空不展示)",
|
||||
tip: "页脚二维码,悬停放大预览;建议先在「附件管理」上传。",
|
||||
},
|
||||
{
|
||||
key: "qr_weibo", label: "微博二维码", group: "footer", kind: "asset", maxLength: 500,
|
||||
placeholder: "如 /yschcms/upload/…/qr-weibo.png(留空不展示)",
|
||||
},
|
||||
{
|
||||
key: "social_wechat", label: "微信公众号", group: "footer", kind: "url", maxLength: 500,
|
||||
placeholder: "微信公众号主页链接,如 https://mp.weixin.qq.com/…(留空不展示图标)",
|
||||
},
|
||||
{
|
||||
key: "social_weibo", label: "微博", group: "footer", kind: "url", maxLength: 500,
|
||||
placeholder: "微博主页链接 https://weibo.com/…(留空不展示图标)",
|
||||
},
|
||||
{
|
||||
key: "social_douyin", label: "抖音", group: "footer", kind: "url", maxLength: 500,
|
||||
placeholder: "抖音主页链接 https://www.douyin.com/…(留空不展示图标)",
|
||||
},
|
||||
{
|
||||
key: "social_bilibili", label: "哔哩哔哩", group: "footer", kind: "url", maxLength: 500,
|
||||
placeholder: "B 站主页链接 https://space.bilibili.com/…(留空不展示图标)",
|
||||
},
|
||||
{
|
||||
key: "copyright", label: "版权文字", group: "footer", kind: "text", maxLength: 200,
|
||||
placeholder: "如:XX学校 · 版权所有 © 2026",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* previewPortalUrl:上级 path + 编码 → 子站门户地址(parentPath 空 → 单段)。
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { portalUrlOf, previewPortalUrl } from "@/utils/sitePath";
|
||||
import { portalUrlOf, previewPortalUrl, adminPathOf, stripSiteSeg, schoolSubPath, resolveRouteSite } from "@/utils/sitePath";
|
||||
|
||||
describe("portalUrlOf(T-W4-16)", () => {
|
||||
it("path 优先:嵌套 path 拼单段门户地址 + 尾斜杠", () => {
|
||||
@@ -27,3 +27,73 @@ describe("previewPortalUrl(T-W4-16)", () => {
|
||||
expect(previewPortalUrl(undefined, "sch-9")).toBe("/portal/sch-9/");
|
||||
});
|
||||
});
|
||||
|
||||
/* ===== docs/13 方案 C:管理端 URL 站点段 ===== */
|
||||
|
||||
describe("adminPathOf(T-D13-01)", () => {
|
||||
it("主站(siteCode 为空/null/等于学校编码)省略站点段——旧链全兼容", () => {
|
||||
expect(adminPathOf("demo", null, "article")).toBe("/sch/demo/article");
|
||||
expect(adminPathOf("demo", "", "article/edit/3")).toBe("/sch/demo/article/edit/3");
|
||||
expect(adminPathOf("demo", "demo", "site")).toBe("/sch/demo/site");
|
||||
});
|
||||
|
||||
it("院系子站插入 /site/{code} 中缀", () => {
|
||||
expect(adminPathOf("demo", "jdxy", "article")).toBe("/sch/demo/site/jdxy/article");
|
||||
expect(adminPathOf("demo", "jdxy", "/category")).toBe("/sch/demo/site/jdxy/category");
|
||||
expect(adminPathOf("demo", "jdxy", "article/edit/9")).toBe("/sch/demo/site/jdxy/article/edit/9");
|
||||
});
|
||||
|
||||
it("空白容错:schoolCode/siteCode 首尾空白 trimmed 后再拼(docs/15 T2)", () => {
|
||||
expect(adminPathOf("demo", " jdxy ", "article")).toBe("/sch/demo/site/jdxy/article");
|
||||
expect(adminPathOf(" demo ", null, "article")).toBe("/sch/demo/article");
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripSiteSeg(T-D13-02)", () => {
|
||||
it("剥掉 /site/{code} 中缀,回到菜单匹配用的主站形态", () => {
|
||||
expect(stripSiteSeg("/sch/demo/site/jdxy/article")).toBe("/sch/demo/article");
|
||||
expect(stripSiteSeg("/sch/demo/site/jdxy/article/edit/9")).toBe("/sch/demo/article/edit/9");
|
||||
expect(stripSiteSeg("/sch/demo/site/jdxy")).toBe("/sch/demo");
|
||||
});
|
||||
|
||||
it("主站形态原样返回(幂等)", () => {
|
||||
expect(stripSiteSeg("/sch/demo/article")).toBe("/sch/demo/article");
|
||||
expect(stripSiteSeg(stripSiteSeg("/sch/demo/site/jdxy/category"))).toBe("/sch/demo/category");
|
||||
});
|
||||
});
|
||||
|
||||
describe("schoolSubPath(T-D13-03)", () => {
|
||||
it("取学校壳内子路径(主站/子站形态一致)", () => {
|
||||
expect(schoolSubPath("/sch/demo/article")).toBe("/article");
|
||||
expect(schoolSubPath("/sch/demo/site/jdxy/article/edit/9")).toBe("/article/edit/9");
|
||||
expect(schoolSubPath("/sch/demo")).toBe("");
|
||||
});
|
||||
|
||||
it("非学校壳路径原样返回", () => {
|
||||
expect(schoolSubPath("/workbench")).toBe("/workbench");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveRouteSite(T-D13-04:URL 段 → 目标站点)", () => {
|
||||
const sites = [
|
||||
{ id: 1, code: "demo", name: "云海大学门户", parentSiteId: 0 },
|
||||
{ id: 2, code: "jdxy", name: "机电学院", parentSiteId: 1 },
|
||||
{ id: 3, code: "wtxy", name: "文学院", parentSiteId: 1 },
|
||||
];
|
||||
|
||||
it("无 siteCode(主站形态)→ 主站", () => {
|
||||
expect(resolveRouteSite(sites, 1, undefined)).toEqual({ site: sites[0], invalid: false });
|
||||
expect(resolveRouteSite(sites, 1, "")).toEqual({ site: sites[0], invalid: false });
|
||||
});
|
||||
|
||||
it("站点段命中本校子站 → 该子站", () => {
|
||||
expect(resolveRouteSite(sites, 1, "jdxy")).toEqual({ site: sites[1], invalid: false });
|
||||
});
|
||||
|
||||
it("不存在 / 跨校 / 把主站编码当子站段 → invalid 并回主站", () => {
|
||||
expect(resolveRouteSite(sites, 1, "nope")).toEqual({ site: sites[0], invalid: true });
|
||||
expect(resolveRouteSite(sites, 1, "demo")).toEqual({ site: sites[0], invalid: true });
|
||||
const other = [...sites, { id: 9, code: "jdxy2", name: "他校子站", parentSiteId: 8 }];
|
||||
expect(resolveRouteSite(other, 1, "jdxy2")).toEqual({ site: sites[0], invalid: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,3 +15,57 @@ export function previewPortalUrl(parentPath: string | undefined | null, code: st
|
||||
const parent = parentPath && parentPath.trim() ? parentPath.trim() : "";
|
||||
return parent ? `/portal/${parent}/${code}/` : `/portal/${code}/`;
|
||||
}
|
||||
|
||||
/* ===== 管理端路由站点段(docs/13:URL 为站点上下文唯一事实源) ===== */
|
||||
|
||||
/**
|
||||
* 管理端路由 path 唯一拼装入口:主站省略站点段(旧链全兼容),
|
||||
* 子站插入 `/site/{siteCode}` 中缀。siteCode 传 null/空/等于学校主站编码时不带段。
|
||||
* 全站硬拼 `/sch/...` 的跳转都应经此函数,防主站/子站形态混写。
|
||||
*/
|
||||
export function adminPathOf(schCode: string, siteCode: string | null | undefined, sub: string): string {
|
||||
const code = String(schCode ?? "").trim();
|
||||
const tail = String(sub ?? "").replace(/^\/+/, "");
|
||||
const seg = String(siteCode ?? "").trim();
|
||||
const isSub = !!seg && seg !== code;
|
||||
return isSub ? `/sch/${code}/site/${seg}/${tail}` : `/sch/${code}/${tail}`;
|
||||
}
|
||||
|
||||
/** 剥掉 URL 中的 `/site/{code}` 中缀(菜单高亮"剥段匹配"与面包屑取页面子路径用) */
|
||||
export function stripSiteSeg(p: string): string {
|
||||
return String(p ?? "").replace(/\/site\/[^/]+(?=\/|$)/, "");
|
||||
}
|
||||
|
||||
/** 完整管理端 path(主站形态,如 `/sch/demo/article`)取其学校壳内子路径(`/article`);非壳内路径返回原值 */
|
||||
export function schoolSubPath(fullPath: string): string {
|
||||
const p = stripSiteSeg(String(fullPath ?? ""));
|
||||
const m = p.match(/^\/sch\/[^/]+(\/.*)?$/);
|
||||
return m ? m[1] || "" : p;
|
||||
}
|
||||
|
||||
export interface RouteSiteHit {
|
||||
/** 命中的站点(无段=主站;非法段也回主站,配合 invalid 打回 URL) */
|
||||
site: { id: number; code: string; name: string; path?: string; parentSiteId?: number } | null;
|
||||
/** URL 站点段非法/跨校:调用方应打回同页主站形态 */
|
||||
invalid: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* URL 站点段 → 目标站点(docs/13 §3 守卫的纯函数核心,便于单测):
|
||||
* - 无 siteCode(主站形态 `/sch/{code}/**`)→ 主站;
|
||||
* - 有 siteCode → 在本校站点中按 code 匹配 **且** parentSiteId 归属本校主站(权限复用 user.sites);
|
||||
* 未命中(不存在/他校/把主站编码当子站段)→ invalid,site 回主站供打回使用。
|
||||
*/
|
||||
export function resolveRouteSite(
|
||||
sites: { id: number; code: string; name: string; path?: string; parentSiteId?: number }[],
|
||||
mainSiteId: number,
|
||||
siteCode?: string | null
|
||||
): RouteSiteHit {
|
||||
const main = sites.find(s => s.id === mainSiteId) || null;
|
||||
const code = String(siteCode ?? "").trim();
|
||||
if (!code) {
|
||||
return { site: main, invalid: false };
|
||||
}
|
||||
const hit = sites.find(s => s.code === code && s.parentSiteId === mainSiteId) || null;
|
||||
return hit ? { site: hit, invalid: false } : { site: main, invalid: true };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user