Files
Neo-ZQYY/apps/admin-web/src/pages/AITriggerJobs.tsx
Neo 6f8f12314f 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>
2026-04-06 00:03:48 +08:00

248 lines
9.3 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.
/**
* AI 调度状态页面。
*
* - 顶部筛选器event_type / status / site_id / 日期范围
* - 统计行:今日去重跳过数
* - 主体:分页表格(事件类型、会员、状态、执行链、耗时、操作列)
* - 操作列:查看详情 Modal、手动重跑 Popconfirm
*/
import React, { useEffect, useState, useCallback } from "react";
import {
Card, Table, Tag, Select, Button, DatePicker, Row, Col, Space,
Statistic, Modal, Popconfirm, Descriptions, message, Typography,
} from "antd";
import { ReloadOutlined } from "@ant-design/icons";
import type { ColumnsType } from "antd/es/table";
import {
getTriggerJobs, getTriggerJobDetail, retryTriggerJob,
type TriggerJobItem, type TriggerJobDetailResponse, type TriggerJobQuery,
} from "../api/adminAI";
const { RangePicker } = DatePicker;
const { Title } = Typography;
const STATUS_COLOR: Record<string, string> = {
pending: "default", running: "processing", success: "success",
failed: "error", skipped_duplicate: "warning", timeout: "orange",
};
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 calcDuration(start: string | null, end: string | null): string {
if (!start || !end) return "—";
const ms = new Date(end).getTime() - new Date(start).getTime();
if (Number.isNaN(ms) || ms < 0) return "—";
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
const AITriggerJobs: React.FC = () => {
const [items, setItems] = useState<TriggerJobItem[]>([]);
const [total, setTotal] = useState(0);
const [skipped, setSkipped] = useState(0);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [pageSize] = useState(20);
// 筛选状态
const [eventType, setEventType] = useState<string | undefined>();
const [status, setStatus] = useState<string | undefined>();
const [siteId, setSiteId] = useState<number | undefined>();
const [dateRange, setDateRange] = useState<[string, string] | null>(null);
// 详情 Modal
const [detailVisible, setDetailVisible] = useState(false);
const [detail, setDetail] = useState<TriggerJobDetailResponse | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const params: TriggerJobQuery = {
page, page_size: pageSize,
event_type: eventType, status, site_id: siteId,
date_from: dateRange?.[0], date_to: dateRange?.[1],
};
const res = await getTriggerJobs(params);
setItems(res.items);
setTotal(res.total);
setSkipped(res.today_skipped_duplicates);
} catch {
message.error("加载调度任务失败");
} finally {
setLoading(false);
}
}, [page, pageSize, eventType, status, siteId, dateRange]);
useEffect(() => { load(); }, [load]);
const handleViewDetail = async (id: number) => {
setDetailLoading(true);
setDetailVisible(true);
try {
const res = await getTriggerJobDetail(id);
setDetail(res);
} catch {
message.error("加载详情失败");
} finally {
setDetailLoading(false);
}
};
const handleRetry = async (id: number) => {
try {
const res = await retryTriggerJob(id);
message.success(`已创建重跑任务 #${res.trigger_job_id}`);
load();
} catch {
message.error("重跑失败");
}
};
const columns: ColumnsType<TriggerJobItem> = [
{ title: "ID", dataIndex: "id", key: "id", width: 70 },
{ title: "事件类型", dataIndex: "event_type", key: "event_type", width: 140 },
{ title: "会员 ID", dataIndex: "member_id", key: "member_id", width: 100, render: (v) => v ?? "—" },
{
title: "状态", dataIndex: "status", key: "status", width: 120,
render: (v: string) => <Tag color={STATUS_COLOR[v] ?? "default"}>{v}</Tag>,
},
{ title: "执行链", dataIndex: "app_chain", key: "app_chain", ellipsis: true, render: (v) => v ?? "—" },
{
title: "强制执行", dataIndex: "is_forced", key: "is_forced", width: 80,
render: (v: boolean) => v ? <Tag color="blue"></Tag> : "否",
},
{
title: "耗时", key: "duration", width: 90,
render: (_: unknown, r: TriggerJobItem) => calcDuration(r.started_at, r.finished_at),
},
{ title: "创建时间", dataIndex: "created_at", key: "created_at", width: 170, render: fmtTime },
{
title: "操作", key: "action", width: 160, fixed: "right" as const,
render: (_: unknown, r: TriggerJobItem) => (
<Space>
<Button size="small" onClick={() => handleViewDetail(r.id)}></Button>
<Popconfirm title="确认手动重跑此任务?" onConfirm={() => handleRetry(r.id)}>
<Button size="small" type="link" danger></Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<Row justify="space-between" align="middle" style={{ marginBottom: 16 }}>
<Title level={4} style={{ margin: 0 }}>AI </Title>
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}></Button>
</Row>
{/* 筛选器行 */}
<Card size="small" style={{ marginBottom: 16 }}>
<Space wrap>
<Select
allowClear placeholder="事件类型" style={{ width: 160 }}
value={eventType} onChange={(v) => { setEventType(v); setPage(1); }}
options={[
{ label: "consumption", value: "consumption" },
{ label: "note", value: "note" },
{ label: "task_assign", value: "task_assign" },
{ label: "coach_consumption", value: "coach_consumption" },
{ label: "scheduled", value: "scheduled" },
]}
/>
<Select
allowClear placeholder="状态" style={{ width: 140 }}
value={status} onChange={(v) => { setStatus(v); setPage(1); }}
options={[
{ label: "pending", value: "pending" },
{ label: "running", value: "running" },
{ label: "success", value: "success" },
{ label: "failed", value: "failed" },
{ label: "skipped_duplicate", value: "skipped_duplicate" },
]}
/>
<Select
allowClear placeholder="门店" style={{ width: 180 }}
value={siteId} onChange={(v) => { setSiteId(v); setPage(1); }}
options={[{ label: "默认门店", value: 2790685415443269 }]}
/>
<RangePicker
onChange={(_, dateStrings) => {
setDateRange(dateStrings[0] ? [dateStrings[0], dateStrings[1]] : null);
setPage(1);
}}
/>
</Space>
</Card>
{/* 统计行 */}
<Row style={{ marginBottom: 16 }}>
<Col>
<Statistic title="今日去重跳过数" value={skipped} />
</Col>
</Row>
{/* 主体表格 */}
<Table<TriggerJobItem>
columns={columns}
dataSource={items}
rowKey="id"
loading={loading}
scroll={{ x: 1100 }}
pagination={{
current: page, pageSize, total,
onChange: (p) => setPage(p),
showTotal: (t) => `${t}`,
}}
/>
{/* 详情 Modal */}
<Modal
title={`调度任务详情 #${detail?.id ?? ""}`}
open={detailVisible}
onCancel={() => { setDetailVisible(false); setDetail(null); }}
footer={null} width={640}
loading={detailLoading}
>
{detail && (
<Descriptions column={2} bordered size="small">
<Descriptions.Item label="事件类型">{detail.event_type}</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={STATUS_COLOR[detail.status] ?? "default"}>{detail.status}</Tag>
</Descriptions.Item>
<Descriptions.Item label="会员 ID">{detail.member_id ?? "—"}</Descriptions.Item>
<Descriptions.Item label="门店 ID">{detail.site_id}</Descriptions.Item>
<Descriptions.Item label="执行链">{detail.app_chain ?? "—"}</Descriptions.Item>
<Descriptions.Item label="连接器">{detail.connector_type}</Descriptions.Item>
<Descriptions.Item label="强制执行">{detail.is_forced ? "是" : "否"}</Descriptions.Item>
<Descriptions.Item label="耗时">{calcDuration(detail.started_at, detail.finished_at)}</Descriptions.Item>
<Descriptions.Item label="创建时间" span={2}>{fmtTime(detail.created_at)}</Descriptions.Item>
{detail.error_message && (
<Descriptions.Item label="错误信息" span={2}>
<pre style={{ margin: 0, whiteSpace: "pre-wrap", maxHeight: 200, overflow: "auto" }}>
{detail.error_message}
</pre>
</Descriptions.Item>
)}
{detail.payload && (
<Descriptions.Item label="Payload" span={2}>
<pre style={{ margin: 0, whiteSpace: "pre-wrap", maxHeight: 200, overflow: "auto" }}>
{JSON.stringify(detail.payload, null, 2)}
</pre>
</Descriptions.Item>
)}
</Descriptions>
)}
</Modal>
</div>
);
};
export default AITriggerJobs;