feat(resume): admin bulk resume import

Adds the dashboard surface for the backend's /api/admin/v1/resume/import-batches
endpoints, and the multipart support it needed.

Client:
- rawRequest passes a FormData body through untouched and skips Content-Type,
  so the browser sets multipart/form-data *with the boundary*. Setting it by
  hand stripped the boundary and the server saw an empty upload — verified
  against the backend: 202 with the fix, 422 VALIDATION_ERROR without it.
  This was the first admin endpoint in the codebase to accept a file.
- New api.upload(). fetch reports no upload progress, so upload UI stays
  indeterminate rather than faking a percentage.

UI (/resume/import-batches, gated on admin:resume:read; writes on :manage):
- Upload dialog with countryCode and dry-run checked by default — seeing the
  collision report before creating accounts is almost always what you want.
- Detail sheet polls every 2s and stops once the batch is terminal. Clickable
  count tiles filter the per-resume report; step statuses surface `命中缓存` /
  `复用结果` so the dedup is visible.
- Confirm a dry run, retry failed rows, and resolve a missing contact.
  IMPORT_IDENTITY_TAKEN is shown inline — a collision is expected input, not
  an error toast.
- progress.total is 0 until unpacking finishes, so both views render 准备中…
  rather than "0 / 0", which would read as an empty batch.

types.gen.ts regenerated from the backend's admin spec.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-08-24 23:21:15 +08:00
co-authored by Claude Opus 5
parent 2c6fed7149
commit 78c28ed1bd
14 changed files with 2243 additions and 13 deletions
@@ -0,0 +1,141 @@
import { createFileRoute } from '@tanstack/react-router'
import { useState } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import type { ResumeImportBatch } from '@/api/types'
import { fetchImportBatches } from '@/api/modules/resume'
import { PERM, requirePermission } from '@/auth/permissions'
import { useCan } from '@/auth/store'
import { useListPage } from '@/lib/list-page'
import { DataTable } from '@/components/data-table/data-table'
import { PageHeader } from '@/components/page-header'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { ResumeImportBatchDetailSheet } from '@/features/resume/batch-detail-sheet'
import { ResumeImportUploadDialog } from '@/features/resume/upload-dialog'
import { BatchStatusBadge, formatBytes, formatTime } from '@/features/resume/labels'
export const Route = createFileRoute('/_authed/resume/import-batches')({
beforeLoad: () => requirePermission(PERM.RESUME_READ),
component: ResumeImportBatchesPage,
})
function ResumeImportBatchesPage() {
const canManage = useCan(PERM.RESUME_MANAGE)
const [detailId, setDetailId] = useState<string | null>(null)
const list = useListPage('resume-import-batches', fetchImportBatches, {})
const columns: ColumnDef<ResumeImportBatch>[] = [
{
header: '压缩包',
cell: ({ row }) => (
<Button
variant="link"
className="h-auto justify-start p-0 text-left"
onClick={() => setDetailId(row.original.id)}
>
<div className="min-w-0">
<div className="truncate font-medium">{row.original.archive.filename ?? '—'}</div>
<code className="font-mono text-xs text-muted-foreground">
{formatBytes(row.original.archive.size)}
</code>
</div>
</Button>
),
},
{
header: '状态',
cell: ({ row }) => (
<div className="flex items-center gap-1.5">
<BatchStatusBadge status={row.original.status} />
{row.original.dryRun && (
<Badge className="bg-blue-500/12 text-blue-700 dark:text-blue-400"></Badge>
)}
</div>
),
},
{
header: '进度',
cell: ({ row }) => {
const { total, processed } = row.original.progress
// total stays 0 until unpacking finishes; "0 / 0" would look like an
// empty batch rather than one that hasn't been opened yet.
if (total === 0) {
return <span className="text-muted-foreground"></span>
}
return (
<span>
{processed} / {total}
</span>
)
},
},
{
header: '已创建',
cell: ({ row }) =>
row.original.counts.created ?? row.original.counts.would_create ?? 0,
},
{
header: '跳过',
cell: ({ row }) =>
(row.original.counts.skipped_duplicate ?? 0) + (row.original.counts.reused_import ?? 0),
},
{
header: '待处理',
cell: ({ row }) => {
const pending =
(row.original.counts.needs_contact ?? 0) +
(row.original.counts.failed ?? 0) +
(row.original.counts.parse_failed ?? 0)
return pending > 0 ? (
<span className="font-medium text-orange-600 dark:text-orange-400">{pending}</span>
) : (
'—'
)
},
},
{
header: '创建时间',
cell: ({ row }) => formatTime(row.original.createdAt),
},
]
return (
<div>
<PageHeader
title="简历批量导入"
description="上传简历压缩包,为每份简历创建未激活账号;同一手机号只对应一个用户,已有账号不会被修改"
actions={
canManage ? (
<ResumeImportUploadDialog
onCreated={(id) => {
setDetailId(id)
void list.query.refetch()
}}
/>
) : undefined
}
/>
<DataTable
columns={columns}
data={list.rows}
pagination={list.pagination}
loading={list.loading}
emptyText="暂无导入批次"
onPageChange={list.setPage}
onPageSizeChange={list.setPageSize}
/>
<ResumeImportBatchDetailSheet
batchId={detailId}
onOpenChange={(open) => {
if (!open) {
setDetailId(null)
void list.query.refetch()
}
}}
/>
</div>
)
}