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:
@ -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(
|
||||
|
||||
@ -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: <T>(path: string, params?: ListParams, signal?: AbortSignal) =>
|
||||
request<T>(path, { params, signal }),
|
||||
post: <T>(path: string, body?: unknown) => request<T>(path, { method: 'POST', body }),
|
||||
/**
|
||||
* Multipart upload. `fetch` reports no upload progress, so callers should
|
||||
* show an indeterminate state rather than a percentage.
|
||||
*/
|
||||
upload: <T>(path: string, body: FormData, signal?: AbortSignal) =>
|
||||
request<T>(path, { method: 'POST', body, signal }),
|
||||
put: <T>(path: string, body?: unknown) => request<T>(path, { method: 'PUT', body }),
|
||||
patch: <T>(path: string, body?: unknown) => request<T>(path, { method: 'PATCH', body }),
|
||||
delete: <T>(path: string, body?: unknown) => request<T>(path, { method: 'DELETE', body }),
|
||||
|
||||
71
src/api/modules/resume.ts
Normal file
71
src/api/modules/resume.ts
Normal file
@ -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<PaginatedResponse<ResumeImportBatch>>(BASE, params)
|
||||
}
|
||||
|
||||
export function fetchImportBatch(id: string, signal?: AbortSignal) {
|
||||
return api.get<ApiResponse<ResumeImportBatch>>(`${BASE}/${id}`, undefined, signal)
|
||||
}
|
||||
|
||||
export function fetchImportBatchItems(id: string, params: ListParams) {
|
||||
return api.get<PaginatedResponse<ResumeImportItem>>(`${BASE}/${id}/items`, params)
|
||||
}
|
||||
|
||||
export function fetchImportBatchItem(batchId: string, itemId: string) {
|
||||
return api.get<ApiResponse<ResumeImportItem>>(`${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<ApiResponse<ResumeImportBatch>>(BASE, form)
|
||||
}
|
||||
|
||||
/** Turn a dry run into real accounts. Reuses the stored results — nothing re-parses. */
|
||||
export function confirmImportBatch(id: string) {
|
||||
return api.post<ApiResponse<ResumeImportBatch>>(`${BASE}/${id}/confirm`)
|
||||
}
|
||||
|
||||
export function retryImportBatch(id: string) {
|
||||
return api.post<ApiResponse<{ requeued: number }>>(`${BASE}/${id}/retry`)
|
||||
}
|
||||
|
||||
/** Resumes from whichever step failed; already-extracted text is reused. */
|
||||
export function retryImportItem(batchId: string, itemId: string) {
|
||||
return api.post<ApiResponse<ResumeImportItem>>(`${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<ApiResponse<ResumeImportItem>>(`${BASE}/${batchId}/items/${itemId}`, contact)
|
||||
}
|
||||
1241
src/api/types.gen.ts
1241
src/api/types.gen.ts
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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: [
|
||||
|
||||
273
src/features/resume/batch-detail-sheet.tsx
Normal file
273
src/features/resume/batch-detail-sheet.tsx
Normal file
@ -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<string>('all')
|
||||
const [resolving, setResolving] = useState<ResumeImportItem | null>(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<unknown>, 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 (
|
||||
<>
|
||||
<Sheet open={batchId !== null} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="w-full overflow-y-auto sm:max-w-3xl">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="break-all">
|
||||
{batch?.archive.filename ?? '导入批次'}
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
<code className="font-mono text-xs">{batchId}</code>
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex flex-col gap-5 px-4 pb-10">
|
||||
{batch && (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<BatchStatusBadge status={batch.status} />
|
||||
{batch.dryRun && (
|
||||
<Badge className="bg-blue-500/12 text-blue-700 dark:text-blue-400">
|
||||
试运行
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{/* 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}`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{batch.error && (
|
||||
<div className="rounded-md bg-red-500/10 p-3 text-sm text-red-700 dark:text-red-400">
|
||||
{batch.error.message}
|
||||
<code className="ml-2 font-mono text-xs opacity-70">{batch.error.code}</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{countEntries.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{countEntries.map(([key, value]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => setOutcome(key)}
|
||||
className="rounded-md border px-2.5 py-1.5 text-left text-xs hover:bg-accent"
|
||||
>
|
||||
<span className="block text-muted-foreground">
|
||||
{OUTCOME_LABELS[key] ?? key}
|
||||
</span>
|
||||
<span className="text-base font-medium">{value}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canManage && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{batch.dryRun && batch.status === 'completed' && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
act(() => confirmImportBatch(batch.id), '已确认,正在创建用户')
|
||||
}
|
||||
>
|
||||
确认并创建用户
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
act(async () => {
|
||||
const res = await retryImportBatch(batch.id)
|
||||
if (res.data.requeued === 0) throw new Error('no rows')
|
||||
}, '已重新排队')
|
||||
}
|
||||
>
|
||||
重试失败项
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium">简历明细</span>
|
||||
<Select value={outcome} onValueChange={setOutcome}>
|
||||
<SelectTrigger size="sm" className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部结果</SelectItem>
|
||||
{Object.entries(OUTCOME_LABELS).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{items.length === 0 && (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||
{itemsQuery.isPending ? '加载中…' : '暂无记录'}
|
||||
</p>
|
||||
)}
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="rounded-md border p-3 text-sm">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium" title={item.document.filename ?? ''}>
|
||||
{item.document.filename ?? '—'}
|
||||
</div>
|
||||
{item.document.archiveEntry &&
|
||||
item.document.archiveEntry !== item.document.filename && (
|
||||
<code className="block truncate font-mono text-xs text-muted-foreground">
|
||||
{item.document.archiveEntry}
|
||||
</code>
|
||||
)}
|
||||
</div>
|
||||
<OutcomeBadge outcome={item.outcome} />
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>
|
||||
提取:{stepLabel(item.steps.extraction)} · 解析:
|
||||
{stepLabel(item.steps.structure)}
|
||||
</span>
|
||||
{item.matchedUserUid && (
|
||||
<span>
|
||||
用户 <code className="font-mono">{item.matchedUserUid}</code>
|
||||
</span>
|
||||
)}
|
||||
<span>{formatTime(item.finishedAt ?? item.createdAt)}</span>
|
||||
</div>
|
||||
|
||||
{item.error && (
|
||||
<p className="mt-2 text-xs text-red-700 dark:text-red-400">
|
||||
{item.error.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{canManage && (
|
||||
<div className="mt-2 flex gap-2">
|
||||
{item.outcome === 'needs_contact' && (
|
||||
<Button size="sm" variant="outline" onClick={() => setResolving(item)}>
|
||||
补充联系方式
|
||||
</Button>
|
||||
)}
|
||||
{(item.outcome === 'failed' || item.outcome === 'parse_failed') && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
act(() => retryImportItem(batch.id, item.id), '已重新排队')
|
||||
}
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<ResolveContactDialog
|
||||
batchId={batchId}
|
||||
item={resolving}
|
||||
onClose={() => setResolving(null)}
|
||||
onResolved={refresh}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
88
src/features/resume/labels.tsx
Normal file
88
src/features/resume/labels.tsx
Normal file
@ -0,0 +1,88 @@
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import type {
|
||||
ResumeImportBatchStatus,
|
||||
ResumeImportOutcome,
|
||||
ResumeImportStepStatus,
|
||||
} from '@/api/types'
|
||||
|
||||
export const BATCH_STATUS_LABELS: Record<ResumeImportBatchStatus, string> = {
|
||||
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<ResumeImportOutcome, string> = {
|
||||
needs_contact: '缺少联系方式',
|
||||
failed: '建号失败',
|
||||
parse_failed: '解析失败',
|
||||
unsupported_entry: '不支持的文件',
|
||||
entry_too_large: '文件过大',
|
||||
created: '已创建',
|
||||
would_create: '将会创建',
|
||||
skipped_duplicate: '已存在,跳过',
|
||||
reused_import: '重复简历',
|
||||
duplicate_file: '重复文件',
|
||||
}
|
||||
|
||||
const OUTCOME_TONE: Record<ResumeImportOutcome, string> = {
|
||||
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<ResumeImportBatchStatus, string> = {
|
||||
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<ResumeImportStepStatus, string> = {
|
||||
pending: '待处理',
|
||||
processing: '进行中',
|
||||
completed: '已完成',
|
||||
cached: '命中缓存',
|
||||
reused: '复用结果',
|
||||
skipped: '已跳过',
|
||||
failed: '失败',
|
||||
}
|
||||
|
||||
export function BatchStatusBadge({ status }: { status: ResumeImportBatchStatus }) {
|
||||
return <Badge className={BATCH_STATUS_TONE[status]}>{BATCH_STATUS_LABELS[status]}</Badge>
|
||||
}
|
||||
|
||||
export function OutcomeBadge({ outcome }: { outcome: ResumeImportOutcome | null }) {
|
||||
if (!outcome) return <span className="text-muted-foreground">处理中</span>
|
||||
return <Badge className={OUTCOME_TONE[outcome]}>{OUTCOME_LABELS[outcome]}</Badge>
|
||||
}
|
||||
|
||||
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`
|
||||
}
|
||||
120
src/features/resume/resolve-contact-dialog.tsx
Normal file
120
src/features/resume/resolve-contact-dialog.tsx
Normal file
@ -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<string | null>(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 (
|
||||
<Dialog open={item !== null} onOpenChange={(next) => !next && onClose()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>补充联系方式</DialogTitle>
|
||||
<DialogDescription>
|
||||
{item?.document.filename} 未能解析出手机号或邮箱。填写其一即可创建账号;
|
||||
简历不会重新解析。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="phone">手机号</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
placeholder="13800138000"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
缺少国际区号时按批次的国家代码解析,统一存储为 E.164 格式。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="email">邮箱</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="zhang@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{conflict && (
|
||||
<div className="rounded-md bg-amber-500/10 p-3 text-sm text-amber-700 dark:text-amber-400">
|
||||
{conflict}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={submitting}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={submitting || (!phone.trim() && !email.trim())}>
|
||||
{submitting ? '提交中…' : '创建账号'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
139
src/features/resume/upload-dialog.tsx
Normal file
139
src/features/resume/upload-dialog.tsx
Normal file
@ -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<File | null>(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<HTMLInputElement>(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 (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
setOpen(next)
|
||||
if (!next) reset()
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm">上传简历压缩包</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>批量导入简历</DialogTitle>
|
||||
<DialogDescription>
|
||||
上传一个包含多份简历的 ZIP 压缩包。系统会逐份解析并创建用户账号(未激活,待本人通过验证码认领)。
|
||||
同一个手机号只会对应一个用户,已存在的账号不会被修改。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="archive">压缩包</Label>
|
||||
<Input
|
||||
id="archive"
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".zip,application/zip"
|
||||
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
{file && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{file.name} · {formatBytes(file.size)}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
支持 PDF / DOCX / 图片 / 纯文本简历,单个文件不超过 10MB。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="country">默认国家代码</Label>
|
||||
<Input
|
||||
id="country"
|
||||
value={countryCode}
|
||||
maxLength={3}
|
||||
onChange={(e) => setCountryCode(e.target.value.toUpperCase())}
|
||||
placeholder="CHN"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
ISO 3166-1 三位国家代码,用于解析简历中缺少国际区号的手机号。
|
||||
缺少此信息的手机号不会被猜测,而是标记为「缺少联系方式」。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label className="flex items-start gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={dryRun}
|
||||
onCheckedChange={(checked) => setDryRun(checked === true)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
试运行
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
先解析并统计结果,不创建任何用户;确认无误后再一键建号(不会重复解析)。
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)} disabled={submitting}>
|
||||
取消
|
||||
</Button>
|
||||
{/* fetch reports no upload progress, so this stays indeterminate */}
|
||||
<Button onClick={submit} disabled={!file || submitting}>
|
||||
{submitting ? '上传中…' : '开始导入'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@ -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,
|
||||
|
||||
141
src/routes/_authed/resume/import-batches.tsx
Normal file
141
src/routes/_authed/resume/import-batches.tsx
Normal file
@ -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