包含多个会话的累积代码变更: - 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>
421 lines
14 KiB
TypeScript
421 lines
14 KiB
TypeScript
/**
|
||
* 维客线索管理页 — 客户搜索 + 线索列表 + 编辑/删除/隐藏。
|
||
*
|
||
* - 客户搜索栏(Input.Search),支持姓名模糊/手机号精确搜索
|
||
* - 门店筛选器(复用 SiteSelector + useSiteFilter)
|
||
* - 搜索结果列表:member_id、姓名、手机号脱敏、所属门店
|
||
* - 搜索结果为空时展示"未找到匹配客户"提示(Ant Design Empty)
|
||
* - 选择客户后展示线索列表,支持来源/隐藏状态筛选
|
||
* - 线索操作:编辑、删除(二次确认)、隐藏/显示(Switch)
|
||
* - API:GET /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;
|