feat: 累积功能变更 — 聊天集成、租户管理、小程序更新、ETL 增强、迁移脚本
包含多个会话的累积代码变更: - backend: AI 聊天服务、触发器调度、认证增强、WebSocket、调度器最小间隔 - admin-web: ETL 状态页、任务管理、调度配置、登录优化 - miniprogram: 看板页面、聊天集成、UI 组件、导航更新 - etl: DWS 新任务(finance_area_daily/board_cache)、连接器增强 - tenant-admin: 项目初始化 - db: 19 个迁移脚本(etl_feiqiu 11 + zqyy_app 8) - packages/shared: 枚举和工具函数更新 - tools: 数据库工具、报表生成、健康检查 - docs: PRD/架构/部署/合约文档更新 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
462
apps/admin-web/src/pages/TriggerManager.tsx
Normal file
462
apps/admin-web/src/pages/TriggerManager.tsx
Normal file
@@ -0,0 +1,462 @@
|
||||
/**
|
||||
* 触发器统一管理页面 — 聚合 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 复用 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';
|
||||
|
||||
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">
|
||||
<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;
|
||||
Reference in New Issue
Block a user