feat: 批量创建支持Excel导入与模板下载;机构账号页新增修改用户(手机号/姓名),移除修改姓名入口

This commit is contained in:
2026-10-27 13:29:30 +08:00
parent cea200ca5e
commit d40461a69a
6 changed files with 276 additions and 51 deletions

View File

@@ -23,6 +23,11 @@ export const createUser = (data: any, config?: any) => {
return http.post("/user/create_org_user", data, config);
};
// 批量创建用户(单条失败不影响其他条目,返回与入参顺序对应的逐条结果)
export const batchCreateUser = (data: any[]) => {
return http.post("/user/batch_create_org_user", data);
};
export const getUserOrg = () => {
return http.get("/uc/org/find");
};

View File

@@ -20,7 +20,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Spinner } from "@/components/ui/spinner";
import { Input } from "@/components/ui/input";
import { useSaveUser } from "../-hooks/useSaveUser";
import { useSaveUser } from "@/hooks/useSaveUser";
import { useEffect, useMemo } from "react";
import { Alert, AlertDescription } from "@/components/ui/alert";
@@ -57,7 +57,9 @@ export function EditUserDialog({
refetch,
data,
}: EditUserDialogProps) {
const isNewUser = useMemo(() => !data?.id, [data]);
// 用户管理页行主键为 id,机构账号页行主键为 userId
const recordId = data?.userId ?? data?.id;
const isNewUser = useMemo(() => !recordId, [recordId]);
const formSchema = useMemo(() => getFormSchema(isNewUser), [isNewUser]);
const form = useForm<z.infer<typeof formSchema>>({
@@ -89,7 +91,7 @@ export function EditUserDialog({
}, [open, data, form]);
const onSubmit = (values: z.infer<typeof formSchema>) => {
save({ id: data?.id, ...values });
save({ id: recordId, ...values });
};
return (

View File

@@ -17,6 +17,7 @@ import { useFetchList } from "./-hooks/useFetchList";
import { useUserStatusActions } from "@/hooks/use-user-status-actions";
import { UserActionConfirmDialog } from "./-components/UserActionConfirmDialog";
import { ResetUserPasswordDialog } from "@/components/reset-user-password-dialog";
import { EditUserDialog } from "@/components/edit-user-dialog";
import { useUserStore } from "@/stores/user";
import { DateRangePicker } from "@/components/date-range-picker";
@@ -40,6 +41,7 @@ export function useColumns({
handleDisable,
handleCancelUser,
handleResetPassword,
handleEditUser,
}: any) {
const currentUserId = useUserStore((state: any) => state.user?.id);
return [
@@ -133,6 +135,13 @@ export function useColumns({
}
return (
<div className="space-x-2">
<Button
size="sm"
variant="outline"
onClick={() => handleEditUser(record)}
>
修改
</Button>
<Button
size="sm"
variant="outline"
@@ -206,12 +215,20 @@ function RouteComponent() {
setOpenResetPasswordDialog(true);
};
// 修改用户(手机号/姓名)
const [openEditUserDialog, setOpenEditUserDialog] = useState(false);
const handleEditUser = (record: any) => {
setCurrentEntity(record);
setOpenEditUserDialog(true);
};
const { table, query, search } = useFetchList(() =>
useColumns({
handleEnable,
handleDisable,
handleCancelUser,
handleResetPassword,
handleEditUser,
}),
);
@@ -252,6 +269,12 @@ function RouteComponent() {
setOpen={setOpenResetPasswordDialog}
data={currentEntity}
/>
<EditUserDialog
open={openEditUserDialog}
setOpen={setOpenEditUserDialog}
data={currentEntity}
refetch={query?.refetch}
/>
<div className="p-4 box-border min-h-full space-y-4">
<div>
<div className="text-lg">机构账号</div>

View File

@@ -1,6 +1,8 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { createUser } from "@/apis/user";
import * as XLSX from "xlsx";
import { Download, FileSpreadsheet, Upload } from "lucide-react";
import { batchCreateUser } from "@/apis/user";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -12,6 +14,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Spinner } from "@/components/ui/spinner";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
type BatchCreateUserDialogProps = {
@@ -25,10 +28,17 @@ type FailedItem = {
msg: string;
};
type ParsedRow = {
phone: string;
realName: string;
line: string;
};
const PHONE_RE = /^1\d{10}$/;
const MAX_BATCH = 500;
const parseText = (text: string) => {
const rows: { phone: string; realName: string; line: string }[] = [];
const rows: ParsedRow[] = [];
const failed: FailedItem[] = [];
text.split("\n").forEach((raw) => {
@@ -49,22 +59,59 @@ const parseText = (text: string) => {
return { rows, failed };
};
// Excel 行解析:取前两列为手机号/姓名,自动跳过模板表头行
const parseExcel = (matrix: unknown[][]) => {
const rows: ParsedRow[] = [];
const failed: FailedItem[] = [];
let data = (matrix || [])
.map((row) => (row || []).map((cell) => String(cell ?? "").trim()))
.filter((cells) => cells.some((cell) => cell !== ""));
if (data.length > 0 && data[0][0].includes("手机")) {
data = data.slice(1);
}
data.forEach((cells) => {
const line = cells.join(", ");
const [phone, realName] = cells;
if (!phone || !realName) {
failed.push({ line, msg: "格式错误,应为「手机号,姓名」" });
return;
}
if (!PHONE_RE.test(phone)) {
failed.push({ line, msg: "手机号格式不正确" });
return;
}
rows.push({ phone, realName, line });
});
return { rows, failed };
};
export function BatchCreateUserDialog({
open,
setOpen,
refetch,
}: BatchCreateUserDialogProps) {
const [mode, setMode] = useState<"text" | "excel">("text");
const [text, setText] = useState("");
const [isPending, setIsPending] = useState(false);
const [progress, setProgress] = useState({ done: 0, total: 0 });
const [failed, setFailed] = useState<FailedItem[]>([]);
const [fileName, setFileName] = useState("");
const [excelResult, setExcelResult] = useState<{
rows: ParsedRow[];
failed: FailedItem[];
}>({ rows: [], failed: [] });
const fileRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (open) {
setMode("text");
setText("");
setIsPending(false);
setProgress({ done: 0, total: 0 });
setFailed([]);
setFileName("");
setExcelResult({ rows: [], failed: [] });
}
}, [open]);
@@ -78,43 +125,97 @@ export function BatchCreateUserDialog({
setOpen(nextOpen);
};
const downloadTemplate = () => {
const worksheet = XLSX.utils.aoa_to_sheet([
["手机号", "姓名"],
["13800000001", "张三"],
["13800000002", "李四"],
]);
worksheet["!cols"] = [{ wch: 20 }, { wch: 24 }];
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, "用户列表");
XLSX.writeFile(workbook, "批量创建用户模板.xlsx");
};
const onFilePicked = async (file?: File) => {
if (!file) return;
try {
const buffer = await file.arrayBuffer();
const workbook = XLSX.read(buffer, { type: "array" });
const worksheet = workbook.Sheets[workbook.SheetNames[0]];
if (!worksheet) {
toast.error("文件中没有工作表");
return;
}
const matrix = XLSX.utils.sheet_to_json<unknown[]>(worksheet, {
header: 1,
defval: "",
});
const result = parseExcel(matrix);
if (result.rows.length === 0 && result.failed.length === 0) {
toast.error("文件中没有用户数据");
return;
}
setFileName(file.name);
setExcelResult(result);
} catch {
toast.error("文件解析失败,请使用下载的模板填写");
} finally {
if (fileRef.current) {
fileRef.current.value = "";
}
}
};
const onSubmit = async () => {
const { rows, failed: invalid } = parseText(text);
const { rows, failed: invalid } =
mode === "text" ? parseText(text) : excelResult;
if (rows.length === 0) {
if (invalid.length > 0) {
setFailed(invalid);
} else {
toast.error("请输入用户数据");
toast.error(mode === "text" ? "请输入用户数据" : "请先选择 Excel 文件");
}
return;
}
if (rows.length + invalid.length > MAX_BATCH) {
toast.error(`单次批量创建不能超过 ${MAX_BATCH} 条`);
return;
}
const failures: FailedItem[] = [...invalid];
setIsPending(true);
setProgress({ done: 0, total: rows.length });
for (let index = 0; index < rows.length; index++) {
const { phone, realName, line } = rows[index];
try {
await createUser({ phone, realName }, { silent: true });
} catch (err: any) {
failures.push({ line, msg: err?.msg || "创建失败" });
}
setProgress({ done: index + 1, total: rows.length });
const res: any = await batchCreateUser(
rows.map(({ phone, realName }) => ({ phone, realName })),
);
// 返回结果与入参顺序一一对应,失败项回显原始行内容
const results: any[] = res?.data || [];
const failures: FailedItem[] = [...invalid];
results.forEach((result, index) => {
if (!result?.success) {
failures.push({
line: rows[index]?.line ?? "",
msg: result?.msg || "创建失败",
});
}
});
setIsPending(false);
refetch?.();
const failedCount = failures.length;
const successCount = rows.length - (failedCount - invalid.length);
if (failedCount > 0) {
if (failures.length > 0) {
setFailed(failures);
toast.error(`创建完成:成功 ${successCount} 条,失败 ${failedCount} 条`);
toast.error(
`创建完成:成功 ${results.length - (failures.length - invalid.length)} 条,失败 ${failures.length} 条`,
);
} else {
toast.success(`创建成功:共 ${rows.length} 条`);
toast.success(`创建成功:共 ${results.length} 条`);
setOpen(false);
}
} catch {
// 请求整体失败已由拦截器统一 toast
} finally {
setIsPending(false);
}
};
return (
@@ -127,7 +228,7 @@ export function BatchCreateUserDialog({
<DialogDescription>
{failed.length > 0
? `以下 ${failed.length} 条数据创建失败,修正后可重新提交`
: "每行一个用户,格式为「手机号,姓名」,支持逗号、空格或制表符分隔"}
: "支持贴内容或上传 Excel 文件导入,每条用户为「手机号,姓名」"}
</DialogDescription>
</DialogHeader>
@@ -147,7 +248,17 @@ export function BatchCreateUserDialog({
))}
</div>
) : (
<div className="space-y-2">
<Tabs value={mode} onValueChange={(v) => setMode(v as any)}>
<TabsList className="w-full mb-2">
<TabsTrigger value="text" disabled={isPending} className="flex-1">
贴内容导入
</TabsTrigger>
<TabsTrigger value="excel" disabled={isPending} className="flex-1">
Excel 导入
</TabsTrigger>
</TabsList>
<TabsContent value="text" className="space-y-2">
<Textarea
value={text}
onChange={(event) => setText(event.target.value)}
@@ -157,13 +268,97 @@ export function BatchCreateUserDialog({
/>
<div className="flex items-center justify-between text-xs text-black/50">
<span>已输入 {lineCount} 条</span>
{isPending && (
<span>
正在创建 {progress.done}/{progress.total}...
</span>
{isPending && <span>正在创建...</span>}
</div>
</TabsContent>
<TabsContent value="excel" className="space-y-2">
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={downloadTemplate}
disabled={isPending}
className="shrink-0"
>
<Download />
下载模板
</Button>
<Button
className="flex-1"
size="sm"
onClick={() => fileRef.current?.click()}
disabled={isPending}
>
<Upload />
{excelResult.rows.length > 0 || excelResult.failed.length > 0
? "重新选择文件"
: "选择文件"}
</Button>
<input
ref={fileRef}
type="file"
accept=".xlsx,.xls"
className="hidden"
onChange={(event) =>
onFilePicked(event.target.files?.[0] as File | undefined)
}
disabled={isPending}
/>
</div>
{fileName ? (
<div className="h-[200px] rounded-md border p-3 space-y-2 overflow-auto">
<div className="flex items-center gap-2 text-sm">
<FileSpreadsheet className="size-4 text-green-600" />
<span className="break-all">{fileName}</span>
</div>
<div className="text-xs text-black/50">
<div>
解析出有效数据{" "}
<span className="text-black font-medium">
{excelResult.rows.length}
</span>{" "}
条
{excelResult.failed.length > 0 && (
<>
,格式错误{" "}
<span className="text-red-600 font-medium">
{excelResult.failed.length}
</span>{" "}
条(提交后将展示明细)
</>
)}
</div>
{excelResult.rows.length > 0 && (
<div className="mt-1 max-h-[130px] overflow-auto font-mono">
{excelResult.rows
.slice(0, 5)
.map((row) => (
<div key={row.line}>
{row.phone}, {row.realName}
</div>
))}
{excelResult.rows.length > 5 && (
<div>...共 {excelResult.rows.length} 条</div>
)}
</div>
)}
</div>
</div>
) : (
<div className="h-[200px] rounded-md border border-dashed flex flex-col items-center justify-center gap-1 text-xs text-black/50">
<span>请先下载模板,按模板格式填写后上传</span>
<span>支持 .xlsx / .xls 文件,最多 {MAX_BATCH} 条</span>
</div>
)}
<div className="flex items-center justify-between text-xs text-black/50">
<span />
{isPending && <span>正在创建...</span>}
</div>
</TabsContent>
</Tabs>
)}
<DialogFooter>

View File

@@ -4,7 +4,7 @@ import { Button } from "@/components/ui/button";
import { DataTable } from "@/components/data-table";
import { useFetchList } from "./-hooks/useFetchList";
import { useState } from "react";
import { EditUserDialog } from "./-components/EditUserDialog";
import { EditUserDialog } from "@/components/edit-user-dialog";
import { BatchCreateUserDialog } from "./-components/BatchCreateUserDialog";
import { CancelUserConfirmDialog } from "./-components/CancelUserConfirmDialog";
import { Badge } from "@/components/ui/badge";