feat(cms): docs/16 站点设置整页化+首页布局编辑器- SiteSetting.vue整页7分区(基本信息/皮肤与外观/首页布局/基础展示/页脚信息/SEO与域名/扩展参数)锚点导航sticky+IntersectionObserver+分区卡片三段式每分区独立保存(configJson单一事实源+submitSection统一提交);创建模式仅①②可编辑其余锁定,创建成功原地replace到site/setting/:id;SiteWizard三步向导退役删除(门户预览收编portalPreviewUrl);SiteManage瘦身表格+行内编辑跳整页(adminPathOf子站带站点段);ImageAssetField图片双通道(上传+路径粘贴折叠);SiteConfigForm支持groups/showExtras/siteId+asset字段接ImageAssetField+三旧键表单不再展示(ABSORBED_LEGACY_KEYS静默保留);homeLayout三件套(homeLayout.ts模型parse/serialize/validate/genFromCategories一键生成+blockTypes.ts注册表showIf条件字段+HomeLayoutEditor.vue序列编辑增删显隐上移下移);routes site/new+site/setting/:id+legacy redirect;CategoryManage home_style提示改口指向首页布局;新增siteSetting.spec+homeLayout.spec,重写siteManage.spec,117用例全绿
build / build (push) Successful in 2m19s

