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 逐一处理
This commit is contained in:
@@ -8,12 +8,27 @@
|
||||
* - 第四行:告警列表
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import React, { useEffect, useRef, useState, useCallback } from "react";
|
||||
import {
|
||||
Card, Row, Col, Statistic, Table, Tag, Badge, Progress,
|
||||
Select, Button, message, Typography, Space,
|
||||
Select, Button, message, Typography, Space, DatePicker,
|
||||
} from "antd";
|
||||
import { ReloadOutlined } from "@ant-design/icons";
|
||||
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,
|
||||
@@ -85,51 +100,119 @@ 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 res = await getDashboard(siteId);
|
||||
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]);
|
||||
}, [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>
|
||||
<Space wrap>
|
||||
<Title level={4} style={{ margin: 0 }}>AI 运行总览</Title>
|
||||
<Select
|
||||
allowClear placeholder="门店筛选" style={{ width: 200 }}
|
||||
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>
|
||||
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>刷新</Button>
|
||||
</Row>
|
||||
|
||||
{/* 第一行:4 个统计卡片 */}
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col span={6}>
|
||||
<Card><Statistic title="今日调用次数" value={data?.today_calls ?? 0} /></Card>
|
||||
<Card><Statistic title={`${statLabel}调用次数`} value={data?.today_calls ?? 0} /></Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="今日成功率" suffix="%"
|
||||
title={`${statLabel}成功率`} suffix="%"
|
||||
value={data ? (data.today_success_rate * 100).toFixed(1) : "0.0"}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card><Statistic title="今日 Token 消耗" value={data?.today_tokens ?? 0} /></Card>
|
||||
<Card><Statistic title={`${statLabel} Token 消耗`} value={data?.today_tokens ?? 0} /></Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
@@ -201,11 +284,22 @@ const AIDashboard: React.FC = () => {
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 第四行:告警列表 */}
|
||||
<Card title="告警列表" size="small">
|
||||
{/* 第四行:告警列表(实时 + 历史合并) */}
|
||||
<Card
|
||||
title="告警列表"
|
||||
size="small"
|
||||
extra={realtimeAlerts.length > 0 && (
|
||||
<Tag color="orange">{realtimeAlerts.length} 条实时</Tag>
|
||||
)}
|
||||
>
|
||||
<Table<AlertItem>
|
||||
columns={alertColumns}
|
||||
dataSource={data?.recent_alerts ?? []}
|
||||
dataSource={[
|
||||
...realtimeAlerts,
|
||||
...(data?.recent_alerts ?? []).filter(
|
||||
(a) => !realtimeAlerts.some((r) => r.id === a.id)
|
||||
),
|
||||
]}
|
||||
rowKey="id" size="small" pagination={{ pageSize: 10 }}
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
@@ -31,7 +31,7 @@ const EVENT_TYPE_OPTIONS = [
|
||||
const { TextArea } = Input;
|
||||
const { Title } = Typography;
|
||||
|
||||
const APP_TYPE_OPTIONS = [
|
||||
export const CACHE_TYPE_OPTIONS = [
|
||||
{ label: "App3 维客线索", value: "app3_clue" },
|
||||
{ label: "App4 关系分析", value: "app4_analysis" },
|
||||
{ label: "App5 话术参考", value: "app5_tactics" },
|
||||
@@ -40,6 +40,15 @@ const APP_TYPE_OPTIONS = [
|
||||
{ label: "App8 线索整理", value: "app8_clue_consolidated" },
|
||||
];
|
||||
|
||||
export const RUN_APP_TYPE_OPTIONS: { label: string; value: AppType }[] = [
|
||||
{ label: "App3 维客线索", value: "app3_clue" },
|
||||
{ label: "App4 关系分析", value: "app4_analysis" },
|
||||
{ label: "App5 话术参考", value: "app5_tactics" },
|
||||
{ label: "App6 备注分析", value: "app6_note" },
|
||||
{ label: "App7 客户分析", value: "app7_customer" },
|
||||
{ label: "App8 线索整理", value: "app8_consolidation" },
|
||||
];
|
||||
|
||||
const ALERT_STATUS_COLOR: Record<string, string> = {
|
||||
failed: "red", timeout: "orange", circuit_open: "volcano",
|
||||
};
|
||||
@@ -160,7 +169,7 @@ const AIOperations: React.FC = () => {
|
||||
};
|
||||
|
||||
// ---- Card 3: 批量执行 ----
|
||||
const [batchAppTypes, setBatchAppTypes] = useState<string[]>([]);
|
||||
const [batchAppTypes, setBatchAppTypes] = useState<AppType[]>([]);
|
||||
const [batchMemberIds, setBatchMemberIds] = useState<string>("");
|
||||
const [batchSiteId, setBatchSiteId] = useState<number>(2790685415443269);
|
||||
const [batchLoading, setBatchLoading] = useState(false);
|
||||
@@ -294,7 +303,7 @@ const AIOperations: React.FC = () => {
|
||||
<Select
|
||||
allowClear placeholder="App 类型(可选)" style={{ width: "100%" }}
|
||||
value={cacheAppType} onChange={setCacheAppType}
|
||||
options={APP_TYPE_OPTIONS}
|
||||
options={CACHE_TYPE_OPTIONS}
|
||||
/>
|
||||
<Input
|
||||
placeholder="会员 ID(可选)" value={cacheMemberId}
|
||||
@@ -321,7 +330,7 @@ const AIOperations: React.FC = () => {
|
||||
<Select
|
||||
allowClear placeholder="App 类型" style={{ width: "100%" }}
|
||||
value={runAppType} onChange={setRunAppType}
|
||||
options={APP_TYPE_OPTIONS}
|
||||
options={RUN_APP_TYPE_OPTIONS}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
@@ -403,9 +412,9 @@ const AIOperations: React.FC = () => {
|
||||
<Col span={8}>
|
||||
<div style={{ marginBottom: 8, fontWeight: 500 }}>选择 App</div>
|
||||
<Checkbox.Group
|
||||
options={APP_TYPE_OPTIONS}
|
||||
options={RUN_APP_TYPE_OPTIONS}
|
||||
value={batchAppTypes}
|
||||
onChange={(v) => setBatchAppTypes(v as string[])}
|
||||
onChange={(v) => setBatchAppTypes(v as AppType[])}
|
||||
style={{ display: "flex", flexDirection: "column", gap: 4 }}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
@@ -85,11 +85,7 @@ const AIRunLogs: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const APP_TYPE_OPTIONS = [
|
||||
"app1_chat", "app2_finance", "app3_clue", "app4_analysis",
|
||||
"app5_tactics", "app6_note_analysis", "app7_customer_analysis",
|
||||
"app8_clue_consolidated",
|
||||
].map((v) => ({ label: v, value: v }));
|
||||
const APP_TYPE_OPTIONS = RUN_LOG_APP_TYPE_OPTIONS;
|
||||
|
||||
const columns: ColumnsType<RunLogItem> = [
|
||||
{ title: "ID", dataIndex: "id", key: "id", width: 70 },
|
||||
@@ -227,3 +223,9 @@ const AIRunLogs: React.FC = () => {
|
||||
};
|
||||
|
||||
export default AIRunLogs;
|
||||
|
||||
export const RUN_LOG_APP_TYPE_OPTIONS = [
|
||||
"app1_chat", "app2_finance", "app2a_finance_area", "app3_clue",
|
||||
"app4_analysis", "app5_tactics", "app6_note", "app7_customer",
|
||||
"app8_consolidate", "app8_consolidation",
|
||||
].map((v) => ({ label: v, value: v }));
|
||||
|
||||
243
apps/admin-web/src/pages/AITriggers.tsx
Normal file
243
apps/admin-web/src/pages/AITriggers.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* AI 触发器设置页面。
|
||||
*
|
||||
* 管理 biz.trigger_jobs 表中 job_type='ai_*' 的所有触发器,支持:
|
||||
* - 启用/禁用
|
||||
* - 修改 cron 表达式(仅 cron 类型)
|
||||
* - 修改描述
|
||||
* - 查看事件名、最近运行、下次运行、最后错误
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
Card, Table, Tag, Button, Space, Modal, Input, Switch,
|
||||
message, Typography, Tooltip, Descriptions,
|
||||
} from "antd";
|
||||
import { ReloadOutlined, EditOutlined } from "@ant-design/icons";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import {
|
||||
listTriggers, updateTrigger,
|
||||
type TriggerItem,
|
||||
} from "../api/adminAI";
|
||||
|
||||
const { Title, Paragraph } = Typography;
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
enabled: "success",
|
||||
disabled: "default",
|
||||
};
|
||||
|
||||
const CONDITION_COLOR: Record<string, string> = {
|
||||
event: "processing",
|
||||
cron: "warning",
|
||||
interval: "cyan",
|
||||
};
|
||||
|
||||
function fmtTime(raw: string | null): string {
|
||||
if (!raw) return "—";
|
||||
const d = new Date(raw);
|
||||
return Number.isNaN(d.getTime()) ? raw : d.toLocaleString("zh-CN");
|
||||
}
|
||||
|
||||
function cronExpr(item: TriggerItem): string {
|
||||
const cfg = item.trigger_config || {};
|
||||
return String(cfg.cron_expression || cfg.event_name || "—");
|
||||
}
|
||||
|
||||
const AITriggers: React.FC = () => {
|
||||
const [items, setItems] = useState<TriggerItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [editing, setEditing] = useState<TriggerItem | null>(null);
|
||||
const [editCron, setEditCron] = useState("");
|
||||
const [editDesc, setEditDesc] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await listTriggers();
|
||||
setItems(res);
|
||||
} catch {
|
||||
message.error("加载触发器列表失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleToggle = async (item: TriggerItem, next: boolean) => {
|
||||
try {
|
||||
await updateTrigger(item.id, { status: next ? "enabled" : "disabled" });
|
||||
message.success(next ? "已启用" : "已禁用");
|
||||
load();
|
||||
} catch {
|
||||
message.error("状态切换失败");
|
||||
}
|
||||
};
|
||||
|
||||
const openEdit = (item: TriggerItem) => {
|
||||
setEditing(item);
|
||||
setEditCron(String(item.trigger_config?.cron_expression || ""));
|
||||
setEditDesc(item.description || "");
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!editing) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const body: { cron_expression?: string; description?: string } = {};
|
||||
if (editing.trigger_condition === "cron" && editCron !== editing.trigger_config?.cron_expression) {
|
||||
body.cron_expression = editCron;
|
||||
}
|
||||
if (editDesc !== (editing.description || "")) {
|
||||
body.description = editDesc;
|
||||
}
|
||||
if (Object.keys(body).length === 0) {
|
||||
message.info("无变更");
|
||||
setEditing(null);
|
||||
return;
|
||||
}
|
||||
await updateTrigger(editing.id, body);
|
||||
message.success("已保存");
|
||||
setEditing(null);
|
||||
load();
|
||||
} catch (err) {
|
||||
const msg = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail;
|
||||
message.error(`保存失败${msg ? `:${msg}` : ""}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<TriggerItem> = [
|
||||
{ title: "ID", dataIndex: "id", key: "id", width: 60 },
|
||||
{
|
||||
title: "触发器名", dataIndex: "job_name", key: "job_name", width: 200,
|
||||
render: (v: string, r) => (
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{v}</div>
|
||||
{r.description && (
|
||||
<div style={{ fontSize: 12, color: "#888", marginTop: 2 }}>{r.description}</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "类型", dataIndex: "trigger_condition", key: "trigger_condition", width: 80,
|
||||
render: (v: string) => <Tag color={CONDITION_COLOR[v] ?? "default"}>{v}</Tag>,
|
||||
},
|
||||
{
|
||||
title: "表达式 / 事件", key: "expr", width: 240,
|
||||
render: (_: unknown, r) => (
|
||||
<code style={{ fontSize: 12 }}>{cronExpr(r)}</code>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "状态", dataIndex: "status", key: "status", width: 100,
|
||||
render: (v: string, r) => (
|
||||
<Space>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={v === "enabled"}
|
||||
onChange={(c) => handleToggle(r, c)}
|
||||
/>
|
||||
<Tag color={STATUS_COLOR[v] ?? "default"}>{v}</Tag>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{ title: "最近运行", dataIndex: "last_run_at", key: "last_run_at", width: 160, render: fmtTime },
|
||||
{ title: "下次运行", dataIndex: "next_run_at", key: "next_run_at", width: 160, render: fmtTime },
|
||||
{
|
||||
title: "最后错误", dataIndex: "last_error", key: "last_error", ellipsis: true,
|
||||
render: (v: string | null) => v ? (
|
||||
<Tooltip title={v}><span style={{ color: "#d46b08" }}>{v.slice(0, 40)}…</span></Tooltip>
|
||||
) : "—",
|
||||
},
|
||||
{
|
||||
title: "操作", key: "action", width: 100, fixed: "right",
|
||||
render: (_: unknown, r) => (
|
||||
<Button size="small" icon={<EditOutlined />} onClick={() => openEdit(r)}>编辑</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 16 }}>
|
||||
<div>
|
||||
<Title level={4} style={{ margin: 0 }}>AI 触发器设置</Title>
|
||||
<Paragraph type="secondary" style={{ margin: 0, fontSize: 13 }}>
|
||||
管理 <code>biz.trigger_jobs</code> 中 AI 相关触发器,支持启停与 cron 修改。修改立即生效。
|
||||
</Paragraph>
|
||||
</div>
|
||||
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>刷新</Button>
|
||||
</div>
|
||||
|
||||
<Card size="small">
|
||||
<Table<TriggerItem>
|
||||
columns={columns}
|
||||
dataSource={items}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
size="small"
|
||||
scroll={{ x: 1200 }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={editing ? `编辑触发器 #${editing.id}` : ""}
|
||||
open={!!editing}
|
||||
onCancel={() => setEditing(null)}
|
||||
onOk={handleSave}
|
||||
confirmLoading={saving}
|
||||
okText="保存" cancelText="取消"
|
||||
width={600}
|
||||
>
|
||||
{editing && (
|
||||
<>
|
||||
<Descriptions size="small" column={1} bordered style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="触发器名">{editing.job_name}</Descriptions.Item>
|
||||
<Descriptions.Item label="类型">
|
||||
<Tag color={CONDITION_COLOR[editing.trigger_condition] ?? "default"}>
|
||||
{editing.trigger_condition}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
{editing.trigger_condition === "event" && (
|
||||
<Descriptions.Item label="事件名">
|
||||
<code>{String(editing.trigger_config?.event_name || "—")}</code>
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
</Descriptions>
|
||||
|
||||
{editing.trigger_condition === "cron" && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ marginBottom: 4, fontWeight: 500 }}>Cron 表达式</div>
|
||||
<Input
|
||||
value={editCron}
|
||||
onChange={(e) => setEditCron(e.target.value)}
|
||||
placeholder="标准 5 段 cron,例如 0 10 * * *"
|
||||
/>
|
||||
<Paragraph type="secondary" style={{ fontSize: 12, margin: "4px 0 0 0" }}>
|
||||
格式:<code>分 时 日 月 周</code>。示例:<code>0 10 * * *</code>(每日 10:00)、<code>*/30 * * * *</code>(每 30 分钟)。
|
||||
</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div style={{ marginBottom: 4, fontWeight: 500 }}>描述</div>
|
||||
<Input.TextArea
|
||||
value={editDesc}
|
||||
onChange={(e) => setEditDesc(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AITriggers;
|
||||
335
apps/admin-web/src/pages/RuntimeContext.tsx
Normal file
335
apps/admin-web/src/pages/RuntimeContext.tsx
Normal file
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* 业务运行上下文 / 沙箱设置页面。
|
||||
*
|
||||
* 仅限超级管理员:列出门店当前模式,支持切换到 sandbox 指定历史日期或切回 live。
|
||||
* 切换会按 site_id 暂停或恢复 biz.trigger_jobs,确保多门店隔离。
|
||||
*/
|
||||
|
||||
import React, { useEffect, useMemo, useState, useCallback } from "react";
|
||||
import {
|
||||
Alert, Button, Card, DatePicker, Form, Input, Modal, Popconfirm, Space,
|
||||
Switch, Table, Tag, Tooltip, Typography, message,
|
||||
} from "antd";
|
||||
import { ReloadOutlined, SwapOutlined } from "@ant-design/icons";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import dayjs, { type Dayjs } from "dayjs";
|
||||
import {
|
||||
fetchRuntimeSites, switchRuntimeContext,
|
||||
type RuntimeSiteItem, type RuntimeMode, type RuntimeTransitionStep,
|
||||
} from "../api/runtimeContext";
|
||||
import { useAuthStore } from "../store/authStore";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
interface SwitchFormValues {
|
||||
mode: RuntimeMode;
|
||||
sandbox_date: Dayjs | null;
|
||||
reset_sandbox: boolean;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
const stepStatusColor: Record<RuntimeTransitionStep["status"], string> = {
|
||||
success: "green",
|
||||
skipped: "default",
|
||||
warning: "orange",
|
||||
failed: "red",
|
||||
};
|
||||
|
||||
const RuntimeContextPage: React.FC = () => {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isSuperAdmin = user?.roles?.includes("super_admin") ?? false;
|
||||
|
||||
const [sites, setSites] = useState<RuntimeSiteItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [switchTarget, setSwitchTarget] = useState<RuntimeSiteItem | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [stepsModal, setStepsModal] = useState<{ siteName: string; steps: RuntimeTransitionStep[] } | null>(null);
|
||||
const [form] = Form.useForm<SwitchFormValues>();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await fetchRuntimeSites();
|
||||
setSites(data);
|
||||
} catch {
|
||||
message.error("加载门店运行上下文失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSuperAdmin) {
|
||||
load();
|
||||
}
|
||||
}, [isSuperAdmin, load]);
|
||||
|
||||
const openSwitch = (record: RuntimeSiteItem, mode: RuntimeMode) => {
|
||||
setSwitchTarget(record);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
mode,
|
||||
sandbox_date: record.sandbox_date ? dayjs(record.sandbox_date) : null,
|
||||
reset_sandbox: true,
|
||||
reason: "",
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!switchTarget) return;
|
||||
let values: SwitchFormValues;
|
||||
try {
|
||||
values = await form.validateFields();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const resp = await switchRuntimeContext({
|
||||
site_id: switchTarget.site_id,
|
||||
mode: values.mode,
|
||||
sandbox_date: values.mode === "sandbox" ? values.sandbox_date?.format("YYYY-MM-DD") : null,
|
||||
reset_sandbox: values.mode === "sandbox" ? values.reset_sandbox : true,
|
||||
reason: values.reason || null,
|
||||
});
|
||||
message.success(values.mode === "sandbox" ? "已切换为沙箱模式" : "已切回 live 模式");
|
||||
setStepsModal({
|
||||
siteName: switchTarget.site_name || `#${switchTarget.site_id}`,
|
||||
steps: resp.steps,
|
||||
});
|
||||
setSwitchTarget(null);
|
||||
form.resetFields();
|
||||
load();
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail;
|
||||
message.error(msg || "切换失败");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<RuntimeSiteItem> = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: "门店",
|
||||
key: "site",
|
||||
width: 200,
|
||||
render: (_: unknown, r) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Text strong>{r.site_name || `#${r.site_id}`}</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{r.site_code ? `${r.site_code} · ` : ""}site_id={r.site_id}
|
||||
</Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "运行模式",
|
||||
key: "mode",
|
||||
width: 140,
|
||||
render: (_: unknown, r) => {
|
||||
const mode = (r.mode ?? "live") as RuntimeMode;
|
||||
return mode === "sandbox" ? (
|
||||
<Tag color="purple">沙箱模式</Tag>
|
||||
) : (
|
||||
<Tag color="blue">正式 live</Tag>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "业务日期",
|
||||
key: "business_date",
|
||||
width: 160,
|
||||
render: (_: unknown, r) =>
|
||||
r.mode === "sandbox" && r.sandbox_date ? (
|
||||
<Tooltip title="沙箱模拟的业务日期">
|
||||
<Tag color="purple">{r.sandbox_date}</Tag>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Text type="secondary">真实日期</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "沙箱实例",
|
||||
dataIndex: "sandbox_instance_id",
|
||||
key: "sandbox_instance_id",
|
||||
width: 240,
|
||||
render: (v: string | null) => (v ? <Text code>{v}</Text> : "—"),
|
||||
},
|
||||
{
|
||||
title: "AI 模式",
|
||||
dataIndex: "ai_mode",
|
||||
key: "ai_mode",
|
||||
width: 100,
|
||||
render: (v: string | null) => v ?? "live",
|
||||
},
|
||||
{
|
||||
title: "更新时间",
|
||||
dataIndex: "updated_at",
|
||||
key: "updated_at",
|
||||
width: 170,
|
||||
render: (v: string | null) => (v ? dayjs(v).format("YYYY-MM-DD HH:mm") : "—"),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
fixed: "right",
|
||||
width: 220,
|
||||
render: (_: unknown, r) => {
|
||||
const mode = (r.mode ?? "live") as RuntimeMode;
|
||||
if (mode === "sandbox") {
|
||||
return (
|
||||
<Space>
|
||||
<Button size="small" icon={<SwapOutlined />} onClick={() => openSwitch(r, "sandbox")}>
|
||||
调整沙箱
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确认切回 live 模式?"
|
||||
description="将恢复该门店触发器并清理沙箱状态。"
|
||||
okText="切回 live"
|
||||
cancelText="取消"
|
||||
onConfirm={() => openSwitch(r, "live")}
|
||||
>
|
||||
<Button size="small" danger>
|
||||
切回 live
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button size="small" type="primary" icon={<SwapOutlined />} onClick={() => openSwitch(r, "sandbox")}>
|
||||
进入沙箱
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[form],
|
||||
);
|
||||
|
||||
if (!isSuperAdmin) {
|
||||
return (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="无权限"
|
||||
description="业务运行上下文/沙箱设置仅对超级管理员开放。"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const target = switchTarget;
|
||||
const targetMode = Form.useWatch("mode", form) ?? "live";
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={<Title level={4} style={{ margin: 0 }}>业务运行上下文 / 沙箱</Title>}
|
||||
extra={
|
||||
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>
|
||||
刷新
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message="按门店隔离的业务时钟"
|
||||
description={
|
||||
<Space direction="vertical" size={2}>
|
||||
<Text>live 模式:使用真实系统日期,正常生产逻辑。</Text>
|
||||
<Text>
|
||||
sandbox 模式:业务上假设是 sandbox_date,按 sandbox_instance_id 隔离写入;
|
||||
切换会终止当前 ETL、取消未完成 AI 触发记录,但不会暂停全局 biz.trigger_jobs(多门店共用)。
|
||||
</Text>
|
||||
<Text type="secondary">
|
||||
真实预算、AI tokens 计费、运行日志写入时间、调度元数据仍按真实系统时间,不受沙箱影响。
|
||||
</Text>
|
||||
<Text type="warning">
|
||||
本次改造目标是让看板 / 任务 / 会员 / AI 等数据读取也按 sandbox_date 截断,
|
||||
进度详见 <Text code>docs/database/changes/2026-05-02__sandbox_no_future_data_plan.md</Text>。
|
||||
</Text>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
<Table
|
||||
rowKey="site_id"
|
||||
loading={loading}
|
||||
dataSource={sites}
|
||||
columns={columns}
|
||||
size="middle"
|
||||
pagination={false}
|
||||
scroll={{ x: 1100 }}
|
||||
/>
|
||||
<Modal
|
||||
open={!!target}
|
||||
title={target ? `切换运行上下文 — ${target.site_name || `#${target.site_id}`}` : ""}
|
||||
onCancel={() => {
|
||||
if (!submitting) {
|
||||
setSwitchTarget(null);
|
||||
form.resetFields();
|
||||
}
|
||||
}}
|
||||
onOk={handleSubmit}
|
||||
okText="确认切换"
|
||||
cancelText="取消"
|
||||
confirmLoading={submitting}
|
||||
destroyOnClose
|
||||
width={640}
|
||||
>
|
||||
<Form
|
||||
layout="vertical"
|
||||
form={form}
|
||||
initialValues={{ mode: "sandbox", reset_sandbox: true, reason: "" }}
|
||||
>
|
||||
<Form.Item label="目标模式" name="mode" rules={[{ required: true }]}>
|
||||
<Input disabled />
|
||||
</Form.Item>
|
||||
{targetMode === "sandbox" && (
|
||||
<>
|
||||
<Form.Item
|
||||
label="沙箱业务日期"
|
||||
name="sandbox_date"
|
||||
rules={[{ required: true, message: "沙箱模式需要选择历史业务日期" }]}
|
||||
>
|
||||
<DatePicker
|
||||
style={{ width: "100%" }}
|
||||
disabledDate={(d) => d && d.isAfter(dayjs(), "day")}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="重置沙箱实例" name="reset_sandbox" valuePropName="checked">
|
||||
<Switch checkedChildren="新实例" unCheckedChildren="沿用原实例" />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
<Form.Item label="操作原因(可选)" name="reason">
|
||||
<Input.TextArea rows={2} maxLength={500} showCount placeholder="例如:演示按 2026-03-15 重放任务分发" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
<Modal
|
||||
open={!!stepsModal}
|
||||
title={stepsModal ? `切换执行结果 — ${stepsModal.siteName}` : ""}
|
||||
onCancel={() => setStepsModal(null)}
|
||||
onOk={() => setStepsModal(null)}
|
||||
okText="知道了"
|
||||
cancelButtonProps={{ style: { display: "none" } }}
|
||||
width={640}
|
||||
destroyOnClose
|
||||
>
|
||||
<Space direction="vertical" size={8} style={{ width: "100%" }}>
|
||||
{(stepsModal?.steps ?? []).map((s) => (
|
||||
<div key={s.key}>
|
||||
<Tag color={stepStatusColor[s.status]}>{s.title}</Tag>
|
||||
{s.count ? <Text type="secondary">影响 {s.count} 条</Text> : null}
|
||||
<Text style={{ marginLeft: 8 }}>{s.detail}</Text>
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
</Modal>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default RuntimeContextPage;
|
||||
@@ -6,7 +6,7 @@
|
||||
* - destroyInactiveTabPane={false} 保持 Tab 状态不丢失
|
||||
* - "全部"Tab 调用 fetchUnifiedTriggers(),展示统一字段表格
|
||||
* - "业务"Tab 复用 TriggerJobs 组件 + 编辑 Modal
|
||||
* - "AI"Tab 复用 AIOperations + AITriggerJobs 组件
|
||||
* - "AI"Tab 复用 AITriggers(触发器设置)+ AIOperations + AITriggerJobs 组件
|
||||
* - "ETL"Tab 展示 scheduled_tasks 数据
|
||||
*
|
||||
* CHANGE 2026-07-15 | Task 10.1:创建 TriggerManager 页面
|
||||
@@ -37,6 +37,7 @@ import { fetchSchedules } from '../api/schedules';
|
||||
import type { ScheduledTask } from '../types';
|
||||
import AIOperations from './AIOperations';
|
||||
import AITriggerJobs from './AITriggerJobs';
|
||||
import AITriggers from './AITriggers';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
@@ -319,6 +320,7 @@ const BizTriggersTab: React.FC = () => {
|
||||
|
||||
const AITriggersTab: React.FC = () => (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
||||
<AITriggers />
|
||||
<AIOperations />
|
||||
<AITriggerJobs />
|
||||
</Space>
|
||||
|
||||
Reference in New Issue
Block a user