361 行
13 KiB
TypeScript
361 行
13 KiB
TypeScript
import { resolveErrorMessage as resolveUserErrorMessage } from "../../lib/resolveErrorMessage";
|
|
import { useEffect, useState } from "react";
|
|
import { useMutation } from "@tanstack/react-query";
|
|
import {
|
|
applyUserImport,
|
|
dryRunUserImport,
|
|
type UserImportApplyResult,
|
|
type UserImportDryRunResult,
|
|
} from "../../api/users";
|
|
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,张三,,[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) {
|
|
return resolveUserErrorMessage(error, 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>
|
|
上传员工表,先预检,再确认导入。成功后可下载一次性密码清单。
|
|
</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、email、departmentName
|
|
</div>
|
|
<div className="text-xs text-zinc-500">
|
|
选填列:phone、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 ? "预检中..." : "预检"}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
onClick={() => applyMutation.mutate()}
|
|
disabled={!canApply}
|
|
>
|
|
{applyMutation.isPending ? "导入中..." : "确认导入"}
|
|
</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">
|
|
下载模板并上传文件后,先预检逐行结果。
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{dryRunResult ? (
|
|
<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">{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>
|
|
);
|
|
}
|