This commit is contained in:
chenxing
2026-09-10 15:09:10 +08:00
parent 7c925cd4f2
commit 34733ec280
16 changed files with 2399 additions and 840 deletions
+724
View File
@@ -0,0 +1,724 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, 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 { FormModelValue, formModelFromTheme, mergeThemeParamsIntoConfigJson, themeParamsFromConfigJson } from "@/utils/themeParamForm";
import { resolveEditableTheme } from "@/utils/themeEdit";
import ThemePicker from "@/components/theme/ThemePicker.vue";
import ThemeParamForm from "@/components/theme/ThemeParamForm.vue";
import ThemePreviewFrame from "@/components/theme/ThemePreviewFrame.vue";
import SiteConfigForm from "@/views/cms/components/SiteConfigForm.vue";
import HomeLayoutEditor from "@/views/cms/homeLayout/HomeLayoutEditor.vue";
/**
* 站点设置中心(docs/16 主线A 整页化):编辑/新增站点从模态框/三步向导 → 完整页面。
* projman 模式:锚点导航 + 分区卡片(card-header/body/footer 三段式),每分区独立保存。
* 单一 configJson 事实源(§3.5):各分区编辑器只读写自己的键空间(SiteConfigForm 分区作用域
* + HomeLayoutEditor 仅动 home_layout 键),「仅保存本分区」= 校验本分区 → 当前 configJson 全量提交;
* 其他分区未保存的修改仍在内存里,下次任一分区保存会一并带上(卡片 footer 已注明)。
* 创建模式(site/new,D5 向导退役):仅①基本信息②皮肤与外观可编辑,③~⑦锁定置灰,
* 创建成功原地 replace 到 site/setting/{id} 解锁全部分区。
*/
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);
const canCreateSite = computed(() => store.isSuperAdmin || isSchoolAdmin.value);
/** 校级锁定在本校主站下建子站(原向导 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[]>([]);
/** 分区清单(锚点导航;locked = 创建模式锁定) */
const sections = [
{ key: "basic", title: "基本信息", desc: "编码 / 名称 / 归属 / 状态" },
{ key: "theme", title: "皮肤与外观", desc: "风格选择与微调" },
{ key: "layout", title: "首页布局", desc: "区块序列编排", locked: true },
{ key: "display", title: "基础展示", desc: "品牌标识与图片" },
{ key: "footer", title: "页脚信息", desc: "联系方式与社媒" },
{ key: "seo", title: "SEO 与域名", desc: "收录与独立域名" },
{ key: "extras", title: "扩展参数", desc: "预留键位", locked: true },
];
const activeSection = ref("basic");
let observer: IntersectionObserver | null = null;
/** 单一 configJson 事实源(§3.5) */
const configJson = 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,
});
/** ② 皮肤与外观分区 */
const theme = ref("default");
const themeUnregistered = ref(false);
const themeParams = ref<FormModelValue>({});
const customCss = ref("");
const previewVisible = ref(false);
const editTheme = computed(() => themes.value.find(t => t.name === theme.value));
/** ③~⑦ 分区表单 ref(分区独立校验) */
const layoutEditorRef = ref();
const displayFormRef = ref();
const footerFormRef = ref();
const seoFormRef = ref();
const extrasFormRef = ref();
const saving = reactive<Record<string, boolean>>({});
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)
: ""
);
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;
nextTick(setupAnchorObserver);
});
onBeforeUnmount(() => observer?.disconnect());
async function loadSites() {
sites.value = (await siteApi.list().send()) || [];
}
async function loadThemes() {
try {
themes.value = (await themeApi.list().send()) || [];
} catch {
themes.value = [];
}
}
function initFromRow(id: number) {
const r = sites.value.find(s => s.id === id);
if (!r) {
loadError.value = "站点不存在或无权管理该站点";
return;
}
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;
configJson.value = r.configJson || "";
customCss.value = readCustomCss(configJson.value);
const t = editTheme.value;
themeParams.value = t ? themeParamsFromConfigJson(configJson.value, t) : {};
}
function readCustomCss(configJson: string): string {
try {
const obj = configJson && configJson.trim() ? JSON.parse(configJson) : {};
return obj && typeof obj === "object" && typeof obj.custom_css === "string" ? obj.custom_css : "";
} catch {
return "";
}
}
/** 把面板值写入/移出 configJson(空值移除键;与 SiteConfigForm 的 preserved 往返兼容) */
function withCustomCss(configJson: string, css: string): string {
let obj: Record<string, unknown>;
try {
obj = configJson && configJson.trim() ? JSON.parse(configJson) : {};
if (!obj || typeof obj !== "object" || Array.isArray(obj)) obj = {};
} catch {
obj = {};
}
if (css.trim()) obj.custom_css = css;
else delete obj.custom_css;
return JSON.stringify(obj);
}
function pickTheme(name: string) {
if (theme.value === name) return;
theme.value = name;
themeUnregistered.value = false;
const t = editTheme.value;
themeParams.value = t ? formModelFromTheme(t, themeParams.value) : {};
}
function openPreview() {
previewVisible.value = true;
}
/**
* 统一提交(§3.5 分区独立保存模型):payload = 页面当前事实源 + 基本信息快照;
* patch.configJson 供分区保存前合并自己的键空间(如皮肤分区的 theme_params/custom_css)。
* 创建成功原地 replace 到 site/setting/{id} 并解锁全部分区。
*/
async function submitSection(key: string, patch: { configJson?: string }) {
saving[key] = true;
try {
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: patch.configJson ?? configJson.value,
status: basic.status,
})
.send();
if (isCreate.value && res && res.id) {
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 刷新事实源(theme_params 合并等)
if (typeof res.configJson === "string") {
configJson.value = res.configJson;
customCss.value = readCustomCss(configJson.value);
}
}
}
} finally {
saving[key] = false;
}
}
async function saveBasic() {
const valid = await basicRef.value?.validate();
if (!valid) return;
await submitSection("basic", {});
}
async function saveTheme() {
const t = editTheme.value;
const baseConfig = withCustomCss(configJson.value, customCss.value);
const merged = t ? mergeThemeParamsIntoConfigJson(baseConfig, t, themeParams.value) : baseConfig;
await submitSection("theme", { configJson: merged });
}
async function saveDisplay() {
if (!(await displayFormRef.value?.validate())) return;
await submitSection("display", {});
}
async function saveFooter() {
if (!(await footerFormRef.value?.validate())) return;
await submitSection("footer", {});
}
async function saveSeo() {
if (!(await seoFormRef.value?.validate())) return;
await submitSection("seo", {});
}
async function saveExtras() {
if (!(await extrasFormRef.value?.validate())) return;
await submitSection("extras", {});
}
async function saveLayout() {
const err = layoutEditorRef.value?.validate();
if (err) {
Message.error(err);
return;
}
await submitSection("layout", {});
}
/** 门户预览(编辑模式):新窗口打开真实门户 */
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"));
}
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" });
}
// 学校上下文切换即时重载(编辑中的站点归属变化防御)
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 />
<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>
<!-- 分区卡片流 -->
<div class="ss-cards">
<!-- ① 基本信息 -->
<div id="sec-basic" class="card setting-card" data-section="basic">
<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>
<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">
{{ isCreate ? "创建站点" : "仅保存基本信息" }}
</Button>
<span class="card-hint">仅保存本分区;其他分区未保存的修改仍在页面上,下次任一分区保存会一并带上</span>
</div>
</div>
<!-- ② 皮肤与外观 -->
<div id="sec-theme" class="card setting-card" data-section="theme">
<div class="card-header">
<span class="card-title">皮肤与外观</span>
<span class="card-subtitle">风格选择 / 参数微调 / 站点级 CSS 覆盖</span>
</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>
<ThemeParamForm :theme="editTheme" v-model="themeParams" />
<p class="form-hint">换肤/改参数保存后门户立即生效(缓存已自动失效)。</p>
</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>
</div>
</div>
<!-- ③ 首页布局 -->
<div id="sec-layout" class="card setting-card" :class="{ locked: isCreate }" data-section="layout">
<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" />
</div>
<div class="card-footer">
<Button type="primary" :loading="saving.layout" :disabled="isCreate" @click="saveLayout">仅保存首页布局</Button>
<span class="card-hint">保存后门户立即生效(缓存自动失效);未编排布局时门户按「全部栏目自动渲染」默认形态展示</span>
</div>
</div>
<!-- ④ 基础展示 -->
<div id="sec-display" class="card setting-card" :class="{ locked: isCreate }" data-section="display">
<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" />
</div>
<div class="card-footer">
<Button type="primary" :loading="saving.display" :disabled="isCreate" @click="saveDisplay">仅保存基础展示</Button>
<span class="card-hint">仅保存本分区;其他分区未保存的修改仍在页面上,下次任一分区保存会一并带上</span>
</div>
</div>
<!-- ⑤ 页脚信息 -->
<div id="sec-footer" class="card setting-card" :class="{ locked: isCreate }" data-section="footer">
<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" />
</div>
<div class="card-footer">
<Button type="primary" :loading="saving.footer" :disabled="isCreate" @click="saveFooter">仅保存页脚信息</Button>
<span class="card-hint">仅保存本分区;其他分区未保存的修改仍在页面上,下次任一分区保存会一并带上</span>
</div>
</div>
<!-- ⑥ SEO 与域名 -->
<div id="sec-seo" class="card setting-card" :class="{ locked: isCreate }" data-section="seo">
<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" />
</div>
<div class="card-footer">
<Button type="primary" :loading="saving.seo" :disabled="isCreate" @click="saveSeo">仅保存 SEO 与域名</Button>
<span class="card-hint">仅保存本分区;其他分区未保存的修改仍在页面上,下次任一分区保存会一并带上</span>
</div>
</div>
<!-- ⑦ 扩展参数 -->
<div id="sec-extras" class="card setting-card" :class="{ locked: isCreate }" data-section="extras">
<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="extrasFormRef" v-model="configJson" show-extras />
</div>
<div class="card-footer">
<Button type="primary" :loading="saving.extras" :disabled="isCreate" @click="saveExtras">仅保存扩展参数</Button>
<span class="card-hint">仅保存本分区;其他分区未保存的修改仍在页面上,下次任一分区保存会一并带上</span>
</div>
</div>
</div>
</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="themeParams"
/>
</Modal>
</div>
</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-layout {
display: flex;
gap: 16px;
align-items: flex-start;
}
.ss-nav {
width: 180px;
flex: none;
position: sticky;
top: 84px;
display: flex;
flex-direction: column;
gap: 4px;
.ss-nav-item {
padding: 8px 12px;
border-radius: 6px;
cursor: pointer;
transition: background 0.2s;
&:hover {
background: #f0faff;
}
&.active {
background: #e8f4ff;
.ss-nav-title {
color: #2d8cf0;
}
}
.ss-nav-title {
display: block;
font-size: 13px;
font-weight: 600;
color: #515a6e;
}
.ss-nav-lock {
color: #c5c8ce;
font-size: 12px;
}
.ss-nav-desc {
display: block;
color: #999;
font-size: 12px;
margin-top: 2px;
}
}
}
.ss-cards {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 14px;
}
.setting-card {
border: 1px solid #e8eaec;
border-radius: 8px;
background: #fff;
padding: 16px 18px;
scroll-margin-top: 76px;
&.locked {
.card-body {
opacity: 0.55;
pointer-events: none;
}
}
.card-header {
display: flex;
align-items: baseline;
gap: 10px;
margin-bottom: 14px;
padding-bottom: 10px;
border-bottom: 1px solid #f0f2f5;
.card-title {
font-size: 15px;
font-weight: 600;
color: #17233d;
}
.card-subtitle {
font-size: 12px;
color: #999;
}
}
.card-footer {
display: flex;
align-items: center;
gap: 12px;
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid #f0f2f5;
.card-hint {
color: #999;
font-size: 12px;
line-height: 1.5;
}
}
.ss-lock-tip {
margin-bottom: 12px;
}
}
.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-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;
}
}
}
</style>