feat: 批量创建支持Excel导入与模板下载;机构账号页新增修改用户(手机号/姓名),移除修改姓名入口
This commit is contained in:
@@ -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");
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user