From 71078841381396d8700e53bfba73a4eadeba9c1f Mon Sep 17 00:00:00 2001 From: Neo Date: Wed, 22 Apr 2026 21:56:17 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin-web):=20AIPrewarm=20=E5=88=86?= =?UTF-8?q?=E7=BB=84=E5=B1=95=E7=A4=BA=20+=20=E6=AF=8F=E8=A1=8C=E8=A7=A6?= =?UTF-8?q?=E5=8F=91=20+=20AppType=20=E8=81=94=E5=90=88=E7=B1=BB=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. AIPrewarm.tsx: - areaToAppType(area) helper · area='all' → app2_finance · 其他 → app2a_finance_area - handleRunOne / handleBackfillMissing 按 area 动态选 app_type - MissingRowWithGroup 含 __group_header 字段 - groupedMissing 数据构造(全域 + 区域两组 · 每组前插 header 行) - 每列 onCell colSpan 合并单元格实现"全域 / 区域"分组标题行 - Descriptions 加全域 8/X + 区域 64/X 双段统计 2. api/adminAI.ts: - 新增 AppType 联合类型(9 项,含 app2a_finance_area) - runApp 签名 appType: AppType(替代原 string) - RunAppResponse.app_type 同步为 AppType 3. AIOperations.tsx: - runAppType state 类型改为 AppType | undefined - import { AppType } type 实测: - pnpm tsc --noEmit 全项目通过 - playwright E2E 访问 /ai/prewarm 显示 "全域 8/8 · 区域 63/64" 分段统计 分组标题行正确合并 · 单独生成按钮按 area 路由到正确 app_type Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/admin-web/src/api/adminAI.ts | 135 ++++++++- apps/admin-web/src/pages/AIOperations.tsx | 154 +++++++++- apps/admin-web/src/pages/AIPrewarm.tsx | 327 ++++++++++++++++++++++ 3 files changed, 612 insertions(+), 4 deletions(-) create mode 100644 apps/admin-web/src/pages/AIPrewarm.tsx diff --git a/apps/admin-web/src/api/adminAI.ts b/apps/admin-web/src/api/adminAI.ts index 98bf47a..1fb1e1b 100644 --- a/apps/admin-web/src/api/adminAI.ts +++ b/apps/admin-web/src/api/adminAI.ts @@ -7,6 +7,32 @@ import { apiClient } from "./client"; +// ---- 公共类型 ---- + +/** + * AI APP 类型联合(与后端 `CacheTypeEnum` / `_SUPPORTED_APP_TYPES` 同步)。 + * + * - app1_chat · 小程序聊天(无缓存) + * - app2_finance · 全域财务洞察(area = 'all',8 组合) + * - app2a_finance_area · 区域财务洞察(area != 'all',64 组合,2026-04-23 新增) + * - app3_clue · 客户线索分析 + * - app4_analysis · 助教关系分析 + * - app5_tactics · 话术参考 + * - app6_note_analysis · 备注分析 + * - app7_customer_analysis · 客户综合分析 + * - app8_clue_consolidated · 线索整合 + */ +export type AppType = + | "app1_chat" + | "app2_finance" + | "app2a_finance_area" + | "app3_clue" + | "app4_analysis" + | "app5_tactics" + | "app6_note_analysis" + | "app7_customer_analysis" + | "app8_clue_consolidated"; + // ---- 类型定义 ---- // Dashboard @@ -201,9 +227,16 @@ export interface AlertActionResponse { // ---- API 调用 ---- // Dashboard -export async function getDashboard(siteId?: number): Promise { +export interface DashboardQuery { + site_id?: number; + range_days?: number; // 1 / 3 / 7 / 10 + date_from?: string; // YYYY-MM-DD(与 date_to 成对) + date_to?: string; +} + +export async function getDashboard(query?: DashboardQuery): Promise { const { data } = await apiClient.get("/admin/ai/dashboard", { - params: siteId != null ? { site_id: siteId } : undefined, + params: query, }); return data; } @@ -275,3 +308,101 @@ export async function ignoreAlert(id: number): Promise { const { data } = await apiClient.post(`/admin/ai/alerts/${id}/ignore`); return data; } + +// 按需单 App 执行 +export interface RunAppRequest { + site_id: number; + member_id?: number; + assistant_id?: number; + time_dimension?: string; + area?: string; + note_content?: string; + noted_by_name?: string; + noted_by_created_at?: string; +} + +export interface RunAppResponse { + app_type: AppType; + success: boolean; + result: Record | null; + error: string | null; +} + +export async function runApp(appType: AppType, body: RunAppRequest): Promise { + const { data } = await apiClient.post(`/admin/ai/run/${appType}`, body); + return data; +} + +// ---- 触发器管理(biz.trigger_jobs)---- + +export interface TriggerItem { + id: number; + job_name: string; + job_type: string; + trigger_condition: string; // event / cron / interval + trigger_config: Record; + status: string; // enabled / disabled + description: string | null; + last_run_at: string | null; + next_run_at: string | null; + last_error: string | null; +} + +export interface TriggerUpdateRequest { + status?: string; // enabled / disabled + cron_expression?: string; + description?: string; +} + +export async function listTriggers(): Promise { + const { data } = await apiClient.get("/admin/ai/triggers"); + return data; +} + +export async function updateTrigger(id: number, body: TriggerUpdateRequest): Promise { + const { data } = await apiClient.patch(`/admin/ai/triggers/${id}`, body); + return data; +} + +// ---- 预热进度(app2_finance 72 组合)---- + +export interface PrewarmMissingItem { + target_id: string; + time_dimension: string; + area: string; +} + +export interface PrewarmProgressResponse { + total: number; + done: number; + missing: PrewarmMissingItem[]; + last_updated: string | null; +} + +export async function getPrewarmProgress(siteId: number): Promise { + const { data } = await apiClient.get("/admin/ai/prewarm/progress", { + params: { site_id: siteId }, + }); + return data; +} + +// ---- 手动触发事件链(越过去重)---- + +export interface ManualTriggerRequest { + event_type: string; // consumption / dws_completed / note_created / task_assigned + site_id: number; + member_id?: number; + assistant_id?: number; + payload?: Record; + is_forced?: boolean; +} + +export interface ManualTriggerResponse { + trigger_job_id: number; + status: string; +} + +export async function triggerEvent(body: ManualTriggerRequest): Promise { + const { data } = await apiClient.post("/admin/ai/trigger-event", body); + return data; +} diff --git a/apps/admin-web/src/pages/AIOperations.tsx b/apps/admin-web/src/pages/AIOperations.tsx index 7fdea70..ebae7e4 100644 --- a/apps/admin-web/src/pages/AIOperations.tsx +++ b/apps/admin-web/src/pages/AIOperations.tsx @@ -17,10 +17,17 @@ import { ReloadOutlined } from "@ant-design/icons"; import type { ColumnsType } from "antd/es/table"; import { retryTriggerJob, invalidateCache, createBatchRun, confirmBatchRun, - getAlerts, ackAlert, ignoreAlert, - type AlertItem, type BatchRunEstimate, + getAlerts, ackAlert, ignoreAlert, runApp, triggerEvent, + type AlertItem, type AppType, type BatchRunEstimate, } from "../api/adminAI"; +const EVENT_TYPE_OPTIONS = [ + { label: "消费事件(App3→App8→App7 [+ App4→App5])", value: "consumption" }, + { label: "备注事件(App6→App8)", value: "note_created" }, + { label: "任务分配(App4→App5)", value: "task_assigned" }, + { label: "DWS 完成(App2 × 72 组合预热)", value: "dws_completed" }, +]; + const { TextArea } = Input; const { Title } = Typography; @@ -92,6 +99,66 @@ const AIOperations: React.FC = () => { } }; + // ---- Card 2.5: 按需重新生成 ---- + const [runAppType, setRunAppType] = useState(); + const [runMemberId, setRunMemberId] = useState(""); + const [runSiteId, setRunSiteId] = useState(2790685415443269); + const [runLoading, setRunLoading] = useState(false); + const [runResult, setRunResult] = useState<{ success: boolean; text: string } | null>(null); + + const handleRunApp = async () => { + if (!runAppType) { message.warning("请选择 App 类型"); return; } + setRunLoading(true); + setRunResult(null); + try { + const res = await runApp(runAppType, { + site_id: runSiteId, + member_id: runMemberId ? Number(runMemberId) : undefined, + }); + if (res.success) { + setRunResult({ success: true, text: "执行成功,缓存已更新" }); + message.success("执行成功"); + } else { + setRunResult({ success: false, text: res.error ?? "执行失败" }); + message.error(res.error ?? "执行失败"); + } + } catch { + message.error("请求失败"); + } finally { + setRunLoading(false); + } + }; + + // ---- Card 2.6: 手动触发事件链(越过去重)---- + const [evtType, setEvtType] = useState("consumption"); + const [evtSiteId, setEvtSiteId] = useState(2790685415443269); + const [evtMemberId, setEvtMemberId] = useState(""); + const [evtAssistantId, setEvtAssistantId] = useState(""); + const [evtForced, setEvtForced] = useState(true); + const [evtLoading, setEvtLoading] = useState(false); + const [evtResult, setEvtResult] = useState(null); + + const handleTriggerEvent = async () => { + if (!evtType) { message.warning("请选择事件类型"); return; } + setEvtLoading(true); + setEvtResult(null); + try { + const res = await triggerEvent({ + event_type: evtType, + site_id: evtSiteId, + member_id: evtMemberId ? Number(evtMemberId) : undefined, + assistant_id: evtAssistantId ? Number(evtAssistantId) : undefined, + is_forced: evtForced, + }); + setEvtResult(res.trigger_job_id); + message.success(`事件已触发,job_id=${res.trigger_job_id}(后台异步执行)`); + } catch { + message.error("触发失败"); + } finally { + setEvtLoading(false); + } + }; + // ---- Card 3: 批量执行 ---- const [batchAppTypes, setBatchAppTypes] = useState([]); const [batchMemberIds, setBatchMemberIds] = useState(""); @@ -247,6 +314,89 @@ const AIOperations: React.FC = () => { + {/* Card 2.5: 按需重新生成 */} + + + + setRunMemberId(e.target.value)} + /> + + + + + +
门店
+ setEvtMemberId(e.target.value)} + /> + + +
assistant_id(可选)
+ setEvtAssistantId(e.target.value)} + /> + + +
跳过去重 + 操作
+ + setEvtForced(e.target.checked)}>强制 + + {evtResult != null && job #{evtResult}} + + +
+
+ {/* Card 3: 批量执行 */} diff --git a/apps/admin-web/src/pages/AIPrewarm.tsx b/apps/admin-web/src/pages/AIPrewarm.tsx new file mode 100644 index 0000000..38f86e3 --- /dev/null +++ b/apps/admin-web/src/pages/AIPrewarm.tsx @@ -0,0 +1,327 @@ +/** + * AI 预热进度页面。 + * + * 监控 app2_finance 的 72 组合(8 时间维度 × 9 区域)缓存覆盖率。 + * 支持: + * - 进度条 + 缺失组合表格 + * - 一键补跑所有缺失(串行 POST /admin/ai/run/app2_finance) + * - 触发全量预热(POST /admin/ai/trigger-event) + * - 按单组合手动重跑 + */ + +import React, { useCallback, useEffect, useState } from "react"; +import { + Card, Progress, Table, Button, Space, Tag, message, Select, + Typography, Descriptions, Modal, Alert, +} from "antd"; +import { ReloadOutlined, ThunderboltOutlined, PlayCircleOutlined } from "@ant-design/icons"; +import type { ColumnsType } from "antd/es/table"; +import { + getPrewarmProgress, runApp, triggerEvent, + type PrewarmProgressResponse, type PrewarmMissingItem, +} from "../api/adminAI"; + +const { Title, Paragraph, Text } = Typography; + +const TIME_LABELS: Record = { + this_month: "本月", last_month: "上月", + this_week: "本周", last_week: "上周", + this_quarter: "本季度", last_quarter: "上季度", + last_3_months: "近三个月", last_6_months: "近六个月", +}; + +const AREA_LABELS: Record = { + all: "全部区域", hall: "大厅", hallA: "A区", hallB: "B区", hallC: "C区", + vip: "台球包厢", snooker: "斯诺克", mahjong: "麻将房", ktv: "团建房", +}; + +/** + * 按 area 选 app_type: + * - area = 'all' → app2_finance(全域,8 组合) + * - area != 'all' → app2a_finance_area(区域精简版,64 组合) + */ +function areaToAppType(area: string): "app2_finance" | "app2a_finance_area" { + return area === "all" ? "app2_finance" : "app2a_finance_area"; +} + +function fmtTime(raw: string | null): string { + if (!raw) return "—"; + const d = new Date(raw); + return Number.isNaN(d.getTime()) ? raw : d.toLocaleString("zh-CN"); +} + +// 用于在 missing 表格插入分组标题行(AntD onCell 合并单元格方案) +type MissingRowWithGroup = PrewarmMissingItem & { __group_header?: string }; + +const AIPrewarm: React.FC = () => { + const [siteId, setSiteId] = useState(2790685415443269); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + + // 单组合重跑 + const [runningTarget, setRunningTarget] = useState(null); + + // 批量补缺 + const [backfillOpen, setBackfillOpen] = useState(false); + const [backfillRunning, setBackfillRunning] = useState(false); + const [backfillProgress, setBackfillProgress] = useState<{ done: number; total: number; current: string } | null>(null); + + const load = useCallback(async () => { + setLoading(true); + try { + const res = await getPrewarmProgress(siteId); + setData(res); + } catch { + message.error("加载预热进度失败"); + } finally { + setLoading(false); + } + }, [siteId]); + + useEffect(() => { load(); }, [load]); + + const handleRunOne = async (item: PrewarmMissingItem) => { + setRunningTarget(item.target_id); + try { + const res = await runApp(areaToAppType(item.area), { + site_id: siteId, + time_dimension: item.time_dimension, + area: item.area, + }); + if (res.success) { + message.success(`${TIME_LABELS[item.time_dimension]} × ${AREA_LABELS[item.area]} 已生成`); + load(); + } else { + message.error(res.error || "生成失败"); + } + } catch { + message.error("请求失败"); + } finally { + setRunningTarget(null); + } + }; + + const handleTriggerFullPrewarm = async () => { + Modal.confirm({ + title: "全量预热", + content: `将触发 dws_completed 事件,后台串行生成 72 组合缓存(预计 1-2 小时)。继续?`, + okText: "开始预热", + onOk: async () => { + try { + const res = await triggerEvent({ + event_type: "dws_completed", + site_id: siteId, + is_forced: true, + }); + message.success(`已触发全量预热(trigger_job_id=${res.trigger_job_id}),在后台异步执行`); + load(); + } catch { + message.error("触发失败"); + } + }, + }); + }; + + const handleBackfillMissing = async () => { + if (!data || data.missing.length === 0) return; + setBackfillOpen(false); + setBackfillRunning(true); + const total = data.missing.length; + let done = 0; + let fail = 0; + for (const item of data.missing) { + setBackfillProgress({ done, total, current: `${TIME_LABELS[item.time_dimension]} × ${AREA_LABELS[item.area]}` }); + try { + const res = await runApp(areaToAppType(item.area), { + site_id: siteId, + time_dimension: item.time_dimension, + area: item.area, + }); + if (!res.success) fail++; + } catch { + fail++; + } + done++; + } + setBackfillProgress({ done, total, current: "完成" }); + setBackfillRunning(false); + if (fail === 0) { + message.success(`已补齐 ${done}/${total} 组合`); + } else { + message.warning(`完成 ${done - fail}/${total},失败 ${fail} 条(详见调用明细)`); + } + load(); + }; + + // 按 area 分组的缺失列表(插入 group header 行) + const groupedMissing: MissingRowWithGroup[] = (() => { + if (!data || data.missing.length === 0) return []; + const global = data.missing.filter((i) => i.area === "all"); + const area = data.missing.filter((i) => i.area !== "all"); + const result: MissingRowWithGroup[] = []; + if (global.length > 0) { + result.push({ + time_dimension: "__header_global", + area: "__header_global", + target_id: "__header_global", + __group_header: `🌐 全域组合(app2_finance)· 缺失 ${global.length} / 8 条`, + }); + result.push(...global); + } + if (area.length > 0) { + result.push({ + time_dimension: "__header_area", + area: "__header_area", + target_id: "__header_area", + __group_header: `🏷️ 区域组合(app2a_finance_area)· 缺失 ${area.length} / 64 条`, + }); + result.push(...area); + } + return result; + })(); + + // 全域 / 区域 完成统计 + const globalDone = data ? 8 - data.missing.filter((i) => i.area === "all").length : 0; + const areaDone = data ? 64 - data.missing.filter((i) => i.area !== "all").length : 0; + + const missingColumns: ColumnsType = [ + { + title: "时间维度", + dataIndex: "time_dimension", + key: "time_dimension", + width: 220, + onCell: (r) => (r.__group_header ? { colSpan: 4 } : {}), + render: (v: string, r) => + r.__group_header ? ( +
+ {r.__group_header} +
+ ) : ( + {TIME_LABELS[v] || v} ({v}) + ), + }, + { + title: "区域", + dataIndex: "area", + key: "area", + width: 140, + onCell: (r) => (r.__group_header ? { colSpan: 0 } : {}), + render: (v: string) => ( + {AREA_LABELS[v] || v} ({v}) + ), + }, + { + title: "缓存键", + dataIndex: "target_id", + key: "target_id", + onCell: (r) => (r.__group_header ? { colSpan: 0 } : {}), + render: (v: string) => {v}, + }, + { + title: "操作", + key: "action", + width: 100, + fixed: "right", + onCell: (r) => (r.__group_header ? { colSpan: 0 } : {}), + render: (_: unknown, r) => + r.__group_header ? null : ( + + ), + }, + ]; + + const donePercent = data ? Math.round((data.done / data.total) * 100) : 0; + + return ( +
+
+
+ AI 预热进度(App2 财务洞察) + + 监控 app2_finance 共 72 组合缓存覆盖率。cron ai_dws_prewarm_1000 每日 10:00 自动补齐。 + +
+ +