R41: 完成员工批量导入

这个提交包含在:
2026-03-28 18:57:02 +08:00
父节点 2eff8e01a2
当前提交 3cf8b43f74
共修改 7 个文件,包含 1477 行新增和 63 行删除
+67
查看文件
@@ -23,6 +23,55 @@ export type UserListResponse = {
items: UserSummary[];
};
export type UserImportRowError = {
field: string;
message: string;
};
export type UserImportPreviewRow = {
rowNumber: number;
employeeNo: string;
name: string;
normalized: {
employeeNo: string;
name: string;
phone: string;
email: string;
departmentName: string;
title: string;
directManagerEmployeeNo: string;
roles: string[];
hireDate: string;
};
errors: UserImportRowError[];
};
export type UserImportDryRunResult = {
sourceFilename: string;
summary: {
totalRows: number;
validRows: number;
invalidRows: number;
};
rows: UserImportPreviewRow[];
};
export type UserImportApplyResult = {
sourceFilename: string;
summary: {
totalRows: number;
validRows: number;
invalidRows: number;
};
imported: Array<{
employeeId: string;
employeeNo: string;
name: string;
email: string;
oneTimePassword: string;
}>;
};
export async function fetchUsers(params: {
page: number;
pageSize: number;
@@ -82,3 +131,21 @@ export async function setUserRoles(userId: string, roles: string[]) {
body: JSON.stringify({ roles }),
});
}
export async function dryRunUserImport(file: File) {
const formData = new FormData();
formData.append("file", file);
return apiRequestData<UserImportDryRunResult>("/api/users/import/dry-run", {
method: "POST",
body: formData,
});
}
export async function applyUserImport(file: File) {
const formData = new FormData();
formData.append("file", file);
return apiRequestData<UserImportApplyResult>("/api/users/import/apply", {
method: "POST",
body: formData,
});
}
+33 -19
查看文件
@@ -23,6 +23,7 @@ import { TableSkeleton } from "../components/common/TableSkeleton";
import { StatusBadge } from "../components/common/StatusBadge";
import { Pagination } from "../components/common/Pagination";
import { CreateUserDialog, type CreateUserFormData } from "./user-management/CreateUserDialog";
import { UserImportDialog } from "./user-management/UserImportDialog";
import { UserRoleDialog } from "./user-management/UserRoleDialog";
const PAGE_SIZE = 20;
@@ -51,6 +52,7 @@ export function UserManagementPage() {
const [resetResult, setResetResult] = useState<{ userId: string; password: string } | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [roleDialogUser, setRoleDialogUser] = useState<UserSummary | null>(null);
const listQuery = useQuery({
@@ -142,25 +144,37 @@ export function UserManagementPage() {
title="系统用户管理"
subtitle="创建/启用/禁用账号,重置密码并分配角色(仅试运行最小运营)。"
actions={
<CreateUserDialog
open={createOpen}
onOpenChange={setCreateOpen}
onSubmit={(data) => createMutation.mutate(data)}
isPending={createMutation.isPending}
departments={{
isLoading: departmentsQuery.isLoading,
items: departmentsQuery.data?.items ?? [],
}}
managers={{
isLoading: managersQuery.isLoading,
items: (managersQuery.data?.items ?? []).map((item) => ({
id: item.id,
name: item.name,
employeeNo: item.employeeNo,
})),
}}
roleOptions={ROLE_OPTIONS}
/>
<div className="flex gap-2">
<UserImportDialog
open={importOpen}
onOpenChange={setImportOpen}
onImported={async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["users"] }),
queryClient.invalidateQueries({ queryKey: ["employees"] }),
]);
}}
/>
<CreateUserDialog
open={createOpen}
onOpenChange={setCreateOpen}
onSubmit={(data) => createMutation.mutate(data)}
isPending={createMutation.isPending}
departments={{
isLoading: departmentsQuery.isLoading,
items: departmentsQuery.data?.items ?? [],
}}
managers={{
isLoading: managersQuery.isLoading,
items: (managersQuery.data?.items ?? []).map((item) => ({
id: item.id,
name: item.name,
employeeNo: item.employeeNo,
})),
}}
roleOptions={ROLE_OPTIONS}
/>
</div>
}
/>
@@ -0,0 +1,363 @@
import { useEffect, useState } from "react";
import { useMutation } from "@tanstack/react-query";
import {
applyUserImport,
dryRunUserImport,
type UserImportApplyResult,
type UserImportDryRunResult,
} from "../../api/users";
import { mapApiErrorMessage } from "../../api/errorMessages";
import { isApiError } from "../../api/types";
import { downloadBlob } from "../../lib/download";
import { Button } from "../../components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "../../components/ui/dialog";
import { Input } from "../../components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../components/ui/table";
import { toast } from "sonner";
export interface UserImportDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onImported: () => Promise<void> | void;
}
const TEMPLATE_CSV = [
"employeeNo,name,phone,email,departmentName,title,directManagerEmployeeNo,roles,hireDate",
"E1001,李经理,13800000001,[email protected],行政部,经理,,manager,2026-03-01",
"E1002,张三,13800000002,[email protected],行政部,员工,E1001,employee,2026-03-15",
].join("\n");
function toCsvCell(value: string) {
if (/[",\n\r]/.test(value)) {
return `"${value.replace(/"/g, "\"\"")}"`;
}
return value;
}
function formatErrorMessage(error: unknown, fallback: string) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
return error instanceof Error ? error.message : fallback;
}
function parsePreviewFromError(error: unknown, sourceFilename: string): UserImportDryRunResult | null {
if (!isApiError(error) || !error.details || typeof error.details !== "object") {
return null;
}
const summary = "summary" in error.details ? error.details.summary : null;
const rows = "rows" in error.details ? error.details.rows : null;
if (!summary || !rows || !Array.isArray(rows)) {
return null;
}
return {
sourceFilename,
summary: summary as UserImportDryRunResult["summary"],
rows: rows as UserImportDryRunResult["rows"],
};
}
function buildPasswordsCsv(result: UserImportApplyResult) {
const lines = [
"employeeNo,name,email,oneTimePassword",
...result.imported.map((item) =>
[
item.employeeNo,
item.name,
item.email,
item.oneTimePassword,
].map(toCsvCell).join(","),
),
];
return `\uFEFF${lines.join("\n")}`;
}
export function UserImportDialog({ open, onOpenChange, onImported }: UserImportDialogProps) {
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [error, setError] = useState("");
const [dryRunResult, setDryRunResult] = useState<UserImportDryRunResult | null>(null);
const [applyResult, setApplyResult] = useState<UserImportApplyResult | null>(null);
useEffect(() => {
if (open) {
return;
}
setSelectedFile(null);
setError("");
setDryRunResult(null);
setApplyResult(null);
}, [open]);
const dryRunMutation = useMutation({
mutationFn: async () => {
if (!selectedFile) {
throw new Error("请先选择 CSV 或 XLSX 文件");
}
return dryRunUserImport(selectedFile);
},
onSuccess: (result) => {
setDryRunResult(result);
setApplyResult(null);
setError("");
if (result.summary.invalidRows > 0) {
toast.error(`预检完成,存在 ${result.summary.invalidRows} 行问题`);
} else {
toast.success("预检通过,可以执行导入");
}
},
onError: (mutationError) => {
setError(formatErrorMessage(mutationError, "预检失败"));
const preview = parsePreviewFromError(mutationError, selectedFile?.name ?? "users-import");
if (preview) {
setDryRunResult(preview);
}
toast.error(formatErrorMessage(mutationError, "预检失败"));
},
});
const applyMutation = useMutation({
mutationFn: async () => {
if (!selectedFile) {
throw new Error("请先选择 CSV 或 XLSX 文件");
}
return applyUserImport(selectedFile);
},
onSuccess: async (result) => {
setApplyResult(result);
setError("");
await onImported();
toast.success(`导入完成,成功创建 ${result.imported.length} 个账号`);
},
onError: (mutationError) => {
setError(formatErrorMessage(mutationError, "导入失败"));
const preview = parsePreviewFromError(mutationError, selectedFile?.name ?? "users-import");
if (preview) {
setDryRunResult(preview);
}
toast.error(formatErrorMessage(mutationError, "导入失败"));
},
});
const summary = applyResult?.summary ?? dryRunResult?.summary ?? null;
const canApply =
Boolean(selectedFile) &&
Boolean(dryRunResult) &&
dryRunResult?.summary.invalidRows === 0 &&
!applyResult &&
!applyMutation.isPending;
function handleDownloadTemplate() {
downloadBlob(
new Blob([`\uFEFF${TEMPLATE_CSV}`], { type: "text/csv;charset=utf-8" }),
"users-import-template.csv",
);
}
function handleDownloadPasswords() {
if (!applyResult) {
return;
}
downloadBlob(
new Blob([buildPasswordsCsv(applyResult)], { type: "text/csv;charset=utf-8" }),
"users-import-passwords.csv",
);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogTrigger asChild>
<Button data-testid="users-import-open" type="button" variant="secondary">
批量导入
</Button>
</DialogTrigger>
<DialogContent className="max-h-[85vh] max-w-5xl overflow-y-auto">
<DialogHeader>
<DialogTitle>批量导入员工</DialogTitle>
<DialogDescription>
导入结果会同时创建员工档案、登录账号和角色。流程固定为 dry-run 预检后再 apply,成功后可下载一次性密码清单。
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
<div className="grid gap-4 lg:grid-cols-[220px,1fr]">
<div className="space-y-3">
<Button type="button" variant="secondary" onClick={handleDownloadTemplate}>
下载 CSV 模板
</Button>
<Input
type="file"
accept=".csv,.xlsx,text/csv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
onChange={(event) => {
const nextFile = event.target.files?.[0] ?? null;
setSelectedFile(nextFile);
setError("");
setDryRunResult(null);
setApplyResult(null);
}}
/>
<div className="text-xs text-zinc-500">
必填列:employeeNo、name、phone、email、departmentName
</div>
<div className="text-xs text-zinc-500">
选填列:title、directManagerEmployeeNo、roles、hireDate
</div>
{selectedFile ? (
<div className="rounded-lg border border-zinc-200 px-3 py-2 text-xs text-zinc-600">
当前文件:{selectedFile.name}
</div>
) : null}
</div>
<div className="space-y-4">
<div className="flex flex-wrap gap-2">
<Button
type="button"
onClick={() => dryRunMutation.mutate()}
disabled={!selectedFile || dryRunMutation.isPending || applyMutation.isPending}
>
{dryRunMutation.isPending ? "预检中..." : "执行 dry-run"}
</Button>
<Button
type="button"
variant="secondary"
onClick={() => applyMutation.mutate()}
disabled={!canApply}
>
{applyMutation.isPending ? "导入中..." : "执行 apply"}
</Button>
{applyResult ? (
<Button type="button" variant="secondary" onClick={handleDownloadPasswords}>
下载密码清单
</Button>
) : null}
</div>
{error ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{error}
</p>
) : null}
{summary ? (
<div className="grid gap-3 md:grid-cols-3">
<div className="rounded-lg border border-zinc-200 p-4">
<div className="text-sm text-zinc-500">总行数</div>
<div className="mt-2 text-2xl font-semibold text-zinc-900">{summary.totalRows}</div>
</div>
<div className="rounded-lg border border-zinc-200 p-4">
<div className="text-sm text-zinc-500">通过行数</div>
<div className="mt-2 text-2xl font-semibold text-emerald-600">{summary.validRows}</div>
</div>
<div className="rounded-lg border border-zinc-200 p-4">
<div className="text-sm text-zinc-500">失败行数</div>
<div className="mt-2 text-2xl font-semibold text-red-600">{summary.invalidRows}</div>
</div>
</div>
) : (
<div className="rounded-lg border border-dashed border-zinc-300 px-4 py-6 text-sm text-zinc-500">
下载模板并上传文件后,先执行 dry-run 查看逐行校验结果。
</div>
)}
</div>
</div>
{dryRunResult ? (
<div className="space-y-4">
<div>
<h3 className="text-base font-semibold text-zinc-900">dry-run 结果</h3>
<div className="mt-1 text-sm text-zinc-500">{dryRunResult.sourceFilename}</div>
</div>
<div className="rounded-lg border border-zinc-200">
<Table>
<TableHeader>
<TableRow>
<TableHead>行号</TableHead>
<TableHead>员工</TableHead>
<TableHead>标准化结果</TableHead>
<TableHead>校验结果</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{dryRunResult.rows.map((row) => (
<TableRow key={row.rowNumber}>
<TableCell>{row.rowNumber}</TableCell>
<TableCell>
<div className="font-medium text-zinc-900">{row.name || "-"}</div>
<div className="text-xs text-zinc-500">{row.employeeNo || "-"}</div>
</TableCell>
<TableCell className="text-sm text-zinc-600">
<div>{row.normalized.departmentName} · {row.normalized.title} · {row.normalized.hireDate}</div>
<div className="mt-1 text-xs text-zinc-500">
直属上级:{row.normalized.directManagerEmployeeNo || "-"} · 角色:{row.normalized.roles.join(" / ")}
</div>
</TableCell>
<TableCell className="text-sm">
{row.errors.length ? (
<div className="space-y-1 text-red-600">
{row.errors.map((item, index) => (
<div key={`${row.rowNumber}-${item.field}-${index}`}>
{item.field}: {item.message}
</div>
))}
</div>
) : (
<span className="text-emerald-600">通过</span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
) : null}
{applyResult ? (
<div className="space-y-4">
<div>
<h3 className="text-base font-semibold text-zinc-900">导入成功清单</h3>
<div className="mt-1 text-sm text-zinc-500">
已生成一次性密码,建议立即下载并线下发放。
</div>
</div>
<div className="rounded-lg border border-zinc-200">
<Table>
<TableHeader>
<TableRow>
<TableHead>员工</TableHead>
<TableHead>邮箱</TableHead>
<TableHead>一次性密码</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{applyResult.imported.map((item) => (
<TableRow key={item.employeeId}>
<TableCell>
<div className="font-medium text-zinc-900">{item.name}</div>
<div className="text-xs text-zinc-500">{item.employeeNo}</div>
</TableCell>
<TableCell>{item.email}</TableCell>
<TableCell className="font-mono text-sm">{item.oneTimePassword}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
) : null}
</div>
</DialogContent>
</Dialog>
);
}