generated from kgod/ai-review-template
feat: start resume sessions in new flow
This commit is contained in:
@@ -5,7 +5,6 @@ import AppHeader from './components/AppHeader.vue'
|
||||
import ComposerBar from './components/ComposerBar.vue'
|
||||
import EditResumePreview from './components/EditResumePreview.vue'
|
||||
import FeatureNavigation from './components/FeatureNavigation.vue'
|
||||
import ResumeImportPanel from './components/ResumeImportPanel.vue'
|
||||
import { useResumeAgent } from './composables/useResumeAgent'
|
||||
import { useResumeDocument } from './composables/useResumeDocument'
|
||||
|
||||
@@ -52,7 +51,6 @@ const remoteRefreshBlocked = computed(
|
||||
!sessionId.value ||
|
||||
isBusy.value ||
|
||||
Boolean(resumeDocument.busyEntryId.value) ||
|
||||
resumeDocument.importBusy.value ||
|
||||
resumeDocument.skillsBusy.value ||
|
||||
resumeDocument.summaryBusy.value,
|
||||
)
|
||||
@@ -94,8 +92,6 @@ function handleVisibilityChange() {
|
||||
const stageLabels: Record<string, string> = {
|
||||
starting: '准备会话',
|
||||
PRIVACY_CONSENT: '隐私确认',
|
||||
RESUME_SOURCE_SELECT: '选择创建方式',
|
||||
RESUME_IMPORT_UPLOAD: '导入简历',
|
||||
PHONE_SELECTION: '手机号授权',
|
||||
MANUAL_PHONE_INPUT: '填写手机号',
|
||||
PERSONAL_INFO: '基本信息',
|
||||
@@ -118,10 +114,6 @@ watch(sessionId, (value, previous) => {
|
||||
if (!value || value !== previous) void resumeDocument.restoreOptimizationRuns()
|
||||
})
|
||||
|
||||
watch(() => resumeDocument.resumeImport.value?.status, (status) => {
|
||||
if (status === 'applied') void refreshTimeline()
|
||||
})
|
||||
|
||||
watch(remoteRefreshBlocked, (blocked) => {
|
||||
if (blocked) cancelRemoteRefresh()
|
||||
}, { flush: 'sync' })
|
||||
@@ -248,12 +240,6 @@ onBeforeUnmount(() => {
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<ResumeImportPanel
|
||||
v-if="stage === 'RESUME_IMPORT_UPLOAD'"
|
||||
:document="resumeDocument"
|
||||
:disabled="!sessionId || initializing"
|
||||
/>
|
||||
|
||||
<AgentTimeline
|
||||
:timeline="timeline"
|
||||
:initializing="initializing"
|
||||
|
||||
@@ -3,7 +3,6 @@ import type {
|
||||
ComponentEventInput,
|
||||
MessageInput,
|
||||
ResumeAgentEnvelope,
|
||||
ResumeImportView,
|
||||
OptimizationRunView,
|
||||
ResumePatchOperationInput,
|
||||
SkillRecommendationCandidate,
|
||||
@@ -186,41 +185,6 @@ export const resumeAgentApi = {
|
||||
})
|
||||
},
|
||||
|
||||
uploadResumeImport(sessionId: string, file: File, signal?: AbortSignal) {
|
||||
const form = new FormData()
|
||||
form.append("file", file)
|
||||
return request<ResumeImportView>(sessionPath(sessionId, "/resume-imports"), {
|
||||
method: "POST",
|
||||
body: form,
|
||||
signal,
|
||||
})
|
||||
},
|
||||
|
||||
getResumeImport(sessionId: string, importId: string, signal?: AbortSignal) {
|
||||
return request<ResumeImportView>(
|
||||
sessionPath(sessionId, `/resume-imports/${encodeURIComponent(importId)}`),
|
||||
{ signal },
|
||||
)
|
||||
},
|
||||
|
||||
applyResumeImport(
|
||||
sessionId: string,
|
||||
importId: string,
|
||||
expectedRevision: number,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
return request<ResumeAgentEnvelope>(
|
||||
sessionPath(sessionId, `/resume-imports/${encodeURIComponent(importId)}/apply`),
|
||||
{ method: "POST", body: JSON.stringify({ expected_revision: expectedRevision }), signal },
|
||||
)
|
||||
},
|
||||
|
||||
cancelResumeImport(sessionId: string, importId: string, signal?: AbortSignal) {
|
||||
return request<ResumeImportView>(
|
||||
sessionPath(sessionId, `/resume-imports/${encodeURIComponent(importId)}`),
|
||||
{ method: "DELETE", signal },
|
||||
)
|
||||
},
|
||||
deleteSession(sessionId: string, signal?: AbortSignal) {
|
||||
return request<Record<string, unknown>>(sessionPath(sessionId), {
|
||||
method: 'DELETE',
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
import type { useResumeDocument } from '../composables/useResumeDocument'
|
||||
|
||||
const props = defineProps<{
|
||||
document: ReturnType<typeof useResumeDocument>
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const input = ref<HTMLInputElement | null>(null)
|
||||
const selectedName = ref('')
|
||||
const accepted = '.pdf,.docx,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
||||
const reviewCount = computed(() => props.document.resumeImport.value?.field_reviews.length ?? 0)
|
||||
const importStatus = computed(() => props.document.resumeImport.value?.status)
|
||||
const hasContent = computed(() => {
|
||||
const content = props.document.resume.value?.content
|
||||
if (!content) return false
|
||||
if (String(content.basics?.name || '').trim()) return true
|
||||
if ((content.skill_groups || []).length) return true
|
||||
return (content.sections || []).some((section) => (section.items || []).length > 0)
|
||||
})
|
||||
const cannotImport = computed(() => Boolean(props.disabled || hasContent.value || props.document.importBusy.value))
|
||||
|
||||
function selectFile() {
|
||||
if (!cannotImport.value) input.value?.click()
|
||||
}
|
||||
|
||||
function onFileChange(event: Event) {
|
||||
const file = (event.target as HTMLInputElement).files?.[0]
|
||||
if (!file || cannotImport.value) return
|
||||
selectedName.value = file.name
|
||||
void props.document.uploadImport(file)
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selectedName.value = ''
|
||||
if (input.value) input.value.value = ''
|
||||
}
|
||||
|
||||
async function cancel() {
|
||||
await props.document.cancelImport()
|
||||
clearSelection()
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
// The panel only lives during RESUME_IMPORT_UPLOAD. When it unmounts (stage
|
||||
// advanced or 重新开始 reset the session) the import view must not leak into
|
||||
// the next session — a stale "导入完成" card blocks selecting a new file.
|
||||
props.document.resumeImport.value = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="resume-import" aria-label="简历导入">
|
||||
<input
|
||||
ref="input"
|
||||
class="resume-import__input"
|
||||
type="file"
|
||||
:accept="accepted"
|
||||
:disabled="cannotImport"
|
||||
@change="onFileChange"
|
||||
/>
|
||||
|
||||
<template v-if="!document.resumeImport.value || importStatus === 'cancelled'">
|
||||
<div class="resume-import__copy">
|
||||
<p>简历导入</p>
|
||||
<h2>导入已有简历</h2>
|
||||
<span>支持 PDF / DOCX,不超过 10 MB</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="resume-import__select"
|
||||
:disabled="cannotImport"
|
||||
@click="selectFile"
|
||||
>
|
||||
{{ document.importBusy.value ? '解析中...' : '选择文件' }}
|
||||
</button>
|
||||
<small v-if="hasContent">简历预览已有内容,如需导入请先从头部重新开始。</small>
|
||||
<small v-else-if="selectedName">{{ selectedName }}</small>
|
||||
</template>
|
||||
|
||||
<template v-else-if="importStatus === 'awaiting_review'">
|
||||
<div class="resume-import__copy">
|
||||
<p>导入预览</p>
|
||||
<h2>{{ document.resumeImport.value.file_name }}</h2>
|
||||
<span>已解析出 {{ reviewCount }} 个字段,确认后应用到简历</span>
|
||||
</div>
|
||||
<div class="resume-import__actions">
|
||||
<button
|
||||
type="button"
|
||||
class="resume-import__button resume-import__button--primary"
|
||||
:disabled="document.importBusy.value"
|
||||
@click="document.applyImport"
|
||||
>
|
||||
{{ document.importBusy.value ? '应用中...' : '应用到简历' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="resume-import__button"
|
||||
:disabled="document.importBusy.value"
|
||||
@click="cancel"
|
||||
>
|
||||
放弃导入
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="importStatus === 'applied'">
|
||||
<div class="resume-import__copy">
|
||||
<p>导入完成</p>
|
||||
<h2>{{ document.resumeImport.value.file_name }}</h2>
|
||||
<span>导入内容已进入右侧简历预览。</span>
|
||||
</div>
|
||||
<button type="button" class="resume-import__button" @click="clearSelection">
|
||||
完成
|
||||
</button>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.resume-import {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin: 0 0 20px 59px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 8px;
|
||||
background: #f9fdfc;
|
||||
}
|
||||
|
||||
.resume-import__input { display: none; }
|
||||
.resume-import__copy { display: grid; gap: 4px; min-width: 0; }
|
||||
.resume-import__copy p { margin: 0; color: var(--brand-dark); font-family: ui-monospace, Consolas, monospace; font-size: 9px; font-weight: 800; }
|
||||
.resume-import__copy h2 { margin: 0; overflow-wrap: anywhere; color: var(--ink); font-size: 14px; line-height: 1.35; }
|
||||
.resume-import__copy span, .resume-import small { color: var(--ink-faint); font-size: 11px; line-height: 1.45; }
|
||||
.resume-import__select, .resume-import__button { min-height: 34px; width: fit-content; padding: 0 10px; border: 1px solid var(--line-strong); border-radius: 6px; color: var(--ink-soft); background: #fff; font-size: 11px; font-weight: 750; }
|
||||
.resume-import__select:hover:not(:disabled), .resume-import__button:hover:not(:disabled) { border-color: #8fc4c1; color: var(--ink); background: var(--surface-muted); }
|
||||
.resume-import__select:disabled, .resume-import__button:disabled { opacity: .55; }
|
||||
.resume-import__actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.resume-import__button--primary { color: #fff; border-color: #1c858a; background: #1c858a; }
|
||||
.resume-import__button--primary:hover:not(:disabled) { color: #fff; border-color: #146e73; background: #146e73; }
|
||||
|
||||
@media (max-width: 760px) { .resume-import { margin-left: 38px; } }
|
||||
</style>
|
||||
@@ -143,6 +143,20 @@ function normalizeBlock(raw: RawTimelineBlock, index: number): TimelineBlock {
|
||||
}
|
||||
}
|
||||
|
||||
function isRemovedResumeSourceBlock(block: TimelineBlock): boolean {
|
||||
if (block.type === 'text') {
|
||||
return ['请选择开始方式。', '请选择需要导入的 PDF 或 DOCX 简历。'].includes(block.text || '')
|
||||
}
|
||||
if (block.type !== 'component' || block.component !== 'choice_chips') return false
|
||||
const options = Array.isArray(block.data.options) ? block.data.options : []
|
||||
const values = new Set(
|
||||
options
|
||||
.map((option) => asString(asRecord(option).value))
|
||||
.filter((value): value is string => Boolean(value)),
|
||||
)
|
||||
return values.has('import') && values.has('manual')
|
||||
}
|
||||
|
||||
function normalizeComposer(envelope: ResumeAgentEnvelope, turns: unknown): ComposerConfig {
|
||||
const timelineRecord = asRecord(envelope.timeline)
|
||||
const gate = asRecord(envelope.gate)
|
||||
@@ -205,7 +219,7 @@ export function normalizeResumeAgentResponse(response: ResumeAgentEnvelope): Nor
|
||||
: typeof latestTurn.sequence === 'number'
|
||||
? latestTurn.sequence
|
||||
: 0,
|
||||
timeline: rawBlocks.map(normalizeBlock),
|
||||
timeline: rawBlocks.map(normalizeBlock).filter((block) => !isRemovedResumeSourceBlock(block)),
|
||||
composer: normalizeComposer(envelope, rawTimeline),
|
||||
missingFields: Array.isArray(envelope.missing_fields)
|
||||
? envelope.missing_fields.filter((item): item is string => typeof item === 'string')
|
||||
|
||||
@@ -3,7 +3,6 @@ import { ResumeAgentApiError, resumeAgentApi } from '../api/resumeAgent'
|
||||
import type {
|
||||
OptimizationRunView,
|
||||
ResumeAgentEnvelope,
|
||||
ResumeImportView,
|
||||
ResumePatchOperationInput,
|
||||
ResumeView,
|
||||
SkillRecommendationCandidate,
|
||||
@@ -20,8 +19,6 @@ export function useResumeDocument(sessionId: Ref<string>) {
|
||||
const resume = ref<ResumeView | null>(null)
|
||||
const busyEntryId = ref('')
|
||||
const errorMessage = ref('')
|
||||
const resumeImport = ref<ResumeImportView | null>(null)
|
||||
const importBusy = ref(false)
|
||||
const optimizationRuns = ref<Record<string, OptimizationRunView>>({})
|
||||
const skillCandidates = ref<SkillRecommendationCandidate[]>([])
|
||||
const skillsBusy = ref(false)
|
||||
@@ -49,9 +46,6 @@ export function useResumeDocument(sessionId: Ref<string>) {
|
||||
if (error.status === 403 && error.payload?.error?.code === 'deep_requires_vip') {
|
||||
return '深度优化为 VIP 功能,升级后可继续进行多轮追问与改写。'
|
||||
}
|
||||
if (error.payload?.error?.code === 'resume_import_not_allowed') {
|
||||
return '简历预览已有内容,如需导入请先从头部重新开始。'
|
||||
}
|
||||
return error.message
|
||||
}
|
||||
if (error instanceof Error && error.message) return error.message
|
||||
@@ -189,62 +183,10 @@ export function useResumeDocument(sessionId: Ref<string>) {
|
||||
summaryBusy.value = false
|
||||
}
|
||||
}
|
||||
async function uploadImport(file: File) {
|
||||
if (!sessionId.value || importBusy.value) return
|
||||
if (resume.value) {
|
||||
errorMessage.value = '简历预览已有内容,如需导入请先从头部重新开始。'
|
||||
return
|
||||
}
|
||||
importBusy.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
resumeImport.value = await resumeAgentApi.uploadResumeImport(sessionId.value, file)
|
||||
} catch (error) {
|
||||
errorMessage.value = formatError(error)
|
||||
} finally {
|
||||
importBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function applyImport() {
|
||||
if (!sessionId.value || !resumeImport.value || importBusy.value) return
|
||||
importBusy.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
const response = await resumeAgentApi.applyResumeImport(
|
||||
sessionId.value,
|
||||
resumeImport.value.id,
|
||||
resume.value?.revision ?? 0,
|
||||
)
|
||||
syncFrom(response)
|
||||
resumeImport.value = { ...resumeImport.value, status: 'applied' }
|
||||
} catch (error) {
|
||||
errorMessage.value = formatError(error)
|
||||
} finally {
|
||||
importBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelImport() {
|
||||
if (!sessionId.value || !resumeImport.value || importBusy.value) return
|
||||
importBusy.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
await resumeAgentApi.cancelResumeImport(sessionId.value, resumeImport.value.id)
|
||||
resumeImport.value = null
|
||||
} catch (error) {
|
||||
errorMessage.value = formatError(error)
|
||||
} finally {
|
||||
importBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
resume,
|
||||
busyEntryId,
|
||||
errorMessage,
|
||||
resumeImport,
|
||||
importBusy,
|
||||
optimizationRuns,
|
||||
skillCandidates,
|
||||
skillsBusy,
|
||||
@@ -253,9 +195,6 @@ export function useResumeDocument(sessionId: Ref<string>) {
|
||||
syncFrom,
|
||||
setTargetPosition,
|
||||
restoreOptimizationRuns,
|
||||
uploadImport,
|
||||
applyImport,
|
||||
cancelImport,
|
||||
updateBasics: (fields: Record<string, string>) => patch({ type: 'update_basics', fields }),
|
||||
updateSkillGroups: (skills: string[]) => patch({ type: 'update_skill_groups', skills }, 'skills'),
|
||||
updateProfileSummary: (content: string) =>
|
||||
@@ -304,4 +243,3 @@ export function useResumeDocument(sessionId: Ref<string>) {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -277,34 +277,6 @@ export interface ResumeDocument {
|
||||
} | null
|
||||
}
|
||||
|
||||
export interface ResumeImportEvidence {
|
||||
page?: number | null
|
||||
paragraph?: number | null
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface ResumeImportFieldReview {
|
||||
field_path: string
|
||||
value: unknown
|
||||
confidence: number
|
||||
status: "needs_review" | "verified"
|
||||
evidence: ResumeImportEvidence[]
|
||||
}
|
||||
|
||||
export interface ResumeImportView {
|
||||
id: string
|
||||
session_id: string
|
||||
file_name: string
|
||||
mime_type: string
|
||||
size_bytes: number
|
||||
sha256: string
|
||||
status: "awaiting_review" | "applied" | "failed" | "cancelled"
|
||||
document: ResumeDocument | null
|
||||
field_reviews: ResumeImportFieldReview[]
|
||||
error_code?: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
export interface SkillRecommendationCandidate {
|
||||
skill: string
|
||||
category: string
|
||||
@@ -339,4 +311,4 @@ export interface BuilderStreamEvent {
|
||||
message?: string
|
||||
status_code?: number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user