Files
Neo-ZQYY/apps/admin-web/src/pages/AIDashboard.tsx
Neo caf179a5da feat: 2026-04-15~05-02 累积变更基线 — AI 重构 + Runtime Context + DWS 修复
涵盖(每条对应已存的审计记录):
- AI 模块拆分:apps/backend/app/ai/apps -> prompts/(8 个 APP + app2a 派生)
  audit: 2026-04-20__ai-module-complete.md
- admin-web AI 管理套件:AIDashboard / AIOperations / AIRunLogs / AITriggers / TriggerManager
  audit: 2026-04-21__admin-web-ai-management-suite.md
- App2 财务洞察 prompt v3 -> v5.1 + 小程序 AI 接入(chat / board-finance)
  audit: 2026-04-22__app2_prompt_v5_1_and_miniprogram_ai_insight.md
- App2 prewarm 全过滤器 + AI 触发器 cron reschedule
  audit: 2026-04-21__app2-finance-prewarm-all-filters.md
  migration: 20260420_ai_trigger_jobs_and_app2_prewarm.sql / 20260421_app2_prewarm_cron_reschedule.sql
- AppType 联合类型对齐 + adminAiAppTypes.test.ts
  audit: 2026-04-30__admin_web_ai_app_type_alignment.md
- DashScope tokens_used 提取修复
  audit: 2026-04-30__backend_dashscope_tokens_used_extraction.md
- App3 线索完整详情 prompt
  audit: 2026-05-01__backend_app3_full_detail_prompt.md
- Runtime Context 沙箱(5-1~5-2 主线):
  - 后端 schema/service + admin_runtime_context / xcx_runtime_clock 两个 router
  - admin-web RuntimeContext.tsx + miniprogram runtime-clock.ts
  - migration: 20260501__runtime_context_sandbox.sql
  - tools/db/verify_admin_web_sandbox.py + verify_sandbox_end_to_end.py
  - database/changes: 7 份 sandbox_* 验证报告
- 飞球 DWS 修复:finance_area_daily 区域汇总 + task_engine 调整
  + RLS 视图业务日上界(migration 20260502 + scripts/ops/gen_rls_business_date_migration.py)

合规:
- .gitignore 启用 tmp/ 排除
- 不入仓:apps/etl/connectors/feiqiu/.env(API_TOKEN secret,本地修改保留)

待验证清单:
- docs/audit/changes/2026-05-04__cumulative_baseline_pending_verification.md
  每个主题的功能完整性 / 上线验证几乎都未收口,按优先级 P0~P3 逐一处理
2026-05-04 02:30:19 +08:00

