[CMS-VISUAL] F1: 站点设置 Tab 化(7 Tab 独立表单/dirty/保存,增量合并互不连带)+ 新建渐进式向导(两步后全开放,无灰态)+ 自定义样式独立 Tab + 页脚/基础展示字段视觉位置说明(165 测试绿)
This commit is contained in:
@@ -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 键不受影响
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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>保存后门户立即生效(缓存自动失效);服务端将拒绝 </style、<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>保存后门户立即生效(缓存自动失效);服务端将拒绝 </style、<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>
|
||||
|
||||
@@ -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 [
|
||||
|
||||
Reference in New Issue
Block a user