From 6f22e519081590a045710a1407601a7426c64b5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Tue, 13 Oct 2026 18:27:56 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=A7=9F=E6=88=B7=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E4=B8=8E=E7=AB=99=E7=82=B9=E7=AE=A1=E7=90=86=EF=BC=88=E5=88=9B?= =?UTF-8?q?=E5=BB=BA=E5=90=91=E5=AF=BC/=E5=81=9C=E7=94=A8/=E5=9F=9F?= =?UTF-8?q?=E5=90=8D/=E9=97=A8=E9=9D=A2=E8=A6=86=E7=9B=96=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - API 层实装(api/tenants.ts):创建/详情/PATCH 租户、建站/改站、 域名绑定解绑——400 撞车 detail(slug/域名归属/prefix 引导)经 showErrorMessage:false 关全局 toast、由向导与绑域名弹窗内联常驻展示 - 租户列表:slug/名称/状态/域名数/版本/管理员概要 + 状态筛选 + 行操作(详情/停用复启用/改名);停用确认框写明 503 服务暂停语义 - 创建向导(ElSteps 四步):名称+slug(^[a-z0-9-]+$ 与撞车内联拦截) → 域名多填 → 出厂档案(auto/jiancai 提示 prefix 全局唯一撞车引导 改 blank;blank 提示墙与提示词取建材出厂档案)→ 管理员账号; 201 后列表刷新并提示首登需改密 - 站点管理(详情抽屉):站点表/新建站(门面覆盖层表单——brand 组 子集字段留空=继承租户默认)/停用复启用(404 语义提示)/域名绑定 (撞车归属内联)与解绑(标签 × 确认) - vitest +9(API 层 URL/方法/请求体契约);页面仅超管(菜单 roles 已挡) Co-Authored-By: Claude Code --- admin/src/api/__tests__/tenants.test.ts | 218 +++++ admin/src/api/tenants.ts | 126 ++- admin/src/types/import/components.d.ts | 3 + admin/src/views/tenants/list/index.vue | 1130 ++++++++++++++++++++++- 4 files changed, 1464 insertions(+), 13 deletions(-) create mode 100644 admin/src/api/__tests__/tenants.test.ts diff --git a/admin/src/api/__tests__/tenants.test.ts b/admin/src/api/__tests__/tenants.test.ts new file mode 100644 index 0000000..3928084 --- /dev/null +++ b/admin/src/api/__tests__/tenants.test.ts @@ -0,0 +1,218 @@ +/** + * 租户/站点/域名 API 单元测试(Phase 3 Task 6) + * + * 钉住 API 层契约:端点 URL / 方法 / 请求体形状(事实源 + * app/platform/routes/tenant_routes.py);HTTP 层行为已在 client.test.ts + * 覆盖,此处 mock http 只断言调用形态: + * - 列表/详情:GET /api/platform/tenants[/{slug}] + * - 创建向导:POST 全量六字段(domains 进 main 站点) + * - PATCH 租户:只发 name 或 status(slug 键绝不出现在 body——后端 400) + * - 站点:POST sites(brand_overlay 只含显式覆盖字段)/ PATCH sites/{site} + * - 域名:POST domains {domain} / DELETE domains/{domain}(路径段直拼) + * - RequestOptions 透传(向导/绑域名内联错误关全局 toast) + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/api/client', () => ({ + http: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), del: vi.fn() } +})) + +import { http } from '@/api/client' +import { + addSiteDomain, + createSite, + createTenant, + deleteSiteDomain, + fetchTenantDetail, + fetchTenantList, + patchSite, + patchTenant +} from '@/api/tenants' + +describe('租户与站点管理 API(src/api/tenants.ts)', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('fetchTenantList:GET /api/platform/tenants', async () => { + const body = { + items: [ + { + slug: 'jiancai', + name: '建材租户', + status: 'active', + domain_count: 1, + current_version: 1, + admins: [{ username: 'jc-admin', status: 'active' }] + } + ] + } + vi.mocked(http.get).mockResolvedValue(body) + + const res = await fetchTenantList() + + expect(res).toEqual(body) + expect(http.get).toHaveBeenCalledWith('/api/platform/tenants') + }) + + it('fetchTenantDetail:GET /api/platform/tenants/{slug}', async () => { + const body = { + slug: 'jiancai', + name: '建材租户', + status: 'active', + current_version: 1, + sites: [ + { + slug: 'main', + name: 'main', + status: 'active', + domains: ['chat2.agent.hnxinyouhe.com'], + brand_overlay: {} + } + ], + admins: [{ username: 'jc-admin', status: 'active' }] + } + vi.mocked(http.get).mockResolvedValue(body) + + const res = await fetchTenantDetail('jiancai') + + expect(res).toEqual(body) + expect(http.get).toHaveBeenCalledWith('/api/platform/tenants/jiancai', undefined) + }) + + it('createTenant:POST 全量六字段(向导提交形态)', async () => { + const body = { + name: '新租户', + slug: 'new-tenant', + profile: 'blank', + admin_username: 'nt-admin', + admin_password: 'initial-pass', + domains: ['a.example.com', 'b.example.com'] + } + const created = { + slug: 'new-tenant', + name: '新租户', + status: 'active', + profile: 'blank', + version: 1, + site: { slug: 'main', domains: ['a.example.com', 'b.example.com'] }, + admin_username: 'nt-admin' + } + vi.mocked(http.post).mockResolvedValue(created) + + const res = await createTenant(body, { showErrorMessage: false }) + + expect(res).toEqual(created) + expect(http.post).toHaveBeenCalledWith('/api/platform/tenants', body, { + showErrorMessage: false + }) + }) + + it('patchTenant 改名:PATCH {slug} 只发 name(slug 键不可出现)', async () => { + vi.mocked(http.patch).mockResolvedValue({ + slug: 'jiancai', + name: '建材租户改', + status: 'active' + }) + + await patchTenant('jiancai', { name: '建材租户改' }) + + expect(http.patch).toHaveBeenCalledWith( + '/api/platform/tenants/jiancai', + { name: '建材租户改' }, + undefined + ) + }) + + it('patchTenant 停用:PATCH {slug} 只发 status=disabled', async () => { + vi.mocked(http.patch).mockResolvedValue({ + slug: 'jiancai', + name: '建材租户', + status: 'disabled' + }) + + await patchTenant('jiancai', { status: 'disabled' }) + + expect(http.patch).toHaveBeenCalledWith( + '/api/platform/tenants/jiancai', + { status: 'disabled' }, + undefined + ) + }) + + it('createSite:POST sites 带 brand_overlay 覆盖子集(留空字段不进 overlay)', async () => { + const body = { + slug: 'shop1', + name: '西班牙站', + brand_overlay: { title: 'HESUPIPE ES', color: '#ff6600' } + } + const created = { + slug: 'shop1', + name: '西班牙站', + status: 'active', + domains: [], + brand_overlay: body.brand_overlay + } + vi.mocked(http.post).mockResolvedValue(created) + + const res = await createSite('jiancai', body) + + expect(res).toEqual(created) + expect(http.post).toHaveBeenCalledWith('/api/platform/tenants/jiancai/sites', body, undefined) + }) + + it('patchSite 停用:PATCH sites/{siteSlug} 只发 status', async () => { + vi.mocked(http.patch).mockResolvedValue({ + slug: 'shop1', + name: '西班牙站', + status: 'disabled', + domains: [], + brand_overlay: {} + }) + + await patchSite('jiancai', 'shop1', { status: 'disabled' }) + + expect(http.patch).toHaveBeenCalledWith( + '/api/platform/tenants/jiancai/sites/shop1', + { status: 'disabled' }, + undefined + ) + }) + + it('addSiteDomain:POST sites/{siteSlug}/domains {domain}', async () => { + vi.mocked(http.post).mockResolvedValue({ + domain: 'es.hesupipe.com', + site: 'shop1' + }) + + const res = await addSiteDomain( + 'jiancai', + 'shop1', + { domain: 'es.hesupipe.com' }, + { showErrorMessage: false } + ) + + expect(res).toEqual({ domain: 'es.hesupipe.com', site: 'shop1' }) + expect(http.post).toHaveBeenCalledWith( + '/api/platform/tenants/jiancai/sites/shop1/domains', + { domain: 'es.hesupipe.com' }, + { showErrorMessage: false } + ) + }) + + it('deleteSiteDomain:DELETE sites/{siteSlug}/domains/{domain}(路径直拼)', async () => { + vi.mocked(http.del).mockResolvedValue({ + ok: true, + domain: 'es.hesupipe.com', + site: 'shop1' + }) + + const res = await deleteSiteDomain('jiancai', 'shop1', 'es.hesupipe.com') + + expect(res).toEqual({ ok: true, domain: 'es.hesupipe.com', site: 'shop1' }) + expect(http.del).toHaveBeenCalledWith( + '/api/platform/tenants/jiancai/sites/shop1/domains/es.hesupipe.com', + undefined + ) + }) +}) diff --git a/admin/src/api/tenants.ts b/admin/src/api/tenants.ts index f9241d6..e034dbc 100644 --- a/admin/src/api/tenants.ts +++ b/admin/src/api/tenants.ts @@ -1,11 +1,17 @@ /** - * 租户与站点管理 API(Phase 3 Task 3 骨架;Task 4 增列表函数) + * 租户与站点管理 API(Phase 3 Task 3 骨架;Task 4 列表函数;Task 6 实装全部调用函数) * * 端点事实源:app/platform/routes/tenant_routes.py * (/api/platform/tenants,require_super / require_tenant(slug) 鉴权)。 - * 其余调用函数在 Task 6(租户管理与站点管理)实装。 + * + * 错误语义(页面交互消费): + * - 400 预检撞车带中文 detail:slug 已存在 / 域名已绑定(含归属租户与站点)/ + * 出厂 push.prefix 被占(引导改 blank 或先改占用方)——向导与绑域名 + * 弹窗用 showErrorMessage:false 关全局 toast、改内联常驻展示; + * - 409 并发竞态(提交瞬间被抢先); + * - PATCH 带 slug 键一律 400(slug 不可变——界面不提供改 slug 入口)。 */ -import { http } from './client' +import { http, type RequestOptions } from './client' /** 租户状态(Tenant.status) */ export type TenantStatus = 'active' | 'disabled' @@ -75,11 +81,49 @@ export interface CreateSiteRequest { domains?: string[] } -/** 绑定域名请求(POST .../sites/{siteSlug}/domains,201;409=域名已被占用) */ +/** 创建租户响应(create_tenant:201 返回概要;管理员凭据绝不回传) */ +export interface TenantCreatedResponse { + slug: string + name: string + status: TenantStatus + profile: string + version: number + site: { slug: string; domains: string[] } + admin_username: string +} + +/** 改名/改状态响应(patch_tenant / patch_site 返回形状) */ +export interface TenantPatchResponse { + slug: string + name: string + status: TenantStatus +} + +/** 改站点请求(PATCH .../sites/{siteSlug};slug 不可变——后端 400) */ +export interface PatchSiteRequest { + name?: string + status?: TenantStatus + brand_overlay?: Record +} + +/** 绑定域名请求(POST .../sites/{siteSlug}/domains,201;400=撞车带归属) */ export interface AddDomainRequest { domain: string } +/** 绑定域名响应 */ +export interface DomainBindResponse { + domain: string + site: string +} + +/** 解绑域名响应 */ +export interface DomainUnbindResponse { + ok: boolean + domain: string + site: string +} + /** 租户管理端点(鉴权与 409 并发冲突语义见 tenant_routes.py) */ export const TENANT_ENDPOINTS = { list: '/api/platform/tenants', @@ -87,8 +131,7 @@ export const TENANT_ENDPOINTS = { detail: (slug: string) => `/api/platform/tenants/${slug}`, patch: (slug: string) => `/api/platform/tenants/${slug}`, createSite: (slug: string) => `/api/platform/tenants/${slug}/sites`, - patchSite: (slug: string, siteSlug: string) => - `/api/platform/tenants/${slug}/sites/${siteSlug}`, + patchSite: (slug: string, siteSlug: string) => `/api/platform/tenants/${slug}/sites/${siteSlug}`, addDomain: (slug: string, siteSlug: string) => `/api/platform/tenants/${slug}/sites/${siteSlug}/domains`, deleteDomain: (slug: string, siteSlug: string, domain: string) => @@ -102,3 +145,74 @@ export const TENANT_ENDPOINTS = { export function fetchTenantList(): Promise { return http.get(TENANT_ENDPOINTS.list) } + +/** 租户详情(含 sites/domains/brand_overlay/admins;本租户管理员亦可读) */ +export function fetchTenantDetail( + slug: string, + config?: RequestOptions +): Promise { + return http.get(TENANT_ENDPOINTS.detail(slug), config) +} + +/** + * 创建租户(创建向导提交;201)。向导调用建议传 + * `{ showErrorMessage: false }`——400 预检撞车 detail(slug 已存在 / + * 出厂 push.prefix 被占的改 blank 引导)在向导内联常驻展示。 + */ +export function createTenant( + body: CreateTenantRequest, + config?: RequestOptions +): Promise { + return http.post(TENANT_ENDPOINTS.create, body, config) +} + +/** 改名 / 停用 / 复启用(PATCH;slug 不可变,body 不含 slug 键) */ +export function patchTenant( + slug: string, + body: PatchTenantRequest, + config?: RequestOptions +): Promise { + return http.patch(TENANT_ENDPOINTS.patch(slug), body, config) +} + +/** 新建站点(brand_overlay 只放显式覆盖的字段——留空=继承租户默认) */ +export function createSite( + slug: string, + body: CreateSiteRequest, + config?: RequestOptions +): Promise { + return http.post(TENANT_ENDPOINTS.createSite(slug), body, config) +} + +/** 改站点(停用/复启用/改名/改覆盖层;整字典赋值——旧键不残留) */ +export function patchSite( + slug: string, + siteSlug: string, + body: PatchSiteRequest, + config?: RequestOptions +): Promise { + return http.patch(TENANT_ENDPOINTS.patchSite(slug, siteSlug), body, config) +} + +/** 绑定域名(全局唯一;400 detail 带现有归属"租户 x 的站点 y") */ +export function addSiteDomain( + slug: string, + siteSlug: string, + body: AddDomainRequest, + config?: RequestOptions +): Promise { + return http.post(TENANT_ENDPOINTS.addDomain(slug, siteSlug), body, config) +} + +/** 解绑域名(只解本站点的该域名;不存在 404) */ +export function deleteSiteDomain( + slug: string, + siteSlug: string, + domain: string, + config?: RequestOptions +): Promise { + return http.del( + TENANT_ENDPOINTS.deleteDomain(slug, siteSlug, domain), + config + ) +} diff --git a/admin/src/types/import/components.d.ts b/admin/src/types/import/components.d.ts index 2ef5b87..3f71ccc 100644 --- a/admin/src/types/import/components.d.ts +++ b/admin/src/types/import/components.d.ts @@ -90,11 +90,14 @@ declare module 'vue' { ElOption: typeof import('element-plus/es')['ElOption'] ElPagination: typeof import('element-plus/es')['ElPagination'] ElPopover: typeof import('element-plus/es')['ElPopover'] + ElRadio: typeof import('element-plus/es')['ElRadio'] ElRadioButton: typeof import('element-plus/es')['ElRadioButton'] ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup'] ElScrollbar: typeof import('element-plus/es')['ElScrollbar'] ElSelect: typeof import('element-plus/es')['ElSelect'] ElSkeleton: typeof import('element-plus/es')['ElSkeleton'] + ElStep: typeof import('element-plus/es')['ElStep'] + ElSteps: typeof import('element-plus/es')['ElSteps'] ElSubMenu: typeof import('element-plus/es')['ElSubMenu'] ElSwitch: typeof import('element-plus/es')['ElSwitch'] ElTable: typeof import('element-plus/es')['ElTable'] diff --git a/admin/src/views/tenants/list/index.vue b/admin/src/views/tenants/list/index.vue index 91f2993..612396a 100644 --- a/admin/src/views/tenants/list/index.vue +++ b/admin/src/views/tenants/list/index.vue @@ -1,25 +1,1141 @@ - +