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
+86
View File
@@ -738,3 +738,89 @@ export interface ExcProvider {
created_at?: string
}>
}
// ---------- resume bulk import ----------
export type ResumeImportBatchStatus =
| 'queued'
| 'unpacking'
| 'processing'
| 'completed'
| 'failed'
/**
* What happened to one resume. Independent of the parse status: a resume can
* parse perfectly and still end up `skipped_duplicate`.
*/
export type ResumeImportOutcome =
| 'created'
| 'would_create'
| 'skipped_duplicate'
| 'reused_import'
| 'needs_contact'
| 'duplicate_file'
| 'unsupported_entry'
| 'entry_too_large'
| 'parse_failed'
| 'failed'
/** `cached` / `reused` mean no provider call was made for that step. */
export type ResumeImportStepStatus =
| 'pending'
| 'processing'
| 'completed'
| 'cached'
| 'reused'
| 'skipped'
| 'failed'
export interface ResumeImportBatch {
id: string
kind: 'resume-import-batch'
status: ResumeImportBatchStatus
dryRun: boolean
progress: {
/** 0 until unpacking finishes — render a preparing state, not "0 / 0". */
total: number
processed: number
percent: number
}
counts: Partial<Record<ResumeImportOutcome, number>>
archive: { filename: string | null; size: number | null }
options: {
countryCode: string | null
languageHint: string | null
schemaVersion: string | null
}
error: { code: string; message: string } | null
createdBy: string | null
pollUrl: string
createdAt: string | null
startedAt: string | null
finishedAt: string | null
}
export interface ResumeImportItem {
id: string
kind: 'resume-import'
status: 'queued' | 'processing' | 'completed' | 'failed'
outcome: ResumeImportOutcome | null
steps: { extraction: ResumeImportStepStatus; structure: ResumeImportStepStatus }
document: {
filename: string | null
archiveEntry: string | null
mimeType: string | null
size: number | null
extractionMethod: string | null
}
identityType: 'phone' | 'email' | null
matchedUserUid: string | null
duplicateOfImportId: string | null
error: { code: string; message: string } | null
createdAt: string | null
finishedAt: string | null
// detail-only — never present on the list
identityValue?: string | null
metrics?: Record<string, number> | null
result?: Record<string, unknown> | null
}