feat(B3): 站点编辑弹窗增自定义样式面板(config_json.custom_css)
- 折叠面板 textarea(等宽字体/10240计数/示例提示),面板为唯一编辑入口 - custom_css 入 RESERVED_SITE_CONFIG_KEYS(结构化表单保留键,不与扩展行重复编辑) - vitest 25/25 绿(新增保留键往返用例)+build 成功
This commit is contained in:
@@ -115,4 +115,11 @@ describe("siteConfigForm", () => {
|
||||
"domain", "public_base_url",
|
||||
]);
|
||||
});
|
||||
it("B3:custom_css 为保留键,不入扩展行,parse/serialize 无损往返", () => {
|
||||
const json = JSON.stringify({ custom_css: ".site-header{padding:16px 0}", my_ext: "v" });
|
||||
const m = parseSiteConfig(json);
|
||||
expect(m.extras).toEqual([{ key: "my_ext", value: "v" }]);
|
||||
expect(m.preserved.custom_css).toBe(".site-header{padding:16px 0}");
|
||||
expect(JSON.parse(serializeSiteConfig(m)).custom_css).toBe(".site-header{padding:16px 0}");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -75,7 +75,7 @@ export const SITE_CONFIG_GROUPS: { name: SiteConfigGroup; title: string }[] = [
|
||||
];
|
||||
|
||||
/** 系统托管键:不进表单,解析时原样保留、序列化时无损回写(theme_params 由主题参数表单维护) */
|
||||
export const RESERVED_SITE_CONFIG_KEYS = ["theme_params"];
|
||||
export const RESERVED_SITE_CONFIG_KEYS = ["theme_params", "custom_css"];
|
||||
|
||||
/** 扩展键名约束(与后端 CONFIG_KEY_PATTERN 一致) */
|
||||
export const EXTRA_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
|
||||
@@ -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>保存后门户立即生效(缓存自动失效);服务端将拒绝 </style、<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>
|
||||
|
||||
Reference in New Issue
Block a user