Files
YschcmsFront/src/views/cms/SiteManage.vue
T
chenxing bb5c5e513f feat(B3): 站点编辑弹窗增自定义样式面板(config_json.custom_css)
- 折叠面板 textarea(等宽字体/10240计数/示例提示),面板为唯一编辑入口
- custom_css 入 RESERVED_SITE_CONFIG_KEYS(结构化表单保留键,不与扩展行重复编辑)
- vitest 25/25 绿(新增保留键往返用例)+build 成功
2026-09-08 15:14:08 +08:00

425 lines
17 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 Utils from "@/utils/index";
import { assetUrl, siteApi, themeApi, themePreviewUrl } from "@/api/module/cms";
import { useCmsStore } from "@/store";
import config from "@/assets/config/host.config";
import { SiteVo, ThemeVo } from "@/types/cms";
import { formModelFromTheme, FormModelValue, mergeThemeParamsIntoConfigJson, themeParamsFromConfigJson } from "@/utils/themeParamForm";
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 SiteWizard from "@/views/cms/SiteWizard.vue";
/**
* 站点管理(方案 §9.1 + §6.2 主题选择增强)。
* 学校化管理(docs/09 §3.3):默认只显示当前学校站点(主站+院系子站);
* 超管可经工作台「全部站点总览」(?scope=all)查看全局列表。
* 行「进入管理」= 院系子站切换入口(壳面包屑条切换器已按 UX 收敛移除):
* 切 store.currentSite 落文章列表,跨校行先重建学校上下文;当前站点行带「当前」标记。
* 新建 → 三步向导(选模板→微调预览→确认);已建站点 → 编辑弹窗内
* 主题卡片单选 + 参数微调表单,保存带 themeParams(服务端合并进 config_json,方案 §5.3)。
* config_json 结构化编辑见 SiteConfigForm(docs/09 §3.1):
* 已知键分组表单 + 扩展参数动态行,不再手写 JSON;theme_params 仍由 ThemeParamForm 自动合并。
*/
const store = useCmsStore();
const route = useRoute();
const router = useRouter();
/** 超管全局视图(工作台入口带入 scope=all) */
const scopeAll = computed(() => route.query.scope === "all" && store.isSuperAdmin);
/** B1 建站权限拆分:校级(学校上下文内)可自助创建院系子站;超管向导行为不变 */
const isSchoolAdmin = computed(() => !store.isSuperAdmin && !!store.currentSchool);
const canCreateSite = computed(() => store.isSuperAdmin || isSchoolAdmin.value);
const rows = ref<SiteVo[]>([]);
const loading = ref(false);
const tableHeight = Utils.useTableHeight({ isOperat: true });
/** 主题元数据映射(列表皮肤列缩略名 + 编辑表单) */
const themes = ref<ThemeVo[]>([]);
const themeMeta = computed(() => {
const map: Record<string, ThemeVo> = {};
themes.value.forEach(t => (map[t.name] = t));
return map;
});
const columns = [
{ title: "站点编码", key: "code", width: 140 },
{
title: "站点名称",
key: "name",
minWidth: 200,
tooltip: true,
render: (h: any, p: any) =>
h("span", { style: "display:inline-flex;align-items:center;gap:6px" }, [
h("span", p.row.name),
// 当前操作站点标记:壳面包屑校名后缀同源(store.currentSite)
p.row.id === store.siteId ? h(Tag, { color: "success" }, () => "当前") : null,
]),
},
{ title: "学校", key: "schName", width: 150, tooltip: true },
{
title: "所属主站",
key: "parentSiteId",
width: 150,
tooltip: true,
render: (h: any, p: any) => h("span", p.row.parentSiteId ? p.row.parentName || `ID ${p.row.parentSiteId}` : "顶层站点"),
},
{
title: "皮肤包",
key: "theme",
width: 150,
render: (h: any, p: any) => {
const t = themeMeta.value[p.row.theme || "default"];
return h("span", { style: "display:inline-flex;align-items:center;gap:6px" }, [
t
? h("img", { src: assetUrl(t.screenshotUrl), style: "width:44px;height:28px;object-fit:cover;border-radius:3px;border:1px solid #eee", alt: "" })
: h("span", { style: "display:inline-block;width:44px;height:28px;background:#f0f2f5;border-radius:3px" }),
h("span", t ? `${t.title}(${p.row.theme})` : p.row.theme || "default"),
]);
},
},
{ title: "门户路径", key: "portalPath", minWidth: 200, tooltip: true, render: (h: any, p: any) => h("span", `/portal/${p.row.code}/`) },
{
title: "状态",
key: "status",
width: 90,
align: "center",
render: (h: any, p: any) =>
h(Tag, { color: p.row.status === 1 ? "success" : "default" }, () =>
p.row.status === 1 ? "启用" : "停用"
),
},
{
title: "操作",
key: "action",
width: 150,
fixed: "right",
align: "center",
render: (h: any, p: any) => [
Utils.operatingHandle(h, "进入管理", "icon-apps", () => enterManage(p.row), p.row.id === store.siteId),
Utils.operatingHandle(h, "编辑", "icon-edit", () => openEdit(p.row)),
Utils.operatingHandle(h, "门户", "icon-browse", () => openPortal(p.row)),
],
},
];
/** 新建向导 */
const wizardVisible = ref(false);
/** 编辑弹窗 */
const modalVisible = ref(false);
const saving = ref(false);
const formRef = ref();
const configFormRef = ref();
const form = reactive({
id: undefined as number | undefined,
code: "",
parentSiteId: undefined as number | undefined,
schId: undefined as number | undefined,
schName: "",
name: "",
theme: "default",
configJson: "",
status: 1,
});
const editThemeParams = ref<FormModelValue>({});
const editTheme = computed(() => themeMeta.value[form.theme]);
/** B3 自定义样式(config_json.custom_css):面板为唯一编辑入口,SiteConfigForm 侧该键为保留键 */
const customCss = ref("");
/** 编辑内预览 */
const previewVisible = ref(false);
/** 可选主站:顶层站点且非自身(院系子站层级固定两层) */
const parentOptions = computed(() =>
rows.value.filter(r => !r.parentSiteId && r.id !== form.id)
);
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" }],
};
async function run() {
loading.value = true;
try {
const list = (await siteApi.list().send()) || [];
// 学校上下文过滤(docs/09 §3.3):默认仅当前学校;超管 scope=all 时显示全部
rows.value = scopeAll.value
? list
: list.filter(s => !store.currentSchool || s.schId === store.currentSchool!.schId);
} finally {
loading.value = false;
}
}
async function loadThemes() {
try {
themes.value = (await themeApi.list().send()) || [];
} catch {
themes.value = [];
}
}
function openWizard() {
wizardVisible.value = true;
}
function onWizardCreated() {
run();
}
function openEdit(row: SiteVo) {
Object.assign(form, {
id: row.id,
code: row.code,
parentSiteId: row.parentSiteId || undefined,
schId: row.schId,
schName: row.schName || "",
name: row.name,
theme: themeMeta.value[row.theme || "default"] ? row.theme! : "default",
configJson: row.configJson || "",
status: row.status,
});
customCss.value = readCustomCss(form.configJson);
const t = editTheme.value;
editThemeParams.value = t ? themeParamsFromConfigJson(row.configJson, t) : {};
modalVisible.value = true;
}
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 (form.theme === name) return;
form.theme = name;
const t = editTheme.value;
editThemeParams.value = t ? formModelFromTheme(t, editThemeParams.value) : {};
}
async function save() {
const valid = await formRef.value?.validate();
if (!valid) return;
const configValid = await configFormRef.value?.validate();
if (!configValid) return;
saving.value = true;
try {
const t = editTheme.value;
const themeParams = t ? editThemeParams.value : {};
const baseConfig = withCustomCss(form.configJson || "", customCss.value);
await siteApi
.save({
id: form.id,
code: form.code,
parentSiteId: form.parentSiteId || 0,
// B1:校级角色隐藏学校信息字段,留空由服务端继承主站(防伪造通道)
schId: isSchoolAdmin.value ? undefined : form.schId,
schName: isSchoolAdmin.value ? undefined : form.schName || undefined,
name: form.name,
theme: form.theme || undefined,
configJson: t ? mergeThemeParamsIntoConfigJson(baseConfig, t, themeParams) : baseConfig || undefined,
status: form.status,
})
.send();
Message.success("保存成功!");
modalVisible.value = false;
run();
} finally {
saving.value = false;
}
}
function openPortal(row: SiteVo) {
window.open(`${config.serviceHost}/portal/${row.code}/`, "_blank");
}
/** 行级「进入管理」(docs/09 §3.3 子站管理入口):切当前操作站点后落文章列表;
* 全局视图跨校行先重建学校上下文(面包屑/左侧菜单跟随),子站行向上找主站行定学校壳 */
function enterManage(row: SiteVo) {
const main = row.parentSiteId ? rows.value.find(s => s.id === row.parentSiteId) : row;
if (!main) {
Message.warning("未找到所属主站,无法进入管理");
return;
}
if (main.code !== store.schoolCode) {
store.enterSchool(
{
schId: row.schId,
schName: row.schName,
mainSiteId: main.id,
mainSiteCode: main.code,
mainSiteName: main.name,
},
rows.value.filter(s => s.id === main.id || s.parentSiteId === main.id)
);
}
store.setCurrentSite({ id: row.id, code: row.code, name: row.name });
router.push(`/sch/${main.code}/article`);
}
function openEditPreview() {
previewVisible.value = true;
}
/** 全局视图返回学校工作台 */
function goBackWorkbench() {
window.location.hash = "#/workbench";
}
onMounted(() => {
run();
loadThemes();
});
// 学校上下文切换即时重载(docs/09 §3.3)
watch(() => store.currentSchool?.schId, () => run());
</script>
<template>
<div class="page-wrapper">
<div class="main-container">
<div class="fix" v-if="scopeAll">
<Alert type="info" show-icon style="width: 100%">
全局站点视图:显示全部学校的站点(含各校主站与院系子站)。
<a href="javascript:void(0);" @click="goBackWorkbench">返回学校工作台</a>
</Alert>
</div>
<div class="fix" v-if="canCreateSite">
<Button class="fl" type="primary" size="large" @click="openWizard">
<Icon type="ios-add" />
{{ isSchoolAdmin ? "新建院系子站" : "新建站点(向导)" }}
</Button>
</div>
<Table :columns="columns" :data="rows" :height="tableHeight" :loading="loading" no-data-text="暂无站点" />
<!-- 编辑弹窗 -->
<Modal v-model="modalVisible" title="编辑站点" :mask-closable="false" width="720px">
<Form ref="formRef" :model="form" :rules="rules" :label-width="110">
<FormItem label="站点编码" prop="code">
<Input v-model="form.code" :disabled="!!form.id" placeholder="URL 路径用,如 demo" />
</FormItem>
<FormItem label="站点名称" prop="name">
<Input v-model="form.name" :maxlength="128" placeholder="如 XX学校门户网站" />
</FormItem>
<FormItem label="上级站点">
<Select v-model="form.parentSiteId" :clearable="!isSchoolAdmin" 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-tip">院系子站挂在主站下,门户 header 右上出现"专业院系"下拉;层级固定两层。{{ isSchoolAdmin ? "校级角色仅可在本校主站之间改挂。" : "" }}</p>
</FormItem>
<FormItem v-if="store.isSuperAdmin" label="学校ID">
<InputNumber v-model="form.schId" :min="1" placeholder="UserBase sys_group.id" style="width: 200px" />
</FormItem>
<FormItem v-if="store.isSuperAdmin" label="学校名称">
<Input v-model="form.schName" :maxlength="128" />
</FormItem>
<FormItem label="皮肤包">
<ThemePicker :themes="themes" :model-value="form.theme" @update:model-value="pickTheme" />
<Button size="small" class="preview-btn" :disabled="!editTheme" @click="openEditPreview">预览本皮肤</Button>
<ThemeParamForm :theme="editTheme" v-model="editThemeParams" />
<p class="form-tip">换肤/改参数保存后门户立即生效(缓存已自动失效)。</p>
</FormItem>
<FormItem label="状态">
<RadioGroup v-model="form.status">
<Radio :label="1">启用</Radio>
<Radio :label="0">停用</Radio>
</RadioGroup>
</FormItem>
</Form>
<!-- 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>
<!-- 站点配置结构化表单(独立 Form 分区,v-model 直绑 configJson) -->
<SiteConfigForm ref="configFormRef" v-model="form.configJson" />
<template #footer>
<Button @click="modalVisible = false">取消</Button>
<Button type="primary" :loading="saving" @click="save">保存</Button>
</template>
</Modal>
<!-- 编辑预览弹窗(iframe 加载 /api/theme/preview,no-store) -->
<Modal v-model="previewVisible" title="皮肤预览" :mask-closable="true" width="980px" footer-hide>
<ThemePreviewFrame v-if="previewVisible && editTheme" :site-code="form.code" :theme="form.theme" :theme-params="editThemeParams" />
</Modal>
<!-- 新建站点三步向导(B1:校级锁定为本校主站下建院系子站) -->
<SiteWizard v-model:visible="wizardVisible" :sites="rows" :lock-parent-id="isSchoolAdmin ? store.currentSchool?.mainSiteId : undefined" @created="onWizardCreated" />
</div>
</div>
</template>
<style scoped lang="scss">
.form-tip {
color: #999;
font-size: 12px;
line-height: 1.6;
}
.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>