From cac91e9e3ea519478c19a4c77a96ab568cc89e8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Tue, 13 Oct 2026 19:27:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=85=8D=E7=BD=AE=E4=B8=AD=E5=BF=83-?= =?UTF-8?q?=E5=93=81=E7=89=8C=EF=BC=88=E8=A1=A8=E5=8D=95/=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E9=A2=84=E8=A7=88/=E5=B5=8C=E5=85=A5=E4=BB=A3?= =?UTF-8?q?=E7=A0=81/=E5=A4=B4=E5=83=8F=E4=B8=8A=E4=BC=A0=EF=BC=89+=20?= =?UTF-8?q?=E7=AB=99=E7=82=B9=E8=A6=86=E7=9B=96=E5=B1=82=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code --- admin/src/api/__tests__/config.test.ts | 167 +++ admin/src/api/config.ts | 175 ++- .../business/SiteOverlayFields/index.vue | 76 ++ .../business/SiteOverlayFields/model.ts | 89 ++ admin/src/types/import/components.d.ts | 2 + admin/src/views/config/center/index.vue | 1094 ++++++++++++++++- admin/src/views/tenants/list/index.vue | 184 ++- 7 files changed, 1660 insertions(+), 127 deletions(-) create mode 100644 admin/src/api/__tests__/config.test.ts create mode 100644 admin/src/components/business/SiteOverlayFields/index.vue create mode 100644 admin/src/components/business/SiteOverlayFields/model.ts diff --git a/admin/src/api/__tests__/config.test.ts b/admin/src/api/__tests__/config.test.ts new file mode 100644 index 0000000..bea1034 --- /dev/null +++ b/admin/src/api/__tests__/config.test.ts @@ -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([]) + }) +}) diff --git a/admin/src/api/config.ts b/admin/src/api/config.ts index f47ec6d..2f17aac 100644 --- a/admin/src/api/config.ts +++ b/admin/src/api/config.ts @@ -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> } -/** 配置版本行(GET .../config/versions) */ +/** 分组保存响应(PUT .../config/{group};config 为该组掩码视图) */ +export interface PutConfigGroupResponse { + slug: string + group: string + version: number + config: Record +} + +/** 配置版本行(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> } -/** 回滚请求(POST .../config/rollback,产生新版本) */ +/** 回滚请求(POST .../config/rollback,产生新版本;后端只读 version) */ export interface ConfigRollbackRequest { version: number - note: string } -/** 分组保存请求(PUT .../config/{group}) */ -export interface PutConfigGroupRequest { - note: string - value: Record +/** 回滚响应 */ +export interface ConfigRollbackResponse { + slug: string + rollback_to: number + version: number + config: Record> } /** 词表试金石请求(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 { + return http.get(CONFIG_ENDPOINTS.current(slug), config) +} + +/** + * 分组保存(整组替换语义):请求体 = 组值本身,备注走 comment 查询参数。 + * 调用方必须先 GET 全组、改字段后整组提交——部分提交会丢掉未携带的键 + * (后端 deep merge 出厂默认兜底,但租户既有定制会被覆盖丢失)。 + */ +export function putConfigGroup( + slug: string, + group: ConfigGroupKey | string, + value: Record, + comment?: string, + config?: RequestOptions +): Promise { + return http.put( + 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 { + const form = new FormData() + form.append('file', file) + return http.post( + CONFIG_ENDPOINTS.avatarUpload(slug), + form, + config + ) +} + +/** + * GET 的 brand 组(原样快照,可能缺键/类型漂移)→ 表单友好的完整值。 + * 缺键补空串(与 DEFAULT_CONFIG.brand 的空串出厂一致);topics 只留字符串项。 + */ +export function normalizeBrandGroup( + value: Record | 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') + : [] + } +} diff --git a/admin/src/components/business/SiteOverlayFields/index.vue b/admin/src/components/business/SiteOverlayFields/index.vue new file mode 100644 index 0000000..20a9d00 --- /dev/null +++ b/admin/src/components/business/SiteOverlayFields/index.vue @@ -0,0 +1,76 @@ + + + + + + diff --git a/admin/src/components/business/SiteOverlayFields/model.ts b/admin/src/components/business/SiteOverlayFields/model.ts new file mode 100644 index 0000000..e7350c1 --- /dev/null +++ b/admin/src/components/business/SiteOverlayFields/model.ts @@ -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 { + topics: string[] +} + +/** 覆盖层字段中文标签(站点表 tooltip 用;topics 单列) */ +export const OVERLAY_LABELS: Record = 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 { + const overlay: Record = {} + 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 | 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([...OVERLAY_FIELDS.map((f) => f.key), 'topics']) + const unknownKeys = Object.keys(source).filter((k) => !known.has(k)) + return { form, unknownKeys } +} diff --git a/admin/src/types/import/components.d.ts b/admin/src/types/import/components.d.ts index 3f71ccc..0a47716 100644 --- a/admin/src/types/import/components.d.ts +++ b/admin/src/types/import/components.d.ts @@ -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'] } diff --git a/admin/src/views/config/center/index.vue b/admin/src/views/config/center/index.vue index 0814069..b123b39 100644 --- a/admin/src/views/config/center/index.vue +++ b/admin/src/views/config/center/index.vue @@ -1,25 +1,1099 @@ - + diff --git a/admin/src/views/tenants/list/index.vue b/admin/src/views/tenants/list/index.vue index 612396a..52a87af 100644 --- a/admin/src/views/tenants/list/index.vue +++ b/admin/src/views/tenants/list/index.vue @@ -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 @@ 继承租户默认 - +