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",
|
"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 由主题参数表单维护) */
|
/** 系统托管键:不进表单,解析时原样保留、序列化时无损回写(theme_params 由主题参数表单维护) */
|
||||||
export const RESERVED_SITE_CONFIG_KEYS = ["theme_params"];
|
export const RESERVED_SITE_CONFIG_KEYS = ["theme_params", "custom_css"];
|
||||||
|
|
||||||
/** 扩展键名约束(与后端 CONFIG_KEY_PATTERN 一致) */
|
/** 扩展键名约束(与后端 CONFIG_KEY_PATTERN 一致) */
|
||||||
export const EXTRA_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
export const EXTRA_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||||
|
|||||||
@@ -131,6 +131,9 @@ const form = reactive({
|
|||||||
const editThemeParams = ref<FormModelValue>({});
|
const editThemeParams = ref<FormModelValue>({});
|
||||||
const editTheme = computed(() => themeMeta.value[form.theme]);
|
const editTheme = computed(() => themeMeta.value[form.theme]);
|
||||||
|
|
||||||
|
/** B3 自定义样式(config_json.custom_css):面板为唯一编辑入口,SiteConfigForm 侧该键为保留键 */
|
||||||
|
const customCss = ref("");
|
||||||
|
|
||||||
/** 编辑内预览 */
|
/** 编辑内预览 */
|
||||||
const previewVisible = ref(false);
|
const previewVisible = ref(false);
|
||||||
|
|
||||||
@@ -192,11 +195,35 @@ function openEdit(row: SiteVo) {
|
|||||||
configJson: row.configJson || "",
|
configJson: row.configJson || "",
|
||||||
status: row.status,
|
status: row.status,
|
||||||
});
|
});
|
||||||
|
customCss.value = readCustomCss(form.configJson);
|
||||||
const t = editTheme.value;
|
const t = editTheme.value;
|
||||||
editThemeParams.value = t ? themeParamsFromConfigJson(row.configJson, t) : {};
|
editThemeParams.value = t ? themeParamsFromConfigJson(row.configJson, t) : {};
|
||||||
modalVisible.value = true;
|
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) {
|
function pickTheme(name: string) {
|
||||||
if (form.theme === name) return;
|
if (form.theme === name) return;
|
||||||
form.theme = name;
|
form.theme = name;
|
||||||
@@ -213,6 +240,7 @@ async function save() {
|
|||||||
try {
|
try {
|
||||||
const t = editTheme.value;
|
const t = editTheme.value;
|
||||||
const themeParams = t ? editThemeParams.value : {};
|
const themeParams = t ? editThemeParams.value : {};
|
||||||
|
const baseConfig = withCustomCss(form.configJson || "", customCss.value);
|
||||||
await siteApi
|
await siteApi
|
||||||
.save({
|
.save({
|
||||||
id: form.id,
|
id: form.id,
|
||||||
@@ -223,7 +251,7 @@ async function save() {
|
|||||||
schName: isSchoolAdmin.value ? undefined : form.schName || undefined,
|
schName: isSchoolAdmin.value ? undefined : form.schName || undefined,
|
||||||
name: form.name,
|
name: form.name,
|
||||||
theme: form.theme || undefined,
|
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,
|
status: form.status,
|
||||||
})
|
})
|
||||||
.send();
|
.send();
|
||||||
@@ -332,6 +360,23 @@ watch(() => store.currentSchool?.schId, () => run());
|
|||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
</Form>
|
</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) -->
|
<!-- 站点配置结构化表单(独立 Form 分区,v-model 直绑 configJson) -->
|
||||||
<SiteConfigForm ref="configFormRef" v-model="form.configJson" />
|
<SiteConfigForm ref="configFormRef" v-model="form.configJson" />
|
||||||
<template #footer>
|
<template #footer>
|
||||||
@@ -360,4 +405,20 @@ watch(() => store.currentSchool?.schId, () => run());
|
|||||||
.preview-btn {
|
.preview-btn {
|
||||||
margin: 8px 0 0;
|
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>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user