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:
Neo
2026-05-04 02:30:19 +08:00
parent 2010034840
commit caf179a5da
130 changed files with 14543 additions and 2717 deletions

View File

@@ -25,6 +25,7 @@ import {
TeamOutlined,
BugOutlined,
ApartmentOutlined,
RobotOutlined,
} from "@ant-design/icons";
import type { MenuProps } from "antd";
import { useAuthStore } from "./store/authStore";
@@ -36,6 +37,10 @@ import EnvConfig from "./pages/EnvConfig";
import DBViewer from "./pages/DBViewer";
import TenantAdmins from "./pages/TenantAdmins";
import AIRunLogs from "./pages/AIRunLogs";
import AIDashboard from "./pages/AIDashboard";
import AIOperations from "./pages/AIOperations";
import AITriggerJobs from "./pages/AITriggerJobs";
import AIPrewarm from "./pages/AIPrewarm";
import DevTrace from "./pages/DevTrace";
import TriggerJobs from "./pages/TriggerJobs";
import TransferLog from "./pages/TransferLog";
@@ -44,6 +49,7 @@ import TaskEngineConfig from "./pages/TaskEngineConfig";
import Dashboard from "./pages/Dashboard";
import ETLTasks from "./pages/ETLTasks";
import TriggerManager from "./pages/TriggerManager";
import RuntimeContextPage from "./pages/RuntimeContext";
const { Sider, Content, Footer } = Layout;
const { Text } = Typography;
@@ -65,11 +71,22 @@ export const NAV_ITEMS: MenuProps["items"] = [
],
},
{ key: "/triggers", icon: <ClockCircleOutlined />, label: "触发器管理" },
{
key: "ai-group", icon: <RobotOutlined />, label: "AI 管理",
children: [
{ key: "/ai/dashboard", label: "总览" },
{ key: "/ai/operations", label: "手动操作" },
{ key: "/ai/prewarm", label: "预热进度" },
{ key: "/triggers?tab=ai", label: "触发器设置" },
{ key: "/ai/trigger-jobs", label: "调度历史" },
],
},
{ key: "/tenant-admins", icon: <TeamOutlined />, label: "租户管理员" },
{
key: "settings-group", icon: <SettingOutlined />, label: "系统设置",
children: [
{ key: "/settings/env-config", label: "环境配置" },
{ key: "/settings/runtime-context", label: "业务运行上下文 / 沙箱" },
{ key: "/triggers?tab=biz", label: "触发器配置" },
],
},
@@ -90,12 +107,14 @@ export const NAV_ITEMS: MenuProps["items"] = [
/** 根据当前路径计算 selectedKeys */
export function getSelectedKeys(pathname: string, search: string): string[] {
const fullPath = pathname + search;
// 精确匹配含查询参数的菜单项(如 /triggers?tab=biz
// 精确匹配含查询参数的菜单项(如 /triggers?tab=biz / ?tab=ai
if (fullPath === "/triggers?tab=biz") return ["/triggers?tab=biz"];
if (fullPath === "/triggers?tab=ai") return ["/triggers?tab=ai"];
// 子路由匹配
if (pathname.startsWith("/task-engine/")) return [pathname];
if (pathname.startsWith("/settings/")) return [pathname];
if (pathname.startsWith("/logs/")) return [pathname];
if (pathname.startsWith("/ai/")) return [pathname];
// 一级路由直接匹配
return [pathname];
}
@@ -106,6 +125,9 @@ export function getDefaultOpenKeys(pathname: string): string[] {
if (pathname.startsWith("/task-engine/")) keys.push("task-engine-group");
if (pathname.startsWith("/settings/")) keys.push("settings-group");
if (pathname.startsWith("/logs/")) keys.push("logs-group");
if (pathname.startsWith("/ai/")) keys.push("ai-group");
// 从 AI 菜单跳过来的"触发器设置"/triggers?tab=ai也展开 ai-group
// 注:此函数参数只接收 pathname无法判断 tab交由路由侧 searchParams 处理默认展开
// 触发器配置跳转入口也需要展开系统设置
if (pathname === "/triggers") keys.push("settings-group");
return keys;
@@ -225,6 +247,13 @@ const AppLayout: React.FC = () => {
{/* 系统设置 */}
<Route path="/settings/env-config" element={<EnvConfig />} />
<Route path="/settings/runtime-context" element={<RuntimeContextPage />} />
{/* AI 管理 */}
<Route path="/ai/dashboard" element={<AIDashboard />} />
<Route path="/ai/operations" element={<AIOperations />} />
<Route path="/ai/prewarm" element={<AIPrewarm />} />
<Route path="/ai/trigger-jobs" element={<AITriggerJobs />} />
{/* 日志调试 */}
<Route path="/logs/dev-trace" element={<DevTrace />} />

View File

@@ -0,0 +1,43 @@
/**
* 回归测试admin-web 手动执行 App 类型必须与后端 /api/admin/ai/run/{app_type} 对齐。
*
* 缓存类型仍使用 `*_analysis` / `*_consolidated`,但手动执行和 run log
* 应使用 dispatcher 支持的 app_type避免前端发出后端 400 的路径。
*/
import { describe, expect, it } from "vitest";
import { RUN_APP_TYPES } from "../api/adminAI";
import { CACHE_TYPE_OPTIONS, RUN_APP_TYPE_OPTIONS } from "../pages/AIOperations";
import { RUN_LOG_APP_TYPE_OPTIONS } from "../pages/AIRunLogs";
describe("admin AI app_type 对齐", () => {
it("手动执行类型使用后端支持的 app_type而不是缓存类型", () => {
const apiTypes = [...RUN_APP_TYPES];
const runOptionValues = RUN_APP_TYPE_OPTIONS.map((item) => item.value);
for (const appType of ["app6_note", "app7_customer", "app8_consolidation"]) {
expect(apiTypes).toContain(appType);
expect(runOptionValues).toContain(appType);
}
for (const cacheType of ["app6_note_analysis", "app7_customer_analysis", "app8_clue_consolidated"]) {
expect(runOptionValues).not.toContain(cacheType);
}
});
it("缓存失效继续使用 cache_type避免破坏已有缓存管理", () => {
const cacheOptionValues = CACHE_TYPE_OPTIONS.map((item) => item.value);
expect(cacheOptionValues).toContain("app6_note_analysis");
expect(cacheOptionValues).toContain("app7_customer_analysis");
expect(cacheOptionValues).toContain("app8_clue_consolidated");
});
it("调用记录筛选包含真实写入 ai_run_logs 的 app_type", () => {
const runLogOptionValues = RUN_LOG_APP_TYPE_OPTIONS.map((item) => item.value);
expect(runLogOptionValues).toContain("app6_note");
expect(runLogOptionValues).toContain("app7_customer");
expect(runLogOptionValues).toContain("app8_consolidate");
});
});

View File

@@ -9,29 +9,30 @@ import { apiClient } from "./client";
// ---- 公共类型 ----
export const RUN_APP_TYPES = [
"app2_finance",
"app2a_finance_area",
"app3_clue",
"app4_analysis",
"app5_tactics",
"app6_note",
"app7_customer",
"app8_consolidation",
] as const;
/**
* AI APP 类型联合(与后端 `CacheTypeEnum` / `_SUPPORTED_APP_TYPES` 同步)。
* 按需执行 App 类型联合(与后端 `/api/admin/ai/run/{app_type}` 同步)。
*
* - 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 · 线索整合
* - app6_note · 备注分析
* - app7_customer · 客户综合分析
* - app8_consolidation · 线索整合
*/
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";
export type AppType = (typeof RUN_APP_TYPES)[number];
// ---- 类型定义 ----

View File

@@ -0,0 +1,78 @@
/**
* 业务运行上下文 / 沙箱配置 API。
*/
import { apiClient } from "./client";
export type RuntimeMode = "live" | "sandbox";
export type AIMode = "live";
export type RuntimeStepStatus = "success" | "skipped" | "warning" | "failed";
export interface RuntimeContext {
site_id: number;
mode: RuntimeMode;
business_day_start_hour: number;
business_date: string;
business_now: string;
sandbox_date: string | null;
sandbox_instance_id: string | null;
ai_mode: AIMode;
status: string;
is_sandbox: boolean;
}
export interface RuntimeSiteItem {
site_id: number;
site_name: string | null;
site_code: string | null;
is_active: boolean;
mode: RuntimeMode | null;
sandbox_date: string | null;
sandbox_instance_id: string | null;
ai_mode: AIMode | null;
status: string | null;
updated_at: string | null;
}
export interface RuntimeSwitchRequest {
site_id: number;
mode: RuntimeMode;
sandbox_date?: string | null;
reset_sandbox?: boolean;
reason?: string | null;
}
export interface RuntimeTransitionStep {
key: string;
title: string;
status: RuntimeStepStatus;
detail: string;
count: number;
}
export interface RuntimeSwitchResponse {
context: RuntimeContext;
steps: RuntimeTransitionStep[];
}
export async function fetchRuntimeSites(): Promise<RuntimeSiteItem[]> {
const { data } = await apiClient.get<RuntimeSiteItem[]>("/admin/runtime-context/sites");
return data;
}
export async function fetchRuntimeContext(siteId: number): Promise<RuntimeContext> {
const { data } = await apiClient.get<RuntimeContext>("/admin/runtime-context", {
params: { site_id: siteId },
});
return data;
}
export async function switchRuntimeContext(
payload: RuntimeSwitchRequest,
): Promise<RuntimeSwitchResponse> {
const { data } = await apiClient.patch<RuntimeSwitchResponse>(
"/admin/runtime-context",
payload,
);
return data;
}

View File

@@ -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}
/>

View File

@@ -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>

View File

@@ -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 }));

View 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;

View 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;

View File

@@ -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>