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
build / build (push) Successful in 2m19s
This commit is contained in:
@@ -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>保存后门户立即生效(缓存自动失效);服务端将拒绝 </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>
|
||||
</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>
|
||||
Reference in New Issue
Block a user