diff --git a/README.md b/README.md index b074f1a..9f4ef6e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/api/client.test.ts b/src/api/client.test.ts index 86bf08c..bbb3a85 100644 --- a/src/api/client.test.ts +++ b/src/api/client.test.ts @@ -41,6 +41,37 @@ describe('api client', () => { expect(init.headers['X-Language-Locale']).toBe('zh-CN') }) + it('sends FormData untouched so the browser can set the multipart boundary', async () => { + useAuthStore.getState().setTokens({ + token_type: 'Bearer', + expires_in: 3600, + access_token: 'token-1', + refresh_token: 'refresh-1', + }) + fetchMock.mockResolvedValueOnce(jsonResponse({ success: true, data: { id: 'batch-1' } })) + + const form = new FormData() + form.append('archive', new Blob(['zip bytes']), 'resumes.zip') + + await api.upload<{ success: boolean; data: { id: string } }>('/api/admin/v1/x', form) + + const [, init] = fetchMock.mock.calls[0] + // Setting it ourselves would strip the boundary and the upload would arrive empty. + expect(init.headers['Content-Type']).toBeUndefined() + expect(init.body).toBe(form) + expect(init.headers.Authorization).toBe('Bearer token-1') + }) + + it('still JSON-encodes plain object bodies', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ success: true, data: null })) + + await api.post('/api/admin/v1/x', { a: 1 }) + + const [, init] = fetchMock.mock.calls[0] + expect(init.headers['Content-Type']).toBe('application/json') + expect(init.body).toBe(JSON.stringify({ a: 1 })) + }) + it('maps error envelopes to ApiError with code and field errors', async () => { fetchMock.mockResolvedValueOnce( jsonResponse( diff --git a/src/api/client.ts b/src/api/client.ts index e2b046a..73713e2 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -19,6 +19,7 @@ export class ApiError extends Error { interface RequestOptions { method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' + /** JSON-encoded, unless it is a FormData — see rawRequest. */ body?: unknown params?: ListParams /** skip Bearer + refresh handling (login/refresh calls) */ @@ -93,13 +94,24 @@ async function rawRequest(path: string, options: RequestOptions, retried = false Accept: 'application/json', 'X-Language-Locale': 'zh-CN', } - if (options.body !== undefined) headers['Content-Type'] = 'application/json' + // A FormData body must be passed through untouched: the browser sets + // `multipart/form-data` *with the boundary parameter*, which we cannot + // produce here. Setting Content-Type ourselves would strip the boundary and + // the server would see an empty upload. + const isFormData = typeof FormData !== 'undefined' && options.body instanceof FormData + + if (options.body !== undefined && !isFormData) headers['Content-Type'] = 'application/json' if (!options.anonymous && accessToken) headers.Authorization = `Bearer ${accessToken}` const res = await fetch(buildUrl(path, options.params), { method: options.method ?? 'GET', headers, - body: options.body !== undefined ? JSON.stringify(options.body) : undefined, + body: + options.body === undefined + ? undefined + : isFormData + ? (options.body as FormData) + : JSON.stringify(options.body), signal: options.signal, }) @@ -129,6 +141,12 @@ export const api = { get: (path: string, params?: ListParams, signal?: AbortSignal) => request(path, { params, signal }), post: (path: string, body?: unknown) => request(path, { method: 'POST', body }), + /** + * Multipart upload. `fetch` reports no upload progress, so callers should + * show an indeterminate state rather than a percentage. + */ + upload: (path: string, body: FormData, signal?: AbortSignal) => + request(path, { method: 'POST', body, signal }), put: (path: string, body?: unknown) => request(path, { method: 'PUT', body }), patch: (path: string, body?: unknown) => request(path, { method: 'PATCH', body }), delete: (path: string, body?: unknown) => request(path, { method: 'DELETE', body }), diff --git a/src/api/modules/resume.ts b/src/api/modules/resume.ts new file mode 100644 index 0000000..1db7de6 --- /dev/null +++ b/src/api/modules/resume.ts @@ -0,0 +1,71 @@ +import { api } from '@/api/client' +import type { + ApiResponse, + ListParams, + PaginatedResponse, + ResumeImportBatch, + ResumeImportItem, +} from '@/api/types' + +const BASE = '/api/admin/v1/resume/import-batches' + +export interface CreateBatchOptions { + /** ISO 3166-1 alpha-3. Supplies the region for phone numbers that omit a country code. */ + countryCode?: string + languageHint?: string + /** Parse and report, but create no users until `confirmBatch`. */ + dryRun?: boolean +} + +export function fetchImportBatches(params: ListParams) { + return api.get>(BASE, params) +} + +export function fetchImportBatch(id: string, signal?: AbortSignal) { + return api.get>(`${BASE}/${id}`, undefined, signal) +} + +export function fetchImportBatchItems(id: string, params: ListParams) { + return api.get>(`${BASE}/${id}/items`, params) +} + +export function fetchImportBatchItem(batchId: string, itemId: string) { + return api.get>(`${BASE}/${batchId}/items/${itemId}`) +} + +/** + * Upload a ZIP of resumes. Returns as soon as the server accepts it (202) — + * unpacking and parsing happen on a queue, so poll `fetchImportBatch`. + */ +export function createImportBatch(archive: File, options: CreateBatchOptions = {}) { + const form = new FormData() + form.append('archive', archive) + if (options.countryCode) form.append('countryCode', options.countryCode) + if (options.languageHint) form.append('languageHint', options.languageHint) + if (options.dryRun) form.append('dryRun', '1') + + return api.upload>(BASE, form) +} + +/** Turn a dry run into real accounts. Reuses the stored results — nothing re-parses. */ +export function confirmImportBatch(id: string) { + return api.post>(`${BASE}/${id}/confirm`) +} + +export function retryImportBatch(id: string) { + return api.post>(`${BASE}/${id}/retry`) +} + +/** Resumes from whichever step failed; already-extracted text is reused. */ +export function retryImportItem(batchId: string, itemId: string) { + return api.post>(`${BASE}/${batchId}/items/${itemId}/retry`) +} + +/** Resolve a `needs_contact` row. The phone is normalized to E.164 server-side. */ +export function updateImportItemContact( + batchId: string, + itemId: string, + contact: { phone?: string; email?: string }, +) { + return api.patch>(`${BASE}/${batchId}/items/${itemId}`, contact) +} diff --git a/src/api/types.gen.ts b/src/api/types.gen.ts index 22e74f9..86ef9d0 100644 --- a/src/api/types.gen.ts +++ b/src/api/types.gen.ts @@ -4685,7 +4685,7 @@ export interface paths { content: { "application/json": { /** - * @example expired + * @example viewed * @enum {string|null} */ status?: "sent" | "delivered" | "viewed" | "accepted" | "rejected" | "ignored" | "expired" | "taken_by_other" | null; @@ -4879,12 +4879,12 @@ export interface paths { content: { "application/json": { /** - * @example confirmed + * @example consumed * @enum {string|null} */ status?: "created" | "confirmed" | "started" | "consumed" | "paid" | "fulfilled" | "canceled" | null; /** - * @example PAID + * @example UNPAID * @enum {string|null} */ payment_status?: "UNPAID" | "PAID" | null; @@ -5163,13 +5163,13 @@ export interface paths { content: { "application/json": { /** - * @example busy + * @example inactive * @enum {string|null} */ status?: "offline" | "online" | "inactive" | "busy" | null; /** @example true */ is_disabled?: boolean | null; - /** @example false */ + /** @example true */ is_verified?: boolean | null; /** * @description Must be at least 1. @@ -5448,12 +5448,12 @@ export interface paths { content: { "application/json": { /** - * @example friends + * @example private * @enum {string|null} */ visibility?: "public" | "friends" | "private" | null; /** - * @example all + * @example blocked * @enum {string|null} */ status?: "blocked" | "visible" | "all" | null; @@ -6349,6 +6349,254 @@ export interface paths { patch?: never; trace?: never; }; + "/api/admin/v1/api-docs/public": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 获取公共 API OpenAPI 文档 */ + get: operations["APIOpenAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/v1/api-docs/admin": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 获取管理端 API OpenAPI 文档 */ + get: operations["APIOpenAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/v1/internal-surfaces/{surface}/session": { + parameters: { + query?: never; + header?: never; + path: { + /** @example architecto */ + surface: string; + }; + cookie?: never; + }; + get?: never; + put?: never; + /** + * 获取内部工具访问链接 + * @description 校验当前管理员是否拥有该工具的权限,并签发一次性入口链接。 + */ + post: { + parameters: { + query?: never; + header?: never; + path: { + /** @example architecto */ + surface: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: never; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/v1/notification/push-apps": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 推送应用列表 */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example false */ + success?: boolean; + /** @example Authentication required */ + message?: string; + /** @example UNAUTHENTICATED */ + code?: string; + }; + }; + }; + }; + }; + put?: never; + /** 创建推送应用 */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: never; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/v1/notification/push-apps/{id}": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description The ID of the push app. + * @example architecto + */ + id: string; + }; + cookie?: never; + }; + /** 推送应用详情 */ + get: { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description The ID of the push app. + * @example architecto + */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example false */ + success?: boolean; + /** @example Authentication required */ + message?: string; + /** @example UNAUTHENTICATED */ + code?: string; + }; + }; + }; + }; + }; + /** 更新推送应用 */ + put: { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description The ID of the push app. + * @example architecto + */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: never; + }; + post?: never; + /** 删除推送应用 */ + delete: { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description The ID of the push app. + * @example architecto + */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: never; + }; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/v1/notification/push-apps/{id}/verify": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description The ID of the push app. + * @example architecto + */ + id: string; + }; + cookie?: never; + }; + get?: never; + put?: never; + /** + * 校验推送应用凭证 + * @description 只报告凭证是否可用,不返回文件内容或解析结果。 + */ + post: { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description The ID of the push app. + * @example architecto + */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: never; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/admin/v1/locales": { parameters: { query?: never; @@ -7416,7 +7664,7 @@ export interface paths { * @example b */ q?: string | null; - /** @example true */ + /** @example false */ status?: boolean | null; /** @example true */ is_robot?: boolean | null; @@ -7791,6 +8039,925 @@ export interface paths { patch?: never; trace?: never; }; + "/api/admin/v1/resume/import-batches": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * 查询批量导入历史 + * @description 需要 admin:resume:read 权限。 + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example true */ + success?: boolean; + /** + * @example [ + * { + * "id": "01K9ZC0J8N7Q2W5X3Y6R4T8V1B", + * "kind": "resume-import-batch", + * "status": "completed", + * "counts": { + * "created": 141, + * "skipped_duplicate": 38 + * } + * } + * ] + */ + data?: { + /** @example 01K9ZC0J8N7Q2W5X3Y6R4T8V1B */ + id?: string; + /** @example resume-import-batch */ + kind?: string; + /** @example completed */ + status?: string; + counts?: { + /** @example 141 */ + created?: number; + /** @example 38 */ + skipped_duplicate?: number; + }; + }[]; + pagination?: { + /** @example 1 */ + current_page?: number; + /** @example null */ + next_page?: string; + /** @example 20 */ + page_size?: number; + /** @example null */ + prev_page?: string; + /** @example 1 */ + total_count?: number; + /** @example 1 */ + total_pages?: number; + }; + /** @example 获取批量导入列表成功。 */ + message?: string; + }; + }; + }; + }; + }; + put?: never; + /** + * 创建批量导入任务 + * @description 上传 ZIP 压缩包,立即返回 202 与批次记录,随后轮询批次详情查看进度。 + * 压缩包会在后台解包,每份简历生成一条导入记录。 + * + * 建议先用 `dryRun=true` 试运行:只统计将会创建 / 跳过的数量,不写入任何用户。 + * + * 需要 admin:resume:manage 权限。 + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "multipart/form-data": { + /** + * Format: binary + * @description 包含多份简历的 ZIP 压缩包。. Must be a file. Must not be greater than 51200 kilobytes. + */ + archive: string; + /** + * @description ISO 3166-1 alpha-3 国家代码,用于解析简历中缺少国际区号的手机号。默认 CHN。. Must be 3 characters. + * @example CHN + */ + countryCode?: string | null; + /** + * @description 可选的语言提示。. Must not be greater than 16 characters. + * @example zh + */ + languageHint?: string | null; + /** + * @description 可选的简历结构版本号。. Must not be greater than 16 characters. + * @example 1.0 + */ + schemaVersion?: string | null; + /** + * @description 试运行:解析并统计结果,但不创建任何用户。确认后再调用 confirm 接口。. + * @example false + */ + dryRun?: boolean | null; + }; + }; + }; + responses: { + /** @description idempotent_replay */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example true */ + success?: boolean; + data?: { + /** @example 01K9ZC0J8N7Q2W5X3Y6R4T8V1B */ + id?: string; + /** @example processing */ + status?: string; + }; + /** @example 简历批量导入任务已创建。 */ + message?: string; + }; + }; + }; + /** @description queued */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example true */ + success?: boolean; + data?: { + /** @example 01K9ZC0J8N7Q2W5X3Y6R4T8V1B */ + id?: string; + /** @example resume-import-batch */ + kind?: string; + /** @example queued */ + status?: string; + /** @example false */ + dryRun?: boolean; + progress?: { + /** @example 0 */ + total?: number; + /** @example 0 */ + processed?: number; + /** @example 0 */ + percent?: number; + }; + counts?: Record; + archive?: { + /** @example resumes.zip */ + filename?: string; + /** @example 10485760 */ + size?: number; + }; + /** @example /api/admin/v1/resume/import-batches/01K9ZC0J8N7Q2W5X3Y6R4T8V1B */ + pollUrl?: string; + }; + /** @example 简历批量导入任务已创建。 */ + message?: string; + }; + }; + }; + /** @description unauthenticated */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example false */ + success?: boolean; + /** @example UNAUTHENTICATED */ + code?: string; + /** @example 未认证访问 */ + message?: string; + }; + }; + }; + /** @description forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example false */ + success?: boolean; + /** @example UNAUTHORIZED */ + code?: string; + /** @example 无权访问 */ + message?: string; + }; + }; + }; + /** @description invalid_archive */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example false */ + success?: boolean; + /** @example VALIDATION_ERROR */ + code?: string; + /** @example The archive must be a file of type: application/zip. */ + message?: string; + }; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/v1/resume/import-batches/{id}": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description The ID of the import batch. + * @example architecto + */ + id: string; + /** + * @description 批次 ID。 + * @example 01K9ZC0J8N7Q2W5X3Y6R4T8V1B + */ + batch: string; + }; + cookie?: never; + }; + /** + * 查询批次进度与统计 + * @description 轮询该接口直到 status 为 completed 或 failed。counts 给出各处理结果的数量: + * created(已创建)、skipped_duplicate(手机号已存在,跳过)、needs_contact(缺少联系方式)、 + * would_create(试运行下将会创建)等。 + * + * 需要 admin:resume:read 权限。 + */ + get: { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description The ID of the import batch. + * @example architecto + */ + id: string; + /** + * @description 批次 ID。 + * @example 01K9ZC0J8N7Q2W5X3Y6R4T8V1B + */ + batch: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description processing */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example true */ + success?: boolean; + data?: { + /** @example 01K9ZC0J8N7Q2W5X3Y6R4T8V1B */ + id?: string; + /** @example processing */ + status?: string; + progress?: { + /** @example 200 */ + total?: number; + /** @example 87 */ + processed?: number; + /** @example 44 */ + percent?: number; + }; + counts?: { + /** @example 61 */ + created?: number; + /** @example 20 */ + skipped_duplicate?: number; + /** @example 6 */ + needs_contact?: number; + }; + }; + /** @example 获取批量导入记录成功。 */ + message?: string; + }; + }; + }; + /** @description not_found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example false */ + success?: boolean; + /** @example NOT_FOUND */ + code?: string; + /** @example 记录不存在。 */ + message?: string; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/v1/resume/import-batches/{batch}/items": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description 批次 ID。 + * @example 01K9ZC0J8N7Q2W5X3Y6R4T8V1B + */ + batch: string; + }; + cookie?: never; + }; + /** + * 查询批次内的简历明细 + * @description 可按 outcome / status 筛选,例如只看 needs_contact 的记录以便补充联系方式。 + * 列表不返回解析结果正文与联系方式,需要时请查询单条明细。 + * + * 需要 admin:resume:read 权限。 + */ + get: { + parameters: { + query?: { + /** + * @description 页码。. Must be at least 1. + * @example 1 + */ + page?: number | null; + /** + * @description 每页数量,最大 100。. Must be between 1 and 100. + * @example 20 + */ + page_size?: number | null; + /** + * @description 按处理结果筛选。. + * @example skipped_duplicate + */ + outcome?: "created" | "would_create" | "skipped_duplicate" | "reused_import" | "needs_contact" | "duplicate_file" | "unsupported_entry" | "entry_too_large" | "parse_failed" | "failed" | null; + /** + * @description 按解析状态筛选。. + * @example completed + */ + status?: "queued" | "processing" | "completed" | "failed" | null; + /** + * @description 按文件名模糊搜索。. Must not be greater than 255 characters. + * @example zhang + */ + q?: string | null; + }; + header?: never; + path: { + /** + * @description 批次 ID。 + * @example 01K9ZC0J8N7Q2W5X3Y6R4T8V1B + */ + batch: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example true */ + success?: boolean; + /** + * @example [ + * { + * "id": "01K9ZD0J8N7Q2W5X3Y6R4T8V1C", + * "kind": "resume-import", + * "status": "completed", + * "outcome": "skipped_duplicate", + * "matchedUserUid": "01HJQYX8RN4M7T5E7S2P9F1V3K", + * "document": { + * "filename": "zhangsan.pdf" + * } + * } + * ] + */ + data?: { + /** @example 01K9ZD0J8N7Q2W5X3Y6R4T8V1C */ + id?: string; + /** @example resume-import */ + kind?: string; + /** @example completed */ + status?: string; + /** @example skipped_duplicate */ + outcome?: string; + /** @example 01HJQYX8RN4M7T5E7S2P9F1V3K */ + matchedUserUid?: string; + document?: { + /** @example zhangsan.pdf */ + filename?: string; + }; + }[]; + pagination?: { + /** @example 1 */ + current_page?: number; + /** @example null */ + next_page?: string; + /** @example 20 */ + page_size?: number; + /** @example null */ + prev_page?: string; + /** @example 1 */ + total_count?: number; + /** @example 1 */ + total_pages?: number; + }; + /** @example 获取批量导入明细成功。 */ + message?: string; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/v1/resume/import-batches/{batch_id}/items/{id}": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description The ID of the batch. + * @example architecto + */ + batch_id: string; + /** + * @description The ID of the item. + * @example architecto + */ + id: string; + /** + * @description 批次 ID。 + * @example 01K9ZC0J8N7Q2W5X3Y6R4T8V1B + */ + batch: string; + /** + * @description 简历导入记录 ID。 + * @example 01K9ZD0J8N7Q2W5X3Y6R4T8V1C + */ + item: string; + }; + cookie?: never; + }; + /** + * 查询单条简历导入明细 + * @description 返回完整解析结果,结构与公开接口 /api/v1/resume-parser/imports/{id} 的 result 一致。 + * + * 需要 admin:resume:read 权限。 + */ + get: { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description The ID of the batch. + * @example architecto + */ + batch_id: string; + /** + * @description The ID of the item. + * @example architecto + */ + id: string; + /** + * @description 批次 ID。 + * @example 01K9ZC0J8N7Q2W5X3Y6R4T8V1B + */ + batch: string; + /** + * @description 简历导入记录 ID。 + * @example 01K9ZD0J8N7Q2W5X3Y6R4T8V1C + */ + item: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example false */ + success?: boolean; + /** @example NOT_FOUND */ + code?: string; + /** @example 记录不存在。 */ + message?: string; + } | { + /** @example false */ + success?: boolean; + /** @example The requested resource was not found. */ + message?: string; + /** @example NOT_FOUND */ + code?: string; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** + * 补充联系方式 + * @description 用于解决 needs_contact 的记录:提供手机号或邮箱后立即校验是否与已有用户冲突, + * 无冲突则排队建号。不会重新解析简历。 + * + * 需要 admin:resume:manage 权限。 + */ + patch: { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description The ID of the batch. + * @example architecto + */ + batch_id: string; + /** + * @description The ID of the item. + * @example architecto + */ + id: string; + /** + * @description 批次 ID。 + * @example 01K9ZC0J8N7Q2W5X3Y6R4T8V1B + */ + batch: string; + /** + * @description 简历导入记录 ID。 + * @example 01K9ZD0J8N7Q2W5X3Y6R4T8V1C + */ + item: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description 手机号。缺少国际区号时按批次的 countryCode 解析,并统一存储为 E.164 格式。. This field is required when email is not present. Must not be greater than 32 characters. + * @example 13800138000 + */ + phone?: string | null; + /** + * @description 邮箱地址,当没有手机号时作为身份标识。. This field is required when phone is not present. Must be a valid email address. Must not be greater than 255 characters. + * @example zhang@example.com + */ + email?: string | null; + }; + }; + }; + responses: { + /** @description queued */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example true */ + success?: boolean; + data?: { + /** @example 01K9ZD0J8N7Q2W5X3Y6R4T8V1C */ + id?: string; + /** @example phone */ + identityType?: string; + }; + /** @example 已重新排队。 */ + message?: string; + }; + }; + }; + /** @description duplicate */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example false */ + success?: boolean; + /** @example IMPORT_IDENTITY_TAKEN */ + code?: string; + /** @example 该联系方式已属于其他用户。 */ + message?: string; + }; + }; + }; + /** @description invalid_phone */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example false */ + success?: boolean; + /** @example INVALID_PHONE */ + code?: string; + /** @example 手机号格式无效。 */ + message?: string; + }; + }; + }; + }; + }; + trace?: never; + }; + "/api/admin/v1/resume/import-batches/{batch}/confirm": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description 批次 ID。 + * @example 01K9ZC0J8N7Q2W5X3Y6R4T8V1B + */ + batch: string; + }; + cookie?: never; + }; + get?: never; + put?: never; + /** + * 确认试运行批次 + * @description 将 dryRun 批次转为正式导入:为所有 would_create 的记录创建用户。 + * 不会重新解析简历(结果已存在),因此不产生额外的模型调用费用。 + * + * 需要 admin:resume:manage 权限。 + */ + post: { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description 批次 ID。 + * @example 01K9ZC0J8N7Q2W5X3Y6R4T8V1B + */ + batch: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description confirmed */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example true */ + success?: boolean; + data?: { + /** @example 01K9ZC0J8N7Q2W5X3Y6R4T8V1B */ + id?: string; + /** @example false */ + dryRun?: boolean; + }; + /** @example 已确认批量导入,正在创建用户。 */ + message?: string; + }; + }; + }; + /** @description not_dry_run */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example false */ + success?: boolean; + /** @example BATCH_NOT_DRY_RUN */ + code?: string; + /** @example 该批次不是试运行,无需确认。 */ + message?: string; + }; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/v1/resume/import-batches/{batch}/retry": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description 批次 ID。 + * @example 01K9ZC0J8N7Q2W5X3Y6R4T8V1B + */ + batch: string; + }; + cookie?: never; + }; + get?: never; + put?: never; + /** + * 重试批次内所有失败的简历 + * @description 需要 admin:resume:manage 权限。 + */ + post: { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description 批次 ID。 + * @example 01K9ZC0J8N7Q2W5X3Y6R4T8V1B + */ + batch: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description requeued */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example true */ + success?: boolean; + data?: { + /** @example 3 */ + requeued?: number; + }; + /** @example 已重新排队。 */ + message?: string; + }; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/v1/resume/import-batches/{batch}/items/{item}/retry": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description 批次 ID。 + * @example 01K9ZC0J8N7Q2W5X3Y6R4T8V1B + */ + batch: string; + /** + * @description 简历导入记录 ID。 + * @example 01K9ZD0J8N7Q2W5X3Y6R4T8V1C + */ + item: string; + }; + cookie?: never; + }; + get?: never; + put?: never; + /** + * 重试单条简历 + * @description 会自动判断从哪一步继续:解析失败则重新解析(已提取的正文会复用,不重复产生 OCR 费用), + * 仅建号失败则只重新建号。 + * + * 需要 admin:resume:manage 权限。 + */ + post: { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description 批次 ID。 + * @example 01K9ZC0J8N7Q2W5X3Y6R4T8V1B + */ + batch: string; + /** + * @description 简历导入记录 ID。 + * @example 01K9ZD0J8N7Q2W5X3Y6R4T8V1C + */ + item: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description requeued */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example true */ + success?: boolean; + data?: { + /** @example 01K9ZD0J8N7Q2W5X3Y6R4T8V1C */ + id?: string; + /** @example queued */ + status?: string; + }; + /** @example 已重新排队。 */ + message?: string; + }; + }; + }; + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example false */ + success?: boolean; + /** @example IMPORT_NEEDS_CONTACT */ + code?: string; + /** @example 该简历缺少手机号或邮箱,请先补充联系方式。 */ + message?: string; + } | { + /** @example false */ + success?: boolean; + /** @example IMPORT_NOT_RETRYABLE */ + code?: string; + /** @example 只有失败的导入任务可以重试。 */ + message?: string; + }; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/admin/v1/auth/login/otp": { parameters: { query?: never; @@ -8577,7 +9744,7 @@ export interface paths { content: { "application/json": { /** - * @example pending + * @example all * @enum {string|null} */ status?: "pending" | "blocked" | "all" | null; @@ -8940,11 +10107,11 @@ export interface paths { content: { "application/json": { /** - * @example android + * @example web * @enum {string|null} */ platform?: "ios" | "android" | "web" | "desktop" | null; - /** @example true */ + /** @example false */ is_online?: boolean | null; /** * @description Must be at least 1. @@ -9940,6 +11107,58 @@ export interface operations { }; }; }; + APIOpenAPI: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example false */ + success?: boolean; + /** @example Authentication required */ + message?: string; + /** @example UNAUTHENTICATED */ + code?: string; + }; + }; + }; + }; + }; + APIOpenAPI: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example false */ + success?: boolean; + /** @example Authentication required */ + message?: string; + /** @example UNAUTHENTICATED */ + code?: string; + }; + }; + }; + }; + }; SID: { parameters: { query?: never; diff --git a/src/api/types.ts b/src/api/types.ts index 62245ad..036903d 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -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> + 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 | null + result?: Record | null +} diff --git a/src/auth/permissions.ts b/src/auth/permissions.ts index 684f588..95f6cfc 100644 --- a/src/auth/permissions.ts +++ b/src/auth/permissions.ts @@ -29,6 +29,8 @@ export const PERM = { SID_MANAGE: 'admin:sid:manage', SID_OVERRIDE: 'admin:sid:override', NOTIFICATION_READ: 'admin:notification:read', + RESUME_READ: 'admin:resume:read', + RESUME_MANAGE: 'admin:resume:manage', ROBOT_TOKEN_ISSUE: 'auth:robot-token:issue', API_DOCS_READ: 'auth:api-docs:read', // internal operator consoles — `auth:` prefixed, so super-admin only diff --git a/src/components/layout/nav.ts b/src/components/layout/nav.ts index 5cc77fc..d8f0f02 100644 --- a/src/components/layout/nav.ts +++ b/src/components/layout/nav.ts @@ -7,6 +7,7 @@ import { Database, FileText, FileJson, + FileUp, Flag, FolderTree, Hash, @@ -84,6 +85,17 @@ export const NAV_GROUPS: NavGroup[] = [ { label: 'Dimzou 翻译', to: '/dimzou-translations', icon: Languages, permission: 'admin:dimzou:read' }, ], }, + { + label: '简历导入', + items: [ + { + label: '批量导入', + to: '/resume/import-batches', + icon: FileUp, + permission: 'admin:resume:read', + }, + ], + }, { label: '内容审核', items: [ diff --git a/src/features/resume/batch-detail-sheet.tsx b/src/features/resume/batch-detail-sheet.tsx new file mode 100644 index 0000000..6c3d112 --- /dev/null +++ b/src/features/resume/batch-detail-sheet.tsx @@ -0,0 +1,273 @@ +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { useState } from 'react' +import { toast } from 'sonner' +import type { ResumeImportItem, ResumeImportOutcome } from '@/api/types' +import { + confirmImportBatch, + fetchImportBatch, + fetchImportBatchItems, + retryImportBatch, + retryImportItem, +} from '@/api/modules/resume' +import { PERM } from '@/auth/permissions' +import { useCan } from '@/auth/store' +import { handleApiError } from '@/lib/errors' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from '@/components/ui/sheet' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { BatchStatusBadge, OUTCOME_LABELS, OutcomeBadge, formatTime, stepLabel } from './labels' +import { ResolveContactDialog } from './resolve-contact-dialog' + +const POLL_INTERVAL = 2000 + +interface Props { + batchId: string | null + onOpenChange: (open: boolean) => void +} + +export function ResumeImportBatchDetailSheet({ batchId, onOpenChange }: Props) { + const canManage = useCan(PERM.RESUME_MANAGE) + const queryClient = useQueryClient() + const [outcome, setOutcome] = useState('all') + const [resolving, setResolving] = useState(null) + + const batchQuery = useQuery({ + queryKey: ['resume-import-batch', batchId], + queryFn: () => fetchImportBatch(batchId as string), + enabled: batchId !== null, + // Unpacking and parsing run on a queue; stop polling once it settles. + refetchInterval: (query) => { + const status = query.state.data?.data.status + return status === 'completed' || status === 'failed' ? false : POLL_INTERVAL + }, + }) + + const batch = batchQuery.data?.data + + const itemsQuery = useQuery({ + queryKey: ['resume-import-items', batchId, outcome], + queryFn: () => + fetchImportBatchItems(batchId as string, { + page_size: 100, + ...(outcome === 'all' ? {} : { outcome }), + }), + enabled: batchId !== null, + // Follow the batch: keep refreshing the report while rows are still moving. + refetchInterval: batch && (batch.status === 'completed' || batch.status === 'failed') + ? false + : POLL_INTERVAL, + }) + + const items = itemsQuery.data?.data ?? [] + const refresh = () => { + void batchQuery.refetch() + void itemsQuery.refetch() + } + + const act = async (fn: () => Promise, done: string) => { + try { + await fn() + toast.success(done) + refresh() + void queryClient.invalidateQueries({ queryKey: ['resume-import-batches'] }) + } catch (error) { + handleApiError(error) + } + } + + const countEntries = Object.entries(batch?.counts ?? {}) as Array<[ResumeImportOutcome, number]> + const isRunning = batch ? batch.status === 'queued' || batch.status === 'unpacking' : false + + return ( + <> + + + + + {batch?.archive.filename ?? '导入批次'} + + + {batchId} + + + +
+ {batch && ( + <> +
+ + {batch.dryRun && ( + + 试运行 + + )} + + {/* total is 0 until unpacking finishes — "0 / 0" would read as an empty batch */} + {isRunning && batch.progress.total === 0 + ? '准备中…' + : `${batch.progress.processed} / ${batch.progress.total}`} + +
+ + {batch.error && ( +
+ {batch.error.message} + {batch.error.code} +
+ )} + + {countEntries.length > 0 && ( +
+ {countEntries.map(([key, value]) => ( + + ))} +
+ )} + + {canManage && ( +
+ {batch.dryRun && batch.status === 'completed' && ( + + )} + +
+ )} + +
+ 简历明细 + +
+ +
+ {items.length === 0 && ( +

+ {itemsQuery.isPending ? '加载中…' : '暂无记录'} +

+ )} + {items.map((item) => ( +
+
+
+
+ {item.document.filename ?? '—'} +
+ {item.document.archiveEntry && + item.document.archiveEntry !== item.document.filename && ( + + {item.document.archiveEntry} + + )} +
+ +
+ +
+ + 提取:{stepLabel(item.steps.extraction)} · 解析: + {stepLabel(item.steps.structure)} + + {item.matchedUserUid && ( + + 用户 {item.matchedUserUid} + + )} + {formatTime(item.finishedAt ?? item.createdAt)} +
+ + {item.error && ( +

+ {item.error.message} +

+ )} + + {canManage && ( +
+ {item.outcome === 'needs_contact' && ( + + )} + {(item.outcome === 'failed' || item.outcome === 'parse_failed') && ( + + )} +
+ )} +
+ ))} +
+ + )} +
+
+
+ + setResolving(null)} + onResolved={refresh} + /> + + ) +} diff --git a/src/features/resume/labels.tsx b/src/features/resume/labels.tsx new file mode 100644 index 0000000..3299353 --- /dev/null +++ b/src/features/resume/labels.tsx @@ -0,0 +1,88 @@ +import { Badge } from '@/components/ui/badge' +import type { + ResumeImportBatchStatus, + ResumeImportOutcome, + ResumeImportStepStatus, +} from '@/api/types' + +export const BATCH_STATUS_LABELS: Record = { + queued: '排队中', + unpacking: '解包中', + processing: '处理中', + completed: '已完成', + failed: '失败', +} + +/** + * Ordered so the report reads worst-first for the outcomes an operator has to + * act on, then the ones that need no attention. + */ +export const OUTCOME_LABELS: Record = { + needs_contact: '缺少联系方式', + failed: '建号失败', + parse_failed: '解析失败', + unsupported_entry: '不支持的文件', + entry_too_large: '文件过大', + created: '已创建', + would_create: '将会创建', + skipped_duplicate: '已存在,跳过', + reused_import: '重复简历', + duplicate_file: '重复文件', +} + +const OUTCOME_TONE: Record = { + created: 'bg-green-500/12 text-green-700 dark:text-green-400', + would_create: 'bg-blue-500/12 text-blue-700 dark:text-blue-400', + skipped_duplicate: 'bg-amber-500/12 text-amber-700 dark:text-amber-400', + reused_import: 'bg-amber-500/12 text-amber-700 dark:text-amber-400', + duplicate_file: 'bg-muted text-muted-foreground', + needs_contact: 'bg-orange-500/12 text-orange-700 dark:text-orange-400', + unsupported_entry: 'bg-muted text-muted-foreground', + entry_too_large: 'bg-muted text-muted-foreground', + parse_failed: 'bg-red-500/12 text-red-700 dark:text-red-400', + failed: 'bg-red-500/12 text-red-700 dark:text-red-400', +} + +const BATCH_STATUS_TONE: Record = { + queued: 'bg-muted text-muted-foreground', + unpacking: 'bg-blue-500/12 text-blue-700 dark:text-blue-400', + processing: 'bg-blue-500/12 text-blue-700 dark:text-blue-400', + completed: 'bg-green-500/12 text-green-700 dark:text-green-400', + failed: 'bg-red-500/12 text-red-700 dark:text-red-400', +} + +/** `cached` / `reused` mean the step cost no provider call. */ +const STEP_LABELS: Record = { + pending: '待处理', + processing: '进行中', + completed: '已完成', + cached: '命中缓存', + reused: '复用结果', + skipped: '已跳过', + failed: '失败', +} + +export function BatchStatusBadge({ status }: { status: ResumeImportBatchStatus }) { + return {BATCH_STATUS_LABELS[status]} +} + +export function OutcomeBadge({ outcome }: { outcome: ResumeImportOutcome | null }) { + if (!outcome) return 处理中 + return {OUTCOME_LABELS[outcome]} +} + +export function stepLabel(step: ResumeImportStepStatus): string { + return STEP_LABELS[step] ?? step +} + +export function formatTime(value: string | null | undefined): string { + if (!value) return '—' + return new Date(value).toLocaleString('zh-CN', { hour12: false }) +} + +export function formatBytes(bytes: number | null | undefined): string { + if (bytes == null) return '—' + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + return `${(bytes / 1024 / 1024).toFixed(1)} MB` +} diff --git a/src/features/resume/resolve-contact-dialog.tsx b/src/features/resume/resolve-contact-dialog.tsx new file mode 100644 index 0000000..57d8bd2 --- /dev/null +++ b/src/features/resume/resolve-contact-dialog.tsx @@ -0,0 +1,120 @@ +import { useEffect, useState } from 'react' +import { toast } from 'sonner' +import type { ResumeImportItem } from '@/api/types' +import { updateImportItemContact } from '@/api/modules/resume' +import { ApiError } from '@/api/client' +import { handleApiError } from '@/lib/errors' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' + +interface Props { + batchId: string | null + item: ResumeImportItem | null + onClose: () => void + onResolved: () => void +} + +/** + * Supplies the contact a resume didn't yield. The server normalizes the phone + * to E.164 and checks for a collision synchronously, so a clash is reported + * here rather than discovered by polling. + */ +export function ResolveContactDialog({ batchId, item, onClose, onResolved }: Props) { + const [phone, setPhone] = useState('') + const [email, setEmail] = useState('') + const [submitting, setSubmitting] = useState(false) + const [conflict, setConflict] = useState(null) + + useEffect(() => { + setPhone('') + setEmail('') + setConflict(null) + }, [item?.id]) + + const submit = async () => { + if (!batchId || !item) return + setSubmitting(true) + setConflict(null) + try { + await updateImportItemContact(batchId, item.id, { + phone: phone.trim() || undefined, + email: email.trim() || undefined, + }) + toast.success('已排队创建用户') + onClose() + onResolved() + } catch (error) { + if (error instanceof ApiError && error.code === 'IMPORT_IDENTITY_TAKEN') { + setConflict(error.message) + } else { + handleApiError(error) + } + } finally { + setSubmitting(false) + } + } + + return ( + !next && onClose()}> + + + 补充联系方式 + + {item?.document.filename} 未能解析出手机号或邮箱。填写其一即可创建账号; + 简历不会重新解析。 + + + +
+
+ + setPhone(e.target.value)} + placeholder="13800138000" + /> +

