包含多个会话的累积代码变更: - 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>
1135 lines
35 KiB
TypeScript
1135 lines
35 KiB
TypeScript
/**
|
||
* 租户管理员管理页面。
|
||
*
|
||
* 功能:
|
||
* - Ant Design Table 展示管理员列表(分页 + 关键词搜索)
|
||
* - 2 步创建流程:Step1 基本信息 → Step2 简写ID 设置(可跳过)
|
||
* - 编辑 Modal:修改用户名 / 显示名称 / managedSiteIds / isActive
|
||
* - 重置密码 Modal:新密码 + 确认密码
|
||
* - 删除:Popconfirm 二次确认 → 软删除
|
||
* - 显示已禁用开关:控制 include_inactive 参数
|
||
* - 简写ID 管理按钮(打开 SiteCode 弹窗,Task 12.5 实现)
|
||
* - 租户列(显示 tenantName)
|
||
*/
|
||
|
||
import React, { useEffect, useState, useCallback } from "react";
|
||
import {
|
||
Table,
|
||
Button,
|
||
Input,
|
||
Space,
|
||
Modal,
|
||
Form,
|
||
Select,
|
||
Switch,
|
||
Tag,
|
||
message,
|
||
Typography,
|
||
Popconfirm,
|
||
Steps,
|
||
Spin,
|
||
} from "antd";
|
||
import {
|
||
PlusOutlined,
|
||
EditOutlined,
|
||
KeyOutlined,
|
||
TeamOutlined,
|
||
DeleteOutlined,
|
||
BarcodeOutlined,
|
||
} from "@ant-design/icons";
|
||
import type { ColumnsType } from "antd/es/table";
|
||
import {
|
||
fetchTenantAdmins,
|
||
createTenantAdmin,
|
||
editTenantAdmin,
|
||
resetTenantAdminPassword,
|
||
deleteTenantAdmin,
|
||
} from "../../api/tenantAdmins";
|
||
import type {
|
||
TenantAdminItem,
|
||
TenantAdminCreatePayload,
|
||
TenantAdminEditPayload,
|
||
} from "../../api/tenantAdmins";
|
||
import {
|
||
fetchTenants,
|
||
fetchTenantSites,
|
||
updateSiteCode,
|
||
fetchSiteCodeHistory,
|
||
createSite,
|
||
deleteSite,
|
||
} from "../../api/registry";
|
||
import type { TenantItem, SiteItem, SiteCodeHistoryItem } from "../../api/registry";
|
||
|
||
const { Title } = Typography;
|
||
const { Search } = Input;
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* 组件 */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
const TenantAdmins: React.FC = () => {
|
||
/* ---- 列表状态 ---- */
|
||
const [data, setData] = useState<TenantAdminItem[]>([]);
|
||
const [total, setTotal] = useState(0);
|
||
const [page, setPage] = useState(1);
|
||
const [pageSize, setPageSize] = useState(20);
|
||
const [keyword, setKeyword] = useState("");
|
||
const [loading, setLoading] = useState(false);
|
||
const [includeInactive, setIncludeInactive] = useState(false);
|
||
|
||
/* ---- Modal 状态 ---- */
|
||
const [createOpen, setCreateOpen] = useState(false);
|
||
const [editOpen, setEditOpen] = useState(false);
|
||
const [resetPwdOpen, setResetPwdOpen] = useState(false);
|
||
const [currentRecord, setCurrentRecord] = useState<TenantAdminItem | null>(null);
|
||
const [submitLoading, setSubmitLoading] = useState(false);
|
||
|
||
/* ---- 2 步创建流程状态 ---- */
|
||
const [createStep, setCreateStep] = useState(0);
|
||
const [tenants, setTenants] = useState<TenantItem[]>([]);
|
||
const [tenantsLoading, setTenantsLoading] = useState(false);
|
||
const [tenantSites, setTenantSites] = useState<SiteItem[]>([]);
|
||
const [tenantSitesLoading, setTenantSitesLoading] = useState(false);
|
||
/** 所有选中租户下的店铺(Step 2 展示用) */
|
||
const [allTenantSites, setAllTenantSites] = useState<SiteItem[]>([]);
|
||
/** Step 2 中每个 siteId 对应的 site_code 输入值 */
|
||
const [siteCodes, setSiteCodes] = useState<Record<number, string>>({});
|
||
|
||
/* ---- SiteCode 弹窗状态 ---- */
|
||
const [siteCodeOpen, setSiteCodeOpen] = useState(false);
|
||
const [siteCodeRecord, setSiteCodeRecord] = useState<TenantAdminItem | null>(null);
|
||
const [siteCodeSites, setSiteCodeSites] = useState<SiteItem[]>([]);
|
||
const [siteCodeSitesLoading, setSiteCodeSitesLoading] = useState(false);
|
||
const [editingSiteId, setEditingSiteId] = useState<number | null>(null);
|
||
const [editingCode, setEditingCode] = useState("");
|
||
const [editingSaving, setEditingSaving] = useState(false);
|
||
const [siteCodeHistory, setSiteCodeHistory] = useState<SiteCodeHistoryItem[]>([]);
|
||
const [historyLoading, setHistoryLoading] = useState(false);
|
||
const [selectedSiteId, setSelectedSiteId] = useState<number | null>(null);
|
||
|
||
/* ---- 测试模式:添加/删除店铺 ---- */
|
||
const [devMode, setDevMode] = useState(false);
|
||
const [addSiteForm] = Form.useForm();
|
||
const [addSiteLoading, setAddSiteLoading] = useState(false);
|
||
|
||
const [createForm] = Form.useForm();
|
||
const [editForm] = Form.useForm();
|
||
const [resetPwdForm] = Form.useForm();
|
||
|
||
/* ---- 数据加载 ---- */
|
||
|
||
const loadData = useCallback(async () => {
|
||
setLoading(true);
|
||
try {
|
||
const res = await fetchTenantAdmins({
|
||
page,
|
||
page_size: pageSize,
|
||
keyword: keyword || undefined,
|
||
include_inactive: includeInactive || undefined,
|
||
});
|
||
setData(res.items);
|
||
setTotal(res.total);
|
||
} catch {
|
||
message.error("加载租户管理员列表失败");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [page, pageSize, keyword, includeInactive]);
|
||
|
||
useEffect(() => {
|
||
loadData();
|
||
}, [loadData]);
|
||
|
||
/* ---- 搜索 ---- */
|
||
|
||
const handleSearch = (value: string) => {
|
||
setKeyword(value);
|
||
setPage(1);
|
||
};
|
||
|
||
/* ---- 创建(2 步流程) ---- */
|
||
|
||
/** 打开创建弹窗时加载租户列表 */
|
||
const openCreate = async () => {
|
||
createForm.resetFields();
|
||
setCreateStep(0);
|
||
setTenantSites([]);
|
||
setAllTenantSites([]);
|
||
setSiteCodes({});
|
||
setCreateOpen(true);
|
||
setTenantsLoading(true);
|
||
try {
|
||
const list = await fetchTenants();
|
||
setTenants(list);
|
||
} catch {
|
||
message.error("加载租户列表失败");
|
||
} finally {
|
||
setTenantsLoading(false);
|
||
}
|
||
};
|
||
|
||
/** 租户选择变化时加载该租户下的店铺 */
|
||
const handleTenantChange = async (tenantId: number) => {
|
||
createForm.setFieldValue("managedSiteIds", []);
|
||
setTenantSites([]);
|
||
if (!tenantId) return;
|
||
setTenantSitesLoading(true);
|
||
try {
|
||
const sites = await fetchTenantSites(tenantId);
|
||
setTenantSites(sites);
|
||
} catch {
|
||
message.error("加载店铺列表失败");
|
||
} finally {
|
||
setTenantSitesLoading(false);
|
||
}
|
||
};
|
||
|
||
/** Step 1 → Step 2:校验表单后进入下一步 */
|
||
const handleNextStep = async () => {
|
||
try {
|
||
await createForm.validateFields();
|
||
// 加载选中租户下所有店铺用于 Step 2
|
||
const tenantId = createForm.getFieldValue("tenantId");
|
||
setTenantSitesLoading(true);
|
||
try {
|
||
const sites = await fetchTenantSites(tenantId);
|
||
setAllTenantSites(sites);
|
||
// 预填已有 site_code
|
||
const codes: Record<number, string> = {};
|
||
for (const s of sites) {
|
||
if (s.siteCode) codes[s.siteId] = s.siteCode;
|
||
}
|
||
setSiteCodes(codes);
|
||
} catch {
|
||
message.error("加载店铺列表失败");
|
||
} finally {
|
||
setTenantSitesLoading(false);
|
||
}
|
||
setCreateStep(1);
|
||
} catch {
|
||
// 表单校验失败,停留在 Step 1
|
||
}
|
||
};
|
||
|
||
/** 仅创建管理员(跳过 Step 2) */
|
||
const handleCreateSkip = async () => {
|
||
try {
|
||
const values = createForm.getFieldsValue();
|
||
setSubmitLoading(true);
|
||
const payload: TenantAdminCreatePayload = {
|
||
username: values.username,
|
||
password: values.password,
|
||
displayName: values.displayName,
|
||
tenantId: Number(values.tenantId),
|
||
managedSiteIds: (values.managedSiteIds ?? []).map(Number),
|
||
};
|
||
await createTenantAdmin(payload);
|
||
message.success("创建成功");
|
||
setCreateOpen(false);
|
||
createForm.resetFields();
|
||
setCreateStep(0);
|
||
loadData();
|
||
} catch (err: any) {
|
||
if (err?.response?.status === 409) {
|
||
message.error("用户名已存在");
|
||
} else {
|
||
const detail = err?.response?.data?.detail || err?.message || "未知错误";
|
||
message.error(`创建失败: ${detail}`);
|
||
}
|
||
} finally {
|
||
setSubmitLoading(false);
|
||
}
|
||
};
|
||
|
||
/** 创建管理员 + 保存 site codes(Step 2 保存) */
|
||
const handleCreateWithCodes = async () => {
|
||
try {
|
||
const values = createForm.getFieldsValue();
|
||
setSubmitLoading(true);
|
||
const payload: TenantAdminCreatePayload = {
|
||
username: values.username,
|
||
password: values.password,
|
||
displayName: values.displayName,
|
||
tenantId: Number(values.tenantId),
|
||
managedSiteIds: (values.managedSiteIds ?? []).map(Number),
|
||
};
|
||
await createTenantAdmin(payload);
|
||
|
||
// 保存有变更的 site codes
|
||
const codeEntries = Object.entries(siteCodes).filter(([, code]) => code.trim());
|
||
let codeErrors = 0;
|
||
for (const [siteIdStr, code] of codeEntries) {
|
||
const siteId = Number(siteIdStr);
|
||
// 跳过已有相同 code 的店铺
|
||
const existing = allTenantSites.find((s) => s.siteId === siteId);
|
||
if (existing?.siteCode === code) continue;
|
||
try {
|
||
await updateSiteCode(siteId, code);
|
||
} catch {
|
||
codeErrors++;
|
||
}
|
||
}
|
||
if (codeErrors > 0) {
|
||
message.warning(`管理员创建成功,但 ${codeErrors} 个简写ID 设置失败`);
|
||
} else {
|
||
message.success("创建成功");
|
||
}
|
||
setCreateOpen(false);
|
||
createForm.resetFields();
|
||
setCreateStep(0);
|
||
loadData();
|
||
} catch (err: any) {
|
||
if (err?.response?.status === 409) {
|
||
message.error("用户名已存在");
|
||
} else {
|
||
const detail = err?.response?.data?.detail || err?.message || "未知错误";
|
||
message.error(`创建失败: ${detail}`);
|
||
}
|
||
} finally {
|
||
setSubmitLoading(false);
|
||
}
|
||
};
|
||
|
||
/** 关闭创建弹窗 */
|
||
const handleCreateCancel = () => {
|
||
setCreateOpen(false);
|
||
setCreateStep(0);
|
||
createForm.resetFields();
|
||
};
|
||
|
||
/* ---- 编辑 ---- */
|
||
|
||
const openEdit = (record: TenantAdminItem) => {
|
||
setCurrentRecord(record);
|
||
editForm.setFieldsValue({
|
||
username: record.username,
|
||
displayName: record.displayName,
|
||
managedSiteIds: record.managedSiteIds,
|
||
isActive: record.isActive,
|
||
});
|
||
setEditOpen(true);
|
||
};
|
||
|
||
const handleEdit = async () => {
|
||
if (!currentRecord) return;
|
||
try {
|
||
const values = await editForm.validateFields();
|
||
setSubmitLoading(true);
|
||
const payload: TenantAdminEditPayload = {
|
||
username: values.username,
|
||
displayName: values.displayName,
|
||
managedSiteIds: values.managedSiteIds,
|
||
isActive: values.isActive,
|
||
};
|
||
await editTenantAdmin(currentRecord.id, payload);
|
||
message.success("编辑成功");
|
||
setEditOpen(false);
|
||
loadData();
|
||
} catch (err: any) {
|
||
if (err?.response?.status === 409) {
|
||
message.error("用户名已存在");
|
||
} else if (err?.errorFields) {
|
||
// 表单校验失败
|
||
} else {
|
||
message.error("编辑失败");
|
||
}
|
||
} finally {
|
||
setSubmitLoading(false);
|
||
}
|
||
};
|
||
|
||
/* ---- 删除 ---- */
|
||
|
||
const handleDelete = async (id: number) => {
|
||
try {
|
||
await deleteTenantAdmin(id);
|
||
message.success("已删除");
|
||
loadData();
|
||
} catch {
|
||
message.error("删除失败");
|
||
}
|
||
};
|
||
|
||
/* ---- 重置密码 ---- */
|
||
|
||
const openResetPwd = (record: TenantAdminItem) => {
|
||
setCurrentRecord(record);
|
||
resetPwdForm.resetFields();
|
||
setResetPwdOpen(true);
|
||
};
|
||
|
||
const handleResetPwd = async () => {
|
||
if (!currentRecord) return;
|
||
try {
|
||
const values = await resetPwdForm.validateFields();
|
||
setSubmitLoading(true);
|
||
await resetTenantAdminPassword(currentRecord.id, {
|
||
newPassword: values.newPassword,
|
||
});
|
||
message.success("密码重置成功");
|
||
setResetPwdOpen(false);
|
||
} catch (err: any) {
|
||
if (err?.errorFields) {
|
||
// 表单校验失败
|
||
} else {
|
||
message.error("密码重置失败");
|
||
}
|
||
} finally {
|
||
setSubmitLoading(false);
|
||
}
|
||
};
|
||
|
||
/* ---- 简写ID 管理 ---- */
|
||
|
||
/** site_code 格式校验:3 位字母/数字 + 3 位数字 */
|
||
const SITE_CODE_RE = /^[A-Z0-9]{3}\d{3}$/;
|
||
|
||
const openSiteCode = async (record: TenantAdminItem) => {
|
||
setSiteCodeRecord(record);
|
||
setSiteCodeOpen(true);
|
||
setSiteCodeSites([]);
|
||
setSiteCodeHistory([]);
|
||
setSelectedSiteId(null);
|
||
setEditingSiteId(null);
|
||
setEditingCode("");
|
||
// 加载该管理员租户下的店铺
|
||
setSiteCodeSitesLoading(true);
|
||
try {
|
||
const sites = await fetchTenantSites(record.tenantId);
|
||
// devMode 下显示全部店铺,否则仅展示管辖门店
|
||
setSiteCodeSites(devMode
|
||
? sites
|
||
: sites.filter((s) => record.managedSiteIds.includes(s.siteId)),
|
||
);
|
||
} catch {
|
||
message.error("加载店铺列表失败");
|
||
} finally {
|
||
setSiteCodeSitesLoading(false);
|
||
}
|
||
};
|
||
|
||
/** 加载某个店铺的简写ID 变更历史 */
|
||
const loadSiteCodeHistory = async (siteId: number) => {
|
||
setSelectedSiteId(siteId);
|
||
setHistoryLoading(true);
|
||
try {
|
||
const list = await fetchSiteCodeHistory(siteId);
|
||
setSiteCodeHistory(list);
|
||
} catch {
|
||
message.error("加载变更历史失败");
|
||
} finally {
|
||
setHistoryLoading(false);
|
||
}
|
||
};
|
||
|
||
/** 开始编辑某行 */
|
||
const startEditCode = (siteId: number, currentCode: string | null) => {
|
||
setEditingSiteId(siteId);
|
||
setEditingCode(currentCode ?? "");
|
||
};
|
||
|
||
/** 取消编辑 */
|
||
const cancelEditCode = () => {
|
||
setEditingSiteId(null);
|
||
setEditingCode("");
|
||
};
|
||
|
||
/** 保存简写ID */
|
||
const saveEditCode = async (siteId: number) => {
|
||
const code = editingCode.toUpperCase().trim();
|
||
if (!SITE_CODE_RE.test(code)) {
|
||
message.error("简写ID 格式错误,需 6 位(前 3 位字母/数字 + 后 3 位数字,如 LLQ001)");
|
||
return;
|
||
}
|
||
setEditingSaving(true);
|
||
try {
|
||
await updateSiteCode(siteId, code);
|
||
message.success("简写ID 修改成功");
|
||
cancelEditCode();
|
||
// 刷新店铺列表
|
||
if (siteCodeRecord) {
|
||
const sites = await fetchTenantSites(siteCodeRecord.tenantId);
|
||
const managed = sites.filter((s) =>
|
||
siteCodeRecord.managedSiteIds.includes(s.siteId),
|
||
);
|
||
setSiteCodeSites(managed);
|
||
}
|
||
// 刷新历史
|
||
await loadSiteCodeHistory(siteId);
|
||
} catch (err: any) {
|
||
if (err?.response?.status === 409) {
|
||
message.error("简写ID 已被占用");
|
||
} else {
|
||
message.error("简写ID 修改失败");
|
||
}
|
||
} finally {
|
||
setEditingSaving(false);
|
||
}
|
||
};
|
||
|
||
/* ---- 测试模式:添加/删除店铺处理函数 ---- */
|
||
|
||
const handleAddSite = async () => {
|
||
try {
|
||
const values = await addSiteForm.validateFields();
|
||
setAddSiteLoading(true);
|
||
await createSite({
|
||
tenantId: siteCodeRecord!.tenantId,
|
||
siteId: Number(values.siteId),
|
||
siteName: values.siteName,
|
||
siteCode: values.siteCode || undefined,
|
||
});
|
||
message.success("店铺创建成功");
|
||
addSiteForm.resetFields();
|
||
// 刷新店铺列表(测试模式下显示全部)
|
||
const sites = await fetchTenantSites(siteCodeRecord!.tenantId);
|
||
setSiteCodeSites(devMode
|
||
? sites
|
||
: sites.filter((s) => siteCodeRecord!.managedSiteIds.includes(s.siteId)),
|
||
);
|
||
} catch (err: any) {
|
||
if (err?.response?.status === 409) {
|
||
message.error(err.response.data?.detail || "唯一约束冲突");
|
||
} else if (!err?.errorFields) {
|
||
message.error("创建失败");
|
||
}
|
||
} finally {
|
||
setAddSiteLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleDeleteSite = async (id: number) => {
|
||
try {
|
||
await deleteSite(id);
|
||
message.success("店铺已删除");
|
||
// 刷新店铺列表(测试模式下显示全部)
|
||
if (siteCodeRecord) {
|
||
const sites = await fetchTenantSites(siteCodeRecord.tenantId);
|
||
setSiteCodeSites(devMode
|
||
? sites
|
||
: sites.filter((s) => siteCodeRecord.managedSiteIds.includes(s.siteId)),
|
||
);
|
||
}
|
||
} catch {
|
||
message.error("删除失败");
|
||
}
|
||
};
|
||
|
||
/* ---- 表格列定义 ---- */
|
||
|
||
const columns: ColumnsType<TenantAdminItem> = [
|
||
{
|
||
title: "用户名",
|
||
dataIndex: "username",
|
||
width: 140,
|
||
},
|
||
{
|
||
title: "显示名称",
|
||
dataIndex: "displayName",
|
||
width: 140,
|
||
render: (v: string | null) => v || "-",
|
||
},
|
||
{
|
||
title: "租户",
|
||
dataIndex: "tenantName",
|
||
width: 140,
|
||
render: (v: string | null) => v || "-",
|
||
},
|
||
{
|
||
title: "类型",
|
||
dataIndex: "adminType",
|
||
width: 100,
|
||
render: (v: string) =>
|
||
v === "site_admin" ? (
|
||
<Tag color="orange">店铺管理员</Tag>
|
||
) : (
|
||
<Tag color="blue">租户管理员</Tag>
|
||
),
|
||
},
|
||
{
|
||
title: "管辖门店",
|
||
dataIndex: "managedSiteIds",
|
||
width: 200,
|
||
render: (ids: number[]) =>
|
||
ids && ids.length > 0
|
||
? ids.map((id) => (
|
||
<Tag key={id} color="blue">
|
||
{id}
|
||
</Tag>
|
||
))
|
||
: "-",
|
||
},
|
||
{
|
||
title: "状态",
|
||
dataIndex: "isActive",
|
||
width: 80,
|
||
render: (v: boolean) =>
|
||
v ? (
|
||
<Tag color="success">启用</Tag>
|
||
) : (
|
||
<Tag color="error">禁用</Tag>
|
||
),
|
||
},
|
||
{
|
||
title: "创建时间",
|
||
dataIndex: "createdAt",
|
||
width: 170,
|
||
render: (v: string) => (v ? new Date(v).toLocaleString() : "-"),
|
||
},
|
||
{
|
||
title: "最后登录",
|
||
dataIndex: "lastLoginAt",
|
||
width: 170,
|
||
render: (v: string | null) =>
|
||
v ? new Date(v).toLocaleString() : "从未登录",
|
||
},
|
||
{
|
||
title: "操作",
|
||
key: "actions",
|
||
width: 280,
|
||
render: (_: unknown, record: TenantAdminItem) => (
|
||
<Space size="small">
|
||
<Button
|
||
type="link"
|
||
size="small"
|
||
icon={<EditOutlined />}
|
||
onClick={() => openEdit(record)}
|
||
>
|
||
编辑
|
||
</Button>
|
||
<Button
|
||
type="link"
|
||
size="small"
|
||
icon={<KeyOutlined />}
|
||
onClick={() => openResetPwd(record)}
|
||
>
|
||
重置密码
|
||
</Button>
|
||
<Button
|
||
type="link"
|
||
size="small"
|
||
icon={<BarcodeOutlined />}
|
||
onClick={() => openSiteCode(record)}
|
||
>
|
||
简写ID
|
||
</Button>
|
||
<Popconfirm
|
||
title="确定要删除此管理员吗?"
|
||
onConfirm={() => handleDelete(record.id)}
|
||
okText="确定"
|
||
cancelText="取消"
|
||
>
|
||
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
|
||
删除
|
||
</Button>
|
||
</Popconfirm>
|
||
</Space>
|
||
),
|
||
},
|
||
];
|
||
|
||
/* ---- 渲染 ---- */
|
||
|
||
return (
|
||
<div>
|
||
<Title level={4} style={{ marginBottom: 16 }}>
|
||
<TeamOutlined style={{ marginRight: 8 }} />
|
||
租户管理员
|
||
</Title>
|
||
|
||
<Space style={{ marginBottom: 16 }}>
|
||
<Search
|
||
placeholder="搜索用户名或显示名称"
|
||
allowClear
|
||
onSearch={handleSearch}
|
||
style={{ width: 280 }}
|
||
/>
|
||
<Button
|
||
type="primary"
|
||
icon={<PlusOutlined />}
|
||
onClick={openCreate}
|
||
>
|
||
创建管理员
|
||
</Button>
|
||
<Switch
|
||
checked={includeInactive}
|
||
onChange={setIncludeInactive}
|
||
checkedChildren="显示已禁用"
|
||
unCheckedChildren="显示已禁用"
|
||
/>
|
||
</Space>
|
||
|
||
<Table
|
||
rowKey="id"
|
||
columns={columns}
|
||
dataSource={data}
|
||
loading={loading}
|
||
pagination={{
|
||
current: page,
|
||
pageSize,
|
||
total,
|
||
showSizeChanger: true,
|
||
showTotal: (t) => `共 ${t} 条`,
|
||
onChange: (p, ps) => {
|
||
setPage(p);
|
||
setPageSize(ps);
|
||
},
|
||
}}
|
||
size="middle"
|
||
/>
|
||
|
||
{/* ---- 创建 Modal(2 步流程) ---- */}
|
||
<Modal
|
||
title="创建租户管理员"
|
||
open={createOpen}
|
||
onCancel={handleCreateCancel}
|
||
width={640}
|
||
footer={
|
||
createStep === 0 ? (
|
||
<Space>
|
||
<Button onClick={handleCreateCancel}>取消</Button>
|
||
<Button type="primary" onClick={handleNextStep}>
|
||
下一步
|
||
</Button>
|
||
</Space>
|
||
) : (
|
||
<Space>
|
||
<Button onClick={() => setCreateStep(0)}>上一步</Button>
|
||
<Button onClick={handleCreateSkip} loading={submitLoading}>
|
||
跳过
|
||
</Button>
|
||
<Button
|
||
type="primary"
|
||
onClick={handleCreateWithCodes}
|
||
loading={submitLoading}
|
||
>
|
||
保存
|
||
</Button>
|
||
</Space>
|
||
)
|
||
}
|
||
destroyOnClose
|
||
>
|
||
<Steps
|
||
current={createStep}
|
||
size="small"
|
||
style={{ marginBottom: 24 }}
|
||
items={[
|
||
{ title: "基本信息" },
|
||
{ title: "简写ID 设置" },
|
||
]}
|
||
/>
|
||
|
||
{/* Step 1: 基本信息 */}
|
||
<div style={{ display: createStep === 0 ? "block" : "none" }}>
|
||
<Form form={createForm} layout="vertical">
|
||
<Form.Item
|
||
name="tenantId"
|
||
label="租户"
|
||
rules={[{ required: true, message: "请选择租户" }]}
|
||
>
|
||
<Select
|
||
placeholder="选择所属租户"
|
||
loading={tenantsLoading}
|
||
onChange={handleTenantChange}
|
||
showSearch
|
||
optionFilterProp="label"
|
||
options={tenants.map((t) => ({
|
||
label: t.tenantName,
|
||
value: t.tenantId,
|
||
}))}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="username"
|
||
label="用户名"
|
||
rules={[{ required: true, message: "请输入用户名" }]}
|
||
>
|
||
<Input placeholder="登录用户名" />
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="password"
|
||
label="初始密码"
|
||
rules={[
|
||
{ required: true, message: "请输入初始密码" },
|
||
{ min: 6, message: "密码至少 6 位" },
|
||
]}
|
||
>
|
||
<Input.Password placeholder="初始密码" />
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="displayName"
|
||
label="显示名称"
|
||
rules={[{ required: true, message: "请输入显示名称" }]}
|
||
>
|
||
<Input placeholder="管理员显示名称" />
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="managedSiteIds"
|
||
label="管辖门店"
|
||
rules={[{ required: true, message: "请选择管辖门店" }]}
|
||
>
|
||
<Select
|
||
mode="multiple"
|
||
placeholder={tenantSitesLoading ? "加载中..." : "选择管辖门店"}
|
||
loading={tenantSitesLoading}
|
||
optionFilterProp="label"
|
||
options={tenantSites.map((s) => ({
|
||
label: s.siteName || `店铺 ${s.siteId}`,
|
||
value: s.siteId,
|
||
}))}
|
||
/>
|
||
</Form.Item>
|
||
</Form>
|
||
</div>
|
||
|
||
{/* Step 2: 简写ID 设置 */}
|
||
{createStep === 1 && (
|
||
<Spin spinning={tenantSitesLoading}>
|
||
<p style={{ marginBottom: 12, color: "#666" }}>
|
||
为该租户下的店铺设置简写ID(6 位,3+3 格式,如 LLQ001)。可跳过此步骤。
|
||
</p>
|
||
<Table
|
||
rowKey="siteId"
|
||
dataSource={allTenantSites}
|
||
size="small"
|
||
pagination={false}
|
||
columns={[
|
||
{
|
||
title: "店铺名称",
|
||
dataIndex: "siteName",
|
||
width: 200,
|
||
render: (v: string | null, r: SiteItem) =>
|
||
v || `店铺 ${r.siteId}`,
|
||
},
|
||
{
|
||
title: "当前简写ID",
|
||
dataIndex: "siteCode",
|
||
width: 120,
|
||
render: (v: string | null) => v || "-",
|
||
},
|
||
{
|
||
title: "设置简写ID",
|
||
key: "newCode",
|
||
width: 180,
|
||
render: (_: unknown, record: SiteItem) => (
|
||
<Input
|
||
size="small"
|
||
placeholder="如 LLQ001"
|
||
maxLength={6}
|
||
value={siteCodes[record.siteId] ?? ""}
|
||
onChange={(e) =>
|
||
setSiteCodes((prev) => ({
|
||
...prev,
|
||
[record.siteId]: e.target.value.toUpperCase(),
|
||
}))
|
||
}
|
||
/>
|
||
),
|
||
},
|
||
]}
|
||
/>
|
||
</Spin>
|
||
)}
|
||
</Modal>
|
||
|
||
{/* ---- 编辑 Modal ---- */}
|
||
<Modal
|
||
title={`编辑管理员 — ${currentRecord?.username ?? ""}`}
|
||
open={editOpen}
|
||
onOk={handleEdit}
|
||
onCancel={() => setEditOpen(false)}
|
||
confirmLoading={submitLoading}
|
||
destroyOnClose
|
||
>
|
||
<Form form={editForm} layout="vertical">
|
||
<Form.Item
|
||
name="username"
|
||
label="用户名"
|
||
rules={[{ required: true, message: "请输入用户名" }]}
|
||
>
|
||
<Input placeholder="登录用户名" />
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="displayName"
|
||
label="显示名称"
|
||
rules={[{ required: true, message: "请输入显示名称" }]}
|
||
>
|
||
<Input />
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="managedSiteIds"
|
||
label="管辖门店"
|
||
rules={[{ required: true, message: "请选择管辖门店" }]}
|
||
>
|
||
<Select
|
||
mode="tags"
|
||
placeholder="输入门店 ID 后回车"
|
||
tokenSeparators={[","]}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="isActive"
|
||
label="账号状态"
|
||
valuePropName="checked"
|
||
>
|
||
<Switch checkedChildren="启用" unCheckedChildren="禁用" />
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
|
||
{/* ---- 重置密码 Modal ---- */}
|
||
<Modal
|
||
title={`重置密码 — ${currentRecord?.username ?? ""}`}
|
||
open={resetPwdOpen}
|
||
onOk={handleResetPwd}
|
||
onCancel={() => setResetPwdOpen(false)}
|
||
confirmLoading={submitLoading}
|
||
destroyOnClose
|
||
>
|
||
<Form form={resetPwdForm} layout="vertical">
|
||
<Form.Item
|
||
name="newPassword"
|
||
label="新密码"
|
||
rules={[
|
||
{ required: true, message: "请输入新密码" },
|
||
{ min: 6, message: "密码至少 6 位" },
|
||
]}
|
||
>
|
||
<Input.Password placeholder="新密码" />
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="confirmPassword"
|
||
label="确认密码"
|
||
dependencies={["newPassword"]}
|
||
rules={[
|
||
{ required: true, message: "请确认密码" },
|
||
({ getFieldValue }) => ({
|
||
validator(_, value) {
|
||
if (!value || getFieldValue("newPassword") === value) {
|
||
return Promise.resolve();
|
||
}
|
||
return Promise.reject(new Error("两次输入的密码不一致"));
|
||
},
|
||
}),
|
||
]}
|
||
>
|
||
<Input.Password placeholder="再次输入新密码" />
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
|
||
{/* ---- 简写ID 管理 Modal ---- */}
|
||
<Modal
|
||
title={`简写ID 管理 — ${siteCodeRecord?.username ?? ""}`}
|
||
open={siteCodeOpen}
|
||
onCancel={() => {
|
||
setSiteCodeOpen(false);
|
||
cancelEditCode();
|
||
setDevMode(false);
|
||
addSiteForm.resetFields();
|
||
}}
|
||
footer={null}
|
||
destroyOnClose
|
||
width={640}
|
||
>
|
||
{/* 测试模式开关 */}
|
||
<div style={{ marginBottom: 12, display: "flex", alignItems: "center", gap: 8 }}>
|
||
<Switch
|
||
checked={devMode}
|
||
onChange={async (checked) => {
|
||
setDevMode(checked);
|
||
// 切换模式时刷新列表
|
||
if (siteCodeRecord) {
|
||
setSiteCodeSitesLoading(true);
|
||
try {
|
||
const sites = await fetchTenantSites(siteCodeRecord.tenantId);
|
||
setSiteCodeSites(checked
|
||
? sites
|
||
: sites.filter((s) => siteCodeRecord.managedSiteIds.includes(s.siteId)),
|
||
);
|
||
} catch {
|
||
message.error("加载店铺列表失败");
|
||
} finally {
|
||
setSiteCodeSitesLoading(false);
|
||
}
|
||
}
|
||
}}
|
||
size="small"
|
||
/>
|
||
<span style={{ color: devMode ? "#fa8c16" : "#999", fontSize: 13 }}>
|
||
测试模式{devMode ? "(已开启)" : ""}
|
||
</span>
|
||
</div>
|
||
|
||
{/* 测试模式:添加店铺表单 */}
|
||
{devMode && (
|
||
<div style={{ marginBottom: 16, padding: 12, background: "#fffbe6", borderRadius: 6, border: "1px solid #ffe58f" }}>
|
||
<Typography.Text strong style={{ display: "block", marginBottom: 8, color: "#fa8c16" }}>
|
||
添加店铺(测试)
|
||
</Typography.Text>
|
||
<Form form={addSiteForm} layout="inline" size="small">
|
||
<Form.Item name="siteId" rules={[{ required: true, message: "必填" }]}>
|
||
<Input placeholder="上游 siteId" style={{ width: 130 }} />
|
||
</Form.Item>
|
||
<Form.Item name="siteName" rules={[{ required: true, message: "必填" }]}>
|
||
<Input placeholder="店铺名称" style={{ width: 130 }} />
|
||
</Form.Item>
|
||
<Form.Item name="siteCode">
|
||
<Input placeholder="简写ID(可选)" maxLength={6} style={{ width: 120 }} />
|
||
</Form.Item>
|
||
<Form.Item>
|
||
<Button type="primary" loading={addSiteLoading} onClick={handleAddSite}>
|
||
添加
|
||
</Button>
|
||
</Form.Item>
|
||
</Form>
|
||
</div>
|
||
)}
|
||
|
||
{/* 店铺表格 */}
|
||
<Table<SiteItem>
|
||
rowKey="siteId"
|
||
dataSource={siteCodeSites}
|
||
loading={siteCodeSitesLoading}
|
||
size="small"
|
||
pagination={false}
|
||
columns={[
|
||
{
|
||
title: "店铺名称",
|
||
dataIndex: "siteName",
|
||
width: 180,
|
||
render: (v: string | null, r: SiteItem) =>
|
||
v || `店铺 ${r.siteId}`,
|
||
},
|
||
{
|
||
title: "当前简写ID",
|
||
dataIndex: "siteCode",
|
||
width: 120,
|
||
render: (v: string | null) =>
|
||
v ? <Tag color="blue">{v}</Tag> : <span style={{ color: "#999" }}>未设置</span>,
|
||
},
|
||
{
|
||
title: "操作",
|
||
key: "action",
|
||
width: devMode ? 300 : 240,
|
||
render: (_: unknown, record: SiteItem) => {
|
||
if (editingSiteId === record.siteId) {
|
||
return (
|
||
<Space size="small">
|
||
<Input
|
||
size="small"
|
||
placeholder="如 LLQ001"
|
||
maxLength={6}
|
||
style={{ width: 100 }}
|
||
value={editingCode}
|
||
onChange={(e) =>
|
||
setEditingCode(e.target.value.toUpperCase())
|
||
}
|
||
onPressEnter={() => saveEditCode(record.siteId)}
|
||
/>
|
||
<Button
|
||
type="primary"
|
||
size="small"
|
||
loading={editingSaving}
|
||
onClick={() => saveEditCode(record.siteId)}
|
||
>
|
||
保存
|
||
</Button>
|
||
<Button size="small" onClick={cancelEditCode}>
|
||
取消
|
||
</Button>
|
||
</Space>
|
||
);
|
||
}
|
||
return (
|
||
<Space size="small">
|
||
<Button
|
||
type="link"
|
||
size="small"
|
||
onClick={() =>
|
||
startEditCode(record.siteId, record.siteCode)
|
||
}
|
||
>
|
||
修改
|
||
</Button>
|
||
<Button
|
||
type="link"
|
||
size="small"
|
||
onClick={() => loadSiteCodeHistory(record.siteId)}
|
||
>
|
||
历史
|
||
</Button>
|
||
{devMode && (
|
||
<Popconfirm
|
||
title="确定删除此店铺?(硬删除,不可恢复)"
|
||
onConfirm={() => handleDeleteSite(record.id)}
|
||
okText="确定"
|
||
cancelText="取消"
|
||
>
|
||
<Button type="link" size="small" danger>
|
||
删除
|
||
</Button>
|
||
</Popconfirm>
|
||
)}
|
||
</Space>
|
||
);
|
||
},
|
||
},
|
||
]}
|
||
/>
|
||
|
||
{/* 变更历史区域 */}
|
||
{selectedSiteId && (
|
||
<div style={{ marginTop: 16 }}>
|
||
<Typography.Text strong style={{ display: "block", marginBottom: 8 }}>
|
||
简写ID 变更历史(店铺 {selectedSiteId})
|
||
</Typography.Text>
|
||
<Table<SiteCodeHistoryItem>
|
||
rowKey="id"
|
||
dataSource={siteCodeHistory}
|
||
loading={historyLoading}
|
||
size="small"
|
||
pagination={false}
|
||
columns={[
|
||
{
|
||
title: "简写ID",
|
||
dataIndex: "siteCode",
|
||
width: 100,
|
||
},
|
||
{
|
||
title: "状态",
|
||
dataIndex: "isCurrent",
|
||
width: 80,
|
||
render: (v: boolean) =>
|
||
v ? (
|
||
<Tag color="success">当前</Tag>
|
||
) : (
|
||
<Tag color="default">已退役</Tag>
|
||
),
|
||
},
|
||
{
|
||
title: "创建时间",
|
||
dataIndex: "createdAt",
|
||
width: 160,
|
||
render: (v: string) =>
|
||
v ? new Date(v).toLocaleString() : "-",
|
||
},
|
||
{
|
||
title: "退役时间",
|
||
dataIndex: "retiredAt",
|
||
width: 160,
|
||
render: (v: string | null) =>
|
||
v ? new Date(v).toLocaleString() : "-",
|
||
},
|
||
]}
|
||
/>
|
||
</div>
|
||
)}
|
||
</Modal>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default TenantAdmins;
|