186 lines
6.5 KiB
Vue
186 lines
6.5 KiB
Vue
<script setup lang="ts">
|
||
import { reactive, ref, watch } from "vue";
|
||
import {
|
||
SITE_CONFIG_FIELD_DEFS,
|
||
SITE_CONFIG_GROUPS,
|
||
SiteConfigFieldDef,
|
||
SiteConfigFormModel,
|
||
SiteConfigGroup,
|
||
parseSiteConfig,
|
||
serializeSiteConfig,
|
||
validateFieldValue,
|
||
} from "@/utils/siteConfigForm";
|
||
import ImageAssetField from "@/components/ImageAssetField.vue";
|
||
import FooterPreview from "@/components/FooterPreview.vue";
|
||
|
||
/**
|
||
* 站点配置结构化表单(docs/09 §3.1;docs/16 §3.3 分区作用域;docs/20 F6 扩展参数区退役):
|
||
* v-model 绑定整页共享的 configJson 字符串(单一事实源)。
|
||
* props.groups 声明本分区渲染的分组。模型仍完整解析全部键(fields/preserved 双通道),
|
||
* 仅渲染限定分组——序列化时未渲染分区的值原样回写,保证多分区实例绑定同一 configJson 互不踩踏。
|
||
* 保存前由父级调用 validate()。
|
||
*/
|
||
const props = defineProps<{
|
||
modelValue: string;
|
||
/** 渲染的配置分组(缺省=全部分组,兼容旧整表单用法) */
|
||
groups?: SiteConfigGroup[];
|
||
/** 图片类字段上传归属站点(附件场景引用) */
|
||
siteId?: number;
|
||
}>();
|
||
const emit = defineEmits<{ (e: "update:modelValue", v: string): void }>();
|
||
|
||
/** 本实例渲染的分组集合(空=全部) */
|
||
const groupSet = (() => new Set(props.groups || SITE_CONFIG_GROUPS.map(g => g.name)))();
|
||
const visibleGroups = SITE_CONFIG_GROUPS.filter(g => groupSet.has(g.name));
|
||
|
||
const formRef = ref();
|
||
const formModel = reactive<{ fields: Record<string, string> }>({
|
||
fields: {},
|
||
});
|
||
let preserved: Record<string, unknown> = {};
|
||
let lastEmitted: string | null = null;
|
||
|
||
function parseIn(v: string) {
|
||
const m: SiteConfigFormModel = parseSiteConfig(v);
|
||
Object.keys(formModel.fields).forEach(k => delete formModel.fields[k]);
|
||
Object.assign(formModel.fields, m.fields);
|
||
preserved = m.preserved;
|
||
}
|
||
|
||
watch(
|
||
() => props.modelValue,
|
||
v => {
|
||
// 自己 emit 出去的值回灌时不重解析,避免光标/输入被打断
|
||
if (v !== lastEmitted) parseIn(v || "");
|
||
},
|
||
{ immediate: true },
|
||
);
|
||
|
||
function emitUp() {
|
||
lastEmitted = serializeSiteConfig({
|
||
fields: { ...formModel.fields },
|
||
preserved,
|
||
});
|
||
emit("update:modelValue", lastEmitted);
|
||
}
|
||
|
||
/** 保存前校验:内置字段 Form 规则;通过返回 true */
|
||
async function validate(): Promise<boolean> {
|
||
return !!(await formRef.value?.validate());
|
||
}
|
||
|
||
/**
|
||
* F1(docs/20)Tab 独立保存:仅导出本实例渲染分组的字段值(空值剔除)。
|
||
* 父级把返回值合并进服务端事实源 configJson 后提交——其他 Tab 的未保存修改不会被连带提交。
|
||
*/
|
||
function ownFields(): Record<string, string> {
|
||
const out: Record<string, string> = {};
|
||
visibleGroups.forEach(g => {
|
||
defsOf(g.name).forEach(d => {
|
||
const v = (formModel.fields[d.key] || "").trim();
|
||
if (v) {
|
||
out[d.key] = v;
|
||
}
|
||
});
|
||
});
|
||
return out;
|
||
}
|
||
|
||
defineExpose({ validate, ownFields });
|
||
|
||
function rulesFor(def: SiteConfigFieldDef) {
|
||
return [
|
||
{
|
||
validator: (_rule: unknown, value: string, cb: (err?: Error) => void) => {
|
||
const msg = validateFieldValue(def, value);
|
||
cb(msg ? new Error(msg) : undefined);
|
||
},
|
||
trigger: "blur",
|
||
},
|
||
];
|
||
}
|
||
|
||
function defsOf(group: string): SiteConfigFieldDef[] {
|
||
return SITE_CONFIG_FIELD_DEFS.filter(d => d.group === group);
|
||
}</script>
|
||
|
||
<template>
|
||
<div class="site-config-form">
|
||
<Form ref="formRef" :model="formModel" :label-width="130">
|
||
<template v-for="g in visibleGroups" :key="g.name">
|
||
<div class="group-title">{{ g.title }}</div>
|
||
<FormItem
|
||
v-for="def in defsOf(g.name)"
|
||
:key="def.key"
|
||
:label="def.label"
|
||
:prop="`fields.${def.key}`"
|
||
:rules="rulesFor(def)"
|
||
>
|
||
<ImageAssetField
|
||
v-if="def.kind === 'asset'"
|
||
v-model="formModel.fields[def.key]"
|
||
:site-id="siteId"
|
||
:placeholder="def.placeholder"
|
||
:aspect="def.aspect"
|
||
@update:model-value="emitUp"
|
||
/>
|
||
<Input
|
||
v-else-if="!def.multiline"
|
||
v-model="formModel.fields[def.key]"
|
||
:placeholder="def.placeholder"
|
||
@on-change="emitUp"
|
||
/>
|
||
<Input
|
||
v-else
|
||
v-model="formModel.fields[def.key]"
|
||
type="textarea"
|
||
:rows="2"
|
||
:placeholder="def.placeholder"
|
||
@on-change="emitUp"
|
||
/>
|
||
<p v-if="def.tip" class="cfg-tip">{{ def.tip }}</p>
|
||
</FormItem>
|
||
<!-- F5(docs/20):页脚分组底部固定结构示意图(字段↔视觉位置一一对应,实时回显) -->
|
||
<FooterPreview
|
||
v-if="g.name === 'footer'"
|
||
:contact="formModel.fields.contact"
|
||
:footer-addresses="formModel.fields.footer_addresses"
|
||
:qr-wechat="formModel.fields.qr_wechat"
|
||
:qr-weibo="formModel.fields.qr_weibo"
|
||
:socials="[
|
||
{ label: '微信', value: formModel.fields.social_wechat },
|
||
{ label: '微博', value: formModel.fields.social_weibo },
|
||
{ label: '抖音', value: formModel.fields.social_douyin },
|
||
{ label: 'B站', value: formModel.fields.social_bilibili },
|
||
]"
|
||
:copyright="formModel.fields.copyright"
|
||
:beian="formModel.fields.beian"
|
||
/>
|
||
</template>
|
||
</Form>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped lang="scss">
|
||
.site-config-form {
|
||
.group-title {
|
||
font-weight: 600;
|
||
color: #515a6e;
|
||
font-size: 13px;
|
||
margin: 8px 0 14px;
|
||
padding-left: 8px;
|
||
border-left: 3px solid #2d8cf0;
|
||
line-height: 1.2;
|
||
&:not(:first-child) {
|
||
margin-top: 22px;
|
||
}
|
||
}
|
||
.cfg-tip {
|
||
color: #999;
|
||
font-size: 12px;
|
||
line-height: 1.6;
|
||
margin-top: 4px;
|
||
}
|
||
}
|
||
</style>
|