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:
420
apps/tenant-admin/src/pages/RetentionClues/index.tsx
Normal file
420
apps/tenant-admin/src/pages/RetentionClues/index.tsx
Normal file
@@ -0,0 +1,420 @@
|
||||
/**
|
||||
* 维客线索管理页 — 客户搜索 + 线索列表 + 编辑/删除/隐藏。
|
||||
*
|
||||
* - 客户搜索栏(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;
|
||||
Reference in New Issue
Block a user