- 折叠面板 textarea(等宽字体/10240计数/示例提示),面板为唯一编辑入口 - custom_css 入 RESERVED_SITE_CONFIG_KEYS(结构化表单保留键,不与扩展行重复编辑) - vitest 25/25 绿(新增保留键往返用例)+build 成功
425 lines
17 KiB
Vue
425 lines
17 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 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>保存后门户立即生效(缓存自动失效);服务端将拒绝 </style、<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>
|