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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user