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,493 @@
/**
* Excel 上传页 — 模板选择 + 文件上传 + 校验结果 + 冲突处理 + 上传记录。
*
* - Ant Design Tabs 切换"上传"和"上传记录"
* - 模板类型选择Radio.Group4 种模板)
* - 模板下载按钮GET /excel/template/{type}
* - 文件上传Upload限 .xlsx/.xls单文件
* - 校验结果展示:错误行标红、警告行黄色高亮
* - 汇总:通过行数、警告行数、错误行数
* - 冲突处理:无冲突直接确认,有冲突展示 DiffTable
* - 上传记录 Tab历史记录列表 + 分页
*/
import React, { useState, useEffect, useCallback } from "react";
import {
Tabs,
Radio,
Button,
Upload,
Table,
Tag,
Space,
Alert,
message,
Statistic,
Card,
Row,
Col,
} from "antd";
import {
UploadOutlined,
DownloadOutlined,
CheckCircleOutlined,
WarningOutlined,
CloseCircleOutlined,
} from "@ant-design/icons";
import type { UploadFile, UploadProps } from "antd/es/upload";
import type { ColumnsType, TablePaginationConfig } from "antd/es/table";
import { tenantApi } from "@/services/api";
import DiffTable from "@/components/DiffTable";
import type { ConflictRow } from "@/components/DiffTable";
/* ------------------------------------------------------------------ */
/* 类型定义 */
/* ------------------------------------------------------------------ */
/** 模板类型 */
type UploadType = "finance_expense" | "platform_income" | "salary_adj" | "recharge_commission";
const UPLOAD_TYPE_OPTIONS: { label: string; value: UploadType }[] = [
{ label: "财务支出", value: "finance_expense" },
{ label: "团购收入", value: "platform_income" },
{ label: "助教奖罚", value: "salary_adj" },
{ label: "充值业绩归属", value: "recharge_commission" },
];
const UPLOAD_TYPE_LABEL: Record<string, string> = {
finance_expense: "财务支出",
platform_income: "团购收入",
salary_adj: "助教奖罚",
recharge_commission: "充值业绩归属",
};
/** 校验行状态 */
type RowStatus = "ok" | "warning" | "error";
/** 校验结果行 */
interface ValidationRow {
rowIndex: number;
status: RowStatus;
data: Record<string, unknown>;
errors?: { column: string; message: string }[];
warnings?: { column: string; message: string }[];
}
/** 上传响应 */
interface UploadResponse {
uploadId: number;
rows: ValidationRow[];
conflicts: ConflictRow[];
summary: {
total: number;
passed: number;
warnings: number;
errors: number;
};
}
/** 上传记录 */
interface UploadLogItem {
id: number;
uploadType: string;
fileName: string;
uploadedByName: string | null;
createdAt: string;
rowCount: number;
conflictCount: number;
status: string;
}
interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
pageSize: number;
}
/* ------------------------------------------------------------------ */
/* 上传 Tab */
/* ------------------------------------------------------------------ */
const UploadTab: React.FC = () => {
const [uploadType, setUploadType] = useState<UploadType>("finance_expense");
const [fileList, setFileList] = useState<UploadFile[]>([]);
const [uploading, setUploading] = useState(false);
const [result, setResult] = useState<UploadResponse | null>(null);
const [confirming, setConfirming] = useState(false);
/** 下载模板 */
const handleDownloadTemplate = async () => {
try {
const res = await tenantApi.get(`/excel/template/${uploadType}`, {
responseType: "blob",
});
const blob = new Blob([res.data as BlobPart]);
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${UPLOAD_TYPE_LABEL[uploadType]}模板.xlsx`;
a.click();
window.URL.revokeObjectURL(url);
} catch {
message.error("模板下载失败");
}
};
/** 上传文件 */
const handleUpload = async () => {
if (fileList.length === 0) {
message.warning("请先选择文件");
return;
}
const file = fileList[0]?.originFileObj;
if (!file) return;
const formData = new FormData();
formData.append("file", file);
formData.append("upload_type", uploadType);
setUploading(true);
setResult(null);
try {
const res = await tenantApi.post<UploadResponse>("/excel/upload", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
setResult(res.data);
if (res.data.summary.errors > 0) {
message.warning("文件存在校验错误,请修正后重新上传");
} else {
message.success("文件解析完成");
}
} catch {
message.error("上传失败,请重试");
} finally {
setUploading(false);
}
};
/** Upload 组件 props */
const uploadProps: UploadProps = {
accept: ".xlsx,.xls",
maxCount: 1,
fileList,
beforeUpload: () => false, // 手动上传
onChange: ({ fileList: newList }) => {
setFileList(newList);
setResult(null); // 换文件时清除结果
},
onRemove: () => {
setFileList([]);
setResult(null);
},
};
/** 确认写入(无冲突或冲突已解决) */
const handleConfirm = async (resolutions?: { rowIndex: number; action: "replace" | "keep" }[]) => {
if (!result) return;
setConfirming(true);
try {
await tenantApi.post("/excel/confirm", {
uploadId: result.uploadId,
resolutions: resolutions ?? [],
});
message.success("数据写入成功");
// 重置状态
setResult(null);
setFileList([]);
} catch {
message.error("写入失败,请重试");
} finally {
setConfirming(false);
}
};
/** 校验结果表格列 */
const validationColumns: ColumnsType<ValidationRow> = [
{
title: "行号",
dataIndex: "rowIndex",
key: "rowIndex",
width: 80,
},
{
title: "状态",
dataIndex: "status",
key: "status",
width: 100,
render: (status: RowStatus) => {
if (status === "error") return <Tag color="red"></Tag>;
if (status === "warning") return <Tag color="orange"></Tag>;
return <Tag color="green"></Tag>;
},
},
{
title: "数据",
dataIndex: "data",
key: "data",
render: (data: Record<string, unknown>) =>
Object.entries(data)
.map(([k, v]) => `${k}: ${String(v ?? "")}`)
.join(""),
},
{
title: "问题详情",
key: "issues",
render: (_, record) => {
const issues = [
...(record.errors ?? []).map((e) => `${e.column}: ${e.message}`),
...(record.warnings ?? []).map((w) => `⚠️ ${w.column}: ${w.message}`),
];
return issues.length > 0 ? issues.join("") : "-";
},
},
];
return (
<Space direction="vertical" size="large" style={{ width: "100%" }}>
{/* 模板类型选择 */}
<Card size="small" title="模板类型">
<Space direction="vertical" size="middle">
<Radio.Group
value={uploadType}
onChange={(e) => {
setUploadType(e.target.value);
setResult(null);
setFileList([]);
}}
optionType="button"
buttonStyle="solid"
options={UPLOAD_TYPE_OPTIONS}
/>
<Button icon={<DownloadOutlined />} onClick={handleDownloadTemplate}>
</Button>
</Space>
</Card>
{/* 文件上传 */}
<Card size="small" title="上传文件">
<Space>
<Upload {...uploadProps}>
<Button icon={<UploadOutlined />}></Button>
</Upload>
<Button
type="primary"
onClick={handleUpload}
loading={uploading}
disabled={fileList.length === 0}
>
</Button>
</Space>
</Card>
{/* 校验结果 */}
{result && (
<>
{/* 汇总统计 */}
<Row gutter={16}>
<Col span={8}>
<Card>
<Statistic
title="通过行数"
value={result.summary.passed}
prefix={<CheckCircleOutlined style={{ color: "#52c41a" }} />}
valueStyle={{ color: "#52c41a" }}
/>
</Card>
</Col>
<Col span={8}>
<Card>
<Statistic
title="警告行数"
value={result.summary.warnings}
prefix={<WarningOutlined style={{ color: "#faad14" }} />}
valueStyle={{ color: "#faad14" }}
/>
</Card>
</Col>
<Col span={8}>
<Card>
<Statistic
title="错误行数"
value={result.summary.errors}
prefix={<CloseCircleOutlined style={{ color: "#ff4d4f" }} />}
valueStyle={{ color: "#ff4d4f" }}
/>
</Card>
</Col>
</Row>
{/* 校验详情表格 */}
{result.summary.errors > 0 && (
<Card size="small" title="校验详情">
<Table<ValidationRow>
rowKey="rowIndex"
columns={validationColumns}
dataSource={result.rows}
pagination={{ pageSize: 10 }}
size="small"
rowClassName={(record) => {
if (record.status === "error") return "row-error";
if (record.status === "warning") return "row-warning";
return "";
}}
/>
<style>{`
.row-error { background-color: #fff2f0 !important; }
.row-error:hover > td { background-color: #fff1f0 !important; }
.row-warning { background-color: #fffbe6 !important; }
.row-warning:hover > td { background-color: #fff8cc !important; }
`}</style>
</Card>
)}
{/* 冲突处理 / 确认写入 */}
{result.summary.errors === 0 && (
<>
{result.conflicts.length > 0 ? (
<Card size="small" title="冲突处理">
<Alert
message={`检测到 ${result.conflicts.length} 行冲突,请逐行选择处理方式`}
type="warning"
showIcon
style={{ marginBottom: 16 }}
/>
<DiffTable
conflicts={result.conflicts}
onConfirm={(resolutions) => handleConfirm(resolutions)}
loading={confirming}
/>
</Card>
) : (
<Card size="small">
<Alert
message="校验通过,无冲突数据"
type="success"
showIcon
style={{ marginBottom: 16 }}
/>
<Button
type="primary"
size="large"
onClick={() => handleConfirm()}
loading={confirming}
>
</Button>
</Card>
)}
</>
)}
</>
)}
</Space>
);
};
/* ------------------------------------------------------------------ */
/* 上传记录 Tab */
/* ------------------------------------------------------------------ */
const STATUS_TAG_MAP: Record<string, { color: string; text: string }> = {
pending: { color: "orange", text: "待确认" },
confirmed: { color: "green", text: "已写入" },
failed: { color: "red", text: "失败" },
};
const UploadLogsTab: React.FC = () => {
const [data, setData] = useState<UploadLogItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [loading, setLoading] = useState(false);
const fetchLogs = useCallback(async () => {
setLoading(true);
try {
const res = await tenantApi.get<PaginatedResponse<UploadLogItem>>("/excel/logs", {
params: { page, page_size: pageSize },
});
setData(res.data.items);
setTotal(res.data.total);
} catch {
message.error("获取上传记录失败");
} finally {
setLoading(false);
}
}, [page, pageSize]);
useEffect(() => {
fetchLogs();
}, [fetchLogs]);
const handleTableChange = (pagination: TablePaginationConfig) => {
setPage(pagination.current ?? 1);
setPageSize(pagination.pageSize ?? 20);
};
const columns: ColumnsType<UploadLogItem> = [
{
title: "模板类型",
dataIndex: "uploadType",
key: "uploadType",
render: (v: string) => UPLOAD_TYPE_LABEL[v] ?? v,
},
{ title: "文件名", dataIndex: "fileName", key: "fileName" },
{
title: "上传人",
dataIndex: "uploadedByName",
key: "uploadedByName",
render: (v) => v ?? "-",
},
{ title: "上传时间", dataIndex: "createdAt", key: "createdAt" },
{ title: "行数", dataIndex: "rowCount", key: "rowCount", width: 80 },
{ title: "冲突数", dataIndex: "conflictCount", key: "conflictCount", width: 80 },
{
title: "状态",
dataIndex: "status",
key: "status",
render: (status: string) => {
const tag = STATUS_TAG_MAP[status];
return tag ? <Tag color={tag.color}>{tag.text}</Tag> : status;
},
},
];
return (
<Table<UploadLogItem>
rowKey="id"
columns={columns}
dataSource={data}
loading={loading}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
showTotal: (t) => `${t}`,
}}
onChange={handleTableChange}
/>
);
};
/* ------------------------------------------------------------------ */
/* 主页面组件 */
/* ------------------------------------------------------------------ */
const ExcelUpload: React.FC = () => {
return (
<Tabs
defaultActiveKey="upload"
items={[
{ key: "upload", label: "上传", children: <UploadTab /> },
{ key: "logs", label: "上传记录", children: <UploadLogsTab /> },
]}
/>
);
};
export default ExcelUpload;

View File

@@ -0,0 +1,97 @@
/**
* 登录页 — 用户名 + 密码表单,居中卡片布局。
*
* - 调用 useAuth().login() 进行认证
* - 成功后跳转 /applications
* - 401 → "用户名或密码错误"403 → "账号已被禁用"
* - 已认证用户访问 /login 时重定向到首页
*/
import React, { useState } from "react";
import { Navigate, useNavigate } from "react-router-dom";
import { Form, Input, Button, Card, message } from "antd";
import { UserOutlined, LockOutlined } from "@ant-design/icons";
import { useAuth } from "../../hooks/useAuth";
import type { AxiosError } from "axios";
interface LoginFormValues {
username: string;
password: string;
}
const Login: React.FC = () => {
const { isAuthenticated, login } = useAuth();
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
// 已认证用户直接重定向到首页
if (isAuthenticated) {
return <Navigate to="/applications" replace />;
}
const onFinish = async (values: LoginFormValues) => {
setLoading(true);
try {
await login(values.username, values.password);
navigate("/applications", { replace: true });
} catch (err) {
const axiosErr = err as AxiosError<{ message?: string }>;
const status = axiosErr.response?.status;
if (status === 403) {
message.error("账号已被禁用");
} else {
// 401 或其他错误统一提示
message.error("用户名或密码错误");
}
} finally {
setLoading(false);
}
};
return (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "100vh",
background: "#f0f2f5",
}}
>
<Card
title="租户管理后台"
style={{ width: 400 }}
styles={{ header: { textAlign: "center", fontSize: 20, fontWeight: 600 } }}
>
<Form<LoginFormValues>
name="login"
onFinish={onFinish}
autoComplete="off"
size="large"
>
<Form.Item
name="username"
rules={[{ required: true, message: "请输入用户名" }]}
>
<Input prefix={<UserOutlined />} placeholder="用户名" />
</Form.Item>
<Form.Item
name="password"
rules={[{ required: true, message: "请输入密码" }]}
>
<Input.Password prefix={<LockOutlined />} placeholder="密码" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={loading} block>
</Button>
</Form.Item>
</Form>
</Card>
</div>
);
};
export default Login;

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;

View File

@@ -0,0 +1,351 @@
/**
* 店铺管理员管理页面。
*
* 功能:列表(分页+搜索)、创建、编辑、重置密码、删除。
* 仅 admin_type='tenant_admin' 可访问(后端 + 前端双重守卫)。
*/
import React, { useCallback, useEffect, useState } from "react";
import {
Table, Button, Input, Space, Modal, Form, Select, Tag, Switch,
message, Popconfirm, Card,
} from "antd";
import {
PlusOutlined, SearchOutlined, ReloadOutlined, LockOutlined,
} from "@ant-design/icons";
import type { ColumnsType } from "antd/es/table";
import { tenantApi } from "../../services/api";
import { useAuth } from "../../hooks/useAuth";
/* ------------------------------------------------------------------ */
/* 类型 */
/* ------------------------------------------------------------------ */
interface SiteAdminItem {
id: number;
username: string;
displayName: string | null;
managedSiteIds: number[];
isActive: boolean;
createdAt: string | null;
lastLoginAt: string | null;
}
interface SiteOption {
siteId: number;
siteName: string;
siteCode: string;
}
/* ------------------------------------------------------------------ */
/* 主组件 */
/* ------------------------------------------------------------------ */
const SiteAdmins: React.FC = () => {
const { user } = useAuth();
/* ---- 状态 ---- */
const [items, setItems] = useState<SiteAdminItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize] = useState(20);
const [keyword, setKeyword] = useState("");
const [loading, setLoading] = useState(false);
const [sites, setSites] = useState<SiteOption[]>([]);
// Modal 状态
const [createOpen, setCreateOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [resetPwdOpen, setResetPwdOpen] = useState(false);
const [currentAdmin, setCurrentAdmin] = useState<SiteAdminItem | null>(null);
const [submitting, setSubmitting] = useState(false);
const [createForm] = Form.useForm();
const [editForm] = Form.useForm();
const [resetPwdForm] = Form.useForm();
/* ---- 加载管辖店铺列表 ---- */
const loadSites = useCallback(async () => {
try {
const { data } = await tenantApi.get<SiteOption[]>("/my-sites");
setSites(Array.isArray(data) ? data : []);
} catch {
/* 静默 */
}
}, []);
/* ---- 加载列表 ---- */
const loadList = useCallback(async () => {
setLoading(true);
try {
const { data } = await tenantApi.get("/site-admins", {
params: { page, page_size: pageSize, keyword: keyword || undefined },
});
setItems(data.items ?? []);
setTotal(data.total ?? 0);
} catch {
message.error("加载店铺管理员列表失败");
} finally {
setLoading(false);
}
}, [page, pageSize, keyword]);
useEffect(() => { loadSites(); }, [loadSites]);
useEffect(() => { loadList(); }, [loadList]);
/* ---- 创建 ---- */
const handleCreate = async () => {
try {
const values = await createForm.validateFields();
setSubmitting(true);
await tenantApi.post("/site-admins", {
username: values.username,
password: values.password,
displayName: values.displayName || null,
managedSiteIds: values.managedSiteIds,
});
message.success("创建成功");
setCreateOpen(false);
createForm.resetFields();
loadList();
} catch (err: any) {
if (err?.response?.data?.detail) {
message.error(err.response.data.detail);
}
} finally {
setSubmitting(false);
}
};
/* ---- 编辑 ---- */
const openEdit = (record: SiteAdminItem) => {
setCurrentAdmin(record);
editForm.setFieldsValue({
displayName: record.displayName,
managedSiteIds: record.managedSiteIds,
isActive: record.isActive,
});
setEditOpen(true);
};
const handleEdit = async () => {
if (!currentAdmin) return;
try {
const values = await editForm.validateFields();
setSubmitting(true);
await tenantApi.patch(`/site-admins/${currentAdmin.id}`, {
displayName: values.displayName || null,
managedSiteIds: values.managedSiteIds,
isActive: values.isActive,
});
message.success("修改成功");
setEditOpen(false);
loadList();
} catch (err: any) {
if (err?.response?.data?.detail) {
message.error(err.response.data.detail);
}
} finally {
setSubmitting(false);
}
};
/* ---- 重置密码 ---- */
const openResetPwd = (record: SiteAdminItem) => {
setCurrentAdmin(record);
resetPwdForm.resetFields();
setResetPwdOpen(true);
};
const handleResetPwd = async () => {
if (!currentAdmin) return;
try {
const values = await resetPwdForm.validateFields();
setSubmitting(true);
await tenantApi.post(`/site-admins/${currentAdmin.id}/reset-password`, {
newPassword: values.newPassword,
});
message.success("密码已重置");
setResetPwdOpen(false);
} catch (err: any) {
if (err?.response?.data?.detail) {
message.error(err.response.data.detail);
}
} finally {
setSubmitting(false);
}
};
/* ---- 删除 ---- */
const handleDelete = async (id: number) => {
try {
await tenantApi.delete(`/site-admins/${id}`);
message.success("已删除");
loadList();
} catch {
message.error("删除失败");
}
};
/* ---- 店铺名称映射 ---- */
const siteMap = new Map(sites.map((s) => [s.siteId, s]));
/* ---- 表格列 ---- */
const columns: ColumnsType<SiteAdminItem> = [
{ title: "用户名", dataIndex: "username", width: 160 },
{ title: "显示名称", dataIndex: "displayName", width: 120 },
{
title: "管辖门店", dataIndex: "managedSiteIds", width: 200,
render: (ids: number[]) =>
ids.map((id) => {
const s = siteMap.get(id);
return <Tag key={id}>{s ? `${s.siteCode} ${s.siteName}` : `#${id}`}</Tag>;
}),
},
{
title: "状态", dataIndex: "isActive", width: 80,
render: (v: boolean) => v ? <Tag color="green"></Tag> : <Tag color="red"></Tag>,
},
{ title: "创建时间", dataIndex: "createdAt", width: 170 },
{ title: "最后登录", dataIndex: "lastLoginAt", width: 170 },
{
title: "操作", width: 220, fixed: "right",
render: (_, record) => (
<Space size="small">
<Button size="small" onClick={() => openEdit(record)}></Button>
<Button size="small" icon={<LockOutlined />} onClick={() => openResetPwd(record)}></Button>
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(record.id)}>
<Button size="small" danger></Button>
</Popconfirm>
</Space>
),
},
];
/* ---- 权限守卫 ---- */
if (user?.adminType !== "tenant_admin") {
return <Card><p></p></Card>;
}
/* ---- 用户名前缀提示 ---- */
const siteCodePrefix = sites.length > 0 ? sites[0].siteCode : "";
return (
<Card title="店铺管理员管理">
{/* 工具栏 */}
<Space style={{ marginBottom: 16 }}>
<Input
placeholder="搜索用户名/名称"
prefix={<SearchOutlined />}
allowClear
value={keyword}
onChange={(e) => { setKeyword(e.target.value); setPage(1); }}
style={{ width: 220 }}
/>
<Button icon={<ReloadOutlined />} onClick={loadList}></Button>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
</Button>
</Space>
{/* 表格 */}
<Table<SiteAdminItem>
rowKey="id"
columns={columns}
dataSource={items}
loading={loading}
scroll={{ x: 1100 }}
pagination={{
current: page, pageSize, total, showTotal: (t) => `${t}`,
onChange: (p) => setPage(p),
}}
/>
{/* 创建 Modal */}
<Modal
title="新建店铺管理员"
open={createOpen}
onCancel={() => setCreateOpen(false)}
onOk={handleCreate}
confirmLoading={submitting}
destroyOnClose
>
<Form form={createForm} layout="vertical" preserve={false}>
<Form.Item
name="managedSiteIds" label="管辖门店"
rules={[{ required: true, message: "请选择至少一个门店" }]}
>
<Select
mode="multiple" placeholder="选择门店"
options={sites.map((s) => ({ value: s.siteId, label: `${s.siteCode} ${s.siteName}` }))}
/>
</Form.Item>
<Form.Item
name="username" label="用户名"
rules={[{ required: true, message: "请输入用户名" }]}
extra={siteCodePrefix ? `用户名须以 ${siteCodePrefix} 开头` : ""}
>
<Input maxLength={56} placeholder={siteCodePrefix ? `${siteCodePrefix}xxx` : "用户名"} />
</Form.Item>
<Form.Item
name="password" label="初始密码"
rules={[{ required: true, message: "请输入密码" }, { min: 6, message: "至少 6 位" }]}
>
<Input.Password />
</Form.Item>
<Form.Item name="displayName" label="显示名称">
<Input maxLength={100} placeholder="可选" />
</Form.Item>
</Form>
</Modal>
{/* 编辑 Modal */}
<Modal
title={`编辑 — ${currentAdmin?.username ?? ""}`}
open={editOpen}
onCancel={() => setEditOpen(false)}
onOk={handleEdit}
confirmLoading={submitting}
destroyOnClose
>
<Form form={editForm} layout="vertical" preserve={false}>
<Form.Item name="displayName" label="显示名称">
<Input maxLength={100} />
</Form.Item>
<Form.Item
name="managedSiteIds" label="管辖门店"
rules={[{ required: true, message: "请选择至少一个门店" }]}
>
<Select
mode="multiple" placeholder="选择门店"
options={sites.map((s) => ({ value: s.siteId, label: `${s.siteCode} ${s.siteName}` }))}
/>
</Form.Item>
<Form.Item name="isActive" label="启用状态" valuePropName="checked">
<Switch checkedChildren="启用" unCheckedChildren="禁用" />
</Form.Item>
</Form>
</Modal>
{/* 重置密码 Modal */}
<Modal
title={`重置密码 — ${currentAdmin?.username ?? ""}`}
open={resetPwdOpen}
onCancel={() => setResetPwdOpen(false)}
onOk={handleResetPwd}
confirmLoading={submitting}
destroyOnClose
>
<Form form={resetPwdForm} layout="vertical" preserve={false}>
<Form.Item
name="newPassword" label="新密码"
rules={[{ required: true, message: "请输入新密码" }, { min: 6, message: "至少 6 位" }]}
>
<Input.Password />
</Form.Item>
</Form>
</Modal>
</Card>
);
};
export default SiteAdmins;

View File

@@ -0,0 +1,586 @@
/**
* 用户审核页 — 申请列表 + 审核操作 Modal。
*
* - 申请列表Ant Design Table支持状态筛选 + 店铺筛选 + 分页
* - 审核 Modal角色动态下拉 + 人员联动查询 + 手机号自动匹配 + "无"选项
* - 数据源tenantApi GET /applications, GET /roles, GET /site-staff, GET /my-sites
* - 操作POST /applications/{id}/approve, POST /applications/{id}/reject
* - 非 pending 状态不显示审核按钮
*
* AI_CHANGELOG
* - 2026-03-23 17:00:00 | Prompt: P20260323-164500审核弹窗改造| Direct cause角色硬编码+无人员联动 | SummaryReviewModal 角色下拉动态化GET /roles、人员列表联动GET /site-staff、手机号自动匹配、"无"选项、staffBinding 格式 source:id、修复中文引号+filterOption 类型 | Verifytenant-admin 审核弹窗功能完整 + Vite HMR 无报错
* - 2026-03-23 19:30:00 | Prompt: P20260323-190000手机号不显示+自动匹配优化)| Direct cause弹窗手机号显示 `-`;身份标签显示数字 | Summary弹窗打开时并行查 coach/staff 自动匹配手机号→自动选角色+人员staffOptions label 加"入职日期"前缀 | VerifyPlaywright 验证自动选中"员工"+"店长 - 厉超 - 13810502304 - 入职日期 2025-12-23"
* - 2026-03-24 | Prompt: 审核弹窗头像昵称+排版优化 | Direct cause信息区纯文本平铺无头像展示 | Summary新增 avatarUrl 字段(后端 SQL+Schema+前端 interface信息区改为 Avatar+Descriptions 卡片布局 | Verify审核弹窗显示头像+信息分行
*/
import React, { useState, useEffect, useCallback } from "react";
import { Table, Select, Button, Modal, Form, Input, Radio, Space, Tag, message, Avatar, Descriptions } from "antd";
import type { ColumnsType, TablePaginationConfig } from "antd/es/table";
import { UserOutlined } from "@ant-design/icons";
import { tenantApi } from "@/services/api";
import type { AxiosError } from "axios";
import { pinyinFilterOption } from "@/utils/pinyinMatch";
/* ------------------------------------------------------------------ */
/* 类型定义 */
/* ------------------------------------------------------------------ */
interface ApplicationItem {
id: number;
userId: number;
nickname: string | null;
avatarUrl: string | null;
phone: string | null;
siteCode: string | null;
appliedRoleText: string | null;
employeeNumber: string | null;
createdAt: string | null;
status: string;
}
interface MatchSuggestion {
assistantId: number | null;
staffId: number | null;
name: string;
number: string | null;
sourceTable: string;
}
interface RoleOption {
id: number;
code: string;
name: string;
description: string | null;
}
interface StaffCandidate {
id: number;
identityLabel: string | null;
name: string;
mobile: string | null;
entryTime: string | null;
source: string; // "assistant" | "staff"
}
interface SiteOption {
siteId: number;
siteName: string;
siteCode: string | null;
}
interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
pageSize: number;
}
/* ------------------------------------------------------------------ */
/* 状态筛选选项 */
/* ------------------------------------------------------------------ */
const STATUS_OPTIONS = [
{ label: "全部", value: "" },
{ label: "待审核", value: "pending" },
{ label: "已通过", value: "approved" },
{ label: "已拒绝", value: "rejected" },
];
const STATUS_TAG_MAP: Record<string, { color: string; text: string }> = {
pending: { color: "orange", text: "待审核" },
approved: { color: "green", text: "已通过" },
rejected: { color: "red", text: "已拒绝" },
};
/* ------------------------------------------------------------------ */
/* 审核 Modal 组件 */
/* ------------------------------------------------------------------ */
interface ReviewModalProps {
open: boolean;
application: ApplicationItem | null;
onClose: () => void;
onSuccess: () => void;
}
const ReviewModal: React.FC<ReviewModalProps> = ({ open, application, onClose, onSuccess }) => {
const [, setSuggestions] = useState<MatchSuggestion[]>([]);
const [, setLoadingSuggestions] = useState(false);
const [action, setAction] = useState<"approve" | "reject">("approve");
const [submitting, setSubmitting] = useState(false);
const [approveForm] = Form.useForm();
const [rejectForm] = Form.useForm();
// 动态角色列表
const [roles, setRoles] = useState<RoleOption[]>([]);
const [loadingRoles, setLoadingRoles] = useState(false);
// 人员候选列表
const [staffCandidates, setStaffCandidates] = useState<StaffCandidate[]>([]);
const [loadingStaff, setLoadingStaff] = useState(false);
// [CHANGE P20260323-164500] intent: 角色下拉从 /api/tenant/roles 动态获取,不硬编码
// 加载角色列表(打开弹窗时)
useEffect(() => {
if (!open) return;
setLoadingRoles(true);
tenantApi
.get<RoleOption[]>("/roles")
.then((res) => setRoles(res.data))
.catch(() => setRoles([]))
.finally(() => setLoadingRoles(false));
}, [open]);
// 打开时重置状态 + 自动匹配手机号
useEffect(() => {
if (!open || !application) {
setSuggestions([]);
setStaffCandidates([]);
setAction("approve");
approveForm.resetFields();
rejectForm.resetFields();
return;
}
// 仍然加载旧的 match-suggestions兼容
setLoadingSuggestions(true);
tenantApi
.get<MatchSuggestion[]>(`/applications/${application.id}/match-suggestions`)
.then((res) => setSuggestions(res.data))
.catch(() => setSuggestions([]))
.finally(() => setLoadingSuggestions(false));
// 自动匹配:如果有手机号和球房编号,并行查 coach 和 staff 人员列表
if (application.phone && application.siteCode && roles.length > 0) {
setLoadingStaff(true);
const coachReq = tenantApi
.get<StaffCandidate[]>("/site-staff", {
params: { role: "coach", site_code: application.siteCode },
})
.catch(() => ({ data: [] as StaffCandidate[] }));
const staffReq = tenantApi
.get<StaffCandidate[]>("/site-staff", {
params: { role: "staff", site_code: application.siteCode },
})
.catch(() => ({ data: [] as StaffCandidate[] }));
Promise.all([coachReq, staffReq])
.then(([coachRes, staffRes]) => {
const phone = application.phone!;
// 优先匹配助教表
const coachMatch = coachRes.data.find((c) => c.mobile === phone);
if (coachMatch) {
setStaffCandidates(coachRes.data);
approveForm.setFieldsValue({
role: "coach",
staffBinding: `${coachMatch.source}:${coachMatch.id}`,
});
return;
}
// 其次匹配员工表
const staffMatch = staffRes.data.find((c) => c.mobile === phone);
if (staffMatch) {
setStaffCandidates(staffRes.data);
approveForm.setFieldsValue({
role: "staff",
staffBinding: `${staffMatch.source}:${staffMatch.id}`,
});
return;
}
// 都没匹配到,不自动选择
})
.finally(() => setLoadingStaff(false));
}
}, [open, application, approveForm, rejectForm, roles]);
// [CHANGE P20260323-164500] intent: 角色变化时联动查询人员列表coach→助教表其他→员工表
// assumptions: staffBinding 格式 "source:id"(如 "assistant:123")或 "none"
// 角色变化时查询人员列表
const handleRoleChange = useCallback(
(roleCode: string) => {
if (!application?.siteCode) {
setStaffCandidates([]);
approveForm.setFieldValue("staffBinding", "none");
return;
}
setLoadingStaff(true);
setStaffCandidates([]);
approveForm.setFieldValue("staffBinding", undefined);
tenantApi
.get<StaffCandidate[]>("/site-staff", {
params: { role: roleCode, site_code: application.siteCode },
})
.then((res) => {
const candidates = res.data;
setStaffCandidates(candidates);
// 自动匹配:如果有人的手机号与申请者一致,自动选中
if (application.phone) {
const matchIdx = candidates.findIndex(
(c) => c.mobile === application.phone
);
if (matchIdx >= 0) {
const matched = candidates[matchIdx];
approveForm.setFieldValue(
"staffBinding",
`${matched.source}:${matched.id}`
);
} else {
approveForm.setFieldValue("staffBinding", "none");
}
} else {
approveForm.setFieldValue("staffBinding", "none");
}
})
.catch(() => {
setStaffCandidates([]);
approveForm.setFieldValue("staffBinding", "none");
})
.finally(() => setLoadingStaff(false));
},
[application, approveForm]
);
const handleApprove = async () => {
if (!application) return;
try {
const values = await approveForm.validateFields();
setSubmitting(true);
// 从 staffBinding 解析 assistantId / staffId
let assistantId: number | undefined;
let staffId: number | undefined;
if (values.staffBinding && values.staffBinding !== "none") {
const [source, idStr] = values.staffBinding.split(":");
const id = parseInt(idStr, 10);
if (source === "assistant") {
assistantId = id;
} else {
staffId = id;
}
}
await tenantApi.post(`/applications/${application.id}/approve`, {
role: values.role,
assistantId,
staffId,
});
message.success("审核通过成功");
onSuccess();
} catch (err) {
const axiosErr = err as AxiosError<{ message?: string }>;
if (axiosErr.response?.status === 409) {
message.error("该申请已被处理");
} else if (axiosErr.response?.data?.message) {
message.error(axiosErr.response.data.message);
}
} finally {
setSubmitting(false);
}
};
const handleReject = async () => {
if (!application) return;
try {
const values = await rejectForm.validateFields();
setSubmitting(true);
await tenantApi.post(`/applications/${application.id}/reject`, {
reason: values.reason,
});
message.success("已拒绝该申请");
onSuccess();
} catch (err) {
const axiosErr = err as AxiosError<{ message?: string }>;
if (axiosErr.response?.status === 409) {
message.error("该申请已被处理");
} else if (axiosErr.response?.data?.message) {
message.error(axiosErr.response.data.message);
}
} finally {
setSubmitting(false);
}
};
const handleOk = () => {
if (action === "approve") {
handleApprove();
} else {
handleReject();
}
};
// 人员下拉选项:格式 "身份/等级 - 姓名 - 手机号 - 入职日期 YYYY-MM-DD"
const staffOptions = [
{ label: "无(不关联)", value: "none" },
...staffCandidates.map((c) => {
const parts = [
c.identityLabel ?? "-",
c.name,
c.mobile ?? "-",
c.entryTime ? `入职日期 ${c.entryTime.slice(0, 10)}` : "-",
];
return {
label: parts.join(" - "),
value: `${c.source}:${c.id}`,
};
}),
];
return (
<Modal
title={`审核申请 — ${application?.nickname ?? "未知用户"}`}
open={open}
onCancel={onClose}
onOk={handleOk}
confirmLoading={submitting}
okText={action === "approve" ? "通过" : "拒绝"}
okButtonProps={{ danger: action === "reject" }}
destroyOnClose
width={560}
>
<div style={{ display: "flex", gap: 16, marginBottom: 20 }}>
<Avatar
size={64}
icon={<UserOutlined />}
src={
application?.avatarUrl
? `/api/xcx/avatar/${application.userId}`
: undefined
}
style={
application?.avatarUrl
? undefined
: { backgroundColor: "#e8e8e8", color: "#999" }
}
/>
<Descriptions column={1} size="small" style={{ flex: 1 }}>
<Descriptions.Item label="昵称">
{application?.nickname ?? "-"}
</Descriptions.Item>
<Descriptions.Item label="手机号">
{application?.phone ?? "-"}
</Descriptions.Item>
<Descriptions.Item label="球房编号">
{application?.siteCode ?? "-"}
</Descriptions.Item>
<Descriptions.Item label="申请角色">
{application?.appliedRoleText ?? "-"}
</Descriptions.Item>
<Descriptions.Item label="员工编号">
{application?.employeeNumber ?? "-"}
</Descriptions.Item>
</Descriptions>
</div>
<Radio.Group
value={action}
onChange={(e) => setAction(e.target.value)}
style={{ marginBottom: 16 }}
>
<Radio.Button value="approve"></Radio.Button>
<Radio.Button value="reject"></Radio.Button>
</Radio.Group>
{action === "approve" ? (
<Form form={approveForm} layout="vertical">
<Form.Item
name="role"
label="分配角色"
rules={[{ required: true, message: "请选择角色" }]}
>
<Select
placeholder={loadingRoles ? "加载中..." : "选择角色"}
loading={loadingRoles}
disabled={loadingRoles || loadingStaff}
onChange={handleRoleChange}
options={roles.map((r) => ({
label: r.name,
value: r.code,
}))}
/>
</Form.Item>
<Form.Item
name="staffBinding"
label="关联助教/员工"
rules={[{ required: true, message: '请选择关联人员(或选择"无"' }]}
>
<Select
placeholder={loadingStaff ? "加载中..." : "请先选择角色"}
loading={loadingStaff}
disabled={loadingRoles || loadingStaff}
options={staffOptions}
showSearch
filterOption={pinyinFilterOption}
notFoundContent={loadingStaff ? "加载中..." : "无匹配人员"}
/>
</Form.Item>
</Form>
) : (
<Form form={rejectForm} layout="vertical">
<Form.Item
name="reason"
label="拒绝原因"
rules={[{ required: true, message: "请填写拒绝原因" }]}
>
<Input.TextArea rows={3} placeholder="请输入拒绝原因" maxLength={500} showCount />
</Form.Item>
</Form>
)}
</Modal>
);
};
/* ------------------------------------------------------------------ */
/* 主页面组件 */
/* ------------------------------------------------------------------ */
const UserApproval: React.FC = () => {
const [data, setData] = useState<ApplicationItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [statusFilter, setStatusFilter] = useState("");
const [siteFilter, setSiteFilter] = useState<number | undefined>(undefined);
const [sites, setSites] = useState<SiteOption[]>([]);
const [loading, setLoading] = useState(false);
// 审核 Modal 状态
const [reviewOpen, setReviewOpen] = useState(false);
const [reviewApp, setReviewApp] = useState<ApplicationItem | null>(null);
// 加载管辖店铺列表
useEffect(() => {
tenantApi
.get<SiteOption[]>("/my-sites")
.then((res) => setSites(res.data))
.catch(() => {});
}, []);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const params: Record<string, string | number> = { page, page_size: pageSize };
if (statusFilter) params.status = statusFilter;
if (siteFilter !== undefined) params.site_id = siteFilter;
const res = await tenantApi.get<PaginatedResponse<ApplicationItem>>("/applications", {
params,
});
setData(res.data.items);
setTotal(res.data.total);
} catch {
message.error("获取申请列表失败");
} finally {
setLoading(false);
}
}, [page, pageSize, statusFilter, siteFilter]);
useEffect(() => {
fetchData();
}, [fetchData]);
const handleTableChange = (pagination: TablePaginationConfig) => {
setPage(pagination.current ?? 1);
setPageSize(pagination.pageSize ?? 20);
};
const openReview = (record: ApplicationItem) => {
setReviewApp(record);
setReviewOpen(true);
};
const handleReviewSuccess = () => {
setReviewOpen(false);
setReviewApp(null);
fetchData();
};
const columns: ColumnsType<ApplicationItem> = [
{ title: "昵称", dataIndex: "nickname", key: "nickname", render: (v) => v ?? "-" },
{ title: "手机号", dataIndex: "phone", key: "phone", render: (v) => v ?? "-" },
{ title: "球房编号", dataIndex: "siteCode", key: "siteCode", render: (v) => v ?? "-" },
{ title: "申请角色", dataIndex: "appliedRoleText", key: "appliedRoleText", render: (v) => v ?? "-" },
{ title: "员工编号", dataIndex: "employeeNumber", key: "employeeNumber", render: (v) => v ?? "-" },
{ title: "申请时间", dataIndex: "createdAt", key: "createdAt", render: (v) => v ?? "-" },
{
title: "状态",
dataIndex: "status",
key: "status",
render: (status: string) => {
const tag = STATUS_TAG_MAP[status];
return tag ? <Tag color={tag.color}>{tag.text}</Tag> : status;
},
},
{
title: "操作",
key: "action",
render: (_, record) =>
record.status === "pending" ? (
<Button type="link" onClick={() => openReview(record)}>
</Button>
) : null,
},
];
return (
<div>
<div style={{ marginBottom: 16, display: "flex", alignItems: "center", gap: 12 }}>
<span></span>
<Select
value={statusFilter}
onChange={(v) => {
setStatusFilter(v);
setPage(1);
}}
options={STATUS_OPTIONS}
style={{ width: 140 }}
/>
<span></span>
<Select
value={siteFilter}
onChange={(v) => {
setSiteFilter(v);
setPage(1);
}}
allowClear
placeholder="全部店铺"
style={{ width: 200 }}
options={sites.map((s) => ({
label: `${s.siteName}${s.siteCode ? ` (${s.siteCode})` : ""}`,
value: s.siteId,
}))}
/>
</div>
<Table<ApplicationItem>
rowKey="id"
columns={columns}
dataSource={data}
loading={loading}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
showTotal: (t) => `${t}`,
}}
onChange={handleTableChange}
/>
<ReviewModal
open={reviewOpen}
application={reviewApp}
onClose={() => {
setReviewOpen(false);
setReviewApp(null);
}}
onSuccess={handleReviewSuccess}
/>
</div>
);
};
export default UserApproval;

