Files
Neo-ZQYY/apps/tenant-admin/src/pages/RetentionClues/index.tsx
Neo 6f8f12314f 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>
2026-04-06 00:03:48 +08:00

421 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 维客线索管理页 — 客户搜索 + 线索列表 + 编辑/删除/隐藏。
*
* - 客户搜索栏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;