Files
Neo-ZQYY/apps/admin-web/src/pages/TriggerManager.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

465 lines
15 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.
/**
* 触发器统一管理页面 — 聚合 biz / ai / etl 三类触发器为 Tab 视图。
*
* - 4 个 Taball全部只读统一视图、biz业务、aiAI、etlETL
* - 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>
);
};
/* ───────── "业务"TabTriggerJobs + 编辑 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"TabAIOperations + AITriggerJobs ───────── */
const AITriggersTab: React.FC = () => (
<Space direction="vertical" style={{ width: '100%' }} size="large">
<AITriggers />
<AIOperations />
<AITriggerJobs />
</Space>
);
/* ───────── "ETL"Tabscheduled_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;