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:
353
apps/admin-web/src/pages/TaskEngineConfig.tsx
Normal file
353
apps/admin-web/src/pages/TaskEngineConfig.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user