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,420 @@
/**
* 维客线索管理页 — 客户搜索 + 线索列表 + 编辑/删除/隐藏。
*
* - 客户搜索栏Input.Search支持姓名模糊/手机号精确搜索
* - 门店筛选器(复用 SiteSelector + useSiteFilter
* - 搜索结果列表member_id、姓名、手机号脱敏、所属门店
* - 搜索结果为空时展示"未找到匹配客户"提示Ant Design Empty
* - 选择客户后展示线索列表,支持来源/隐藏状态筛选
* - 线索操作:编辑、删除(二次确认)、隐藏/显示Switch
* - APIGET /customers/search, GET /customers/{member_id}/clues,
* PATCH /clues/{id}, DELETE /clues/{id}, PATCH /clues/{id}/visibility
*/
import React, { useState, useCallback } from "react";
import {
Input, Table, Select, Switch, Button, Space, Tag, Empty, Modal, message, Card,
} from "antd";
import { ExclamationCircleOutlined } from "@ant-design/icons";
import type { ColumnsType } from "antd/es/table";
import { tenantApi } from "@/services/api";
import SiteSelector, { useSiteFilter } from "@/components/SiteSelector";
import ClueEditor, { CATEGORY_LABEL } from "@/components/ClueEditor";
import type { ClueData, ClueFormValues } from "@/components/ClueEditor";
import type { AxiosError } from "axios";
/* ------------------------------------------------------------------ */
/* 类型定义 */
/* ------------------------------------------------------------------ */
interface CustomerItem {
memberId: number;
nickname: string | null;
mobileMasked: string | null;
siteId: number;
}
interface ClueItem {
id: number;
category: string;
summary: string;
detail: string | null;
recordedByName: string | null;
source: string;
recordedAt: string | null;
isHidden: boolean;
}
/* ------------------------------------------------------------------ */
/* 常量 */
/* ------------------------------------------------------------------ */
const SOURCE_OPTIONS = [
{ label: "全部来源", value: "" },
{ label: "手动录入", value: "manual" },
{ label: "AI-消费分析", value: "ai_consumption" },
{ label: "AI-笔记", value: "ai_note" },
];
const SOURCE_LABEL: Record<string, string> = {
manual: "手动录入",
ai_consumption: "AI-消费分析",
ai_note: "AI-笔记",
};
const HIDDEN_OPTIONS = [
{ label: "全部状态", value: "" },
{ label: "已隐藏", value: "true" },
{ label: "未隐藏", value: "false" },
];
/* ------------------------------------------------------------------ */
/* 主页面组件 */
/* ------------------------------------------------------------------ */
const RetentionClues: React.FC = () => {
const { selectedSiteIds, setSelectedSiteIds, allSiteIds } = useSiteFilter();
// 客户搜索状态
const [customers, setCustomers] = useState<CustomerItem[]>([]);
const [searchLoading, setSearchLoading] = useState(false);
const [searched, setSearched] = useState(false);
// 选中客户 + 线索列表状态
const [selectedCustomer, setSelectedCustomer] = useState<CustomerItem | null>(null);
const [clues, setClues] = useState<ClueItem[]>([]);
const [cluesLoading, setCluesLoading] = useState(false);
const [sourceFilter, setSourceFilter] = useState("");
const [hiddenFilter, setHiddenFilter] = useState("");
// 编辑 Modal 状态
const [editorVisible, setEditorVisible] = useState(false);
const [editingClue, setEditingClue] = useState<ClueData | null>(null);
/* ---------------------------------------------------------------- */
/* 客户搜索 */
/* ---------------------------------------------------------------- */
const handleSearch = useCallback(async (keyword: string) => {
const trimmed = keyword.trim();
if (!trimmed) {
message.warning("请输入搜索关键词");
return;
}
setSearchLoading(true);
setSearched(true);
setSelectedCustomer(null);
setClues([]);
try {
const params: Record<string, string | number> = { keyword: trimmed };
// 如果选了特定门店,传第一个 site_id搜索接口支持单门店筛选
if (selectedSiteIds.length === 1) {
params.site_id = selectedSiteIds[0];
}
const res = await tenantApi.get<CustomerItem[]>("/customers/search", { params });
setCustomers(res.data);
} catch {
message.error("搜索客户失败");
setCustomers([]);
} finally {
setSearchLoading(false);
}
}, [selectedSiteIds]);
/* ---------------------------------------------------------------- */
/* 线索列表 */
/* ---------------------------------------------------------------- */
const fetchClues = useCallback(async (memberId: number) => {
setCluesLoading(true);
try {
const params: Record<string, string> = {};
if (sourceFilter) params.source = sourceFilter;
if (hiddenFilter) params.is_hidden = hiddenFilter;
const res = await tenantApi.get<ClueItem[]>(`/customers/${memberId}/clues`, { params });
setClues(res.data);
} catch {
message.error("获取线索列表失败");
setClues([]);
} finally {
setCluesLoading(false);
}
}, [sourceFilter, hiddenFilter]);
const handleSelectCustomer = useCallback((customer: CustomerItem) => {
setSelectedCustomer(customer);
fetchClues(customer.memberId);
}, [fetchClues]);
// 筛选条件变化时重新加载线索
React.useEffect(() => {
if (selectedCustomer) {
fetchClues(selectedCustomer.memberId);
}
}, [sourceFilter, hiddenFilter]); // eslint-disable-line react-hooks/exhaustive-deps
/* ---------------------------------------------------------------- */
/* 线索操作 */
/* ---------------------------------------------------------------- */
/** 编辑线索 */
const handleEdit = (clue: ClueItem) => {
setEditingClue({
id: clue.id,
category: clue.category,
summary: clue.summary,
detail: clue.detail,
});
setEditorVisible(true);
};
const handleEditSubmit = async (values: ClueFormValues) => {
if (!editingClue || !selectedCustomer) return;
try {
await tenantApi.patch(`/clues/${editingClue.id}`, values);
message.success("线索更新成功");
setEditorVisible(false);
setEditingClue(null);
fetchClues(selectedCustomer.memberId);
} catch (err) {
const axiosErr = err as AxiosError<{ message?: string }>;
message.error(axiosErr.response?.data?.message ?? "更新失败");
}
};
/** 删除线索 — 二次确认 */
const handleDelete = (clue: ClueItem) => {
Modal.confirm({
title: "确认删除",
icon: <ExclamationCircleOutlined />,
content: `确定要删除该线索吗?删除后不可恢复。`,
okText: "删除",
okButtonProps: { danger: true },
cancelText: "取消",
onOk: async () => {
try {
await tenantApi.delete(`/clues/${clue.id}`);
message.success("线索已删除");
if (selectedCustomer) fetchClues(selectedCustomer.memberId);
} catch (err) {
const axiosErr = err as AxiosError<{ message?: string }>;
message.error(axiosErr.response?.data?.message ?? "删除失败");
}
},
});
};
/** 隐藏/显示切换 */
const handleToggleVisibility = async (clue: ClueItem, isHidden: boolean) => {
try {
await tenantApi.patch(`/clues/${clue.id}/visibility`, { isHidden });
message.success(isHidden ? "线索已隐藏" : "线索已显示");
if (selectedCustomer) fetchClues(selectedCustomer.memberId);
} catch (err) {
const axiosErr = err as AxiosError<{ message?: string }>;
message.error(axiosErr.response?.data?.message ?? "操作失败");
}
};
/* ---------------------------------------------------------------- */
/* 客户搜索结果列定义 */
/* ---------------------------------------------------------------- */
const customerColumns: ColumnsType<CustomerItem> = [
{ title: "会员 ID", dataIndex: "memberId", key: "memberId", width: 100 },
{ title: "姓名", dataIndex: "nickname", key: "nickname", render: (v) => v ?? "-" },
{ title: "手机号", dataIndex: "mobileMasked", key: "mobileMasked", render: (v) => v ?? "-" },
{ title: "所属门店", dataIndex: "siteId", key: "siteId", render: (v: number) => `门店 ${v}` },
{
title: "操作",
key: "action",
width: 100,
render: (_, record) => (
<Button
type="link"
onClick={() => handleSelectCustomer(record)}
>
线
</Button>
),
},
];
/* ---------------------------------------------------------------- */
/* 线索列表列定义 */
/* ---------------------------------------------------------------- */
const clueColumns: ColumnsType<ClueItem> = [
{
title: "大类",
dataIndex: "category",
key: "category",
width: 110,
render: (v: string) => CATEGORY_LABEL[v] ?? v,
},
{
title: "摘要",
dataIndex: "summary",
key: "summary",
ellipsis: true,
},
{
title: "来源",
dataIndex: "source",
key: "source",
width: 120,
render: (v: string) => {
const label = SOURCE_LABEL[v] ?? v;
const color = v === "manual" ? "blue" : "purple";
return <Tag color={color}>{label}</Tag>;
},
},
{
title: "提供人",
dataIndex: "recordedByName",
key: "recordedByName",
width: 100,
render: (v) => v ?? "-",
},
{
title: "记录时间",
dataIndex: "recordedAt",
key: "recordedAt",
width: 170,
render: (v) => v ?? "-",
},
{
title: "隐藏",
dataIndex: "isHidden",
key: "isHidden",
width: 80,
render: (isHidden: boolean, record) => (
<Switch
size="small"
checked={isHidden}
onChange={(checked) => handleToggleVisibility(record, checked)}
/>
),
},
{
title: "操作",
key: "action",
width: 120,
render: (_, record) => (
<Space>
<Button type="link" size="small" onClick={() => handleEdit(record)}>
</Button>
<Button type="link" size="small" danger onClick={() => handleDelete(record)}>
</Button>
</Space>
),
},
];
/* ---------------------------------------------------------------- */
/* 渲染 */
/* ---------------------------------------------------------------- */
return (
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
{/* 搜索栏 + 门店筛选 */}
<Card size="small" title="客户搜索">
<Space wrap>
<Input.Search
placeholder="输入客户姓名或手机号搜索"
allowClear
onSearch={handleSearch}
loading={searchLoading}
style={{ width: 320 }}
enterButton
/>
<SiteSelector
value={selectedSiteIds}
onChange={setSelectedSiteIds}
allSiteIds={allSiteIds}
/>
</Space>
</Card>
{/* 搜索结果 */}
{searched && (
<Card size="small" title="搜索结果">
{customers.length === 0 ? (
<Empty description="未找到匹配客户" />
) : (
<Table<CustomerItem>
rowKey="memberId"
columns={customerColumns}
dataSource={customers}
loading={searchLoading}
pagination={false}
size="small"
/>
)}
</Card>
)}
{/* 线索列表 */}
{selectedCustomer && (
<Card
size="small"
title={`${selectedCustomer.nickname ?? "未知客户"} 的线索列表`}
extra={
<Space>
<Select
value={sourceFilter}
onChange={setSourceFilter}
options={SOURCE_OPTIONS}
style={{ width: 140 }}
/>
<Select
value={hiddenFilter}
onChange={setHiddenFilter}
options={HIDDEN_OPTIONS}
style={{ width: 120 }}
/>
</Space>
}
>
<Table<ClueItem>
rowKey="id"
columns={clueColumns}
dataSource={clues}
loading={cluesLoading}
pagination={{ pageSize: 10 }}
size="small"
rowClassName={(record) =>
record.isHidden ? "clue-row-hidden" : ""
}
/>
<style>{`
.clue-row-hidden td {
color: #bfbfbf !important;
text-decoration: line-through;
}
.clue-row-hidden:hover td {
color: #8c8c8c !important;
}
`}</style>
</Card>
)}
{/* 线索编辑 Modal */}
<ClueEditor
visible={editorVisible}
clue={editingClue}
onSubmit={handleEditSubmit}
onCancel={() => {
setEditorVisible(false);
setEditingClue(null);
}}
/>
</Space>
);
};
export default RetentionClues;