767 lines
35 KiB
Vue
767 lines
35 KiB
Vue
<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>保存后门户立即生效(缓存自动失效);服务端将拒绝 </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">
|
||
.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>
|