Files
YschcmsFront/src/views/cms/SiteSetting.vue
T

767 lines
35 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
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";
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 { 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 整页化 → docs/20 F1 Tab 化 + 渐进式向导):
* 编辑模式 = 顶部 7 Tab(基本信息/皮肤与外观/首页布局/基础展示/页脚信息/SEO 与域名/自定义样式),
* 每个 Tab 独立表单、独立 dirty 状态、独立保存——保存只合并本 Tab 的键空间进
* lastSavedConfigJson(服务端事实源),其他 Tab 的未保存修改既不丢失也不被连带提交。
* 新建模式 = 渐进式向导:第一步只填基本信息,创建成功自动进入第二步(选皮肤),
* 完成后提示并展开全部 Tab;不再有任何「创建站点后开放」灰态锁定。
*/
const store = useCmsStore();
const route = useRoute();
const router = useRouter();
const siteIdParam = computed(() => (route.params.id ? Number(route.params.id) : undefined));
const isCreate = computed(() => siteIdParam.value == null);
/** B1 建站权限拆分:校级(学校上下文内)可自助创建院系子站;超管行为不变 */
const isSchoolAdmin = computed(() => !store.isSuperAdmin && !!store.currentSchool);
/** 校级锁定在本校主站下建子站(原向导 lockParentId 语义) */
const lockParentId = computed(() => (isCreate.value && isSchoolAdmin.value ? store.currentSchool?.mainSiteId : undefined));
const loading = ref(true);
const loadError = ref("");
const row = ref<SiteVo | null>(null);
const sites = ref<SiteVo[]>([]);
const themes = ref<ThemeVo[]>([]);
// ===== 向导(F1):1=填基本信息(创建模式);2=选皮肤(创建成功后);0=常规编辑 =====
const wizardStep = ref(isCreate.value ? 1 : 0);
// ===== 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,
code: "",
parentSiteId: undefined as number | undefined,
schId: undefined as number | undefined,
schName: "",
name: "",
status: 1,
});
/** ② 皮肤与外观(工作项 T:参数微调已迁皮肤包管理,站点只负责选哪套包) */
const theme = ref("default");
const themeUnregistered = ref(false);
const previewVisible = ref(false);
const editTheme = computed(() => themes.value.find(t => t.name === theme.value));
/** ⑦ 自定义样式(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)
);
/** 可选主站:顶层站点且非自身(院系子站层级固定两层);校级锁定本校主站 */
const parentOptions = computed(() => {
if (lockParentId.value) {
return sites.value.filter(s => s.id === lockParentId.value && !s.parentSiteId);
}
return schoolScopedSites.value.filter(s => !s.parentSiteId && s.id !== basic.id);
});
const parentSiteOfBasic = computed(() => sites.value.find(s => s.id === basic.parentSiteId));
const portalPreviewUrl = computed(() =>
isCreate.value
? previewPortalUrl(parentSiteOfBasic.value?.path ?? parentSiteOfBasic.value?.code, basic.code)
: row.value
? portalUrlOf(row.value)
: ""
);
/** F2 实时预览 iframe 地址(编辑模式 = serviceHost + 正式门户首页) */
const portalFrameUrl = computed(() =>
!isCreate.value && row.value ? `${config.serviceHost}${portalUrlOf(row.value)}` : undefined
);
const rules = {
code: [
{ required: true, message: "站点编码不得为空", trigger: "blur" },
{ pattern: /^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$/, message: "仅限小写字母/数字/短横线", trigger: "blur" },
],
name: [{ required: true, message: "站点名称不得为空", trigger: "blur" }],
};
/** B-8:顶层站 schId 必填(async-validator 默认按 string,数字必选须 type:"number");动态规则挂 FormItem */
const schIdRules = [{ required: true, type: "number" as const, message: "顶层站点必须指定学校ID", trigger: "change" }];
const schIdRequired = computed(() => store.isSuperAdmin && !basic.parentSiteId);
onMounted(async () => {
await Promise.all([loadSites(), loadThemes()]);
if (!isCreate.value) {
initFromRow(siteIdParam.value!);
} else if (lockParentId.value) {
basic.parentSiteId = lockParentId.value;
}
loading.value = false;
});
async function loadSites() {
sites.value = (await siteApi.list().send()) || [];
}
async function loadThemes() {
try {
themes.value = (await themeApi.list().send()) || [];
} catch {
themes.value = [];
}
}
/** 载入站点 → 各 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));
themeUnregistered.value = resolved.unregistered;
Object.assign(basic, {
id: r.id,
code: r.code,
parentSiteId: r.parentSiteId || undefined,
schId: r.schId,
schName: r.schName || "",
name: r.name,
status: r.status,
});
theme.value = resolved.theme;
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 {
try {
const obj = configJson && configJson.trim() ? JSON.parse(configJson) : {};
return obj && typeof obj === "object" && typeof obj.custom_css === "string" ? obj.custom_css : "";
} catch {
return "";
}
}
/** HomeLayoutEditor 的整份 config 输出 → 仅取 home_layout 键的增量(无布局=删除键) */
function layoutPatchOf(model: string): { home_layout?: unknown } {
try {
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 {
// 非法 JSON 视为无变更
}
return {};
}
function pickTheme(name: string) {
if (theme.value === name) return;
theme.value = name;
themeUnregistered.value = false;
dirty.theme = true;
}
function openPreview() {
previewVisible.value = true;
}
/**
* 统一提交(F1 Tab 独立保存):site 字段 = 当前基本信息快照 + 当前皮肤;
* configJson = lastSavedConfigJson + 本 Tab 增量 patch(patch=null = 不动 config)。
* 成功后回写事实源并清本 Tab dirty;其他 Tab 的内存修改不受影响。
*/
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,
code: basic.code,
parentSiteId: basic.parentSiteId || 0,
// B1:校级角色隐藏学校信息字段,留空由服务端继承主站(防伪造通道)
schId: isSchoolAdmin.value ? undefined : basic.schId,
schName: isSchoolAdmin.value ? undefined : basic.schName || undefined,
name: basic.name,
theme: theme.value || undefined,
configJson,
status: basic.status,
})
.send();
if (isCreate.value && res && res.id) {
// 向导第一步完成:原地 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);
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;
layoutEditorRef.value?.refreshAfterSave?.();
} else if (tab === "css") {
customCss.value = readCustomCss(lastSavedConfigJson.value);
}
seeding = false;
} finally {
saving[tab] = false;
}
}
async function saveBasic() {
const valid = await basicRef.value?.validate();
if (!valid) {
Message.warning("请填写红色星号内容!"); // bsch §4 统一提示
return;
}
await persistTab("basic", null);
}
async function saveTheme() {
await persistTab("theme", null);
}
/** 向导第二步收口:保存皮肤 → 提示可继续配置首页布局,展开全部 Tab */
async function finishWizard() {
await persistTab("theme", null);
wizardStep.value = 0;
Message.success("站点创建成功,可继续配置首页布局");
}
/** 向导第二步跳过(皮肤先用当前选择/默认) */
function skipWizard() {
wizardStep.value = 0;
Message.info("已跳过皮肤配置,全部设置已开放");
}
async function saveLayout() {
const err = layoutEditorRef.value?.validate();
if (err) {
Message.error(err);
return;
}
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 });
}
/** 门户预览(编辑模式):新窗口打开真实门户 */
function openPortal() {
if (!row.value) return;
window.open(`${config.serviceHost}${portalUrlOf(row.value)}`, "_blank");
}
function backToList() {
const main = row.value?.parentSiteId ? sites.value.find(s => s.id === row.value!.parentSiteId) : row.value;
const schCode = main?.code || store.schoolCode;
if (!schCode) {
router.push("/workbench");
return;
}
router.push(adminPathOf(schCode, row.value?.parentSiteId ? row.value.code : null, "site"));
}
// ===== 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());
</script>
<template>
<div class="site-setting">
<div class="main-container">
<!-- 页头 -->
<div class="ss-header">
<div class="ss-title">
<h3>{{ isCreate ? (isSchoolAdmin ? "新建院系子站" : "新建站点") : basic.name || "站点设置" }}</h3>
<template v-if="!isCreate">
<Tag>{{ basic.code }}</Tag>
<Tag :color="basic.status === 1 ? 'success' : 'default'">{{ basic.status === 1 ? "启用" : "停用" }}</Tag>
</template>
</div>
<div class="ss-actions">
<Button v-if="!isCreate" icon="ios-browsers" @click="openPortal">门户预览</Button>
<Button icon="md-arrow-back" @click="backToList">返回列表</Button>
</div>
</div>
<Alert v-if="loadError" type="error" show-icon style="margin-bottom: 12px">{{ loadError }}</Alert>
<Spin v-if="loading" size="large" fix />
<!-- 渐进式向导进度(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 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>
</div>
<div class="card-body">
<Form ref="basicRef" :model="basic" :rules="rules" :label-width="110">
<FormItem label="站点编码" prop="code">
<Input v-model="basic.code" :disabled="!isCreate" placeholder="URL 路径用,如 demo" style="max-width: 320px" />
<div v-if="!isCreate" class="form-hint">编码创建后不可修改(URL 末段)</div>
</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>
</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">仅保存本 Tab;其他 Tab 未保存的修改保留在各自表单里,不会连带提交</span>
</div>
</div>
</TabPane>
<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 已独立为专属 Tab)</span>
</div>
<div class="card-body">
<div class="field-block">
<label class="field-label">皮肤包</label>
<Alert v-if="themeUnregistered" type="warning" show-icon style="margin-bottom: 8px">
皮肤包「{{ theme }}」未在注册表中,保存将被服务端拒绝;请先由平台超管上传该皮肤包,或改选下方其他皮肤。
</Alert>
<Alert v-else-if="!themes.length" type="warning" show-icon style="margin-bottom: 8px">
皮肤包列表加载失败,暂时无法选择;站点当前皮肤包({{ theme }})保持不变,可稍后重试。
</Alert>
<ThemePicker :themes="themes" :model-value="theme" @update:model-value="pickTheme" />
<Button size="small" class="preview-btn" :disabled="!editTheme" @click="openPreview">预览本皮肤</Button>
</div>
</div>
<div class="card-footer">
<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>
<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>
<div class="card-body">
<HomeLayoutEditor
v-model="layoutModel"
:site-id="basic.id"
:portal-url="portalFrameUrl"
ref="layoutEditorRef"
@save="saveLayout"
/>
</div>
<div class="card-footer">
<Button type="primary" :loading="saving.layout" @click="saveLayout">保存首页布局</Button>
<span class="card-hint">保存后门户立即生效(缓存自动失效);未编排布局时门户按「全部栏目自动渲染」默认形态展示</span>
</div>
</div>
</TabPane>
<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>
<div class="card-body">
<SiteConfigForm ref="displayFormRef" v-model="displayModel" :groups="['display']" :site-id="basic.id" />
</div>
<div class="card-footer">
<Button type="primary" :loading="saving.display" @click="saveGroupForm('display', displayFormRef)">保存基础展示</Button>
<span class="card-hint">仅保存本 Tab 的字段</span>
</div>
</div>
</TabPane>
<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>
<div class="card-body">
<SiteConfigForm ref="footerFormRef" v-model="footerModel" :groups="['footer']" :site-id="basic.id" />
</div>
<div class="card-footer">
<Button type="primary" :loading="saving.footer" @click="saveGroupForm('footer', footerFormRef)">保存页脚信息</Button>
<span class="card-hint">仅保存本 Tab 的字段</span>
</div>
</div>
</TabPane>
<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>
<div class="card-body">
<SiteConfigForm ref="seoFormRef" v-model="seoModel" :groups="['seo', 'domain']" :site-id="basic.id" />
</div>
<div class="card-footer">
<Button type="primary" :loading="saving.seo" @click="saveGroupForm('seo', seoFormRef)">保存 SEO 与域名</Button>
<span class="card-hint">仅保存本 Tab 的字段</span>
</div>
</div>
</TabPane>
<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">
.site-setting {
position: relative;
min-height: 400px;
.ss-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
.ss-title {
display: flex;
align-items: center;
gap: 10px;
h3 {
margin: 0;
}
}
}
.ss-steps {
margin-bottom: 18px;
background: #fff;
border: 1px solid #e9edf5;
border-radius: 10px;
padding: 14px 24px 4px;
}
.ss-tabs {
background: #fff;
border: 1px solid #e9edf5;
border-radius: 10px;
padding: 4px 16px 16px;
:deep(.ivu-tabs-nav) {
margin-bottom: 12px;
}
}
.setting-card {
border: 1px solid #f0f2f5;
border-radius: 10px;
background: #fff;
padding: 18px 20px;
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.04);
.card-header {
display: flex;
align-items: baseline;
gap: 10px;
margin-bottom: 14px;
padding-bottom: 10px;
border-bottom: 1px solid #f0f2f5;
.card-title {
position: relative;
font-size: 15px;
font-weight: 600;
color: #1e2635;
padding-left: 10px;
&::before {
content: "";
position: absolute;
left: 0;
top: 3px;
bottom: 3px;
width: 3px;
border-radius: 2px;
background: #577afb;
}
}
.card-subtitle {
font-size: 12px;
color: #8a93a6;
}
}
.card-footer {
display: flex;
align-items: center;
gap: 12px;
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid #f0f2f5;
.card-hint {
flex: 1;
min-width: 0;
color: #8a93a6;
font-size: 12px;
line-height: 1.6;
}
}
}
.form-hint {
color: #999;
font-size: 12px;
line-height: 1.6;
margin-top: 4px;
}
.field-block {
margin-bottom: 12px;
.field-label {
display: block;
font-size: 13px;
color: #515a6e;
margin-bottom: 8px;
font-weight: 600;
}
}
.ss-portal-url {
color: #2d8cf0;
font-family: Consolas, Menlo, monospace;
word-break: break-all;
}
.preview-btn {
margin: 8px 0 0;
}
.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>