312 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* AI 运行总览 Dashboard 页面。
*
* - 顶部:门店筛选 + 刷新
* - 第一行4 个统计卡片今日调用、成功率、Token 消耗、平均延迟)
* - 第二行7 天趋势表格 + App 调用占比表格
* - 第三行Token 预算进度条 + App 健康状态
* - 第四行:告警列表
*/
import React, { useEffect, useRef, useState, useCallback } from "react";
import {
Card, Row, Col, Statistic, Table, Tag, Badge, Progress,
Select, Button, message, Typography, Space, DatePicker,
} from "antd";
import { ReloadOutlined, WifiOutlined } from "@ant-design/icons";
import type { Dayjs } from "dayjs";
const { RangePicker } = DatePicker;
const RANGE_OPTIONS = [
{ label: "今日", value: 1 },
{ label: "近 3 天", value: 3 },
{ label: "近 7 天", value: 7 },
{ label: "近 10 天", value: 10 },
{ label: "指定日期", value: 0 }, // 0 = 启用 RangePicker
];
const RANGE_LABEL: Record<number, string> = {
1: "今日", 3: "近 3 天", 7: "近 7 天", 10: "近 10 天",
};
import type { ColumnsType } from "antd/es/table";
import {
getDashboard,
type DashboardResponse, type DailyTrend, type AppDistItem,
type AlertItem, type AppHealthItem,
} from "../api/adminAI";
const { Title } = Typography;
const ALERT_STATUS_COLOR: Record<string, string> = {
failed: "red", timeout: "orange", circuit_open: "volcano",
};
const ALERT_MGMT_COLOR: Record<string, string> = {
pending: "warning", acknowledged: "success", ignored: "default",
};
const HEALTH_STATUS: Record<string, "success" | "error" | "warning" | "default"> = {
success: "success", failed: "error", timeout: "warning", circuit_open: "error",
};
function fmtTime(raw: string | null): string {
if (!raw) return "—";
const d = new Date(raw);
return Number.isNaN(d.getTime()) ? raw : d.toLocaleString("zh-CN");
}
// ---- 表格列定义 ----
const trendColumns: ColumnsType<DailyTrend> = [
{ title: "日期", dataIndex: "date", key: "date", width: 120 },
{ title: "调用量", dataIndex: "calls", key: "calls", align: "right" },
{
title: "成功率", dataIndex: "success_rate", key: "success_rate", align: "right",
render: (v: number) => `${(v * 100).toFixed(1)}%`,
},
];
const distColumns: ColumnsType<AppDistItem> = [
{ title: "App 类型", dataIndex: "app_type", key: "app_type" },
{ title: "调用次数", dataIndex: "count", key: "count", align: "right" },
{
title: "占比", dataIndex: "percentage", key: "percentage", align: "right",
render: (v: number) => `${(v * 100).toFixed(1)}%`,
},
];
const alertColumns: ColumnsType<AlertItem> = [
{ title: "ID", dataIndex: "id", key: "id", width: 80 },
{ title: "App", dataIndex: "app_type", key: "app_type", width: 160 },
{
title: "状态", dataIndex: "status", key: "status", width: 110,
render: (v: string) => <Tag color={ALERT_STATUS_COLOR[v] ?? "default"}>{v}</Tag>,
},
{
title: "告警状态", dataIndex: "alert_status", key: "alert_status", width: 110,
render: (v: string | null) => v ? <Tag color={ALERT_MGMT_COLOR[v] ?? "default"}>{v}</Tag> : "—",
},
{
title: "错误信息", dataIndex: "error_message", key: "error_message", ellipsis: true,
render: (v: string | null) => v ?? "—",
},
{ title: "时间", dataIndex: "created_at", key: "created_at", width: 170, render: fmtTime },
];
// ---- 页面组件 ----
const AIDashboard: React.FC = () => {
const [data, setData] = useState<DashboardResponse | null>(null);
const [loading, setLoading] = useState(false);
const [siteId, setSiteId] = useState<number | undefined>(undefined);
const [rangeDays, setRangeDays] = useState<number>(1); // 0=自定义日期 / 1/3/7/10
const [customRange, setCustomRange] = useState<[Dayjs, Dayjs] | null>(null);
const [wsStatus, setWsStatus] = useState<"connecting" | "connected" | "disconnected">("disconnected");
const [realtimeAlerts, setRealtimeAlerts] = useState<AlertItem[]>([]);
const wsRef = useRef<WebSocket | null>(null);
const load = useCallback(async () => {
setLoading(true);
try {
const query: { site_id?: number; range_days?: number; date_from?: string; date_to?: string } = {};
if (siteId != null) query.site_id = siteId;
if (rangeDays === 0 && customRange) {
query.date_from = customRange[0].format("YYYY-MM-DD");
query.date_to = customRange[1].format("YYYY-MM-DD");
} else if (rangeDays > 0) {
query.range_days = rangeDays;
}
const res = await getDashboard(query);
setData(res);
} catch {
message.error("加载 Dashboard 失败");
} finally {
setLoading(false);
}
}, [siteId, rangeDays, customRange]);
useEffect(() => { load(); }, [load]);
const statLabel = rangeDays === 0
? (customRange ? `${customRange[0].format("MM-DD")} ~ ${customRange[1].format("MM-DD")}` : "指定日期")
: (RANGE_LABEL[rangeDays] || "今日");
// WebSocket 实时告警订阅
useEffect(() => {
const wsKey = siteId ?? -1;
const proto = window.location.protocol === "https:" ? "wss" : "ws";
const url = `${proto}://${window.location.host}/ws/ai-alerts/${wsKey}`;
setWsStatus("connecting");
const ws = new WebSocket(url);
wsRef.current = ws;
ws.onopen = () => setWsStatus("connected");
ws.onclose = () => setWsStatus("disconnected");
ws.onerror = () => setWsStatus("disconnected");
ws.onmessage = (evt) => {
try {
const msg = JSON.parse(evt.data as string) as {
type: string;
payload: AlertItem;
};
if (msg.type === "alert_created" && msg.payload) {
setRealtimeAlerts((prev) => [msg.payload, ...prev].slice(0, 20));
message.warning(`[实时] ${msg.payload.app_type} ${msg.payload.status}`);
}
} catch {
// 忽略非 JSON 消息
}
};
return () => {
ws.close();
wsRef.current = null;
setWsStatus("disconnected");
};
}, [siteId]);
return (
<div>
{/* 顶部:门店筛选 + 刷新 */}
<Row justify="space-between" align="middle" style={{ marginBottom: 16 }}>
<Space wrap>
<Title level={4} style={{ margin: 0 }}>AI </Title>
<Select
allowClear placeholder="门店筛选" style={{ width: 180 }}
value={siteId} onChange={(v) => setSiteId(v)}
options={[{ label: "默认门店", value: 2790685415443269 }]}
/>
<Select
value={rangeDays} onChange={setRangeDays} style={{ width: 140 }}
options={RANGE_OPTIONS}
/>
{rangeDays === 0 && (
<RangePicker
value={customRange}
onChange={(v) => setCustomRange(v as [Dayjs, Dayjs] | null)}
/>
)}
</Space>
<Space>
<Badge
status={wsStatus === "connected" ? "success" : wsStatus === "connecting" ? "processing" : "default"}
text={<span style={{ fontSize: 12, color: "#888" }}><WifiOutlined /> {wsStatus === "connected" ? "已连接" : wsStatus === "connecting" ? "连接中" : "断开"}</span>}
/>
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}></Button>
</Space>
</Row>
{/* 第一行4 个统计卡片 */}
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={6}>
<Card><Statistic title={`${statLabel}调用次数`} value={data?.today_calls ?? 0} /></Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title={`${statLabel}成功率`} suffix="%"
value={data ? (data.today_success_rate * 100).toFixed(1) : "0.0"}
/>
</Card>
</Col>
<Col span={6}>
<Card><Statistic title={`${statLabel} Token 消耗`} value={data?.today_tokens ?? 0} /></Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="平均延迟" suffix="ms"
value={data ? data.today_avg_latency_ms.toFixed(0) : "0"}
/>
</Card>
</Col>
</Row>
{/* 第二行7 天趋势 + App 调用占比 */}
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={12}>
<Card title="近 7 天趋势" size="small">
<Table<DailyTrend>
columns={trendColumns}
dataSource={data?.trend_7d ?? []}
rowKey="date" size="small" pagination={false}
loading={loading}
/>
</Card>
</Col>
<Col span={12}>
<Card title="App 调用占比" size="small">
<Table<AppDistItem>
columns={distColumns}
dataSource={data?.app_distribution ?? []}
rowKey="app_type" size="small" pagination={false}
loading={loading}
/>
</Card>
</Col>
</Row>
{/* 第三行Token 预算 + App 健康状态 */}
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={12}>
<Card title="Token 预算" size="small">
<div style={{ marginBottom: 12 }}>
<span>{data?.budget.daily_used ?? 0} / {data?.budget.daily_limit ?? 0}</span>
<Progress
percent={data ? +(data.budget.daily_pct * 100).toFixed(1) : 0}
status={data && data.budget.daily_pct > 0.9 ? "exception" : "active"}
/>
</div>
<div>
<span>{data?.budget.monthly_used ?? 0} / {data?.budget.monthly_limit ?? 0}</span>
<Progress
percent={data ? +(data.budget.monthly_pct * 100).toFixed(1) : 0}
status={data && data.budget.monthly_pct > 0.9 ? "exception" : "active"}
/>
</div>
</Card>
</Col>
<Col span={12}>
<Card title="App 健康状态" size="small">
{(data?.app_health ?? []).map((item: AppHealthItem) => (
<div key={item.app_type} style={{ display: "flex", justifyContent: "space-between", padding: "4px 0" }}>
<span>{item.app_type}</span>
<Space>
<Badge status={HEALTH_STATUS[item.last_status ?? ""] ?? "default"} text={item.last_status ?? "无记录"} />
<span style={{ fontSize: 12, color: "#999" }}>{fmtTime(item.last_call_at)}</span>
</Space>
</div>
))}
{(data?.app_health ?? []).length === 0 && <span style={{ color: "#999" }}></span>}
</Card>
</Col>
</Row>
{/* 第四行:告警列表(实时 + 历史合并) */}
<Card
title="告警列表"
size="small"
extra={realtimeAlerts.length > 0 && (
<Tag color="orange">{realtimeAlerts.length} </Tag>
)}
>
<Table<AlertItem>
columns={alertColumns}
dataSource={[
...realtimeAlerts,
...(data?.recent_alerts ?? []).filter(
(a) => !realtimeAlerts.some((r) => r.id === a.id)
),
]}
rowKey="id" size="small" pagination={{ pageSize: 10 }}
loading={loading}
/>
</Card>
</div>
);
};
export default AIDashboard;