+ 缺少国际区号时按批次的国家代码解析,统一存储为 E.164 格式。 +

+
+ +
+ + setEmail(e.target.value)} + placeholder="zhang@example.com" + /> +
+ + {conflict && ( +
+ {conflict} +
+ )} +
+ + + + + +
+
+ ) +} diff --git a/src/features/resume/upload-dialog.tsx b/src/features/resume/upload-dialog.tsx new file mode 100644 index 0000000..77cf3ee --- /dev/null +++ b/src/features/resume/upload-dialog.tsx @@ -0,0 +1,139 @@ +import { useRef, useState } from 'react' +import { toast } from 'sonner' +import { createImportBatch } from '@/api/modules/resume' +import { handleApiError } from '@/lib/errors' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { formatBytes } from './labels' + +interface Props { + onCreated: (batchId: string) => void +} + +export function ResumeImportUploadDialog({ onCreated }: Props) { + const [open, setOpen] = useState(false) + const [file, setFile] = useState(null) + const [countryCode, setCountryCode] = useState('CHN') + // Default on: seeing the collision report before creating accounts is + // almost always what an operator wants. + const [dryRun, setDryRun] = useState(true) + const [submitting, setSubmitting] = useState(false) + const inputRef = useRef(null) + + const reset = () => { + setFile(null) + setDryRun(true) + setCountryCode('CHN') + if (inputRef.current) inputRef.current.value = '' + } + + const submit = async () => { + if (!file) return + setSubmitting(true) + try { + const res = await createImportBatch(file, { countryCode, dryRun }) + toast.success(dryRun ? '已创建试运行任务' : '已创建导入任务') + setOpen(false) + reset() + onCreated(res.data.id) + } catch (error) { + handleApiError(error) + } finally { + setSubmitting(false) + } + } + + return ( + { + setOpen(next) + if (!next) reset() + }} + > + + + + + + 批量导入简历 + + 上传一个包含多份简历的 ZIP 压缩包。系统会逐份解析并创建用户账号(未激活,待本人通过验证码认领)。 + 同一个手机号只会对应一个用户,已存在的账号不会被修改。 + + + +
+
+ + setFile(e.target.files?.[0] ?? null)} + /> + {file && ( +

+ {file.name} · {formatBytes(file.size)} +

+ )} +

