feat(resume): admin bulk resume import

Adds the dashboard surface for the backend's /api/admin/v1/resume/import-batches
endpoints, and the multipart support it needed.

Client:
- rawRequest passes a FormData body through untouched and skips Content-Type,
  so the browser sets multipart/form-data *with the boundary*. Setting it by
  hand stripped the boundary and the server saw an empty upload — verified
  against the backend: 202 with the fix, 422 VALIDATION_ERROR without it.
  This was the first admin endpoint in the codebase to accept a file.
- New api.upload(). fetch reports no upload progress, so upload UI stays
  indeterminate rather than faking a percentage.

UI (/resume/import-batches, gated on admin:resume:read; writes on :manage):
- Upload dialog with countryCode and dry-run checked by default — seeing the
  collision report before creating accounts is almost always what you want.
- Detail sheet polls every 2s and stops once the batch is terminal. Clickable
  count tiles filter the per-resume report; step statuses surface `命中缓存` /
  `复用结果` so the dedup is visible.
- Confirm a dry run, retry failed rows, and resolve a missing contact.
  IMPORT_IDENTITY_TAKEN is shown inline — a collision is expected input, not
  an error toast.
- progress.total is 0 until unpacking finishes, so both views render 准备中…
  rather than "0 / 0", which would read as an empty batch.

types.gen.ts regenerated from the backend's admin spec.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-24 23:21:15 +08:00
parent 2c6fed7149
commit 78c28ed1bd
14 changed files with 2243 additions and 13 deletions

View File

@ -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(

View File

@ -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
View 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)
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -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

View File

@ -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: [

View 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}
/>
</>
)
}

View 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`
}

View 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>
)
}

View 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>
)
}

View File

@ -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,

View 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>
)
}