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:
217
apps/admin-web/src/pages/AIDashboard.tsx
Normal file
217
apps/admin-web/src/pages/AIDashboard.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* AI 运行总览 Dashboard 页面。
|
||||
*
|
||||
* - 顶部:门店筛选 + 刷新
|
||||
* - 第一行:4 个统计卡片(今日调用、成功率、Token 消耗、平均延迟)
|
||||
* - 第二行:7 天趋势表格 + App 调用占比表格
|
||||
* - 第三行:Token 预算进度条 + App 健康状态
|
||||
* - 第四行:告警列表
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import {
|
||||
Card, Row, Col, Statistic, Table, Tag, Badge, Progress,
|
||||
Select, Button, message, Typography, Space,
|
||||
} from "antd";
|
||||
import { ReloadOutlined } from "@ant-design/icons";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import {
|
||||
getDashboard,
|
||||
type DashboardResponse, type DailyTrend, type AppDistItem,
|
||||
type AlertItem, type AppHealthItem,
|
||||
} from "../api/adminAI";
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
const ALERT_STATUS_COLOR: Record<string, string> = {
|
||||
failed: "red", timeout: "orange", circuit_open: "volcano",
|
||||
};
|
||||
|
||||
const ALERT_MGMT_COLOR: Record<string, string> = {
|
||||
pending: "warning", acknowledged: "success", ignored: "default",
|
||||
};
|
||||
|
||||
const HEALTH_STATUS: Record<string, "success" | "error" | "warning" | "default"> = {
|
||||
success: "success", failed: "error", timeout: "warning", circuit_open: "error",
|
||||
};
|
||||
|
||||
function fmtTime(raw: string | null): string {
|
||||
if (!raw) return "—";
|
||||
const d = new Date(raw);
|
||||
return Number.isNaN(d.getTime()) ? raw : d.toLocaleString("zh-CN");
|
||||
}
|
||||
|
||||
// ---- 表格列定义 ----
|
||||
|
||||
const trendColumns: ColumnsType<DailyTrend> = [
|
||||
{ title: "日期", dataIndex: "date", key: "date", width: 120 },
|
||||
{ title: "调用量", dataIndex: "calls", key: "calls", align: "right" },
|
||||
{
|
||||
title: "成功率", dataIndex: "success_rate", key: "success_rate", align: "right",
|
||||
render: (v: number) => `${(v * 100).toFixed(1)}%`,
|
||||
},
|
||||
];
|
||||
|
||||
const distColumns: ColumnsType<AppDistItem> = [
|
||||
{ title: "App 类型", dataIndex: "app_type", key: "app_type" },
|
||||
{ title: "调用次数", dataIndex: "count", key: "count", align: "right" },
|
||||
{
|
||||
title: "占比", dataIndex: "percentage", key: "percentage", align: "right",
|
||||
render: (v: number) => `${(v * 100).toFixed(1)}%`,
|
||||
},
|
||||
];
|
||||
|
||||
const alertColumns: ColumnsType<AlertItem> = [
|
||||
{ title: "ID", dataIndex: "id", key: "id", width: 80 },
|
||||
{ title: "App", dataIndex: "app_type", key: "app_type", width: 160 },
|
||||
{
|
||||
title: "状态", dataIndex: "status", key: "status", width: 110,
|
||||
render: (v: string) => <Tag color={ALERT_STATUS_COLOR[v] ?? "default"}>{v}</Tag>,
|
||||
},
|
||||
{
|
||||
title: "告警状态", dataIndex: "alert_status", key: "alert_status", width: 110,
|
||||
render: (v: string | null) => v ? <Tag color={ALERT_MGMT_COLOR[v] ?? "default"}>{v}</Tag> : "—",
|
||||
},
|
||||
{
|
||||
title: "错误信息", dataIndex: "error_message", key: "error_message", ellipsis: true,
|
||||
render: (v: string | null) => v ?? "—",
|
||||
},
|
||||
{ title: "时间", dataIndex: "created_at", key: "created_at", width: 170, render: fmtTime },
|
||||
];
|
||||
|
||||
// ---- 页面组件 ----
|
||||
|
||||
const AIDashboard: React.FC = () => {
|
||||
const [data, setData] = useState<DashboardResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [siteId, setSiteId] = useState<number | undefined>(undefined);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getDashboard(siteId);
|
||||
setData(res);
|
||||
} catch {
|
||||
message.error("加载 Dashboard 失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [siteId]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 顶部:门店筛选 + 刷新 */}
|
||||
<Row justify="space-between" align="middle" style={{ marginBottom: 16 }}>
|
||||
<Space>
|
||||
<Title level={4} style={{ margin: 0 }}>AI 运行总览</Title>
|
||||
<Select
|
||||
allowClear placeholder="门店筛选" style={{ width: 200 }}
|
||||
value={siteId} onChange={(v) => setSiteId(v)}
|
||||
options={[{ label: "默认门店", value: 2790685415443269 }]}
|
||||
/>
|
||||
</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>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="今日成功率" 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>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="平均延迟" suffix="ms"
|
||||
value={data ? data.today_avg_latency_ms.toFixed(0) : "0"}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 第二行:7 天趋势 + App 调用占比 */}
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col span={12}>
|
||||
<Card title="近 7 天趋势" size="small">
|
||||
<Table<DailyTrend>
|
||||
columns={trendColumns}
|
||||
dataSource={data?.trend_7d ?? []}
|
||||
rowKey="date" size="small" pagination={false}
|
||||
loading={loading}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Card title="App 调用占比" size="small">
|
||||
<Table<AppDistItem>
|
||||
columns={distColumns}
|
||||
dataSource={data?.app_distribution ?? []}
|
||||
rowKey="app_type" size="small" pagination={false}
|
||||
loading={loading}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 第三行:Token 预算 + App 健康状态 */}
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col span={12}>
|
||||
<Card title="Token 预算" size="small">
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<span>日预算:{data?.budget.daily_used ?? 0} / {data?.budget.daily_limit ?? 0}</span>
|
||||
<Progress
|
||||
percent={data ? +(data.budget.daily_pct * 100).toFixed(1) : 0}
|
||||
status={data && data.budget.daily_pct > 0.9 ? "exception" : "active"}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span>月预算:{data?.budget.monthly_used ?? 0} / {data?.budget.monthly_limit ?? 0}</span>
|
||||
<Progress
|
||||
percent={data ? +(data.budget.monthly_pct * 100).toFixed(1) : 0}
|
||||
status={data && data.budget.monthly_pct > 0.9 ? "exception" : "active"}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Card title="App 健康状态" size="small">
|
||||
{(data?.app_health ?? []).map((item: AppHealthItem) => (
|
||||
<div key={item.app_type} style={{ display: "flex", justifyContent: "space-between", padding: "4px 0" }}>
|
||||
<span>{item.app_type}</span>
|
||||
<Space>
|
||||
<Badge status={HEALTH_STATUS[item.last_status ?? ""] ?? "default"} text={item.last_status ?? "无记录"} />
|
||||
<span style={{ fontSize: 12, color: "#999" }}>{fmtTime(item.last_call_at)}</span>
|
||||
</Space>
|
||||
</div>
|
||||
))}
|
||||
{(data?.app_health ?? []).length === 0 && <span style={{ color: "#999" }}>暂无数据</span>}
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 第四行:告警列表 */}
|
||||
<Card title="告警列表" size="small">
|
||||
<Table<AlertItem>
|
||||
columns={alertColumns}
|
||||
dataSource={data?.recent_alerts ?? []}
|
||||
rowKey="id" size="small" pagination={{ pageSize: 10 }}
|
||||
loading={loading}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AIDashboard;
|
||||
329
apps/admin-web/src/pages/AIOperations.tsx
Normal file
329
apps/admin-web/src/pages/AIOperations.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* AI 手动操作页面。
|
||||
*
|
||||
* 4 个 Card 区域:
|
||||
* - Card 1:手动重跑(App + member_id + site_id → 执行)
|
||||
* - Card 2:缓存失效(app_type + member_id + site_id → 失效)
|
||||
* - Card 3:批量执行(app_types + member_ids + site_id → 预估 → 确认)
|
||||
* - Card 4:告警管理(告警列表 + 确认/忽略)
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import {
|
||||
Card, Row, Col, Select, Input, Button, Table, Tag, Space,
|
||||
Checkbox, Modal, Statistic, message, Typography,
|
||||
} from "antd";
|
||||
import { ReloadOutlined } from "@ant-design/icons";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import {
|
||||
retryTriggerJob, invalidateCache, createBatchRun, confirmBatchRun,
|
||||
getAlerts, ackAlert, ignoreAlert,
|
||||
type AlertItem, type BatchRunEstimate,
|
||||
} from "../api/adminAI";
|
||||
|
||||
const { TextArea } = Input;
|
||||
const { Title } = Typography;
|
||||
|
||||
const APP_TYPE_OPTIONS = [
|
||||
{ label: "App3 维客线索", value: "app3_clue" },
|
||||
{ label: "App4 关系分析", value: "app4_analysis" },
|
||||
{ label: "App5 话术参考", value: "app5_tactics" },
|
||||
{ label: "App6 备注分析", value: "app6_note_analysis" },
|
||||
{ label: "App7 客户分析", value: "app7_customer_analysis" },
|
||||
{ label: "App8 线索整理", value: "app8_clue_consolidated" },
|
||||
];
|
||||
|
||||
const ALERT_STATUS_COLOR: Record<string, string> = {
|
||||
failed: "red", timeout: "orange", circuit_open: "volcano",
|
||||
};
|
||||
|
||||
const ALERT_MGMT_COLOR: Record<string, string> = {
|
||||
pending: "warning", acknowledged: "success", ignored: "default",
|
||||
};
|
||||
|
||||
function fmtTime(raw: string | null): string {
|
||||
if (!raw) return "—";
|
||||
const d = new Date(raw);
|
||||
return Number.isNaN(d.getTime()) ? raw : d.toLocaleString("zh-CN");
|
||||
}
|
||||
|
||||
const AIOperations: React.FC = () => {
|
||||
// ---- Card 1: 手动重跑 ----
|
||||
const [retryJobId, setRetryJobId] = useState<string>("");
|
||||
const [retryLoading, setRetryLoading] = useState(false);
|
||||
|
||||
const handleRetry = async () => {
|
||||
const id = Number(retryJobId);
|
||||
if (!id || Number.isNaN(id)) { message.warning("请输入有效的任务 ID"); return; }
|
||||
setRetryLoading(true);
|
||||
try {
|
||||
const res = await retryTriggerJob(id);
|
||||
message.success(`已创建重跑任务 #${res.trigger_job_id}`);
|
||||
setRetryJobId("");
|
||||
} catch {
|
||||
message.error("重跑失败");
|
||||
} finally {
|
||||
setRetryLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ---- Card 2: 缓存失效 ----
|
||||
const [cacheAppType, setCacheAppType] = useState<string | undefined>();
|
||||
const [cacheMemberId, setCacheMemberId] = useState<string>("");
|
||||
const [cacheSiteId, setCacheSiteId] = useState<number>(2790685415443269);
|
||||
const [cacheLoading, setCacheLoading] = useState(false);
|
||||
const [cacheResult, setCacheResult] = useState<number | null>(null);
|
||||
|
||||
const handleInvalidate = async () => {
|
||||
setCacheLoading(true);
|
||||
setCacheResult(null);
|
||||
try {
|
||||
const res = await invalidateCache({
|
||||
site_id: cacheSiteId,
|
||||
app_type: cacheAppType,
|
||||
member_id: cacheMemberId ? Number(cacheMemberId) : undefined,
|
||||
});
|
||||
setCacheResult(res.affected_count);
|
||||
message.success(`已失效 ${res.affected_count} 条缓存`);
|
||||
} catch {
|
||||
message.error("缓存失效操作失败");
|
||||
} finally {
|
||||
setCacheLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ---- Card 3: 批量执行 ----
|
||||
const [batchAppTypes, setBatchAppTypes] = useState<string[]>([]);
|
||||
const [batchMemberIds, setBatchMemberIds] = useState<string>("");
|
||||
const [batchSiteId, setBatchSiteId] = useState<number>(2790685415443269);
|
||||
const [batchLoading, setBatchLoading] = useState(false);
|
||||
const [batchEstimate, setBatchEstimate] = useState<BatchRunEstimate | null>(null);
|
||||
const [confirmVisible, setConfirmVisible] = useState(false);
|
||||
const [confirmLoading, setConfirmLoading] = useState(false);
|
||||
|
||||
const parseMemberIds = (text: string): number[] =>
|
||||
text.split(/[\n,;\s]+/).map(Number).filter((n) => !Number.isNaN(n) && n > 0);
|
||||
|
||||
const handleBatchEstimate = async () => {
|
||||
const memberIds = parseMemberIds(batchMemberIds);
|
||||
if (batchAppTypes.length === 0) { message.warning("请选择至少一个 App"); return; }
|
||||
if (memberIds.length === 0) { message.warning("请输入有效的会员 ID"); return; }
|
||||
setBatchLoading(true);
|
||||
try {
|
||||
const res = await createBatchRun({ app_types: batchAppTypes, member_ids: memberIds, site_id: batchSiteId });
|
||||
setBatchEstimate(res);
|
||||
setConfirmVisible(true);
|
||||
} catch {
|
||||
message.error("预估失败");
|
||||
} finally {
|
||||
setBatchLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchConfirm = async () => {
|
||||
if (!batchEstimate) return;
|
||||
setConfirmLoading(true);
|
||||
try {
|
||||
await confirmBatchRun(batchEstimate.batch_id);
|
||||
message.success("批量执行已启动");
|
||||
setConfirmVisible(false);
|
||||
setBatchEstimate(null);
|
||||
setBatchAppTypes([]);
|
||||
setBatchMemberIds("");
|
||||
} catch {
|
||||
message.error("确认执行失败");
|
||||
} finally {
|
||||
setConfirmLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ---- Card 4: 告警管理 ----
|
||||
const [alerts, setAlerts] = useState<AlertItem[]>([]);
|
||||
const [alertTotal, setAlertTotal] = useState(0);
|
||||
const [alertLoading, setAlertLoading] = useState(false);
|
||||
const [alertPage, setAlertPage] = useState(1);
|
||||
|
||||
const loadAlerts = useCallback(async () => {
|
||||
setAlertLoading(true);
|
||||
try {
|
||||
const res = await getAlerts({ page: alertPage, page_size: 10 });
|
||||
setAlerts(res.items);
|
||||
setAlertTotal(res.total);
|
||||
} catch {
|
||||
message.error("加载告警列表失败");
|
||||
} finally {
|
||||
setAlertLoading(false);
|
||||
}
|
||||
}, [alertPage]);
|
||||
|
||||
useEffect(() => { loadAlerts(); }, [loadAlerts]);
|
||||
|
||||
const handleAck = async (id: number) => {
|
||||
try {
|
||||
await ackAlert(id);
|
||||
message.success("已确认告警");
|
||||
loadAlerts();
|
||||
} catch {
|
||||
message.error("确认失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleIgnore = async (id: number) => {
|
||||
try {
|
||||
await ignoreAlert(id);
|
||||
message.success("已忽略告警");
|
||||
loadAlerts();
|
||||
} catch {
|
||||
message.error("忽略失败");
|
||||
}
|
||||
};
|
||||
|
||||
const alertColumns: ColumnsType<AlertItem> = [
|
||||
{ title: "ID", dataIndex: "id", key: "id", width: 70 },
|
||||
{ title: "App", dataIndex: "app_type", key: "app_type", width: 150 },
|
||||
{
|
||||
title: "状态", dataIndex: "status", key: "status", width: 100,
|
||||
render: (v: string) => <Tag color={ALERT_STATUS_COLOR[v] ?? "default"}>{v}</Tag>,
|
||||
},
|
||||
{
|
||||
title: "告警状态", dataIndex: "alert_status", key: "alert_status", width: 100,
|
||||
render: (v: string | null) => v ? <Tag color={ALERT_MGMT_COLOR[v] ?? "default"}>{v}</Tag> : "—",
|
||||
},
|
||||
{ title: "错误信息", dataIndex: "error_message", key: "error_message", ellipsis: true, render: (v) => v ?? "—" },
|
||||
{ title: "时间", dataIndex: "created_at", key: "created_at", width: 160, render: fmtTime },
|
||||
{
|
||||
title: "操作", key: "action", width: 140,
|
||||
render: (_: unknown, r: AlertItem) => (
|
||||
<Space>
|
||||
<Button size="small" onClick={() => handleAck(r.id)} disabled={r.alert_status === "acknowledged"}>确认</Button>
|
||||
<Button size="small" onClick={() => handleIgnore(r.id)} disabled={r.alert_status === "ignored"}>忽略</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Title level={4} style={{ marginBottom: 16 }}>AI 手动操作</Title>
|
||||
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
{/* Card 1: 手动重跑 */}
|
||||
<Col span={12}>
|
||||
<Card title="手动重跑" size="small">
|
||||
<Space direction="vertical" style={{ width: "100%" }}>
|
||||
<Input
|
||||
placeholder="调度任务 ID" value={retryJobId}
|
||||
onChange={(e) => setRetryJobId(e.target.value)}
|
||||
/>
|
||||
<Button type="primary" onClick={handleRetry} loading={retryLoading}>执行重跑</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* Card 2: 缓存失效 */}
|
||||
<Col span={12}>
|
||||
<Card title="缓存失效" size="small">
|
||||
<Space direction="vertical" style={{ width: "100%" }}>
|
||||
<Select
|
||||
allowClear placeholder="App 类型(可选)" style={{ width: "100%" }}
|
||||
value={cacheAppType} onChange={setCacheAppType}
|
||||
options={APP_TYPE_OPTIONS}
|
||||
/>
|
||||
<Input
|
||||
placeholder="会员 ID(可选)" value={cacheMemberId}
|
||||
onChange={(e) => setCacheMemberId(e.target.value)}
|
||||
/>
|
||||
<Select
|
||||
placeholder="门店" style={{ width: "100%" }}
|
||||
value={cacheSiteId} onChange={setCacheSiteId}
|
||||
options={[{ label: "默认门店", value: 2790685415443269 }]}
|
||||
/>
|
||||
<Space>
|
||||
<Button type="primary" danger onClick={handleInvalidate} loading={cacheLoading}>执行失效</Button>
|
||||
{cacheResult != null && <Statistic title="受影响记录" value={cacheResult} />}
|
||||
</Space>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Card 3: 批量执行 */}
|
||||
<Card title="批量执行" size="small" style={{ marginBottom: 16 }}>
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<div style={{ marginBottom: 8, fontWeight: 500 }}>选择 App</div>
|
||||
<Checkbox.Group
|
||||
options={APP_TYPE_OPTIONS}
|
||||
value={batchAppTypes}
|
||||
onChange={(v) => setBatchAppTypes(v as string[])}
|
||||
style={{ display: "flex", flexDirection: "column", gap: 4 }}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<div style={{ marginBottom: 8, fontWeight: 500 }}>会员 ID(每行一个或逗号分隔)</div>
|
||||
<TextArea
|
||||
rows={6} value={batchMemberIds}
|
||||
onChange={(e) => setBatchMemberIds(e.target.value)}
|
||||
placeholder="例如: 12345 67890"
|
||||
/>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<div style={{ marginBottom: 8, fontWeight: 500 }}>门店</div>
|
||||
<Select
|
||||
style={{ width: "100%", marginBottom: 16 }}
|
||||
value={batchSiteId} onChange={setBatchSiteId}
|
||||
options={[{ label: "默认门店", value: 2790685415443269 }]}
|
||||
/>
|
||||
<Button type="primary" onClick={handleBatchEstimate} loading={batchLoading}>
|
||||
预估并执行
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* 批量执行确认弹窗 */}
|
||||
<Modal
|
||||
title="确认批量执行"
|
||||
open={confirmVisible}
|
||||
onCancel={() => { setConfirmVisible(false); setBatchEstimate(null); }}
|
||||
onOk={handleBatchConfirm}
|
||||
confirmLoading={confirmLoading}
|
||||
okText="确认执行" cancelText="取消"
|
||||
>
|
||||
{batchEstimate && (
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Statistic title="预估调用次数" value={batchEstimate.estimated_calls} suffix="次" />
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Statistic title="预估 Token 消耗" value={batchEstimate.estimated_tokens} />
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
<p style={{ marginTop: 16, color: "#faad14" }}>
|
||||
确认后将在后台异步执行,请确保预算充足。
|
||||
</p>
|
||||
</Modal>
|
||||
|
||||
{/* Card 4: 告警管理 */}
|
||||
<Card
|
||||
title="告警管理" size="small"
|
||||
extra={<Button icon={<ReloadOutlined />} size="small" onClick={loadAlerts}>刷新</Button>}
|
||||
>
|
||||
<Table<AlertItem>
|
||||
columns={alertColumns}
|
||||
dataSource={alerts}
|
||||
rowKey="id" size="small"
|
||||
loading={alertLoading}
|
||||
pagination={{
|
||||
current: alertPage, pageSize: 10, total: alertTotal,
|
||||
onChange: (p) => setAlertPage(p),
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AIOperations;
|
||||
229
apps/admin-web/src/pages/AIRunLogs.tsx
Normal file
229
apps/admin-web/src/pages/AIRunLogs.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* AI 调用明细页面。
|
||||
*
|
||||
* - 顶部筛选器:app_type / status / trigger_type / site_id / 日期范围
|
||||
* - 主体:分页表格(app_type、trigger_type、member_id、tokens、延迟、状态)
|
||||
* - 点击行:Drawer 展示完整 prompt / response / error_message
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import {
|
||||
Card, Table, Tag, Select, Button, DatePicker, Row, Space,
|
||||
Drawer, Descriptions, message, Typography,
|
||||
} from "antd";
|
||||
import { ReloadOutlined } from "@ant-design/icons";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import {
|
||||
getRunLogs, getRunLogDetail,
|
||||
type RunLogItem, type RunLogDetailResponse, type RunLogQuery,
|
||||
} from "../api/adminAI";
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
const { Title } = Typography;
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
success: "green", failed: "red", timeout: "orange",
|
||||
circuit_open: "volcano", pending: "default", running: "processing",
|
||||
};
|
||||
|
||||
function fmtTime(raw: string | null): string {
|
||||
if (!raw) return "—";
|
||||
const d = new Date(raw);
|
||||
return Number.isNaN(d.getTime()) ? raw : d.toLocaleString("zh-CN");
|
||||
}
|
||||
|
||||
const AIRunLogs: React.FC = () => {
|
||||
const [items, setItems] = useState<RunLogItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize] = useState(20);
|
||||
|
||||
// 筛选状态
|
||||
const [appType, setAppType] = useState<string | undefined>();
|
||||
const [status, setStatus] = useState<string | undefined>();
|
||||
const [triggerType, setTriggerType] = useState<string | undefined>();
|
||||
const [siteId, setSiteId] = useState<number | undefined>();
|
||||
const [dateRange, setDateRange] = useState<[string, string] | null>(null);
|
||||
|
||||
// Drawer 详情
|
||||
const [drawerVisible, setDrawerVisible] = useState(false);
|
||||
const [detail, setDetail] = useState<RunLogDetailResponse | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: RunLogQuery = {
|
||||
page, page_size: pageSize,
|
||||
app_type: appType, status, trigger_type: triggerType,
|
||||
site_id: siteId,
|
||||
date_from: dateRange?.[0], date_to: dateRange?.[1],
|
||||
};
|
||||
const res = await getRunLogs(params);
|
||||
setItems(res.items);
|
||||
setTotal(res.total);
|
||||
} catch {
|
||||
message.error("加载调用记录失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, pageSize, appType, status, triggerType, siteId, dateRange]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleRowClick = async (id: number) => {
|
||||
setDetailLoading(true);
|
||||
setDrawerVisible(true);
|
||||
try {
|
||||
const res = await getRunLogDetail(id);
|
||||
setDetail(res);
|
||||
} catch {
|
||||
message.error("加载详情失败");
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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 columns: ColumnsType<RunLogItem> = [
|
||||
{ title: "ID", dataIndex: "id", key: "id", width: 70 },
|
||||
{ title: "App 类型", dataIndex: "app_type", key: "app_type", width: 160 },
|
||||
{ title: "触发方式", dataIndex: "trigger_type", key: "trigger_type", width: 110 },
|
||||
{ title: "会员 ID", dataIndex: "member_id", key: "member_id", width: 100, render: (v) => v ?? "—" },
|
||||
{ title: "Tokens", dataIndex: "tokens_used", key: "tokens_used", width: 90, align: "right" },
|
||||
{
|
||||
title: "延迟", dataIndex: "latency_ms", key: "latency_ms", width: 90, align: "right",
|
||||
render: (v: number | null) => v != null ? `${v}ms` : "—",
|
||||
},
|
||||
{
|
||||
title: "状态", dataIndex: "status", key: "status", width: 110,
|
||||
render: (v: string) => <Tag color={STATUS_COLOR[v] ?? "default"}>{v}</Tag>,
|
||||
},
|
||||
{ title: "时间", dataIndex: "created_at", key: "created_at", width: 170, render: fmtTime },
|
||||
];
|
||||
|
||||
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="App 类型" style={{ width: 180 }}
|
||||
value={appType} onChange={(v) => { setAppType(v); setPage(1); }}
|
||||
options={APP_TYPE_OPTIONS}
|
||||
/>
|
||||
<Select
|
||||
allowClear placeholder="状态" style={{ width: 130 }}
|
||||
value={status} onChange={(v) => { setStatus(v); setPage(1); }}
|
||||
options={[
|
||||
{ label: "success", value: "success" },
|
||||
{ label: "failed", value: "failed" },
|
||||
{ label: "timeout", value: "timeout" },
|
||||
{ label: "circuit_open", value: "circuit_open" },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
allowClear placeholder="触发方式" style={{ width: 130 }}
|
||||
value={triggerType} onChange={(v) => { setTriggerType(v); setPage(1); }}
|
||||
options={[
|
||||
{ label: "event", value: "event" },
|
||||
{ label: "scheduled", value: "scheduled" },
|
||||
{ label: "manual", value: "manual" },
|
||||
{ label: "backfill", value: "backfill" },
|
||||
]}
|
||||
/>
|
||||
<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>
|
||||
|
||||
{/* 主体表格 */}
|
||||
<Table<RunLogItem>
|
||||
columns={columns}
|
||||
dataSource={items}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
scroll={{ x: 1000 }}
|
||||
onRow={(record) => ({
|
||||
onClick: () => handleRowClick(record.id),
|
||||
style: { cursor: "pointer" },
|
||||
})}
|
||||
pagination={{
|
||||
current: page, pageSize, total,
|
||||
onChange: (p) => setPage(p),
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 详情 Drawer */}
|
||||
<Drawer
|
||||
title={`调用记录详情 #${detail?.id ?? ""}`}
|
||||
open={drawerVisible}
|
||||
onClose={() => { setDrawerVisible(false); setDetail(null); }}
|
||||
width={640}
|
||||
loading={detailLoading}
|
||||
>
|
||||
{detail && (
|
||||
<>
|
||||
<Descriptions column={2} bordered size="small" style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="App 类型">{detail.app_type}</Descriptions.Item>
|
||||
<Descriptions.Item label="触发方式">{detail.trigger_type}</Descriptions.Item>
|
||||
<Descriptions.Item label="会员 ID">{detail.member_id ?? "—"}</Descriptions.Item>
|
||||
<Descriptions.Item label="门店 ID">{detail.site_id}</Descriptions.Item>
|
||||
<Descriptions.Item label="Tokens">{detail.tokens_used}</Descriptions.Item>
|
||||
<Descriptions.Item label="延迟">{detail.latency_ms != null ? `${detail.latency_ms}ms` : "—"}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag color={STATUS_COLOR[detail.status] ?? "default"}>{detail.status}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Session ID">{detail.session_id ?? "—"}</Descriptions.Item>
|
||||
<Descriptions.Item label="创建时间" span={2}>{fmtTime(detail.created_at)}</Descriptions.Item>
|
||||
<Descriptions.Item label="完成时间" span={2}>{fmtTime(detail.finished_at)}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
{detail.error_message && (
|
||||
<Card title="错误信息" size="small" style={{ marginBottom: 16 }}>
|
||||
<pre style={{ margin: 0, whiteSpace: "pre-wrap", color: "#cf1322" }}>
|
||||
{detail.error_message}
|
||||
</pre>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card title="Request Prompt" size="small" style={{ marginBottom: 16 }}>
|
||||
<pre style={{ margin: 0, whiteSpace: "pre-wrap", maxHeight: 300, overflow: "auto", background: "#f5f5f5", padding: 8, borderRadius: 4 }}>
|
||||
{detail.request_prompt ?? "(无)"}
|
||||
</pre>
|
||||
</Card>
|
||||
|
||||
<Card title="Response" size="small">
|
||||
<pre style={{ margin: 0, whiteSpace: "pre-wrap", maxHeight: 300, overflow: "auto", background: "#f5f5f5", padding: 8, borderRadius: 4 }}>
|
||||
{detail.response_text ?? "(无)"}
|
||||
</pre>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AIRunLogs;
|
||||
247
apps/admin-web/src/pages/AITriggerJobs.tsx
Normal file
247
apps/admin-web/src/pages/AITriggerJobs.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* 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;
|
||||
380
apps/admin-web/src/pages/Dashboard.tsx
Normal file
380
apps/admin-web/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,380 @@
|
||||
/**
|
||||
* 运行状态仪表盘(Dashboard)
|
||||
*
|
||||
* 登录后默认首页,聚合 4 个区块:
|
||||
* 1. OpsPanel 子组件(系统资源、服务状态、Git 状态)
|
||||
* 2. 数据库健康监控(DbHealthCard)
|
||||
* 3. AI 运行总览(复用 AIDashboard)
|
||||
* 4. AI 调度摘要(今日触发数、成功率、最近错误 + 跳转链接)
|
||||
*
|
||||
* 跳转链接:
|
||||
* - "ETL 状态详情" → /etl-tasks?tab=status
|
||||
* - "触发器详情" → /triggers?tab=all
|
||||
* - "AI 调度详情" → /triggers?tab=ai
|
||||
*
|
||||
* _Requirements: 2.1, 2.2, 2.6, 2.7, 2.8, 7.1, 7.2_
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import {
|
||||
Typography,
|
||||
Spin,
|
||||
message,
|
||||
Modal,
|
||||
Card,
|
||||
Row,
|
||||
Col,
|
||||
Statistic,
|
||||
Tag,
|
||||
Button,
|
||||
Space,
|
||||
Divider,
|
||||
List,
|
||||
} from "antd";
|
||||
import {
|
||||
DashboardOutlined,
|
||||
ReloadOutlined,
|
||||
RightOutlined,
|
||||
CloseCircleOutlined,
|
||||
ThunderboltOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import type { SystemInfo, ServiceStatus, GitInfo } from "../api/opsPanel";
|
||||
import {
|
||||
fetchSystemInfo,
|
||||
fetchServicesStatus,
|
||||
fetchGitInfo,
|
||||
startService,
|
||||
stopService,
|
||||
restartService,
|
||||
gitPull,
|
||||
syncDeps,
|
||||
} from "../api/opsPanel";
|
||||
import {
|
||||
SystemResourceSection,
|
||||
ServiceStatusSection,
|
||||
GitStatusSection,
|
||||
} from "../components/ops";
|
||||
import { fetchDbHealth } from "../api/dbHealth";
|
||||
import type { DbHealthItem } from "../api/dbHealth";
|
||||
import DbHealthCard from "../components/DbHealthCard";
|
||||
import AIDashboard from "./AIDashboard";
|
||||
import { getTriggerJobs, type TriggerJobItem } from "../api/adminAI";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
/* 超时阈值(毫秒) */
|
||||
const DB_HEALTH_TIMEOUT = 10_000;
|
||||
|
||||
const Dashboard: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// ---- OpsPanel 数据 ----
|
||||
const [system, setSystem] = useState<SystemInfo | null>(null);
|
||||
const [services, setServices] = useState<ServiceStatus[]>([]);
|
||||
const [gitInfos, setGitInfos] = useState<GitInfo[]>([]);
|
||||
const [opsLoading, setOpsLoading] = useState(true);
|
||||
const [actionLoading, setActionLoading] = useState<Record<string, boolean>>({});
|
||||
|
||||
// ---- DB 健康数据 ----
|
||||
const [dbItems, setDbItems] = useState<DbHealthItem[]>([]);
|
||||
const [dbLoading, setDbLoading] = useState(true);
|
||||
const [dbTimeout, setDbTimeout] = useState(false);
|
||||
|
||||
// ---- AI 调度摘要 ----
|
||||
const [triggerItems, setTriggerItems] = useState<TriggerJobItem[]>([]);
|
||||
const [triggerTotal, setTriggerTotal] = useState(0);
|
||||
const [triggerLoading, setTriggerLoading] = useState(true);
|
||||
|
||||
// ---- OpsPanel 数据加载(复用 OpsPanel.tsx 逻辑) ----
|
||||
|
||||
const loadOps = useCallback(async () => {
|
||||
try {
|
||||
const [sys, svc, git] = await Promise.all([
|
||||
fetchSystemInfo(),
|
||||
fetchServicesStatus(),
|
||||
fetchGitInfo(),
|
||||
]);
|
||||
setSystem(sys);
|
||||
setServices(svc);
|
||||
setGitInfos(git);
|
||||
} catch {
|
||||
message.error("加载运维数据失败");
|
||||
} finally {
|
||||
setOpsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ---- DB 健康加载 ----
|
||||
|
||||
const loadDbHealth = useCallback(async () => {
|
||||
setDbLoading(true);
|
||||
setDbTimeout(false);
|
||||
const timer = setTimeout(() => {
|
||||
setDbTimeout(true);
|
||||
setDbLoading(false);
|
||||
}, DB_HEALTH_TIMEOUT);
|
||||
try {
|
||||
const items = await fetchDbHealth();
|
||||
clearTimeout(timer);
|
||||
setDbItems(items);
|
||||
setDbTimeout(false);
|
||||
} catch {
|
||||
clearTimeout(timer);
|
||||
message.error("加载数据库健康数据失败");
|
||||
} finally {
|
||||
setDbLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ---- AI 调度摘要加载 ----
|
||||
|
||||
const loadTriggerSummary = useCallback(async () => {
|
||||
setTriggerLoading(true);
|
||||
try {
|
||||
const res = await getTriggerJobs({ page: 1, page_size: 50 });
|
||||
setTriggerItems(res.items);
|
||||
setTriggerTotal(res.total);
|
||||
} catch {
|
||||
message.error("加载 AI 调度数据失败");
|
||||
} finally {
|
||||
setTriggerLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ---- 初始化 + 定时刷新 ----
|
||||
|
||||
useEffect(() => {
|
||||
loadOps();
|
||||
loadDbHealth();
|
||||
loadTriggerSummary();
|
||||
const timer = setInterval(loadOps, 15_000);
|
||||
return () => clearInterval(timer);
|
||||
}, [loadOps, loadDbHealth, loadTriggerSummary]);
|
||||
|
||||
// ---- OpsPanel 操作处理(复用 OpsPanel.tsx 逻辑) ----
|
||||
|
||||
const withAction = async (key: string, fn: () => Promise<void>) => {
|
||||
setActionLoading((prev) => ({ ...prev, [key]: true }));
|
||||
try {
|
||||
await fn();
|
||||
} finally {
|
||||
setActionLoading((prev) => ({ ...prev, [key]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleStart = (env: string) =>
|
||||
withAction(`start-${env}`, async () => {
|
||||
const r = await startService(env);
|
||||
r.success ? message.success(r.message) : message.warning(r.message);
|
||||
await loadOps();
|
||||
});
|
||||
|
||||
const handleStop = (env: string) =>
|
||||
withAction(`stop-${env}`, async () => {
|
||||
const r = await stopService(env);
|
||||
r.success ? message.success(r.message) : message.warning(r.message);
|
||||
await loadOps();
|
||||
});
|
||||
|
||||
const handleRestart = (env: string) =>
|
||||
withAction(`restart-${env}`, async () => {
|
||||
const r = await restartService(env);
|
||||
r.success ? message.success(r.message) : message.warning(r.message);
|
||||
await loadOps();
|
||||
});
|
||||
|
||||
const handlePull = (env: string) =>
|
||||
withAction(`pull-${env}`, async () => {
|
||||
const r = await gitPull(env);
|
||||
if (r.success) {
|
||||
message.success("拉取成功");
|
||||
Modal.info({
|
||||
title: `Git Pull - ${env}`,
|
||||
content: (
|
||||
<pre style={{ maxHeight: 300, overflow: "auto", fontSize: 12 }}>
|
||||
{r.output}
|
||||
</pre>
|
||||
),
|
||||
width: 600,
|
||||
});
|
||||
} else {
|
||||
message.error("拉取失败");
|
||||
Modal.error({
|
||||
title: `Git Pull 失败 - ${env}`,
|
||||
content: (
|
||||
<pre style={{ maxHeight: 300, overflow: "auto", fontSize: 12 }}>
|
||||
{r.output}
|
||||
</pre>
|
||||
),
|
||||
width: 600,
|
||||
});
|
||||
}
|
||||
await loadOps();
|
||||
});
|
||||
|
||||
const handleSyncDeps = (env: string) =>
|
||||
withAction(`sync-${env}`, async () => {
|
||||
const r = await syncDeps(env);
|
||||
r.success ? message.success("依赖同步完成") : message.error(r.message);
|
||||
});
|
||||
|
||||
// ---- AI 调度摘要计算 ----
|
||||
|
||||
const todayStr = new Date().toISOString().slice(0, 10);
|
||||
const todayJobs = triggerItems.filter(
|
||||
(j) => j.created_at && j.created_at.startsWith(todayStr),
|
||||
);
|
||||
const todayCount = todayJobs.length;
|
||||
const todaySuccess = todayJobs.filter((j) => j.status === "success").length;
|
||||
const todaySuccessRate =
|
||||
todayCount > 0 ? ((todaySuccess / todayCount) * 100).toFixed(1) : "0.0";
|
||||
const recentErrors = triggerItems
|
||||
.filter((j) => j.status === "failed")
|
||||
.slice(0, 5);
|
||||
|
||||
// ---- 渲染 ----
|
||||
|
||||
if (opsLoading) {
|
||||
return (
|
||||
<Spin
|
||||
size="large"
|
||||
style={{ display: "flex", justifyContent: "center", marginTop: 120 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 页面标题 + 快捷跳转 */}
|
||||
<Row justify="space-between" align="middle" style={{ marginBottom: 16 }}>
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
<DashboardOutlined style={{ marginRight: 8 }} />
|
||||
运行状态
|
||||
</Title>
|
||||
<Space>
|
||||
<Button size="small" onClick={() => navigate("/etl-tasks?tab=status")}>
|
||||
ETL 状态详情 <RightOutlined />
|
||||
</Button>
|
||||
<Button size="small" onClick={() => navigate("/triggers?tab=all")}>
|
||||
触发器详情 <RightOutlined />
|
||||
</Button>
|
||||
<Button size="small" onClick={() => navigate("/triggers?tab=ai")}>
|
||||
AI 调度详情 <RightOutlined />
|
||||
</Button>
|
||||
</Space>
|
||||
</Row>
|
||||
|
||||
{/* 区块 1:OpsPanel 子组件 */}
|
||||
{system && <SystemResourceSection system={system} />}
|
||||
|
||||
<ServiceStatusSection
|
||||
services={services}
|
||||
actionLoading={actionLoading}
|
||||
onStart={handleStart}
|
||||
onStop={handleStop}
|
||||
onRestart={handleRestart}
|
||||
/>
|
||||
|
||||
<GitStatusSection
|
||||
gitInfos={gitInfos}
|
||||
services={services}
|
||||
actionLoading={actionLoading}
|
||||
onPull={handlePull}
|
||||
onSyncDeps={handleSyncDeps}
|
||||
/>
|
||||
|
||||
{/* 区块 2:数据库健康监控 */}
|
||||
<DbHealthCard
|
||||
items={dbItems}
|
||||
loading={dbLoading}
|
||||
timeout={dbTimeout}
|
||||
onRetry={loadDbHealth}
|
||||
/>
|
||||
|
||||
{/* 区块 3:AI 运行总览(复用 AIDashboard) */}
|
||||
<Divider orientation="left">AI 运行总览</Divider>
|
||||
<AIDashboard />
|
||||
|
||||
{/* 区块 4:AI 调度摘要 */}
|
||||
<Divider orientation="left">AI 调度摘要</Divider>
|
||||
<Card
|
||||
size="small"
|
||||
title={
|
||||
<Space>
|
||||
<ThunderboltOutlined />
|
||||
<span>AI 调度摘要</span>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Button
|
||||
size="small"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={loadTriggerSummary}
|
||||
loading={triggerLoading}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
}
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
{/* 统计卡片行 */}
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col span={8}>
|
||||
<Statistic title="今日触发数" value={todayCount} />
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Statistic title="今日成功率" value={todaySuccessRate} suffix="%" />
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Statistic
|
||||
title="总记录数"
|
||||
value={triggerTotal}
|
||||
valueStyle={{ fontSize: 16 }}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 最近错误列表 */}
|
||||
<Card type="inner" size="small" title="最近错误" style={{ marginBottom: 12 }}>
|
||||
{recentErrors.length === 0 ? (
|
||||
<Text type="secondary">暂无失败记录</Text>
|
||||
) : (
|
||||
<List
|
||||
size="small"
|
||||
dataSource={recentErrors}
|
||||
renderItem={(item) => (
|
||||
<List.Item>
|
||||
<Space>
|
||||
<CloseCircleOutlined style={{ color: "#ff4d4f" }} />
|
||||
<Tag color="error">{item.status}</Tag>
|
||||
<Text>{item.event_type}</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
ID: {item.id}
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{item.created_at
|
||||
? new Date(item.created_at).toLocaleString("zh-CN")
|
||||
: "—"}
|
||||
</Text>
|
||||
</Space>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 跳转链接 */}
|
||||
<Button
|
||||
type="link"
|
||||
onClick={() => navigate("/triggers?tab=ai")}
|
||||
style={{ padding: 0 }}
|
||||
>
|
||||
查看 AI 调度详情 <RightOutlined />
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
760
apps/admin-web/src/pages/DevTrace.tsx
Normal file
760
apps/admin-web/src/pages/DevTrace.tsx
Normal file
@@ -0,0 +1,760 @@
|
||||
/**
|
||||
* 开发调试全链路日志页面。
|
||||
*
|
||||
* - 顶部:覆盖率状态栏 + 筛选栏
|
||||
* - 左侧:请求列表(Table,分页)
|
||||
* - 右侧:选中请求的 span 链路树
|
||||
* - 设置面板:Drawer(日志开关、保留天数、清理等)
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import {
|
||||
Table, Tag, Alert, Button, Select, Input, InputNumber,
|
||||
Checkbox, DatePicker, TimePicker, Space, Typography, Row, Col,
|
||||
Drawer, Switch, message, Spin, Tooltip, Divider, Progress,
|
||||
} from "antd";
|
||||
import {
|
||||
SettingOutlined, ReloadOutlined, SearchOutlined,
|
||||
DeleteOutlined, ScanOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import dayjs from "dayjs";
|
||||
import {
|
||||
fetchDates, fetchRequests, fetchRequestDetail,
|
||||
fetchCoverage, triggerCoverageScan,
|
||||
fetchSettings, updateSettings, cleanupLogs,
|
||||
} from "../api/devTrace";
|
||||
import type {
|
||||
TraceRequest, TraceDetail, TraceSpan, TraceSettings,
|
||||
TraceFilter, TraceCoverage, SpanType, TraceType, CoverageCategory,
|
||||
} from "../types/devTrace";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
// ---- 常量 ----
|
||||
|
||||
const SPAN_TYPE_OPTIONS: SpanType[] = [
|
||||
"HTTP_IN", "AUTH", "ROUTE", "SERVICE",
|
||||
"DB_QUERY", "DB_CONN", "DB_CONN_RELEASE",
|
||||
"HTTP_OUT", "ERROR", "DB_ERROR",
|
||||
"MIDDLEWARE", "MIDDLEWARE_ERROR",
|
||||
"SSE_START", "SSE_EVENT", "SSE_END",
|
||||
"AI_CALL", "AI_STREAM", "AI_ERROR",
|
||||
"WS_CONNECT", "WS_MESSAGE", "WS_DISCONNECT",
|
||||
"JOB_START", "JOB_END", "JOB_ERROR",
|
||||
];
|
||||
|
||||
const TRACE_TYPE_OPTIONS: { label: string; value: TraceType }[] = [
|
||||
{ label: "HTTP", value: "http" },
|
||||
{ label: "SSE", value: "sse" },
|
||||
{ label: "WebSocket", value: "ws" },
|
||||
{ label: "Job", value: "job" },
|
||||
];
|
||||
|
||||
const METHOD_OPTIONS = ["GET", "POST", "PUT", "DELETE", "PATCH"];
|
||||
|
||||
/** span_type → 颜色映射 */
|
||||
const SPAN_COLOR: Record<string, string> = {
|
||||
HTTP_IN: "#1890ff", HTTP_OUT: "#1890ff",
|
||||
AUTH: "#fa8c16",
|
||||
ROUTE: "#1890ff",
|
||||
SERVICE: "#52c41a",
|
||||
DB_QUERY: "#722ed1", DB_CONN: "#722ed1", DB_CONN_RELEASE: "#722ed1",
|
||||
DB_ERROR: "#f5222d",
|
||||
ERROR: "#f5222d",
|
||||
MIDDLEWARE: "#8c8c8c", MIDDLEWARE_ERROR: "#f5222d",
|
||||
SSE_START: "#13c2c2", SSE_EVENT: "#13c2c2", SSE_END: "#13c2c2",
|
||||
AI_CALL: "#2f54eb", AI_STREAM: "#2f54eb", AI_ERROR: "#f5222d",
|
||||
WS_CONNECT: "#faad14", WS_MESSAGE: "#faad14", WS_DISCONNECT: "#faad14",
|
||||
JOB_START: "#8c8c8c", JOB_END: "#8c8c8c", JOB_ERROR: "#f5222d",
|
||||
};
|
||||
|
||||
const ERROR_SPAN_TYPES = new Set(["ERROR", "DB_ERROR", "MIDDLEWARE_ERROR", "AI_ERROR", "JOB_ERROR"]);
|
||||
|
||||
// ---- 辅助函数 ----
|
||||
|
||||
function fmtTime(raw: string | null | undefined): string {
|
||||
if (!raw) return "—";
|
||||
const d = new Date(raw);
|
||||
return Number.isNaN(d.getTime()) ? raw : d.toLocaleString("zh-CN");
|
||||
}
|
||||
|
||||
function fmtDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms.toFixed(0)}ms`;
|
||||
return `${(ms / 1000).toFixed(2)}s`;
|
||||
}
|
||||
|
||||
function coveragePct(cat: CoverageCategory): number {
|
||||
return cat.total === 0 ? 100 : Math.round((cat.covered / cat.total) * 100);
|
||||
}
|
||||
|
||||
// ---- 覆盖率状态栏 ----
|
||||
|
||||
const CoverageBar: React.FC<{
|
||||
coverage: TraceCoverage | null;
|
||||
loading: boolean;
|
||||
onScan: () => void;
|
||||
}> = ({ coverage, loading, onScan }) => {
|
||||
if (!coverage) {
|
||||
return (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 12 }}
|
||||
message={
|
||||
<Row align="middle" gutter={16}>
|
||||
<Col>
|
||||
<Text strong>Trace 覆盖率</Text>
|
||||
</Col>
|
||||
<Col>
|
||||
<Text type="secondary">暂无扫描数据</Text>
|
||||
</Col>
|
||||
<Col>
|
||||
<Button
|
||||
size="small" icon={<ScanOutlined />}
|
||||
loading={loading} onClick={onScan}
|
||||
>
|
||||
扫描
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const dims: { label: string; cat: CoverageCategory }[] = [
|
||||
{ label: "路由", cat: coverage.routes },
|
||||
{ label: "Service", cat: coverage.services },
|
||||
{ label: "Job", cat: coverage.jobs },
|
||||
{ label: "SSE", cat: coverage.sse_endpoints },
|
||||
{ label: "WS", cat: coverage.ws_endpoints },
|
||||
];
|
||||
|
||||
const allUncovered = dims.flatMap((d) =>
|
||||
d.cat.uncovered.map((name) => `${d.label}: ${name}`),
|
||||
);
|
||||
|
||||
return (
|
||||
<Alert
|
||||
type={allUncovered.length === 0 ? "success" : "warning"}
|
||||
showIcon
|
||||
style={{ marginBottom: 12 }}
|
||||
message={
|
||||
<Row align="middle" gutter={16}>
|
||||
<Col>
|
||||
<Text strong>Trace 覆盖率</Text>
|
||||
</Col>
|
||||
{dims.map((d) => (
|
||||
<Col key={d.label}>
|
||||
<Space size={4}>
|
||||
<Text type="secondary">{d.label}</Text>
|
||||
<Progress
|
||||
type="circle" size={28}
|
||||
percent={coveragePct(d.cat)}
|
||||
format={(p) => `${p}%`}
|
||||
/>
|
||||
</Space>
|
||||
</Col>
|
||||
))}
|
||||
<Col>
|
||||
<Button
|
||||
size="small" icon={<ScanOutlined />}
|
||||
loading={loading} onClick={onScan}
|
||||
>
|
||||
扫描
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
}
|
||||
description={
|
||||
allUncovered.length > 0 ? (
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
未覆盖:{allUncovered.join("、")}
|
||||
</Text>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// ---- Span 链路树 ----
|
||||
|
||||
const SpanTree: React.FC<{ detail: TraceDetail | null; loading: boolean }> = ({ detail, loading }) => {
|
||||
if (loading) return <Spin style={{ display: "block", marginTop: 40, textAlign: "center" }} />;
|
||||
if (!detail) {
|
||||
return <Text type="secondary" style={{ display: "block", textAlign: "center", marginTop: 40 }}>点击左侧请求查看链路详情</Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: "0 8px" }}>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<Text strong>{detail.method} {detail.path}</Text>
|
||||
<Tag color={detail.error ? "red" : "green"} style={{ marginLeft: 8 }}>
|
||||
{detail.status_code ?? "—"}
|
||||
</Tag>
|
||||
<Text type="secondary" style={{ marginLeft: 8 }}>{fmtDuration(detail.total_duration_ms)}</Text>
|
||||
</div>
|
||||
<Divider style={{ margin: "8px 0" }} />
|
||||
{detail.spans.map((span, idx) => (
|
||||
<SpanRow key={idx} span={span} index={idx} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SpanRow: React.FC<{ span: TraceSpan; index: number }> = ({ span, index }) => {
|
||||
const isError = ERROR_SPAN_TYPES.has(span.span_type);
|
||||
const color = SPAN_COLOR[span.span_type] ?? "#8c8c8c";
|
||||
const isDbQuery = span.span_type === "DB_QUERY";
|
||||
|
||||
// 根据 span 类型决定缩进层级
|
||||
const indent = getSpanIndent(span.span_type);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
marginLeft: indent * 16,
|
||||
padding: "4px 8px",
|
||||
marginBottom: 2,
|
||||
borderLeft: `3px solid ${color}`,
|
||||
background: isError ? "#fff2f0" : index % 2 === 0 ? "#fafafa" : "#fff",
|
||||
borderRadius: 2,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<Row justify="space-between" align="top">
|
||||
<Col flex="auto">
|
||||
<Tag color={color} style={{ fontSize: 11, lineHeight: "18px" }}>
|
||||
{span.span_type}
|
||||
</Tag>
|
||||
<Text style={{ color: isError ? "#f5222d" : undefined }}>
|
||||
{span.description_zh || `${span.module}.${span.function}`}
|
||||
</Text>
|
||||
</Col>
|
||||
<Col flex="none">
|
||||
<Text type="secondary" style={{ fontSize: 12, whiteSpace: "nowrap" }}>
|
||||
{fmtDuration(span.duration_ms)}
|
||||
</Text>
|
||||
</Col>
|
||||
</Row>
|
||||
{/* DB_QUERY / DB_ERROR:展示 SQL 详情 */}
|
||||
{(isDbQuery || span.span_type === "DB_ERROR") && !!span.extra?.sql && (
|
||||
<div style={{ margin: "4px 0 0", fontSize: 11 }}>
|
||||
<pre style={{
|
||||
margin: 0, padding: "4px 8px",
|
||||
background: span.span_type === "DB_ERROR" ? "#fff1f0" : "#f0f5ff",
|
||||
border: `1px solid ${span.span_type === "DB_ERROR" ? "#ffa39e" : "#adc6ff"}`,
|
||||
borderRadius: 2,
|
||||
whiteSpace: "pre-wrap", wordBreak: "break-all",
|
||||
maxHeight: 200, overflow: "auto",
|
||||
fontFamily: "'Cascadia Code', 'Fira Code', Consolas, monospace",
|
||||
}}>
|
||||
{String(span.extra.sql)}
|
||||
</pre>
|
||||
{/* 绑定参数 */}
|
||||
{span.extra.params != null && (
|
||||
<div style={{ marginTop: 2, color: "#8c8c8c" }}>
|
||||
<span style={{ color: "#722ed1" }}>参数:</span>
|
||||
{JSON.stringify(span.extra.params)}
|
||||
</div>
|
||||
)}
|
||||
{/* 行数 + 调用来源 */}
|
||||
<div style={{ marginTop: 2, color: "#8c8c8c", display: "flex", gap: 12 }}>
|
||||
{span.extra.row_count != null && (
|
||||
<span><span style={{ color: "#1890ff" }}>行数:</span>{String(span.extra.row_count)}</span>
|
||||
)}
|
||||
{span.extra.caller != null && (
|
||||
<span><span style={{ color: "#52c41a" }}>来源:</span>{String(span.extra.caller)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* 通用 params 展示(非 DB_QUERY/DB_ERROR,且 params 非空) */}
|
||||
{!isDbQuery && span.span_type !== "DB_ERROR" && span.params && Object.keys(span.params).length > 0 && (
|
||||
<div style={{ marginTop: 4, fontSize: 11, color: "#8c8c8c" }}>
|
||||
<span style={{ color: "#722ed1" }}>参数:</span>
|
||||
<span style={{ fontFamily: "'Cascadia Code', 'Fira Code', Consolas, monospace" }}>
|
||||
{JSON.stringify(span.params, null, 0)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{/* ERROR:展示错误信息 */}
|
||||
{isError && span.result_summary && (
|
||||
<div style={{ marginTop: 4, color: "#f5222d", fontSize: 12 }}>
|
||||
{span.result_summary}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** 根据 span_type 返回缩进层级(模拟层级关系) */
|
||||
function getSpanIndent(spanType: SpanType): number {
|
||||
switch (spanType) {
|
||||
case "HTTP_IN": case "HTTP_OUT": return 0;
|
||||
case "MIDDLEWARE": case "MIDDLEWARE_ERROR": return 1;
|
||||
case "AUTH": return 1;
|
||||
case "ROUTE": return 1;
|
||||
case "SERVICE": return 2;
|
||||
case "DB_QUERY": case "DB_CONN": case "DB_CONN_RELEASE": case "DB_ERROR": return 3;
|
||||
case "SSE_START": case "SSE_END": return 1;
|
||||
case "SSE_EVENT": case "AI_CALL": case "AI_STREAM": case "AI_ERROR": return 2;
|
||||
case "WS_CONNECT": case "WS_DISCONNECT": return 1;
|
||||
case "WS_MESSAGE": return 2;
|
||||
case "JOB_START": case "JOB_END": case "JOB_ERROR": return 0;
|
||||
case "ERROR": return 1;
|
||||
default: return 1;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 设置面板(Task 20) ----
|
||||
|
||||
const SettingsDrawer: React.FC<{
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}> = ({ open, onClose }) => {
|
||||
const [settings, setSettings] = useState<TraceSettings | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [cleanRange, setCleanRange] = useState<[dayjs.Dayjs, dayjs.Dayjs] | null>(null);
|
||||
const [cleaning, setCleaning] = useState(false);
|
||||
|
||||
const loadSettings = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetchSettings();
|
||||
setSettings(res);
|
||||
} catch {
|
||||
message.error("加载设置失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) loadSettings();
|
||||
}, [open, loadSettings]);
|
||||
|
||||
const handleToggle = async (field: keyof TraceSettings, value: boolean) => {
|
||||
try {
|
||||
const res = await updateSettings({ [field]: value });
|
||||
setSettings(res);
|
||||
message.success("设置已更新");
|
||||
} catch {
|
||||
message.error("更新失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveRetention = async () => {
|
||||
if (!settings) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await updateSettings({ retention_days: settings.retention_days });
|
||||
setSettings(res);
|
||||
message.success("保留天数已更新");
|
||||
} catch {
|
||||
message.error("更新失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCleanup = async () => {
|
||||
if (!cleanRange) { message.warning("请选择日期范围"); return; }
|
||||
setCleaning(true);
|
||||
try {
|
||||
const res = await cleanupLogs(
|
||||
cleanRange[0].format("YYYY-MM-DD"),
|
||||
cleanRange[1].format("YYYY-MM-DD"),
|
||||
);
|
||||
message.success(`已清理 ${res.deleted_files} 个文件(${res.deleted_dates.length} 天)`);
|
||||
} catch {
|
||||
message.error("清理失败");
|
||||
} finally {
|
||||
setCleaning(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="Trace 设置" open={open} onClose={onClose}
|
||||
width={400} destroyOnClose
|
||||
>
|
||||
{loading ? <Spin /> : settings && (
|
||||
<div>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<Text strong>日志开关</Text>
|
||||
<Switch
|
||||
checked={settings.enabled}
|
||||
onChange={(v) => handleToggle("enabled", v)}
|
||||
style={{ marginLeft: 12 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<Text strong>记录 SQL</Text>
|
||||
<Switch
|
||||
checked={settings.log_sql}
|
||||
onChange={(v) => handleToggle("log_sql", v)}
|
||||
style={{ marginLeft: 12 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<Text strong>记录参数</Text>
|
||||
<Switch
|
||||
checked={settings.log_params}
|
||||
onChange={(v) => handleToggle("log_params", v)}
|
||||
style={{ marginLeft: 12 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<Text strong>保留天数</Text>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Space>
|
||||
<InputNumber
|
||||
min={1} max={365}
|
||||
value={settings.retention_days}
|
||||
onChange={(v) => v && setSettings({ ...settings, retention_days: v })}
|
||||
/>
|
||||
<Button type="primary" size="small" loading={saving} onClick={handleSaveRetention}>
|
||||
保存
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<Text strong>日志目录</Text>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<Text type="secondary" copyable style={{ fontSize: 12 }}>{settings.log_dir}</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div>
|
||||
<Text strong>手动清理</Text>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Space direction="vertical" style={{ width: "100%" }}>
|
||||
<DatePicker.RangePicker
|
||||
style={{ width: "100%" }}
|
||||
value={cleanRange}
|
||||
onChange={(v) => setCleanRange(v as [dayjs.Dayjs, dayjs.Dayjs] | null)}
|
||||
/>
|
||||
<Button
|
||||
danger icon={<DeleteOutlined />}
|
||||
loading={cleaning} onClick={handleCleanup}
|
||||
block
|
||||
>
|
||||
清理选定范围
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
// ---- 请求列表列定义 ----
|
||||
|
||||
const requestColumns: ColumnsType<TraceRequest> = [
|
||||
{
|
||||
title: "时间", dataIndex: "timestamp", key: "timestamp", width: 170,
|
||||
render: fmtTime,
|
||||
},
|
||||
{
|
||||
title: "类型", dataIndex: "trace_type", key: "trace_type", width: 70,
|
||||
render: (v: TraceType) => {
|
||||
const colors: Record<TraceType, string> = { http: "blue", sse: "cyan", ws: "gold", job: "default" };
|
||||
return <Tag color={colors[v]}>{v.toUpperCase()}</Tag>;
|
||||
},
|
||||
},
|
||||
{ title: "方法", dataIndex: "method", key: "method", width: 70 },
|
||||
{
|
||||
title: "路径", dataIndex: "path", key: "path", ellipsis: true,
|
||||
render: (v: string) => <Tooltip title={v}>{v}</Tooltip>,
|
||||
},
|
||||
{
|
||||
title: "状态", dataIndex: "status_code", key: "status_code", width: 60,
|
||||
render: (v: number | null) => {
|
||||
if (v == null) return "—";
|
||||
const color = v >= 400 ? "red" : v >= 300 ? "orange" : "green";
|
||||
return <Tag color={color}>{v}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "耗时", dataIndex: "total_duration_ms", key: "duration", width: 90,
|
||||
render: fmtDuration,
|
||||
sorter: (a, b) => a.total_duration_ms - b.total_duration_ms,
|
||||
},
|
||||
{
|
||||
title: "DB", dataIndex: "db_query_count", key: "db", width: 50,
|
||||
render: (v: number) => v > 0 ? <Text type="secondary">{v}</Text> : "—",
|
||||
},
|
||||
{
|
||||
title: "错误", dataIndex: "error", key: "error", width: 60,
|
||||
render: (v: string | null) => v ? <Tag color="red">有</Tag> : null,
|
||||
},
|
||||
];
|
||||
|
||||
// ---- 主页面组件 ----
|
||||
|
||||
const DevTrace: React.FC = () => {
|
||||
// 数据状态
|
||||
const [dates, setDates] = useState<string[]>([]);
|
||||
const [requests, setRequests] = useState<TraceRequest[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [detail, setDetail] = useState<TraceDetail | null>(null);
|
||||
const [coverage, setCoverage] = useState<TraceCoverage | null>(null);
|
||||
|
||||
// 加载状态
|
||||
const [listLoading, setListLoading] = useState(false);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [coverageLoading, setCoverageLoading] = useState(false);
|
||||
|
||||
// 筛选状态
|
||||
const [filter, setFilter] = useState<TraceFilter>({
|
||||
date: dayjs().format("YYYY-MM-DD"),
|
||||
page: 1,
|
||||
page_size: 30,
|
||||
});
|
||||
|
||||
// UI 状态
|
||||
const [selectedRowKey, setSelectedRowKey] = useState<string | null>(null);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [hiddenIds, setHiddenIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// 加载日期列表
|
||||
const loadDates = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetchDates();
|
||||
setDates(res.dates);
|
||||
} catch {
|
||||
// 静默降级
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 加载请求列表
|
||||
const loadRequests = useCallback(async () => {
|
||||
setListLoading(true);
|
||||
try {
|
||||
const res = await fetchRequests(filter);
|
||||
setRequests(res.items);
|
||||
setTotal(res.total);
|
||||
} catch {
|
||||
message.error("加载请求列表失败");
|
||||
} finally {
|
||||
setListLoading(false);
|
||||
}
|
||||
}, [filter]);
|
||||
|
||||
// 加载覆盖率
|
||||
const loadCoverage = useCallback(async () => {
|
||||
setCoverageLoading(true);
|
||||
try {
|
||||
const res = await fetchCoverage();
|
||||
setCoverage(res);
|
||||
} catch {
|
||||
message.warning("覆盖率数据加载失败,可点击扫描按钮重试");
|
||||
} finally {
|
||||
setCoverageLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 手动扫描覆盖率
|
||||
const handleScan = async () => {
|
||||
setCoverageLoading(true);
|
||||
try {
|
||||
const res = await triggerCoverageScan();
|
||||
setCoverage(res);
|
||||
message.success("覆盖率扫描完成");
|
||||
} catch {
|
||||
message.error("扫描失败");
|
||||
} finally {
|
||||
setCoverageLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 点击行查看详情
|
||||
const handleRowClick = async (record: TraceRequest) => {
|
||||
setSelectedRowKey(record.request_id);
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
const res = await fetchRequestDetail(record.request_id);
|
||||
setDetail(res);
|
||||
} catch {
|
||||
message.error("加载详情失败");
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 初始化
|
||||
useEffect(() => { loadDates(); loadCoverage(); }, [loadDates, loadCoverage]);
|
||||
useEffect(() => { loadRequests(); }, [loadRequests]);
|
||||
|
||||
// 筛选变更辅助
|
||||
const updateFilter = (patch: Partial<TraceFilter>) => {
|
||||
setFilter((prev) => ({ ...prev, ...patch, page: 1 }));
|
||||
};
|
||||
|
||||
// 过滤掉被屏蔽的记录
|
||||
const visibleRequests = requests.filter((r) => !hiddenIds.has(r.request_id));
|
||||
|
||||
// 清空:把当前可见记录全部加入屏蔽集合(累积)
|
||||
const handleClearList = () => {
|
||||
setHiddenIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const r of requests) next.add(r.request_id);
|
||||
return next;
|
||||
});
|
||||
setDetail(null);
|
||||
setSelectedRowKey(null);
|
||||
};
|
||||
|
||||
// 取消清空:清空屏蔽集合
|
||||
const handleUnclearList = () => {
|
||||
setHiddenIds(new Set());
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 标题栏 */}
|
||||
<Row justify="space-between" align="middle" style={{ marginBottom: 12 }}>
|
||||
<Title level={4} style={{ margin: 0 }}>开发调试日志</Title>
|
||||
<Space>
|
||||
<Button icon={<DeleteOutlined />} onClick={handleClearList} disabled={visibleRequests.length === 0}>
|
||||
清空
|
||||
</Button>
|
||||
{hiddenIds.size > 0 && (
|
||||
<Button onClick={handleUnclearList}>
|
||||
取消清空({hiddenIds.size})
|
||||
</Button>
|
||||
)}
|
||||
<Button icon={<ReloadOutlined />} onClick={() => { loadRequests(); loadCoverage(); }} loading={listLoading}>
|
||||
刷新
|
||||
</Button>
|
||||
<Button icon={<SettingOutlined />} onClick={() => setSettingsOpen(true)}>
|
||||
设置
|
||||
</Button>
|
||||
</Space>
|
||||
</Row>
|
||||
|
||||
{/* 覆盖率状态栏 */}
|
||||
<CoverageBar coverage={coverage} loading={coverageLoading} onScan={handleScan} />
|
||||
|
||||
{/* 筛选栏 */}
|
||||
<div style={{ marginBottom: 12, background: "#fafafa", padding: "10px 12px", borderRadius: 4 }}>
|
||||
<Space wrap size={[8, 8]}>
|
||||
<Select
|
||||
placeholder="日期" style={{ width: 130 }}
|
||||
value={filter.date}
|
||||
onChange={(v) => updateFilter({ date: v })}
|
||||
options={dates.map((d) => ({ label: d, value: d }))}
|
||||
showSearch
|
||||
/>
|
||||
<TimePicker.RangePicker
|
||||
format="HH:mm"
|
||||
placeholder={["开始时间", "结束时间"]}
|
||||
onChange={(_, strs) => updateFilter({
|
||||
start_time: strs[0] || undefined,
|
||||
end_time: strs[1] || undefined,
|
||||
})}
|
||||
/>
|
||||
<Select
|
||||
placeholder="类型" style={{ width: 100 }} allowClear
|
||||
options={TRACE_TYPE_OPTIONS}
|
||||
onChange={(v) => updateFilter({ trace_type: v })}
|
||||
/>
|
||||
<Select
|
||||
placeholder="方法" style={{ width: 100 }} allowClear
|
||||
options={METHOD_OPTIONS.map((m) => ({ label: m, value: m }))}
|
||||
onChange={(v) => updateFilter({ method: v })}
|
||||
/>
|
||||
<Input
|
||||
placeholder="路径关键词" style={{ width: 160 }}
|
||||
prefix={<SearchOutlined />} allowClear
|
||||
onPressEnter={(e) => updateFilter({ path_contains: (e.target as HTMLInputElement).value || undefined })}
|
||||
onBlur={(e) => updateFilter({ path_contains: e.target.value || undefined })}
|
||||
/>
|
||||
<InputNumber
|
||||
placeholder="状态码" style={{ width: 90 }}
|
||||
min={100} max={599}
|
||||
onChange={(v) => updateFilter({ status_code: v ?? undefined })}
|
||||
/>
|
||||
<InputNumber
|
||||
placeholder="最小耗时(ms)" style={{ width: 130 }}
|
||||
min={0}
|
||||
onChange={(v) => updateFilter({ min_duration: v ?? undefined })}
|
||||
/>
|
||||
<Checkbox
|
||||
onChange={(e) => updateFilter({ has_error: e.target.checked || undefined })}
|
||||
>
|
||||
仅错误
|
||||
</Checkbox>
|
||||
<Select
|
||||
placeholder="Span 类型" style={{ width: 150 }} allowClear
|
||||
options={SPAN_TYPE_OPTIONS.map((s) => ({ label: s, value: s }))}
|
||||
onChange={(v) => updateFilter({ span_type: v })}
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* 左右分栏 */}
|
||||
<Row gutter={12} style={{ minHeight: 500 }}>
|
||||
{/* 左侧:请求列表 */}
|
||||
<Col span={14}>
|
||||
<Table<TraceRequest>
|
||||
columns={requestColumns}
|
||||
dataSource={visibleRequests}
|
||||
rowKey="request_id"
|
||||
size="small"
|
||||
loading={listLoading}
|
||||
pagination={{
|
||||
current: filter.page,
|
||||
pageSize: filter.page_size,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ["20", "30", "50", "100"],
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
onChange: (page, pageSize) => setFilter((prev) => ({ ...prev, page, page_size: pageSize })),
|
||||
}}
|
||||
onRow={(record) => ({
|
||||
onClick: () => handleRowClick(record),
|
||||
style: {
|
||||
cursor: "pointer",
|
||||
background: record.request_id === selectedRowKey ? "#e6f7ff" : undefined,
|
||||
},
|
||||
})}
|
||||
scroll={{ y: 520 }}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
{/* 右侧:Span 链路树 */}
|
||||
<Col span={10}>
|
||||
<div style={{
|
||||
border: "1px solid #f0f0f0", borderRadius: 4,
|
||||
padding: 12, minHeight: 520, maxHeight: 600,
|
||||
overflow: "auto", background: "#fff",
|
||||
}}>
|
||||
<SpanTree detail={detail} loading={detailLoading} />
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 设置面板 */}
|
||||
<SettingsDrawer open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DevTrace;
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
type CursorInfo, type RecentRun,
|
||||
} from '../api/etlStatus';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { Title } = Typography;
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
success: 'green', failed: 'red', running: 'blue', cancelled: 'orange',
|
||||
@@ -38,11 +38,8 @@ function formatDuration(ms: number | null): string {
|
||||
|
||||
const cursorColumns: ColumnsType<CursorInfo> = [
|
||||
{ title: '任务编码', dataIndex: 'task_code', key: 'task_code', render: (v: string) => <code>{v}</code> },
|
||||
{ title: '最后抓取时间', dataIndex: 'last_fetch_time', key: 'last_fetch_time', render: (v: string | null) => formatTime(v) },
|
||||
{
|
||||
title: '记录数', dataIndex: 'record_count', key: 'record_count', align: 'right',
|
||||
render: (v: number | null) => (v != null ? <Text strong>{v.toLocaleString()}</Text> : '—'),
|
||||
},
|
||||
{ title: '数据起始时间', dataIndex: 'last_start', key: 'last_start', render: (v: string | null) => formatTime(v) },
|
||||
{ title: '数据截止时间', dataIndex: 'last_end', key: 'last_end', render: (v: string | null) => formatTime(v) },
|
||||
];
|
||||
|
||||
const runColumns: ColumnsType<RecentRun> = [
|
||||
|
||||
121
apps/admin-web/src/pages/ETLTasks.tsx
Normal file
121
apps/admin-web/src/pages/ETLTasks.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* ETL 任务管理页面 — 合并 TaskConfig / QueueTab / ScheduleTab / ETLStatus 为 Tab 视图。
|
||||
*
|
||||
* - 5 个 Tab:config(发起)、queue(队列)、schedule(调度)、history(历史)、status(状态)
|
||||
* - Tab 切换通过 useSearchParams 同步 URL 查询参数 ?tab=config|queue|schedule|history|status
|
||||
* - destroyInactiveTabPane={false} 保持 Tab 状态不丢失
|
||||
*
|
||||
* CHANGE 2026-07-14 | Task 9.1:从占位页面替换为完整 Tab 视图实现
|
||||
* CHANGE 2026-03-25 | 将 TaskManager 内部子 Tab(队列/调度)提升到顶层,去掉历史 Tab
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { Tabs, Typography } from 'antd';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
SettingOutlined,
|
||||
UnorderedListOutlined,
|
||||
ClockCircleOutlined,
|
||||
HistoryOutlined,
|
||||
DashboardOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import TaskConfig from './TaskConfig';
|
||||
import { QueueTab, HistoryTab } from './TaskManager';
|
||||
import ScheduleTab from '../components/ScheduleTab';
|
||||
import ETLStatus from './ETLStatus';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
const VALID_TABS = ['config', 'queue', 'schedule', 'history', 'status'] as const;
|
||||
type TabKey = (typeof VALID_TABS)[number];
|
||||
const DEFAULT_TAB: TabKey = 'config';
|
||||
|
||||
function isValidTab(value: string | null): value is TabKey {
|
||||
return value != null && (VALID_TABS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
const ETLTasks: 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: 'config' as TabKey,
|
||||
label: (
|
||||
<span>
|
||||
<SettingOutlined style={{ marginRight: 6 }} />
|
||||
发起
|
||||
</span>
|
||||
),
|
||||
children: <TaskConfig />,
|
||||
},
|
||||
{
|
||||
key: 'queue' as TabKey,
|
||||
label: (
|
||||
<span>
|
||||
<UnorderedListOutlined style={{ marginRight: 6 }} />
|
||||
队列
|
||||
</span>
|
||||
),
|
||||
children: <QueueTab />,
|
||||
},
|
||||
{
|
||||
key: 'schedule' as TabKey,
|
||||
label: (
|
||||
<span>
|
||||
<ClockCircleOutlined style={{ marginRight: 6 }} />
|
||||
调度
|
||||
</span>
|
||||
),
|
||||
children: <ScheduleTab />,
|
||||
},
|
||||
{
|
||||
key: 'history' as TabKey,
|
||||
label: (
|
||||
<span>
|
||||
<HistoryOutlined style={{ marginRight: 6 }} />
|
||||
历史
|
||||
</span>
|
||||
),
|
||||
children: <HistoryTab />,
|
||||
},
|
||||
{
|
||||
key: 'status' as TabKey,
|
||||
label: (
|
||||
<span>
|
||||
<DashboardOutlined style={{ marginRight: 6 }} />
|
||||
状态
|
||||
</span>
|
||||
),
|
||||
children: <ETLStatus />,
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Title level={4} style={{ marginBottom: 16 }}>
|
||||
<UnorderedListOutlined style={{ marginRight: 8 }} />
|
||||
ETL 任务管理
|
||||
</Title>
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={handleTabChange}
|
||||
items={items}
|
||||
destroyInactiveTabPane={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ETLTasks;
|
||||
@@ -25,7 +25,7 @@ const Login: React.FC = () => {
|
||||
try {
|
||||
await login(values.username, values.password);
|
||||
message.success("登录成功");
|
||||
navigate("/", { replace: true });
|
||||
navigate("/dashboard", { replace: true });
|
||||
} catch (err: unknown) {
|
||||
const detail =
|
||||
(err as { response?: { data?: { detail?: string } } })?.response?.data
|
||||
|
||||
@@ -6,43 +6,16 @@
|
||||
* - 各环境服务状态 + 启停重启按钮
|
||||
* - 各环境 Git 状态 + pull / 同步依赖按钮
|
||||
* - 各环境 .env 配置查看(敏感值脱敏)
|
||||
*
|
||||
* CHANGE 2026-07-25 | admin-web-restructure 8.1
|
||||
* 拆分为 SystemResourceSection / ServiceStatusSection / GitStatusSection 三个子组件,
|
||||
* 本页面改为组合子组件,功能不变。
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import {
|
||||
Card,
|
||||
Row,
|
||||
Col,
|
||||
Tag,
|
||||
Button,
|
||||
Space,
|
||||
Statistic,
|
||||
Progress,
|
||||
Modal,
|
||||
message,
|
||||
Descriptions,
|
||||
Spin,
|
||||
Tooltip,
|
||||
Typography,
|
||||
Input,
|
||||
} from "antd";
|
||||
import {
|
||||
PlayCircleOutlined,
|
||||
PauseCircleOutlined,
|
||||
ReloadOutlined,
|
||||
CloudDownloadOutlined,
|
||||
SyncOutlined,
|
||||
FileTextOutlined,
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
DesktopOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import type {
|
||||
SystemInfo,
|
||||
ServiceStatus,
|
||||
GitInfo,
|
||||
} from "../api/opsPanel";
|
||||
import { Modal, message, Spin, Typography } from "antd";
|
||||
import { DesktopOutlined } from "@ant-design/icons";
|
||||
import type { SystemInfo, ServiceStatus, GitInfo } from "../api/opsPanel";
|
||||
import {
|
||||
fetchSystemInfo,
|
||||
fetchServicesStatus,
|
||||
@@ -52,28 +25,14 @@ import {
|
||||
restartService,
|
||||
gitPull,
|
||||
syncDeps,
|
||||
fetchEnvFile,
|
||||
} from "../api/opsPanel";
|
||||
import {
|
||||
SystemResourceSection,
|
||||
ServiceStatusSection,
|
||||
GitStatusSection,
|
||||
} from "../components/ops";
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
const { TextArea } = Input;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 工具函数 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** 秒数格式化为 "Xd Xh Xm" */
|
||||
function formatUptime(seconds: number | null): string {
|
||||
if (seconds == null) return "-";
|
||||
const d = Math.floor(seconds / 86400);
|
||||
const h = Math.floor((seconds % 86400) / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const parts: string[] = [];
|
||||
if (d > 0) parts.push(`${d}天`);
|
||||
if (h > 0) parts.push(`${h}时`);
|
||||
parts.push(`${m}分`);
|
||||
return parts.join(" ");
|
||||
}
|
||||
const { Title } = Typography;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 组件 */
|
||||
@@ -85,9 +44,6 @@ const OpsPanel: React.FC = () => {
|
||||
const [gitInfos, setGitInfos] = useState<GitInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [actionLoading, setActionLoading] = useState<Record<string, boolean>>({});
|
||||
const [envModalOpen, setEnvModalOpen] = useState(false);
|
||||
const [envModalContent, setEnvModalContent] = useState("");
|
||||
const [envModalTitle, setEnvModalTitle] = useState("");
|
||||
|
||||
// ---- 数据加载 ----
|
||||
|
||||
@@ -165,17 +121,6 @@ const OpsPanel: React.FC = () => {
|
||||
r.success ? message.success("依赖同步完成") : message.error(r.message);
|
||||
});
|
||||
|
||||
const handleViewEnv = async (env: string, label: string) => {
|
||||
try {
|
||||
const r = await fetchEnvFile(env);
|
||||
setEnvModalTitle(`${label} .env 配置`);
|
||||
setEnvModalContent(r.content);
|
||||
setEnvModalOpen(true);
|
||||
} catch {
|
||||
message.error("读取配置文件失败");
|
||||
}
|
||||
};
|
||||
|
||||
// ---- 渲染 ----
|
||||
|
||||
if (loading) {
|
||||
@@ -189,175 +134,23 @@ const OpsPanel: React.FC = () => {
|
||||
运维控制面板
|
||||
</Title>
|
||||
|
||||
{/* ---- 系统资源 ---- */}
|
||||
{system && (
|
||||
<Card size="small" title="服务器资源" style={{ marginBottom: 16 }}>
|
||||
<Row gutter={24}>
|
||||
<Col span={8}>
|
||||
<Statistic title="CPU 使用率" value={system.cpu_percent} suffix="%" />
|
||||
<Progress percent={system.cpu_percent} size="small" status={system.cpu_percent > 80 ? "exception" : "normal"} showInfo={false} />
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Statistic title="内存" value={system.memory_used_gb} suffix={`/ ${system.memory_total_gb} GB`} precision={1} />
|
||||
<Progress percent={system.memory_percent} size="small" status={system.memory_percent > 85 ? "exception" : "normal"} showInfo={false} />
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Statistic title="磁盘" value={system.disk_used_gb} suffix={`/ ${system.disk_total_gb} GB`} precision={1} />
|
||||
<Progress percent={system.disk_percent} size="small" status={system.disk_percent > 90 ? "exception" : "normal"} showInfo={false} />
|
||||
</Col>
|
||||
</Row>
|
||||
<Text type="secondary" style={{ fontSize: 12, marginTop: 8, display: "block" }}>
|
||||
开机时间:{new Date(system.boot_time).toLocaleString()}
|
||||
</Text>
|
||||
</Card>
|
||||
)}
|
||||
{system && <SystemResourceSection system={system} />}
|
||||
|
||||
{/* ---- 服务状态 ---- */}
|
||||
<Card size="small" title="服务状态" style={{ marginBottom: 16 }}>
|
||||
<Row gutter={16}>
|
||||
{services.map((svc) => (
|
||||
<Col span={12} key={svc.env}>
|
||||
<Card
|
||||
size="small"
|
||||
type="inner"
|
||||
title={
|
||||
<Space>
|
||||
{svc.running
|
||||
? <CheckCircleOutlined style={{ color: "#52c41a" }} />
|
||||
: <CloseCircleOutlined style={{ color: "#ff4d4f" }} />}
|
||||
{svc.label}
|
||||
<Tag color={svc.running ? "success" : "error"}>
|
||||
{svc.running ? "运行中" : "已停止"}
|
||||
</Tag>
|
||||
</Space>
|
||||
}
|
||||
extra={<Tag>:{svc.port}</Tag>}
|
||||
>
|
||||
{svc.running && (
|
||||
<Descriptions size="small" column={3} style={{ marginBottom: 12 }}>
|
||||
<Descriptions.Item label="PID">{svc.pid}</Descriptions.Item>
|
||||
<Descriptions.Item label="运行时长">
|
||||
<ClockCircleOutlined style={{ marginRight: 4 }} />
|
||||
{formatUptime(svc.uptime_seconds)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="内存">{svc.memory_mb ?? "-"} MB</Descriptions.Item>
|
||||
</Descriptions>
|
||||
)}
|
||||
<Space>
|
||||
{!svc.running && (
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<PlayCircleOutlined />}
|
||||
loading={actionLoading[`start-${svc.env}`]}
|
||||
onClick={() => handleStart(svc.env)}
|
||||
>
|
||||
启动
|
||||
</Button>
|
||||
)}
|
||||
{svc.running && (
|
||||
<>
|
||||
<Button
|
||||
danger
|
||||
size="small"
|
||||
icon={<PauseCircleOutlined />}
|
||||
loading={actionLoading[`stop-${svc.env}`]}
|
||||
onClick={() => handleStop(svc.env)}
|
||||
>
|
||||
停止
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<ReloadOutlined />}
|
||||
loading={actionLoading[`restart-${svc.env}`]}
|
||||
onClick={() => handleRestart(svc.env)}
|
||||
>
|
||||
重启
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</Card>
|
||||
<ServiceStatusSection
|
||||
services={services}
|
||||
actionLoading={actionLoading}
|
||||
onStart={handleStart}
|
||||
onStop={handleStop}
|
||||
onRestart={handleRestart}
|
||||
/>
|
||||
|
||||
{/* ---- Git 状态 & 配置 ---- */}
|
||||
<Card size="small" title="代码与配置" style={{ marginBottom: 16 }}>
|
||||
<Row gutter={16}>
|
||||
{gitInfos.map((git) => {
|
||||
const envCfg = services.find((s) => s.env === git.env);
|
||||
const label = envCfg?.label ?? git.env;
|
||||
return (
|
||||
<Col span={12} key={git.env}>
|
||||
<Card size="small" type="inner" title={label}>
|
||||
<Descriptions size="small" column={1} style={{ marginBottom: 12 }}>
|
||||
<Descriptions.Item label="分支">
|
||||
<Tag color="blue">{git.branch}</Tag>
|
||||
{git.has_local_changes && (
|
||||
<Tooltip title="工作区有未提交的变更">
|
||||
<Tag color="warning">有变更</Tag>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="最新提交">
|
||||
<Text code style={{ fontSize: 12 }}>{git.last_commit_hash}</Text>
|
||||
<Text type="secondary" style={{ marginLeft: 8, fontSize: 12 }}>
|
||||
{git.last_commit_message}
|
||||
</Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="提交时间">
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{git.last_commit_time}</Text>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Space>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<CloudDownloadOutlined />}
|
||||
loading={actionLoading[`pull-${git.env}`]}
|
||||
onClick={() => handlePull(git.env)}
|
||||
>
|
||||
Git Pull
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<SyncOutlined />}
|
||||
loading={actionLoading[`sync-${git.env}`]}
|
||||
onClick={() => handleSyncDeps(git.env)}
|
||||
>
|
||||
同步依赖
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<FileTextOutlined />}
|
||||
onClick={() => handleViewEnv(git.env, label)}
|
||||
>
|
||||
查看配置
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* ---- 配置查看弹窗 ---- */}
|
||||
<Modal
|
||||
title={envModalTitle}
|
||||
open={envModalOpen}
|
||||
onCancel={() => setEnvModalOpen(false)}
|
||||
footer={null}
|
||||
width={700}
|
||||
>
|
||||
<TextArea
|
||||
value={envModalContent}
|
||||
readOnly
|
||||
autoSize={{ minRows: 10, maxRows: 30 }}
|
||||
style={{ fontFamily: "monospace", fontSize: 12 }}
|
||||
/>
|
||||
</Modal>
|
||||
<GitStatusSection
|
||||
gitInfos={gitInfos}
|
||||
services={services}
|
||||
actionLoading={actionLoading}
|
||||
onPull={handlePull}
|
||||
onSyncDeps={handleSyncDeps}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
293
apps/admin-web/src/pages/PendingReview.tsx
Normal file
293
apps/admin-web/src/pages/PendingReview.tsx
Normal file
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* P18 待审核任务页面。
|
||||
*
|
||||
* 展示 status='pending_review' 的任务,支持重新分配和关闭操作。
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import {
|
||||
Table, Card, Typography, Button, Space, InputNumber, Tag, Tooltip,
|
||||
Modal, Input, Drawer, message,
|
||||
} from "antd";
|
||||
import {
|
||||
ReloadOutlined, ExclamationCircleOutlined, AuditOutlined,
|
||||
SwapOutlined, CloseCircleOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import dayjs from "dayjs";
|
||||
import {
|
||||
fetchPendingReviews, reassignTask, closeTask,
|
||||
fetchMemberTransferHistory,
|
||||
type PendingReviewItem, type PendingReviewQuery, type TransferLogItem,
|
||||
} from "../api/taskEngine";
|
||||
import { useAuthStore } from "../store/authStore";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { TextArea } = Input;
|
||||
|
||||
function formatTime(raw: string | null): string {
|
||||
if (!raw) return "—";
|
||||
return dayjs(raw).format("YYYY-MM-DD HH:mm");
|
||||
}
|
||||
|
||||
const PendingReview: React.FC = () => {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isSuperAdmin = user?.roles?.includes("super_admin") ?? false;
|
||||
|
||||
const [items, setItems] = useState<PendingReviewItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [query, setQuery] = useState<PendingReviewQuery>({ page: 1, page_size: 20 });
|
||||
|
||||
// 重新分配弹窗
|
||||
const [reassignVisible, setReassignVisible] = useState(false);
|
||||
const [reassignTaskId, setReassignTaskId] = useState<number | null>(null);
|
||||
const [toAssistantId, setToAssistantId] = useState<number | null>(null);
|
||||
const [reassigning, setReassigning] = useState(false);
|
||||
|
||||
// 关闭弹窗
|
||||
const [closeVisible, setCloseVisible] = useState(false);
|
||||
const [closeTaskId, setCloseTaskId] = useState<number | null>(null);
|
||||
const [closeReason, setCloseReason] = useState("");
|
||||
const [closing, setClosing] = useState(false);
|
||||
|
||||
// 转移历史抽屉
|
||||
const [historyVisible, setHistoryVisible] = useState(false);
|
||||
const [historyMemberId, setHistoryMemberId] = useState<number | null>(null);
|
||||
const [historyItems, setHistoryItems] = useState<TransferLogItem[]>([]);
|
||||
const [historyLoading, setHistoryLoading] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await fetchPendingReviews(query);
|
||||
setItems(data.items);
|
||||
setTotal(data.total);
|
||||
} catch {
|
||||
message.error("加载待审核任务失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleReassign = async () => {
|
||||
if (!reassignTaskId || !toAssistantId) return;
|
||||
setReassigning(true);
|
||||
try {
|
||||
await reassignTask(reassignTaskId, toAssistantId);
|
||||
message.success("重新分配成功");
|
||||
setReassignVisible(false);
|
||||
setToAssistantId(null);
|
||||
load();
|
||||
} catch {
|
||||
message.error("重新分配失败");
|
||||
} finally {
|
||||
setReassigning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = async () => {
|
||||
if (!closeTaskId || !closeReason.trim()) return;
|
||||
setClosing(true);
|
||||
try {
|
||||
await closeTask(closeTaskId, closeReason.trim());
|
||||
message.success("任务已关闭");
|
||||
setCloseVisible(false);
|
||||
setCloseReason("");
|
||||
load();
|
||||
} catch {
|
||||
message.error("关闭任务失败");
|
||||
} finally {
|
||||
setClosing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const showHistory = async (memberId: number) => {
|
||||
setHistoryMemberId(memberId);
|
||||
setHistoryVisible(true);
|
||||
setHistoryLoading(true);
|
||||
try {
|
||||
const data = await fetchMemberTransferHistory(memberId);
|
||||
setHistoryItems(data);
|
||||
} catch {
|
||||
message.error("加载转移历史失败");
|
||||
} finally {
|
||||
setHistoryLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<PendingReviewItem> = [
|
||||
{
|
||||
title: "创建时间", dataIndex: "created_at", key: "created_at", width: 160,
|
||||
render: (v: string) => formatTime(v),
|
||||
},
|
||||
{
|
||||
title: "门店", dataIndex: "site_name", key: "site_name", width: 120,
|
||||
render: (v: string, r) => v || `#${r.site_id}`,
|
||||
},
|
||||
{
|
||||
title: "客户", key: "member", width: 140,
|
||||
render: (_: unknown, r) => (
|
||||
<Tooltip title={`ID: ${r.member_id}`}>
|
||||
<a onClick={() => showHistory(r.member_id)}>
|
||||
{r.member_name || `会员#${r.member_id}`}
|
||||
</a>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "当前助教", key: "assistant", width: 120,
|
||||
render: (_: unknown, r) => r.assistant_name || `#${r.assistant_id}`,
|
||||
},
|
||||
{
|
||||
title: "任务类型", dataIndex: "task_type_label", key: "type", width: 120,
|
||||
render: (v: string) => <Tag color="blue">{v || "未知"}</Tag>,
|
||||
},
|
||||
{
|
||||
title: "转移次数", dataIndex: "transfer_count", key: "tc", width: 90,
|
||||
render: (v: number) => (
|
||||
<Tag color={v >= 2 ? "red" : "default"} icon={v >= 2 ? <ExclamationCircleOutlined /> : undefined}>
|
||||
{v}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "优先级分", dataIndex: "priority_score", key: "score", width: 90,
|
||||
render: (v: number | null) => v != null ? v.toFixed(2) : "—",
|
||||
},
|
||||
];
|
||||
|
||||
// 超级管理员才显示操作列
|
||||
if (isSuperAdmin) {
|
||||
columns.push({
|
||||
title: "操作", key: "action", width: 180, fixed: "right",
|
||||
render: (_: unknown, r) => (
|
||||
<Space size={4}>
|
||||
<Button
|
||||
type="primary" size="small" icon={<SwapOutlined />}
|
||||
onClick={() => { setReassignTaskId(r.id); setReassignVisible(true); }}
|
||||
>
|
||||
分配
|
||||
</Button>
|
||||
<Button
|
||||
danger size="small" icon={<CloseCircleOutlined />}
|
||||
onClick={() => { setCloseTaskId(r.id); setCloseVisible(true); }}
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
<AuditOutlined style={{ marginRight: 8 }} />
|
||||
待审核任务
|
||||
</Title>
|
||||
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>刷新</Button>
|
||||
</div>
|
||||
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Space wrap>
|
||||
<InputNumber
|
||||
placeholder="门店 ID"
|
||||
style={{ width: 140 }}
|
||||
onChange={(v) => setQuery((q) => ({ ...q, site_id: (v as number) ?? undefined, page: 1 }))}
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card size="small">
|
||||
<Table<PendingReviewItem>
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={items}
|
||||
loading={loading}
|
||||
size="small"
|
||||
scroll={{ x: 1100 }}
|
||||
pagination={{
|
||||
current: query.page,
|
||||
pageSize: query.page_size,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
onChange: (page, pageSize) => setQuery((q) => ({ ...q, page, page_size: pageSize })),
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 重新分配弹窗 */}
|
||||
<Modal
|
||||
title="重新分配任务"
|
||||
open={reassignVisible}
|
||||
onOk={handleReassign}
|
||||
onCancel={() => { setReassignVisible(false); setToAssistantId(null); }}
|
||||
confirmLoading={reassigning}
|
||||
okButtonProps={{ disabled: !toAssistantId }}
|
||||
>
|
||||
<Text>请输入目标助教 ID:</Text>
|
||||
<InputNumber
|
||||
style={{ width: "100%", marginTop: 8 }}
|
||||
placeholder="目标助教 ID"
|
||||
value={toAssistantId}
|
||||
onChange={(v) => setToAssistantId(v)}
|
||||
/>
|
||||
<Text type="secondary" style={{ display: "block", marginTop: 8, fontSize: 12 }}>
|
||||
提示:POOL 为空时无法自动推荐候选助教。如需强制指定,操作将标记为 manual_override。
|
||||
</Text>
|
||||
</Modal>
|
||||
|
||||
{/* 关闭任务弹窗 */}
|
||||
<Modal
|
||||
title="关闭任务"
|
||||
open={closeVisible}
|
||||
onOk={handleClose}
|
||||
onCancel={() => { setCloseVisible(false); setCloseReason(""); }}
|
||||
confirmLoading={closing}
|
||||
okButtonProps={{ disabled: !closeReason.trim(), danger: true }}
|
||||
okText="确认关闭"
|
||||
>
|
||||
<Text>请填写关闭原因:</Text>
|
||||
<TextArea
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
showCount
|
||||
style={{ marginTop: 8 }}
|
||||
value={closeReason}
|
||||
onChange={(e) => setCloseReason(e.target.value)}
|
||||
placeholder="例如:客户已流失,无需继续跟进"
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
{/* 转移历史抽屉 */}
|
||||
<Drawer
|
||||
title={`会员 #${historyMemberId} 转移历史`}
|
||||
open={historyVisible}
|
||||
onClose={() => setHistoryVisible(false)}
|
||||
width={600}
|
||||
>
|
||||
<Table<TransferLogItem>
|
||||
rowKey="id"
|
||||
dataSource={historyItems}
|
||||
loading={historyLoading}
|
||||
size="small"
|
||||
pagination={false}
|
||||
columns={[
|
||||
{ title: "时间", dataIndex: "created_at", render: (v: string) => formatTime(v), width: 140 },
|
||||
{ title: "原助教", key: "from", render: (_: unknown, r: TransferLogItem) => r.from_assistant_name || `#${r.from_assistant_id}`, width: 100 },
|
||||
{ title: "新助教", key: "to", render: (_: unknown, r: TransferLogItem) => r.to_assistant_name || `#${r.to_assistant_id}`, width: 100 },
|
||||
{ title: "原因", dataIndex: "transfer_reason", width: 120 },
|
||||
{ title: "得分", dataIndex: "transfer_score", render: (v: number | null) => v != null ? v.toFixed(2) : "—", width: 80 },
|
||||
]}
|
||||
/>
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PendingReview;
|
||||
@@ -47,7 +47,7 @@ import { useNavigate } from "react-router-dom";
|
||||
import TaskSelector from "../components/TaskSelector";
|
||||
import { validateTaskConfig, fetchFlows } from "../api/tasks";
|
||||
import type { FlowDef, ProcessingModeDef } from "../api/tasks";
|
||||
import { submitToQueue, executeDirectly } from "../api/execution";
|
||||
import { submitToQueue, executeDirectly, cleanupOutput } from "../api/execution";
|
||||
import { createSchedule } from "../api/schedules";
|
||||
import { useAuthStore } from "../store/authStore";
|
||||
import BusinessDayHint from "../components/BusinessDayHint";
|
||||
@@ -55,6 +55,7 @@ import type { RadioChangeEvent } from "antd";
|
||||
import type { Dayjs } from "dayjs";
|
||||
import dayjs from "dayjs";
|
||||
import type { TaskConfig as TaskConfigType, ScheduleConfig } from "../types";
|
||||
import type { MinRunIntervalItem } from "../types";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { TextArea } = Input;
|
||||
@@ -228,6 +229,7 @@ const TaskConfig: React.FC = () => {
|
||||
/* ---------- 任务选择 ---------- */
|
||||
const [selectedTasks, setSelectedTasks] = useState<string[]>([]);
|
||||
const [selectedDwdTables, setSelectedDwdTables] = useState<string[]>([]);
|
||||
const [taskIntervals, setTaskIntervals] = useState<Record<string, MinRunIntervalItem>>({});
|
||||
|
||||
/* ---------- 高级选项 ---------- */
|
||||
const [dryRun, setDryRun] = useState(false);
|
||||
@@ -320,12 +322,26 @@ const TaskConfig: React.FC = () => {
|
||||
/* ---------- 事件处理 ---------- */
|
||||
const handleFlowChange = (e: RadioChangeEvent) => setFlow(e.target.value);
|
||||
|
||||
// CHANGE 2026-03-27 | 包含 ODS 层的 flow 执行前清理输出目录,每类任务只保留最近 10 个运行记录
|
||||
const tryCleanupOutput = async () => {
|
||||
if (!layers.includes("ODS")) return;
|
||||
try {
|
||||
const result = await cleanupOutput();
|
||||
if (result.dirs_deleted > 0) {
|
||||
message.info(`已清理 ${result.dirs_deleted} 个旧运行记录`);
|
||||
}
|
||||
} catch {
|
||||
message.warning("输出目录清理失败,不影响任务执行");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitToQueue = async () => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await tryCleanupOutput();
|
||||
await submitToQueue(buildTaskConfig());
|
||||
message.success("已提交到执行队列");
|
||||
navigate("/task-manager");
|
||||
navigate("/etl-tasks?tab=queue");
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : "提交失败";
|
||||
message.error(`提交到队列失败:${msg}`);
|
||||
@@ -334,12 +350,14 @@ const TaskConfig: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// CHANGE 2026-03-27 | 直接执行后跳转历史 tab 并自动打开任务详情
|
||||
const handleExecuteDirectly = async () => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await executeDirectly(buildTaskConfig());
|
||||
await tryCleanupOutput();
|
||||
const { execution_id } = await executeDirectly(buildTaskConfig());
|
||||
message.success("任务已开始执行");
|
||||
navigate("/task-manager");
|
||||
navigate(`/etl-tasks?tab=history&openExecution=${execution_id}`);
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : "执行失败";
|
||||
message.error(`直接执行失败:${msg}`);
|
||||
@@ -399,6 +417,7 @@ const TaskConfig: React.FC = () => {
|
||||
task_config: taskConfig,
|
||||
schedule_config: scheduleConfig,
|
||||
run_immediately: !!values.run_immediately,
|
||||
min_run_intervals: Object.keys(taskIntervals).length > 0 ? taskIntervals : undefined,
|
||||
});
|
||||
message.success("调度任务已创建");
|
||||
setScheduleModalOpen(false);
|
||||
@@ -681,13 +700,15 @@ const TaskConfig: React.FC = () => {
|
||||
</Card>
|
||||
|
||||
{/* ---- 任务选择(含 DWD 表过滤) ---- */}
|
||||
<Card size="small" title="任务选择" style={cardStyle}>
|
||||
<Card size="small" title={<Space size={8}>任务选择<Text type="secondary" style={{ fontSize: 11, fontWeight: 400 }}>右侧可设置每个任务的最小执行间隔</Text></Space>} style={cardStyle}>
|
||||
<TaskSelector
|
||||
layers={layers}
|
||||
selectedTasks={selectedTasks}
|
||||
onTasksChange={setSelectedTasks}
|
||||
selectedDwdTables={selectedDwdTables}
|
||||
onDwdTablesChange={setSelectedDwdTables}
|
||||
taskIntervals={taskIntervals}
|
||||
onTaskIntervalsChange={setTaskIntervals}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
|
||||
353
apps/admin-web/src/pages/TaskEngineConfig.tsx
Normal file
353
apps/admin-web/src/pages/TaskEngineConfig.tsx
Normal file
@@ -0,0 +1,353 @@
|
||||
/**
|
||||
* P18 任务引擎参数管理页面。
|
||||
*
|
||||
* 展示 biz.cfg_task_generator_params 全局默认 + 门店覆盖参数。
|
||||
* 超级管理员可编辑/新增/删除;门店管理员只读。
|
||||
* 权重参数(w_rs/w_ms/w_ml)以卡片形式整体编辑,后端联合校验。
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import {
|
||||
Table, Card, Typography, Button, Space, Tag, InputNumber,
|
||||
Modal, Select, Popconfirm, Tooltip, message,
|
||||
} from "antd";
|
||||
import {
|
||||
ReloadOutlined, SettingOutlined, PlusOutlined,
|
||||
EditOutlined, DeleteOutlined, SaveOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import dayjs from "dayjs";
|
||||
import {
|
||||
fetchConfigParams, updateConfigParam, createConfigParam, deleteConfigParam,
|
||||
type ConfigParam,
|
||||
} from "../api/taskEngine";
|
||||
import { useAuthStore } from "../store/authStore";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
/** 参数中文描述映射 */
|
||||
const PARAM_LABELS: Record<string, string> = {
|
||||
high_priority_recall_threshold: "高优先召回阈值",
|
||||
priority_recall_threshold: "优先召回阈值",
|
||||
rs_min_for_relationship: "关系构建 RS 下限",
|
||||
rs_max_for_relationship: "关系构建 RS 上限",
|
||||
consecutive_recall_fail_cycles: "连续失败触发转移轮数",
|
||||
min_wbi_for_transfer: "触发转移最低 WBI",
|
||||
guard_assistant_coverage_ratio: "助教绑定率保护阈值",
|
||||
guard_new_assistant_days: "新助教入驻保护天数",
|
||||
transfer_score_w_rs: "转移排序 RS 权重",
|
||||
transfer_score_w_ms: "转移排序 MS 权重",
|
||||
transfer_score_w_ml: "转移排序 ML 权重",
|
||||
max_transfer_count: "单客户最大转移次数",
|
||||
follow_up_visit_retention_hours: "回访任务保留时长(h)",
|
||||
};
|
||||
|
||||
const WEIGHT_KEYS = ["transfer_score_w_rs", "transfer_score_w_ms", "transfer_score_w_ml"];
|
||||
|
||||
const TaskEngineConfig: React.FC = () => {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isSuperAdmin = user?.roles?.includes("super_admin") ?? false;
|
||||
|
||||
const [params, setParams] = useState<ConfigParam[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 行内编辑
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [editValue, setEditValue] = useState<number>(0);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// 新增弹窗
|
||||
const [addVisible, setAddVisible] = useState(false);
|
||||
const [addSiteId, setAddSiteId] = useState<number | null>(null);
|
||||
const [addKey, setAddKey] = useState<string>("");
|
||||
const [addValue, setAddValue] = useState<number>(0);
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
// 权重卡片编辑
|
||||
const [weightVisible, setWeightVisible] = useState(false);
|
||||
const [weightSiteId, setWeightSiteId] = useState<number | null>(null);
|
||||
const [wRs, setWRs] = useState(0.5);
|
||||
const [wMs, setWMs] = useState(0.3);
|
||||
const [wMl, setWMl] = useState(0.2);
|
||||
const [weightSaving, setWeightSaving] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await fetchConfigParams();
|
||||
setParams(data.params);
|
||||
} catch {
|
||||
message.error("加载参数配置失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (editingId == null) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await updateConfigParam(editingId, editValue);
|
||||
message.success("参数已更新");
|
||||
setEditingId(null);
|
||||
load();
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail;
|
||||
message.error(msg || "更新失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!addSiteId || !addKey) return;
|
||||
setAdding(true);
|
||||
try {
|
||||
await createConfigParam(addSiteId, addKey, addValue);
|
||||
message.success("门店覆盖参数已添加");
|
||||
setAddVisible(false);
|
||||
setAddKey("");
|
||||
setAddValue(0);
|
||||
load();
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail;
|
||||
message.error(msg || "添加失败");
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (paramId: number) => {
|
||||
try {
|
||||
await deleteConfigParam(paramId);
|
||||
message.success("门店覆盖已删除");
|
||||
load();
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail;
|
||||
message.error(msg || "删除失败");
|
||||
}
|
||||
};
|
||||
|
||||
/** 打开权重卡片编辑弹窗 */
|
||||
const openWeightEditor = (siteId: number | null) => {
|
||||
const siteParams = params.filter(
|
||||
(p) => p.site_id === siteId && WEIGHT_KEYS.includes(p.param_key),
|
||||
);
|
||||
const findVal = (key: string) => siteParams.find((p) => p.param_key === key)?.param_value ?? 0;
|
||||
setWeightSiteId(siteId);
|
||||
setWRs(findVal("transfer_score_w_rs"));
|
||||
setWMs(findVal("transfer_score_w_ms"));
|
||||
setWMl(findVal("transfer_score_w_ml"));
|
||||
setWeightVisible(true);
|
||||
};
|
||||
|
||||
const handleWeightSave = async () => {
|
||||
const sum = wRs + wMs + wMl;
|
||||
if (Math.abs(sum - 1.0) > 0.001) {
|
||||
message.error(`权重之和必须为 1.0,当前为 ${sum.toFixed(4)}`);
|
||||
return;
|
||||
}
|
||||
setWeightSaving(true);
|
||||
try {
|
||||
// 逐个更新三个权重参数
|
||||
const weightParams = params.filter(
|
||||
(p) => p.site_id === weightSiteId && WEIGHT_KEYS.includes(p.param_key),
|
||||
);
|
||||
const valMap: Record<string, number> = {
|
||||
transfer_score_w_rs: wRs,
|
||||
transfer_score_w_ms: wMs,
|
||||
transfer_score_w_ml: wMl,
|
||||
};
|
||||
for (const wp of weightParams) {
|
||||
await updateConfigParam(wp.id, valMap[wp.param_key]);
|
||||
}
|
||||
message.success("权重配置已更新");
|
||||
setWeightVisible(false);
|
||||
load();
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail;
|
||||
message.error(msg || "权重更新失败");
|
||||
} finally {
|
||||
setWeightSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<ConfigParam> = [
|
||||
{
|
||||
title: "参数", dataIndex: "param_key", key: "param_key", width: 220,
|
||||
render: (v: string) => (
|
||||
<Tooltip title={v}>
|
||||
<Text strong>{PARAM_LABELS[v] || v}</Text>
|
||||
<br />
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>{v}</Text>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "门店", key: "site", width: 120,
|
||||
render: (_: unknown, r) => r.site_id == null
|
||||
? <Tag color="blue">全局默认</Tag>
|
||||
: <span>{r.site_name || `#${r.site_id}`}</span>,
|
||||
},
|
||||
{
|
||||
title: "参数值", key: "value", width: 160,
|
||||
render: (_: unknown, r) => {
|
||||
if (editingId === r.id) {
|
||||
return (
|
||||
<Space>
|
||||
<InputNumber
|
||||
size="small"
|
||||
value={editValue}
|
||||
onChange={(v) => v != null && setEditValue(v)}
|
||||
step={WEIGHT_KEYS.includes(r.param_key) ? 0.01 : 1}
|
||||
/>
|
||||
<Button size="small" type="primary" icon={<SaveOutlined />} loading={saving} onClick={handleSave} />
|
||||
<Button size="small" onClick={() => setEditingId(null)}>取消</Button>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
return <Text>{r.param_value}</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "说明", dataIndex: "description", key: "desc", width: 200,
|
||||
render: (v: string | null) => v || "—",
|
||||
},
|
||||
{
|
||||
title: "更新时间", dataIndex: "updated_at", key: "updated_at", width: 160,
|
||||
render: (v: string) => dayjs(v).format("YYYY-MM-DD HH:mm"),
|
||||
},
|
||||
];
|
||||
|
||||
if (isSuperAdmin) {
|
||||
columns.push({
|
||||
title: "操作", key: "action", width: 160, fixed: "right",
|
||||
render: (_: unknown, r) => {
|
||||
// 权重参数用卡片编辑
|
||||
if (WEIGHT_KEYS.includes(r.param_key)) {
|
||||
return (
|
||||
<Button
|
||||
size="small" icon={<EditOutlined />}
|
||||
onClick={() => openWeightEditor(r.site_id)}
|
||||
>
|
||||
权重编辑
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Space size={4}>
|
||||
<Button
|
||||
size="small" icon={<EditOutlined />}
|
||||
onClick={() => { setEditingId(r.id); setEditValue(r.param_value); }}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
{r.site_id != null && (
|
||||
<Popconfirm title="确认删除此门店覆盖?" onConfirm={() => handleDelete(r.id)}>
|
||||
<Button size="small" danger icon={<DeleteOutlined />}>删除</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
<SettingOutlined style={{ marginRight: 8 }} />
|
||||
任务引擎参数管理
|
||||
</Title>
|
||||
<Space>
|
||||
{isSuperAdmin && (
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setAddVisible(true)}>
|
||||
新增门店覆盖
|
||||
</Button>
|
||||
)}
|
||||
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>刷新</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Card size="small">
|
||||
<Table<ConfigParam>
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={params}
|
||||
loading={loading}
|
||||
size="small"
|
||||
scroll={{ x: 1000 }}
|
||||
pagination={false}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 新增门店覆盖弹窗 */}
|
||||
<Modal
|
||||
title="新增门店覆盖参数"
|
||||
open={addVisible}
|
||||
onOk={handleAdd}
|
||||
onCancel={() => setAddVisible(false)}
|
||||
confirmLoading={adding}
|
||||
okButtonProps={{ disabled: !addSiteId || !addKey }}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: "100%" }}>
|
||||
<div>
|
||||
<Text>门店 ID:</Text>
|
||||
<InputNumber style={{ width: "100%" }} value={addSiteId} onChange={(v) => setAddSiteId(v)} />
|
||||
</div>
|
||||
<div>
|
||||
<Text>参数名:</Text>
|
||||
<Select
|
||||
style={{ width: "100%" }}
|
||||
value={addKey || undefined}
|
||||
onChange={(v) => setAddKey(v)}
|
||||
placeholder="选择参数"
|
||||
options={Object.entries(PARAM_LABELS).map(([k, label]) => ({ value: k, label: `${label} (${k})` }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Text>参数值:</Text>
|
||||
<InputNumber style={{ width: "100%" }} value={addValue} onChange={(v) => v != null && setAddValue(v)} />
|
||||
</div>
|
||||
</Space>
|
||||
</Modal>
|
||||
|
||||
{/* 权重卡片编辑弹窗 */}
|
||||
<Modal
|
||||
title={`权重配置${weightSiteId != null ? ` — 门店 #${weightSiteId}` : "(全局)"}`}
|
||||
open={weightVisible}
|
||||
onOk={handleWeightSave}
|
||||
onCancel={() => setWeightVisible(false)}
|
||||
confirmLoading={weightSaving}
|
||||
>
|
||||
<Text type="secondary" style={{ display: "block", marginBottom: 12 }}>
|
||||
三项权重之和必须等于 1.0(容差 0.001)
|
||||
</Text>
|
||||
<Space direction="vertical" style={{ width: "100%" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<Text style={{ width: 120 }}>RS 权重 (w_rs):</Text>
|
||||
<InputNumber value={wRs} onChange={(v) => v != null && setWRs(v)} step={0.05} min={0} max={1} style={{ flex: 1 }} />
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<Text style={{ width: 120 }}>MS 权重 (w_ms):</Text>
|
||||
<InputNumber value={wMs} onChange={(v) => v != null && setWMs(v)} step={0.05} min={0} max={1} style={{ flex: 1 }} />
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<Text style={{ width: 120 }}>ML 权重 (w_ml):</Text>
|
||||
<InputNumber value={wMl} onChange={(v) => v != null && setWMl(v)} step={0.05} min={0} max={1} style={{ flex: 1 }} />
|
||||
</div>
|
||||
<div style={{ textAlign: "right", marginTop: 8 }}>
|
||||
<Text type={Math.abs(wRs + wMs + wMl - 1.0) > 0.001 ? "danger" : "success"}>
|
||||
当前合计:{(wRs + wMs + wMl).toFixed(4)}
|
||||
</Text>
|
||||
</div>
|
||||
</Space>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskEngineConfig;
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Tabs, Table, Tag, Button, Popconfirm, Space, message, Drawer,
|
||||
Typography, Descriptions, Empty, Spin,
|
||||
@@ -14,12 +15,12 @@ import {
|
||||
import {
|
||||
ReloadOutlined, DeleteOutlined, StopOutlined,
|
||||
UnorderedListOutlined, ClockCircleOutlined, HistoryOutlined,
|
||||
FileTextOutlined,
|
||||
FileTextOutlined, PlayCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import type { QueuedTask, ExecutionLog } from '../types';
|
||||
import {
|
||||
fetchQueue, fetchHistory, deleteFromQueue, cancelExecution,
|
||||
fetchQueue, fetchHistory, deleteFromQueue, cancelExecution, rerunExecution,
|
||||
} from '../api/execution';
|
||||
import { apiClient } from '../api/client';
|
||||
import LogStream from '../components/LogStream';
|
||||
@@ -37,6 +38,7 @@ const STATUS_COLOR: Record<string, string> = {
|
||||
success: 'success',
|
||||
failed: 'error',
|
||||
cancelled: 'warning',
|
||||
interrupted: 'volcano',
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -62,7 +64,7 @@ function fmtDuration(ms: number | null | undefined): string {
|
||||
/* 队列 Tab */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const QueueTab: React.FC = () => {
|
||||
export const QueueTab: React.FC = () => {
|
||||
const [data, setData] = useState<QueuedTask[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -236,7 +238,7 @@ const QueueTab: React.FC = () => {
|
||||
/* 历史 Tab */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const HistoryTab: React.FC = () => {
|
||||
export const HistoryTab: React.FC = () => {
|
||||
const [data, setData] = useState<ExecutionLog[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [detail, setDetail] = useState<ExecutionLog | null>(null);
|
||||
@@ -263,6 +265,16 @@ const HistoryTab: React.FC = () => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// CHANGE 2026-03-22 | 重新执行历史任务
|
||||
const handleRerun = useCallback(async (id: string) => {
|
||||
try {
|
||||
const { execution_id } = await rerunExecution(id);
|
||||
message.success(`已重新执行,新 ID: ${execution_id.slice(0, 8)}…`);
|
||||
load();
|
||||
} catch { message.error('重新执行失败'); }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try { setData(await fetchHistory()); }
|
||||
@@ -330,6 +342,23 @@ const HistoryTab: React.FC = () => {
|
||||
}
|
||||
}, [closeHistoryWs, load]);
|
||||
|
||||
// CHANGE 2026-03-27 | 支持 URL 参数 openExecution 自动打开任务详情
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const openExecutionHandled = useRef(false);
|
||||
useEffect(() => {
|
||||
const openId = searchParams.get('openExecution');
|
||||
if (!openId || openExecutionHandled.current || loading || data.length === 0) return;
|
||||
openExecutionHandled.current = true;
|
||||
const target = data.find((r) => r.id === openId);
|
||||
if (target) {
|
||||
handleRowClick(target);
|
||||
} else {
|
||||
handleRowClick({ id: openId, status: 'running' } as ExecutionLog);
|
||||
}
|
||||
searchParams.delete('openExecution');
|
||||
setSearchParams(searchParams, { replace: true });
|
||||
}, [data, loading, searchParams, setSearchParams, handleRowClick]);
|
||||
|
||||
const columns: ColumnsType<ExecutionLog> = [
|
||||
{
|
||||
title: '执行 ID', dataIndex: 'id', key: 'id', width: 120,
|
||||
@@ -366,19 +395,25 @@ const HistoryTab: React.FC = () => {
|
||||
) : '—',
|
||||
},
|
||||
{
|
||||
title: '操作', key: 'action', width: 80, align: 'center',
|
||||
render: (_: unknown, record: ExecutionLog) => {
|
||||
if (record.status === 'running') {
|
||||
return (
|
||||
title: '操作', key: 'action', width: 140, align: 'center',
|
||||
render: (_: unknown, record: ExecutionLog) => (
|
||||
<Space size={0}>
|
||||
{record.status === 'running' && (
|
||||
<Popconfirm title="确认终止该任务?" onConfirm={(e) => { e?.stopPropagation(); handleCancelHistory(record.id); }} onCancel={(e) => e?.stopPropagation()}>
|
||||
<Button type="link" danger icon={<StopOutlined />} size="small" onClick={(e) => e.stopPropagation()}>
|
||||
终止
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
)}
|
||||
{record.status !== 'running' && (
|
||||
<Popconfirm title="确认重新执行该任务?" onConfirm={(e) => { e?.stopPropagation(); handleRerun(record.id); }} onCancel={(e) => e?.stopPropagation()}>
|
||||
<Button type="link" icon={<PlayCircleOutlined />} size="small" onClick={(e) => e.stopPropagation()}>
|
||||
重新执行
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
1134
apps/admin-web/src/pages/TenantAdmins/index.tsx
Normal file
1134
apps/admin-web/src/pages/TenantAdmins/index.tsx
Normal file
File diff suppressed because it is too large
Load Diff
181
apps/admin-web/src/pages/TransferLog.tsx
Normal file
181
apps/admin-web/src/pages/TransferLog.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* P18 客户转移日志页面。
|
||||
*
|
||||
* 展示 biz.coach_task_transfer_log 分页列表,支持门店/时间/助教筛选。
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import {
|
||||
Table, Card, Typography, Button, Space, DatePicker, InputNumber,
|
||||
Tag, Tooltip, message,
|
||||
} from "antd";
|
||||
import {
|
||||
ReloadOutlined, SwapOutlined, CheckCircleOutlined, CloseCircleOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import dayjs from "dayjs";
|
||||
import {
|
||||
fetchTransferLogs, type TransferLogItem, type TransferLogQuery,
|
||||
} from "../api/taskEngine";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
function formatTime(raw: string | null): string {
|
||||
if (!raw) return "—";
|
||||
return dayjs(raw).format("YYYY-MM-DD HH:mm");
|
||||
}
|
||||
|
||||
/** guard_checks JSON → 三项检查标签 */
|
||||
function renderGuardChecks(checks: Record<string, unknown> | null) {
|
||||
if (!checks) return <Text type="secondary">—</Text>;
|
||||
return (
|
||||
<Space size={4} wrap>
|
||||
{Object.entries(checks).map(([k, v]) => (
|
||||
<Tag
|
||||
key={k}
|
||||
color={v ? "success" : "error"}
|
||||
icon={v ? <CheckCircleOutlined /> : <CloseCircleOutlined />}
|
||||
>
|
||||
{k}
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
const REASON_LABELS: Record<string, string> = {
|
||||
consecutive_recall_fail: "连续召回失败",
|
||||
manual_reassign: "人工重新分配",
|
||||
ownership_change: "归属变更",
|
||||
};
|
||||
|
||||
const TransferLog: React.FC = () => {
|
||||
const [items, setItems] = useState<TransferLogItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [query, setQuery] = useState<TransferLogQuery>({ page: 1, page_size: 20 });
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await fetchTransferLogs(query);
|
||||
setItems(data.items);
|
||||
setTotal(data.total);
|
||||
} catch {
|
||||
message.error("加载转移日志失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const columns: ColumnsType<TransferLogItem> = [
|
||||
{
|
||||
title: "转移时间", dataIndex: "created_at", key: "created_at", width: 160,
|
||||
render: (v: string) => formatTime(v),
|
||||
},
|
||||
{
|
||||
title: "门店", dataIndex: "site_name", key: "site_name", width: 120,
|
||||
render: (v: string, r) => v || `#${r.site_id}`,
|
||||
},
|
||||
{
|
||||
title: "客户", key: "member", width: 140,
|
||||
render: (_: unknown, r) => (
|
||||
<Tooltip title={`ID: ${r.member_id}`}>
|
||||
{r.member_name || `会员#${r.member_id}`}
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "原助教", key: "from", width: 120,
|
||||
render: (_: unknown, r) => r.from_assistant_name || `#${r.from_assistant_id}`,
|
||||
},
|
||||
{
|
||||
title: "新助教", key: "to", width: 120,
|
||||
render: (_: unknown, r) => r.to_assistant_name || `#${r.to_assistant_id}`,
|
||||
},
|
||||
{
|
||||
title: "转移原因", dataIndex: "transfer_reason", key: "reason", width: 140,
|
||||
render: (v: string | null) => v ? (
|
||||
<Tag>{REASON_LABELS[v] || v}</Tag>
|
||||
) : "—",
|
||||
},
|
||||
{
|
||||
title: "转移得分", dataIndex: "transfer_score", key: "score", width: 90,
|
||||
render: (v: number | null) => v != null ? v.toFixed(2) : "—",
|
||||
},
|
||||
{
|
||||
title: "保护检查", dataIndex: "guard_checks", key: "guards", width: 200,
|
||||
render: (v: Record<string, unknown> | null) => renderGuardChecks(v),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
<SwapOutlined style={{ marginRight: 8 }} />
|
||||
客户转移日志
|
||||
</Title>
|
||||
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>刷新</Button>
|
||||
</div>
|
||||
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Space wrap>
|
||||
<RangePicker
|
||||
placeholder={["开始日期", "结束日期"]}
|
||||
onChange={(dates) => {
|
||||
setQuery((q) => ({
|
||||
...q,
|
||||
from_date: dates?.[0]?.format("YYYY-MM-DD"),
|
||||
to_date: dates?.[1]?.format("YYYY-MM-DD"),
|
||||
page: 1,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<InputNumber
|
||||
placeholder="助教 ID"
|
||||
style={{ width: 140 }}
|
||||
onChange={(v) => setQuery((q) => ({
|
||||
...q,
|
||||
assistant_id: (v as number) ?? undefined,
|
||||
page: 1,
|
||||
}))}
|
||||
/>
|
||||
<InputNumber
|
||||
placeholder="门店 ID"
|
||||
style={{ width: 140 }}
|
||||
onChange={(v) => setQuery((q) => ({
|
||||
...q,
|
||||
site_id: (v as number) ?? undefined,
|
||||
page: 1,
|
||||
}))}
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card size="small">
|
||||
<Table<TransferLogItem>
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={items}
|
||||
loading={loading}
|
||||
size="small"
|
||||
scroll={{ x: 1200 }}
|
||||
pagination={{
|
||||
current: query.page,
|
||||
pageSize: query.page_size,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
onChange: (page, pageSize) => setQuery((q) => ({ ...q, page, page_size: pageSize })),
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TransferLog;
|
||||
206
apps/admin-web/src/pages/TriggerJobs.tsx
Normal file
206
apps/admin-web/src/pages/TriggerJobs.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* 定时任务管理页面。
|
||||
*
|
||||
* 展示 biz.trigger_jobs 表中所有定时任务,支持手动执行。
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { Table, Tag, Button, message, Modal, Typography, Card, Space, Popconfirm, Tooltip } from 'antd';
|
||||
import {
|
||||
ReloadOutlined,
|
||||
ClockCircleOutlined,
|
||||
PlayCircleOutlined,
|
||||
CheckCircleOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { fetchTriggerJobs, runTriggerJob, clearAllTasks, type TriggerJob } from '../api/triggerJobs';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
const TRIGGER_LABEL: Record<string, string> = {
|
||||
cron: '定时(Cron)',
|
||||
interval: '间隔',
|
||||
event: '事件触发',
|
||||
};
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
enabled: 'green',
|
||||
disabled: 'default',
|
||||
};
|
||||
|
||||
function formatTime(raw: string | null): string {
|
||||
if (!raw) return '—';
|
||||
const d = new Date(raw);
|
||||
return Number.isNaN(d.getTime()) ? raw : d.toLocaleString('zh-CN');
|
||||
}
|
||||
|
||||
function 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 TriggerJobs: React.FC = () => {
|
||||
const [jobs, setJobs] = useState<TriggerJob[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [runningId, setRunningId] = useState<number | null>(null);
|
||||
const [clearing, setClearing] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await fetchTriggerJobs();
|
||||
setJobs(data);
|
||||
} catch {
|
||||
message.error('加载定时任务失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleRun = async (jobId: number) => {
|
||||
setRunningId(jobId);
|
||||
try {
|
||||
const result = await runTriggerJob(jobId);
|
||||
if (result.success) {
|
||||
message.success(result.message);
|
||||
} else {
|
||||
message.error(result.message);
|
||||
}
|
||||
await load();
|
||||
} catch {
|
||||
message.error('执行失败');
|
||||
} finally {
|
||||
setRunningId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearAllTasks = async () => {
|
||||
setClearing(true);
|
||||
try {
|
||||
const result = await clearAllTasks();
|
||||
if (result.success) {
|
||||
Modal.success({
|
||||
title: '清空完成',
|
||||
content: result.message,
|
||||
});
|
||||
await load();
|
||||
} else {
|
||||
message.error(result.message);
|
||||
}
|
||||
} catch {
|
||||
message.error('清空任务失败');
|
||||
} finally {
|
||||
setClearing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<TriggerJob> = [
|
||||
{
|
||||
title: '任务名称', dataIndex: 'job_name', key: 'job_name', width: 180,
|
||||
render: (name: string, record) => (
|
||||
<Tooltip title={record.description || name}>
|
||||
<Text strong>{record.description || name}</Text>
|
||||
<br />
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{name}</Text>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
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={STATUS_COLOR[v] || 'default'}>{v === 'enabled' ? '启用' : '禁用'}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '上次执行', dataIndex: 'last_run_at', key: 'last_run_at', width: 170,
|
||||
render: (v: string | null) => formatTime(v),
|
||||
},
|
||||
{
|
||||
title: '下次执行', dataIndex: 'next_run_at', key: 'next_run_at', width: 170,
|
||||
render: (v: string | null) => formatTime(v),
|
||||
},
|
||||
{
|
||||
title: '最近错误', dataIndex: 'last_error', key: 'last_error', width: 200,
|
||||
render: (v: string | null) => v
|
||||
? <Tooltip title={v}><Text type="danger" ellipsis style={{ maxWidth: 180 }}><ExclamationCircleOutlined /> {v}</Text></Tooltip>
|
||||
: <Text type="success"><CheckCircleOutlined /> 正常</Text>,
|
||||
},
|
||||
{
|
||||
title: '操作', key: 'action', width: 100, fixed: 'right',
|
||||
render: (_: unknown, record) => (
|
||||
<Popconfirm
|
||||
title={`确认手动执行「${record.description || record.job_name}」?`}
|
||||
onConfirm={() => handleRun(record.id)}
|
||||
okText="执行"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<PlayCircleOutlined />}
|
||||
loading={runningId === record.id}
|
||||
disabled={record.status !== 'enabled'}
|
||||
>
|
||||
执行
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
<ClockCircleOutlined style={{ marginRight: 8 }} />
|
||||
定时任务管理
|
||||
</Title>
|
||||
<Space>
|
||||
<Popconfirm
|
||||
title="确认清空所有助教任务?"
|
||||
description="将删除 coach_tasks 和 coach_task_history 中的全部数据,此操作不可撤销。"
|
||||
onConfirm={handleClearAllTasks}
|
||||
okText="确认清空"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<Button danger loading={clearing}>🧹 清空所有任务</Button>
|
||||
</Popconfirm>
|
||||
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>刷新</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Card size="small">
|
||||
<Table<TriggerJob>
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={jobs}
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
size="small"
|
||||
scroll={{ x: 1200 }}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TriggerJobs;
|
||||
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;
|
||||
@@ -1,5 +1,9 @@
|
||||
/**
|
||||
* 日志查看器页面。
|
||||
* [ARCHIVED] 日志查看器页面。
|
||||
*
|
||||
* 已废弃:功能已合并到 ETLTasks 页面的"任务管理"Tab。
|
||||
* 归档日期:2026-03-25
|
||||
* 归档原因:admin-web-restructure spec,需求 8(LogViewer 废弃)
|
||||
*
|
||||
* - 输入执行 ID,通过 WebSocket 实时接收日志
|
||||
* - 支持加载历史日志
|
||||
@@ -13,9 +17,9 @@ import {
|
||||
FileTextOutlined, SearchOutlined, ClearOutlined,
|
||||
AppstoreOutlined, UnorderedListOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { apiClient } from "../api/client";
|
||||
import LogStream from "../components/LogStream";
|
||||
import TaskLogViewer from "../components/TaskLogViewer";
|
||||
import { apiClient } from "../../api/client";
|
||||
import LogStream from "../../components/LogStream";
|
||||
import TaskLogViewer from "../../components/TaskLogViewer";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
Reference in New Issue
Block a user