feat: 配置中心-品牌(表单/本地预览/嵌入代码/头像上传)+ 站点覆盖层编辑入口

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-13 19:27:08 +08:00
parent 6f22e51908
commit cac91e9e3e
7 changed files with 1660 additions and 127 deletions
@@ -0,0 +1,76 @@
<!--
站点门面覆盖层表单字段(Task 7a 从 tenants/list 的「新建站点」弹窗提取共用)
- 消费方:新建站点弹窗 / 编辑门面弹窗(同一字段清单与留空=继承语义)
- props.form 为父组件持有的 reactive 表单对象,本组件就地双向绑定其字段
(不整体替换引用——父组件保留 slug/name 等自有字段的所有权)
- 留空 = 不写入覆盖 = 继承租户默认品牌(brand 组);收集逻辑见 model.ts
-->
<template>
<div class="overlay-fields">
<div class="overlay-header">
<span class="overlay-title">门面覆盖层</span>
<span class="overlay-sub">以下字段留空 = 继承租户默认品牌(brand 组)</span>
</div>
<div class="overlay-grid">
<ElFormItem v-for="field in OVERLAY_FIELDS" :key="field.key" :label="field.label">
<ElInput
v-model="form[field.key]"
:placeholder="field.key === 'color' ? '#066fc0(留空继承)' : '留空=继承租户默认'"
/>
</ElFormItem>
</div>
<ElFormItem label="快捷话题 topics">
<ElSelect
v-model="form.topics"
multiple
filterable
allow-create
default-first-option
:reserve-keyword="false"
placeholder="输入后回车添加;留空 = 继承租户默认"
class="overlay-topics"
/>
</ElFormItem>
</div>
</template>
<script setup lang="ts">
import { OVERLAY_FIELDS, type OverlayFormState } from './model'
defineOptions({ name: 'SiteOverlayFields' })
defineProps<{ form: OverlayFormState }>()
</script>
<style scoped lang="scss">
.overlay-fields {
.overlay-header {
display: flex;
align-items: baseline;
gap: 8px;
margin: 4px 0 8px;
padding-top: 8px;
border-top: 1px dashed var(--art-card-border);
.overlay-title {
font-size: 13px;
font-weight: 600;
}
.overlay-sub {
font-size: 12px;
color: var(--art-gray-400);
}
}
.overlay-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0 16px;
}
.overlay-topics {
width: 100%;
}
}
</style>
@@ -0,0 +1,89 @@
/**
* 站点门面覆盖层字段模型(Task 6 建立;Task 7a 提取共用)
*
* 消费方:站点管理页(tenants/list)的「新建站点」与「编辑门面」弹窗——
* 两处共用同一字段清单 / 空表单 / 提交收集逻辑。
*
* 语义(与后端 tenant_routes.patch_site 的整字典赋值对齐):
* - buildOverlay 只收集非空字段——留空 = 剔除该键的覆盖 = 继承租户默认
* 品牌(brand 组);旧键不残留(整字典替换,M-2a)。
* - overlay 值是 brand 组子集(站点级覆盖,public.py 的 site.brand 深合并)。
*/
/** 门面覆盖层字符串字段(brand 组子集) */
export const OVERLAY_FIELDS = [
{ key: 'title', label: '标题 title' },
{ key: 'color', label: '主题色 color' },
{ key: 'avatar', label: '头像路径 avatar' },
{ key: 'welcome', label: '欢迎语 welcome' },
{ key: 'placeholder', label: '输入框占位 placeholder' },
{ key: 'whatsapp', label: 'WhatsApp' },
{ key: 'email', label: '邮箱 email' },
{ key: 'privacy_url', label: '隐私政策 URL' }
] as const
/** 覆盖层字段 key 联合类型 */
export type OverlayFieldKey = (typeof OVERLAY_FIELDS)[number]['key']
/** 覆盖层表单状态(字符串字段全空串语义 + topics 数组) */
export interface OverlayFormState extends Record<OverlayFieldKey, string> {
topics: string[]
}
/** 覆盖层字段中文标签(站点表 tooltip 用;topics 单列) */
export const OVERLAY_LABELS: Record<string, string> = Object.fromEntries(
OVERLAY_FIELDS.map((f) => [f.key, f.label] as [string, string]).concat([
['topics', '快捷话题 topics']
])
)
/** 空表单(全留空 = 完全继承租户默认) */
export function blankOverlayForm(): OverlayFormState {
return {
title: '',
color: '',
avatar: '',
welcome: '',
placeholder: '',
whatsapp: '',
email: '',
privacy_url: '',
topics: []
}
}
/**
* 表单 → brand_overlay 提交字典:只收集非空字段(留空 = 不覆盖)。
* 全空表单产出 {} = 清空覆盖层(整字典赋值语义下回到完全继承)。
*/
export function buildOverlay(form: OverlayFormState): Record<string, unknown> {
const overlay: Record<string, unknown> = {}
for (const field of OVERLAY_FIELDS) {
const value = form[field.key].trim()
if (value) overlay[field.key] = value
}
const topics = form.topics.map((t) => t.trim()).filter(Boolean)
if (topics.length > 0) overlay.topics = topics
return overlay
}
/**
* overlay 落库值 → 表单预填(编辑门面用):只认已知键的字符串值;
* 返回表单之外未能识别的键(保存时会被整字典替换丢弃,调用方提示用户)。
*/
export function overlayToForm(
overlay: Record<string, unknown> | null | undefined
): { form: OverlayFormState; unknownKeys: string[] } {
const form = blankOverlayForm()
const source = overlay ?? {}
for (const field of OVERLAY_FIELDS) {
const value = source[field.key]
if (typeof value === 'string') form[field.key] = value
}
if (Array.isArray(source.topics)) {
form.topics = source.topics.filter((t): t is string => typeof t === 'string')
}
const known = new Set<string>([...OVERLAY_FIELDS.map((f) => f.key), 'topics'])
const unknownKeys = Object.keys(source).filter((k) => !known.has(k))
return { form, unknownKeys }
}