View File

@@ -0,0 +1,455 @@
/**
* 用户管理页 — 用户列表 + 编辑/绑定合并 Modal。
*
* - 用户列表Ant Design Table支持角色筛选 + 关键词搜索 + 分页
* - 编辑 Modal角色动态下拉 + 人员联动查询 + 拼音搜索 + "无"选项 + 门店选择
* (复用审核弹窗的交互模式,角色与绑定互斥,换角色自动清除旧绑定)
* - 移除用户时二次确认Modal.confirm
* - 数据源tenantApi GET /users, GET /roles, GET /site-staff, GET /my-sites
* - 操作PATCH /users/{id}(角色+门店+绑定合并提交)
*
* AI_CHANGELOG
* - 2026-03-24 | Prompt: 用户管理绑定功能改造 | Direct causeEditModal+BindModal 分离BindModal 手动输入 ID 体验差 | Summary合并为单一弹窗复用审核弹窗交互模式角色动态下拉→人员联动→拼音搜索→"无"选项PATCH 接口同时提交角色+绑定 | Verify编辑弹窗角色联动人员列表 + 拼音搜索 + 解绑 + 换角色清除旧绑定
*/
import React, { useState, useEffect, useCallback } from "react";
import {
Table, Select, Input, Button, Modal, Form,
Space, message,
} from "antd";
import type { ColumnsType, TablePaginationConfig } from "antd/es/table";
import { tenantApi } from "@/services/api";
import type { AxiosError } from "axios";
import { pinyinFilterOption } from "@/utils/pinyinMatch";
/* ------------------------------------------------------------------ */
/* 类型定义 */
/* ------------------------------------------------------------------ */
interface UserItem {
id: number;
nickname: string | null;
role: string | null; // 中文名(显示用)
roleCode: string | null; // code提交用
assistantId: number | null; // 当前绑定的助教 ID
staffId: number | null; // 当前绑定的员工 ID
assistantName: string | null;
siteName: string | null;
siteId: number | null;
status: string; // approved / disabled
}
interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
pageSize: number;
}
interface RoleOption {
id: number;
code: string;
name: string;
description: string | null;
}
interface SiteOption {
siteId: number;
siteName: string;
}
interface StaffCandidate {
id: number;
identityLabel: string | null;
name: string;
mobile: string | null;
entryTime: string | null;
source: string; // "assistant" | "staff"
}
/* ------------------------------------------------------------------ */
/* 常量 */
/* ------------------------------------------------------------------ */
const ROLE_LABEL: Record<string, string> = {
coach: "助教",
staff: "员工",
head_coach: "教练",
manager: "管理人员",
};
/* ------------------------------------------------------------------ */
/* 编辑/绑定合并 Modal */
/* ------------------------------------------------------------------ */
// [CHANGE P20260324] intent: 合并 EditModal + BindModal复用审核弹窗的角色联动+人员下拉交互
// assumptions: 角色与绑定互斥coach→助教其他→员工换角色自动清除旧绑定
// verify: 编辑弹窗角色联动人员列表 + 拼音搜索 + 解绑 + 换角色清除旧绑定
interface EditBindModalProps {
open: boolean;
user: UserItem | null;
sites: SiteOption[];
roles: RoleOption[];
onClose: () => void;
onSuccess: () => void;
}
const EditBindModal: React.FC<EditBindModalProps> = ({
open, user, sites, roles, onClose, onSuccess,
}) => {
const [form] = Form.useForm();
const [submitting, setSubmitting] = useState(false);
const [staffCandidates, setStaffCandidates] = useState<StaffCandidate[]>([]);
const [loadingStaff, setLoadingStaff] = useState(false);
// 打开弹窗时初始化表单 + 加载当前角色对应的人员列表
useEffect(() => {
if (!open || !user) {
setStaffCandidates([]);
return;
}
form.setFieldsValue({
role: user.roleCode ?? undefined,
siteId: user.siteId ?? undefined,
staffBinding: "none",
});
// 如果用户已有角色和门店,加载对应人员列表并回显当前绑定
if (user.roleCode && user.siteId) {
loadStaffForRole(user.roleCode, user.siteId).then(() => {
// 回显当前绑定状态
if (user.assistantId) {
form.setFieldValue("staffBinding", `assistant:${user.assistantId}`);
} else if (user.staffId) {
form.setFieldValue("staffBinding", `staff:${user.staffId}`);
}
});
}
}, [open, user, form]);
// 按角色+门店查询人员候选列表
const loadStaffForRole = useCallback((roleCode: string, siteId: number): Promise<void> => {
setLoadingStaff(true);
setStaffCandidates([]);
return tenantApi
.get<StaffCandidate[]>("/site-staff", {
params: { role: roleCode, site_id: siteId },
})
.then((res) => setStaffCandidates(res.data))
.catch(() => setStaffCandidates([]))
.finally(() => setLoadingStaff(false));
}, []);
// 角色变化时联动查询人员列表
const handleRoleChange = useCallback(
(roleCode: string) => {
// 清除旧绑定
form.setFieldValue("staffBinding", "none");
setStaffCandidates([]);
const siteId = form.getFieldValue("siteId");
if (!siteId) return;
loadStaffForRole(roleCode, siteId);
},
[form, loadStaffForRole],
);
// 门店变化时也需要重新加载人员列表
const handleSiteChange = useCallback(
(siteId: number) => {
form.setFieldValue("staffBinding", "none");
setStaffCandidates([]);
const roleCode = form.getFieldValue("role");
if (!roleCode) return;
loadStaffForRole(roleCode, siteId);
},
[form, loadStaffForRole],
);
const handleOk = async () => {
if (!user) return;
try {
const values = await form.validateFields();
setSubmitting(true);
// 从 staffBinding 解析 assistantId / staffId
let assistantId: number | null = null;
let staffId: number | null = null;
if (values.staffBinding && values.staffBinding !== "none") {
const [source, idStr] = values.staffBinding.split(":");
const id = parseInt(idStr, 10);
if (source === "assistant") {
assistantId = id;
} else {
staffId = id;
}
}
await tenantApi.patch(`/users/${user.id}`, {
role: values.role || undefined,
siteId: values.siteId || undefined,
assistantId,
staffId,
});
message.success("更新成功");
onSuccess();
} catch (err) {
const axiosErr = err as AxiosError<{ message?: string }>;
if (axiosErr.response?.status === 403) {
message.error("目标门店不在管辖范围内");
} else if (axiosErr.response?.data?.message) {
message.error(axiosErr.response.data.message);
}
} finally {
setSubmitting(false);
}
};
// 人员下拉选项:格式 "身份/等级 - 姓名 - 手机号 - 入职日期 YYYY-MM-DD"
const staffOptions = [
{ label: "无(不关联)", value: "none" },
...staffCandidates.map((c) => {
const parts = [
c.identityLabel ?? "-",
c.name,
c.mobile ?? "-",
c.entryTime ? `入职日期 ${c.entryTime.slice(0, 10)}` : "-",
];
return {
label: parts.join(" - "),
value: `${c.source}:${c.id}`,
};
}),
];
const siteOptions = sites.map((s) => ({
label: s.siteName,
value: s.siteId,
}));
return (
<Modal
title={`编辑用户 — ${user?.nickname ?? ""}`}
open={open}
onCancel={onClose}
onOk={handleOk}
confirmLoading={submitting}
okText="保存"
destroyOnClose
width={560}
>
<Form form={form} layout="vertical">
<Form.Item name="siteId" label="所属门店">
<Select
placeholder="选择门店"
options={siteOptions}
onChange={handleSiteChange}
/>
</Form.Item>
<Form.Item name="role" label="角色" rules={[{ required: true, message: "请选择角色" }]}>
<Select placeholder="选择角色" onChange={handleRoleChange}>
{roles.map((r) => (
<Select.Option key={r.code} value={r.code}>{r.name}</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item name="staffBinding" label="关联人员">
<Select
showSearch
loading={loadingStaff}
placeholder={loadingStaff ? "加载中..." : "选择关联人员"}
options={staffOptions}
filterOption={pinyinFilterOption}
/>
</Form.Item>
</Form>
</Modal>
);
};
/* ------------------------------------------------------------------ */
/* 主页面组件 */
/* ------------------------------------------------------------------ */
const UserManagement: React.FC = () => {
const [data, setData] = useState<UserItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [roleFilter, setRoleFilter] = useState("");
const [keyword, setKeyword] = useState("");
const [loading, setLoading] = useState(false);
const [roles, setRoles] = useState<RoleOption[]>([]);
const [sites, setSites] = useState<SiteOption[]>([]);
// Modal 状态(合并为单一弹窗)
const [editOpen, setEditOpen] = useState(false);
const [editUser, setEditUser] = useState<UserItem | null>(null);
// 加载角色列表(一次性)
useEffect(() => {
tenantApi.get<RoleOption[]>("/roles")
.then((res) => setRoles(res.data))
.catch(() => setRoles([]));
}, []);
// 加载管辖门店列表(一次性)
useEffect(() => {
tenantApi.get<SiteOption[]>("/my-sites")
.then((res) => setSites(res.data))
.catch(() => setSites([]));
}, []);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const params: Record<string, string | number> = { page, page_size: pageSize };
if (roleFilter) params.role = roleFilter;
if (keyword.trim()) params.keyword = keyword.trim();
const res = await tenantApi.get<PaginatedResponse<UserItem>>("/users", { params });
setData(res.data.items);
setTotal(res.data.total);
} catch {
message.error("获取用户列表失败");
} finally {
setLoading(false);
}
}, [page, pageSize, roleFilter, keyword]);
useEffect(() => {
fetchData();
}, [fetchData]);
const handleTableChange = (pagination: TablePaginationConfig) => {
setPage(pagination.current ?? 1);
setPageSize(pagination.pageSize ?? 20);
};
const handleSearch = (value: string) => {
setKeyword(value);
setPage(1);
};
const openEdit = (record: UserItem) => {
setEditUser(record);
setEditOpen(true);
};
// CHANGE 2026-03-23 | 移除用户:从店铺关系中删除,替代原来的"禁用"
const handleRemove = (record: UserItem) => {
Modal.confirm({
title: "确认移除",
content: `确定要将用户「${record.nickname ?? record.id}」从店铺中移除吗?移除后该用户需重新申请才能使用小程序。`,
okText: "确认移除",
okButtonProps: { danger: true },
cancelText: "取消",
onOk: async () => {
try {
await tenantApi.delete(`/users/${record.id}`);
message.success("用户已移除");
fetchData();
} catch (err) {
const axiosErr = err as AxiosError<{ message?: string }>;
message.error(axiosErr.response?.data?.message ?? "移除失败");
}
},
});
};
const handleModalSuccess = () => {
setEditOpen(false);
setEditUser(null);
fetchData();
};
const columns: ColumnsType<UserItem> = [
{
title: "姓名",
dataIndex: "nickname",
key: "nickname",
render: (v) => v ?? "-",
},
{
title: "角色",
dataIndex: "role",
key: "role",
render: (v: string | null, record: UserItem) => v || (record.roleCode ? (ROLE_LABEL[record.roleCode] ?? record.roleCode) : "-"),
},
{
title: "关联助教",
dataIndex: "assistantName",
key: "assistantName",
render: (v) => v ?? "-",
},
{
title: "所属门店",
dataIndex: "siteName",
key: "siteName",
render: (v, record) => v ?? (record.siteId ? `门店 ${record.siteId}` : "-"),
},
{
title: "操作",
key: "action",
render: (_, record) => (
<Space>
<Button type="link" onClick={() => openEdit(record)}></Button>
<Button type="link" danger onClick={() => handleRemove(record)}></Button>
</Space>
),
},
];
return (
<div>
<div style={{ marginBottom: 16, display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap" }}>
<span></span>
<Select
value={roleFilter}
onChange={(v) => { setRoleFilter(v); setPage(1); }}
options={[
{ label: "全部角色", value: "" },
...roles.map((r) => ({ label: r.name, value: r.code })),
]}
style={{ width: 140 }}
/>
<Input.Search
placeholder="搜索姓名/手机号"
allowClear
onSearch={handleSearch}
style={{ width: 240 }}
/>
</div>
<Table<UserItem>
rowKey="id"
columns={columns}
dataSource={data}
loading={loading}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
showTotal: (t) => `${t}`,
}}
onChange={handleTableChange}
/>
<EditBindModal
open={editOpen}
user={editUser}
sites={sites}
roles={roles}
onClose={() => { setEditOpen(false); setEditUser(null); }}
onSuccess={handleModalSuccess}
/>
</div>
);
};
export default UserManagement;