[CMS-VISUAL] F1: 站点设置 Tab 化(7 Tab 独立表单/dirty/保存,增量合并互不连带)+ 新建渐进式向导(两步后全开放,无灰态)+ 自定义样式独立 Tab + 页脚/基础展示字段视觉位置说明(165 测试绿)

This commit is contained in:
chenxing
2026-09-28 12:39:21 +08:00
parent 010fb39e85
commit af451703e7
4 changed files with 411 additions and 361 deletions
+58 -36
View File
@@ -1,10 +1,12 @@
// @vitest-environment jsdom
/**
* 站点设置中心(docs/16 主线A)挂载回归:
* ① 创建模式:③~⑦锁定("创建站点后开放"×5)、主按钮=「创建站点」、
* 校级锁定本校主站时门户地址预览随编码联动(原向导 T-W4-18 能力收编);
* ② 编辑模式:基本信息回填、未注册皮肤告警保留原值(任务三行为迁入②分区);
* ③ 分区保存:保存基本信息 = 当前 configJson 事实源全量提交(其他分区键不丢)。
* 站点设置中心(docs/16 主线A → docs/20 F1 Tab 化 + 渐进式向导)挂载回归:
* ① 创建模式(渐进式向导):无任何「创建站点后开放」灰态;主按钮=「创建站点,下一步」;
* 创建成功自动进入第二步(皮肤 Tab + Steps + 「保存并完成」);
* 校级锁定本校主站时门户地址预览随编码联动;
* ② 编辑模式:7 Tab 结构、基本信息回填、未注册皮肤告警保留原值;
* ③ Tab 独立保存:保存基本信息 = 以服务端事实源 configJson 提交(其他 Tab 键不丢、不连带提交);
* ④ 自定义样式独立 Tab(textarea 代码编辑器,非折叠面板)。
* 沿用手写 createApp 挂载 + vi.mock 桩风格(与 siteManage.spec.ts 一致)。
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -12,8 +14,6 @@ import { createApp, defineComponent, h, nextTick } from "vue";
import { createPinia } from "pinia";
import ViewUIPlus from "view-ui-plus";
Element.prototype.scrollIntoView = Element.prototype.scrollIntoView || (() => {});
const routeMock = vi.hoisted(() => ({ params: {} as Record<string, string>, query: {} as Record<string, string> }));
const routerMock = vi.hoisted(() => ({ push: vi.fn(), replace: vi.fn() }));
@@ -69,8 +69,8 @@ vi.mock("@/components/theme/ThemePreviewFrame.vue", () => ({
}));
vi.mock("@/views/cms/components/SiteConfigForm.vue", () => ({
default: defineComponent({
props: ["modelValue", "groups", "showExtras", "siteId"],
methods: { validate: async () => true },
props: ["modelValue", "groups", "siteId"],
methods: { validate: async () => true, ownFields: () => ({}) },
render: () => h("div", "config-form-stub"),
}),
}));
@@ -135,16 +135,15 @@ afterEach(() => {
cleanups = [];
});
describe("SiteSetting 创建模式(site/new,向导退役收编)", () => {
it("③~⑥分区锁定(创建站点后开放×4,F6 扩展参数分区已退役),主按钮=创建站点", async () => {
describe("SiteSetting 创建模式(docs/20 F1 渐进式向导)", () => {
it("第一步仅基本信息表单:无任何灰态锁定提示,主按钮=创建站点,下一步", async () => {
const el = mountPage();
await flush();
const tips = (el.textContent.match(/创建站点后开放/g) || []).length;
expect(tips).toBe(4);
expect(el.textContent).not.toContain("创建站点后开放");
expect(el.textContent).toContain("新建站点");
const createBtn = [...el.querySelectorAll("button")].find(b => b.textContent?.includes("创建站点"));
expect(createBtn).toBeTruthy();
expect(el.textContent).toContain("创建站点,下一步");
expect(el.querySelector('input[placeholder="URL 路径用,如 demo"]')).toBeTruthy();
});
it("无上级时门户地址预览随编码联动(/portal/{code}/)", async () => {
@@ -179,7 +178,7 @@ describe("SiteSetting 创建模式(site/new,向导退役收编)", () => {
expect(selectEl).toBeTruthy();
});
it("创建成功:replace 到站点设置页并解锁分区", async () => {
it("创建成功:自动进入第二步(皮肤 Tab + Steps + 保存并完成),全 Tab 开放", async () => {
const el = mountPage();
await flush();
@@ -189,26 +188,34 @@ describe("SiteSetting 创建模式(site/new,向导退役收编)", () => {
setInputValue(nameInput, "机械学院门户");
await flush();
const createBtn = [...el.querySelectorAll("button")].find(b => b.textContent?.includes("创建站点"))!;
const createBtn = [...el.querySelectorAll("button")].find(b => b.textContent?.includes("创建站点,下一步"))!;
createBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await flush();
await flush(10);
expect(mocks.siteSave).toHaveBeenCalledTimes(1);
const payload = mocks.siteSave.mock.calls[0][0];
expect(payload.code).toBe("jdxy");
expect(payload.name).toBe("机械学院门户");
expect(routerMock.replace).toHaveBeenCalledWith("/sch/jdxy/site/setting/99");
// 向导第二步:Steps 可见 + 皮肤 Tab 激活 + 完成按钮;无灰态
expect(el.textContent).toContain("皮肤与外观");
expect(el.textContent).toContain("保存并完成");
expect(el.textContent).toContain("跳过,稍后配置");
expect(el.textContent).not.toContain("创建站点后开放");
});
});
describe("SiteSetting 编辑模式(site/setting/:id)", () => {
it("基本信息回填 + 已注册皮肤无告警", async () => {
describe("SiteSetting 编辑模式(site/setting/:id,docs/20 F1 7 Tab)", () => {
it("7 个 Tab 标题齐备(基本信息…自定义样式),基本信息回填,无灰态提示", async () => {
routeMock.params = { id: "1" };
const el = mountPage();
await flush();
["基本信息", "皮肤与外观", "首页布局", "基础展示", "页脚信息", "SEO 与域名", "自定义样式"].forEach(t =>
expect(el.textContent).toContain(t)
);
expect(el.querySelector<HTMLInputElement>('input[placeholder="URL 路径用,如 demo"]')!.value).toBe("demo");
expect((el.textContent || "")).not.toContain("未在注册表中");
expect(el.textContent || "").not.toContain("未在注册表中");
expect(el.textContent).toContain("云海大学门户网站");
expect(el.textContent).not.toContain("创建站点后开放");
});
@@ -223,7 +230,7 @@ describe("SiteSetting 编辑模式(site/setting/:id)", () => {
expect(el.textContent).toContain("skin-x");
});
it("仅保存基本信息:以当前 configJson 事实源全量提交(home_layout 等其他分区键随行)", async () => {
it("保存基本信息:以服务端事实源 configJson 提交(home_layout 等其他 Tab 键随行不丢)", async () => {
routeMock.params = { id: "1" };
const el = mountPage();
await flush();
@@ -232,7 +239,7 @@ describe("SiteSetting 编辑模式(site/setting/:id)", () => {
setInputValue(nameInput, "改名后的门户");
await flush();
const saveBtn = [...el.querySelectorAll("button")].find(b => b.textContent?.includes("仅保存基本信息"))!;
const saveBtn = [...el.querySelectorAll("button")].find(b => b.textContent?.includes("保存基本信息"))!;
saveBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await flush();
@@ -244,27 +251,21 @@ describe("SiteSetting 编辑模式(site/setting/:id)", () => {
const config = JSON.parse(payload.configJson);
expect(config.logo).toBe("/cms/upload/logo.png");
expect(config.gallery_title).toBe("线上展厅"); // 被吸收旧键随事实源保留
expect(config.home_layout.blocks).toEqual([{ id: "b1", type: "links" }]); // 首页布局分区未动
expect(config.home_layout.blocks).toEqual([{ id: "b1", type: "links" }]); // 首页布局 Tab 未动
});
it("T-T-05: 皮肤分区不再有参数微调区(只留选皮肤 + 站点级 CSS),保存不写 theme_params", async () => {
it("T-T-05: 皮肤 Tab 只留选皮肤(无参数微调),保存不写 theme_params", async () => {
routeMock.params = { id: "1" };
const el = mountPage();
await flush();
const themeCard = el.querySelector('[data-section="theme"]')!;
expect(themeCard).toBeTruthy();
// ThemeParamForm 已被桩化为 theme-param-stub:站点设置页不再出现该桩
expect(themeCard.textContent).not.toContain("theme-param-stub");
expect(themeCard.textContent).not.toContain("参数微调");
// 站点级 CSS 覆盖区保留(副标题文案 + textarea 编辑框)
expect(themeCard.textContent).toContain("站点级 CSS 覆盖");
expect(themeCard.querySelector("textarea")).toBeTruthy();
// v1 §三:站点只负责选哪套包(选皮肤仍在)
expect(themeCard.textContent).toContain("theme-picker-stub");
expect(el.textContent).not.toContain("theme-param-stub");
expect(el.textContent).not.toContain("参数微调");
expect(el.textContent).toContain("theme-picker-stub");
const saveBtn = [...themeCard.querySelectorAll("button")].find(b =>
b.textContent?.includes("仅保存皮肤与外观")
const saveBtn = [...el.querySelectorAll("button")].find(b =>
b.textContent?.includes("保存皮肤与外观")
)!;
saveBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await flush();
@@ -274,4 +275,25 @@ describe("SiteSetting 编辑模式(site/setting/:id)", () => {
expect(config).not.toHaveProperty("theme_params"); // 参数值不再由站点提交
expect(config.logo).toBe("/cms/upload/logo.png"); // 其余键仍随事实源保留
});
it("F1:自定义样式为独立 Tab 的代码编辑器(textarea + 字符计数),非折叠面板", async () => {
routeMock.params = { id: "1" };
const el = mountPage();
await flush();
const cssTextarea = el.querySelector("textarea");
expect(cssTextarea).toBeTruthy();
expect(el.textContent).toContain("自定义样式");
expect(el.textContent).toContain("/10240 字符");
// 独立保存按钮
const cssSaveBtn = [...el.querySelectorAll("button")].find(b => b.textContent?.includes("保存自定义样式"))!;
expect(cssSaveBtn).toBeTruthy();
cssSaveBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await flush();
expect(mocks.siteSave).toHaveBeenCalledTimes(1);
const config = JSON.parse(mocks.siteSave.mock.calls[0][0].configJson);
expect(config).not.toHaveProperty("custom_css"); // 空样式 = 删除键
expect(config.home_layout).toBeTruthy(); // 其他 Tab 键不受影响
});
});
+32
View File
@@ -30,6 +30,7 @@ export const SITE_CONFIG_FIELD_DEFS: SiteConfigFieldDef[] = [
{
key: "favicon", label: "浏览器图标 favicon", group: "display", kind: "asset", maxLength: 500,
placeholder: "如 /yschcms/upload/…/favicon.png(留空用主题内置图标)",
tip: "浏览器标签页与收藏夹的小图标,不显示在页面正文中。",
},
{
key: "slogan", label: "首页标语", group: "display", kind: "text", maxLength: 200,
@@ -43,6 +44,7 @@ export const SITE_CONFIG_FIELD_DEFS: SiteConfigFieldDef[] = [
{
key: "contact", label: "联系方式", group: "footer", kind: "text", maxLength: 200, multiline: true,
placeholder: "如:电话 0571-88888888 · 地址杭州市 XX 路 100 号",
tip: "显示在页脚品牌区(页脚第一行位置)。",
},
{
key: "footer_addresses", label: "多校区地址", group: "footer", kind: "text", maxLength: 500, multiline: true,
@@ -57,30 +59,37 @@ export const SITE_CONFIG_FIELD_DEFS: SiteConfigFieldDef[] = [
{
key: "qr_weibo", label: "微博二维码", group: "footer", kind: "asset", maxLength: 500,
placeholder: "如 /yschcms/upload/…/qr-weibo.png(留空不展示)",
tip: "显示在页脚右侧二维码区,与微信二维码并排,悬停放大预览。",
},
{
key: "social_wechat", label: "微信公众号", group: "footer", kind: "url", maxLength: 500,
placeholder: "微信公众号主页链接,如 https://mp.weixin.qq.com/…(留空不展示图标)",
tip: "页脚社媒图标行(点击跳转链接地址)。",
},
{
key: "social_weibo", label: "微博", group: "footer", kind: "url", maxLength: 500,
placeholder: "微博主页链接 https://weibo.com/…(留空不展示图标)",
tip: "页脚社媒图标行(点击跳转链接地址)。",
},
{
key: "social_douyin", label: "抖音", group: "footer", kind: "url", maxLength: 500,
placeholder: "抖音主页链接 https://www.douyin.com/…(留空不展示图标)",
tip: "页脚社媒图标行(点击跳转链接地址)。",
},
{
key: "social_bilibili", label: "哔哩哔哩", group: "footer", kind: "url", maxLength: 500,
placeholder: "B 站主页链接 https://space.bilibili.com/…(留空不展示图标)",
tip: "页脚社媒图标行(点击跳转链接地址)。",
},
{
key: "copyright", label: "版权文字", group: "footer", kind: "text", maxLength: 200,
placeholder: "如:XX学校 · 版权所有 © 2026",
tip: "页脚最底行的版权声明文字。",
},
{
key: "beian", label: "备案号", group: "footer", kind: "text", maxLength: 100,
placeholder: "如:浙ICP备XXXXXXXX号-1",
tip: "页脚最底行版权文字旁的 ICP 备案号(可点击性质由模板决定,通常为纯文字)。",
},
{
key: "seo_description", label: "SEO 描述", group: "seo", kind: "text", maxLength: 500, multiline: true,
@@ -180,6 +189,29 @@ export function serializeSiteConfig(model: SiteConfigFormModel): string {
return JSON.stringify(out);
}
/**
* F1(docs/20)Tab 独立保存:服务端事实源 base + 本 Tab 增量 patch 合并——
* patch 值为空串/null/undefined = 删除该键;其余键原样保留。
* 各 Tab 只提交自己的键空间,其他 Tab 未保存的内存修改不会被连带提交。
*/
export function mergeConfigPatch(base: string | undefined | null, patch: Record<string, unknown>): string {
let obj: Record<string, unknown>;
try {
const parsed = base && base.trim() ? JSON.parse(base) : {};
obj = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? { ...parsed } : {};
} catch {
obj = {};
}
Object.entries(patch).forEach(([k, v]) => {
if (v === undefined || v === null || (typeof v === "string" && !v.trim())) {
delete obj[k];
} else {
obj[k] = v;
}
});
return JSON.stringify(obj);
}
/** 已知字段值校验:返回错误消息,空串=通过(与后端 validateConfigJson 同语义) */
export function validateFieldValue(def: SiteConfigFieldDef, value: string): string {
const v = (value || "").trim();
+303 -324
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import { computed, onMounted, reactive, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { Message } from "view-ui-plus";
import { siteApi, themeApi } from "@/api/module/cms";
@@ -8,19 +8,19 @@ import { portalUrlOf, adminPathOf, previewPortalUrl } from "@/utils/sitePath";
import { useCmsStore } from "@/store";
import { SiteVo, ThemeVo } from "@/types/cms";
import { resolveEditableTheme } from "@/utils/themeEdit";
import { mergeConfigPatch } from "@/utils/siteConfigForm";
import ThemePicker from "@/components/theme/ThemePicker.vue";
import ThemePreviewFrame from "@/components/theme/ThemePreviewFrame.vue";
import SiteConfigForm from "@/views/cms/components/SiteConfigForm.vue";
import HomeLayoutEditor from "@/views/cms/homeLayout/HomeLayoutEditor.vue";
/**
* 站点设置中心(docs/16 主线A 整页化):编辑/新增站点从模态框/三步向导 → 完整页面。
* projman 模式:锚点导航 + 分区卡片(card-header/body/footer 三段式),每分区独立保存。
* 单一 configJson 事实源(§3.5):各分区编辑器只读写自己的键空间(SiteConfigForm 分区作用域
* + HomeLayoutEditor 仅动 home_layout 键),「仅保存本分区」= 校验本分区 → 当前 configJson 全量提交;
* 其他分区未保存的修改仍在内存里,下次任一分区保存会一并带上(卡片 footer 已注明)。
* 创建模式(site/new,D5 向导退役):仅①基本信息②皮肤与外观可编辑,③~⑦锁定置灰,
* 创建成功原地 replace 到 site/setting/{id} 解锁全部分区。
* 站点设置中心(docs/16 主线A 整页化 → docs/20 F1 Tab 化 + 渐进式向导):
* 编辑模式 = 顶部 7 Tab(基本信息/皮肤与外观/首页布局/基础展示/页脚信息/SEO 与域名/自定义样式),
* 每个 Tab 独立表单、独立 dirty 状态、独立保存——保存只合并本 Tab 的键空间进
* lastSavedConfigJson(服务端事实源),其他 Tab 的未保存修改既不丢失也不被连带提交。
* 新建模式 = 渐进式向导:第一步只填基本信息,创建成功自动进入第二步(选皮肤),
* 完成后提示并展开全部 Tab;不再有任何「创建站点后开放」灰态锁定。
*/
const store = useCmsStore();
const route = useRoute();
@@ -31,7 +31,6 @@ const isCreate = computed(() => siteIdParam.value == null);
/** B1 建站权限拆分:校级(学校上下文内)可自助创建院系子站;超管行为不变 */
const isSchoolAdmin = computed(() => !store.isSuperAdmin && !!store.currentSchool);
const canCreateSite = computed(() => store.isSuperAdmin || isSchoolAdmin.value);
/** 校级锁定在本校主站下建子站(原向导 lockParentId 语义) */
const lockParentId = computed(() => (isCreate.value && isSchoolAdmin.value ? store.currentSchool?.mainSiteId : undefined));
@@ -41,23 +40,26 @@ const row = ref<SiteVo | null>(null);
const sites = ref<SiteVo[]>([]);
const themes = ref<ThemeVo[]>([]);
/** 分区清单(锚点导航;locked = 创建模式锁定)。F6(docs/20):「扩展参数」分区退役——
* 未知配置键由后端宽容保留,前端不再提供编辑区。 */
const sections = [
{ key: "basic", title: "基本信息", desc: "编码 / 名称 / 归属 / 状态" },
{ key: "theme", title: "皮肤与外观", desc: "选皮肤 / 自定义 CSS" },
{ key: "layout", title: "首页布局", desc: "区块序列编排", locked: true },
{ key: "display", title: "基础展示", desc: "品牌标识与图片" },
{ key: "footer", title: "页脚信息", desc: "联系方式与社媒" },
{ key: "seo", title: "SEO 与域名", desc: "收录与独立域名" },
];
const activeSection = ref("basic");
let observer: IntersectionObserver | null = null;
// ===== 向导(F1):1=填基本信息(创建模式);2=选皮肤(创建成功后);0=常规编辑 =====
const wizardStep = ref(isCreate.value ? 1 : 0);
/** 单一 configJson 事实源(§3.5) */
const configJson = ref("");
// ===== Tab 结构(F1:7 Tab;自定义样式独立成 Tab)=====
const TABS = [
{ key: "basic", title: "基本信息" },
{ key: "theme", title: "皮肤与外观" },
{ key: "layout", title: "首页布局" },
{ key: "display", title: "基础展示" },
{ key: "footer", title: "页脚信息" },
{ key: "seo", title: "SEO 与域名" },
{ key: "css", title: "自定义样式" },
] as const;
type TabKey = (typeof TABS)[number]["key"];
const activeTab = ref<string>("basic");
/** ① 基本信息分区 */
/** 服务端事实源:最近一次保存成功(或载入)时的 configJson —— 各 Tab 保存以此为基础做增量合并 */
const lastSavedConfigJson = ref("");
/** ① 基本信息表单 */
const basicRef = ref();
const basic = reactive({
id: undefined as number | undefined,
@@ -69,20 +71,36 @@ const basic = reactive({
status: 1,
});
/** ② 皮肤与外观分区(工作项 T:参数微调已迁皮肤包管理,站点只负责选哪套包) */
/** ② 皮肤与外观(工作项 T:参数微调已迁皮肤包管理,站点只负责选哪套包) */
const theme = ref("default");
const themeUnregistered = ref(false);
const customCss = ref("");
const previewVisible = ref(false);
const editTheme = computed(() => themes.value.find(t => t.name === theme.value));
/** ③~⑥ 分区表单 ref(分区独立校验) */
const layoutEditorRef = ref();
const displayFormRef = ref();
const footerFormRef = ref();
const seoFormRef = ref();
/** ⑦ 自定义样式(F1:独立 Tab,config_json.custom_css,服务端黑名单强校验) */
const customCss = ref("");
/** ③~⑥ 分区表单:各持独立模型(seed 后不再互相同步,保存各自合并) */
const layoutEditorRef = ref();
const layoutModel = ref("");
const displayFormRef = ref();
const displayModel = ref("");
const footerFormRef = ref();
const footerModel = ref("");
const seoFormRef = ref();
const seoModel = ref("");
/** 各 Tab 独立 dirty 状态(切 Tab 不丢;仅本 Tab 保存后清除) */
const dirty = reactive<Record<string, boolean>>({
basic: false, theme: false, layout: false, display: false, footer: false, seo: false, css: false,
});
const saving = reactive<Record<string, boolean>>({});
/** initFromRow / 保存回灌种子时挂起 dirty 标记 */
let seeding = false;
function tabLabel(tab: { key: string; title: string }): string {
return dirty[tab.key] ? `${tab.title} ●` : tab.title;
}
const schoolScopedSites = computed(() =>
!store.currentSchool ? sites.value : sites.value.filter(s => s.schId === store.currentSchool!.schId)
@@ -122,31 +140,6 @@ onMounted(async () => {
basic.parentSiteId = lockParentId.value;
}
loading.value = false;
patchAncestorOverflow();
nextTick(setupAnchorObserver);
});
/**
* 左侧锚点导航 sticky 的生效前提:布局遗留的 .main-container 带 overflow:hidden
* (清浮动时代产物),sticky 会改为相对这个"自身不滚动的隐藏滚动盒"定位 → 表现为
* 跟随内容滚走(2026-09-20 实测滚动 600px 导航 top=-387)。本页内容为 flex/常规流,
* 不依赖该 overflow 裁剪浮动,故进入本页时运行时改写、离开时还原,零全局影响。
*/
let patchedContainer: HTMLElement | null = null;
function patchAncestorOverflow() {
const el = document.querySelector<HTMLElement>(".base-wrap-main .main-container");
if (el && getComputedStyle(el).overflow === "hidden") {
patchedContainer = el;
el.style.overflow = "visible";
}
}
onBeforeUnmount(() => {
observer?.disconnect();
if (patchedContainer) {
patchedContainer.style.overflow = "";
patchedContainer = null;
}
});
async function loadSites() {
@@ -161,12 +154,14 @@ async function loadThemes() {
}
}
/** 载入站点 → 各 Tab 模型按 lastSavedConfigJson 种子(seeding 挂起 dirty 标记) */
function initFromRow(id: number) {
const r = sites.value.find(s => s.id === id);
if (!r) {
loadError.value = "站点不存在或无权管理该站点";
return;
}
seeding = true;
row.value = r;
// 主题值保留原值(未注册时告警,由服务端校验拒绝),不再静默改写为 default
const resolved = resolveEditableTheme(r.theme, themes.value.map(t => t.name));
@@ -181,8 +176,14 @@ function initFromRow(id: number) {
status: r.status,
});
theme.value = resolved.theme;
configJson.value = r.configJson || "";
customCss.value = readCustomCss(configJson.value);
lastSavedConfigJson.value = r.configJson || "";
layoutModel.value = lastSavedConfigJson.value;
displayModel.value = lastSavedConfigJson.value;
footerModel.value = lastSavedConfigJson.value;
seoModel.value = lastSavedConfigJson.value;
customCss.value = readCustomCss(lastSavedConfigJson.value);
Object.keys(dirty).forEach(k => (dirty[k] = false));
seeding = false;
}
function readCustomCss(configJson: string): string {
@@ -194,24 +195,24 @@ function readCustomCss(configJson: string): string {
}
}
/** 把面板值写入/移出 configJson(空值移除键;与 SiteConfigForm 的 preserved 往返兼容) */
function withCustomCss(configJson: string, css: string): string {
let obj: Record<string, unknown>;
/** HomeLayoutEditor 的整份 config 输出 → 仅取 home_layout 键的增量(无布局=删除键) */
function layoutPatchOf(model: string): { home_layout?: unknown } {
try {
obj = configJson && configJson.trim() ? JSON.parse(configJson) : {};
if (!obj || typeof obj !== "object" || Array.isArray(obj)) obj = {};
const parsed = model && model.trim() ? JSON.parse(model) : {};
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return { home_layout: (parsed as Record<string, unknown>)["home_layout"] };
}
} catch {
obj = {};
// 非法 JSON 视为无变更
}
if (css.trim()) obj.custom_css = css;
else delete obj.custom_css;
return JSON.stringify(obj);
return {};
}
function pickTheme(name: string) {
if (theme.value === name) return;
theme.value = name;
themeUnregistered.value = false;
dirty.theme = true;
}
function openPreview() {
@@ -219,13 +220,16 @@ function openPreview() {
}
/**
* 统一提交(§3.5 分区独立保存模型):payload = 页面当前事实源 + 基本信息快照;
* patch.configJson 供分区保存前合并自己的键空间(如皮肤分区的 custom_css)。
* 创建成功原地 replace 到 site/setting/{id} 并解锁全部分区。
* 统一提交(F1 Tab 独立保存):site 字段 = 当前基本信息快照 + 当前皮肤;
* configJson = lastSavedConfigJson + 本 Tab 增量 patch(patch=null = 不动 config)。
* 成功后回写事实源并清本 Tab dirty;其他 Tab 的内存修改不受影响。
*/
async function submitSection(key: string, patch: { configJson?: string }) {
saving[key] = true;
async function persistTab(tab: TabKey, patch: Record<string, unknown> | null) {
saving[tab] = true;
try {
const configJson = patch == null
? lastSavedConfigJson.value || undefined
: mergeConfigPatch(lastSavedConfigJson.value, patch);
const res = await siteApi
.save({
id: basic.id,
@@ -236,30 +240,40 @@ async function submitSection(key: string, patch: { configJson?: string }) {
schName: isSchoolAdmin.value ? undefined : basic.schName || undefined,
name: basic.name,
theme: theme.value || undefined,
configJson: patch.configJson ?? configJson.value,
configJson,
status: basic.status,
})
.send();
if (isCreate.value && res && res.id) {
Message.success("站点创建成功!全部分区已开放");
// 向导第一步完成:原地 replace 解锁全部 Tab,自动进入第二步(选皮肤)
Message.success("站点创建成功!接下来为它挑选一套皮肤");
await loadSites();
const main = sites.value.find(s => s.id === (res.parentSiteId || 0));
const schCode = main?.code || res.code;
await router.replace(adminPathOf(schCode, res.parentSiteId ? res.code : null, `site/setting/${res.id}`));
initFromRow(res.id);
} else {
Message.success("保存成功!");
if (res) {
row.value = res;
// 服务端归一后的 configJson 刷新事实源
if (typeof res.configJson === "string") {
configJson.value = res.configJson;
customCss.value = readCustomCss(configJson.value);
}
wizardStep.value = 2;
activeTab.value = "theme";
return;
}
Message.success("保存成功!");
if (res) {
row.value = res;
if (typeof res.configJson === "string") {
lastSavedConfigJson.value = res.configJson;
}
}
dirty[tab] = false;
// 保存回灌:本 Tab 模型与服务端事实源对齐(seeding 挂起 dirty 重标)
seeding = true;
if (tab === "layout") {
layoutModel.value = lastSavedConfigJson.value;
} else if (tab === "css") {
customCss.value = readCustomCss(lastSavedConfigJson.value);
}
seeding = false;
} finally {
saving[key] = false;
saving[tab] = false;
}
}
@@ -269,35 +283,24 @@ async function saveBasic() {
Message.warning("请填写红色星号内容!"); // bsch §4 统一提示
return;
}
await submitSection("basic", {});
await persistTab("basic", null);
}
async function saveTheme() {
await submitSection("theme", { configJson: withCustomCss(configJson.value, customCss.value) });
await persistTab("theme", null);
}
async function saveDisplay() {
if (!(await displayFormRef.value?.validate())) {
Message.warning("请填写红色星号内容!"); // bsch §4
return;
}
await submitSection("display", {});
/** 向导第二步收口:保存皮肤 → 提示可继续配置首页布局,展开全部 Tab */
async function finishWizard() {
await persistTab("theme", null);
wizardStep.value = 0;
Message.success("站点创建成功,可继续配置首页布局");
}
async function saveFooter() {
if (!(await footerFormRef.value?.validate())) {
Message.warning("请填写红色星号内容!"); // bsch §4
return;
}
await submitSection("footer", {});
}
async function saveSeo() {
if (!(await seoFormRef.value?.validate())) {
Message.warning("请填写红色星号内容!"); // bsch §4
return;
}
await submitSection("seo", {});
/** 向导第二步跳过(皮肤先用当前选择/默认) */
function skipWizard() {
wizardStep.value = 0;
Message.info("已跳过皮肤配置,全部设置已开放");
}
async function saveLayout() {
@@ -306,7 +309,19 @@ async function saveLayout() {
Message.error(err);
return;
}
await submitSection("layout", {});
await persistTab("layout", layoutPatchOf(layoutModel.value));
}
async function saveGroupForm(tab: "display" | "footer" | "seo", formRef: typeof displayFormRef) {
if (!(await formRef.value?.validate())) {
Message.warning("请填写红色星号内容!"); // bsch §4
return;
}
await persistTab(tab, formRef.value?.ownFields?.() || {});
}
async function saveCss() {
await persistTab("css", { custom_css: customCss.value });
}
/** 门户预览(编辑模式):新窗口打开真实门户 */
@@ -325,28 +340,25 @@ function backToList() {
router.push(adminPathOf(schCode, row.value?.parentSiteId ? row.value.code : null, "site"));
}
function setupAnchorObserver() {
if (typeof IntersectionObserver === "undefined") return;
observer = new IntersectionObserver(
entries => {
entries.forEach(e => {
if (e.isIntersecting) {
activeSection.value = (e.target as HTMLElement).dataset.section || activeSection.value;
}
});
},
{ rootMargin: "-90px 0px -55% 0px", threshold: 0 },
);
sections.forEach(s => {
const el = document.getElementById(`sec-${s.key}`);
if (el) observer!.observe(el);
});
}
function scrollToSection(key: string) {
activeSection.value = key;
document.getElementById(`sec-${key}`)?.scrollIntoView({ behavior: "smooth", block: "start" });
}
// ===== dirty 追踪:各 Tab 模型变更即标脏(seeding 期间挂起)=====
watch(basic, () => {
if (!seeding) dirty.basic = true;
});
watch(customCss, () => {
if (!seeding) dirty.css = true;
});
watch(layoutModel, () => {
if (!seeding) dirty.layout = true;
});
watch(displayModel, () => {
if (!seeding) dirty.display = true;
});
watch(footerModel, () => {
if (!seeding) dirty.footer = true;
});
watch(seoModel, () => {
if (!seeding) dirty.seo = true;
});
// 学校上下文切换即时重载(编辑中的站点归属变化防御)
watch(() => store.currentSchool?.schId, () => loadSites());
@@ -372,28 +384,65 @@ watch(() => store.currentSchool?.schId, () => loadSites());
<Alert v-if="loadError" type="error" show-icon style="margin-bottom: 12px">{{ loadError }}</Alert>
<Spin v-if="loading" size="large" fix />
<div class="ss-layout">
<!-- 锚点导航 -->
<aside class="ss-nav">
<div
v-for="s in sections"
:key="s.key"
class="ss-nav-item"
:class="{ active: activeSection === s.key }"
@click="scrollToSection(s.key)"
>
<span class="ss-nav-title">
{{ s.title }}
<Icon v-if="isCreate && s.locked" type="md-lock" class="ss-nav-lock" />
</span>
<span class="ss-nav-desc">{{ s.desc }}</span>
</div>
</aside>
<!-- 渐进式向导进度(F1):仅创建流程期间展示 -->
<Steps v-if="wizardStep > 0" :current="wizardStep" class="ss-steps">
<Step title="基本信息" content="编码 / 名称 / 归属"></Step>
<Step title="皮肤与外观" content="挑一套门户皮肤"></Step>
<Step title="完整设置" content="首页布局等全部配置开放"></Step>
</Steps>
<!-- 分区卡片流 -->
<div class="ss-cards">
<!-- ① 基本信息 -->
<div id="sec-basic" class="card setting-card" data-section="basic">
<!-- ===== 向导第一步(创建模式):仅基本信息表单 ===== -->
<div v-if="wizardStep === 1" class="card setting-card">
<div class="card-header">
<span class="card-title">基本信息</span>
<span class="card-subtitle">站点编码 / 名称 / 归属与状态</span>
</div>
<div class="card-body">
<Form ref="basicRef" :model="basic" :rules="rules" :label-width="110">
<FormItem label="站点编码" prop="code">
<Input v-model="basic.code" placeholder="URL 路径用,如 demo" style="max-width: 320px" />
</FormItem>
<FormItem label="站点名称" prop="name">
<Input v-model="basic.name" :maxlength="128" placeholder="如 XX学校门户网站" style="max-width: 320px" />
</FormItem>
<FormItem label="上级站点">
<Select v-model="basic.parentSiteId" :clearable="!lockParentId" :disabled="!!lockParentId" placeholder="空=顶层校站点;选主站则为院系子站" style="width: 320px">
<Option v-for="s in parentOptions" :key="s.id" :value="s.id">{{ s.name }}({{ s.code }})</Option>
</Select>
<p class="form-hint">
院系子站挂在主站下,门户 header 右上出现"专业院系"下拉;层级固定两层。
{{ isSchoolAdmin ? "校级角色仅可在本校主站之间改挂。" : "" }}
</p>
</FormItem>
<FormItem v-if="store.isSuperAdmin" label="学校ID" prop="schId" :rules="schIdRequired ? schIdRules : []">
<InputNumber v-model="basic.schId" :min="1" placeholder="UserBase sys_group.id" style="width: 200px" />
<div v-if="schIdRequired" class="form-hint">顶层站点必填(否则校级角色不可管理);院系子站可留空自动继承主站</div>
</FormItem>
<FormItem v-if="store.isSuperAdmin" label="学校名称">
<Input v-model="basic.schName" :maxlength="128" style="max-width: 320px" />
</FormItem>
<FormItem label="门户地址预览">
<span class="ss-portal-url">{{ portalPreviewUrl || "输入编码后自动生成" }}</span>
<div class="form-hint">子站地址 = 上级地址 / 编码;创建后经站点管理「门户路径」列直达</div>
</FormItem>
<FormItem label="状态">
<RadioGroup v-model="basic.status">
<Radio :label="1">启用</Radio>
<Radio :label="0">停用</Radio>
</RadioGroup>
</FormItem>
</Form>
</div>
<div class="card-footer">
<Button type="primary" :loading="saving.basic" @click="saveBasic">创建站点,下一步</Button>
<span class="card-hint">创建后自动进入第二步挑选皮肤,其余配置随即全部开放</span>
</div>
</div>
<!-- ===== 常规编辑(含向导第二步起):顶部 Tab,7 个分区各持独立表单与保存 ===== -->
<Tabs v-else v-model="activeTab" class="ss-tabs" :animated="false">
<TabPane :label="tabLabel(TABS[0])" name="basic">
<div class="card setting-card">
<div class="card-header">
<span class="card-title">基本信息</span>
<span class="card-subtitle">站点编码 / 名称 / 归属与状态</span>
@@ -423,9 +472,8 @@ watch(() => store.currentSchool?.schId, () => loadSites());
<FormItem v-if="store.isSuperAdmin" label="学校名称">
<Input v-model="basic.schName" :maxlength="128" style="max-width: 320px" />
</FormItem>
<FormItem label="门户地址预览">
<span class="ss-portal-url">{{ portalPreviewUrl || "输入编码后自动生成" }}</span>
<div class="form-hint">子站地址 = 上级地址 / 编码;创建后经站点管理「门户路径」列直达</div>
<FormItem label="门户地址">
<span class="ss-portal-url">{{ portalPreviewUrl || "-" }}</span>
</FormItem>
<FormItem label="状态">
<RadioGroup v-model="basic.status">
@@ -436,18 +484,17 @@ watch(() => store.currentSchool?.schId, () => loadSites());
</Form>
</div>
<div class="card-footer">
<Button type="primary" :loading="saving.basic" @click="saveBasic">
{{ isCreate ? "创建站点" : "仅保存基本信息" }}
</Button>
<span class="card-hint">仅保存本分区;其他分区未保存的修改仍在页面上,下次任一分区保存会一并带上</span>
<Button type="primary" :loading="saving.basic" @click="saveBasic">保存基本信息</Button>
<span class="card-hint">仅保存本 Tab;其他 Tab 未保存的修改保留在各自表单里,不会连带提交</span>
</div>
</div>
</TabPane>
<!-- ② 皮肤与外观 -->
<div id="sec-theme" class="card setting-card" data-section="theme">
<TabPane :label="tabLabel(TABS[1])" name="theme">
<div class="card setting-card">
<div class="card-header">
<span class="card-title">皮肤与外观</span>
<span class="card-subtitle">风格选择 / 站点级 CSS 覆盖(微调请去「皮肤包管理」)</span>
<span class="card-subtitle">风格选择(皮肤参数的调整入口在「皮肤包管理」;自定义 CSS 已独立为专属 Tab)</span>
</div>
<div class="card-body">
<div class="field-block">
@@ -461,107 +508,124 @@ watch(() => store.currentSchool?.schId, () => loadSites());
<ThemePicker :themes="themes" :model-value="theme" @update:model-value="pickTheme" />
<Button size="small" class="preview-btn" :disabled="!editTheme" @click="openPreview">预览本皮肤</Button>
</div>
<!-- B3 自定义样式(config_json.custom_css,服务端黑名单强校验) -->
<Collapse class="css-collapse">
<Panel name="css" header="自定义样式(站点级 CSS 覆盖)">
<Input
v-model="customCss"
type="textarea"
:rows="6"
:maxlength="10240"
placeholder="如:--primary:#59aeeb; 或 .site-header{height:80px}"
class="css-editor"
/>
<div class="css-meta">
<span>{{ customCss.length }}/10240 字符</span>
<span>保存后门户立即生效(缓存自动失效);服务端将拒绝 &lt;/style、&lt;script、@import、expression( 等危险内容</span>
</div>
</Panel>
</Collapse>
</div>
<div class="card-footer">
<Button type="primary" :loading="saving.theme" @click="saveTheme">仅保存皮肤与外观</Button>
<span class="card-hint">换肤/改参数保存后门户立即生效(缓存已自动失效);其他分区未保存的修改仍在页面上,下次任一分区保存会一并带上</span>
<template v-if="wizardStep === 2">
<Button type="primary" :loading="saving.theme" @click="finishWizard">保存并完成</Button>
<Button @click="skipWizard">跳过,稍后配置</Button>
<span class="card-hint">第二步(选皮肤)完成后全部设置开放</span>
</template>
<template v-else>
<Button type="primary" :loading="saving.theme" @click="saveTheme">保存皮肤与外观</Button>
<span class="card-hint">换肤保存后门户立即生效(缓存已自动失效)</span>
</template>
</div>
</div>
</TabPane>
<!-- ③ 首页布局 -->
<div id="sec-layout" class="card setting-card" :class="{ locked: isCreate }" data-section="layout">
<TabPane :label="tabLabel(TABS[2])" name="layout">
<div class="card setting-card">
<div class="card-header">
<span class="card-title">首页布局</span>
<span class="card-subtitle">首页 = 可编排的区块序列(要闻/栏目文章/画廊/链接组)</span>
</div>
<Alert v-if="isCreate" type="info" show-icon class="ss-lock-tip">创建站点后开放</Alert>
<div class="card-body">
<HomeLayoutEditor v-model="configJson" :site-id="basic.id" ref="layoutEditorRef" @save="saveLayout" />
<HomeLayoutEditor v-model="layoutModel" :site-id="basic.id" ref="layoutEditorRef" @save="saveLayout" />
</div>
<div class="card-footer">
<Button type="primary" :loading="saving.layout" :disabled="isCreate" @click="saveLayout">仅保存首页布局</Button>
<Button type="primary" :loading="saving.layout" @click="saveLayout">保存首页布局</Button>
<span class="card-hint">保存后门户立即生效(缓存自动失效);未编排布局时门户按「全部栏目自动渲染」默认形态展示</span>
</div>
</div>
</TabPane>
<!-- ④ 基础展示 -->
<div id="sec-display" class="card setting-card" :class="{ locked: isCreate }" data-section="display">
<TabPane :label="tabLabel(TABS[3])" name="display">
<div class="card setting-card">
<div class="card-header">
<span class="card-title">基础展示</span>
<span class="card-subtitle">Logo / favicon / 标语 / 院系文案(图片支持直接上传)</span>
</div>
<Alert v-if="isCreate" type="info" show-icon class="ss-lock-tip">创建站点后开放</Alert>
<div class="card-body">
<SiteConfigForm ref="displayFormRef" v-model="configJson" :groups="['display']" :site-id="basic.id" />
<SiteConfigForm ref="displayFormRef" v-model="displayModel" :groups="['display']" :site-id="basic.id" />
</div>
<div class="card-footer">
<Button type="primary" :loading="saving.display" :disabled="isCreate" @click="saveDisplay">仅保存基础展示</Button>
<span class="card-hint">仅保存本分区;其他分区未保存的修改仍在页面上,下次任一分区保存会一并带上</span>
<Button type="primary" :loading="saving.display" @click="saveGroupForm('display', displayFormRef)">保存基础展示</Button>
<span class="card-hint">仅保存本 Tab 的字段</span>
</div>
</div>
</TabPane>
<!-- ⑤ 页脚信息 -->
<div id="sec-footer" class="card setting-card" :class="{ locked: isCreate }" data-section="footer">
<TabPane :label="tabLabel(TABS[4])" name="footer">
<div class="card setting-card">
<div class="card-header">
<span class="card-title">页脚信息</span>
<span class="card-subtitle">联系方式 / 多校区地址 / 二维码 / 社媒 / 版权备案</span>
</div>
<Alert v-if="isCreate" type="info" show-icon class="ss-lock-tip">创建站点后开放</Alert>
<div class="card-body">
<SiteConfigForm ref="footerFormRef" v-model="configJson" :groups="['footer']" :site-id="basic.id" />
<SiteConfigForm ref="footerFormRef" v-model="footerModel" :groups="['footer']" :site-id="basic.id" />
</div>
<div class="card-footer">
<Button type="primary" :loading="saving.footer" :disabled="isCreate" @click="saveFooter">仅保存页脚信息</Button>
<span class="card-hint">仅保存本分区;其他分区未保存的修改仍在页面上,下次任一分区保存会一并带上</span>
<Button type="primary" :loading="saving.footer" @click="saveGroupForm('footer', footerFormRef)">保存页脚信息</Button>
<span class="card-hint">仅保存本 Tab 的字段</span>
</div>
</div>
</TabPane>
<!-- ⑥ SEO 与域名 -->
<div id="sec-seo" class="card setting-card" :class="{ locked: isCreate }" data-section="seo">
<TabPane :label="tabLabel(TABS[5])" name="seo">
<div class="card setting-card">
<div class="card-header">
<span class="card-title">SEO 与域名</span>
<span class="card-subtitle">搜索引擎摘要 / 独立域名台账与对外基地址</span>
</div>
<Alert v-if="isCreate" type="info" show-icon class="ss-lock-tip">创建站点后开放</Alert>
<div class="card-body">
<SiteConfigForm ref="seoFormRef" v-model="configJson" :groups="['seo', 'domain']" :site-id="basic.id" />
<SiteConfigForm ref="seoFormRef" v-model="seoModel" :groups="['seo', 'domain']" :site-id="basic.id" />
</div>
<div class="card-footer">
<Button type="primary" :loading="saving.seo" :disabled="isCreate" @click="saveSeo">仅保存 SEO 与域名</Button>
<span class="card-hint">仅保存本分区;其他分区未保存的修改仍在页面上,下次任一分区保存会一并带上</span>
<Button type="primary" :loading="saving.seo" @click="saveGroupForm('seo', seoFormRef)">保存 SEO 与域名</Button>
<span class="card-hint">仅保存本 Tab 的字段</span>
</div>
</div>
</div>
</div>
</div>
</TabPane>
<!-- 皮肤预览弹窗(iframe 加载 /api/theme/preview,no-store) -->
<Modal v-model="previewVisible" title="皮肤预览" :mask-closable="true" width="980px" footer-hide>
<ThemePreviewFrame
v-if="previewVisible && editTheme && row"
:site-path="row.path || row.code"
:theme="theme"
:theme-params="editTheme?.paramValues || {}"
/>
</Modal>
<TabPane :label="tabLabel(TABS[6])" name="css">
<div class="card setting-card">
<div class="card-header">
<span class="card-title">自定义样式</span>
<span class="card-subtitle">站点级 CSS 覆盖(面向需要微调的高级用户)</span>
</div>
<div class="card-body">
<Input
v-model="customCss"
type="textarea"
:rows="14"
:maxlength="10240"
placeholder="如:--primary:#59aeeb; 或 .site-header{height:80px}"
class="css-editor"
/>
<div class="css-meta">
<span>{{ customCss.length }}/10240 字符</span>
<span>保存后门户立即生效(缓存自动失效);服务端将拒绝 &lt;/style、&lt;script、@import、expression( 等危险内容</span>
</div>
</div>
<div class="card-footer">
<Button type="primary" :loading="saving.css" @click="saveCss">保存自定义样式</Button>
<span class="card-hint">仅保存本 Tab 的字段</span>
</div>
</div>
</TabPane>
</Tabs>
</div>
</div>
<!-- 皮肤预览弹窗(iframe 加载 /api/theme/preview,no-store) -->
<Modal v-model="previewVisible" title="皮肤预览" :mask-closable="true" width="980px" footer-hide>
<ThemePreviewFrame
v-if="previewVisible && editTheme && row"
:site-path="row.path || row.code"
:theme="theme"
:theme-params="editTheme?.paramValues || {}"
/>
</Modal>
</template>
<style scoped lang="scss">
@@ -582,108 +646,28 @@ watch(() => store.currentSchool?.schId, () => loadSites());
}
}
}
.ss-layout {
display: flex;
gap: 16px;
align-items: flex-start;
}
.ss-nav {
width: 184px;
flex: none;
position: sticky;
top: 72px;
display: flex;
flex-direction: column;
gap: 2px;
padding: 6px;
.ss-steps {
margin-bottom: 18px;
background: #fff;
border: 1px solid #e9edf5;
border-radius: 10px;
padding: 14px 24px 4px;
}
.ss-tabs {
background: #fff;
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.04), 0 14px 30px -22px rgba(16, 24, 40, 0.4);
.ss-nav-item {
position: relative;
padding: 8px 10px 8px 14px;
border-radius: 7px;
cursor: pointer;
transition: background 0.2s;
&:hover {
background: #f0f4ff;
}
&.active {
background: #eef2ff;
&::before {
content: "";
position: absolute;
left: 4px;
top: 10px;
bottom: 10px;
width: 3px;
border-radius: 2px;
background: #577afb;
}
.ss-nav-title {
color: #4264e4;
}
}
.ss-nav-title {
display: block;
font-size: 13px;
font-weight: 600;
color: #1e2635;
}
.ss-nav-lock {
color: #c5c8ce;
font-size: 12px;
}
.ss-nav-desc {
display: block;
color: #8a93a6;
font-size: 12px;
margin-top: 2px;
}
border: 1px solid #e9edf5;
border-radius: 10px;
padding: 4px 16px 16px;
:deep(.ivu-tabs-nav) {
margin-bottom: 12px;
}
}
@media (max-width: 1080px) {
.ss-layout {
flex-direction: column;
}
.ss-nav {
position: static;
width: 100%;
flex-direction: row;
flex-wrap: wrap;
gap: 4px;
.ss-nav-item {
flex: none;
.ss-nav-desc {
display: none;
}
&.active::before {
display: none;
}
}
}
}
.ss-cards {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 14px;
}
.setting-card {
border: 1px solid #e9edf5;
border: 1px solid #f0f2f5;
border-radius: 10px;
background: #fff;
padding: 18px 20px;
scroll-margin-top: 84px;
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.04);
&.locked {
.card-body {
opacity: 0.55;
pointer-events: none;
}
}
.card-header {
display: flex;
align-items: baseline;
@@ -728,9 +712,6 @@ watch(() => store.currentSchool?.schId, () => loadSites());
line-height: 1.6;
}
}
.ss-lock-tip {
margin-bottom: 12px;
}
}
.form-hint {
color: #999;
@@ -756,21 +737,19 @@ watch(() => store.currentSchool?.schId, () => loadSites());
.preview-btn {
margin: 8px 0 0;
}
.css-collapse {
margin: 4px 0 12px;
.css-editor :deep(textarea) {
font-family: Consolas, Monaco, monospace;
font-size: 12px;
}
.css-meta {
display: flex;
justify-content: space-between;
gap: 12px;
margin-top: 6px;
color: #999;
font-size: 12px;
line-height: 1.6;
}
.css-editor :deep(textarea) {
font-family: Consolas, Monaco, monospace;
font-size: 12px;
line-height: 1.7;
}
.css-meta {
display: flex;
justify-content: space-between;
gap: 12px;
margin-top: 6px;
color: #999;
font-size: 12px;
line-height: 1.6;
}
}
</style>
+18 -1
View File
@@ -68,7 +68,24 @@ async function validate(): Promise<boolean> {
return !!(await formRef.value?.validate());
}
defineExpose({ validate });
/**
* F1(docs/20)Tab 独立保存:仅导出本实例渲染分组的字段值(空值剔除)。
* 父级把返回值合并进服务端事实源 configJson 后提交——其他 Tab 的未保存修改不会被连带提交。
*/
function ownFields(): Record<string, string> {
const out: Record<string, string> = {};
visibleGroups.forEach(g => {
defsOf(g.name).forEach(d => {
const v = (formModel.fields[d.key] || "").trim();
if (v) {
out[d.key] = v;
}
});
});
return out;
}
defineExpose({ validate, ownFields });
function rulesFor(def: SiteConfigFieldDef) {
return [