R41: 完成员工批量导入
这个提交包含在:
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户