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:
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;
|
||||
Reference in New Issue
Block a user