generated from kgod/ai-review-template
自内部仓库剥离深度优化与 RAG 知识库后的交付版本: - Builder 对话式简历生成(FSM + 意图路由 LLM 兜底增强) - 条目级轻度优化:事实覆盖门禁 + STAR/bullet 修复链,功能/简介/成果与技术栈同级保护 - 简历导入:DOCX/PDF 解析、结构归一、手机号脱敏 - PostgreSQL 运行时 + Alembic 迁移链 Co-Authored-By: Claude <noreply@anthropic.com>
313 lines
7.7 KiB
Vue
313 lines
7.7 KiB
Vue
<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>
|