feat(theme): S5 学院蓝主题改造(管理端)——homeLayout编辑器/主题参数表单/站点设置/类型 + 单测
build / build (push) Successful in 2m19s
build / build (push) Successful in 2m19s
This commit is contained in:
@@ -7,10 +7,8 @@ import config from "@/assets/config/host.config";
|
||||
import { portalUrlOf, adminPathOf, previewPortalUrl } from "@/utils/sitePath";
|
||||
import { useCmsStore } from "@/store";
|
||||
import { SiteVo, ThemeVo } from "@/types/cms";
|
||||
import { FormModelValue, formModelFromTheme, mergeThemeParamsIntoConfigJson, themeParamsFromConfigJson } from "@/utils/themeParamForm";
|
||||
import { resolveEditableTheme } from "@/utils/themeEdit";
|
||||
import ThemePicker from "@/components/theme/ThemePicker.vue";
|
||||
import ThemeParamForm from "@/components/theme/ThemeParamForm.vue";
|
||||
import ThemePreviewFrame from "@/components/theme/ThemePreviewFrame.vue";
|
||||
import SiteConfigForm from "@/views/cms/components/SiteConfigForm.vue";
|
||||
import HomeLayoutEditor from "@/views/cms/homeLayout/HomeLayoutEditor.vue";
|
||||
@@ -46,7 +44,7 @@ const themes = ref<ThemeVo[]>([]);
|
||||
/** 分区清单(锚点导航;locked = 创建模式锁定) */
|
||||
const sections = [
|
||||
{ key: "basic", title: "基本信息", desc: "编码 / 名称 / 归属 / 状态" },
|
||||
{ key: "theme", title: "皮肤与外观", desc: "风格选择与微调" },
|
||||
{ key: "theme", title: "皮肤与外观", desc: "选皮肤 / 自定义 CSS" },
|
||||
{ key: "layout", title: "首页布局", desc: "区块序列编排", locked: true },
|
||||
{ key: "display", title: "基础展示", desc: "品牌标识与图片" },
|
||||
{ key: "footer", title: "页脚信息", desc: "联系方式与社媒" },
|
||||
@@ -71,10 +69,9 @@ const basic = reactive({
|
||||
status: 1,
|
||||
});
|
||||
|
||||
/** ② 皮肤与外观分区 */
|
||||
/** ② 皮肤与外观分区(工作项 T:参数微调已迁皮肤包管理,站点只负责选哪套包) */
|
||||
const theme = ref("default");
|
||||
const themeUnregistered = ref(false);
|
||||
const themeParams = ref<FormModelValue>({});
|
||||
const customCss = ref("");
|
||||
const previewVisible = ref(false);
|
||||
const editTheme = computed(() => themes.value.find(t => t.name === theme.value));
|
||||
@@ -187,8 +184,6 @@ function initFromRow(id: number) {
|
||||
theme.value = resolved.theme;
|
||||
configJson.value = r.configJson || "";
|
||||
customCss.value = readCustomCss(configJson.value);
|
||||
const t = editTheme.value;
|
||||
themeParams.value = t ? themeParamsFromConfigJson(configJson.value, t) : {};
|
||||
}
|
||||
|
||||
function readCustomCss(configJson: string): string {
|
||||
@@ -218,8 +213,6 @@ function pickTheme(name: string) {
|
||||
if (theme.value === name) return;
|
||||
theme.value = name;
|
||||
themeUnregistered.value = false;
|
||||
const t = editTheme.value;
|
||||
themeParams.value = t ? formModelFromTheme(t, themeParams.value) : {};
|
||||
}
|
||||
|
||||
function openPreview() {
|
||||
@@ -228,7 +221,7 @@ function openPreview() {
|
||||
|
||||
/**
|
||||
* 统一提交(§3.5 分区独立保存模型):payload = 页面当前事实源 + 基本信息快照;
|
||||
* patch.configJson 供分区保存前合并自己的键空间(如皮肤分区的 theme_params/custom_css)。
|
||||
* patch.configJson 供分区保存前合并自己的键空间(如皮肤分区的 custom_css)。
|
||||
* 创建成功原地 replace 到 site/setting/{id} 并解锁全部分区。
|
||||
*/
|
||||
async function submitSection(key: string, patch: { configJson?: string }) {
|
||||
@@ -259,7 +252,7 @@ async function submitSection(key: string, patch: { configJson?: string }) {
|
||||
Message.success("保存成功!");
|
||||
if (res) {
|
||||
row.value = res;
|
||||
// 服务端归一后的 configJson 刷新事实源(theme_params 合并等)
|
||||
// 服务端归一后的 configJson 刷新事实源
|
||||
if (typeof res.configJson === "string") {
|
||||
configJson.value = res.configJson;
|
||||
customCss.value = readCustomCss(configJson.value);
|
||||
@@ -281,10 +274,7 @@ async function saveBasic() {
|
||||
}
|
||||
|
||||
async function saveTheme() {
|
||||
const t = editTheme.value;
|
||||
const baseConfig = withCustomCss(configJson.value, customCss.value);
|
||||
const merged = t ? mergeThemeParamsIntoConfigJson(baseConfig, t, themeParams.value) : baseConfig;
|
||||
await submitSection("theme", { configJson: merged });
|
||||
await submitSection("theme", { configJson: withCustomCss(configJson.value, customCss.value) });
|
||||
}
|
||||
|
||||
async function saveDisplay() {
|
||||
@@ -466,7 +456,7 @@ watch(() => store.currentSchool?.schId, () => loadSites());
|
||||
<div id="sec-theme" class="card setting-card" data-section="theme">
|
||||
<div class="card-header">
|
||||
<span class="card-title">皮肤与外观</span>
|
||||
<span class="card-subtitle">风格选择 / 参数微调 / 站点级 CSS 覆盖</span>
|
||||
<span class="card-subtitle">风格选择 / 站点级 CSS 覆盖(微调请去「皮肤包管理」)</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="field-block">
|
||||
@@ -479,7 +469,6 @@ watch(() => store.currentSchool?.schId, () => loadSites());
|
||||
</Alert>
|
||||
<ThemePicker :themes="themes" :model-value="theme" @update:model-value="pickTheme" />
|
||||
<Button size="small" class="preview-btn" :disabled="!editTheme" @click="openPreview">预览本皮肤</Button>
|
||||
<ThemeParamForm :theme="editTheme" v-model="themeParams" />
|
||||
</div>
|
||||
<!-- B3 自定义样式(config_json.custom_css,服务端黑名单强校验) -->
|
||||
<Collapse class="css-collapse">
|
||||
@@ -594,7 +583,7 @@ watch(() => store.currentSchool?.schId, () => loadSites());
|
||||
v-if="previewVisible && editTheme && row"
|
||||
:site-path="row.path || row.code"
|
||||
:theme="theme"
|
||||
:theme-params="themeParams"
|
||||
:theme-params="editTheme?.paramValues || {}"
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
@@ -5,14 +5,18 @@ import { Message } from "view-ui-plus";
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
import WorkbenchHeader from "@/components/WorkbenchHeader.vue";
|
||||
import ThemePreviewFrame from "@/components/theme/ThemePreviewFrame.vue";
|
||||
import ThemeParamForm from "@/components/theme/ThemeParamForm.vue";
|
||||
import { ThemeParamVo, ThemeVo, SiteVo } from "@/types/cms";
|
||||
import { themeApi, siteApi, assetUrl } from "@/api/module/cms";
|
||||
import { isValidParamValue, paramValuesOf, FormModelValue } from "@/utils/themeParamForm";
|
||||
import { useCmsStore } from "@/store";
|
||||
|
||||
/**
|
||||
* 皮肤包管理(W1 + 任务二/四,2026-09-08):平台级全屏页(/theme-manage,超管专属,
|
||||
* 工作台按钮进入、不再挂左侧菜单)。内置包内容只读但显示名/描述可改(.meta 覆盖层)、
|
||||
* 外部包可管(上传/派生/编辑/删除)。标识名(name,即主题路径/URL 段)仅创建时可定,创建后不可改。
|
||||
* 工作项 T:参数值改挂皮肤包(themes/.meta/{name}.json → paramValues),编辑弹窗内嵌参数微调,
|
||||
* 内置包与外部包同权(值只落覆盖层,不碰包内 theme.json);卡片显示「已微调 M 项」。
|
||||
*/
|
||||
|
||||
const router = useRouter();
|
||||
@@ -41,6 +45,9 @@ const metaTheme = ref<ThemeVo | null>(null);
|
||||
const metaTitle = ref("");
|
||||
const metaDescription = ref("");
|
||||
const metaParams = ref<ThemeParamVo[]>([]);
|
||||
/** 包级参数值模型(paramValuesOf 回显);paramTouched 决定保存时是否携带 paramValues */
|
||||
const metaParamModel = ref<FormModelValue>({});
|
||||
const paramTouched = ref(false);
|
||||
|
||||
// 非超管深链兜底(引导期守卫放行,user 装载后弹回工作台)
|
||||
watch(
|
||||
@@ -64,9 +71,8 @@ async function run() {
|
||||
|
||||
onMounted(run);
|
||||
|
||||
/** 预览内容源站点(W1:与向导同语义,取启用站点按门户 path 预览) */
|
||||
async function openPreview(theme: ThemeVo) {
|
||||
previewTheme.value = theme;
|
||||
/** 预览内容源站点(W1:与向导同语义,取启用站点按门户 path 预览);亦借给参数微调的 image 上传归属 */
|
||||
async function ensureContentSources(): Promise<SiteVo[]> {
|
||||
if (!contentSources.value.length) {
|
||||
try {
|
||||
contentSources.value = ((await siteApi.list().send()) || []).filter(s => s.status === 1);
|
||||
@@ -74,7 +80,16 @@ async function openPreview(theme: ThemeVo) {
|
||||
contentSources.value = [];
|
||||
}
|
||||
}
|
||||
const first = contentSources.value[0];
|
||||
return contentSources.value;
|
||||
}
|
||||
|
||||
/** image 型参数上传需归属站点(后端 siteId 必填);无可用站点时控件退化为仅手填路径 */
|
||||
const metaSiteId = computed(() => contentSources.value[0]?.id);
|
||||
|
||||
async function openPreview(theme: ThemeVo) {
|
||||
previewTheme.value = theme;
|
||||
const sources = await ensureContentSources();
|
||||
const first = sources[0];
|
||||
previewSitePath.value = first ? first.path || first.code : "";
|
||||
previewVisible.value = true;
|
||||
}
|
||||
@@ -145,36 +160,55 @@ async function doAdd() {
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 编辑元数据(任务四:内置包亦可改显示名/描述,走覆盖层) ===== */
|
||||
function openMeta(theme: ThemeVo) {
|
||||
/* ===== 编辑元数据(任务四:内置包亦可改显示名/描述,走覆盖层;工作项 T:包级参数值同区编辑) ===== */
|
||||
async function openMeta(theme: ThemeVo) {
|
||||
metaTheme.value = theme;
|
||||
metaTitle.value = theme.title;
|
||||
metaDescription.value = theme.description || "";
|
||||
metaParams.value = (theme.params || []).map(p => ({ ...p }));
|
||||
metaParamModel.value = paramValuesOf(theme);
|
||||
paramTouched.value = false;
|
||||
metaVisible.value = true;
|
||||
if ((theme.params || []).some(p => p.type === "image")) await ensureContentSources();
|
||||
}
|
||||
|
||||
function onParamModelChange(v: FormModelValue) {
|
||||
metaParamModel.value = v;
|
||||
paramTouched.value = true;
|
||||
}
|
||||
|
||||
/** 待提交的包级参数值:只带「合法且偏离包内默认」的 key(= 卡片「已微调」口径,与后端整体替换语义一致) */
|
||||
function currentParamValues(): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
(metaTheme.value?.params || []).forEach(p => {
|
||||
const v = metaParamModel.value[p.key];
|
||||
if (isValidParamValue(p, v) && v !== (p.defaultValue ?? "")) result[p.key] = v;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
async function doSaveMeta() {
|
||||
const builtin = metaTheme.value?.source === "builtin";
|
||||
await themeApi
|
||||
.updateMeta(metaTheme.value!.name, {
|
||||
title: metaTitle.value,
|
||||
description: metaDescription.value,
|
||||
// 内置包参数 schema 不可改(后端强校验),不传即不改动
|
||||
params: builtin ? undefined : metaParams.value,
|
||||
})
|
||||
.send();
|
||||
const payload = {
|
||||
title: metaTitle.value,
|
||||
description: metaDescription.value,
|
||||
// 内置包参数 schema 不可改(后端强校验),不传即不改动
|
||||
params: builtin ? undefined : metaParams.value,
|
||||
} as { title: string; description: string; params?: unknown[]; paramValues?: Record<string, string> };
|
||||
// 未触碰参数区则不带 paramValues 键(后端 null=不改),避免改名顺带清空微调
|
||||
if (paramTouched.value) payload.paramValues = currentParamValues();
|
||||
await themeApi.updateMeta(metaTheme.value!.name, payload).send();
|
||||
Message.success("已保存,引用该皮肤的站点门户与预览立即生效");
|
||||
metaVisible.value = false;
|
||||
run();
|
||||
}
|
||||
|
||||
/** 内置包恢复默认显示名/描述(清空 .meta 覆盖层) */
|
||||
/** 内置包恢复默认(清空 .meta 覆盖层:改名 + 描述 + 包级参数微调一并回到包内声明) */
|
||||
async function doResetBuiltinMeta() {
|
||||
MyConfirm({
|
||||
title: "恢复默认名称",
|
||||
msg: `确定恢复「${metaTheme.value?.name}」的默认显示名称与描述吗?`,
|
||||
info: "将清除改名覆盖层,恢复包内声明的原始名称",
|
||||
info: "将清除改名覆盖层,恢复包内声明的原始名称;包级参数微调一并清空",
|
||||
async onOk() {
|
||||
await themeApi.updateMeta(metaTheme.value!.name, { title: "", description: "" }).send();
|
||||
Message.success("已恢复默认名称");
|
||||
@@ -206,6 +240,20 @@ function askRemove(theme: ThemeVo) {
|
||||
}
|
||||
|
||||
const builtinCount = computed(() => themes.value.filter(t => t.source === "builtin").length);
|
||||
|
||||
/** 卡片「已微调 M 项」口径:包级 paramValues 里合法且偏离 theme.json 默认值的 key 数 */
|
||||
function tweakedCount(t: ThemeVo): number {
|
||||
const stored = t.paramValues || {};
|
||||
return (t.params || []).filter(p => {
|
||||
const v = stored[p.key];
|
||||
return isValidParamValue(p, v) && v !== (p.defaultValue ?? "");
|
||||
}).length;
|
||||
}
|
||||
|
||||
function tweakSummary(t: ThemeVo): string {
|
||||
const m = tweakedCount(t);
|
||||
return m ? `已微调 ${m} 项` : "未微调";
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -234,13 +282,13 @@ const builtinCount = computed(() => themes.value.filter(t => t.source === "built
|
||||
<div class="meta">
|
||||
<p class="title">{{ t.title }}<span class="name">({{ t.name }})</span></p>
|
||||
<p class="desc">{{ t.description || "—" }}</p>
|
||||
<p class="stats">参数 {{ (t.params || []).length }} 个 · 被用 {{ t.inUseCount || 0 }} 站点</p>
|
||||
<p class="stats">参数 {{ (t.params || []).length }} 个 · {{ tweakSummary(t) }} · 被用 {{ t.inUseCount || 0 }} 站点</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<Button size="small" @click="openPreview(t)">预览</Button>
|
||||
<Tooltip
|
||||
:content="t.source === 'builtin'
|
||||
? '内置包内容只读,可改显示名称与描述'
|
||||
? '内置包内容只读,可改显示名称与描述、微调包级参数值'
|
||||
: (t.inUseCount || 0) > 0
|
||||
? `被站点引用:${(t.usedBySiteNames || []).join('、')}`
|
||||
: '外部包可编辑参数 schema'"
|
||||
@@ -334,14 +382,20 @@ const builtinCount = computed(() => themes.value.filter(t => t.source === "built
|
||||
</Option>
|
||||
</Select>
|
||||
</div>
|
||||
<ThemePreviewFrame fill :site-path="previewSitePath" :theme="previewTheme?.name" :theme-params="{}" />
|
||||
<ThemePreviewFrame
|
||||
fill
|
||||
:site-path="previewSitePath"
|
||||
:theme="previewTheme?.name"
|
||||
:theme-params="previewTheme?.paramValues || {}"
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<!-- 编辑弹窗(任务四:内置包仅显示名/描述;外部包另含参数 schema) -->
|
||||
<!-- 编辑弹窗(任务四:内置包仅显示名/描述;外部包另含参数 schema。工作项 T:包级参数值两态同权编辑) -->
|
||||
<Modal v-model="metaVisible" :title="`编辑皮肤:${metaTheme?.name || ''}`" :mask-closable="false" width="560px">
|
||||
<Alert v-if="metaTheme?.source === 'builtin'" type="info" show-icon style="margin-bottom: 12px">
|
||||
内置包内容(模板/资产)不可修改,仅显示名称与描述生效;标识名({{ metaTheme?.name }})不可改。
|
||||
下方的参数微调只写包级覆盖层,不改包内 theme.json。
|
||||
</Alert>
|
||||
<Form :label-width="110">
|
||||
<FormItem label="显示名称">
|
||||
@@ -357,6 +411,14 @@ const builtinCount = computed(() => themes.value.filter(t => t.source === "built
|
||||
</FormItem>
|
||||
</template>
|
||||
</Form>
|
||||
<div class="meta-params">
|
||||
<ThemeParamForm
|
||||
:theme="metaTheme || undefined"
|
||||
:model-value="metaParamModel"
|
||||
:site-id="metaSiteId"
|
||||
@update:model-value="onParamModelChange"
|
||||
/>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button v-if="metaTheme?.source === 'builtin'" style="float: left" @click="doResetBuiltinMeta">
|
||||
恢复默认名称
|
||||
@@ -462,6 +524,11 @@ const builtinCount = computed(() => themes.value.filter(t => t.source === "built
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
.meta-params {
|
||||
margin-top: 4px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px dashed #e9edf5;
|
||||
}
|
||||
.form-tip {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
BlockType,
|
||||
HOME_LAYOUT_VERSION,
|
||||
LayoutBlock,
|
||||
LINK_NAME_MAX,
|
||||
QUICK_LINK_MAX,
|
||||
flattenListCategories,
|
||||
genBlockId,
|
||||
genFromCategories,
|
||||
@@ -176,6 +178,24 @@ function onFieldChange() {
|
||||
emitUp();
|
||||
}
|
||||
|
||||
/** quick_links 入口行:编辑期内存允许留空行,提交时由 cleanBlock 剔除 */
|
||||
type LinkItems = { name: string; url: string }[];
|
||||
function itemsOf(block: LayoutBlock): LinkItems {
|
||||
return block.items || (block.items = []);
|
||||
}
|
||||
|
||||
function addLink(block: LayoutBlock) {
|
||||
const items = itemsOf(block);
|
||||
if (items.length >= QUICK_LINK_MAX) return;
|
||||
items.push({ name: "", url: "" });
|
||||
emitUp();
|
||||
}
|
||||
|
||||
function removeLink(block: LayoutBlock, index: number) {
|
||||
itemsOf(block).splice(index, 1);
|
||||
emitUp();
|
||||
}
|
||||
|
||||
/** 保存前校验(父分区调用):返回 null=通过,否则错误消息串 */
|
||||
function validate(): string | null {
|
||||
const errors = validateHomeLayout({ version: HOME_LAYOUT_VERSION, blocks: blocks.value });
|
||||
@@ -239,7 +259,7 @@ defineExpose({ validate });
|
||||
<Input
|
||||
v-if="f.kind === 'text'"
|
||||
v-model="selected[f.key]"
|
||||
:maxlength="100"
|
||||
:maxlength="f.maxlength || 100"
|
||||
:placeholder="f.placeholder"
|
||||
@on-change="onFieldChange"
|
||||
/>
|
||||
@@ -277,6 +297,35 @@ defineExpose({ validate });
|
||||
<p v-if="!flat.length" class="hle-cats-empty">暂无列表型栏目</p>
|
||||
</template>
|
||||
</div>
|
||||
<div v-else-if="f.kind === 'links'" class="hle-links">
|
||||
<div v-for="(l, li) in itemsOf(selected!)" :key="li" class="hle-link-row">
|
||||
<Input
|
||||
v-model="l.name"
|
||||
class="hle-link-name"
|
||||
:maxlength="LINK_NAME_MAX"
|
||||
placeholder="入口名称"
|
||||
@on-change="onFieldChange"
|
||||
/>
|
||||
<Input v-model="l.url" placeholder="https://… 或 / 站内路径" @on-change="onFieldChange" />
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon="md-trash"
|
||||
class="hle-link-del"
|
||||
@click="removeLink(selected!, li)"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="!itemsOf(selected!).length" class="hle-links-empty">还没有入口,点下方按钮添加</p>
|
||||
<Button
|
||||
class="hle-link-add"
|
||||
long
|
||||
icon="md-add"
|
||||
:disabled="itemsOf(selected!).length >= QUICK_LINK_MAX"
|
||||
@click="addLink(selected!)"
|
||||
>
|
||||
添加入口
|
||||
</Button>
|
||||
</div>
|
||||
<p v-if="f.tip" class="hle-field-tip">{{ f.tip }}</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -459,6 +508,31 @@ defineExpose({ validate });
|
||||
margin: 8px 0;
|
||||
}
|
||||
}
|
||||
.hle-links {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
.hle-link-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
.hle-link-name {
|
||||
flex: none;
|
||||
width: 100px;
|
||||
}
|
||||
}
|
||||
.hle-link-del:hover {
|
||||
color: #ed4014;
|
||||
}
|
||||
.hle-links-empty {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
.hle-link-add {
|
||||
border-style: dashed;
|
||||
}
|
||||
}
|
||||
}
|
||||
.hle-type-card {
|
||||
display: flex;
|
||||
|
||||
@@ -4,16 +4,17 @@
|
||||
* 编辑器属性面板/添加抽屉按本表 schema 驱动自动出现。
|
||||
* propFields 顺序即属性面板渲染顺序;showIf 控制条件显示(如 background 仅 news_grid)。
|
||||
*/
|
||||
import { LayoutBlock } from "./homeLayout";
|
||||
import { LIST_STYLES, styleLabel } from "./homeLayout";
|
||||
import { LayoutBlock, LINK_NAME_MAX, LIST_STYLES, NOTICE_TEXT_MAX, QUICK_LINK_MAX, styleLabel } from "./homeLayout";
|
||||
|
||||
export interface BlockPropField {
|
||||
key: string;
|
||||
label: string;
|
||||
kind: "text" | "number" | "select" | "switch" | "categories";
|
||||
kind: "text" | "number" | "select" | "switch" | "categories" | "links";
|
||||
options?: { value: string; label: string }[];
|
||||
min?: number;
|
||||
max?: number;
|
||||
/** text 输入框字数上限(缺省 100) */
|
||||
maxlength?: number;
|
||||
placeholder?: string;
|
||||
tip?: string;
|
||||
/** 条件显示(返回 false 时隐藏且不校验) */
|
||||
@@ -99,6 +100,42 @@ export const BLOCK_TYPES: BlockTypeDef[] = [
|
||||
{ key: "group", label: "链接分组", kind: "text", placeholder: "留空显示全部分组" },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "quick_links",
|
||||
title: "快捷通道",
|
||||
icon: "md-flash",
|
||||
description: "入口墙:自行填写 1~12 个跳转入口(名称 + 地址),不依赖栏目与友情链接数据",
|
||||
defaultProps: () => ({ items: [{ name: "", url: "" }] }),
|
||||
propFields: [
|
||||
{ key: "title", label: "区块标题", kind: "text", placeholder: "默认「快捷通道」" },
|
||||
{
|
||||
key: "items", label: "入口列表", kind: "links",
|
||||
tip: `每个入口 = 名称(${LINK_NAME_MAX} 字内)+ 地址(http(s):// 或站内 / 路径),最多 ${QUICK_LINK_MAX} 个`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "notice_bar",
|
||||
title: "公告条",
|
||||
icon: "md-volume-up",
|
||||
description: "通栏提示条:一条全站公告(放假/停网/招生等),可选跳转与醒目配色",
|
||||
defaultProps: () => ({ text: "", level: "info" }),
|
||||
propFields: [
|
||||
{ key: "title", label: "区块标题", kind: "text", placeholder: "默认「公告」,显示为左侧标签" },
|
||||
{
|
||||
key: "text", label: "公告正文", kind: "text", maxlength: NOTICE_TEXT_MAX,
|
||||
placeholder: "如:国庆放假安排通知如下……", tip: `通栏一行展示,建议一句话(${NOTICE_TEXT_MAX} 字内)`,
|
||||
},
|
||||
{ key: "url", label: "跳转地址", kind: "text", placeholder: "可选:http(s):// 或站内 / 路径" },
|
||||
{
|
||||
key: "level", label: "提示级别", kind: "select",
|
||||
options: [
|
||||
{ value: "info", label: "一般(主题色)" },
|
||||
{ value: "warn", label: "醒目(警示红)" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function blockDefOf(type: string): BlockTypeDef | undefined {
|
||||
@@ -120,6 +157,15 @@ export function blockSummary(block: LayoutBlock, namesOf: (id: number) => string
|
||||
parts.push("线上展厅图集");
|
||||
} else if (block.type === "links") {
|
||||
parts.push(block.group ? `分组:${block.group}` : "全部分组");
|
||||
} else if (block.type === "quick_links") {
|
||||
const filled = (block.items || []).filter(i => (i.name || "").trim() || (i.url || "").trim());
|
||||
parts.push(`${filled.length} 个入口`);
|
||||
} else if (block.type === "notice_bar") {
|
||||
parts.push(block.level === "warn" ? "醒目提示" : "普通提示");
|
||||
const text = (block.text || "").trim();
|
||||
if (text) {
|
||||
parts.push(text.length > 18 ? `${text.slice(0, 18)}…` : text);
|
||||
}
|
||||
}
|
||||
const ids = block.categoryIds || [];
|
||||
if (ids.length) {
|
||||
|
||||
@@ -126,6 +126,84 @@ describe("validateHomeLayout:与后端 SiteService 同语义", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("quick_links / notice_bar(v2 手册 §2.8):装拆与校验须与后端 S3 同语义", () => {
|
||||
const link = (name: string, url: string) => ({ name, url });
|
||||
const quick = (items: LayoutBlock["items"]): LayoutBlock => ({ id: "q", type: "quick_links", visible: true, items });
|
||||
const notice = (extra: Partial<LayoutBlock>): LayoutBlock => ({ id: "n", type: "notice_bar", visible: true, ...extra });
|
||||
|
||||
it("T-B-06 parseHomeLayout 接受两个新类型;normalizeBlock 还原 items/text/url/level 并剔除脏行", () => {
|
||||
const json = JSON.stringify({
|
||||
home_layout: {
|
||||
version: 1,
|
||||
blocks: [
|
||||
{
|
||||
id: "q1", type: "quick_links", title: "服务入口", items: [
|
||||
link("办事大厅", "https://bsch.edu.cn/hall"), link("图书馆", "/library"),
|
||||
"脏项", { name: 1, url: 2 },
|
||||
],
|
||||
},
|
||||
{ id: "n1", type: "notice_bar", text: "国庆放假", url: "/notice/gq", level: "warn" },
|
||||
{ id: "n2", type: "notice_bar", text: "常规提示" },
|
||||
],
|
||||
},
|
||||
});
|
||||
const layout = parseHomeLayout(json)!;
|
||||
expect(layout.blocks.length).toBe(3);
|
||||
expect(layout.blocks[0]).toMatchObject({
|
||||
id: "q1", type: "quick_links", title: "服务入口", visible: true,
|
||||
items: [link("办事大厅", "https://bsch.edu.cn/hall"), link("图书馆", "/library")],
|
||||
});
|
||||
expect(layout.blocks[1]).toMatchObject({ text: "国庆放假", url: "/notice/gq", level: "warn" });
|
||||
expect(layout.blocks[2].level).toBeUndefined();
|
||||
// 未知类型仍被跳过(两个新类型入白名单不等于放开任意类型)
|
||||
expect(parseHomeLayout(JSON.stringify({ home_layout: { blocks: [{ type: "quicklink" }] } }))!.blocks).toEqual([]);
|
||||
});
|
||||
|
||||
it("T-B-07 cleanBlock:两个新类型只写业务键、缺省值不落键、空入口行剔除", () => {
|
||||
expect(cleanBlock(quick([link("办事大厅", "https://x.edu.cn"), link("", "")]))).toEqual({
|
||||
id: "q", type: "quick_links", items: [link("办事大厅", "https://x.edu.cn")],
|
||||
});
|
||||
expect(cleanBlock(quick([]))).toEqual({ id: "q", type: "quick_links" });
|
||||
expect(cleanBlock({ ...quick([link("图", "/lib")]), visible: false })).toEqual({
|
||||
id: "q", type: "quick_links", visible: false, items: [link("图", "/lib")],
|
||||
});
|
||||
expect(cleanBlock(notice({ text: " 放假通知 ", url: "/a", level: "warn" }))).toEqual({
|
||||
id: "n", type: "notice_bar", text: "放假通知", url: "/a", level: "warn",
|
||||
});
|
||||
// level=info 是缺省值 → 不写;url 空 → 不写
|
||||
expect(cleanBlock(notice({ text: "提示", level: "info", url: "" }))).toEqual({ id: "n", type: "notice_bar", text: "提示" });
|
||||
});
|
||||
|
||||
it("T-B-07b parse→serialize 往返稳定(新区块不被改写)", () => {
|
||||
const json = JSON.stringify({
|
||||
home_layout: {
|
||||
version: 1,
|
||||
blocks: [
|
||||
{ id: "q1", type: "quick_links", items: [link("办事大厅", "https://x.edu.cn")] },
|
||||
{ id: "n1", type: "notice_bar", text: "放假通知", url: "/notice", level: "warn" },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(JSON.parse(serializeHomeLayout(json, parseHomeLayout(json)))).toEqual(JSON.parse(json));
|
||||
});
|
||||
|
||||
it("T-B-08 validateHomeLayout 与后端同语义:必填 / 12 项 / 20 字 / 200 字 / url 前缀 / level 白名单", () => {
|
||||
expect(validateHomeLayout({ version: 1, blocks: [quick([link("办事大厅", "https://x.edu.cn")]), notice({ text: "通知" })] })).toEqual([]);
|
||||
const joined = (blocks: LayoutBlock[]) => validateHomeLayout({ version: 1, blocks }).join(" ");
|
||||
expect(joined([quick([])])).toContain("入口");
|
||||
expect(joined([quick(Array.from({ length: 13 }, (_, i) => link(`入口${i}`, `/e${i}`)))])).toContain("12");
|
||||
expect(joined([quick([link("长".repeat(21), "/x")])])).toContain("20");
|
||||
expect(joined([quick([link("", "/x")])])).toContain("名称");
|
||||
expect(joined([quick([link("办事大厅", "")])])).toContain("http(s)://");
|
||||
expect(joined([quick([link("办事大厅", "javascript:alert(1)")])])).toContain("http(s)://");
|
||||
expect(joined([quick([link("办事大厅", "lib.edu.cn")])])).toContain("http(s)://");
|
||||
expect(joined([notice({})])).toContain("公告正文");
|
||||
expect(joined([notice({ text: "告".repeat(201) })])).toContain("200");
|
||||
expect(joined([notice({ text: "x", url: "javascript:alert(1)" })])).toContain("http(s)://");
|
||||
expect(joined([notice({ text: "x", level: "danger" })])).toContain("info");
|
||||
});
|
||||
});
|
||||
|
||||
describe("genFromCategories:一键生成与现状渲染等价", () => {
|
||||
const tree: CategoryTreeNode[] = [
|
||||
cat(1, "校园时讯", 1, "news_grid", [cat(11, "院系动态", 1), cat(12, "单页栏目", 2), cat(13, "外链", 3)]),
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
/**
|
||||
* 首页布局纯函数模块(docs/16 主线B §4.1/§4.5)。
|
||||
* 布局存 config_json.home_layout(零 DDL):{version:1, blocks:[…]};无该键 = 回落现状渲染(老站零迁移)。
|
||||
* 与后端契约对齐:type 白名单(article_list/ticker/gallery/links)、size 1~20、title/subtitle ≤100 字;
|
||||
* 与后端契约对齐:type 白名单(article_list/ticker/gallery/links/quick_links/notice_bar)、size 1~20、
|
||||
* title/subtitle ≤100 字、quick_links items ≤12 项(name ≤20 字、url 须 http(s):// 或 / 开头)、
|
||||
* notice_bar text 必填 ≤200 字、level ∈ {info,warn};
|
||||
* 服务端 SiteService.validateHomeLayout 强校验,本模块做编辑端同语义校验与往返装拆。
|
||||
* vitest 单测对象:homeLayout.spec.ts,不依赖组件挂载基建。
|
||||
*/
|
||||
import { CategoryTreeNode } from "@/types/cms";
|
||||
|
||||
/** v1 区块类型白名单(与后端 SiteService HOME_LAYOUT_BLOCK_TYPES 一致) */
|
||||
export type BlockType = "article_list" | "ticker" | "gallery" | "links";
|
||||
/** 区块类型白名单(与后端 SiteService HOME_LAYOUT_BLOCK_TYPES 一致) */
|
||||
export type BlockType = "article_list" | "ticker" | "gallery" | "links" | "quick_links" | "notice_bar";
|
||||
|
||||
/** quick_links 单个入口(v2 手册 §2.8) */
|
||||
export interface QuickLinkItem {
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/** article_list 展示形态(与后端渲染端 style 分支及主题 fragment 对应) */
|
||||
export const LIST_STYLES: { value: string; label: string }[] = [
|
||||
@@ -44,6 +52,14 @@ export interface LayoutBlock {
|
||||
source?: string;
|
||||
/** links 分组名(空=全部分组) */
|
||||
group?: string;
|
||||
/** quick_links 入口列表(≤12 项;name ≤20 字、url 为 http(s):// 或站内 / 路径) */
|
||||
items?: QuickLinkItem[];
|
||||
/** notice_bar 公告正文(必填,≤200 字) */
|
||||
text?: string;
|
||||
/** notice_bar 跳转地址(可选) */
|
||||
url?: string;
|
||||
/** notice_bar 提示级别:info=一般(缺省)| warn=醒目 */
|
||||
level?: string;
|
||||
}
|
||||
|
||||
export interface HomeLayout {
|
||||
@@ -53,12 +69,20 @@ export interface HomeLayout {
|
||||
|
||||
export const HOME_LAYOUT_VERSION = 1;
|
||||
|
||||
/** 服务端同款约束(SiteService §4.6) */
|
||||
export const BLOCK_TYPES_ALLOWED: BlockType[] = ["article_list", "ticker", "gallery", "links"];
|
||||
/** 服务端同款约束(SiteService §4.6 + v2 手册 §2.8) */
|
||||
export const BLOCK_TYPES_ALLOWED: BlockType[] = [
|
||||
"article_list", "ticker", "gallery", "links", "quick_links", "notice_bar",
|
||||
];
|
||||
export const SIZE_MIN = 1;
|
||||
export const SIZE_MAX = 20;
|
||||
export const TITLE_MAX = 100;
|
||||
export const MAX_BLOCKS = 30;
|
||||
export const QUICK_LINK_MAX = 12;
|
||||
export const LINK_NAME_MAX = 20;
|
||||
export const NOTICE_TEXT_MAX = 200;
|
||||
export const NOTICE_LEVELS = ["info", "warn"];
|
||||
/** 入口/跳转地址:http(s):// 绝对地址或站内 / 路径(与后端 HOME_LAYOUT_LINK_PATTERN 同语义) */
|
||||
const LINK_URL_PATTERN = /^(https?:\/\/\S+|\/.+)$/;
|
||||
|
||||
export function genBlockId(): string {
|
||||
return "b_" + Math.random().toString(36).slice(2, 8) + Date.now().toString(36).slice(-4);
|
||||
@@ -109,6 +133,24 @@ function toIdList(v: unknown): number[] | undefined {
|
||||
return ids.length ? ids : undefined;
|
||||
}
|
||||
|
||||
/** 入口列表容错还原:非对象项(字符串/数字/null)与非字符串 name/url 的脏行整行丢弃 */
|
||||
function toLinkItems(v: unknown): QuickLinkItem[] | undefined {
|
||||
if (!Array.isArray(v)) {
|
||||
return undefined;
|
||||
}
|
||||
const items: QuickLinkItem[] = [];
|
||||
for (const raw of v as unknown[]) {
|
||||
const obj = asObject(raw);
|
||||
const name = typeof obj?.["name"] === "string" ? (obj["name"] as string).trim() : null;
|
||||
const url = typeof obj?.["url"] === "string" ? (obj["url"] as string).trim() : null;
|
||||
if (name === null || url === null) {
|
||||
continue;
|
||||
}
|
||||
items.push({ name, url });
|
||||
}
|
||||
return items.length ? items : undefined;
|
||||
}
|
||||
|
||||
function normalizeBlock(obj: Record<string, unknown>, type: BlockType): LayoutBlock {
|
||||
const size = typeof obj["size"] === "number" ? Math.round(obj["size"]) : undefined;
|
||||
const block: LayoutBlock = {
|
||||
@@ -123,6 +165,15 @@ function normalizeBlock(obj: Record<string, unknown>, type: BlockType): LayoutBl
|
||||
if (typeof obj["background"] === "boolean") block.background = obj["background"];
|
||||
if (typeof obj["source"] === "string") block.source = obj["source"];
|
||||
if (typeof obj["group"] === "string") block.group = obj["group"];
|
||||
if (type === "quick_links") {
|
||||
const items = toLinkItems(obj["items"]);
|
||||
if (items) block.items = items;
|
||||
}
|
||||
if (type === "notice_bar") {
|
||||
if (typeof obj["text"] === "string") block.text = obj["text"];
|
||||
if (typeof obj["url"] === "string") block.url = obj["url"];
|
||||
if (typeof obj["level"] === "string") block.level = obj["level"];
|
||||
}
|
||||
const ids = toIdList(obj["categoryIds"]);
|
||||
if (ids) block.categoryIds = ids;
|
||||
return block;
|
||||
@@ -171,6 +222,26 @@ export function cleanBlock(block: LayoutBlock): Record<string, unknown> {
|
||||
if (group) {
|
||||
out["group"] = group;
|
||||
}
|
||||
} else if (block.type === "quick_links") {
|
||||
// 编辑期允许存在未填写的空行,提交前整行剔除(后端要求 name/url 齐备)
|
||||
const items = (block.items || [])
|
||||
.map(i => ({ name: (i.name || "").trim(), url: (i.url || "").trim() }))
|
||||
.filter(i => i.name || i.url);
|
||||
if (items.length) {
|
||||
out["items"] = items;
|
||||
}
|
||||
} else if (block.type === "notice_bar") {
|
||||
const text = (block.text || "").trim();
|
||||
if (text) {
|
||||
out["text"] = text;
|
||||
}
|
||||
const url = (block.url || "").trim();
|
||||
if (url) {
|
||||
out["url"] = url;
|
||||
}
|
||||
if (block.level === "warn") {
|
||||
out["level"] = "warn";
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -228,6 +299,41 @@ export function validateHomeLayout(layout: HomeLayout | null): string[] {
|
||||
if (b.type === "ticker" && b.source === "category" && !(b.categoryIds && b.categoryIds.length)) {
|
||||
errors.push(`${no}:指定栏目模式请选择栏目`);
|
||||
}
|
||||
if (b.type === "quick_links") {
|
||||
const items = b.items || [];
|
||||
if (!items.length) {
|
||||
errors.push(`${no}:请至少填写 1 个入口`);
|
||||
} else if (items.length > QUICK_LINK_MAX) {
|
||||
errors.push(`${no}:入口数量不能超过 ${QUICK_LINK_MAX} 个`);
|
||||
}
|
||||
items.forEach(item => {
|
||||
const name = (item.name || "").trim();
|
||||
const linkUrl = (item.url || "").trim();
|
||||
if (!name) {
|
||||
errors.push(`${no}:入口名称不能为空`);
|
||||
} else if (name.length > LINK_NAME_MAX) {
|
||||
errors.push(`${no}:入口名称不能超过 ${LINK_NAME_MAX} 字`);
|
||||
}
|
||||
if (!LINK_URL_PATTERN.test(linkUrl)) {
|
||||
errors.push(`${no}:入口「${name || linkUrl}」地址须为 http(s):// 开头或站内路径(/ 开头)`);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (b.type === "notice_bar") {
|
||||
const text = (b.text || "").trim();
|
||||
if (!text) {
|
||||
errors.push(`${no}:请填写公告正文`);
|
||||
} else if (text.length > NOTICE_TEXT_MAX) {
|
||||
errors.push(`${no}:公告正文不能超过 ${NOTICE_TEXT_MAX} 字`);
|
||||
}
|
||||
const linkUrl = (b.url || "").trim();
|
||||
if (linkUrl && !LINK_URL_PATTERN.test(linkUrl)) {
|
||||
errors.push(`${no}:跳转地址须为 http(s):// 开头或站内路径(/ 开头)`);
|
||||
}
|
||||
if (b.level != null && !NOTICE_LEVELS.includes(b.level)) {
|
||||
errors.push(`${no}:提示级别只能是 info 或 warn`);
|
||||
}
|
||||
}
|
||||
});
|
||||
return errors;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user