+ 支持 PDF / DOCX / 图片 / 纯文本简历,单个文件不超过 10MB。 +

+
+ +
+ + setCountryCode(e.target.value.toUpperCase())} + placeholder="CHN" + /> +

+ ISO 3166-1 三位国家代码,用于解析简历中缺少国际区号的手机号。 + 缺少此信息的手机号不会被猜测,而是标记为「缺少联系方式」。 +

+
+ + +
+ + + + {/* fetch reports no upload progress, so this stays indeterminate */} + + +
+
+ ) +} diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 0e826b6..e79bee9 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -32,6 +32,7 @@ import { Route as AuthedStudioRobotAuthorsRouteImport } from './routes/_authed/s import { Route as AuthedStudioCategoryBriefsRouteImport } from './routes/_authed/studio/category-briefs' import { Route as AuthedStudioArticlesRouteImport } from './routes/_authed/studio/articles' import { Route as AuthedStudioAgentTokensRouteImport } from './routes/_authed/studio/agent-tokens' +import { Route as AuthedResumeImportBatchesRouteImport } from './routes/_authed/resume/import-batches' import { Route as AuthedExcProvidersRouteImport } from './routes/_authed/exc/providers' import { Route as AuthedExcOrdersRouteImport } from './routes/_authed/exc/orders' import { Route as AuthedExcDispatchesRouteImport } from './routes/_authed/exc/dispatches' @@ -158,6 +159,12 @@ const AuthedStudioAgentTokensRoute = AuthedStudioAgentTokensRouteImport.update({ path: '/studio/agent-tokens', getParentRoute: () => AuthedRoute, } as any) +const AuthedResumeImportBatchesRoute = + AuthedResumeImportBatchesRouteImport.update({ + id: '/resume/import-batches', + path: '/resume/import-batches', + getParentRoute: () => AuthedRoute, + } as any) const AuthedExcProvidersRoute = AuthedExcProvidersRouteImport.update({ id: '/exc/providers', path: '/exc/providers', @@ -216,6 +223,7 @@ export interface FileRoutesByFullPath { '/exc/dispatches': typeof AuthedExcDispatchesRoute '/exc/orders': typeof AuthedExcOrdersRoute '/exc/providers': typeof AuthedExcProvidersRoute + '/resume/import-batches': typeof AuthedResumeImportBatchesRoute '/studio/agent-tokens': typeof AuthedStudioAgentTokensRoute '/studio/articles': typeof AuthedStudioArticlesRoute '/studio/category-briefs': typeof AuthedStudioCategoryBriefsRoute @@ -247,6 +255,7 @@ export interface FileRoutesByTo { '/exc/dispatches': typeof AuthedExcDispatchesRoute '/exc/orders': typeof AuthedExcOrdersRoute '/exc/providers': typeof AuthedExcProvidersRoute + '/resume/import-batches': typeof AuthedResumeImportBatchesRoute '/studio/agent-tokens': typeof AuthedStudioAgentTokensRoute '/studio/articles': typeof AuthedStudioArticlesRoute '/studio/category-briefs': typeof AuthedStudioCategoryBriefsRoute @@ -280,6 +289,7 @@ export interface FileRoutesById { '/_authed/exc/dispatches': typeof AuthedExcDispatchesRoute '/_authed/exc/orders': typeof AuthedExcOrdersRoute '/_authed/exc/providers': typeof AuthedExcProvidersRoute + '/_authed/resume/import-batches': typeof AuthedResumeImportBatchesRoute '/_authed/studio/agent-tokens': typeof AuthedStudioAgentTokensRoute '/_authed/studio/articles': typeof AuthedStudioArticlesRoute '/_authed/studio/category-briefs': typeof AuthedStudioCategoryBriefsRoute @@ -313,6 +323,7 @@ export interface FileRouteTypes { | '/exc/dispatches' | '/exc/orders' | '/exc/providers' + | '/resume/import-batches' | '/studio/agent-tokens' | '/studio/articles' | '/studio/category-briefs' @@ -344,6 +355,7 @@ export interface FileRouteTypes { | '/exc/dispatches' | '/exc/orders' | '/exc/providers' + | '/resume/import-batches' | '/studio/agent-tokens' | '/studio/articles' | '/studio/category-briefs' @@ -376,6 +388,7 @@ export interface FileRouteTypes { | '/_authed/exc/dispatches' | '/_authed/exc/orders' | '/_authed/exc/providers' + | '/_authed/resume/import-batches' | '/_authed/studio/agent-tokens' | '/_authed/studio/articles' | '/_authed/studio/category-briefs' @@ -556,6 +569,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthedStudioAgentTokensRouteImport parentRoute: typeof AuthedRoute } + '/_authed/resume/import-batches': { + id: '/_authed/resume/import-batches' + path: '/resume/import-batches' + fullPath: '/resume/import-batches' + preLoaderRoute: typeof AuthedResumeImportBatchesRouteImport + parentRoute: typeof AuthedRoute + } '/_authed/exc/providers': { id: '/_authed/exc/providers' path: '/exc/providers' @@ -627,6 +647,7 @@ interface AuthedRouteChildren { AuthedExcDispatchesRoute: typeof AuthedExcDispatchesRoute AuthedExcOrdersRoute: typeof AuthedExcOrdersRoute AuthedExcProvidersRoute: typeof AuthedExcProvidersRoute + AuthedResumeImportBatchesRoute: typeof AuthedResumeImportBatchesRoute AuthedStudioAgentTokensRoute: typeof AuthedStudioAgentTokensRoute AuthedStudioArticlesRoute: typeof AuthedStudioArticlesRoute AuthedStudioCategoryBriefsRoute: typeof AuthedStudioCategoryBriefsRoute @@ -657,6 +678,7 @@ const AuthedRouteChildren: AuthedRouteChildren = { AuthedExcDispatchesRoute: AuthedExcDispatchesRoute, AuthedExcOrdersRoute: AuthedExcOrdersRoute, AuthedExcProvidersRoute: AuthedExcProvidersRoute, + AuthedResumeImportBatchesRoute: AuthedResumeImportBatchesRoute, AuthedStudioAgentTokensRoute: AuthedStudioAgentTokensRoute, AuthedStudioArticlesRoute: AuthedStudioArticlesRoute, AuthedStudioCategoryBriefsRoute: AuthedStudioCategoryBriefsRoute, diff --git a/src/routes/_authed/resume/import-batches.tsx b/src/routes/_authed/resume/import-batches.tsx new file mode 100644 index 0000000..7d7c982 --- /dev/null +++ b/src/routes/_authed/resume/import-batches.tsx @@ -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(null) + + const list = useListPage('resume-import-batches', fetchImportBatches, {}) + + const columns: ColumnDef[] = [ + { + header: '压缩包', + cell: ({ row }) => ( + + ), + }, + { + header: '状态', + cell: ({ row }) => ( +
+ + {row.original.dryRun && ( + 试运行 + )} +
+ ), + }, + { + 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 准备中… + } + return ( + + {processed} / {total} + + ) + }, + }, + { + 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 ? ( + {pending} + ) : ( + '—' + ) + }, + }, + { + header: '创建时间', + cell: ({ row }) => formatTime(row.original.createdAt), + }, + ] + + return ( +
+ { + setDetailId(id) + void list.query.refetch() + }} + /> + ) : undefined + } + /> + + + + { + if (!open) { + setDetailId(null) + void list.query.refetch() + } + }} + /> +
+ ) +}