Files
resume-agent/frontend/src/components/ResumeEntryCard.vue
T
hypandClaude ae2d9b128d feat: builder 简历生成 + 轻度优化 + 简历导入交付副本
自内部仓库剥离深度优化与 RAG 知识库后的交付版本:
- Builder 对话式简历生成(FSM + 意图路由 LLM 兜底增强)
- 条目级轻度优化:事实覆盖门禁 + STAR/bullet 修复链,功能/简介/成果与技术栈同级保护
- 简历导入:DOCX/PDF 解析、结构归一、手机号脱敏
- PostgreSQL 运行时 + Alembic 迁移链

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-05 11:08:30 +08:00

166 lines
13 KiB
Vue

<script setup lang="ts">
import { computed, ref } from 'vue'
import type { OptimizationRunView, ResumeEntry } from '../types/resumeAgent'
const props = withDefaults(
defineProps<{
entry: ResumeEntry
kind: string
busy: boolean
optimizationRun?: OptimizationRunView
mode?: 'edit' | 'light'
}>(),
{ mode: 'edit' },
)
const emit = defineEmits<{
updateEntry: [entryId: string, fields: Record<string, string>]
deleteEntry: [entryId: string]
optimizeLight: [entryId: string]
confirmOptimization: [entryId: string]
rejectOptimization: [entryId: string]
undoOptimize: [entryId: string]
}>()
const DISPLAY_FIELDS: Record<string, Array<[string, string]>> = {
education: [['school', '学校'], ['major', '专业'], ['degree', '学历'], ['start_date', '开始时间'], ['end_date_or_present', '结束时间'], ['description', '经历描述']],
work_experience: [['company', '公司'], ['position', '职位'], ['start_date', '开始时间'], ['end_date_or_present', '结束时间'], ['description', '经历描述']],
internship_experience: [['company', '公司'], ['position', '职位'], ['start_date', '开始时间'], ['end_date_or_present', '结束时间'], ['description', '经历描述']],
project_experience: [['project_name', '项目名称'], ['project_role', '项目角色'], ['start_date', '开始时间'], ['end_date_or_present', '结束时间'], ['description', '经历描述']],
campus_experience: [['organization', '组织'], ['role', '角色'], ['start_date', '开始时间'], ['end_date_or_present', '结束时间'], ['description', '经历描述']],
competition: [['name', '竞赛名称'], ['award', '获奖情况'], ['date', '获奖时间'], ['description', '经历描述']],
additional_experience: [['organization', '组织'], ['role', '角色'], ['description', '经历描述']],
skills: [['value', '内容']], certificates: [['value', '内容']],
}
const FALLBACK_DISPLAY_FIELDS: Array<[string, string]> = [['title', '标题'], ['organization', '组织'], ['role', '角色'], ['description', '经历描述']]
const DATE_KEYS = new Set(['start_date', 'end_date_or_present', 'date'])
const editing = ref(false)
const draft = ref<Record<string, string>>({})
const visibleFields = computed(() => (DISPLAY_FIELDS[props.kind] || FALLBACK_DISPLAY_FIELDS).filter(([key]) => Object.prototype.hasOwnProperty.call(props.entry, key)).map(([key, label]) => ({ key, label, value: String(props.entry[key] ?? '') })))
const title = computed(() => {
const keysByKind: Record<string, string[]> = { education: ['school'], work_experience: ['position', 'company'], internship_experience: ['position', 'company'], project_experience: ['project_name'], campus_experience: ['role', 'organization'], competition: ['name'], additional_experience: ['role', 'organization', 'title'], skills: ['value'], certificates: ['value'] }
const key = (keysByKind[props.kind] || ['title', 'value']).find((candidate) => String(props.entry[candidate] ?? '').trim())
return key ? String(props.entry[key]) : '简历条目'
})
const run = computed(() => props.optimizationRun?.mode === props.mode ? props.optimizationRun : undefined)
const proposal = computed(() => run.value?.proposal)
const canUndo = computed(() => props.mode === 'edit' && Boolean(props.entry.previous_version))
const hasOtherActiveRun = computed(() => Boolean(props.optimizationRun && !run.value))
const originalDescription = computed(() => String(props.entry.description || '').trim())
const provenanceLabel = computed(() => ({ user_provided: '原始内容', user_edited: '手动编辑', ai_expanded: 'AI 优化', rule_polish: '结构化草稿' }[String(props.entry.provenance || '')] || '简历内容'))
const proposalPending = computed(() => run.value?.status === 'proposal_pending')
const supplementalSuggestions = computed(() => {
const labels: Record<string, string> = {
'verifiable result or impact': '\u53ef\u9a8c\u8bc1\u7684\u7ed3\u679c\u6216\u5b9e\u9645\u5f71\u54cd',
'method, tool, or collaboration approach': '\u6240\u7528\u65b9\u6cd5\u3001\u5de5\u5177\u6216\u534f\u4f5c\u65b9\u5f0f',
'scope of responsibility': '\u5177\u4f53\u804c\u8d23\u4e0e\u4ea4\u4ed8\u8303\u56f4',
}
const source = [
...(proposal.value?.optional_enhancements || []),
...(proposal.value?.unconfirmed_suggestions || []),
...(proposal.value?.missing_facts || []).map((item) => labels[item] || item),
]
const known = new Set<string>()
return source.filter((item) => {
const normalized = item.trim().toLocaleLowerCase()
if (!normalized || known.has(normalized)) return false
known.add(normalized)
return true
})
})
const suggestionsHeading = computed(() => '\u8fd8\u53ef\u8865\u5145\u5e76\u786e\u8ba4')
function formatBullets(value: string) { return value.replace(/•/g, '\n•').replace(/^\n/, '') }
function displayValue(key: string, value: string) {
if (key === 'end_date_or_present' && value === 'present') return '至今'
const text = value || '未填写'
return key === 'description' && value ? formatBullets(text) : text
}
function inputType(key: string) { return DATE_KEYS.has(key) ? 'month' : 'text' }
function startEdit() { draft.value = Object.fromEntries(visibleFields.value.map((field) => [field.key, field.value])); editing.value = true }
function saveEdit() { const changed = Object.fromEntries(Object.entries(draft.value).map(([key, value]) => [key, value.trim()]).filter(([key, value]) => value !== String(props.entry[key] ?? '').trim())); editing.value = false; if (Object.keys(changed).length) emit('updateEntry', props.entry.id, changed) }
function removeEntry() { if (window.confirm('确定从简历中删除这条经历吗?')) emit('deleteEntry', props.entry.id) }
</script>
<template>
<article class="entry-card" :class="{ 'entry-card--busy': busy }" :aria-busy="busy">
<header class="entry-card__head">
<div class="entry-card__identity"><strong>{{ title }}</strong><span>{{ provenanceLabel }}</span></div>
<div class="entry-card__toolbar" aria-label="经历操作">
<button v-if="mode === 'edit'" type="button" class="action-button" :disabled="busy" @click="startEdit">编辑</button>
<button v-if="mode === 'light'" type="button" class="action-button action-button--primary" :disabled="busy || proposalPending || hasOtherActiveRun" title="整理已有事实并提取 STAR 结构,不追加追问。" @click="emit('optimizeLight', entry.id)">生成 STAR 优化稿</button>
<button v-if="canUndo" type="button" class="action-button" :disabled="busy" @click="emit('undoOptimize', entry.id)">撤销</button>
<button v-if="mode === 'edit'" type="button" class="action-button action-button--danger" :disabled="busy" @click="removeEntry">删除</button>
</div>
</header>
<form v-if="mode === 'edit' && editing" class="entry-card__editing" @submit.prevent="saveEdit">
<label v-for="field in visibleFields" :key="field.key"><span>{{ field.label }}</span><textarea v-if="field.key === 'description'" v-model="draft[field.key]" rows="4" /><input v-else v-model="draft[field.key]" :type="inputType(field.key)" /></label>
<div class="entry-card__form-actions"><button type="button" class="action-button" @click="editing = false">取消</button><button type="submit" class="action-button action-button--primary">保存</button></div>
</form>
<dl v-else-if="visibleFields.length" class="entry-card__fields"><template v-for="field in visibleFields" :key="field.key"><dt>{{ field.label }}</dt><dd>{{ displayValue(field.key, field.value) }}</dd></template></dl>
<section v-if="mode !== 'edit' && proposalPending && proposal" class="entry-card__proposal" aria-label="候选优化稿">
<header><strong>轻度优化候选稿</strong><span>{{ proposal.source === 'ai_expanded' ? 'AI 拓展' : '基于已有事实整理' }}</span></header>
<div class="entry-card__diff"><div><h4>原始描述</h4><p>{{ formatBullets(originalDescription) || '未填写经历描述。' }}</p></div><div class="entry-card__diff-proposal"><h4>候选优化稿</h4><p>{{ formatBullets(proposal.optimized_description) }}</p><section v-if="supplementalSuggestions.length" class="entry-card__suggestions"><h4>{{ suggestionsHeading }}</h4><ul><li v-for="suggestion in supplementalSuggestions" :key="suggestion">{{ suggestion }}</li></ul></section></div></div>
<div class="entry-card__proposal-actions"><button type="button" class="action-button" :disabled="busy" @click="emit('rejectOptimization', entry.id)">保留原文</button><button type="button" class="action-button action-button--primary" :disabled="busy" @click="emit('confirmOptimization', entry.id)">使用优化稿</button></div>
</section>
<div v-if="busy" class="entry-card__busy" role="status">更新中</div>
</article>
</template>
<style scoped>
.entry-card {
position: relative;
min-width: 0;
padding: 14px;
border: 1px solid #d6e5e2;
border-radius: 8px;
background: #fff;
box-shadow: 0 5px 18px rgba(35, 88, 86, 0.05);
}
.entry-card--busy > :not(.entry-card__busy) { opacity: 0.55; }
.entry-card__head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.entry-card__identity { display: grid; min-width: 0; gap: 3px; }
.entry-card__identity strong { overflow-wrap: anywhere; color: var(--ink); font-size: 14px; line-height: 1.4; }
.entry-card__identity span { color: var(--ink-faint); font-size: 10px; }
.entry-card__toolbar, .entry-card__form-actions, .entry-card__proposal-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 6px; }
.action-button { min-height: 31px; padding: 0 9px; border: 1px solid var(--line); border-radius: 6px; color: var(--ink-soft); background: #fff; font-size: 11px; font-weight: 700; }
.action-button:hover:not(:disabled) { color: var(--ink); border-color: var(--line-strong); background: var(--surface-muted); }
.action-button--primary { color: #fff; border-color: #1c858a; background: #1c858a; }
.action-button--primary:hover:not(:disabled) { color: #fff; border-color: #146e73; background: #146e73; }
.action-button--danger { color: #b84c42; }
.action-button:disabled { opacity: 0.55; }
.entry-card__fields { display: grid; grid-template-columns: minmax(72px, auto) minmax(0, 1fr); gap: 5px 12px; margin: 13px 0 0; font-size: 12px; line-height: 1.55; }
.entry-card__fields dt { color: var(--ink-faint); }
.entry-card__fields dd { min-width: 0; margin: 0; overflow-wrap: anywhere; color: var(--ink-soft); white-space: pre-wrap; }
.entry-card__editing { display: grid; gap: 9px; margin-top: 13px; }
.entry-card__editing label { display: grid; gap: 5px; color: var(--ink-soft); font-size: 11px; font-weight: 700; }
.entry-card__editing input, .entry-card__editing textarea { width: 100%; min-width: 0; border: 1px solid var(--line-strong); border-radius: 6px; color: var(--ink); background: #fbfefd; }
.entry-card__editing input { min-height: 36px; padding: 0 10px; }
.entry-card__editing textarea { padding: 9px 10px; line-height: 1.5; resize: vertical; }
.entry-card__proposal > header { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.entry-card__proposal { margin-top: 14px; padding: 12px 0 0 12px; border-top: 1px solid #dcebd6; border-left: 3px solid #7cad63; }
.entry-card__proposal > header strong { color: #315c37; font-size: 12px; }
.entry-card__proposal > header span { color: #587b5c; font-size: 10px; }
.entry-card__diff { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin-top: 10px; }
.entry-card__diff > div { min-width: 0; }
.entry-card__diff-proposal { padding-left: 12px; border-left: 1px solid #cfe2c7; }
.entry-card__diff h4 { margin: 0 0 6px; color: var(--ink-faint); font-size: 10px; font-weight: 800; }
.entry-card__diff p { margin: 0; overflow-wrap: anywhere; color: var(--ink-soft); font-size: 11px; line-height: 1.65; white-space: pre-wrap; }
.entry-card__changes { display: grid; gap: 4px; margin: 8px 0 0; padding-left: 16px; color: #587b5c; font-size: 10px; line-height: 1.5; }
.entry-card__missing { margin-top: 8px !important; color: #766131 !important; }
.entry-card__suggestions { margin-top: 9px; padding: 8px 10px; border: 1px dashed #c9d9b8; border-radius: 6px; background: #fbfdf6; }
.entry-card__suggestions h4 { margin: 0 0 5px; color: #587b5c; }
.entry-card__suggestions ul { display: grid; gap: 3px; margin: 0; padding-left: 16px; color: var(--ink-soft); font-size: 10px; line-height: 1.5; }
.entry-card__proposal-actions { margin-top: 11px; }
.entry-card__busy { position: absolute; top: 11px; right: 11px; padding: 4px 7px; border-radius: 6px; color: #fff; background: #31565b; font-size: 10px; font-weight: 700; }
@media (max-width: 520px) {
.entry-card__head { align-items: stretch; flex-direction: column; }
.entry-card__toolbar { justify-content: flex-start; }
.entry-card__diff { grid-template-columns: 1fr; }
.entry-card__diff-proposal { padding-top: 10px; padding-left: 0; border-top: 1px solid #cfe2c7; border-left: 0; }
}
</style>