generated from kgod/ai-review-template
feat: builder 简历生成 + 轻度优化 + 简历导入交付副本
自内部仓库剥离深度优化与 RAG 知识库后的交付版本: - Builder 对话式简历生成(FSM + 意图路由 LLM 兜底增强) - 条目级轻度优化:事实覆盖门禁 + STAR/bullet 修复链,功能/简介/成果与技术栈同级保护 - 简历导入:DOCX/PDF 解析、结构归一、手机号脱敏 - PostgreSQL 运行时 + Alembic 迁移链 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,312 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
import type { ComponentSubmission, TimelineBlock } from '../types/resumeAgent'
|
||||
import BlockRenderer from './BlockRenderer.vue'
|
||||
import ErrorCard from './ErrorCard.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
timeline: TimelineBlock[]
|
||||
initializing?: boolean
|
||||
pendingBlockId?: string
|
||||
creatingResume?: boolean
|
||||
errorMessage?: string
|
||||
traceId?: string
|
||||
resumeId?: string
|
||||
missingFields?: string[]
|
||||
aiStatus?: string
|
||||
streamedAssistantText?: string
|
||||
}>(),
|
||||
{
|
||||
initializing: false,
|
||||
pendingBlockId: '',
|
||||
creatingResume: false,
|
||||
errorMessage: '',
|
||||
traceId: '',
|
||||
resumeId: '',
|
||||
missingFields: () => [],
|
||||
aiStatus: '',
|
||||
streamedAssistantText: '',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [blockId: string, submission: ComponentSubmission]
|
||||
create: []
|
||||
retry: []
|
||||
clearError: []
|
||||
}>()
|
||||
const endMarker = ref<HTMLElement | null>(null)
|
||||
|
||||
function forwardSubmit(blockId: string, submission: ComponentSubmission) {
|
||||
emit('submit', blockId, submission)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.timeline.length, props.pendingBlockId, props.errorMessage, props.aiStatus, props.streamedAssistantText],
|
||||
async ([nextLength, , nextError, nextStatus, nextStream], [previousLength, , previousError, previousStatus, previousStream]) => {
|
||||
if (
|
||||
Number(nextLength) <= Number(previousLength) &&
|
||||
nextError === previousError &&
|
||||
nextStatus === previousStatus &&
|
||||
nextStream === previousStream
|
||||
) return
|
||||
await nextTick()
|
||||
endMarker.value?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="timeline-wrap" aria-live="polite">
|
||||
<div v-if="initializing && !timeline.length" class="timeline-skeleton" aria-label="正在打开简历对话">
|
||||
<span style="--delay: 0ms" />
|
||||
<span style="--delay: 90ms" />
|
||||
<span style="--delay: 180ms" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="!timeline.length && !errorMessage" class="timeline-empty">
|
||||
<span aria-hidden="true">+</span>
|
||||
<h2>正在准备第一步</h2>
|
||||
<p>简历共创会在这里逐步展开。</p>
|
||||
</div>
|
||||
|
||||
<ol v-else class="timeline-list">
|
||||
<li
|
||||
v-for="(block, index) in timeline"
|
||||
:key="`${block.id}-${block.version || 1}`"
|
||||
class="timeline-item"
|
||||
:class="[`timeline-item--${block.type}`, `timeline-item--${block.role}`]"
|
||||
>
|
||||
<div class="timeline-item__rail" aria-hidden="true">
|
||||
<span class="timeline-item__index">{{ String(index + 1).padStart(2, '0') }}</span>
|
||||
<i />
|
||||
</div>
|
||||
<div class="timeline-item__content">
|
||||
<BlockRenderer
|
||||
:block="block"
|
||||
:pending="pendingBlockId === block.id"
|
||||
:creating-resume="creatingResume"
|
||||
:resume-id="resumeId"
|
||||
:missing-fields="missingFields"
|
||||
@submit="forwardSubmit"
|
||||
@create="emit('create')"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li v-if="errorMessage" class="timeline-item timeline-item--error-client">
|
||||
<div class="timeline-item__rail" aria-hidden="true">
|
||||
<span class="timeline-item__index">!</span>
|
||||
<i />
|
||||
</div>
|
||||
<div class="timeline-item__content">
|
||||
<ErrorCard
|
||||
:message="errorMessage"
|
||||
:trace-id="traceId"
|
||||
retry-label="重新连接"
|
||||
dismissible
|
||||
@retry="emit('retry')"
|
||||
@dismiss="emit('clearError')"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li v-if="aiStatus || streamedAssistantText" class="timeline-item timeline-item--stream timeline-item--assistant">
|
||||
<div class="timeline-item__rail" aria-hidden="true">
|
||||
<span class="timeline-item__index">AI</span>
|
||||
<i />
|
||||
</div>
|
||||
<div class="timeline-item__content timeline-stream">
|
||||
<p v-if="aiStatus" class="timeline-stream__status"><i aria-hidden="true" />{{ aiStatus }}</p>
|
||||
<p v-if="streamedAssistantText" class="timeline-stream__text">{{ streamedAssistantText }}</p>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
<span ref="endMarker" class="timeline-end" aria-hidden="true" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.timeline-wrap {
|
||||
position: relative;
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
.timeline-list {
|
||||
position: relative;
|
||||
display: grid;
|
||||
gap: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.timeline-list::before {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
bottom: 8px;
|
||||
left: 21px;
|
||||
width: 1px;
|
||||
background: linear-gradient(to bottom, var(--brand), #bddbd7 86%, transparent);
|
||||
content: "";
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 44px minmax(0, 1fr);
|
||||
gap: 15px;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.timeline-item__rail {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.timeline-item__rail i {
|
||||
position: absolute;
|
||||
top: 11px;
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border: 3px solid #f4fbfa;
|
||||
border-radius: 50%;
|
||||
background: var(--brand);
|
||||
box-shadow: 0 0 0 1px #8acbc7;
|
||||
}
|
||||
|
||||
.timeline-item__index {
|
||||
position: absolute;
|
||||
top: 27px;
|
||||
padding: 2px 3px;
|
||||
color: var(--ink-faint);
|
||||
background: #f2faf8;
|
||||
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 8px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.timeline-item__content {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.timeline-item--text {
|
||||
padding-bottom: 15px;
|
||||
}
|
||||
|
||||
.timeline-item--text .timeline-item__rail i {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-width: 2px;
|
||||
background: #a1cbc7;
|
||||
}
|
||||
|
||||
.timeline-item--user .timeline-item__rail i {
|
||||
background: var(--ink-soft);
|
||||
}
|
||||
|
||||
.timeline-item--resume_patch .timeline-item__rail i {
|
||||
background: var(--lime);
|
||||
}
|
||||
|
||||
.timeline-item--error-client .timeline-item__rail i,
|
||||
.timeline-item--error .timeline-item__rail i {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.timeline-item--stream .timeline-item__rail i { background: var(--brand-dark); }
|
||||
.timeline-stream { display: grid; gap: 8px; padding: 12px 14px; border: 1px solid #cfe4e0; border-radius: 7px; background: #fff; }
|
||||
.timeline-stream__status { display: inline-flex; align-items: center; gap: 7px; margin: 0; color: var(--brand-dark); font-size: 11px; font-weight: 700; }
|
||||
.timeline-stream__status i { width: 7px; height: 7px; border-radius: 50%; background: #75b495; animation: status-pulse 1s ease-in-out infinite; }
|
||||
.timeline-stream__text { margin: 0; color: var(--ink-soft); font-size: 13px; line-height: 1.7; white-space: pre-wrap; }
|
||||
|
||||
.timeline-end {
|
||||
display: block;
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.timeline-skeleton {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 10px 0 0 58px;
|
||||
}
|
||||
|
||||
.timeline-skeleton span {
|
||||
display: block;
|
||||
width: min(100%, 580px);
|
||||
height: 76px;
|
||||
border-radius: 18px;
|
||||
background: linear-gradient(100deg, rgba(221, 237, 234, 0.7) 30%, rgba(255, 255, 255, 0.95) 48%, rgba(221, 237, 234, 0.7) 66%) 0 0 / 260% 100%;
|
||||
animation: skeleton-sheen 1.5s ease-in-out infinite;
|
||||
animation-delay: var(--delay);
|
||||
}
|
||||
|
||||
.timeline-skeleton span:nth-child(2) {
|
||||
height: 210px;
|
||||
}
|
||||
|
||||
.timeline-empty {
|
||||
display: grid;
|
||||
min-height: 260px;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.timeline-empty > span {
|
||||
display: grid;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
place-items: center;
|
||||
border-radius: 15px;
|
||||
color: var(--brand-dark);
|
||||
background: var(--brand-soft);
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.timeline-empty h2 {
|
||||
margin: 13px 0 0;
|
||||
color: var(--ink);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.timeline-empty p {
|
||||
margin: 5px 0 0;
|
||||
color: var(--ink-faint);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@keyframes skeleton-sheen {
|
||||
to {
|
||||
background-position: 100% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes status-pulse {
|
||||
50% { opacity: .35; }
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.timeline-item {
|
||||
grid-template-columns: 29px minmax(0, 1fr);
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.timeline-list::before {
|
||||
left: 13px;
|
||||
}
|
||||
|
||||
.timeline-item__index {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.timeline-skeleton {
|
||||
padding-left: 38px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user