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:
Neo
2026-04-06 00:03:48 +08:00
parent 70324d8542
commit 6f8f12314f
515 changed files with 76604 additions and 7456 deletions

View 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;