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