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:
493
apps/tenant-admin/src/pages/ExcelUpload/index.tsx
Normal file
493
apps/tenant-admin/src/pages/ExcelUpload/index.tsx
Normal file
@@ -0,0 +1,493 @@
|
||||
/**
|
||||
* Excel 上传页 — 模板选择 + 文件上传 + 校验结果 + 冲突处理 + 上传记录。
|
||||
*
|
||||
* - Ant Design Tabs 切换"上传"和"上传记录"
|
||||
* - 模板类型选择(Radio.Group:4 种模板)
|
||||
* - 模板下载按钮(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;
|
||||
97
apps/tenant-admin/src/pages/Login/index.tsx
Normal file
97
apps/tenant-admin/src/pages/Login/index.tsx
Normal 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;
|
||||
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;
|
||||
351
apps/tenant-admin/src/pages/SiteAdmins/index.tsx
Normal file
351
apps/tenant-admin/src/pages/SiteAdmins/index.tsx
Normal 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;
|
||||
586
apps/tenant-admin/src/pages/UserApproval/index.tsx
Normal file
586
apps/tenant-admin/src/pages/UserApproval/index.tsx
Normal 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:角色硬编码+无人员联动 | Summary:ReviewModal 角色下拉动态化(GET /roles)、人员列表联动(GET /site-staff)、手机号自动匹配、"无"选项、staffBinding 格式 source:id、修复中文引号+filterOption 类型 | Verify:tenant-admin 审核弹窗功能完整 + Vite HMR 无报错
|
||||
* - 2026-03-23 19:30:00 | Prompt: P20260323-190000(手机号不显示+自动匹配优化)| Direct cause:弹窗手机号显示 `-`;身份标签显示数字 | Summary:弹窗打开时并行查 coach/staff 自动匹配手机号→自动选角色+人员;staffOptions label 加"入职日期"前缀 | Verify:Playwright 验证自动选中"员工"+"店长 - 厉超 - 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;
|
||||
455
apps/tenant-admin/src/pages/UserManagement/index.tsx
Normal file
455
apps/tenant-admin/src/pages/UserManagement/index.tsx
Normal 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 cause:EditModal+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;
|
||||
Reference in New Issue
Block a user