涵盖(每条对应已存的审计记录): - 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 逐一处理
465 lines
15 KiB
TypeScript
465 lines
15 KiB
TypeScript
/**
|
||
* 触发器统一管理页面 — 聚合 biz / ai / etl 三类触发器为 Tab 视图。
|
||
*
|
||
* - 4 个 Tab:all(全部,只读统一视图)、biz(业务)、ai(AI)、etl(ETL)
|
||
* - Tab 切换通过 useSearchParams 同步 URL 查询参数 ?tab=all|biz|ai|etl
|
||
* - destroyInactiveTabPane={false} 保持 Tab 状态不丢失
|
||
* - "全部"Tab 调用 fetchUnifiedTriggers(),展示统一字段表格
|
||
* - "业务"Tab 复用 TriggerJobs 组件 + 编辑 Modal
|
||
* - "AI"Tab 复用 AITriggers(触发器设置)+ AIOperations + AITriggerJobs 组件
|
||
* - "ETL"Tab 展示 scheduled_tasks 数据
|
||
*
|
||
* CHANGE 2026-07-15 | Task 10.1:创建 TriggerManager 页面
|
||
*/
|
||
|
||
import React, { useMemo, useState, useEffect, useCallback } from 'react';
|
||
import {
|
||
Tabs, Typography, Table, Tag, message, Modal, Form, Input, InputNumber, Space,
|
||
Button, Card,
|
||
} from 'antd';
|
||
import {
|
||
AppstoreOutlined,
|
||
SettingOutlined,
|
||
RobotOutlined,
|
||
CloudServerOutlined,
|
||
EditOutlined,
|
||
ReloadOutlined,
|
||
} from '@ant-design/icons';
|
||
import { useSearchParams } from 'react-router-dom';
|
||
import type { ColumnsType } from 'antd/es/table';
|
||
|
||
import { fetchUnifiedTriggers, type UnifiedTriggerItem } from '../api/triggers';
|
||
import {
|
||
fetchTriggerJobs, updateTriggerConfig,
|
||
type TriggerJob, type UpdateTriggerConfigReq,
|
||
} from '../api/triggerJobs';
|
||
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;
|
||
|
||
/* ───────── Tab 常量 ───────── */
|
||
|
||
const VALID_TABS = ['all', 'biz', 'ai', 'etl'] as const;
|
||
type TabKey = (typeof VALID_TABS)[number];
|
||
const DEFAULT_TAB: TabKey = 'all';
|
||
|
||
function isValidTab(value: string | null): value is TabKey {
|
||
return value != null && (VALID_TABS as readonly string[]).includes(value);
|
||
}
|
||
|
||
/* ───────── 工具函数 ───────── */
|
||
|
||
const SOURCE_COLOR: Record<string, string> = {
|
||
biz: 'blue', ai: 'purple', etl: 'green',
|
||
};
|
||
const SOURCE_LABEL: Record<string, string> = {
|
||
biz: '业务', ai: 'AI', etl: 'ETL',
|
||
};
|
||
|
||
function formatTime(raw: string | null): string {
|
||
if (!raw) return '—';
|
||
const d = new Date(raw);
|
||
return Number.isNaN(d.getTime()) ? raw : d.toLocaleString('zh-CN');
|
||
}
|
||
|
||
/* ───────── "全部"Tab:统一视图(只读) ───────── */
|
||
|
||
const AllTriggersTab: React.FC = () => {
|
||
const [data, setData] = useState<UnifiedTriggerItem[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
|
||
const load = useCallback(async () => {
|
||
setLoading(true);
|
||
try {
|
||
setData(await fetchUnifiedTriggers());
|
||
} catch {
|
||
message.error('加载统一触发器数据失败');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => { load(); }, [load]);
|
||
|
||
const columns: ColumnsType<UnifiedTriggerItem> = [
|
||
{ title: '名称', dataIndex: 'name', key: 'name', width: 200 },
|
||
{
|
||
title: '类型', dataIndex: 'source', key: 'source', width: 80,
|
||
render: (v: string) => (
|
||
<Tag color={SOURCE_COLOR[v] ?? 'default'}>{SOURCE_LABEL[v] ?? v}</Tag>
|
||
),
|
||
},
|
||
{ title: '触发条件', dataIndex: 'trigger_condition', key: 'trigger_condition', width: 120 },
|
||
{
|
||
title: '状态', dataIndex: 'status', key: 'status', width: 100,
|
||
render: (v: string) => {
|
||
const color = v === 'running' ? 'processing' : v === 'error' ? 'error'
|
||
: v === 'disabled' ? 'default' : 'success';
|
||
return <Tag color={color}>{v}</Tag>;
|
||
},
|
||
},
|
||
{ title: '上次执行', dataIndex: 'last_run_at', key: 'last_run_at', width: 170, render: formatTime },
|
||
{ title: '下次执行', dataIndex: 'next_run_at', key: 'next_run_at', width: 170, render: formatTime },
|
||
{
|
||
title: '最近错误', dataIndex: 'last_error', key: 'last_error', ellipsis: true,
|
||
render: (v: string | null) => v
|
||
? <Typography.Text type="danger" ellipsis style={{ maxWidth: 200 }}>{v}</Typography.Text>
|
||
: '—',
|
||
},
|
||
];
|
||
|
||
return (
|
||
<Card
|
||
size="small"
|
||
extra={<Button icon={<ReloadOutlined />} size="small" onClick={load} loading={loading}>刷新</Button>}
|
||
>
|
||
<Table<UnifiedTriggerItem>
|
||
rowKey={(r) => `${r.source}-${r.id}`}
|
||
columns={columns}
|
||
dataSource={data}
|
||
loading={loading}
|
||
pagination={{ pageSize: 20, showTotal: (t) => `共 ${t} 条` }}
|
||
size="small"
|
||
scroll={{ x: 1000 }}
|
||
/>
|
||
</Card>
|
||
);
|
||
};
|
||
|
||
/* ───────── "业务"Tab:TriggerJobs + 编辑 Modal ───────── */
|
||
|
||
const BizTriggersTab: React.FC = () => {
|
||
const [jobs, setJobs] = useState<TriggerJob[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [editingJob, setEditingJob] = useState<TriggerJob | null>(null);
|
||
const [editModalOpen, setEditModalOpen] = useState(false);
|
||
const [saving, setSaving] = useState(false);
|
||
const [form] = Form.useForm<UpdateTriggerConfigReq>();
|
||
|
||
const load = useCallback(async () => {
|
||
setLoading(true);
|
||
try {
|
||
setJobs(await fetchTriggerJobs());
|
||
} catch {
|
||
message.error('加载业务触发器失败');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => { load(); }, [load]);
|
||
|
||
const openEdit = (job: TriggerJob) => {
|
||
setEditingJob(job);
|
||
const cfg = job.trigger_config ?? {};
|
||
form.setFieldsValue({
|
||
cron_expression: (cfg.cron_expression as string) ?? undefined,
|
||
interval_seconds: (cfg.interval_seconds as number) ?? undefined,
|
||
});
|
||
setEditModalOpen(true);
|
||
};
|
||
|
||
const handleSave = async () => {
|
||
if (!editingJob) return;
|
||
try {
|
||
const values = await form.validateFields();
|
||
// 只发送有值的字段
|
||
const body: UpdateTriggerConfigReq = {};
|
||
if (values.cron_expression != null && values.cron_expression !== '') {
|
||
body.cron_expression = values.cron_expression;
|
||
}
|
||
if (values.interval_seconds != null) {
|
||
body.interval_seconds = values.interval_seconds;
|
||
}
|
||
if (!body.cron_expression && body.interval_seconds == null) {
|
||
message.warning('请至少填写 cron 表达式或间隔秒数');
|
||
return;
|
||
}
|
||
setSaving(true);
|
||
await updateTriggerConfig(editingJob.id, body);
|
||
message.success('触发器配置已更新');
|
||
setEditModalOpen(false);
|
||
setEditingJob(null);
|
||
form.resetFields();
|
||
await load();
|
||
} catch (err: unknown) {
|
||
// 422 错误展示具体信息
|
||
if (err && typeof err === 'object' && 'response' in err) {
|
||
const resp = (err as { response?: { status?: number; data?: { detail?: string } } }).response;
|
||
if (resp?.status === 422 && resp.data?.detail) {
|
||
message.error(resp.data.detail);
|
||
return;
|
||
}
|
||
}
|
||
message.error('保存失败');
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
const TRIGGER_LABEL: Record<string, string> = {
|
||
cron: '定时(Cron)', interval: '间隔', event: '事件触发',
|
||
};
|
||
|
||
const formatTriggerConfig = (job: TriggerJob): string => {
|
||
const cfg = job.trigger_config;
|
||
if (!cfg) return '—';
|
||
if (job.trigger_condition === 'cron') return (cfg.cron_expression as string) || '—';
|
||
if (job.trigger_condition === 'interval') {
|
||
const sec = cfg.interval_seconds as number;
|
||
if (sec >= 3600) return `每 ${sec / 3600} 小时`;
|
||
if (sec >= 60) return `每 ${sec / 60} 分钟`;
|
||
return `每 ${sec} 秒`;
|
||
}
|
||
if (job.trigger_condition === 'event') return `事件: ${cfg.event_name || '—'}`;
|
||
return JSON.stringify(cfg);
|
||
};
|
||
|
||
const columns: ColumnsType<TriggerJob> = [
|
||
{
|
||
title: '任务名称', dataIndex: 'job_name', key: 'job_name', width: 180,
|
||
render: (name: string, record) => (
|
||
<>
|
||
<Typography.Text strong>{record.description || name}</Typography.Text>
|
||
<br />
|
||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{name}</Typography.Text>
|
||
</>
|
||
),
|
||
},
|
||
{
|
||
title: '触发方式', dataIndex: 'trigger_condition', key: 'trigger_condition', width: 120,
|
||
render: (v: string) => <Tag>{TRIGGER_LABEL[v] || v}</Tag>,
|
||
},
|
||
{
|
||
title: '触发配置', key: 'trigger_config', width: 150,
|
||
render: (_: unknown, record) => <code style={{ fontSize: 12 }}>{formatTriggerConfig(record)}</code>,
|
||
},
|
||
{
|
||
title: '状态', dataIndex: 'status', key: 'status', width: 80,
|
||
render: (v: string) => (
|
||
<Tag color={v === 'enabled' ? 'green' : 'default'}>{v === 'enabled' ? '启用' : '禁用'}</Tag>
|
||
),
|
||
},
|
||
{ title: '上次执行', dataIndex: 'last_run_at', key: 'last_run_at', width: 170, render: formatTime },
|
||
{ title: '下次执行', dataIndex: 'next_run_at', key: 'next_run_at', width: 170, render: formatTime },
|
||
{
|
||
title: '最近错误', dataIndex: 'last_error', key: 'last_error', width: 200,
|
||
render: (v: string | null) => v
|
||
? <Typography.Text type="danger" ellipsis style={{ maxWidth: 180 }}>{v}</Typography.Text>
|
||
: <Typography.Text type="success">正常</Typography.Text>,
|
||
},
|
||
{
|
||
title: '操作', key: 'action', width: 80, fixed: 'right',
|
||
render: (_: unknown, record) => (
|
||
<Button
|
||
size="small"
|
||
icon={<EditOutlined />}
|
||
onClick={() => openEdit(record)}
|
||
disabled={record.status !== 'enabled'}
|
||
>
|
||
编辑
|
||
</Button>
|
||
),
|
||
},
|
||
];
|
||
|
||
return (
|
||
<>
|
||
<Card
|
||
size="small"
|
||
extra={<Button icon={<ReloadOutlined />} size="small" onClick={load} loading={loading}>刷新</Button>}
|
||
>
|
||
<Table<TriggerJob>
|
||
rowKey="id"
|
||
columns={columns}
|
||
dataSource={jobs}
|
||
loading={loading}
|
||
pagination={false}
|
||
size="small"
|
||
scroll={{ x: 1200 }}
|
||
/>
|
||
</Card>
|
||
|
||
<Modal
|
||
title={`编辑触发器配置 — ${editingJob?.description || editingJob?.job_name || ''}`}
|
||
open={editModalOpen}
|
||
onCancel={() => { setEditModalOpen(false); setEditingJob(null); form.resetFields(); }}
|
||
onOk={handleSave}
|
||
confirmLoading={saving}
|
||
okText="保存"
|
||
cancelText="取消"
|
||
destroyOnClose
|
||
>
|
||
<Form form={form} layout="vertical">
|
||
<Form.Item
|
||
name="cron_expression"
|
||
label="Cron 表达式(5 字段格式)"
|
||
help="例如:0 */2 * * *(每 2 小时执行)"
|
||
>
|
||
<Input placeholder="分 时 日 月 周" />
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="interval_seconds"
|
||
label="间隔秒数"
|
||
help="最小值为 1"
|
||
rules={[{ type: 'number', min: 1, message: 'interval_seconds 必须 >= 1' }]}
|
||
>
|
||
<InputNumber style={{ width: '100%' }} min={1} placeholder="秒" />
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
</>
|
||
);
|
||
};
|
||
|
||
/* ───────── "AI"Tab:AIOperations + AITriggerJobs ───────── */
|
||
|
||
const AITriggersTab: React.FC = () => (
|
||
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
||
<AITriggers />
|
||
<AIOperations />
|
||
<AITriggerJobs />
|
||
</Space>
|
||
);
|
||
|
||
/* ───────── "ETL"Tab:scheduled_tasks 数据 ───────── */
|
||
|
||
const ETLTriggersTab: React.FC = () => {
|
||
const [data, setData] = useState<ScheduledTask[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
|
||
const load = useCallback(async () => {
|
||
setLoading(true);
|
||
try {
|
||
setData(await fetchSchedules());
|
||
} catch {
|
||
message.error('加载 ETL 调度任务失败');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => { load(); }, [load]);
|
||
|
||
const columns: ColumnsType<ScheduledTask> = [
|
||
{ title: '名称', dataIndex: 'name', key: 'name', width: 200 },
|
||
{
|
||
title: '任务代码', dataIndex: 'task_codes', key: 'task_codes', width: 200,
|
||
render: (v: string[]) => v?.join(', ') ?? '—',
|
||
},
|
||
{
|
||
title: '状态', dataIndex: 'enabled', key: 'enabled', width: 80,
|
||
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '禁用'}</Tag>,
|
||
},
|
||
{
|
||
title: '上次状态', dataIndex: 'last_status', key: 'last_status', width: 100,
|
||
render: (v: string | null) => v
|
||
? <Tag color={v === 'success' ? 'success' : v === 'failed' ? 'error' : 'default'}>{v}</Tag>
|
||
: '—',
|
||
},
|
||
{ title: '上次执行', dataIndex: 'last_run_at', key: 'last_run_at', width: 170, render: formatTime },
|
||
{ title: '下次执行', dataIndex: 'next_run_at', key: 'next_run_at', width: 170, render: formatTime },
|
||
{ title: '执行次数', dataIndex: 'run_count', key: 'run_count', width: 90 },
|
||
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 170, render: formatTime },
|
||
];
|
||
|
||
return (
|
||
<Card
|
||
size="small"
|
||
extra={<Button icon={<ReloadOutlined />} size="small" onClick={load} loading={loading}>刷新</Button>}
|
||
>
|
||
<Table<ScheduledTask>
|
||
rowKey="id"
|
||
columns={columns}
|
||
dataSource={data}
|
||
loading={loading}
|
||
pagination={{ pageSize: 20, showTotal: (t) => `共 ${t} 条` }}
|
||
size="small"
|
||
scroll={{ x: 1000 }}
|
||
/>
|
||
</Card>
|
||
);
|
||
};
|
||
|
||
/* ───────── 主组件 ───────── */
|
||
|
||
const TriggerManager: React.FC = () => {
|
||
const [searchParams, setSearchParams] = useSearchParams();
|
||
|
||
const activeTab: TabKey = useMemo(() => {
|
||
const raw = searchParams.get('tab');
|
||
return isValidTab(raw) ? raw : DEFAULT_TAB;
|
||
}, [searchParams]);
|
||
|
||
const handleTabChange = (key: string) => {
|
||
setSearchParams({ tab: key }, { replace: true });
|
||
};
|
||
|
||
const items = useMemo(
|
||
() => [
|
||
{
|
||
key: 'all' as TabKey,
|
||
label: (
|
||
<span>
|
||
<AppstoreOutlined style={{ marginRight: 6 }} />
|
||
全部
|
||
</span>
|
||
),
|
||
children: <AllTriggersTab />,
|
||
},
|
||
{
|
||
key: 'biz' as TabKey,
|
||
label: (
|
||
<span>
|
||
<SettingOutlined style={{ marginRight: 6 }} />
|
||
业务
|
||
</span>
|
||
),
|
||
children: <BizTriggersTab />,
|
||
},
|
||
{
|
||
key: 'ai' as TabKey,
|
||
label: (
|
||
<span>
|
||
<RobotOutlined style={{ marginRight: 6 }} />
|
||
AI
|
||
</span>
|
||
),
|
||
children: <AITriggersTab />,
|
||
},
|
||
{
|
||
key: 'etl' as TabKey,
|
||
label: (
|
||
<span>
|
||
<CloudServerOutlined style={{ marginRight: 6 }} />
|
||
ETL
|
||
</span>
|
||
),
|
||
children: <ETLTriggersTab />,
|
||
},
|
||
],
|
||
[],
|
||
);
|
||
|
||
return (
|
||
<div>
|
||
<Title level={4} style={{ marginBottom: 16 }}>
|
||
<SettingOutlined style={{ marginRight: 8 }} />
|
||
触发器管理
|
||
</Title>
|
||
<Tabs
|
||
activeKey={activeTab}
|
||
onChange={handleTabChange}
|
||
items={items}
|
||
destroyInactiveTabPane={false}
|
||
/>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default TriggerManager;
|