feat(B3): 站点编辑弹窗增自定义样式面板(config_json.custom_css)

- 折叠面板 textarea(等宽字体/10240计数/示例提示),面板为唯一编辑入口
- custom_css 入 RESERVED_SITE_CONFIG_KEYS(结构化表单保留键,不与扩展行重复编辑)
- vitest 25/25 绿(新增保留键往返用例)+build 成功
This commit is contained in:
chenxing
2026-09-08 15:14:08 +08:00
parent bcd639cfc2
commit bb5c5e513f
3 changed files with 70 additions and 2 deletions
+62 -1
View File
@@ -131,6 +131,9 @@ const form = reactive({
const editThemeParams = ref<FormModelValue>({});
const editTheme = computed(() => themeMeta.value[form.theme]);
/** B3 自定义样式(config_json.custom_css):面板为唯一编辑入口,SiteConfigForm 侧该键为保留键 */
const customCss = ref("");
/** 编辑内预览 */
const previewVisible = ref(false);
@@ -192,11 +195,35 @@ function openEdit(row: SiteVo) {
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;
@@ -213,6 +240,7 @@ async function save() {
try {
const t = editTheme.value;
const themeParams = t ? editThemeParams.value : {};
const baseConfig = withCustomCss(form.configJson || "", customCss.value);
await siteApi
.save({
id: form.id,
@@ -223,7 +251,7 @@ async function save() {
schName: isSchoolAdmin.value ? undefined : form.schName || undefined,
name: form.name,
theme: form.theme || undefined,
configJson: t ? mergeThemeParamsIntoConfigJson(form.configJson || "", t, themeParams) : form.configJson || undefined,
configJson: t ? mergeThemeParamsIntoConfigJson(baseConfig, t, themeParams) : baseConfig || undefined,
status: form.status,
})
.send();
@@ -332,6 +360,23 @@ watch(() => store.currentSchool?.schId, () => run());
</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>
@@ -360,4 +405,20 @@ watch(() => store.currentSchool?.schId, () => run());
.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>