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