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; } const TEMPLATE_CSV = [ "employeeNo,name,phone,email,departmentName,title,directManagerEmployeeNo,roles,hireDate", "E1001,李经理,13800000001,manager@example.com,行政部,经理,,manager,2026-03-01", "E1002,张三,13800000002,zhangsan@example.com,行政部,员工,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(null); const [error, setError] = useState(""); const [dryRunResult, setDryRunResult] = useState(null); const [applyResult, setApplyResult] = useState(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 ( 批量导入员工 上传员工表,先预检,再确认导入。成功后可下载一次性密码清单。
{ const nextFile = event.target.files?.[0] ?? null; setSelectedFile(nextFile); setError(""); setDryRunResult(null); setApplyResult(null); }} />
必填列:employeeNo、name、phone、email、departmentName
选填列:title、directManagerEmployeeNo、roles、hireDate
{selectedFile ? (
当前文件:{selectedFile.name}
) : null}
{applyResult ? ( ) : null}
{error ? (

{error}

) : null} {summary ? (
总行数
{summary.totalRows}
通过行数
{summary.validRows}
失败行数
{summary.invalidRows}
) : (
下载模板并上传文件后,先预检逐行结果。
)}
{dryRunResult ? (

预检结果

{dryRunResult.sourceFilename}
行号 员工 标准化结果 校验结果 {dryRunResult.rows.map((row) => ( {row.rowNumber}
{row.name || "-"}
{row.employeeNo || "-"}
{row.normalized.departmentName} · {row.normalized.title} · {row.normalized.hireDate}
直属上级:{row.normalized.directManagerEmployeeNo || "-"} · 角色:{row.normalized.roles.join(" / ")}
{row.errors.length ? (
{row.errors.map((item, index) => (
{item.field}: {item.message}
))}
) : ( 通过 )}
))}
) : null} {applyResult ? (

导入成功清单

已生成一次性密码,建议立即下载并线下发放。
员工 邮箱 一次性密码 {applyResult.imported.map((item) => (
{item.name}
{item.employeeNo}
{item.email} {item.oneTimePassword}
))}
) : null}
); }