包含多个会话的累积代码变更: - 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>
294 lines
9.5 KiB
TypeScript
294 lines
9.5 KiB
TypeScript
/**
|
||
* 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;
|