feat(admin-web): AIPrewarm 分组展示 + 每行触发 + AppType 联合类型
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) <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,32 @@
|
|||||||
|
|
||||||
import { apiClient } from "./client";
|
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
|
// Dashboard
|
||||||
@@ -201,9 +227,16 @@ export interface AlertActionResponse {
|
|||||||
// ---- API 调用 ----
|
// ---- API 调用 ----
|
||||||
|
|
||||||
// Dashboard
|
// Dashboard
|
||||||
export async function getDashboard(siteId?: number): Promise<DashboardResponse> {
|
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<DashboardResponse> {
|
||||||
const { data } = await apiClient.get<DashboardResponse>("/admin/ai/dashboard", {
|
const { data } = await apiClient.get<DashboardResponse>("/admin/ai/dashboard", {
|
||||||
params: siteId != null ? { site_id: siteId } : undefined,
|
params: query,
|
||||||
});
|
});
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
@@ -275,3 +308,101 @@ export async function ignoreAlert(id: number): Promise<AlertActionResponse> {
|
|||||||
const { data } = await apiClient.post<AlertActionResponse>(`/admin/ai/alerts/${id}/ignore`);
|
const { data } = await apiClient.post<AlertActionResponse>(`/admin/ai/alerts/${id}/ignore`);
|
||||||
return data;
|
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<string, unknown> | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runApp(appType: AppType, body: RunAppRequest): Promise<RunAppResponse> {
|
||||||
|
const { data } = await apiClient.post<RunAppResponse>(`/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<string, unknown>;
|
||||||
|
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<TriggerItem[]> {
|
||||||
|
const { data } = await apiClient.get<TriggerItem[]>("/admin/ai/triggers");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateTrigger(id: number, body: TriggerUpdateRequest): Promise<TriggerItem> {
|
||||||
|
const { data } = await apiClient.patch<TriggerItem>(`/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<PrewarmProgressResponse> {
|
||||||
|
const { data } = await apiClient.get<PrewarmProgressResponse>("/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<string, unknown>;
|
||||||
|
is_forced?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ManualTriggerResponse {
|
||||||
|
trigger_job_id: number;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function triggerEvent(body: ManualTriggerRequest): Promise<ManualTriggerResponse> {
|
||||||
|
const { data } = await apiClient.post<ManualTriggerResponse>("/admin/ai/trigger-event", body);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,10 +17,17 @@ import { ReloadOutlined } from "@ant-design/icons";
|
|||||||
import type { ColumnsType } from "antd/es/table";
|
import type { ColumnsType } from "antd/es/table";
|
||||||
import {
|
import {
|
||||||
retryTriggerJob, invalidateCache, createBatchRun, confirmBatchRun,
|
retryTriggerJob, invalidateCache, createBatchRun, confirmBatchRun,
|
||||||
getAlerts, ackAlert, ignoreAlert,
|
getAlerts, ackAlert, ignoreAlert, runApp, triggerEvent,
|
||||||
type AlertItem, type BatchRunEstimate,
|
type AlertItem, type AppType, type BatchRunEstimate,
|
||||||
} from "../api/adminAI";
|
} 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 { TextArea } = Input;
|
||||||
const { Title } = Typography;
|
const { Title } = Typography;
|
||||||
|
|
||||||
@@ -92,6 +99,66 @@ const AIOperations: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---- Card 2.5: 按需重新生成 ----
|
||||||
|
const [runAppType, setRunAppType] = useState<AppType | undefined>();
|
||||||
|
const [runMemberId, setRunMemberId] = useState<string>("");
|
||||||
|
const [runSiteId, setRunSiteId] = useState<number>(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<string>("consumption");
|
||||||
|
const [evtSiteId, setEvtSiteId] = useState<number>(2790685415443269);
|
||||||
|
const [evtMemberId, setEvtMemberId] = useState<string>("");
|
||||||
|
const [evtAssistantId, setEvtAssistantId] = useState<string>("");
|
||||||
|
const [evtForced, setEvtForced] = useState<boolean>(true);
|
||||||
|
const [evtLoading, setEvtLoading] = useState(false);
|
||||||
|
const [evtResult, setEvtResult] = useState<number | null>(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: 批量执行 ----
|
// ---- Card 3: 批量执行 ----
|
||||||
const [batchAppTypes, setBatchAppTypes] = useState<string[]>([]);
|
const [batchAppTypes, setBatchAppTypes] = useState<string[]>([]);
|
||||||
const [batchMemberIds, setBatchMemberIds] = useState<string>("");
|
const [batchMemberIds, setBatchMemberIds] = useState<string>("");
|
||||||
@@ -247,6 +314,89 @@ const AIOperations: React.FC = () => {
|
|||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
|
{/* Card 2.5: 按需重新生成 */}
|
||||||
|
<Card title="按需重新生成" size="small" style={{ marginBottom: 16 }}>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={6}>
|
||||||
|
<Select
|
||||||
|
allowClear placeholder="App 类型" style={{ width: "100%" }}
|
||||||
|
value={runAppType} onChange={setRunAppType}
|
||||||
|
options={APP_TYPE_OPTIONS}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={6}>
|
||||||
|
<Input
|
||||||
|
placeholder="会员 ID(部分 App 必填)" value={runMemberId}
|
||||||
|
onChange={(e) => setRunMemberId(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={6}>
|
||||||
|
<Select
|
||||||
|
placeholder="门店" style={{ width: "100%" }}
|
||||||
|
value={runSiteId} onChange={setRunSiteId}
|
||||||
|
options={[{ label: "默认门店", value: 2790685415443269 }]}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={6}>
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" onClick={handleRunApp} loading={runLoading}>立即执行</Button>
|
||||||
|
{runResult && (
|
||||||
|
<Tag color={runResult.success ? "success" : "error"}>{runResult.text}</Tag>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Card 2.6: 手动触发事件链(越过去重,调试利器)*/}
|
||||||
|
<Card
|
||||||
|
title="手动触发事件链(调试用)" size="small" style={{ marginBottom: 16 }}
|
||||||
|
extra={<Tag color="orange">默认跳过去重</Tag>}
|
||||||
|
>
|
||||||
|
<Row gutter={12}>
|
||||||
|
<Col span={6}>
|
||||||
|
<div style={{ marginBottom: 4, fontSize: 12, color: "#888" }}>事件类型</div>
|
||||||
|
<Select
|
||||||
|
value={evtType} onChange={setEvtType}
|
||||||
|
options={EVENT_TYPE_OPTIONS}
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={5}>
|
||||||
|
<div style={{ marginBottom: 4, fontSize: 12, color: "#888" }}>门店</div>
|
||||||
|
<Select
|
||||||
|
value={evtSiteId} onChange={setEvtSiteId}
|
||||||
|
options={[{ label: "默认门店", value: 2790685415443269 }]}
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={4}>
|
||||||
|
<div style={{ marginBottom: 4, fontSize: 12, color: "#888" }}>member_id(可选)</div>
|
||||||
|
<Input
|
||||||
|
placeholder="consumption/note/task 事件需填"
|
||||||
|
value={evtMemberId}
|
||||||
|
onChange={(e) => setEvtMemberId(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={4}>
|
||||||
|
<div style={{ marginBottom: 4, fontSize: 12, color: "#888" }}>assistant_id(可选)</div>
|
||||||
|
<Input
|
||||||
|
placeholder="task_assigned 事件需填"
|
||||||
|
value={evtAssistantId}
|
||||||
|
onChange={(e) => setEvtAssistantId(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={5}>
|
||||||
|
<div style={{ marginBottom: 4, fontSize: 12, color: "#888" }}>跳过去重 + 操作</div>
|
||||||
|
<Space>
|
||||||
|
<Checkbox checked={evtForced} onChange={(e) => setEvtForced(e.target.checked)}>强制</Checkbox>
|
||||||
|
<Button type="primary" danger onClick={handleTriggerEvent} loading={evtLoading}>触发</Button>
|
||||||
|
{evtResult != null && <Tag color="processing">job #{evtResult}</Tag>}
|
||||||
|
</Space>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Card 3: 批量执行 */}
|
{/* Card 3: 批量执行 */}
|
||||||
<Card title="批量执行" size="small" style={{ marginBottom: 16 }}>
|
<Card title="批量执行" size="small" style={{ marginBottom: 16 }}>
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
|
|||||||
327
apps/admin-web/src/pages/AIPrewarm.tsx
Normal file
327
apps/admin-web/src/pages/AIPrewarm.tsx
Normal file
@@ -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<string, string> = {
|
||||||
|
this_month: "本月", last_month: "上月",
|
||||||
|
this_week: "本周", last_week: "上周",
|
||||||
|
this_quarter: "本季度", last_quarter: "上季度",
|
||||||
|
last_3_months: "近三个月", last_6_months: "近六个月",
|
||||||
|
};
|
||||||
|
|
||||||
|
const AREA_LABELS: Record<string, string> = {
|
||||||
|
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<number>(2790685415443269);
|
||||||
|
const [data, setData] = useState<PrewarmProgressResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// 单组合重跑
|
||||||
|
const [runningTarget, setRunningTarget] = useState<string | null>(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<MissingRowWithGroup> = [
|
||||||
|
{
|
||||||
|
title: "时间维度",
|
||||||
|
dataIndex: "time_dimension",
|
||||||
|
key: "time_dimension",
|
||||||
|
width: 220,
|
||||||
|
onCell: (r) => (r.__group_header ? { colSpan: 4 } : {}),
|
||||||
|
render: (v: string, r) =>
|
||||||
|
r.__group_header ? (
|
||||||
|
<div style={{ padding: "6px 4px", background: "#fafafa", fontWeight: 600, color: "#595959" }}>
|
||||||
|
{r.__group_header}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span>{TIME_LABELS[v] || v} <Text type="secondary" style={{ fontSize: 12 }}>({v})</Text></span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "区域",
|
||||||
|
dataIndex: "area",
|
||||||
|
key: "area",
|
||||||
|
width: 140,
|
||||||
|
onCell: (r) => (r.__group_header ? { colSpan: 0 } : {}),
|
||||||
|
render: (v: string) => (
|
||||||
|
<span>{AREA_LABELS[v] || v} <Text type="secondary" style={{ fontSize: 12 }}>({v})</Text></span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "缓存键",
|
||||||
|
dataIndex: "target_id",
|
||||||
|
key: "target_id",
|
||||||
|
onCell: (r) => (r.__group_header ? { colSpan: 0 } : {}),
|
||||||
|
render: (v: string) => <code>{v}</code>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "操作",
|
||||||
|
key: "action",
|
||||||
|
width: 100,
|
||||||
|
fixed: "right",
|
||||||
|
onCell: (r) => (r.__group_header ? { colSpan: 0 } : {}),
|
||||||
|
render: (_: unknown, r) =>
|
||||||
|
r.__group_header ? null : (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
icon={<PlayCircleOutlined />}
|
||||||
|
loading={runningTarget === r.target_id}
|
||||||
|
onClick={() => handleRunOne(r as PrewarmMissingItem)}
|
||||||
|
>
|
||||||
|
单独生成
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const donePercent = data ? Math.round((data.done / data.total) * 100) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 16 }}>
|
||||||
|
<div>
|
||||||
|
<Title level={4} style={{ margin: 0 }}>AI 预热进度(App2 财务洞察)</Title>
|
||||||
|
<Paragraph type="secondary" style={{ margin: 0, fontSize: 13 }}>
|
||||||
|
监控 <code>app2_finance</code> 共 72 组合缓存覆盖率。cron <code>ai_dws_prewarm_1000</code> 每日 10:00 自动补齐。
|
||||||
|
</Paragraph>
|
||||||
|
</div>
|
||||||
|
<Space>
|
||||||
|
<Select
|
||||||
|
value={siteId} onChange={setSiteId} style={{ width: 200 }}
|
||||||
|
options={[{ label: "默认门店 (2790685415443269)", value: 2790685415443269 }]}
|
||||||
|
/>
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>刷新</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card size="small" style={{ marginBottom: 16 }}>
|
||||||
|
<Descriptions column={4} size="small">
|
||||||
|
<Descriptions.Item label="总完成度">
|
||||||
|
<Text strong style={{ fontSize: 16 }}>{data?.done ?? 0} / {data?.total ?? 72}</Text>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="全域 (app2_finance)">
|
||||||
|
<Tag color={globalDone === 8 ? "success" : "warning"}>{globalDone} / 8</Tag>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="区域 (app2a_finance_area)">
|
||||||
|
<Tag color={areaDone === 64 ? "success" : "warning"}>{areaDone} / 64</Tag>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="最近更新">{fmtTime(data?.last_updated ?? null)}</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
<Progress
|
||||||
|
percent={donePercent}
|
||||||
|
status={donePercent === 100 ? "success" : "active"}
|
||||||
|
strokeColor={{ from: "#108ee9", to: "#87d068" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Space style={{ marginTop: 16 }}>
|
||||||
|
<Button
|
||||||
|
type="primary" icon={<ThunderboltOutlined />}
|
||||||
|
onClick={handleTriggerFullPrewarm}
|
||||||
|
>触发全量预热</Button>
|
||||||
|
<Button
|
||||||
|
icon={<PlayCircleOutlined />}
|
||||||
|
disabled={!data || data.missing.length === 0}
|
||||||
|
onClick={() => setBackfillOpen(true)}
|
||||||
|
>一键补齐缺失({data?.missing.length ?? 0})</Button>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{backfillRunning && backfillProgress && (
|
||||||
|
<Alert
|
||||||
|
type="info" showIcon style={{ marginBottom: 16 }}
|
||||||
|
message={`补齐进行中:${backfillProgress.done}/${backfillProgress.total} — ${backfillProgress.current}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card title={`缺失组合(${data?.missing.length ?? 0}) · 按全域 / 区域分组`} size="small">
|
||||||
|
<Table<MissingRowWithGroup>
|
||||||
|
columns={missingColumns}
|
||||||
|
dataSource={groupedMissing}
|
||||||
|
rowKey="target_id"
|
||||||
|
loading={loading}
|
||||||
|
pagination={{ pageSize: 30 }}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="一键补齐缺失组合"
|
||||||
|
open={backfillOpen}
|
||||||
|
onCancel={() => setBackfillOpen(false)}
|
||||||
|
onOk={handleBackfillMissing}
|
||||||
|
okText="开始补齐" cancelText="取消"
|
||||||
|
>
|
||||||
|
<p>将串行调用 <code>POST /admin/ai/run/app2_finance</code>,逐个生成共 <strong>{data?.missing.length ?? 0}</strong> 条缓存。</p>
|
||||||
|
<p>每条耗时约 30-120 秒,总耗时 <strong>{Math.ceil((data?.missing.length ?? 0) * 1.5)}</strong>~<strong>{Math.ceil((data?.missing.length ?? 0) * 2.5)}</strong> 分钟。</p>
|
||||||
|
<p style={{ color: "#d46b08" }}>期间此标签页请保持打开,关闭会中断补齐。</p>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AIPrewarm;
|
||||||
Reference in New Issue
Block a user