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:
13
apps/tenant-admin/index.html
Normal file
13
apps/tenant-admin/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>租户管理后台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
32
apps/tenant-admin/package.json
Normal file
32
apps/tenant-admin/package.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "tenant-admin",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"lint": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.6.1",
|
||||
"antd": "^5.24.7",
|
||||
"axios": "^1.9.0",
|
||||
"dayjs": "^1.11.19",
|
||||
"pinyin-pro": "^3.28.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.5.2",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^6.3.5",
|
||||
"vitest": "^3.1.4"
|
||||
}
|
||||
}
|
||||
2379
apps/tenant-admin/pnpm-lock.yaml
generated
Normal file
2379
apps/tenant-admin/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
155
apps/tenant-admin/src/App.tsx
Normal file
155
apps/tenant-admin/src/App.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* 主布局与路由配置。
|
||||
*
|
||||
* - Ant Design Layout:Sider + Content
|
||||
* - react-router-dom v6 路由
|
||||
* - 未认证重定向到 /login
|
||||
* - 路由:/login, /applications, /users, /excel, /clues, / → /applications
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { Routes, Route, Navigate, useNavigate, useLocation } from "react-router-dom";
|
||||
import { Layout, Menu, Button, Tooltip } from "antd";
|
||||
import {
|
||||
AuditOutlined,
|
||||
TeamOutlined,
|
||||
FileExcelOutlined,
|
||||
SolutionOutlined,
|
||||
LogoutOutlined,
|
||||
UserSwitchOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import type { MenuProps } from "antd";
|
||||
import { AuthProvider, useAuth } from "./hooks/useAuth";
|
||||
import Login from "./pages/Login";
|
||||
import UserApproval from "./pages/UserApproval";
|
||||
import UserManagement from "./pages/UserManagement";
|
||||
import ExcelUpload from "./pages/ExcelUpload";
|
||||
import RetentionClues from "./pages/RetentionClues";
|
||||
import SiteAdmins from "./pages/SiteAdmins";
|
||||
|
||||
const { Sider, Content } = Layout;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 侧边栏导航配置 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function getNavItems(adminType: string): MenuProps["items"] {
|
||||
const items: MenuProps["items"] = [
|
||||
{ key: "/applications", icon: <AuditOutlined />, label: "用户审核" },
|
||||
{ key: "/users", icon: <TeamOutlined />, label: "用户管理" },
|
||||
{ key: "/excel", icon: <FileExcelOutlined />, label: "Excel 上传" },
|
||||
{ key: "/clues", icon: <SolutionOutlined />, label: "维客线索管理" },
|
||||
];
|
||||
// 仅租户管理员可见
|
||||
if (adminType === "tenant_admin") {
|
||||
items.push({ key: "/site-admins", icon: <UserSwitchOutlined />, label: "店铺管理员" });
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 路由守卫 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const PrivateRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { isAuthenticated } = useAuth();
|
||||
return isAuthenticated ? <>{children}</> : <Navigate to="/login" replace />;
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 主布局(含侧边栏) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const AppLayout: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { logout, user } = useAuth();
|
||||
|
||||
const navItems = getNavItems(user?.adminType ?? "site_admin");
|
||||
|
||||
const onMenuClick: MenuProps["onClick"] = ({ key }) => {
|
||||
navigate(key);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: "100vh" }}>
|
||||
<Sider collapsible style={{ display: "flex", flexDirection: "column" }}>
|
||||
<div
|
||||
style={{
|
||||
height: 48,
|
||||
margin: "12px 16px",
|
||||
color: "#fff",
|
||||
fontWeight: 700,
|
||||
fontSize: 16,
|
||||
textAlign: "center",
|
||||
lineHeight: "48px",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
租户管理后台
|
||||
</div>
|
||||
<Menu
|
||||
theme="dark"
|
||||
mode="inline"
|
||||
selectedKeys={[location.pathname]}
|
||||
items={navItems}
|
||||
onClick={onMenuClick}
|
||||
/>
|
||||
<div style={{ flex: 1 }} />
|
||||
<div style={{ padding: "12px 16px" }}>
|
||||
<Tooltip title="退出登录">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<LogoutOutlined />}
|
||||
onClick={handleLogout}
|
||||
style={{ color: "rgba(255,255,255,0.65)", width: "100%" }}
|
||||
>
|
||||
退出
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Sider>
|
||||
<Layout>
|
||||
<Content style={{ margin: 16, minHeight: 280 }}>
|
||||
<Routes>
|
||||
<Route path="/applications" element={<UserApproval />} />
|
||||
<Route path="/users" element={<UserManagement />} />
|
||||
<Route path="/excel" element={<ExcelUpload />} />
|
||||
<Route path="/clues" element={<RetentionClues />} />
|
||||
<Route path="/site-admins" element={<SiteAdmins />} />
|
||||
<Route path="/" element={<Navigate to="/applications" replace />} />
|
||||
</Routes>
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 根组件 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const App: React.FC = () => {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route
|
||||
path="/*"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<AppLayout />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
60
apps/tenant-admin/src/__tests__/apiResponse.test.ts
Normal file
60
apps/tenant-admin/src/__tests__/apiResponse.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Property 16: 响应格式一致性(前端 fast-check)
|
||||
*
|
||||
* 验证:
|
||||
* - 成功响应符合 { code: 0, data } 格式
|
||||
* - 错误响应符合 { code: number, message: string } 格式
|
||||
* - 分页响应 data 包含 items/total/page/pageSize
|
||||
*
|
||||
* **验证: 需求 18.4**
|
||||
*
|
||||
* 注意:此文件为骨架,需安装 fast-check 后运行。
|
||||
* pnpm add -D fast-check
|
||||
*/
|
||||
|
||||
// import * as fc from "fast-check";
|
||||
// import { describe, it, expect } from "vitest";
|
||||
|
||||
// describe("Property 16: API 响应格式一致性", () => {
|
||||
// it("成功响应包含 code=0 和 data 字段", () => {
|
||||
// fc.assert(
|
||||
// fc.property(fc.anything(), (data) => {
|
||||
// const response = { code: 0, data };
|
||||
// expect(response).toHaveProperty("code", 0);
|
||||
// expect(response).toHaveProperty("data");
|
||||
// }),
|
||||
// );
|
||||
// });
|
||||
//
|
||||
// it("错误响应包含 code>0 和 message 字段", () => {
|
||||
// fc.assert(
|
||||
// fc.property(
|
||||
// fc.integer({ min: 1 }),
|
||||
// fc.string({ minLength: 1 }),
|
||||
// (code, message) => {
|
||||
// const response = { code, message };
|
||||
// expect(response.code).toBeGreaterThan(0);
|
||||
// expect(typeof response.message).toBe("string");
|
||||
// },
|
||||
// ),
|
||||
// );
|
||||
// });
|
||||
//
|
||||
// it("分页响应 data 包含 items/total/page/pageSize", () => {
|
||||
// fc.assert(
|
||||
// fc.property(
|
||||
// fc.array(fc.anything()),
|
||||
// fc.nat(),
|
||||
// fc.integer({ min: 1 }),
|
||||
// fc.integer({ min: 1, max: 100 }),
|
||||
// (items, total, page, pageSize) => {
|
||||
// const response = { code: 0, data: { items, total, page, pageSize } };
|
||||
// expect(response.data).toHaveProperty("items");
|
||||
// expect(response.data).toHaveProperty("total");
|
||||
// expect(response.data).toHaveProperty("page");
|
||||
// expect(response.data).toHaveProperty("pageSize");
|
||||
// },
|
||||
// ),
|
||||
// );
|
||||
// });
|
||||
// });
|
||||
130
apps/tenant-admin/src/components/ClueEditor/index.tsx
Normal file
130
apps/tenant-admin/src/components/ClueEditor/index.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* 线索编辑表单 — Modal 内的 Form。
|
||||
*
|
||||
* - category Select(6 值枚举)
|
||||
* - summary Input(必填,最长 200 字符,showCount)
|
||||
* - detail TextArea(可选)
|
||||
* - Props: visible, clue(编辑时传入现有数据), onSubmit, onCancel
|
||||
*/
|
||||
|
||||
import React, { useEffect } from "react";
|
||||
import { Modal, Form, Select, Input } from "antd";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 线索大类枚举 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export const CLUE_CATEGORY_OPTIONS = [
|
||||
{ label: "客户基础", value: "customer_basic" },
|
||||
{ label: "消费习惯", value: "consumption_habit" },
|
||||
{ label: "玩法偏好", value: "play_preference" },
|
||||
{ label: "促销偏好", value: "promotion_preference" },
|
||||
{ label: "社交关系", value: "social_relation" },
|
||||
{ label: "重要反馈", value: "important_feedback" },
|
||||
];
|
||||
|
||||
export const CATEGORY_LABEL: Record<string, string> = {
|
||||
customer_basic: "客户基础",
|
||||
consumption_habit: "消费习惯",
|
||||
play_preference: "玩法偏好",
|
||||
promotion_preference: "促销偏好",
|
||||
social_relation: "社交关系",
|
||||
important_feedback: "重要反馈",
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 类型 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export interface ClueFormValues {
|
||||
category: string;
|
||||
summary: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface ClueData {
|
||||
id: number;
|
||||
category: string;
|
||||
summary: string;
|
||||
detail?: string | null;
|
||||
}
|
||||
|
||||
interface ClueEditorProps {
|
||||
visible: boolean;
|
||||
clue: ClueData | null;
|
||||
onSubmit: (values: ClueFormValues) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 组件 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const ClueEditor: React.FC<ClueEditorProps> = ({ visible, clue, onSubmit, onCancel }) => {
|
||||
const [form] = Form.useForm<ClueFormValues>();
|
||||
const [submitting, setSubmitting] = React.useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && clue) {
|
||||
form.setFieldsValue({
|
||||
category: clue.category,
|
||||
summary: clue.summary,
|
||||
detail: clue.detail ?? undefined,
|
||||
});
|
||||
} else if (visible) {
|
||||
form.resetFields();
|
||||
}
|
||||
}, [visible, clue, form]);
|
||||
|
||||
const handleOk = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setSubmitting(true);
|
||||
await onSubmit(values);
|
||||
form.resetFields();
|
||||
} catch {
|
||||
// validateFields 失败,不需要额外处理
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={clue ? "编辑线索" : "新建线索"}
|
||||
open={visible}
|
||||
onCancel={onCancel}
|
||||
onOk={handleOk}
|
||||
confirmLoading={submitting}
|
||||
okText="保存"
|
||||
cancelText="取消"
|
||||
destroyOnClose
|
||||
width={520}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
name="category"
|
||||
label="大类标签"
|
||||
rules={[{ required: true, message: "请选择大类标签" }]}
|
||||
>
|
||||
<Select placeholder="选择大类标签" options={CLUE_CATEGORY_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="summary"
|
||||
label="摘要"
|
||||
rules={[
|
||||
{ required: true, message: "请输入摘要" },
|
||||
{ max: 200, message: "摘要不能超过 200 字符" },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="请输入摘要" maxLength={200} showCount />
|
||||
</Form.Item>
|
||||
<Form.Item name="detail" label="详情">
|
||||
<Input.TextArea rows={4} placeholder="请输入详情(可选)" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClueEditor;
|
||||
191
apps/tenant-admin/src/components/DiffTable/index.tsx
Normal file
191
apps/tenant-admin/src/components/DiffTable/index.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* 冲突 diff 交互表格 — 展示新旧值对比,支持逐行选择替换/保留。
|
||||
*
|
||||
* - 每行显示:字段名、旧值、新值、操作选项(替换/保留 Radio)
|
||||
* - 支持"全部替换"和"全部保留"快捷操作按钮
|
||||
* - 确认按钮提交 resolutions 数组
|
||||
* - Props: conflicts(冲突数据)、onConfirm(确认回调)、loading
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback } from "react";
|
||||
import { Table, Radio, Button, Space } from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 类型定义 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** 单个字段的 diff */
|
||||
export interface FieldDiff {
|
||||
field: string;
|
||||
oldValue: string | number | null;
|
||||
newValue: string | number | null;
|
||||
}
|
||||
|
||||
/** 一行冲突数据 */
|
||||
export interface ConflictRow {
|
||||
rowIndex: number;
|
||||
diffs: FieldDiff[];
|
||||
}
|
||||
|
||||
/** 解决方案 */
|
||||
export interface Resolution {
|
||||
rowIndex: number;
|
||||
action: "replace" | "keep";
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Props */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
interface DiffTableProps {
|
||||
conflicts: ConflictRow[];
|
||||
onConfirm: (resolutions: Resolution[]) => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 展开行内的字段 diff 表格 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const FieldDiffTable: React.FC<{ diffs: FieldDiff[] }> = ({ diffs }) => {
|
||||
const columns: ColumnsType<FieldDiff> = [
|
||||
{ title: "字段名", dataIndex: "field", key: "field", width: 160 },
|
||||
{
|
||||
title: "旧值",
|
||||
dataIndex: "oldValue",
|
||||
key: "oldValue",
|
||||
render: (v) => (v !== null && v !== undefined ? String(v) : "-"),
|
||||
},
|
||||
{
|
||||
title: "新值",
|
||||
dataIndex: "newValue",
|
||||
key: "newValue",
|
||||
render: (v) => (v !== null && v !== undefined ? String(v) : "-"),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Table<FieldDiff>
|
||||
rowKey="field"
|
||||
columns={columns}
|
||||
dataSource={diffs}
|
||||
pagination={false}
|
||||
size="small"
|
||||
style={{ marginBottom: 0 }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 主组件 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const DiffTable: React.FC<DiffTableProps> = ({ conflicts, onConfirm, loading }) => {
|
||||
// 每行的选择状态:rowIndex → action
|
||||
const [selections, setSelections] = useState<Record<number, "replace" | "keep">>({});
|
||||
|
||||
const setAction = useCallback((rowIndex: number, action: "replace" | "keep") => {
|
||||
setSelections((prev) => ({ ...prev, [rowIndex]: action }));
|
||||
}, []);
|
||||
|
||||
/** 全部替换 */
|
||||
const handleReplaceAll = useCallback(() => {
|
||||
const all: Record<number, "replace" | "keep"> = {};
|
||||
conflicts.forEach((c) => {
|
||||
all[c.rowIndex] = "replace";
|
||||
});
|
||||
setSelections(all);
|
||||
}, [conflicts]);
|
||||
|
||||
/** 全部保留 */
|
||||
const handleKeepAll = useCallback(() => {
|
||||
const all: Record<number, "replace" | "keep"> = {};
|
||||
conflicts.forEach((c) => {
|
||||
all[c.rowIndex] = "keep";
|
||||
});
|
||||
setSelections(all);
|
||||
}, [conflicts]);
|
||||
|
||||
/** 是否所有行都已选择 */
|
||||
const allSelected = conflicts.every((c) => selections[c.rowIndex] !== undefined);
|
||||
|
||||
/** 提交 */
|
||||
const handleConfirm = () => {
|
||||
const resolutions: Resolution[] = conflicts.map((c) => ({
|
||||
rowIndex: c.rowIndex,
|
||||
action: selections[c.rowIndex] ?? "keep",
|
||||
}));
|
||||
onConfirm(resolutions);
|
||||
};
|
||||
|
||||
const columns: ColumnsType<ConflictRow> = [
|
||||
{
|
||||
title: "行号",
|
||||
dataIndex: "rowIndex",
|
||||
key: "rowIndex",
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: "冲突字段数",
|
||||
key: "diffCount",
|
||||
width: 100,
|
||||
render: (_, record) => record.diffs.length,
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
width: 200,
|
||||
render: (_, record) => (
|
||||
<Radio.Group
|
||||
value={selections[record.rowIndex]}
|
||||
onChange={(e) => setAction(record.rowIndex, e.target.value)}
|
||||
>
|
||||
<Radio value="replace">替换</Radio>
|
||||
<Radio value="keep">保留</Radio>
|
||||
</Radio.Group>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
|
||||
{/* 快捷操作 */}
|
||||
<Space>
|
||||
<Button onClick={handleReplaceAll}>全部替换</Button>
|
||||
<Button onClick={handleKeepAll}>全部保留</Button>
|
||||
</Space>
|
||||
|
||||
{/* 冲突表格 */}
|
||||
<Table<ConflictRow>
|
||||
rowKey="rowIndex"
|
||||
columns={columns}
|
||||
dataSource={conflicts}
|
||||
pagination={false}
|
||||
size="small"
|
||||
expandable={{
|
||||
expandedRowRender: (record) => <FieldDiffTable diffs={record.diffs} />,
|
||||
defaultExpandAllRows: true,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 确认按钮 */}
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
onClick={handleConfirm}
|
||||
loading={loading}
|
||||
disabled={!allSelected}
|
||||
>
|
||||
确认提交({conflicts.length} 行冲突)
|
||||
</Button>
|
||||
{!allSelected && (
|
||||
<span style={{ color: "#999", fontSize: 12 }}>
|
||||
请为所有冲突行选择操作后再提交
|
||||
</span>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiffTable;
|
||||
96
apps/tenant-admin/src/components/SiteSelector/index.tsx
Normal file
96
apps/tenant-admin/src/components/SiteSelector/index.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* 门店筛选器组件 + useSiteFilter hook。
|
||||
*
|
||||
* - 数据源:当前管理员的 managed_site_ids(从 useAuth 获取)
|
||||
* - Ant Design Select,支持多选/全选
|
||||
* - useSiteFilter hook 供各页面使用,管理选中状态
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useMemo } from "react";
|
||||
import { Select, Space } from "antd";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Hook:门店筛选状态管理 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export interface SiteFilterState {
|
||||
/** 当前选中的 site_id 列表(空 = 全选) */
|
||||
selectedSiteIds: number[];
|
||||
/** 设置选中的 site_id */
|
||||
setSelectedSiteIds: (ids: number[]) => void;
|
||||
/** 管理员管辖的全部 site_id */
|
||||
allSiteIds: number[];
|
||||
/** 用于 API 请求的 site_id 列表(空选时返回全部) */
|
||||
effectiveSiteIds: number[];
|
||||
}
|
||||
|
||||
export function useSiteFilter(): SiteFilterState {
|
||||
const { user } = useAuth();
|
||||
const allSiteIds = useMemo(() => user?.managedSiteIds ?? [], [user]);
|
||||
const [selectedSiteIds, setSelectedSiteIds] = useState<number[]>([]);
|
||||
|
||||
const effectiveSiteIds = useMemo(
|
||||
() => (selectedSiteIds.length > 0 ? selectedSiteIds : allSiteIds),
|
||||
[selectedSiteIds, allSiteIds],
|
||||
);
|
||||
|
||||
return { selectedSiteIds, setSelectedSiteIds, allSiteIds, effectiveSiteIds };
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 组件 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
interface SiteSelectorProps {
|
||||
value: number[];
|
||||
onChange: (ids: number[]) => void;
|
||||
allSiteIds: number[];
|
||||
}
|
||||
|
||||
const SiteSelector: React.FC<SiteSelectorProps> = ({ value, onChange, allSiteIds }) => {
|
||||
const isAllSelected = value.length === 0 || value.length === allSiteIds.length;
|
||||
|
||||
const handleChange = useCallback(
|
||||
(selected: number[]) => {
|
||||
// 如果选了全部,清空表示"全选"
|
||||
if (selected.length === allSiteIds.length) {
|
||||
onChange([]);
|
||||
} else {
|
||||
onChange(selected);
|
||||
}
|
||||
},
|
||||
[allSiteIds, onChange],
|
||||
);
|
||||
|
||||
const handleSelectAll = useCallback(() => {
|
||||
onChange([]);
|
||||
}, [onChange]);
|
||||
|
||||
const options = useMemo(
|
||||
() => allSiteIds.map((id) => ({ label: `门店 ${id}`, value: id })),
|
||||
[allSiteIds],
|
||||
);
|
||||
|
||||
return (
|
||||
<Space>
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder="选择门店(默认全部)"
|
||||
value={isAllSelected ? [] : value}
|
||||
onChange={handleChange}
|
||||
options={options}
|
||||
style={{ minWidth: 240 }}
|
||||
allowClear
|
||||
maxTagCount="responsive"
|
||||
/>
|
||||
{!isAllSelected && (
|
||||
<a onClick={handleSelectAll} style={{ whiteSpace: "nowrap" }}>
|
||||
全选
|
||||
</a>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
|
||||
export default SiteSelector;
|
||||
124
apps/tenant-admin/src/hooks/useAuth.tsx
Normal file
124
apps/tenant-admin/src/hooks/useAuth.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* 认证状态管理 — React Context + Provider 模式。
|
||||
*
|
||||
* - 登录:调用 /api/tenant/auth/login,存储 token 到 localStorage
|
||||
* - 登出:清除 token,跳转 /login
|
||||
* - 用户信息:display_name, managed_site_ids(从 JWT payload 解析)
|
||||
*/
|
||||
|
||||
import React, { createContext, useContext, useState, useCallback, useEffect } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import axios from "axios";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 常量 & 类型 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const ACCESS_TOKEN_KEY = "access_token";
|
||||
const REFRESH_TOKEN_KEY = "refresh_token";
|
||||
|
||||
interface UserInfo {
|
||||
adminId: number;
|
||||
tenantId: number;
|
||||
displayName: string;
|
||||
managedSiteIds: number[];
|
||||
adminType: string;
|
||||
}
|
||||
|
||||
interface AuthContextValue {
|
||||
isAuthenticated: boolean;
|
||||
user: UserInfo | null;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* JWT payload 解析(不做签名验证,仅提取信息) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function parseJwtPayload(token: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const base64 = token.split(".")[1];
|
||||
const json = atob(base64.replace(/-/g, "+").replace(/_/g, "/"));
|
||||
return JSON.parse(json);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function extractUserInfo(token: string): UserInfo | null {
|
||||
const payload = parseJwtPayload(token);
|
||||
if (!payload) return null;
|
||||
return {
|
||||
adminId: Number(payload.sub),
|
||||
tenantId: Number(payload.tenant_id),
|
||||
displayName: String(payload.display_name ?? ""),
|
||||
managedSiteIds: Array.isArray(payload.managed_site_ids)
|
||||
? (payload.managed_site_ids as number[])
|
||||
: [],
|
||||
adminType: String(payload.admin_type ?? "tenant_admin"),
|
||||
};
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Provider */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const [user, setUser] = useState<UserInfo | null>(null);
|
||||
|
||||
// 启动时从 localStorage 恢复登录状态
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem(ACCESS_TOKEN_KEY);
|
||||
if (token) {
|
||||
const info = extractUserInfo(token);
|
||||
if (info) setUser(info);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (username: string, password: string) => {
|
||||
const { data } = await axios.post<{
|
||||
code: number;
|
||||
data: { access_token: string; refresh_token: string };
|
||||
}>("/api/tenant/auth/login", { username, password });
|
||||
|
||||
const tokens = data.data;
|
||||
localStorage.setItem(ACCESS_TOKEN_KEY, tokens.access_token);
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refresh_token);
|
||||
|
||||
const info = extractUserInfo(tokens.access_token);
|
||||
setUser(info);
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||
setUser(null);
|
||||
if (window.location.pathname !== "/login") {
|
||||
window.location.href = "/login";
|
||||
}
|
||||
}, []);
|
||||
|
||||
const value: AuthContextValue = {
|
||||
isAuthenticated: user !== null,
|
||||
user,
|
||||
login,
|
||||
logout,
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Hook */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useAuth 必须在 AuthProvider 内使用");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
19
apps/tenant-admin/src/main.tsx
Normal file
19
apps/tenant-admin/src/main.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { ConfigProvider } from "antd";
|
||||
import zhCN from "antd/locale/zh_CN";
|
||||
import App from "./App";
|
||||
|
||||
/**
|
||||
* 入口:BrowserRouter + antd 中文 locale + App 根组件。
|
||||
*/
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<ConfigProvider locale={zhCN}>
|
||||
<App />
|
||||
</ConfigProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
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;
|
||||
159
apps/tenant-admin/src/services/api.ts
Normal file
159
apps/tenant-admin/src/services/api.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* axios 实例 & JWT 拦截器。
|
||||
*
|
||||
* - tenantApi:baseURL /api/tenant,用于租户端 API
|
||||
* - adminApi:baseURL /api/admin,用于管理端 API
|
||||
* - 请求拦截器:自动从 localStorage 读取 access_token 附加 Authorization header
|
||||
* - 响应拦截器:401 时用 refresh_token 刷新,刷新失败重定向 /login
|
||||
* - 并发刷新保护:多个 401 只触发一次 refresh,其余排队
|
||||
* - 响应解包:{ code: 0, data } → 提取 data
|
||||
*/
|
||||
|
||||
import axios, {
|
||||
type AxiosError,
|
||||
type AxiosInstance,
|
||||
type AxiosRequestConfig,
|
||||
type InternalAxiosRequestConfig,
|
||||
} from "axios";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 常量 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const ACCESS_TOKEN_KEY = "access_token";
|
||||
const REFRESH_TOKEN_KEY = "refresh_token";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 刷新状态(两个实例共享) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
let isRefreshing = false;
|
||||
let pendingQueue: {
|
||||
resolve: (token: string) => void;
|
||||
reject: (err: unknown) => void;
|
||||
}[] = [];
|
||||
|
||||
function processPendingQueue(token: string | null, error: unknown) {
|
||||
pendingQueue.forEach(({ resolve, reject }) => {
|
||||
if (token) resolve(token);
|
||||
else reject(error);
|
||||
});
|
||||
pendingQueue = [];
|
||||
}
|
||||
|
||||
function clearTokensAndRedirect() {
|
||||
localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||
if (window.location.pathname !== "/login") {
|
||||
window.location.href = "/login";
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 拦截器工厂 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function attachInterceptors(instance: AxiosInstance) {
|
||||
// 请求拦截器 — 附加 JWT
|
||||
instance.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||
const token = localStorage.getItem(ACCESS_TOKEN_KEY);
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// 响应拦截器 — 解包 + 401 刷新
|
||||
instance.interceptors.response.use(
|
||||
(response) => {
|
||||
// 后端 ResponseWrapperMiddleware: { code: 0, data } → 提取 data
|
||||
const body = response.data;
|
||||
if (body && typeof body === "object" && "code" in body && "data" in body) {
|
||||
response.data = body.data;
|
||||
}
|
||||
return response;
|
||||
},
|
||||
async (error: AxiosError) => {
|
||||
const originalRequest = error.config as AxiosRequestConfig & {
|
||||
_retried?: boolean;
|
||||
};
|
||||
|
||||
if (
|
||||
error.response?.status !== 401 ||
|
||||
!originalRequest ||
|
||||
originalRequest._retried
|
||||
) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// 刷新端点本身 401 → 不递归
|
||||
if (originalRequest.url?.includes("/auth/refresh")) {
|
||||
clearTokensAndRedirect();
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// 已有刷新在飞 → 排队
|
||||
if (isRefreshing) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
pendingQueue.push({ resolve, reject });
|
||||
}).then((newToken) => {
|
||||
originalRequest.headers = {
|
||||
...originalRequest.headers,
|
||||
Authorization: `Bearer ${newToken}`,
|
||||
};
|
||||
originalRequest._retried = true;
|
||||
return instance(originalRequest);
|
||||
});
|
||||
}
|
||||
|
||||
// 发起刷新
|
||||
isRefreshing = true;
|
||||
originalRequest._retried = true;
|
||||
|
||||
const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY);
|
||||
if (!refreshToken) {
|
||||
isRefreshing = false;
|
||||
processPendingQueue(null, error);
|
||||
clearTokensAndRedirect();
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
try {
|
||||
// ResponseWrapperMiddleware 包装响应为 { code: 0, data: { access_token, refresh_token } }
|
||||
const resp = await axios.post<{
|
||||
code: number;
|
||||
data: { access_token: string; refresh_token: string };
|
||||
}>("/api/tenant/auth/refresh", { refresh_token: refreshToken });
|
||||
|
||||
const tokens = resp.data.data;
|
||||
localStorage.setItem(ACCESS_TOKEN_KEY, tokens.access_token);
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refresh_token);
|
||||
processPendingQueue(tokens.access_token, null);
|
||||
|
||||
originalRequest.headers = {
|
||||
...originalRequest.headers,
|
||||
Authorization: `Bearer ${tokens.access_token}`,
|
||||
};
|
||||
return instance(originalRequest);
|
||||
} catch (refreshError) {
|
||||
processPendingQueue(null, refreshError);
|
||||
clearTokensAndRedirect();
|
||||
return Promise.reject(refreshError);
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 实例 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** 租户端 API(/api/tenant) */
|
||||
export const tenantApi = axios.create({ baseURL: "/api/tenant" });
|
||||
attachInterceptors(tenantApi);
|
||||
|
||||
/** 管理端 API(/api/admin) */
|
||||
export const adminApi = axios.create({ baseURL: "/api/admin" });
|
||||
attachInterceptors(adminApi);
|
||||
47
apps/tenant-admin/src/utils/pinyinMatch.ts
Normal file
47
apps/tenant-admin/src/utils/pinyinMatch.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 拼音匹配工具函数。
|
||||
*
|
||||
* 支持:首字母匹配(xy → 小燕)、全拼匹配(yan → 燕)、
|
||||
* 中文+字母混合(小y → 小燕)、多音字全覆盖。
|
||||
*
|
||||
* 基于 pinyin-pro 的 match 函数实现。
|
||||
*/
|
||||
|
||||
import { match } from "pinyin-pro";
|
||||
|
||||
/**
|
||||
* 判断 text 是否匹配 input(支持拼音 + 中文 + 混合输入)。
|
||||
*
|
||||
* @param input 用户输入的搜索词
|
||||
* @param text 待匹配的文本(如姓名)
|
||||
* @returns 是否匹配
|
||||
*/
|
||||
export function pinyinMatch(input: string, text: string): boolean {
|
||||
if (!input || !text) return false;
|
||||
const q = input.toLowerCase();
|
||||
const t = text.toLowerCase();
|
||||
|
||||
// 原始中文/数字包含匹配(兜底)
|
||||
if (t.includes(q)) return true;
|
||||
|
||||
// pinyin-pro match:返回匹配的字符索引数组,无匹配返回 null
|
||||
// 自动处理首字母、全拼、混合输入、多音字
|
||||
const indices = match(text, input, { continuous: true });
|
||||
return indices !== null && indices.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ant Design Select filterOption 的拼音增强版。
|
||||
*
|
||||
* 用法:
|
||||
* ```tsx
|
||||
* <Select filterOption={pinyinFilterOption} />
|
||||
* ```
|
||||
*/
|
||||
export function pinyinFilterOption(
|
||||
input: string,
|
||||
option?: { label?: unknown; value?: unknown },
|
||||
): boolean {
|
||||
const label = String(option?.label ?? "");
|
||||
return pinyinMatch(input, label);
|
||||
}
|
||||
1
apps/tenant-admin/src/vite-env.d.ts
vendored
Normal file
1
apps/tenant-admin/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
32
apps/tenant-admin/tsconfig.json
Normal file
32
apps/tenant-admin/tsconfig.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* 打包 */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* 路径别名 */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
|
||||
/* 类型检查 */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
25
apps/tenant-admin/tsconfig.node.json
Normal file
25
apps/tenant-admin/tsconfig.node.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
|
||||
"composite": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"emitDeclarationOnly": true,
|
||||
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
1
apps/tenant-admin/tsconfig.node.tsbuildinfo
Normal file
1
apps/tenant-admin/tsconfig.node.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
1
apps/tenant-admin/tsconfig.tsbuildinfo
Normal file
1
apps/tenant-admin/tsconfig.tsbuildinfo
Normal file
@@ -0,0 +1 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/__tests__/apiresponse.test.ts","./src/components/clueeditor/index.tsx","./src/components/difftable/index.tsx","./src/components/siteselector/index.tsx","./src/hooks/useauth.tsx","./src/pages/excelupload/index.tsx","./src/pages/login/index.tsx","./src/pages/retentionclues/index.tsx","./src/pages/siteadmins/index.tsx","./src/pages/userapproval/index.tsx","./src/pages/usermanagement/index.tsx","./src/services/api.ts","./src/utils/pinyinmatch.ts"],"version":"5.8.3"}
|
||||
3
apps/tenant-admin/vite.config.d.ts
vendored
Normal file
3
apps/tenant-admin/vite.config.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
declare const _default: import("vite").UserConfig;
|
||||
export default _default;
|
||||
//# sourceMappingURL=vite.config.d.ts.map
|
||||
1
apps/tenant-admin/vite.config.d.ts.map
Normal file
1
apps/tenant-admin/vite.config.d.ts.map
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"vite.config.d.ts","sourceRoot":"","sources":["vite.config.ts"],"names":[],"mappings":";AAIA,wBAyBG"}
|
||||
34
apps/tenant-admin/vite.config.ts
Normal file
34
apps/tenant-admin/vite.config.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import path from "path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "src"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5174,
|
||||
proxy: {
|
||||
"/api/tenant": {
|
||||
target: "http://localhost:8000",
|
||||
changeOrigin: true,
|
||||
},
|
||||
"/api/admin": {
|
||||
target: "http://localhost:8000",
|
||||
changeOrigin: true,
|
||||
},
|
||||
"/api/xcx/avatar": {
|
||||
target: "http://localhost:8000",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "jsdom",
|
||||
setupFiles: [],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user