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 <noreply@anthropic.com>
This commit is contained in:
2026-08-24 23:21:15 +08:00
parent 2c6fed7149
commit 78c28ed1bd
14 changed files with 2243 additions and 13 deletions

View File

@ -31,6 +31,14 @@ ddev exec php artisan tinker storage/app/e2e-admin-seed.php
| `pnpm test` | vitest (API client envelope/refresh/error tests) |
| `pnpm gen:api` | regenerate `src/api/types.gen.ts` from the backend's private admin OpenAPI spec (`../gig-platform/storage/app/api-docs/admin/openapi.yaml`; run `ddev artisan api-docs:generate` there first) |
## Uploads
`api.upload(path, formData)` is the multipart path. `rawRequest` deliberately
does **not** set `Content-Type` for a `FormData` body — the browser must set it
itself so the multipart boundary is included; setting it by hand makes the
server see an empty upload (a 422 rather than a 202). `fetch` reports no upload
progress, so upload UIs stay indeterminate.
## Deploy
The app is a client-rendered SPA served as static assets by a Cloudflare Worker