feat: 配置中心-品牌(表单/本地预览/嵌入代码/头像上传)+ 站点覆盖层编辑入口
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* 配置中心 API 单元测试(Phase 3 Task 7a——品牌相关)
|
||||
*
|
||||
* 钉住 API 层契约(事实源 app/platform/routes/config_routes.py 与
|
||||
* upload_routes.py);HTTP 层行为已在 client.test.ts 覆盖,此处 mock http
|
||||
* 只断言调用形态:
|
||||
* - GET config:快照 + version(原样组——缺键防御读取靠 normalizeBrandGroup)
|
||||
* - PUT config/brand:请求体是**组值本身**(整组替换语义,非 {note,value}
|
||||
* 包装),备注走 comment 查询参数,无备注时不带 params
|
||||
* - 头像上传:multipart file 字段(FormData)
|
||||
* - normalizeBrandGroup:缺键补空串 / 类型漂移防御 / topics 只留字符串项
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
http: { get: vi.fn(), post: vi.fn(), put: vi.fn() }
|
||||
}))
|
||||
|
||||
import { http } from '@/api/client'
|
||||
import {
|
||||
CONFIG_ENDPOINTS,
|
||||
fetchTenantConfig,
|
||||
normalizeBrandGroup,
|
||||
putConfigGroup,
|
||||
uploadBrandAvatar
|
||||
} from '@/api/config'
|
||||
|
||||
describe('配置中心 API(src/api/config.ts)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('fetchTenantConfig:GET /api/platform/tenants/{slug}/config', async () => {
|
||||
const body = {
|
||||
slug: 'jiancai',
|
||||
version: 3,
|
||||
config: {
|
||||
brand: { title: 'HESUPIPE Support', color: '#066fc0', topics: ['Get Quotation'] }
|
||||
}
|
||||
}
|
||||
vi.mocked(http.get).mockResolvedValue(body)
|
||||
|
||||
const res = await fetchTenantConfig('jiancai')
|
||||
|
||||
expect(res).toEqual(body)
|
||||
expect(http.get).toHaveBeenCalledWith('/api/platform/tenants/jiancai/config', undefined)
|
||||
})
|
||||
|
||||
it('putConfigGroup:PUT 请求体为组值本身 + comment 查询参数(整组替换契约)', async () => {
|
||||
const brand = {
|
||||
title: 'HESUPIPE Support',
|
||||
color: '#ff6600',
|
||||
avatar: 'abc123.png',
|
||||
welcome: 'Hello',
|
||||
placeholder: 'Ask me',
|
||||
whatsapp: '+8615937108317',
|
||||
email: 'hesu@hesuu.com',
|
||||
privacy_url: 'https://hesupipe.com/privacy-policy',
|
||||
topics: ['Get Quotation', 'Delivery & Shipping']
|
||||
}
|
||||
const response = { slug: 'jiancai', group: 'brand', version: 4, config: brand }
|
||||
vi.mocked(http.put).mockResolvedValue(response)
|
||||
|
||||
const res = await putConfigGroup('jiancai', 'brand', { ...brand }, '改主题色')
|
||||
|
||||
expect(res).toEqual(response)
|
||||
expect(http.put).toHaveBeenCalledWith(
|
||||
'/api/platform/tenants/jiancai/config/brand',
|
||||
brand, // 组值直传——绝不能包成 { note, value }(后端 _json_body 整组校验)
|
||||
{ params: { comment: '改主题色' } }
|
||||
)
|
||||
})
|
||||
|
||||
it('putConfigGroup:无备注时不带 params(comment 是可选项)', async () => {
|
||||
vi.mocked(http.put).mockResolvedValue({
|
||||
slug: 'jiancai',
|
||||
group: 'brand',
|
||||
version: 5,
|
||||
config: {}
|
||||
})
|
||||
|
||||
await putConfigGroup('jiancai', 'brand', { title: 'T' })
|
||||
|
||||
expect(http.put).toHaveBeenCalledWith(
|
||||
'/api/platform/tenants/jiancai/config/brand',
|
||||
{ title: 'T' },
|
||||
{ params: undefined }
|
||||
)
|
||||
})
|
||||
|
||||
it('uploadBrandAvatar:POST multipart(FormData file 字段)', async () => {
|
||||
const response = { slug: 'jiancai', avatar: '7f3c9d.png', version: 6 }
|
||||
vi.mocked(http.post).mockResolvedValue(response)
|
||||
const file = new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], 'logo.png', {
|
||||
type: 'image/png'
|
||||
})
|
||||
|
||||
const res = await uploadBrandAvatar('jiancai', file, { showErrorMessage: false })
|
||||
|
||||
expect(res).toEqual(response)
|
||||
expect(http.post).toHaveBeenCalledTimes(1)
|
||||
const [url, body, config] = vi.mocked(http.post).mock.calls[0]
|
||||
expect(url).toBe('/api/platform/tenants/jiancai/brand/avatar')
|
||||
expect(body).toBeInstanceOf(FormData)
|
||||
expect((body as FormData).get('file')).toBe(file)
|
||||
expect(config).toEqual({ showErrorMessage: false })
|
||||
})
|
||||
|
||||
it('CONFIG_ENDPOINTS.avatarUpload:端点路径与后端 upload_routes 一致', () => {
|
||||
expect(CONFIG_ENDPOINTS.avatarUpload('jiancai')).toBe(
|
||||
'/api/platform/tenants/jiancai/brand/avatar'
|
||||
)
|
||||
expect(CONFIG_ENDPOINTS.putGroup('jiancai', 'brand')).toBe(
|
||||
'/api/platform/tenants/jiancai/config/brand'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeBrandGroup(GET 原样组 → 表单完整值)', () => {
|
||||
it('缺键补空串、topics 缺省为空数组(快照是原样组,可能缺键)', () => {
|
||||
const out = normalizeBrandGroup({ title: 'HESUPIPE Support' })
|
||||
expect(out).toEqual({
|
||||
title: 'HESUPIPE Support',
|
||||
color: '',
|
||||
avatar: '',
|
||||
welcome: '',
|
||||
placeholder: '',
|
||||
whatsapp: '',
|
||||
email: '',
|
||||
privacy_url: '',
|
||||
topics: []
|
||||
})
|
||||
})
|
||||
|
||||
it('类型漂移防御:非字符串字段读作空串、topics 只留字符串项', () => {
|
||||
const out = normalizeBrandGroup({
|
||||
title: 123,
|
||||
color: null,
|
||||
topics: ['Get Quotation', 42, null, 'Delivery'],
|
||||
welcome: true
|
||||
})
|
||||
expect(out.title).toBe('')
|
||||
expect(out.color).toBe('')
|
||||
expect(out.welcome).toBe('')
|
||||
expect(out.topics).toEqual(['Get Quotation', 'Delivery'])
|
||||
})
|
||||
|
||||
it('完整组原样保留(正常路径)', () => {
|
||||
const full = {
|
||||
title: 'HESUPIPE Support',
|
||||
color: '#066fc0',
|
||||
avatar: '/static/kefu-small.png',
|
||||
welcome: 'What kind of piping project?',
|
||||
placeholder: 'Ask about pipes...',
|
||||
whatsapp: '+8615937108317',
|
||||
email: 'hesu@hesuu.com',
|
||||
privacy_url: 'https://hesupipe.com/privacy-policy',
|
||||
topics: ['Get Quotation']
|
||||
}
|
||||
expect(normalizeBrandGroup(full)).toEqual(full)
|
||||
})
|
||||
|
||||
it('null / undefined 组值 → 全空表单值', () => {
|
||||
expect(normalizeBrandGroup(null).title).toBe('')
|
||||
expect(normalizeBrandGroup(undefined).topics).toEqual([])
|
||||
})
|
||||
})
|
||||
+157
-18
@@ -1,16 +1,49 @@
|
||||
/**
|
||||
* 租户配置中心 API 骨架(Phase 3 Task 3)
|
||||
* 租户配置中心 API(Phase 3 Task 3 骨架;Task 7a 实装品牌相关调用函数)
|
||||
*
|
||||
* 端点事实源:app/platform/routes/config_routes.py
|
||||
* (配置快照 / 分组编辑 / 版本历史 / 回滚 / 重置 / 试金石)。
|
||||
* 本文件只建类型与端点常量——调用函数在 Task 7(配置中心四件)实装。
|
||||
* (配置快照 / 分组编辑 / 版本历史 / 回滚 / 重置 / 试金石)
|
||||
* 与 app/platform/routes/upload_routes.py(头像上传)。
|
||||
*
|
||||
* 密钥掩码哨兵:GET 的密钥字段非空时为 '__SET__'(见 src/api/mask.ts
|
||||
* 的表单语义转换);PUT 提交 '__SET__' = 保持库值。
|
||||
* 的表单语义转换);PUT 提交 '__SET__' = 保持库值。品牌组无密钥字段,
|
||||
* 无哨兵处理需求。
|
||||
*
|
||||
* ## Task 7a 契约修正(对齐后端事实源)
|
||||
*
|
||||
* - PUT /config/{group} 的请求体是**组值本身**(整组替换语义),保存备注
|
||||
* 走查询参数 comment(config_routes.put_config_group 的 comment 形参);
|
||||
* - 版本行字段是 updated_by / comment(非骨架期误写的 created_by / note);
|
||||
* - 回滚请求体只认 version(note 不被后端读取);
|
||||
* - 试金石响应逐句为 {sentence, action, reason, replacement}。
|
||||
*/
|
||||
|
||||
/** 配置组名(快照顶层键) */
|
||||
export type ConfigGroupKey = 'brand' | 'prompts' | 'wall' | 'push' | 'email' | 'ai'
|
||||
import { http, type RequestOptions } from './client'
|
||||
|
||||
/** 配置组名(快照顶层键;与 config_validation.CONFIG_GROUPS 对齐) */
|
||||
export type ConfigGroupKey =
|
||||
| 'brand'
|
||||
| 'prompts'
|
||||
| 'wall'
|
||||
| 'contact'
|
||||
| 'email'
|
||||
| 'push'
|
||||
| 'ai'
|
||||
| 'engine'
|
||||
| 'misc'
|
||||
|
||||
/** 品牌组值(整组替换语义:字段清单固定,topics 为字符串数组) */
|
||||
export interface BrandGroupValue {
|
||||
title: string
|
||||
color: string
|
||||
avatar: string
|
||||
welcome: string
|
||||
topics: string[]
|
||||
placeholder: string
|
||||
whatsapp: string
|
||||
email: string
|
||||
privacy_url: string
|
||||
}
|
||||
|
||||
/** 当前生效配置响应(GET /api/platform/tenants/{slug}/config) */
|
||||
export interface TenantConfigResponse {
|
||||
@@ -20,12 +53,20 @@ export interface TenantConfigResponse {
|
||||
config: Record<string, Record<string, unknown>>
|
||||
}
|
||||
|
||||
/** 配置版本行(GET .../config/versions) */
|
||||
/** 分组保存响应(PUT .../config/{group};config 为该组掩码视图) */
|
||||
export interface PutConfigGroupResponse {
|
||||
slug: string
|
||||
group: string
|
||||
version: number
|
||||
config: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** 配置版本行(GET .../config/versions;字段名与后端一致) */
|
||||
export interface ConfigVersionItem {
|
||||
version: number
|
||||
created_at: string
|
||||
created_by: string
|
||||
note: string
|
||||
updated_by: string
|
||||
comment: string
|
||||
}
|
||||
|
||||
/** 版本列表响应 */
|
||||
@@ -40,27 +81,43 @@ export interface ConfigVersionDetailResponse extends ConfigVersionItem {
|
||||
config: Record<string, Record<string, unknown>>
|
||||
}
|
||||
|
||||
/** 回滚请求(POST .../config/rollback,产生新版本) */
|
||||
/** 回滚请求(POST .../config/rollback,产生新版本;后端只读 version) */
|
||||
export interface ConfigRollbackRequest {
|
||||
version: number
|
||||
note: string
|
||||
}
|
||||
|
||||
/** 分组保存请求(PUT .../config/{group}) */
|
||||
export interface PutConfigGroupRequest {
|
||||
note: string
|
||||
value: Record<string, unknown>
|
||||
/** 回滚响应 */
|
||||
export interface ConfigRollbackResponse {
|
||||
slug: string
|
||||
rollback_to: number
|
||||
version: number
|
||||
config: Record<string, Record<string, unknown>>
|
||||
}
|
||||
|
||||
/** 词表试金石请求(POST .../wall/test) */
|
||||
export interface WallTestRequest {
|
||||
sentences: string[]
|
||||
has_contact?: boolean
|
||||
source?: 'web' | 'app'
|
||||
}
|
||||
|
||||
/** 词表试金石响应(逐句判定) */
|
||||
/** 词表试金石响应(逐句判定;action: blocked/replaced/pass) */
|
||||
export interface WallTestResponse {
|
||||
results: Array<{ sentence: string; blocked: boolean; rule?: string }>
|
||||
slug: string
|
||||
wall_source: 'current' | 'override'
|
||||
results: Array<{
|
||||
sentence: string
|
||||
action: 'blocked' | 'replaced' | 'pass'
|
||||
reason: 'boundary' | 'no_contact' | 'reask' | null
|
||||
replacement: string | null
|
||||
}>
|
||||
}
|
||||
|
||||
/** 头像上传响应(POST .../brand/avatar;avatar 为落盘文件名) */
|
||||
export interface AvatarUploadResponse {
|
||||
slug: string
|
||||
avatar: string
|
||||
version: number
|
||||
}
|
||||
|
||||
/** 配置中心端点 */
|
||||
@@ -74,5 +131,87 @@ export const CONFIG_ENDPOINTS = {
|
||||
versionDetail: (slug: string, version: number) =>
|
||||
`/api/platform/tenants/${slug}/config/versions/${version}`,
|
||||
rollback: (slug: string) => `/api/platform/tenants/${slug}/config/rollback`,
|
||||
wallTest: (slug: string) => `/api/platform/tenants/${slug}/wall/test`
|
||||
wallTest: (slug: string) => `/api/platform/tenants/${slug}/wall/test`,
|
||||
avatarUpload: (slug: string) => `/api/platform/tenants/${slug}/brand/avatar`
|
||||
} as const
|
||||
|
||||
/**
|
||||
* 当前生效配置(MAX 版本快照 + version)。
|
||||
* 注意快照是"原样组"——组内缺省键由消费侧深合并出厂默认,
|
||||
* 品牌表单消费 config.brand 时用 normalizeBrandGroup 防御读取。
|
||||
*/
|
||||
export function fetchTenantConfig(
|
||||
slug: string,
|
||||
config?: RequestOptions
|
||||
): Promise<TenantConfigResponse> {
|
||||
return http.get<TenantConfigResponse>(CONFIG_ENDPOINTS.current(slug), config)
|
||||
}
|
||||
|
||||
/**
|
||||
* 分组保存(整组替换语义):请求体 = 组值本身,备注走 comment 查询参数。
|
||||
* 调用方必须先 GET 全组、改字段后整组提交——部分提交会丢掉未携带的键
|
||||
* (后端 deep merge 出厂默认兜底,但租户既有定制会被覆盖丢失)。
|
||||
*/
|
||||
export function putConfigGroup(
|
||||
slug: string,
|
||||
group: ConfigGroupKey | string,
|
||||
value: Record<string, unknown>,
|
||||
comment?: string,
|
||||
config?: RequestOptions
|
||||
): Promise<PutConfigGroupResponse> {
|
||||
return http.put<PutConfigGroupResponse>(
|
||||
CONFIG_ENDPOINTS.putGroup(slug, group),
|
||||
value,
|
||||
{
|
||||
...config,
|
||||
params: comment ? { comment } : undefined
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 头像上传(multipart file 字段):魔数校验 / 2MB 上限在后端
|
||||
* (upload_routes.py),前端做扩展名与大小预检提前拦截。
|
||||
* 成功后 brand.avatar 更新为落盘文件名,版本 +1 并热生效。
|
||||
*/
|
||||
export function uploadBrandAvatar(
|
||||
slug: string,
|
||||
file: File,
|
||||
config?: RequestOptions
|
||||
): Promise<AvatarUploadResponse> {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
return http.post<AvatarUploadResponse>(
|
||||
CONFIG_ENDPOINTS.avatarUpload(slug),
|
||||
form,
|
||||
config
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* GET 的 brand 组(原样快照,可能缺键/类型漂移)→ 表单友好的完整值。
|
||||
* 缺键补空串(与 DEFAULT_CONFIG.brand 的空串出厂一致);topics 只留字符串项。
|
||||
*/
|
||||
export function normalizeBrandGroup(
|
||||
value: Record<string, unknown> | null | undefined
|
||||
): BrandGroupValue {
|
||||
const source = value ?? {}
|
||||
const str = (key: string): string => {
|
||||
const v = source[key]
|
||||
return typeof v === 'string' ? v : ''
|
||||
}
|
||||
const topics = source.topics
|
||||
return {
|
||||
title: str('title'),
|
||||
color: str('color'),
|
||||
avatar: str('avatar'),
|
||||
welcome: str('welcome'),
|
||||
placeholder: str('placeholder'),
|
||||
whatsapp: str('whatsapp'),
|
||||
email: str('email'),
|
||||
privacy_url: str('privacy_url'),
|
||||
topics: Array.isArray(topics)
|
||||
? topics.filter((t): t is string => typeof t === 'string')
|
||||
: []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
+2
@@ -70,6 +70,7 @@ declare module 'vue' {
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElCard: typeof import('element-plus/es')['ElCard']
|
||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
|
||||
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
|
||||
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
||||
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
|
||||
@@ -119,6 +120,7 @@ declare module 'vue' {
|
||||
SettingHeader: typeof import('./../../components/core/layouts/art-settings-panel/widget/SettingHeader.vue')['default']
|
||||
SettingItem: typeof import('./../../components/core/layouts/art-settings-panel/widget/SettingItem.vue')['default']
|
||||
SidebarSubmenu: typeof import('./../../components/core/layouts/art-menus/art-sidebar-menu/widget/SidebarSubmenu.vue')['default']
|
||||
SiteOverlayFields: typeof import('./../../components/business/SiteOverlayFields/index.vue')['default']
|
||||
ThemeSettings: typeof import('./../../components/core/layouts/art-settings-panel/widget/ThemeSettings.vue')['default']
|
||||
ThemeSvg: typeof import('./../../components/core/theme/theme-svg/index.vue')['default']
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,9 @@
|
||||
全局唯一 → 400 引导改 blank 或先改占用方)→ ④管理员账号 → 创建
|
||||
(201 后列表刷新 + 提示"租户已创建,管理员首登需改密")
|
||||
- 站点管理(详情抽屉内):站点列表/新建(门面覆盖层表单,留空=继承租户
|
||||
默认)/停用复启用/域名绑定解绑(撞车 400 的归属信息内联展示)
|
||||
默认)/编辑门面(Task 7a:PATCH brand_overlay 整字典,留空剔除,共用
|
||||
SiteOverlayFields 组件)/停用复启用/域名绑定解绑(撞车 400 的归属信息
|
||||
内联展示)
|
||||
- 停用后果文案(Phase 2 语义):租户停用=官网挂件 503 服务暂停页;
|
||||
站点停用=该站点域名 404(不是服务暂停)——确认框写明
|
||||
- slug 不可变(后端 PATCH 带 slug 键 400)——界面不提供改 slug 入口
|
||||
@@ -203,8 +205,11 @@
|
||||
<span v-else class="muted">继承租户默认</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="操作" width="150" fixed="right">
|
||||
<ElTableColumn label="操作" width="216" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<ElButton type="primary" link size="small" @click="openOverlayEdit(row)">
|
||||
编辑门面
|
||||
</ElButton>
|
||||
<ElButton
|
||||
:type="row.status === 'active' ? 'danger' : 'success'"
|
||||
link
|
||||
@@ -395,33 +400,10 @@
|
||||
</ElFormItem>
|
||||
</div>
|
||||
|
||||
<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="siteForm[field.key]"
|
||||
:placeholder="field.key === 'color' ? '#066fc0(留空继承)' : '留空=继承租户默认'"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</div>
|
||||
<ElFormItem label="快捷话题 topics">
|
||||
<ElSelect
|
||||
v-model="siteForm.topics"
|
||||
multiple
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
:reserve-keyword="false"
|
||||
placeholder="输入后回车添加;留空 = 继承租户默认"
|
||||
class="overlay-topics"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<SiteOverlayFields :form="siteForm" />
|
||||
</ElForm>
|
||||
<p class="dialog-tip">
|
||||
站点创建后,域名经站点列表的「绑定域名」挂载;覆盖层可随时整组更新。
|
||||
站点创建后,域名经站点列表的「绑定域名」挂载;覆盖层可随时经「编辑门面」整组更新。
|
||||
</p>
|
||||
<template #footer>
|
||||
<ElButton @click="siteDialogVisible = false">取消</ElButton>
|
||||
@@ -429,6 +411,35 @@
|
||||
</template>
|
||||
</ElDialog>
|
||||
|
||||
<!-- ==================== 编辑门面覆盖层弹窗(Task 7a 顺手项) ==================== -->
|
||||
<ElDialog
|
||||
v-model="overlayEditVisible"
|
||||
:title="`编辑门面覆盖层(站点 ${overlayEditSiteSlug})`"
|
||||
width="620px"
|
||||
append-to-body
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<ElForm label-position="top" @submit.prevent>
|
||||
<SiteOverlayFields :form="overlayEditForm" />
|
||||
</ElForm>
|
||||
<ElAlert
|
||||
v-if="overlayUnknownKeys.length > 0"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
class="overlay-edit-alert"
|
||||
:title="`覆盖层含未支持编辑的键:${overlayUnknownKeys.join('、')}——保存将整组替换,未识别键会被移除`"
|
||||
/>
|
||||
<p class="dialog-tip">
|
||||
保存按整组替换提交:留空字段即剔除覆盖、恢复继承租户默认品牌(与新建站点同一语义)。
|
||||
</p>
|
||||
<template #footer>
|
||||
<ElButton @click="overlayEditVisible = false">取消</ElButton>
|
||||
<ElButton type="primary" :loading="overlayEditSaving" @click="onSaveOverlay">
|
||||
保存
|
||||
</ElButton>
|
||||
</template>
|
||||
</ElDialog>
|
||||
|
||||
<!-- ==================== 绑定域名弹窗 ==================== -->
|
||||
<ElDialog
|
||||
v-model="domainDialogVisible"
|
||||
@@ -466,6 +477,14 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import SiteOverlayFields from '@/components/business/SiteOverlayFields/index.vue'
|
||||
import {
|
||||
OVERLAY_LABELS,
|
||||
blankOverlayForm,
|
||||
buildOverlay,
|
||||
overlayToForm,
|
||||
type OverlayFormState
|
||||
} from '@/components/business/SiteOverlayFields/model'
|
||||
import {
|
||||
addSiteDomain,
|
||||
createSite,
|
||||
@@ -505,25 +524,6 @@
|
||||
}
|
||||
] as const
|
||||
|
||||
/** 门面覆盖层字符串字段(brand 组子集;留空=不写入 overlay=继承租户默认) */
|
||||
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
|
||||
|
||||
/** 覆盖层字段中文标签(站点表 tooltip 用;topics 单列) */
|
||||
const OVERLAY_LABELS: Record<string, string> = Object.fromEntries(
|
||||
OVERLAY_FIELDS.map((f) => [f.key, f.label] as [string, string]).concat([
|
||||
['topics', '快捷话题 topics']
|
||||
])
|
||||
)
|
||||
|
||||
function overlayKeyLabel(key: string): string {
|
||||
return OVERLAY_LABELS[key] ?? key
|
||||
}
|
||||
@@ -771,20 +771,8 @@
|
||||
const siteDialogVisible = ref(false)
|
||||
const siteCreating = ref(false)
|
||||
|
||||
function blankSiteForm() {
|
||||
return {
|
||||
slug: '',
|
||||
name: '',
|
||||
title: '',
|
||||
color: '',
|
||||
avatar: '',
|
||||
welcome: '',
|
||||
placeholder: '',
|
||||
whatsapp: '',
|
||||
email: '',
|
||||
privacy_url: '',
|
||||
topics: [] as string[]
|
||||
}
|
||||
function blankSiteForm(): OverlayFormState & { slug: string; name: string } {
|
||||
return { slug: '', name: '', ...blankOverlayForm() }
|
||||
}
|
||||
|
||||
const siteForm = reactive(blankSiteForm())
|
||||
@@ -794,18 +782,6 @@
|
||||
siteDialogVisible.value = true
|
||||
}
|
||||
|
||||
/** 只收集非空字段进 brand_overlay——留空=不覆盖=继承租户默认 */
|
||||
function buildOverlay(): Record<string, unknown> {
|
||||
const overlay: Record<string, unknown> = {}
|
||||
for (const field of OVERLAY_FIELDS) {
|
||||
const value = siteForm[field.key].trim()
|
||||
if (value) overlay[field.key] = value
|
||||
}
|
||||
const topics = siteForm.topics.map((t) => t.trim()).filter(Boolean)
|
||||
if (topics.length > 0) overlay.topics = topics
|
||||
return overlay
|
||||
}
|
||||
|
||||
async function onCreateSite(): Promise<void> {
|
||||
const slug = siteForm.slug.trim()
|
||||
if (!SLUG_RE.test(slug)) {
|
||||
@@ -817,7 +793,7 @@
|
||||
await createSite(detailSlug.value, {
|
||||
slug,
|
||||
name: siteForm.name.trim() || undefined,
|
||||
brand_overlay: buildOverlay()
|
||||
brand_overlay: buildOverlay(siteForm)
|
||||
})
|
||||
ElMessage.success('站点已创建')
|
||||
siteDialogVisible.value = false
|
||||
@@ -829,6 +805,39 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 编辑门面覆盖层(Task 7a 顺手项:PATCH sites 的 brand_overlay 整字典) ----------
|
||||
|
||||
const overlayEditVisible = ref(false)
|
||||
const overlayEditSaving = ref(false)
|
||||
const overlayEditSiteSlug = ref('')
|
||||
const overlayEditForm = reactive(blankOverlayForm())
|
||||
/** 落库 overlay 中未支持编辑的键(保存整组替换会丢弃——弹窗内警示) */
|
||||
const overlayUnknownKeys = ref<string[]>([])
|
||||
|
||||
function openOverlayEdit(site: SiteSummary): void {
|
||||
overlayEditSiteSlug.value = site.slug
|
||||
const { form, unknownKeys } = overlayToForm(site.brand_overlay)
|
||||
Object.assign(overlayEditForm, form)
|
||||
overlayUnknownKeys.value = unknownKeys
|
||||
overlayEditVisible.value = true
|
||||
}
|
||||
|
||||
async function onSaveOverlay(): Promise<void> {
|
||||
overlayEditSaving.value = true
|
||||
try {
|
||||
await patchSite(detailSlug.value, overlayEditSiteSlug.value, {
|
||||
brand_overlay: buildOverlay(overlayEditForm)
|
||||
})
|
||||
ElMessage.success('门面覆盖层已更新(热生效)')
|
||||
overlayEditVisible.value = false
|
||||
refreshAfterMutation()
|
||||
} catch {
|
||||
// 全局拦截器已提示
|
||||
} finally {
|
||||
overlayEditSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 创建向导(四步) -----------------------------------------------------------------
|
||||
|
||||
const wizardVisible = ref(false)
|
||||
@@ -1110,32 +1119,9 @@
|
||||
gap: 0 16px;
|
||||
}
|
||||
|
||||
.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%;
|
||||
.overlay-edit-alert {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user