From f640889f226d4ee242afacce6b9b5f9dfba97d8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Tue, 13 Oct 2026 16:59:12 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BB=AA=E8=A1=A8=E7=9B=98=EF=BC=88?= =?UTF-8?q?=E7=A7=9F=E6=88=B7=E5=8D=A1=E7=89=87=E5=A2=99/=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E6=80=BB=E8=A7=88/=E6=97=B6=E9=97=B4=E5=88=87?= =?UTF-8?q?=E6=8D=A2=EF=BC=89+=20=E9=A1=BA=E6=89=8B=EF=BC=9A409=E5=A5=91?= =?UTF-8?q?=E7=BA=A6=E9=94=81=E4=B8=8E=E6=AD=BB=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 仪表盘视图:超管=平台总览四指标卡(/api/platform/stats 合并视图)+ 租户卡片墙(逐租户 ?tenant= 下钻,含停用租户);租户管理员=自己 指标卡(/tenants/{slug}/stats,slug 取登录态);口径文案照 stats.py docstring(线索=留资三选一/成功率分母0显—等) - 时间范围快捷切换(今天/近7天/近30天)→ from/to 本地日期串; utils/statsRange.ts 纯函数 + epoch 防快速切换过期响应竞写 - 空数据态:无租户 ElEmpty / 单租户零数据自然显 0 与 — - api/stats.ts 补 fetchPlatformStats/fetchTenantStats;tenants.ts 补 fetchTenantList(卡片墙枚举,其余仍留 Task 6) - vitest +11(statsRange 6 + stats API 5,总 36) - 顺手1:test_auth must_change 409 测试补 detail 断言,锁死前后端 契约文案(client.ts MUST_CHANGE_DETAIL) - 顺手2:删除 beforeEach.ts 的 isUnauthorizedError 死分支与 ApiStatus import(模板 isHttpError 判自建 ApiError 恒 false,分支不可达) Co-Authored-By: Claude Code --- admin/src/api/__tests__/stats.test.ts | 89 ++++ admin/src/api/stats.ts | 37 +- admin/src/api/tenants.ts | 13 +- admin/src/router/guards/beforeEach.ts | 16 - admin/src/types/import/components.d.ts | 5 + admin/src/utils/__tests__/statsRange.test.ts | 57 +++ admin/src/utils/statsRange.ts | 41 ++ admin/src/views/dashboard/console/index.vue | 429 ++++++++++++++++++- tests/test_auth.py | 9 +- 9 files changed, 664 insertions(+), 32 deletions(-) create mode 100644 admin/src/api/__tests__/stats.test.ts create mode 100644 admin/src/utils/__tests__/statsRange.test.ts create mode 100644 admin/src/utils/statsRange.ts diff --git a/admin/src/api/__tests__/stats.test.ts b/admin/src/api/__tests__/stats.test.ts new file mode 100644 index 0000000..3269ff9 --- /dev/null +++ b/admin/src/api/__tests__/stats.test.ts @@ -0,0 +1,89 @@ +/** + * 统计 API 单元测试(Phase 3 Task 4) + * + * 钉住端点 URL 与查询参数组装(?from/&to/&tenant= 事实源 + * app/platform/routes/stats_routes.py);HTTP 层行为(拦截/token/错误 + * 提示)已在 client.test.ts 覆盖,此处 mock http 只断言 API 层契约: + * - fetchPlatformStats:/api/platform/stats 合并视图;tenant= 下钻; + * showErrorMessage: false 透传(卡片墙逐卡内联错误防 N 连弹) + * - fetchTenantStats:/api/platform/tenants/{slug}/stats 单租户 + * - fetchTenantList:/api/platform/tenants(卡片墙枚举租户) + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/api/client', () => ({ + http: { get: vi.fn() } +})) + +import { http } from '@/api/client' +import { fetchPlatformStats, fetchTenantStats } from '@/api/stats' +import { fetchTenantList } from '@/api/tenants' + +/** 零数据统计响应形态(与后端 stats._empty_stats 同构) */ +const EMPTY_STATS = { + cards: { conversations: 0, leads: 0, high_intent: 0, push_success_rate: null }, + funnel: { conversations: 0, leads: 0, evaluated: 0, push_succeeded: 0 }, + trend: [], + distributions: { intent: {}, source: {}, site: {}, country: [] }, + push_health: { pending: 0, success: 0, failed: 0, skipped: 0, duplicated: 0 } +} + +describe('统计 API(src/api/stats.ts + tenants.ts 列表函数)', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(http.get).mockResolvedValue(EMPTY_STATS) + }) + + it('fetchPlatformStats 无参数:GET /api/platform/stats,无查询参数', async () => { + const res = await fetchPlatformStats() + + expect(res).toEqual(EMPTY_STATS) + expect(http.get).toHaveBeenCalledWith('/api/platform/stats', { params: undefined }) + }) + + it('fetchPlatformStats 带 from/to/tenant:全部进查询参数(卡片墙下钻形态)', async () => { + await fetchPlatformStats({ tenant: 'jiancai', from: '2026-10-13', to: '2026-10-13' }) + + expect(http.get).toHaveBeenCalledWith('/api/platform/stats', { + params: { tenant: 'jiancai', from: '2026-10-13', to: '2026-10-13' } + }) + }) + + it('fetchPlatformStats 透传 showErrorMessage: false(卡片墙防 N 连弹)', async () => { + await fetchPlatformStats({ from: '2026-10-13' }, { showErrorMessage: false }) + + expect(http.get).toHaveBeenCalledWith('/api/platform/stats', { + params: { from: '2026-10-13' }, + showErrorMessage: false + }) + }) + + it('fetchTenantStats:GET /api/platform/tenants/{slug}/stats 带 from/to', async () => { + await fetchTenantStats('jiancai', { from: '2026-10-07', to: '2026-10-13' }) + + expect(http.get).toHaveBeenCalledWith('/api/platform/tenants/jiancai/stats', { + params: { from: '2026-10-07', to: '2026-10-13' } + }) + }) + + 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') + }) +}) diff --git a/admin/src/api/stats.ts b/admin/src/api/stats.ts index 0b57ddb..43aeb63 100644 --- a/admin/src/api/stats.ts +++ b/admin/src/api/stats.ts @@ -1,10 +1,15 @@ /** - * 统计分析 API 骨架(Phase 3 Task 3) + * 统计分析 API(Phase 3 Task 4 实装调用函数;类型与端点 Task 3 建立) * * 端点事实源:app/platform/routes/stats_routes.py + app/platform/stats.py - * (平台合并统计 / 单租户统计,?from/&to= 闭区间)。本文件只建类型与 - * 端点常量——调用函数在 Task 4(仪表盘)与 Task 8(统计分析)实装。 + * (平台合并统计 / 单租户统计,?from/&to= 闭区间)。 + * + * 指标口径事实源:app/platform/stats.py 模块 docstring 的口径表—— + * 会话数不要求有消息 / 线索=留资三选一(phone/whatsapp/email)任一非空 / + * 高意向=评估 interested / 推送成功率 success/(success+failed)(分母 0 → + * null,前端展示"—")。Task 8(统计分析页)复用本组函数。 */ +import { http } from './client' /** 指标卡(None 项为 null——如无推送时成功率无定义) */ export interface StatsCards { @@ -66,3 +71,29 @@ export const STATS_ENDPOINTS = { platform: '/api/platform/stats', tenant: (slug: string) => `/api/platform/tenants/${slug}/stats` } as const + +/** 平台合并统计查询参数(?tenant= 单租户下钻,未知 slug 404) */ +export interface PlatformStatsParams extends StatsParams { + tenant?: string +} + +/** + * 平台统计(仅超管):缺省全部租户合并(逐库查询应用层合并); + * tenant=slug 单租户下钻——仪表盘租户卡片墙逐租户调用即此形态。 + * options.showErrorMessage: false 关全局错误提示(卡片墙 N 连发的失败 + * toast 改由调用方逐卡内联展示) + */ +export function fetchPlatformStats( + params?: PlatformStatsParams, + options?: { showErrorMessage?: boolean } +): Promise { + return http.get(STATS_ENDPOINTS.platform, { params, ...options }) +} + +/** + * 单租户统计(本租户管理员或超管):租户管理员的"自己指标卡"走这里, + * slug 取登录态 tenant_slug(auth store) + */ +export function fetchTenantStats(slug: string, params?: StatsParams): Promise { + return http.get(STATS_ENDPOINTS.tenant(slug), { params }) +} diff --git a/admin/src/api/tenants.ts b/admin/src/api/tenants.ts index 599f6c9..f9241d6 100644 --- a/admin/src/api/tenants.ts +++ b/admin/src/api/tenants.ts @@ -1,10 +1,11 @@ /** - * 租户与站点管理 API 骨架(Phase 3 Task 3) + * 租户与站点管理 API(Phase 3 Task 3 骨架;Task 4 增列表函数) * * 端点事实源:app/platform/routes/tenant_routes.py * (/api/platform/tenants,require_super / require_tenant(slug) 鉴权)。 - * 本文件只建类型与端点常量——调用函数在 Task 6(租户管理与站点管理)实装。 + * 其余调用函数在 Task 6(租户管理与站点管理)实装。 */ +import { http } from './client' /** 租户状态(Tenant.status) */ export type TenantStatus = 'active' | 'disabled' @@ -93,3 +94,11 @@ export const TENANT_ENDPOINTS = { deleteDomain: (slug: string, siteSlug: string, domain: string) => `/api/platform/tenants/${slug}/sites/${siteSlug}/domains/${domain}` } as const + +/** + * 租户列表(仅超管)——Task 4 仪表盘的租户卡片墙需要枚举租户; + * 列表行的 slug/name/status 供卡片展示与逐租户统计下钻 + */ +export function fetchTenantList(): Promise { + return http.get(TENANT_ENDPOINTS.list) +} diff --git a/admin/src/router/guards/beforeEach.ts b/admin/src/router/guards/beforeEach.ts index d66cbc8..c0d4b0c 100644 --- a/admin/src/router/guards/beforeEach.ts +++ b/admin/src/router/guards/beforeEach.ts @@ -49,7 +49,6 @@ import { staticRoutes } from '../routes/staticRoutes' import { loadingService } from '@/utils/ui' import { useCommon } from '@/hooks/core/useCommon' import { useWorktabStore } from '@/store/modules/worktab' -import { ApiStatus } from '@/utils/http/status' import { isHttpError } from '@/utils/http/error' import { RouteRegistry, MenuProcessor, IframeRouteManager, RoutePermissionValidator } from '../core' @@ -389,14 +388,6 @@ async function handleDynamicRoutes( // 关闭 loading closeLoading() - // 401 错误:axios 拦截器已处理退出登录,取消当前导航 - if (isUnauthorizedError(error)) { - // 重置状态,允许重新登录后再次初始化 - routeInitInProgress = false - next(false) - return - } - // 标记初始化失败,防止死循环 routeInitFailed = true routeInitInProgress = false @@ -459,10 +450,3 @@ function handleRootPathRedirect(to: RouteLocationNormalized, next: NavigationGua return false } - -/** - * 判断是否为未授权错误(401) - */ -function isUnauthorizedError(error: unknown): boolean { - return isHttpError(error) && error.code === ApiStatus.unauthorized -} diff --git a/admin/src/types/import/components.d.ts b/admin/src/types/import/components.d.ts index 447520a..400a080 100644 --- a/admin/src/types/import/components.d.ts +++ b/admin/src/types/import/components.d.ts @@ -65,6 +65,7 @@ declare module 'vue' { BoxStyleSettings: typeof import('./../../components/core/layouts/art-settings-panel/widget/BoxStyleSettings.vue')['default'] ColorSettings: typeof import('./../../components/core/layouts/art-settings-panel/widget/ColorSettings.vue')['default'] ContainerSettings: typeof import('./../../components/core/layouts/art-settings-panel/widget/ContainerSettings.vue')['default'] + ElAlert: typeof import('element-plus/es')['ElAlert'] ElAvatar: typeof import('element-plus/es')['ElAvatar'] ElButton: typeof import('element-plus/es')['ElButton'] ElCard: typeof import('element-plus/es')['ElCard'] @@ -85,10 +86,14 @@ declare module 'vue' { ElMenuItem: typeof import('element-plus/es')['ElMenuItem'] ElOption: typeof import('element-plus/es')['ElOption'] ElPopover: typeof import('element-plus/es')['ElPopover'] + 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'] ElSubMenu: typeof import('element-plus/es')['ElSubMenu'] ElSwitch: typeof import('element-plus/es')['ElSwitch'] + ElTag: typeof import('element-plus/es')['ElTag'] ElTooltip: typeof import('element-plus/es')['ElTooltip'] ElWatermark: typeof import('element-plus/es')['ElWatermark'] HorizontalSubmenu: typeof import('./../../components/core/layouts/art-menus/art-horizontal-menu/widget/HorizontalSubmenu.vue')['default'] diff --git a/admin/src/utils/__tests__/statsRange.test.ts b/admin/src/utils/__tests__/statsRange.test.ts new file mode 100644 index 0000000..3bb0cfe --- /dev/null +++ b/admin/src/utils/__tests__/statsRange.test.ts @@ -0,0 +1,57 @@ +/** + * 统计时间快捷范围单测(Phase 3 Task 4) + * + * 钉住口径:from/to 为本地日期串 YYYY-MM-DD;"近 N 天"含今天往回共 N 天 + * (今天→from==to;近7天→from=今天-6;近30天→今天-29)——后端把日期-only + * 的 to 视为含当日全天(stats_routes 的 _parse_dt),前端不传时间部分。 + */ +import { describe, expect, it } from 'vitest' +import { STATS_QUICK_RANGES, rangeToParams } from '@/utils/statsRange' + +/** 固定"现在":2026-10-13 09:30(本地时间)——跨月/跨年边界另测 */ +const NOW = new Date(2026, 9, 13, 9, 30) + +describe('统计时间快捷范围(src/utils/statsRange.ts)', () => { + it('今天:from 与 to 同为今天', () => { + expect(rangeToParams('today', NOW)).toEqual({ + from: '2026-10-13', + to: '2026-10-13' + }) + }) + + it('近7天:from = 今天往回 6 天(窗口含今天共 7 天)', () => { + expect(rangeToParams('last7', NOW)).toEqual({ + from: '2026-10-07', + to: '2026-10-13' + }) + }) + + it('近30天:from = 今天往回 29 天', () => { + expect(rangeToParams('last30', NOW)).toEqual({ + from: '2026-09-14', + to: '2026-10-13' + }) + }) + + it('月份/日期补零:单数月日出两位日期串', () => { + expect(rangeToParams('today', new Date(2026, 0, 5, 23, 59))).toEqual({ + from: '2026-01-05', + to: '2026-01-05' + }) + }) + + it('跨月回退由 Date 构造正确结算(10-01 的近7天跨回 9 月)', () => { + expect(rangeToParams('last7', new Date(2026, 9, 1, 12))).toEqual({ + from: '2026-09-25', + to: '2026-10-01' + }) + }) + + it('快捷范围清单与类型对齐:三个挡位、标签非空', () => { + expect(STATS_QUICK_RANGES.map((item) => item.value)).toEqual(['today', 'last7', 'last30']) + for (const item of STATS_QUICK_RANGES) { + expect(item.label).not.toBe('') + expect(item.days).toBeGreaterThan(0) + } + }) +}) diff --git a/admin/src/utils/statsRange.ts b/admin/src/utils/statsRange.ts new file mode 100644 index 0000000..ae93a99 --- /dev/null +++ b/admin/src/utils/statsRange.ts @@ -0,0 +1,41 @@ +/** + * 统计时间快捷范围(Phase 3 Task 4 仪表盘;Task 8 统计分析复用) + * + * 口径事实源:app/platform/routes/stats_routes.py 的 from/to 解析 + * (data_routes._parse_dt)——前端只传本地日期串 YYYY-MM-DD: + * - 日期-only 的 to 后端视为含当日全天(23:59:59.999999,边界含); + * - 边界是 naive UTC(与库内 created_at 同一对比空间),前端不做时区换算; + * - "近 N 天" = 含今天往回共 N 天(今天 / 近7天 / 近30天)。 + */ +export type StatsQuickRange = 'today' | 'last7' | 'last30' + +/** 快捷范围清单(顺序即切换器展示序) */ +export const STATS_QUICK_RANGES: Array<{ + value: StatsQuickRange + label: string + /** 窗口天数(含今天) */ + days: number +}> = [ + { value: 'today', label: '今天', days: 1 }, + { value: 'last7', label: '近7天', days: 7 }, + { value: 'last30', label: '近30天', days: 30 } +] + +/** Date → 本地日期串 YYYY-MM-DD(月/日两位补零) */ +function toDateKey(date: Date): string { + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${date.getFullYear()}-${month}-${day}` +} + +/** 快捷范围 → {from, to} 查询参数(本地日期;now 可注入便于测试) */ +export function rangeToParams( + range: StatsQuickRange, + now: Date = new Date() +): { from: string; to: string } { + const days = STATS_QUICK_RANGES.find((item) => item.value === range)?.days ?? 1 + return { + from: toDateKey(new Date(now.getFullYear(), now.getMonth(), now.getDate() - (days - 1))), + to: toDateKey(now) + } +} diff --git a/admin/src/views/dashboard/console/index.vue b/admin/src/views/dashboard/console/index.vue index 43b3368..449548f 100644 --- a/admin/src/views/dashboard/console/index.vue +++ b/admin/src/views/dashboard/console/index.vue @@ -1,25 +1,438 @@ - + diff --git a/tests/test_auth.py b/tests/test_auth.py index de97d0d..6699a58 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -126,10 +126,13 @@ def test_change_password_flow(app_client): def test_must_change_password_409_semantics(app_client): - """引导密码强制语义: must_change=True 时受保护端点(logout)409; - change-password 本身豁免(200);改完 409 消除。""" + """引导密码强制语义: must_change=True 时受保护端点(logout)409 + (detail 为固定文案——前端 client.ts 的 MUST_CHANGE_DETAIL 依赖此 + 契约,改动文案即红);change-password 本身豁免(200);改完 409 消除。""" token = _login(app_client).json()["token"] - assert app_client.post(LOGOUT, headers=_auth(token)).status_code == 409 + r = app_client.post(LOGOUT, headers=_auth(token)) + assert r.status_code == 409 + assert r.json()["detail"] == "必须先修改初始密码" r = app_client.post( CHANGE_PW, headers=_auth(token), json={"old_password": "admin123", "new_password": "n3w-pass-3"})