feat: add resume agent MVP

This commit is contained in:
OfferPai
2026-07-20 14:48:41 +08:00
commit 48599bf55b
65 changed files with 10988 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
VITE_API_BASE_URL=
VITE_API_PROXY_TARGET=http://localhost:8000
VITE_DEMO_ACCOUNT_PHONE=13800138000
+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
.DS_Store
*.local
*.tsbuildinfo
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#eef8f7" />
<meta
name="description"
content="OfferPai AI 简历共创助手,通过对话把经历整理成一份完整简历。"
/>
<title>简历共创室 · OfferPai</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1537
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
{
"name": "offerpai-resume-agent-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview",
"typecheck": "vue-tsc --noEmit",
"check:syntax": "node scripts/check-syntax.cjs"
},
"dependencies": {
"vue": "^3.5.13"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"typescript": "~5.6.3",
"vite": "^6.0.5",
"vue-tsc": "^2.2.0"
}
}
+49
View File
@@ -0,0 +1,49 @@
const fs = require('node:fs')
const path = require('node:path')
const ts = require('typescript')
const sourceRoot = path.resolve(__dirname, '..', 'src')
let checked = 0
let errors = 0
function visit(directory) {
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const filename = path.join(directory, entry.name)
if (entry.isDirectory()) {
visit(filename)
continue
}
if ((!filename.endsWith('.ts') && !filename.endsWith('.vue')) || filename.endsWith('.d.ts')) {
continue
}
let source = fs.readFileSync(filename, 'utf8')
if (filename.endsWith('.vue')) {
const match = source.match(/<script setup lang="ts">([\s\S]*?)<\/script>/)
if (!match) continue
source = match[1]
}
const result = ts.transpileModule(source, {
compilerOptions: {
module: ts.ModuleKind.ESNext,
target: ts.ScriptTarget.ES2022,
},
fileName: filename,
reportDiagnostics: true,
})
const diagnostics = (result.diagnostics || []).filter(
(item) => item.category === ts.DiagnosticCategory.Error,
)
checked += 1
if (!diagnostics.length) continue
errors += diagnostics.length
process.stderr.write(`${filename}\n`)
for (const diagnostic of diagnostics) {
process.stderr.write(` ${ts.flattenDiagnosticMessageText(diagnostic.messageText, ' ')}\n`)
}
}
}
visit(sourceRoot)
process.stdout.write(`checked=${checked} syntax_errors=${errors}\n`)
process.exitCode = errors ? 1 : 0
+274
View File
@@ -0,0 +1,274 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import AgentTimeline from './components/AgentTimeline.vue'
import AppHeader from './components/AppHeader.vue'
import ComposerBar from './components/ComposerBar.vue'
import StageRail from './components/StageRail.vue'
import { useResumeAgent } from './composables/useResumeAgent'
const {
sessionId,
draftId,
revision,
stage,
timeline,
composer,
missingFields,
resumeId,
traceId,
initializing,
pendingBlockId,
sendingMessage,
creatingResume,
resetting,
errorMessage,
start,
refreshTimeline,
submitComponent,
sendMessage,
createResume,
resetSession,
clearError,
} = useResumeAgent()
const stageLabels: Record<string, string> = {
starting: '准备会话',
PRIVACY_CONSENT: '隐私确认',
PHONE_SELECTION: '定位简历',
MANUAL_PHONE_INPUT: '填写手机号',
NAME_CAPTURE: '基本信息',
JOB_TYPE_SELECT: '求职方向',
ANCHOR_TYPE_SELECT: '经历锚点',
ANCHOR_COLLECTING: '整理经历',
CONTENT_DISAMBIGUATION: '补充细节',
ANCHOR_CONFIRM: '经历校对',
MINIMUM_READY: '准备创建',
RESUME_CREATING: '正在创建',
CREATE_FAILED: '等待重试',
CONTENT_READY: '简历已创建',
RESUME_ENRICHING: '持续完善',
}
const stageLabel = computed(() => stageLabels[stage.value] || '整理简历')
const stageCode = computed(() => stage.value.toUpperCase().replaceAll('_', ' · '))
const chatEnabled = computed(() =>
[
'ANCHOR_COLLECTING',
'CONTENT_READY',
'RESUME_ENRICHING',
'CONTENT_DISAMBIGUATION',
].includes(stage.value),
)
async function retryConnection() {
clearError()
if (!sessionId.value) await start()
else {
try {
await refreshTimeline()
} catch {
await start()
}
}
}
async function confirmReset() {
const confirmed = window.confirm('重新开始会清空当前简历共创记录。确定继续吗?')
if (confirmed) await resetSession()
}
onMounted(start)
</script>
<template>
<div id="top" class="app-shell">
<AppHeader
:stage-label="stageLabel"
:revision="revision"
:session-id="sessionId"
:resetting="resetting"
@reset="confirmReset"
/>
<main class="workspace">
<StageRail
:stage="stage"
:missing-fields="missingFields"
:draft-id="draftId"
:resume-id="resumeId"
/>
<section class="conversation" aria-labelledby="conversation-title">
<header class="conversation-heading">
<div>
<p>{{ stageCode }}</p>
<h1 id="conversation-title">把经历沿着一条线整理清楚</h1>
</div>
<span class="conversation-heading__live">
<i aria-hidden="true" />
AI 共创中
</span>
</header>
<AgentTimeline
:timeline="timeline"
:initializing="initializing"
:pending-block-id="pendingBlockId"
:creating-resume="creatingResume"
:error-message="errorMessage"
:trace-id="traceId"
:resume-id="resumeId"
:missing-fields="missingFields"
@submit="submitComponent"
@create="createResume"
@retry="retryConnection"
@clear-error="clearError"
/>
<ComposerBar
:config="composer"
:sending="sendingMessage"
:disabled="composer.mode !== 'ui_only' && !chatEnabled"
@send="sendMessage"
/>
</section>
</main>
<footer class="app-footer">
<span>OfferPai Resume Agent</span>
<span>你的内容会保留在本次简历会话中</span>
</footer>
</div>
</template>
<style scoped>
.app-shell {
min-height: 100vh;
}
.workspace {
display: grid;
width: min(1100px, calc(100% - 40px));
grid-template-columns: 220px minmax(0, 760px);
justify-content: center;
gap: clamp(38px, 7vw, 82px);
margin: 0 auto;
padding: 42px 0 0;
}
.conversation {
min-width: 0;
}
.conversation-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
margin: 0 0 30px 59px;
padding-bottom: 20px;
border-bottom: 1px solid rgba(190, 217, 213, 0.78);
}
.conversation-heading p {
margin: 0;
color: var(--brand-dark);
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
font-size: 9px;
font-weight: 800;
letter-spacing: 0.13em;
}
.conversation-heading h1 {
max-width: 19ch;
margin: 8px 0 0;
color: var(--ink);
font-family: "Aptos Display", "MiSans", "PingFang SC", sans-serif;
font-size: clamp(25px, 4vw, 38px);
font-weight: 780;
letter-spacing: -0.055em;
line-height: 1.18;
}
.conversation-heading__live {
display: inline-flex;
flex: none;
align-items: center;
gap: 6px;
padding: 7px 10px;
border: 1px solid #cfe4d0;
border-radius: 999px;
color: #4b7551;
background: rgba(243, 250, 239, 0.86);
font-size: 10px;
font-weight: 750;
}
.conversation-heading__live i {
width: 6px;
height: 6px;
border-radius: 50%;
background: #7db75f;
box-shadow: 0 0 0 3px rgba(125, 183, 95, 0.14);
}
.app-footer {
display: flex;
width: min(1100px, calc(100% - 40px));
align-items: center;
justify-content: space-between;
gap: 20px;
margin: 10px auto 0;
padding: 24px 0 28px 302px;
border-top: 1px solid rgba(194, 217, 214, 0.66);
color: var(--ink-faint);
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
font-size: 8px;
letter-spacing: 0.04em;
}
@media (max-width: 900px) {
.workspace {
width: min(760px, calc(100% - 32px));
grid-template-columns: 1fr;
gap: 28px;
padding-top: 28px;
}
.conversation-heading {
margin-left: 38px;
}
.app-footer {
width: min(760px, calc(100% - 32px));
padding-left: 0;
}
}
@media (max-width: 620px) {
.workspace {
width: min(100% - 20px, 760px);
gap: 22px;
padding-top: 21px;
}
.conversation-heading {
margin: 0 4px 24px 38px;
}
.conversation-heading h1 {
font-size: 27px;
}
.conversation-heading__live {
display: none;
}
.app-footer {
width: calc(100% - 24px);
align-items: flex-start;
flex-direction: column;
gap: 5px;
}
}
</style>
+104
View File
@@ -0,0 +1,104 @@
import type {
ApiErrorPayload,
ComponentEventInput,
MessageInput,
ResumeAgentEnvelope,
} from '../types/resumeAgent'
const API_ROOT = `${(import.meta.env.VITE_API_BASE_URL || '').replace(/\/$/, '')}/ai-api/resume-agent`
export class ResumeAgentApiError extends Error {
readonly status: number
readonly payload?: ApiErrorPayload
constructor(message: string, status: number, payload?: ApiErrorPayload) {
super(message)
this.name = 'ResumeAgentApiError'
this.status = status
this.payload = payload
}
}
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const headers = new Headers(init.headers)
headers.set('Accept', 'application/json')
if (init.body && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json')
}
let response: Response
try {
response = await fetch(`${API_ROOT}${path}`, { ...init, headers })
} catch {
throw new ResumeAgentApiError('无法连接简历服务,请检查网络后重试。', 0)
}
const contentType = response.headers.get('content-type') || ''
const body = contentType.includes('application/json')
? await response.json().catch(() => undefined)
: await response.text().catch(() => undefined)
if (!response.ok) {
const payload = body && typeof body === 'object' ? (body as ApiErrorPayload) : undefined
const detail = payload?.detail
const message =
payload?.error?.message ||
payload?.message ||
(typeof detail === 'string' ? detail : undefined) ||
`简历服务返回了 ${response.status} 错误。`
throw new ResumeAgentApiError(message, response.status, payload)
}
return (body ?? {}) as T
}
function sessionPath(sessionId: string, suffix = ''): string {
return `/sessions/${encodeURIComponent(sessionId)}${suffix}`
}
export const resumeAgentApi = {
createSession(signal?: AbortSignal) {
const accountPhone = import.meta.env.VITE_DEMO_ACCOUNT_PHONE?.trim()
return request<ResumeAgentEnvelope>('/sessions', {
method: 'POST',
body: JSON.stringify(accountPhone ? { account_phone: accountPhone } : {}),
signal,
})
},
getTimeline(sessionId: string, signal?: AbortSignal) {
return request<ResumeAgentEnvelope>(sessionPath(sessionId, '/timeline'), { signal })
},
sendComponentEvent(sessionId: string, input: ComponentEventInput, signal?: AbortSignal) {
return request<ResumeAgentEnvelope>(sessionPath(sessionId, '/component-events'), {
method: 'POST',
body: JSON.stringify(input),
signal,
})
},
sendMessage(sessionId: string, input: MessageInput, signal?: AbortSignal) {
return request<ResumeAgentEnvelope>(sessionPath(sessionId, '/messages'), {
method: 'POST',
body: JSON.stringify(input),
signal,
})
},
createResume(sessionId: string, signal?: AbortSignal) {
return request<ResumeAgentEnvelope>(sessionPath(sessionId, '/create'), {
method: 'POST',
body: JSON.stringify({}),
signal,
})
},
deleteSession(sessionId: string, signal?: AbortSignal) {
return request<Record<string, unknown>>(sessionPath(sessionId), {
method: 'DELETE',
signal,
})
},
}
+280
View File
@@ -0,0 +1,280 @@
<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[]
}>(),
{
initializing: false,
pendingBlockId: '',
creatingResume: false,
errorMessage: '',
traceId: '',
resumeId: '',
missingFields: () => [],
},
)
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],
async ([nextLength], [previousLength]) => {
if (Number(nextLength) <= Number(previousLength) && !props.errorMessage) 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 v-for="index in 3" :key="index" :style="{ '--delay': `${index * 90}ms` }" />
</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>
</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-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;
}
}
@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>
@@ -0,0 +1,37 @@
<script setup lang="ts">
import type { ComponentSubmission } from '../types/resumeAgent'
import SingleChoiceCards from './shared/SingleChoiceCards.vue'
withDefaults(
defineProps<{
data: Record<string, unknown>
value?: unknown
readOnly?: boolean
pending?: boolean
}>(),
{ value: undefined, readOnly: false, pending: false },
)
const emit = defineEmits<{ submit: [submission: ComponentSubmission] }>()
const fallbackOptions = [
{ value: 'education', label: '教育经历', description: '从学校、专业和学历开始' },
{ value: 'work_experience', label: '工作经历', description: '从最近一段正式工作开始' },
{ value: 'internship_experience', label: '实习经历', description: '从岗位职责与成果切入' },
{ value: 'project_experience', label: '项目经历', description: '先整理最有代表性的项目' },
]
</script>
<template>
<SingleChoiceCards
:data="data"
:value="value"
:read-only="readOnly"
:pending="pending"
:default-options="fallbackOptions"
default-title="先从哪段经历开始"
default-description="选一个最容易讲清楚的锚点我们会从这里逐步展开"
eyebrow="经历锚点"
field="anchor_type"
@submit="emit('submit', $event)"
/>
</template>
+241
View File
@@ -0,0 +1,241 @@
<script setup lang="ts">
withDefaults(
defineProps<{
stageLabel: string
revision?: number
sessionId?: string
resetting?: boolean
}>(),
{ revision: 0, sessionId: '', resetting: false },
)
const emit = defineEmits<{ reset: [] }>()
</script>
<template>
<header class="app-header">
<div class="app-header__inner">
<a class="brand" href="#top" aria-label="OfferPai 简历共创室首页">
<span class="brand-mark" aria-hidden="true">
<i />
<i />
<i />
</span>
<span class="brand-copy">
<strong>OfferPai</strong>
<small>简历共创室</small>
</span>
</a>
<div class="session-state" role="status">
<span class="session-state__dot" />
<span>
<small>当前进度</small>
<strong>{{ stageLabel }}</strong>
</span>
<code v-if="revision">R{{ revision }}</code>
</div>
<button
class="reset-button"
type="button"
:disabled="resetting || !sessionId"
@click="emit('reset')"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4.8 9A7.7 7.7 0 1 1 4 14" />
<path d="M4.8 4.7V9h4.4" />
</svg>
<span>{{ resetting ? '正在重置' : '重新开始' }}</span>
</button>
</div>
</header>
</template>
<style scoped>
.app-header {
position: sticky;
z-index: 20;
top: 0;
border-bottom: 1px solid rgba(190, 218, 214, 0.7);
background: rgba(247, 252, 251, 0.82);
backdrop-filter: blur(18px) saturate(140%);
}
.app-header__inner {
display: grid;
width: min(1180px, calc(100% - 36px));
min-height: 68px;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 20px;
margin: 0 auto;
}
.brand {
display: inline-flex;
width: fit-content;
align-items: center;
gap: 10px;
color: var(--ink);
text-decoration: none;
}
.brand-mark {
position: relative;
display: block;
width: 36px;
height: 36px;
overflow: hidden;
border-radius: 12px;
background: #153d43;
box-shadow: 0 7px 16px rgba(19, 61, 67, 0.18);
}
.brand-mark i {
position: absolute;
display: block;
border: 2px solid #73d5d4;
border-radius: 50%;
}
.brand-mark i:nth-child(1) {
inset: 7px;
}
.brand-mark i:nth-child(2) {
top: 4px;
left: 16px;
width: 15px;
height: 26px;
border-color: transparent transparent #9edb78 #9edb78;
transform: rotate(-24deg);
}
.brand-mark i:nth-child(3) {
top: 16px;
left: 16px;
width: 4px;
height: 4px;
border: 0;
background: #fff;
}
.brand-copy {
display: grid;
gap: 1px;
}
.brand-copy strong {
font-family: "Aptos Display", "Segoe UI", sans-serif;
font-size: 16px;
font-weight: 800;
letter-spacing: -0.03em;
}
.brand-copy small {
color: var(--ink-faint);
font-size: 10px;
font-weight: 650;
letter-spacing: 0.08em;
}
.session-state {
display: flex;
align-items: center;
gap: 9px;
padding: 7px 11px;
border: 1px solid rgba(194, 219, 216, 0.8);
border-radius: 14px;
background: rgba(255, 255, 255, 0.66);
}
.session-state__dot {
width: 8px;
height: 8px;
border: 2px solid #d6f2ee;
border-radius: 50%;
background: var(--brand);
box-shadow: 0 0 0 2px rgba(50, 185, 191, 0.14);
}
.session-state > span:nth-child(2) {
display: grid;
gap: 1px;
}
.session-state small {
color: var(--ink-faint);
font-size: 8px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.session-state strong {
color: var(--ink);
font-size: 11px;
font-weight: 750;
}
.session-state code {
padding-left: 8px;
border-left: 1px solid var(--line);
color: var(--ink-faint);
font-size: 9px;
}
.reset-button {
display: inline-flex;
min-height: 38px;
align-items: center;
justify-self: end;
gap: 7px;
padding: 0 11px;
border: 1px solid transparent;
border-radius: 11px;
color: var(--ink-soft);
background: transparent;
font-size: 11px;
font-weight: 700;
}
.reset-button:hover:not(:disabled) {
border-color: var(--line);
color: var(--ink);
background: #fff;
}
.reset-button:disabled {
opacity: 0.5;
}
.reset-button svg {
width: 16px;
height: 16px;
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 1.8;
}
@media (max-width: 680px) {
.app-header__inner {
width: min(100% - 24px, 1180px);
grid-template-columns: 1fr auto;
}
.session-state {
display: none;
}
.reset-button span {
display: none;
}
.reset-button {
width: 38px;
padding: 0;
}
}
</style>
+207
View File
@@ -0,0 +1,207 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { ComponentSubmission, TimelineBlock } from '../types/resumeAgent'
import { canonicalComponentName, stringValue } from '../utils/componentData'
import AnchorTypeCards from './AnchorTypeCards.vue'
import ChoiceChips from './ChoiceChips.vue'
import CreateResumeCard from './CreateResumeCard.vue'
import DateRangeSelector from './DateRangeSelector.vue'
import DegreeSelector from './DegreeSelector.vue'
import ErrorCard from './ErrorCard.vue'
import ExperienceConfirmCard from './ExperienceConfirmCard.vue'
import JobTypeCards from './JobTypeCards.vue'
import PrivacyConsentCard from './PrivacyConsentCard.vue'
import ResumeNameInput from './ResumeNameInput.vue'
import ResumePatchCard from './ResumePatchCard.vue'
import ResumePhoneInput from './ResumePhoneInput.vue'
import ResumePhoneSelector from './ResumePhoneSelector.vue'
import ShortTextInput from './ShortTextInput.vue'
import StatusCard from './StatusCard.vue'
import TextBlock from './TextBlock.vue'
import UnknownComponentCard from './UnknownComponentCard.vue'
const props = withDefaults(
defineProps<{
block: TimelineBlock
pending?: boolean
creatingResume?: boolean
resumeId?: string
missingFields?: string[]
}>(),
{
pending: false,
creatingResume: false,
resumeId: '',
missingFields: () => [],
},
)
const emit = defineEmits<{
submit: [blockId: string, submission: ComponentSubmission]
create: []
}>()
const componentName = computed(() => canonicalComponentName(props.block.component))
const readOnly = computed(() => props.block.submitted)
const blockText = computed(() => props.block.text || stringValue(props.block.data.text))
const errorMessage = computed(() =>
stringValue(props.block.data.message ?? props.block.data.detail ?? props.block.description, '这一步没有完成,请重试。'),
)
function submit(submission: ComponentSubmission) {
emit('submit', props.block.id, submission)
}
</script>
<template>
<TextBlock
v-if="block.type === 'text'"
:text="blockText"
:role="block.role"
/>
<ResumePatchCard
v-else-if="block.type === 'resume_patch'"
:data="block.data"
/>
<ErrorCard
v-else-if="block.type === 'error'"
:title="block.title || stringValue(block.data.title, '处理遇到问题')"
:message="errorMessage"
:trace-id="stringValue(block.data.trace_id)"
retry-label="重新加载"
@retry="emit('create')"
/>
<StatusCard
v-else-if="block.type === 'status'"
:data="block.data"
:title="block.title"
:description="block.description"
:read-only="readOnly"
:pending="pending"
@submit="submit"
/>
<template v-else-if="block.type === 'component'">
<PrivacyConsentCard
v-if="componentName === 'privacy_consent'"
:data="block.data"
:value="block.value"
:read-only="readOnly"
:pending="pending"
@submit="submit"
/>
<ResumePhoneSelector
v-else-if="componentName === 'resume_phone_selector'"
:data="block.data"
:value="block.value"
:read-only="readOnly"
:pending="pending"
@submit="submit"
/>
<ResumePhoneInput
v-else-if="componentName === 'resume_phone_input'"
:data="block.data"
:value="block.value"
:read-only="readOnly"
:pending="pending"
@submit="submit"
/>
<ResumeNameInput
v-else-if="componentName === 'resume_name_input'"
:data="block.data"
:value="block.value"
:read-only="readOnly"
:pending="pending"
@submit="submit"
/>
<JobTypeCards
v-else-if="componentName === 'job_type_cards'"
:data="block.data"
:value="block.value"
:read-only="readOnly"
:pending="pending"
@submit="submit"
/>
<AnchorTypeCards
v-else-if="componentName === 'anchor_type_cards'"
:data="block.data"
:value="block.value"
:read-only="readOnly"
:pending="pending"
@submit="submit"
/>
<ShortTextInput
v-else-if="componentName === 'short_text'"
:data="block.data"
:value="block.value"
:read-only="readOnly"
:pending="pending"
@submit="submit"
/>
<DegreeSelector
v-else-if="componentName === 'degree_selector'"
:data="block.data"
:value="block.value"
:read-only="readOnly"
:pending="pending"
@submit="submit"
/>
<DateRangeSelector
v-else-if="componentName === 'date_range_selector'"
:data="block.data"
:value="block.value"
:read-only="readOnly"
:pending="pending"
@submit="submit"
/>
<ChoiceChips
v-else-if="componentName === 'choice_chips'"
:data="block.data"
:value="block.value"
:read-only="readOnly"
:pending="pending"
@submit="submit"
/>
<ExperienceConfirmCard
v-else-if="componentName === 'experience_confirm'"
:data="{ ...block.data, confirmed: block.lifecycle === 'confirmed' }"
:value="block.value"
:read-only="readOnly"
:pending="pending"
@submit="submit"
/>
<CreateResumeCard
v-else-if="componentName === 'create_resume'"
:data="block.data"
:read-only="readOnly"
:pending="creatingResume"
:resume-id="resumeId"
:missing-fields="missingFields"
@create="emit('create')"
/>
<StatusCard
v-else-if="['creating_status_card', 'content_ready_card'].includes(componentName)"
:data="block.data"
:read-only="readOnly"
:pending="pending"
:component-name="componentName"
@submit="submit"
/>
<ErrorCard
v-else-if="componentName === 'create_retry_card'"
title="简历创建没有完成"
message="已保留你填写的全部内容,可以直接重新创建。"
retry-label="重新创建"
:pending="creatingResume"
@retry="emit('create')"
/>
<UnknownComponentCard
v-else
:name="block.component"
:read-only="readOnly"
/>
</template>
</template>
+135
View File
@@ -0,0 +1,135 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { ComponentSubmission } from '../types/resumeAgent'
import {
booleanValue,
initialValue,
optionList,
stringArray,
stringValue,
} from '../utils/componentData'
import FormCard from './shared/FormCard.vue'
const props = withDefaults(
defineProps<{
data: Record<string, unknown>
value?: unknown
readOnly?: boolean
pending?: boolean
}>(),
{ value: undefined, readOnly: false, pending: false },
)
const emit = defineEmits<{ submit: [submission: ComponentSubmission] }>()
const options = computed(() => optionList(props.data.options ?? props.data.choices))
const multiple = computed(() => booleanValue(props.data.multiple))
const sourceValues = computed(() => {
if (Array.isArray(props.value)) return stringArray(props.value)
const fromData = stringArray(props.data.values ?? props.data.value)
if (fromData.length) return fromData
const single = typeof props.value === 'string' ? props.value : initialValue(props.data)
return single ? [single] : []
})
const selected = ref<string[]>([...sourceValues.value])
watch(sourceValues, (next) => {
selected.value = [...next]
})
const selectedLabels = computed(() =>
selected.value.map(
(value) => options.value.find((option) => option.value === value)?.label || value,
),
)
function toggle(value: string) {
if (multiple.value) {
selected.value = selected.value.includes(value)
? selected.value.filter((item) => item !== value)
: [...selected.value, value]
} else {
selected.value = [value]
}
}
function submit() {
if (!selected.value.length || props.readOnly || props.pending) return
const value = multiple.value ? selected.value : selected.value[0]
emit('submit', {
event: 'select',
payload: multiple.value
? { value, values: selected.value }
: { value },
})
}
</script>
<template>
<FormCard
:eyebrow="stringValue(data.eyebrow, '快速选择')"
:title="stringValue(data.title ?? data.prompt, '请选择最符合的一项')"
:description="stringValue(data.description, multiple ? '可以选择多项。' : '选择一项后确认。')"
:read-only="readOnly"
:pending="pending"
>
<div v-if="readOnly" class="read-only-value">{{ selectedLabels.join('') || '选择已记录' }}</div>
<form v-else @submit.prevent="submit">
<div class="chips" :role="multiple ? 'group' : 'radiogroup'">
<button
v-for="option in options"
:key="option.value"
class="choice-chip"
:class="{ 'choice-chip--selected': selected.includes(option.value) }"
type="button"
:role="multiple ? 'checkbox' : 'radio'"
:aria-checked="selected.includes(option.value)"
:disabled="pending || option.disabled"
@click="toggle(option.value)"
>
<span v-if="option.icon" aria-hidden="true">{{ option.icon }}</span>
{{ option.label }}
</button>
</div>
<div class="component-actions">
<button class="primary-button" type="submit" :disabled="!selected.length || pending">
确认{{ multiple ? '这些选项' : '选择' }}
</button>
</div>
</form>
</FormCard>
</template>
<style scoped>
.chips {
display: flex;
flex-wrap: wrap;
gap: 9px;
}
.choice-chip {
display: inline-flex;
min-height: 42px;
align-items: center;
gap: 6px;
padding: 0 14px;
border: 1px solid var(--line);
border-radius: 12px;
color: var(--ink-soft);
background: #f8fbfa;
font-size: 13px;
font-weight: 680;
transition: transform 160ms ease, border-color 160ms ease, color 160ms ease, background 160ms ease;
}
.choice-chip:hover:not(:disabled) {
transform: translateY(-1px);
border-color: #91cbc7;
color: var(--ink);
}
.choice-chip--selected {
border-color: #79c9c5;
color: #116f73;
background: var(--brand-soft);
}
</style>
+191
View File
@@ -0,0 +1,191 @@
<script setup lang="ts">
import { nextTick, ref } from 'vue'
import type { ComposerConfig } from '../types/resumeAgent'
const props = withDefaults(
defineProps<{
config: ComposerConfig
sending?: boolean
disabled?: boolean
}>(),
{ sending: false, disabled: false },
)
const emit = defineEmits<{ send: [message: string] }>()
const message = ref('')
const textarea = ref<HTMLTextAreaElement | null>(null)
function resizeTextarea() {
const element = textarea.value
if (!element) return
element.style.height = 'auto'
element.style.height = `${Math.min(element.scrollHeight, 132)}px`
}
function send() {
const clean = message.value.trim()
if (!clean || props.sending || props.disabled || props.config.disabled) return
emit('send', clean)
message.value = ''
void nextTick(resizeTextarea)
}
function handleKeydown(event: KeyboardEvent) {
if (event.key !== 'Enter' || event.shiftKey || event.isComposing) return
event.preventDefault()
send()
}
</script>
<template>
<div class="composer" :class="`composer--${config.mode}`">
<div v-if="config.mode === 'ui_only'" class="composer-ui-only" role="status">
<span aria-hidden="true"></span>
<p>{{ config.helper_text || '完成上方这一步后,对话会继续。' }}</p>
</div>
<form v-else class="composer-form" @submit.prevent="send">
<label class="visually-hidden" for="resume-agent-message">给简历助手发送消息</label>
<textarea
id="resume-agent-message"
ref="textarea"
v-model="message"
rows="1"
:maxlength="config.max_length || 8000"
:placeholder="config.placeholder || (disabled ? '完成创建后即可继续补充经历' : '继续讲讲你的经历…')"
:disabled="sending || disabled || config.disabled"
@input="resizeTextarea"
@keydown="handleKeydown"
/>
<button
type="submit"
:disabled="!message.trim() || sending || disabled || config.disabled"
aria-label="发送消息"
>
<span v-if="sending" class="composer-spinner" aria-hidden="true" />
<svg v-else viewBox="0 0 24 24" aria-hidden="true">
<path d="m5 12 13-7-4.6 14-2.2-5.2L5 12Z" />
<path d="m11.2 13.8 3.1-3.1" />
</svg>
</button>
<p class="composer-hint">
{{ config.helper_text || 'Enter 发送 · Shift + Enter 换行' }}
</p>
</form>
</div>
</template>
<style scoped>
.composer {
position: sticky;
z-index: 8;
bottom: 0;
padding: 14px 0 20px;
background: linear-gradient(to bottom, rgba(245, 250, 249, 0), rgba(245, 250, 249, 0.94) 24%, #f5faf9 62%);
}
.composer-form {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) 46px;
gap: 9px;
padding: 8px;
border: 1px solid rgba(168, 204, 200, 0.92);
border-radius: 19px;
background: rgba(255, 255, 255, 0.96);
box-shadow: var(--shadow-float);
backdrop-filter: blur(16px);
}
.composer-form textarea {
width: 100%;
min-height: 45px;
max-height: 132px;
padding: 12px 10px 9px;
border: 0;
outline: none;
color: var(--ink);
background: transparent;
font-size: 14px;
line-height: 1.5;
resize: none;
}
.composer-form textarea::placeholder {
color: #90a5a8;
}
.composer-form textarea:disabled {
color: #87999c;
}
.composer-form button {
display: grid;
width: 42px;
height: 42px;
place-items: center;
align-self: end;
border: 0;
border-radius: 14px;
color: #fff;
background: var(--brand-dark);
box-shadow: 0 8px 18px rgba(20, 127, 133, 0.24);
}
.composer-form button:disabled {
color: #d7e3e2;
background: #afc9c7;
box-shadow: none;
}
.composer-form svg {
width: 21px;
height: 21px;
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 1.8;
}
.composer-hint {
grid-column: 1 / -1;
margin: -2px 5px 1px;
color: var(--ink-faint);
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
font-size: 9px;
}
.composer-ui-only {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 43px;
padding: 10px 14px;
border: 1px solid rgba(200, 221, 218, 0.85);
border-radius: 15px;
color: var(--ink-faint);
background: rgba(247, 251, 250, 0.9);
font-size: 12px;
backdrop-filter: blur(12px);
}
.composer-ui-only span {
color: var(--brand-dark);
font-size: 16px;
}
.composer-ui-only p {
margin: 0;
}
.composer-spinner {
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.35);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
</style>
@@ -0,0 +1,182 @@
<script setup lang="ts">
import { computed } from 'vue'
import { stringArray, stringValue } from '../utils/componentData'
import FormCard from './shared/FormCard.vue'
const props = withDefaults(
defineProps<{
data: Record<string, unknown>
readOnly?: boolean
pending?: boolean
resumeId?: string
missingFields?: string[]
}>(),
{
readOnly: false,
pending: false,
resumeId: '',
missingFields: () => [],
},
)
const emit = defineEmits<{ create: [] }>()
const missing = computed(() =>
props.missingFields.length
? props.missingFields
: stringArray(props.data.missing_fields),
)
const resumeUrl = computed(() => stringValue(props.data.resume_url ?? props.data.url))
</script>
<template>
<FormCard
eyebrow="准备创建"
:title="stringValue(data.title, resumeId ? '简历已经创建完成' : '信息齐了,生成这份简历')"
:description="stringValue(data.description, resumeId ? '你可以前往简历中心继续编辑或用于投递。' : '我们会把确认过的信息汇总成一份结构化简历。')"
:read-only="readOnly || Boolean(resumeId)"
:pending="pending"
>
<div class="create-visual" :class="{ 'create-visual--done': resumeId }" aria-hidden="true">
<span class="create-visual__sheet create-visual__sheet--back" />
<span class="create-visual__sheet create-visual__sheet--front">
<i />
<i />
<i />
</span>
<span class="create-visual__seal">{{ resumeId ? '✓' : 'AI' }}</span>
</div>
<ul v-if="missing.length" class="missing-list">
<li v-for="field in missing" :key="field">还需要{{ field }}</li>
</ul>
<div v-if="resumeId" class="created-summary">
<span>简历编号</span>
<strong>{{ resumeId }}</strong>
</div>
<div class="component-actions">
<a v-if="resumeId && resumeUrl" class="primary-button link-button" :href="resumeUrl">
查看简历
</a>
<button
v-else-if="!resumeId"
class="primary-button create-button"
type="button"
:disabled="pending || Boolean(missing.length)"
@click="emit('create')"
>
{{ pending ? '正在生成…' : '创建简历' }}
</button>
</div>
</FormCard>
</template>
<style scoped>
.create-visual {
position: relative;
width: 126px;
height: 112px;
margin: 2px auto 20px;
}
.create-visual__sheet {
position: absolute;
width: 78px;
height: 98px;
border: 1px solid var(--line-strong);
border-radius: 11px;
background: #fff;
box-shadow: 0 12px 24px rgba(35, 88, 86, 0.1);
}
.create-visual__sheet--back {
top: 4px;
left: 18px;
transform: rotate(-7deg);
background: #e7f6f3;
}
.create-visual__sheet--front {
top: 8px;
left: 30px;
display: grid;
align-content: end;
gap: 8px;
padding: 18px 14px;
transform: rotate(3deg);
}
.create-visual__sheet--front i {
display: block;
height: 4px;
border-radius: 999px;
background: #d6e8e5;
}
.create-visual__sheet--front i:nth-child(2) {
width: 82%;
}
.create-visual__seal {
position: absolute;
right: 2px;
bottom: 2px;
display: grid;
width: 44px;
height: 44px;
place-items: center;
border: 4px solid var(--paper);
border-radius: 50%;
color: #fff;
background: var(--brand-dark);
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
font-size: 13px;
font-weight: 850;
box-shadow: 0 8px 18px rgba(20, 127, 133, 0.25);
}
.create-visual--done .create-visual__seal {
background: #6ba852;
}
.missing-list {
display: grid;
gap: 7px;
margin: 0;
padding: 12px 14px 12px 32px;
border-radius: 13px;
color: var(--warning);
background: var(--warning-soft);
font-size: 12px;
line-height: 1.5;
}
.created-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px;
border: 1px solid #dce9da;
border-radius: 14px;
color: #56705c;
background: #f3f9f0;
font-size: 12px;
}
.created-summary strong {
overflow: hidden;
color: #35533d;
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
text-overflow: ellipsis;
white-space: nowrap;
}
.link-button {
display: inline-flex;
align-items: center;
justify-content: center;
text-decoration: none;
}
</style>
@@ -0,0 +1,164 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { ComponentSubmission } from '../types/resumeAgent'
import {
booleanValue,
recordValue,
stringValue,
} from '../utils/componentData'
import FormCard from './shared/FormCard.vue'
const props = withDefaults(
defineProps<{
data: Record<string, unknown>
value?: unknown
readOnly?: boolean
pending?: boolean
}>(),
{ value: undefined, readOnly: false, pending: false },
)
const emit = defineEmits<{ submit: [submission: ComponentSubmission] }>()
const source = computed(() => ({
...recordValue(props.data.value ?? props.data.default_value),
...recordValue(props.value),
}))
const start = ref(stringValue(source.value.start_date ?? source.value.start))
const end = ref(stringValue(source.value.end_date ?? source.value.end))
const current = ref(booleanValue(source.value.current ?? source.value.is_current))
const validationError = ref('')
const inputType = computed(() => (props.data.granularity === 'day' ? 'date' : 'month'))
watch(source, (next) => {
start.value = stringValue(next.start_date ?? next.start)
end.value = stringValue(next.end_date ?? next.end)
current.value = booleanValue(next.current ?? next.is_current)
}, { deep: true })
function prettyDate(value: string): string {
if (!value) return ''
const [year, month, day] = value.split('-')
return day ? `${year}.${month}.${day}` : `${year}.${month}`
}
const summary = computed(() => {
const startLabel = prettyDate(start.value)
const endLabel = current.value ? '至今' : prettyDate(end.value)
return [startLabel, endLabel].filter(Boolean).join(' — ')
})
function submit() {
validationError.value = ''
if (!start.value) {
validationError.value = '请选择开始时间。'
return
}
if (!current.value && !end.value) {
validationError.value = '请选择结束时间,或勾选“至今”。'
return
}
if (!current.value && end.value < start.value) {
validationError.value = '结束时间不能早于开始时间。'
return
}
emit('submit', {
event: 'submit',
payload: {
value: { start_date: start.value, end_date: current.value ? null : end.value, current: current.value },
start_date: start.value,
end_date: current.value ? null : end.value,
current: current.value,
},
})
}
</script>
<template>
<FormCard
eyebrow="时间坐标"
:title="stringValue(data.title, '这段经历发生在什么时候?')"
:description="stringValue(data.description, '大致到月份就可以,后续仍能修改。')"
:read-only="readOnly"
:pending="pending"
>
<div v-if="readOnly" class="read-only-value">{{ summary || '时间范围已记录' }}</div>
<form v-else novalidate @submit.prevent="submit">
<div class="date-grid">
<div class="field-group">
<label class="field-label" for="date-start">开始时间</label>
<input
id="date-start"
v-model="start"
class="text-input"
:type="inputType"
:min="stringValue(data.min)"
:max="stringValue(data.max)"
:disabled="pending"
@change="validationError = ''"
/>
</div>
<div class="field-group">
<label class="field-label" for="date-end">结束时间</label>
<input
id="date-end"
v-model="end"
class="text-input"
:type="inputType"
:min="start || stringValue(data.min)"
:max="stringValue(data.max)"
:disabled="pending || current"
@change="validationError = ''"
/>
</div>
</div>
<label class="current-check">
<input v-model="current" type="checkbox" :disabled="pending" @change="validationError = ''" />
<span>这段经历仍在继续</span>
</label>
<p v-if="validationError" class="validation-error" role="alert">{{ validationError }}</p>
<div class="component-actions">
<button class="primary-button" type="submit" :disabled="pending">
保存时间
</button>
</div>
</form>
</FormCard>
</template>
<style scoped>
.date-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.current-check {
display: inline-flex;
align-items: center;
gap: 8px;
margin-top: 14px;
color: var(--ink-soft);
font-size: 13px;
font-weight: 620;
}
.current-check input {
width: 17px;
height: 17px;
margin: 0;
accent-color: var(--brand-dark);
}
.validation-error {
margin: 10px 0 0;
color: var(--danger);
font-size: 12px;
}
@media (max-width: 540px) {
.date-grid {
grid-template-columns: 1fr;
}
}
</style>
+113
View File
@@ -0,0 +1,113 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { ChoiceOption, ComponentSubmission } from '../types/resumeAgent'
import { initialValue, optionList, stringValue } from '../utils/componentData'
import FormCard from './shared/FormCard.vue'
const props = withDefaults(
defineProps<{
data: Record<string, unknown>
value?: unknown
readOnly?: boolean
pending?: boolean
}>(),
{ value: undefined, readOnly: false, pending: false },
)
const emit = defineEmits<{ submit: [submission: ComponentSubmission] }>()
const fallbackOptions: ChoiceOption[] = [
{ value: 'doctor', label: '博士' },
{ value: 'master', label: '硕士' },
{ value: 'bachelor', label: '本科' },
{ value: 'associate', label: '大专' },
{ value: 'high_school', label: '高中 / 中专' },
{ value: 'other', label: '其他' },
]
const options = computed(() => optionList(props.data.options ?? props.data.choices, fallbackOptions))
const sourceValue = computed(() =>
typeof props.value === 'string' ? props.value : initialValue(props.data),
)
const selected = ref(sourceValue.value)
watch(sourceValue, (next) => {
selected.value = next
})
const selectedLabel = computed(
() => options.value.find((option) => option.value === selected.value)?.label || selected.value,
)
function submit() {
if (!selected.value || props.readOnly || props.pending) return
emit('submit', {
event: 'select',
payload: { value: selected.value, degree: selected.value },
})
}
</script>
<template>
<FormCard
eyebrow="教育背景"
:title="stringValue(data.title, '你的最高学历是?')"
:description="stringValue(data.description, '选择已经获得或正在就读的最高学历。')"
:read-only="readOnly"
:pending="pending"
>
<div v-if="readOnly" class="read-only-value">{{ selectedLabel || '学历已记录' }}</div>
<form v-else @submit.prevent="submit">
<div class="degree-grid" role="radiogroup" aria-label="最高学历">
<button
v-for="option in options"
:key="option.value"
class="degree-option"
:class="{ 'degree-option--selected': selected === option.value }"
type="button"
role="radio"
:aria-checked="selected === option.value"
:disabled="pending || option.disabled"
@click="selected = option.value"
>
{{ option.label }}
</button>
</div>
<div class="component-actions">
<button class="primary-button" type="submit" :disabled="!selected || pending">
确认学历
</button>
</div>
</form>
</FormCard>
</template>
<style scoped>
.degree-grid {
display: flex;
flex-wrap: wrap;
gap: 9px;
}
.degree-option {
min-height: 44px;
padding: 0 16px;
border: 1px solid var(--line);
border-radius: 999px;
color: var(--ink-soft);
background: #f8fbfa;
font-size: 14px;
font-weight: 680;
transition: color 160ms ease, border-color 160ms ease, background 160ms ease, transform 160ms ease;
}
.degree-option:hover:not(:disabled) {
transform: translateY(-1px);
border-color: #91cbc7;
color: var(--ink);
}
.degree-option--selected {
border-color: var(--brand-dark);
color: #fff;
background: var(--brand-dark);
}
</style>
+105
View File
@@ -0,0 +1,105 @@
<script setup lang="ts">
withDefaults(
defineProps<{
title?: string
message: string
traceId?: string
retryLabel?: string
dismissible?: boolean
pending?: boolean
}>(),
{
title: '这一步没有完成',
traceId: '',
retryLabel: '重试',
dismissible: false,
pending: false,
},
)
const emit = defineEmits<{ retry: []; dismiss: [] }>()
</script>
<template>
<section class="error-card" role="alert">
<div class="error-card__mark" aria-hidden="true">!</div>
<div class="error-card__content">
<h2>{{ title }}</h2>
<p>{{ message }}</p>
<code v-if="traceId">追踪编号{{ traceId }}</code>
<div class="error-card__actions">
<button class="secondary-button" type="button" :disabled="pending" @click="emit('retry')">
{{ pending ? '正在重试' : retryLabel }}
</button>
<button
v-if="dismissible"
class="ghost-button"
type="button"
:disabled="pending"
@click="emit('dismiss')"
>
暂时关闭
</button>
</div>
</div>
</section>
</template>
<style scoped>
.error-card {
display: flex;
align-items: flex-start;
gap: 13px;
padding: 18px;
border: 1px solid #efc9c2;
border-radius: 18px;
color: #753a34;
background: rgba(255, 245, 243, 0.97);
box-shadow: 0 10px 28px rgba(132, 61, 52, 0.07);
}
.error-card__mark {
display: grid;
width: 37px;
height: 37px;
flex: none;
place-items: center;
border-radius: 12px;
color: #fff;
background: var(--danger);
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
font-weight: 900;
}
.error-card__content {
min-width: 0;
}
.error-card h2 {
margin: 1px 0 0;
font-size: 16px;
}
.error-card p {
margin: 6px 0 0;
color: #8a4d46;
font-size: 13px;
line-height: 1.6;
}
.error-card code {
display: block;
margin-top: 8px;
overflow: hidden;
color: #9a625b;
font-size: 10px;
text-overflow: ellipsis;
white-space: nowrap;
}
.error-card__actions {
display: flex;
gap: 7px;
margin-top: 12px;
}
</style>
@@ -0,0 +1,189 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { ComponentSubmission } from '../types/resumeAgent'
import {
booleanValue,
recordValue,
stringArray,
stringValue,
} from '../utils/componentData'
import FormCard from './shared/FormCard.vue'
const props = withDefaults(
defineProps<{
data: Record<string, unknown>
value?: unknown
readOnly?: boolean
pending?: boolean
}>(),
{ value: undefined, readOnly: false, pending: false },
)
const emit = defineEmits<{ submit: [submission: ComponentSubmission] }>()
const summary = computed(() =>
recordValue(props.data.summary ?? props.data.experience ?? props.data.value ?? props.value),
)
const highlights = computed(() =>
stringArray(props.data.highlights ?? summary.value.highlights ?? summary.value.bullets),
)
const fields = computed(() => {
const ignored = new Set(['highlights', 'bullets', 'description'])
return Object.entries(summary.value)
.filter(([key, value]) => !ignored.has(key) && ['string', 'number'].includes(typeof value))
.map(([key, value]) => ({ key, label: key, value: String(value) }))
})
const confirmed = computed(() =>
booleanValue(props.data.confirmed, booleanValue(recordValue(props.value).confirmed)),
)
function confirm() {
emit('submit', {
event: 'confirm',
payload: { value: true, confirmed: true },
})
}
function revise() {
emit('submit', {
event: 'edit',
payload: { value: false, confirmed: false, field: props.data.edit_field },
})
}
</script>
<template>
<FormCard
eyebrow="经历校对"
:title="stringValue(data.title, '这段经历整理得准确吗?')"
:description="stringValue(data.description, '请检查关键信息。确认后,我们会把它合并进简历草稿。')"
:read-only="readOnly"
:pending="pending"
>
<article class="experience-sheet">
<div v-if="stringValue(summary.title ?? summary.name ?? summary.company)" class="experience-sheet__heading">
<strong>{{ stringValue(summary.title ?? summary.name ?? summary.company) }}</strong>
<span v-if="stringValue(summary.subtitle ?? summary.role)">
{{ stringValue(summary.subtitle ?? summary.role) }}
</span>
</div>
<dl v-if="fields.length" class="experience-fields">
<div v-for="field in fields" :key="field.key">
<dt>{{ field.label }}</dt>
<dd>{{ field.value }}</dd>
</div>
</dl>
<p v-if="stringValue(summary.description)" class="experience-description">
{{ stringValue(summary.description) }}
</p>
<ul v-if="highlights.length" class="experience-highlights">
<li v-for="highlight in highlights" :key="highlight">{{ highlight }}</li>
</ul>
<p v-if="!fields.length && !highlights.length && !stringValue(summary.description)" class="experience-empty">
{{ stringValue(data.preview, '经历信息已整理完成等待你的确认') }}
</p>
</article>
<div v-if="readOnly" class="confirmation-note">
{{ confirmed ? '已确认这段经历' : '已提交修改意见' }}
</div>
<div v-else class="component-actions confirm-actions">
<button class="secondary-button" type="button" :disabled="pending" @click="revise">
需要调整
</button>
<button class="primary-button" type="button" :disabled="pending" @click="confirm">
准确加入简历
</button>
</div>
</FormCard>
</template>
<style scoped>
.experience-sheet {
padding: 18px;
border: 1px solid var(--line);
border-radius: 18px;
background:
linear-gradient(90deg, rgba(50, 185, 191, 0.07) 1px, transparent 1px) 0 0 / 22px 22px,
#fbfefd;
}
.experience-sheet__heading {
display: grid;
gap: 4px;
margin-bottom: 14px;
}
.experience-sheet__heading strong {
color: var(--ink);
font-size: 17px;
}
.experience-sheet__heading span {
color: var(--ink-faint);
font-size: 12px;
}
.experience-fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin: 0;
}
.experience-fields div {
min-width: 0;
}
.experience-fields dt {
overflow: hidden;
color: var(--ink-faint);
font-size: 10px;
letter-spacing: 0.08em;
text-overflow: ellipsis;
text-transform: uppercase;
white-space: nowrap;
}
.experience-fields dd {
margin: 4px 0 0;
overflow-wrap: anywhere;
color: var(--ink);
font-size: 13px;
line-height: 1.5;
}
.experience-description,
.experience-empty {
margin: 14px 0 0;
color: var(--ink-soft);
font-size: 13px;
line-height: 1.65;
}
.experience-highlights {
display: grid;
gap: 8px;
margin: 14px 0 0;
padding-left: 19px;
color: var(--ink-soft);
font-size: 13px;
line-height: 1.6;
}
.confirmation-note {
margin-top: 14px;
color: #4f765b;
font-size: 13px;
font-weight: 700;
}
@media (max-width: 540px) {
.experience-fields {
grid-template-columns: 1fr;
}
}
</style>
+36
View File
@@ -0,0 +1,36 @@
<script setup lang="ts">
import type { ComponentSubmission } from '../types/resumeAgent'
import SingleChoiceCards from './shared/SingleChoiceCards.vue'
withDefaults(
defineProps<{
data: Record<string, unknown>
value?: unknown
readOnly?: boolean
pending?: boolean
}>(),
{ value: undefined, readOnly: false, pending: false },
)
const emit = defineEmits<{ submit: [submission: ComponentSubmission] }>()
const fallbackOptions = [
{ value: 'campus', label: '校园招聘', description: '应届生、毕业生与实习转正', recommended: true },
{ value: 'social', label: '社会招聘', description: '突出工作经验与业务成果' },
{ value: 'other', label: '其他机会', description: '灵活选择最匹配的经历方向' },
]
</script>
<template>
<SingleChoiceCards
:data="data"
:value="value"
:read-only="readOnly"
:pending="pending"
:default-options="fallbackOptions"
default-title="你准备投递哪类机会"
default-description="不同求职类型会影响经历排序和内容侧重点"
eyebrow="求职方向"
field="job_type"
@submit="emit('submit', $event)"
/>
</template>
@@ -0,0 +1,129 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { ComponentSubmission } from '../types/resumeAgent'
import { booleanValue, stringArray, stringValue } from '../utils/componentData'
import FormCard from './shared/FormCard.vue'
const props = withDefaults(
defineProps<{
data: Record<string, unknown>
value?: unknown
readOnly?: boolean
pending?: boolean
}>(),
{ value: undefined, readOnly: false, pending: false },
)
const emit = defineEmits<{ submit: [submission: ComponentSubmission] }>()
const initialAccepted = computed(() =>
booleanValue(props.data.accepted, booleanValue(props.value)),
)
const accepted = ref(initialAccepted.value)
const points = computed(() =>
stringArray(props.data.items ?? props.data.points).length
? stringArray(props.data.items ?? props.data.points)
: ['仅用于生成和完善本次简历', '敏感信息会按隐私规则处理', '创建前你仍可以检查和修改'],
)
watch(initialAccepted, (next) => {
accepted.value = next
})
function submit() {
if (!accepted.value || props.readOnly || props.pending) return
emit('submit', {
event: 'accept',
payload: { accepted: true, value: true },
})
}
</script>
<template>
<FormCard
eyebrow="隐私确认"
:title="stringValue(data.title, '先确认信息如何被使用')"
:description="stringValue(data.description, '我们会在这次对话中读取你主动提供的简历信息,用来整理内容并生成简历。')"
:read-only="readOnly"
:pending="pending"
>
<ul class="privacy-points">
<li v-for="point in points" :key="point">{{ point }}</li>
</ul>
<div v-if="readOnly" class="read-only-value">已同意本次简历共创所需的信息处理</div>
<form v-else @submit.prevent="submit">
<label class="consent-check">
<input v-model="accepted" type="checkbox" :disabled="pending" />
<span>
我已阅读并同意
<a
v-if="stringValue(data.policy_url)"
:href="stringValue(data.policy_url)"
target="_blank"
rel="noreferrer"
>隐私说明</a>
<template v-else>隐私说明</template>
</span>
</label>
<div class="component-actions">
<button class="primary-button" type="submit" :disabled="!accepted || pending">
同意并继续
</button>
</div>
</form>
</FormCard>
</template>
<style scoped>
.privacy-points {
display: grid;
gap: 11px;
margin: 0 0 20px;
padding: 0;
list-style: none;
}
.privacy-points li {
position: relative;
padding-left: 25px;
color: var(--ink-soft);
font-size: 14px;
line-height: 1.55;
}
.privacy-points li::before {
position: absolute;
top: 4px;
left: 0;
display: grid;
width: 16px;
height: 16px;
place-items: center;
border-radius: 50%;
color: #fff;
background: var(--brand);
font-size: 10px;
content: "✓";
}
.consent-check {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 14px;
border: 1px solid var(--line);
border-radius: 14px;
color: var(--ink-soft);
background: var(--surface-muted);
font-size: 13px;
line-height: 1.55;
}
.consent-check input {
width: 18px;
height: 18px;
flex: none;
margin: 1px 0 0;
accent-color: var(--brand-dark);
}
</style>
@@ -0,0 +1,69 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { ComponentSubmission } from '../types/resumeAgent'
import { initialValue, numberValue, stringValue } from '../utils/componentData'
import FormCard from './shared/FormCard.vue'
const props = withDefaults(
defineProps<{
data: Record<string, unknown>
value?: unknown
readOnly?: boolean
pending?: boolean
}>(),
{ value: undefined, readOnly: false, pending: false },
)
const emit = defineEmits<{ submit: [submission: ComponentSubmission] }>()
const sourceValue = computed(() =>
typeof props.value === 'string' ? props.value : initialValue(props.data),
)
const name = ref(sourceValue.value)
const maxLength = computed(() => numberValue(props.data.max_length, 40))
watch(sourceValue, (next) => {
name.value = next
})
function submit() {
const cleanName = name.value.trim()
if (!cleanName || props.readOnly || props.pending) return
emit('submit', {
event: 'submit',
payload: { value: cleanName, name: cleanName },
})
}
</script>
<template>
<FormCard
eyebrow="基本信息"
:title="stringValue(data.title, '这份简历叫什么名字?')"
:description="stringValue(data.description, '给它一个容易辨认的名字,之后管理多份简历会更清楚。')"
:read-only="readOnly"
:pending="pending"
>
<div v-if="readOnly" class="read-only-value">{{ sourceValue || '名称已记录' }}</div>
<form v-else @submit.prevent="submit">
<div class="field-group">
<label class="field-label" for="resume-name">简历名称</label>
<input
id="resume-name"
v-model="name"
class="text-input"
type="text"
autocomplete="off"
:maxlength="maxLength"
:placeholder="stringValue(data.placeholder, '例如:产品经理 · 社招版')"
:disabled="pending"
/>
<p class="field-hint">{{ name.length }}/{{ maxLength }} </p>
</div>
<div class="component-actions">
<button class="primary-button" type="submit" :disabled="!name.trim() || pending">
保存名称
</button>
</div>
</form>
</FormCard>
</template>
+131
View File
@@ -0,0 +1,131 @@
<script setup lang="ts">
import { computed } from 'vue'
import { recordValue, stringValue } from '../utils/componentData'
const props = defineProps<{
data: Record<string, unknown>
}>()
const patchValue = computed(() => recordValue(props.data.value))
const previewItems = computed(() =>
Object.entries(patchValue.value)
.filter(([, value]) => typeof value === 'string' || typeof value === 'number')
.slice(0, 4)
.map(([label, value]) => ({ label, value: String(value) })),
)
</script>
<template>
<section class="patch-card">
<div class="patch-card__icon" aria-hidden="true">
<span />
<span />
<span />
</div>
<div class="patch-card__content">
<p>简历草稿</p>
<h2>{{ stringValue(data.title, '已同步最新内容') }}</h2>
<span>{{ data.operation === 'replace' ? '完整版本已刷新' : '新的经历已写入' }}</span>
<dl v-if="previewItems.length">
<div v-for="item in previewItems" :key="item.label">
<dt>{{ item.label }}</dt>
<dd>{{ item.value }}</dd>
</div>
</dl>
</div>
<code v-if="data.revision">R{{ data.revision }}</code>
</section>
</template>
<style scoped>
.patch-card {
display: flex;
align-items: flex-start;
gap: 13px;
padding: 17px;
border: 1px dashed #a9cfca;
border-radius: 17px;
color: var(--ink);
background: rgba(240, 250, 248, 0.78);
}
.patch-card__icon {
display: grid;
width: 38px;
height: 44px;
flex: none;
align-content: center;
gap: 5px;
padding: 9px;
border: 1px solid #a7d5d0;
border-radius: 9px;
background: #fff;
}
.patch-card__icon span {
height: 3px;
border-radius: 99px;
background: #a7d5d0;
}
.patch-card__content {
min-width: 0;
flex: 1;
}
.patch-card__content > p {
margin: 0 0 4px;
color: var(--brand-dark);
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
font-size: 9px;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.patch-card h2 {
margin: 0;
font-size: 14px;
}
.patch-card__content > span {
display: block;
margin-top: 4px;
color: var(--ink-faint);
font-size: 11px;
}
.patch-card > code {
color: var(--ink-faint);
font-size: 10px;
}
.patch-card dl {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px 12px;
margin: 12px 0 0;
}
.patch-card dl div {
min-width: 0;
}
.patch-card dt,
.patch-card dd {
overflow: hidden;
margin: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
.patch-card dt {
color: var(--ink-faint);
font-size: 9px;
}
.patch-card dd {
margin-top: 2px;
font-size: 11px;
}
</style>
@@ -0,0 +1,27 @@
<script setup lang="ts">
import type { ComponentSubmission } from '../types/resumeAgent'
import ResumePhoneSelector from './ResumePhoneSelector.vue'
withDefaults(
defineProps<{
data: Record<string, unknown>
value?: unknown
readOnly?: boolean
pending?: boolean
}>(),
{ value: undefined, readOnly: false, pending: false },
)
const emit = defineEmits<{ submit: [submission: ComponentSubmission] }>()
</script>
<template>
<ResumePhoneSelector
:data="data"
:value="value"
:read-only="readOnly"
:pending="pending"
input-only
@submit="emit('submit', $event)"
/>
</template>
@@ -0,0 +1,214 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { ComponentSubmission } from '../types/resumeAgent'
import {
booleanValue,
initialValue,
optionList,
stringValue,
} from '../utils/componentData'
import FormCard from './shared/FormCard.vue'
const props = withDefaults(
defineProps<{
data: Record<string, unknown>
value?: unknown
readOnly?: boolean
pending?: boolean
inputOnly?: boolean
}>(),
{ value: undefined, readOnly: false, pending: false, inputOnly: false },
)
const emit = defineEmits<{ submit: [submission: ComponentSubmission] }>()
const accountOptions = computed(() => {
if (props.inputOnly) return []
const explicit = optionList(props.data.options ?? props.data.phones ?? props.data.choices)
if (explicit.length) return explicit
return [
...(booleanValue(props.data.has_account_phone)
? [{
value: 'account',
label: stringValue(props.data.masked_phone, '使用登录手机号'),
description: '将登录手机号作为简历联系电话',
}]
: []),
{ value: 'manual', label: '输入其他手机号', description: '仅校验格式,不发送验证码' },
]
})
const sourceValue = computed(() =>
typeof props.value === 'string' ? props.value : initialValue(props.data),
)
const selected = ref(sourceValue.value)
const manualPhone = ref(props.inputOnly ? sourceValue.value : '')
const validationError = ref('')
watch(sourceValue, (next) => {
selected.value = next
if (props.inputOnly) manualPhone.value = next
})
const resolvedValue = computed(() => (props.inputOnly ? manualPhone.value.trim() : selected.value))
const resolvedLabel = computed(
() => accountOptions.value.find((option) => option.value === sourceValue.value)?.label || sourceValue.value,
)
function selectPhone(value: string) {
selected.value = value
validationError.value = ''
}
function submit() {
if (!props.inputOnly) {
if (!selected.value) {
validationError.value = '请选择手机号来源。'
return
}
const source = ['manual', 'other'].includes(selected.value) ? 'manual' : 'account'
emit('submit', {
event: 'select',
payload: { value: source, source },
})
return
}
const phone = resolvedValue.value.trim()
if (!/^1[3-9]\d{9}$/.test(phone)) {
validationError.value = '手机号格式不正确,请检查后重试。'
return
}
emit('submit', {
event: 'submit',
payload: { value: phone, phone },
})
}
</script>
<template>
<FormCard
eyebrow="简历定位"
:title="stringValue(data.title, inputOnly ? '输入其他简历手机号' : '选择简历联系电话')"
:description="stringValue(data.description, '可以使用登录手机号,也可以填写其他中国大陆手机号。')"
:read-only="readOnly"
:pending="pending"
>
<div v-if="readOnly" class="read-only-value">{{ resolvedLabel || '手机号已记录' }}</div>
<form v-else novalidate @submit.prevent="submit">
<ul v-if="!inputOnly" class="phone-list">
<li v-for="phone in accountOptions" :key="phone.value">
<button
class="phone-option"
:class="{ 'phone-option--selected': selected === phone.value }"
type="button"
:disabled="pending || phone.disabled"
:aria-pressed="selected === phone.value"
@click="selectPhone(phone.value)"
>
<span class="phone-option__avatar" aria-hidden="true">{{ phone.label.slice(-2) }}</span>
<span>
<strong>{{ phone.label }}</strong>
<small>{{ phone.description || '已有简历记录' }}</small>
</span>
</button>
</li>
</ul>
<div v-if="inputOnly" class="field-group">
<label class="field-label" for="resume-phone">手机号</label>
<input
id="resume-phone"
v-model="manualPhone"
class="text-input"
type="tel"
inputmode="tel"
autocomplete="tel"
:placeholder="stringValue(data.placeholder, '例如:13800138000')"
:disabled="pending"
:aria-invalid="Boolean(validationError)"
aria-describedby="resume-phone-help resume-phone-error"
@input="validationError = ''"
/>
<p id="resume-phone-help" class="field-hint">仅校验 11 位中国大陆手机号格式不发送验证码</p>
<p v-if="validationError" id="resume-phone-error" class="validation-error" role="alert">
{{ validationError }}
</p>
</div>
<div class="component-actions">
<button class="primary-button" type="submit" :disabled="!resolvedValue || pending">
继续
</button>
</div>
</form>
</FormCard>
</template>
<style scoped>
.phone-list {
display: grid;
gap: 9px;
margin: 0;
padding: 0;
list-style: none;
}
.phone-option {
display: flex;
width: 100%;
min-height: 70px;
align-items: center;
gap: 12px;
padding: 11px 13px;
border: 1px solid var(--line);
border-radius: 16px;
color: var(--ink);
background: #f9fcfb;
text-align: left;
transition: border-color 160ms ease, background 160ms ease, transform 160ms ease;
}
.phone-option:hover:not(:disabled) {
transform: translateY(-1px);
border-color: #91cbc7;
}
.phone-option--selected {
border-color: var(--brand);
background: var(--brand-soft);
}
.phone-option__avatar {
display: grid;
width: 42px;
height: 42px;
flex: none;
place-items: center;
border-radius: 13px;
color: var(--brand-dark);
background: #fff;
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
font-size: 11px;
font-weight: 800;
}
.phone-option strong,
.phone-option small {
display: block;
}
.phone-option strong {
font-size: 14px;
}
.phone-option small {
margin-top: 3px;
color: var(--ink-faint);
font-size: 11px;
}
.validation-error {
margin: 0;
color: var(--danger);
font-size: 12px;
}
</style>
@@ -0,0 +1,70 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { ComponentSubmission } from '../types/resumeAgent'
import { initialValue, numberValue, stringValue } from '../utils/componentData'
import FormCard from './shared/FormCard.vue'
const props = withDefaults(
defineProps<{
data: Record<string, unknown>
value?: unknown
readOnly?: boolean
pending?: boolean
}>(),
{ value: undefined, readOnly: false, pending: false },
)
const emit = defineEmits<{ submit: [submission: ComponentSubmission] }>()
const sourceValue = computed(() =>
typeof props.value === 'string' ? props.value : initialValue(props.data),
)
const answer = ref(sourceValue.value)
const maxLength = computed(() => numberValue(props.data.max_length, 300))
const fieldName = computed(() => stringValue(props.data.field, 'text'))
watch(sourceValue, (next) => {
answer.value = next
})
function submit() {
const cleanAnswer = answer.value.trim()
if (!cleanAnswer || props.readOnly || props.pending) return
emit('submit', {
event: 'submit',
payload: { value: cleanAnswer, [fieldName.value]: cleanAnswer },
})
}
</script>
<template>
<FormCard
:eyebrow="stringValue(data.eyebrow, '补充细节')"
:title="stringValue(data.title ?? data.prompt, '请简单讲讲这段经历')"
:description="stringValue(data.description ?? data.helper_text, '先写事实即可,我们会在后续帮你整理表达。')"
:read-only="readOnly"
:pending="pending"
>
<div v-if="readOnly" class="read-only-value">{{ sourceValue || '内容已记录' }}</div>
<form v-else @submit.prevent="submit">
<div class="field-group">
<label class="field-label" :for="`short-text-${fieldName}`">
{{ stringValue(data.label, '你的回答') }}
</label>
<textarea
:id="`short-text-${fieldName}`"
v-model="answer"
class="text-area"
:maxlength="maxLength"
:placeholder="stringValue(data.placeholder, '写下关键事实、行动或结果…')"
:disabled="pending"
/>
<p class="field-hint">{{ answer.length }}/{{ maxLength }} </p>
</div>
<div class="component-actions">
<button class="primary-button" type="submit" :disabled="!answer.trim() || pending">
保存并继续
</button>
</div>
</form>
</FormCard>
</template>
+267
View File
@@ -0,0 +1,267 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
stage: string
missingFields?: string[]
draftId?: string
resumeId?: string
}>()
const phases = [
{ key: 'consent', label: '隐私确认', stages: ['STARTING', 'PRIVACY_CONSENT'] },
{ key: 'identity', label: '基本信息', stages: ['PHONE_SELECTION', 'MANUAL_PHONE_INPUT', 'NAME_CAPTURE'] },
{ key: 'direction', label: '求职方向', stages: ['JOB_TYPE_SELECT', 'ANCHOR_TYPE_SELECT'] },
{ key: 'experience', label: '核心经历', stages: ['ANCHOR_COLLECTING', 'CONTENT_DISAMBIGUATION', 'ANCHOR_CONFIRM'] },
{ key: 'create', label: '创建简历', stages: ['MINIMUM_READY', 'RESUME_CREATING', 'CREATE_FAILED'] },
{ key: 'enrich', label: '持续完善', stages: ['CONTENT_READY', 'RESUME_ENRICHING'] },
]
const activeIndex = computed(() => {
const normalized = props.stage.toUpperCase()
const found = phases.findIndex((phase) => phase.stages.includes(normalized))
return found < 0 ? 0 : found
})
</script>
<template>
<aside class="stage-rail" aria-label="简历创建进度">
<div class="stage-rail__heading">
<p>CAREER ROUTE</p>
<h2>简历路线</h2>
<span>把散落的经历整理成清晰的职业坐标</span>
</div>
<ol>
<li
v-for="(phase, index) in phases"
:key="phase.key"
:class="{
'is-complete': index < activeIndex,
'is-active': index === activeIndex,
}"
>
<span class="stage-number">{{ String(index + 1).padStart(2, '0') }}</span>
<span class="stage-label">{{ phase.label }}</span>
<i aria-hidden="true" />
</li>
</ol>
<div class="stage-rail__meta">
<div>
<span>待补字段</span>
<strong>{{ missingFields?.length || 0 }}</strong>
</div>
<div>
<span>草稿状态</span>
<strong>{{ resumeId ? '已创建' : draftId ? '已就绪' : '收集中' }}</strong>
</div>
</div>
</aside>
</template>
<style scoped>
.stage-rail {
position: sticky;
top: 96px;
align-self: start;
padding: 5px 8px 20px 0;
}
.stage-rail__heading p {
margin: 0;
color: var(--brand-dark);
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
font-size: 9px;
font-weight: 800;
letter-spacing: 0.16em;
}
.stage-rail__heading h2 {
margin: 8px 0 0;
color: var(--ink);
font-size: 21px;
letter-spacing: -0.04em;
}
.stage-rail__heading > span {
display: block;
max-width: 20ch;
margin-top: 8px;
color: var(--ink-faint);
font-size: 11px;
line-height: 1.65;
}
.stage-rail ol {
position: relative;
display: grid;
gap: 0;
margin: 24px 0 0;
padding: 0;
list-style: none;
}
.stage-rail ol::before {
position: absolute;
top: 17px;
bottom: 17px;
left: 15px;
width: 1px;
background: #cfe0de;
content: "";
}
.stage-rail li {
position: relative;
display: grid;
min-height: 43px;
grid-template-columns: 31px 1fr;
align-items: center;
gap: 10px;
color: #8aa0a3;
}
.stage-rail li i {
position: absolute;
left: 11px;
z-index: 1;
width: 9px;
height: 9px;
border: 3px solid #f1faf8;
border-radius: 50%;
background: #c5d6d4;
box-shadow: 0 0 0 1px #c5d6d4;
}
.stage-number {
padding-left: 39px;
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
font-size: 8px;
}
.stage-label {
font-size: 12px;
font-weight: 650;
}
.stage-rail li.is-complete {
color: #608076;
}
.stage-rail li.is-complete i {
background: var(--lime);
box-shadow: 0 0 0 1px #91c271;
}
.stage-rail li.is-active {
color: var(--ink);
}
.stage-rail li.is-active .stage-label {
font-weight: 800;
}
.stage-rail li.is-active i {
background: var(--brand);
box-shadow: 0 0 0 1px var(--brand), 0 0 0 5px rgba(50, 185, 191, 0.12);
}
.stage-rail__meta {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 7px;
margin-top: 23px;
}
.stage-rail__meta div {
min-width: 0;
padding: 10px;
border: 1px solid rgba(199, 220, 217, 0.8);
border-radius: 12px;
background: rgba(255, 255, 255, 0.5);
}
.stage-rail__meta span,
.stage-rail__meta strong {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.stage-rail__meta span {
color: var(--ink-faint);
font-size: 8px;
}
.stage-rail__meta strong {
margin-top: 4px;
color: var(--ink);
font-size: 11px;
}
@media (max-width: 900px) {
.stage-rail {
position: static;
padding: 0;
}
.stage-rail__heading > span,
.stage-rail__meta {
display: none;
}
.stage-rail__heading {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
}
.stage-rail__heading h2 {
margin: 0;
font-size: 16px;
}
.stage-rail ol {
grid-template-columns: repeat(6, minmax(54px, 1fr));
margin-top: 13px;
overflow-x: auto;
scrollbar-width: none;
}
.stage-rail ol::before {
top: 11px;
right: 28px;
bottom: auto;
left: 28px;
width: auto;
height: 1px;
}
.stage-rail li {
min-width: 70px;
min-height: 48px;
grid-template-columns: 1fr;
justify-items: center;
align-content: start;
gap: 8px;
}
.stage-rail li i {
position: relative;
left: auto;
order: -1;
}
.stage-number {
display: none;
}
.stage-label {
font-size: 9px;
white-space: nowrap;
}
}
</style>
+151
View File
@@ -0,0 +1,151 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { ComponentSubmission } from '../types/resumeAgent'
import { stringArray, stringValue } from '../utils/componentData'
const props = withDefaults(
defineProps<{
data: Record<string, unknown>
title?: string
description?: string
readOnly?: boolean
pending?: boolean
componentName?: string
}>(),
{
title: '',
description: '',
readOnly: false,
pending: false,
componentName: '',
},
)
const emit = defineEmits<{ submit: [submission: ComponentSubmission] }>()
const status = computed(() => stringValue(props.data.status, 'ready'))
const actions = computed(() => stringArray(props.data.actions))
const isCreating = computed(() =>
props.componentName === 'creating_status_card' || status.value === 'creating',
)
const isReady = computed(() =>
props.componentName === 'content_ready_card' || status.value === 'ready',
)
const actionLabels: Record<string, string> = {
continue_enriching: '继续补充经历',
finish_enrichment: '暂时完成',
}
function actionLabel(action: string): string {
return actionLabels[action] || action
}
</script>
<template>
<section class="status-card" :class="{ 'status-card--creating': isCreating }" role="status">
<div class="status-card__icon" aria-hidden="true">
<span v-if="isCreating" class="status-spinner" />
<span v-else></span>
</div>
<div class="status-card__content">
<p class="status-card__label">{{ isCreating ? '正在处理' : '状态更新' }}</p>
<h2>{{ title || stringValue(data.title, isCreating ? '正在创建你的简历' : '简历内容已更新') }}</h2>
<p>
{{ description || stringValue(data.description, isCreating ? '正在合并已确认的经历,请稍候。' : '已保存到简历草稿,你可以继续补充更多经历。') }}
</p>
<div v-if="isReady && actions.length && !readOnly" class="status-card__actions">
<button
v-for="(action, index) in actions"
:key="action"
:class="index === 0 ? 'primary-button' : 'secondary-button'"
type="button"
:disabled="pending"
@click="emit('submit', { event: action, payload: {} })"
>
{{ actionLabel(action) }}
</button>
</div>
</div>
</section>
</template>
<style scoped>
.status-card {
display: flex;
align-items: flex-start;
gap: 14px;
padding: 18px;
border: 1px solid #cfe3cc;
border-radius: 18px;
color: #37553d;
background: rgba(245, 251, 242, 0.94);
box-shadow: 0 10px 28px rgba(50, 92, 54, 0.07);
}
.status-card--creating {
border-color: #c5e2de;
color: var(--ink);
background: rgba(241, 250, 248, 0.96);
}
.status-card__icon {
display: grid;
width: 39px;
height: 39px;
flex: none;
place-items: center;
border-radius: 13px;
color: #fff;
background: #78ad5e;
font-weight: 850;
}
.status-card--creating .status-card__icon {
background: var(--brand-dark);
}
.status-card__content {
min-width: 0;
}
.status-card__label {
margin: 1px 0 5px !important;
color: currentColor !important;
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
font-size: 10px !important;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
opacity: 0.72;
}
.status-card h2 {
margin: 0;
color: currentColor;
font-size: 16px;
line-height: 1.4;
}
.status-card p {
margin: 6px 0 0;
color: currentColor;
font-size: 13px;
line-height: 1.6;
opacity: 0.82;
}
.status-card__actions {
display: flex;
flex-wrap: wrap;
gap: 9px;
margin-top: 14px;
}
.status-spinner {
width: 17px;
height: 17px;
border: 2px solid rgba(255, 255, 255, 0.35);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
</style>
+80
View File
@@ -0,0 +1,80 @@
<script setup lang="ts">
withDefaults(
defineProps<{
text?: string
role?: 'assistant' | 'user' | 'system'
}>(),
{ text: '', role: 'assistant' },
)
</script>
<template>
<div class="text-block" :class="`text-block--${role}`">
<span v-if="role === 'assistant'" class="text-block__agent" aria-hidden="true"></span>
<p>{{ text }}</p>
</div>
</template>
<style scoped>
.text-block {
display: flex;
align-items: flex-start;
gap: 10px;
}
.text-block__agent {
display: grid;
width: 31px;
height: 31px;
flex: none;
place-items: center;
border: 1px solid #bce2de;
border-radius: 11px 11px 11px 4px;
color: #0e757a;
background: #e4f8f5;
font-size: 13px;
font-weight: 800;
box-shadow: 0 6px 14px rgba(35, 88, 86, 0.09);
}
.text-block p {
width: fit-content;
max-width: min(620px, 86%);
margin: 0;
padding: 11px 15px;
border: 1px solid rgba(201, 222, 219, 0.86);
border-radius: 6px 17px 17px 17px;
color: var(--ink-soft);
background: rgba(255, 255, 255, 0.84);
font-size: 14px;
line-height: 1.7;
box-shadow: 0 8px 22px rgba(35, 88, 86, 0.05);
white-space: pre-wrap;
}
.text-block--user {
justify-content: flex-end;
}
.text-block--user p {
border-color: #178289;
border-radius: 17px 6px 17px 17px;
color: #fff;
background: #178289;
box-shadow: 0 8px 22px rgba(20, 127, 133, 0.2);
}
.text-block--system {
justify-content: center;
}
.text-block--system p {
padding: 7px 12px;
border: 0;
border-radius: 999px;
color: var(--ink-faint);
background: rgba(230, 240, 238, 0.9);
font-size: 12px;
box-shadow: none;
}
</style>
@@ -0,0 +1,42 @@
<script setup lang="ts">
defineProps<{ name?: string; readOnly?: boolean }>()
</script>
<template>
<section class="unknown-card" role="status">
<span aria-hidden="true"></span>
<div>
<strong>这一步需要新版界面</strong>
<p>组件 {{ name || 'unknown' }} 暂时无法显示刷新页面或升级后再试</p>
</div>
</section>
</template>
<style scoped>
.unknown-card {
display: flex;
align-items: flex-start;
gap: 11px;
padding: 16px;
border: 1px dashed var(--line-strong);
border-radius: 16px;
color: var(--ink-soft);
background: rgba(248, 252, 251, 0.9);
}
.unknown-card > span {
color: var(--brand-dark);
font-size: 19px;
}
.unknown-card strong {
color: var(--ink);
font-size: 13px;
}
.unknown-card p {
margin: 4px 0 0;
font-size: 11px;
line-height: 1.55;
}
</style>
@@ -0,0 +1,36 @@
<script setup lang="ts">
withDefaults(
defineProps<{
eyebrow?: string
title: string
description?: string
readOnly?: boolean
pending?: boolean
}>(),
{
eyebrow: '下一步',
description: '',
readOnly: false,
pending: false,
},
)
</script>
<template>
<section
class="component-card"
:class="{ 'component-card--readonly': readOnly }"
:aria-busy="pending"
>
<div class="component-card__topline">
<p class="component-card__eyebrow">{{ eyebrow }}</p>
<span v-if="readOnly" class="component-card__state">已记录</span>
</div>
<h2 class="component-card__title">{{ title }}</h2>
<p v-if="description" class="component-card__description">{{ description }}</p>
<div class="component-card__body">
<slot />
</div>
<p v-if="pending" class="component-card__pending" role="status">正在整理这一步</p>
</section>
</template>
@@ -0,0 +1,107 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { ChoiceOption, ComponentSubmission } from '../../types/resumeAgent'
import { initialValue, optionList, stringValue } from '../../utils/componentData'
import FormCard from './FormCard.vue'
const props = withDefaults(
defineProps<{
data: Record<string, unknown>
value?: unknown
defaultOptions?: ChoiceOption[]
defaultTitle: string
defaultDescription?: string
eyebrow?: string
field: string
readOnly?: boolean
pending?: boolean
}>(),
{
value: undefined,
defaultOptions: () => [],
defaultDescription: '',
eyebrow: '方向校准',
readOnly: false,
pending: false,
},
)
const emit = defineEmits<{ submit: [submission: ComponentSubmission] }>()
const options = computed(() => optionList(props.data.options ?? props.data.choices, props.defaultOptions))
const sourceValue = computed(() =>
typeof props.value === 'string' ? props.value : initialValue(props.data),
)
const selected = ref(sourceValue.value)
watch(sourceValue, (next) => {
selected.value = next
})
const selectedLabel = computed(
() => options.value.find((option) => option.value === selected.value)?.label || selected.value,
)
function submit() {
if (!selected.value || props.readOnly || props.pending) return
emit('submit', {
event: 'select',
payload: {
value: selected.value,
[props.field]: selected.value,
},
})
}
</script>
<template>
<FormCard
:eyebrow="eyebrow"
:title="stringValue(data.title, defaultTitle)"
:description="stringValue(data.description, defaultDescription)"
:read-only="readOnly"
:pending="pending"
>
<div v-if="readOnly" class="read-only-value">{{ selectedLabel || '已完成选择' }}</div>
<form v-else @submit.prevent="submit">
<ul class="option-grid">
<li v-for="option in options" :key="option.value">
<button
class="option-card"
:class="{ 'option-card--selected': selected === option.value }"
type="button"
:disabled="pending || option.disabled"
:aria-pressed="selected === option.value"
@click="selected = option.value"
>
<span class="option-card__label">
{{ option.label }}
<small v-if="option.recommended" class="recommended-label">推荐</small>
</span>
<span v-if="option.description" class="option-card__description">
{{ option.description }}
</span>
</button>
</li>
</ul>
<div class="component-actions">
<button class="primary-button" type="submit" :disabled="!selected || pending">
确认选择
</button>
</div>
</form>
</FormCard>
</template>
<style scoped>
.recommended-label {
display: inline-flex;
margin-left: 6px;
padding: 2px 6px;
border-radius: 999px;
color: #4a7135;
background: #e8f5df;
font-size: 10px;
font-weight: 750;
vertical-align: 2px;
}
</style>
+423
View File
@@ -0,0 +1,423 @@
import { computed, onBeforeUnmount, ref } from 'vue'
import { ResumeAgentApiError, resumeAgentApi } from '../api/resumeAgent'
import type {
ComponentSubmission,
ComposerConfig,
ComposerMode,
NormalizedResumeAgentState,
RawTimelineBlock,
ResumeAgentEnvelope,
TimelineBlock,
TimelineBlockType,
} from '../types/resumeAgent'
const STORAGE_KEY = 'offerpai.resume-agent.session-id'
const VALID_BLOCK_TYPES = new Set<TimelineBlockType>([
'text',
'component',
'resume_patch',
'status',
'error',
])
const VALID_COMPOSER_MODES = new Set<ComposerMode>(['ui_only', 'chat', 'hybrid'])
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: {}
}
function asString(value: unknown): string | undefined {
return typeof value === 'string' && value.trim() ? value : undefined
}
function unwrapEnvelope(response: ResumeAgentEnvelope): ResumeAgentEnvelope {
return response.data && typeof response.data === 'object'
? { ...response, ...response.data, data: undefined }
: response
}
function collectRawBlocks(source: unknown, inherited: RawTimelineBlock = {}): RawTimelineBlock[] {
if (!Array.isArray(source)) return []
return source.flatMap((item, index) => {
if (typeof item === 'string') {
return [{
id: `text-${inherited.turn ?? 'x'}-${index}`,
type: 'text',
text: item,
...inherited,
}]
}
const record = asRecord(item)
const nested = record.blocks ?? record.components
const turnValue = record.turn ?? record.sequence ?? inherited.turn
const context: RawTimelineBlock = {
stage: asString(record.stage ?? inherited.stage),
turn: typeof turnValue === 'number' ? turnValue : undefined,
role: asString(record.role ?? inherited.role),
created_at: asString(record.created_at ?? inherited.created_at),
}
const nestedBlocks = collectRawBlocks(nested, context)
const content = asString(record.content)
const hasMatchingTextBlock = nestedBlocks.some((block) => {
const blockData = asRecord(block.data)
return block.type === 'text' && asString(block.text ?? blockData.text) === content
})
const contentBlock: RawTimelineBlock[] = content && !hasMatchingTextBlock
? [{
id: asString(record.id) || `turn-${context.turn ?? index}-content`,
type: 'text',
text: content,
...context,
role: asString(record.role) || context.role || 'assistant',
}]
: []
if (nestedBlocks.length || contentBlock.length) return [...contentBlock, ...nestedBlocks]
return [{ ...inherited, ...record } as RawTimelineBlock]
})
}
function normalizeBlock(raw: RawTimelineBlock, index: number): TimelineBlock {
const nestedData = asRecord(raw.data)
const nestedProps = asRecord(raw.props)
const data = { ...nestedProps, ...nestedData }
const rawType = asString(raw.type)
const inferredType = raw.component || raw.component_name || data.component || data.component_name
? 'component'
: rawType
const type = VALID_BLOCK_TYPES.has(inferredType as TimelineBlockType)
? (inferredType as TimelineBlockType)
: 'status'
const lifecycleValue = asString(raw.lifecycle ?? data.lifecycle)
const submitted = Boolean(
raw.submitted ||
raw.read_only ||
data.submitted ||
data.read_only ||
(lifecycleValue && lifecycleValue !== 'active'),
)
const roleValue = asString(raw.role ?? data.role)
const contentText = typeof raw.content === 'string' ? raw.content : undefined
return {
id:
asString(raw.id) ||
asString(raw.block_id) ||
asString(data.id) ||
`block-${raw.turn ?? 'x'}-${index}`,
type,
stage: asString(raw.stage ?? data.stage),
turn: typeof raw.turn === 'number' ? raw.turn : undefined,
role: roleValue === 'user' || roleValue === 'system' ? roleValue : 'assistant',
text: asString(raw.text ?? data.text) || contentText,
title: asString(raw.title ?? data.title),
description: asString(raw.description ?? data.description),
component: asString(
raw.component ?? raw.component_name ?? data.component ?? data.component_name ?? data.name,
),
data,
value: raw.value ?? data.value,
lifecycle: (lifecycleValue || (submitted ? 'submitted' : 'active')) as TimelineBlock['lifecycle'],
submitted,
createdAt: asString(raw.created_at ?? data.created_at),
version: typeof raw.version === 'number' ? raw.version : undefined,
raw,
}
}
function normalizeComposer(envelope: ResumeAgentEnvelope, turns: unknown): ComposerConfig {
const timelineRecord = asRecord(envelope.timeline)
const gate = asRecord(envelope.gate)
const turnList = Array.isArray(turns) ? turns : []
const latestTurn = asRecord(turnList.at(-1))
const actionTurn = asRecord(envelope.turn)
const composerValue =
envelope.composer ??
actionTurn.composer ??
actionTurn.composer_mode ??
latestTurn.composer ??
latestTurn.composer_mode ??
timelineRecord.composer ??
gate.composer
const composer = asRecord(composerValue)
const modeCandidate =
asString(typeof composerValue === 'string' ? composerValue : composer.mode) ||
asString(envelope.composer_mode) ||
asString(gate.composer_mode) ||
asString(gate.mode)
const mode = VALID_COMPOSER_MODES.has(modeCandidate as ComposerMode)
? (modeCandidate as ComposerMode)
: 'hybrid'
return {
mode,
placeholder: asString(composer.placeholder ?? gate.placeholder),
helper_text: asString(composer.helper_text ?? gate.helper_text),
max_length:
typeof composer.max_length === 'number' ? composer.max_length : undefined,
disabled: Boolean(composer.disabled ?? gate.disabled),
}
}
export function normalizeResumeAgentResponse(response: ResumeAgentEnvelope): NormalizedResumeAgentState {
const envelope = unwrapEnvelope(response)
const timelineRecord = asRecord(envelope.timeline)
const session = asRecord(envelope.session)
const resume = asRecord(envelope.resume)
const rawTimeline = Array.isArray(envelope.turns)
? envelope.turns
: Array.isArray(envelope.timeline)
? envelope.timeline
: timelineRecord.blocks ??
timelineRecord.turns ??
(envelope.turn && typeof envelope.turn === 'object' ? [envelope.turn] : undefined) ??
envelope.blocks ??
envelope.components
const rawBlocks = collectRawBlocks(rawTimeline)
const latestTurn = Array.isArray(rawTimeline) ? asRecord(rawTimeline.at(-1)) : {}
return {
sessionId: asString(envelope.session_id ?? session.id ?? envelope.id),
draftId: asString(envelope.draft_id ?? session.draft_id),
revision: typeof envelope.revision === 'number' ? envelope.revision : 0,
stage: asString(envelope.stage ?? session.stage ?? timelineRecord.stage) || 'starting',
turn:
typeof envelope.turn === 'number'
? envelope.turn
: typeof latestTurn.sequence === 'number'
? latestTurn.sequence
: 0,
timeline: rawBlocks.map(normalizeBlock),
composer: normalizeComposer(envelope, rawTimeline),
missingFields: Array.isArray(envelope.missing_fields)
? envelope.missing_fields.filter((item): item is string => typeof item === 'string')
: [],
gate: envelope.gate ?? null,
resumeId: asString(envelope.resume_id ?? session.resume_id ?? resume.id),
traceId: asString(envelope.trace_id),
}
}
function formatError(error: unknown): string {
if (error instanceof ResumeAgentApiError) return error.message
if (error instanceof Error && error.message) return error.message
return '操作没有完成,请稍后重试。'
}
export function useResumeAgent() {
const sessionId = ref('')
const draftId = ref('')
const revision = ref(0)
const stage = ref('starting')
const turn = ref(0)
const timeline = ref<TimelineBlock[]>([])
const composer = ref<ComposerConfig>({ mode: 'hybrid' })
const missingFields = ref<string[]>([])
const gate = ref<ResumeAgentEnvelope['gate']>(null)
const resumeId = ref('')
const traceId = ref('')
const initializing = ref(true)
const pendingBlockId = ref('')
const sendingMessage = ref(false)
const creatingResume = ref(false)
const resetting = ref(false)
const errorMessage = ref('')
let controller: AbortController | null = null
const isBusy = computed(
() =>
initializing.value ||
Boolean(pendingBlockId.value) ||
sendingMessage.value ||
creatingResume.value ||
resetting.value,
)
function applyState(response: ResumeAgentEnvelope, keepTimeline = false) {
const state = normalizeResumeAgentResponse(response)
if (state.sessionId) {
sessionId.value = state.sessionId
localStorage.setItem(STORAGE_KEY, state.sessionId)
}
draftId.value = state.draftId || draftId.value
revision.value = state.revision
stage.value = state.stage
turn.value = state.turn
composer.value = state.composer
missingFields.value = state.missingFields
gate.value = state.gate
resumeId.value = state.resumeId || resumeId.value
traceId.value = state.traceId || ''
if (!keepTimeline) timeline.value = state.timeline
}
async function refreshTimeline() {
if (!sessionId.value) return
const response = await resumeAgentApi.getTimeline(sessionId.value, controller?.signal)
applyState(response)
}
async function applyMutationResponse(response: ResumeAgentEnvelope) {
const envelope = unwrapEnvelope(response)
const hasFullTimeline = Array.isArray(envelope.timeline) || Array.isArray(envelope.turns)
applyState(response, !hasFullTimeline)
if (!hasFullTimeline && sessionId.value) await refreshTimeline()
}
async function start() {
controller?.abort()
const activeController = new AbortController()
controller = activeController
initializing.value = true
errorMessage.value = ''
const storedSessionId = localStorage.getItem(STORAGE_KEY)
try {
if (storedSessionId) {
sessionId.value = storedSessionId
try {
await refreshTimeline()
return
} catch (error) {
if (!(error instanceof ResumeAgentApiError) || ![404, 410].includes(error.status)) throw error
localStorage.removeItem(STORAGE_KEY)
sessionId.value = ''
}
}
const response = await resumeAgentApi.createSession(activeController.signal)
applyState(response)
if (!timeline.value.length) await refreshTimeline()
} catch (error) {
if (activeController.signal.aborted) return
errorMessage.value = formatError(error)
} finally {
initializing.value = false
}
}
async function submitComponent(blockId: string, submission: ComponentSubmission) {
if (!sessionId.value || pendingBlockId.value) return
pendingBlockId.value = blockId
errorMessage.value = ''
try {
const response = await resumeAgentApi.sendComponentEvent(
sessionId.value,
{
component_id: blockId,
event: submission.event || 'submit',
payload: submission.payload,
},
controller?.signal,
)
await applyMutationResponse(response)
} catch (error) {
errorMessage.value = formatError(error)
} finally {
pendingBlockId.value = ''
}
}
async function sendMessage(message: string) {
const cleanMessage = message.trim()
if (!sessionId.value || !cleanMessage || sendingMessage.value) return
sendingMessage.value = true
errorMessage.value = ''
try {
const response = await resumeAgentApi.sendMessage(
sessionId.value,
{ content: cleanMessage },
controller?.signal,
)
await applyMutationResponse(response)
} catch (error) {
errorMessage.value = formatError(error)
} finally {
sendingMessage.value = false
}
}
async function createResume() {
if (!sessionId.value || creatingResume.value) return
creatingResume.value = true
errorMessage.value = ''
try {
const response = await resumeAgentApi.createResume(sessionId.value, controller?.signal)
await applyMutationResponse(response)
} catch (error) {
errorMessage.value = formatError(error)
try {
await refreshTimeline()
if (stage.value === 'CREATE_FAILED') errorMessage.value = ''
} catch {
// Keep the original creation error when the follow-up refresh also fails.
}
} finally {
creatingResume.value = false
}
}
async function resetSession() {
if (resetting.value) return
resetting.value = true
errorMessage.value = ''
try {
if (sessionId.value) await resumeAgentApi.deleteSession(sessionId.value, controller?.signal)
} catch (error) {
if (!(error instanceof ResumeAgentApiError) || error.status !== 404) {
errorMessage.value = formatError(error)
resetting.value = false
return
}
}
localStorage.removeItem(STORAGE_KEY)
sessionId.value = ''
draftId.value = ''
timeline.value = []
resumeId.value = ''
resetting.value = false
await start()
}
function clearError() {
errorMessage.value = ''
}
onBeforeUnmount(() => controller?.abort())
return {
sessionId,
draftId,
revision,
stage,
turn,
timeline,
composer,
missingFields,
gate,
resumeId,
traceId,
initializing,
pendingBlockId,
sendingMessage,
creatingResume,
resetting,
errorMessage,
isBusy,
start,
refreshTimeline,
submitComponent,
sendMessage,
createResume,
resetSession,
clearError,
}
}
+11
View File
@@ -0,0 +1,11 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string
readonly VITE_API_PROXY_TARGET?: string
readonly VITE_DEMO_ACCOUNT_PHONE?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import App from './App.vue'
import './styles/base.css'
createApp(App).mount('#app')
+473
View File
@@ -0,0 +1,473 @@
:root {
color: #13272f;
background: #eef8f7;
font-family: "MiSans", "HarmonyOS Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
--ink: #13272f;
--ink-soft: #49636a;
--ink-faint: #72898e;
--paper: #f4fbfa;
--surface: #ffffff;
--surface-muted: #f2f7f6;
--line: #d9e8e6;
--line-strong: #bcd7d3;
--brand: #32b9bf;
--brand-dark: #147f85;
--brand-soft: #dff6f4;
--lime: #94ce6e;
--danger: #d95649;
--danger-soft: #fff0ed;
--warning: #a76a12;
--warning-soft: #fff7e7;
--shadow-card: 0 18px 48px rgba(35, 88, 86, 0.09), 0 2px 8px rgba(35, 88, 86, 0.05);
--shadow-float: 0 22px 60px rgba(22, 74, 72, 0.16);
}
* {
box-sizing: border-box;
}
html {
min-width: 320px;
min-height: 100%;
background: #eef8f7;
scroll-behavior: smooth;
}
body {
min-width: 320px;
min-height: 100vh;
margin: 0;
background:
radial-gradient(circle at 8% 0%, rgba(82, 202, 209, 0.18), transparent 31rem),
radial-gradient(circle at 96% 24%, rgba(148, 206, 110, 0.11), transparent 26rem),
linear-gradient(180deg, #f7fcfb 0, #eef8f7 34rem, #f5faf9 100%);
}
body::before {
position: fixed;
inset: 0;
z-index: -1;
background-image:
linear-gradient(rgba(34, 100, 99, 0.035) 1px, transparent 1px),
linear-gradient(90deg, rgba(34, 100, 99, 0.035) 1px, transparent 1px);
background-size: 42px 42px;
mask-image: linear-gradient(to bottom, black, transparent 70%);
content: "";
pointer-events: none;
}
button,
input,
textarea,
select {
font: inherit;
}
button,
a {
-webkit-tap-highlight-color: transparent;
}
button:not(:disabled) {
cursor: pointer;
}
button:disabled,
input:disabled,
textarea:disabled,
select:disabled {
cursor: not-allowed;
}
a {
color: var(--brand-dark);
text-underline-offset: 3px;
}
:focus-visible {
outline: 3px solid rgba(50, 185, 191, 0.34);
outline-offset: 3px;
}
::selection {
color: var(--ink);
background: rgba(82, 202, 209, 0.28);
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.component-card {
position: relative;
overflow: hidden;
padding: clamp(20px, 4vw, 30px);
border: 1px solid rgba(179, 211, 207, 0.78);
border-radius: 24px;
background: rgba(255, 255, 255, 0.96);
box-shadow: var(--shadow-card);
}
.component-card::before {
position: absolute;
top: 0;
left: 30px;
width: 52px;
height: 3px;
border-radius: 0 0 4px 4px;
background: var(--brand);
content: "";
}
.component-card--readonly {
box-shadow: 0 8px 24px rgba(35, 88, 86, 0.06);
}
.component-card__topline {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 12px;
}
.component-card__eyebrow {
margin: 0;
color: var(--brand-dark);
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.component-card__state {
display: inline-flex;
align-items: center;
gap: 6px;
flex: none;
padding: 5px 9px;
border-radius: 999px;
color: #3e6a55;
background: #eaf7e5;
font-size: 12px;
font-weight: 700;
}
.component-card__state::before {
width: 6px;
height: 6px;
border-radius: 50%;
background: #72ae55;
content: "";
}
.component-card__title {
max-width: 28ch;
margin: 0;
color: var(--ink);
font-size: clamp(21px, 3.8vw, 28px);
font-weight: 760;
letter-spacing: -0.035em;
line-height: 1.24;
}
.component-card__description {
max-width: 62ch;
margin: 10px 0 0;
color: var(--ink-soft);
font-size: 14px;
line-height: 1.72;
}
.component-card__body {
margin-top: 22px;
}
.component-card__pending {
display: inline-flex;
align-items: center;
gap: 8px;
margin: 18px 0 0;
color: var(--brand-dark);
font-size: 13px;
font-weight: 650;
}
.component-card__pending::before {
width: 12px;
height: 12px;
border: 2px solid rgba(20, 127, 133, 0.24);
border-top-color: var(--brand-dark);
border-radius: 50%;
animation: spin 0.8s linear infinite;
content: "";
}
.field-group {
display: grid;
gap: 9px;
}
.field-label {
color: var(--ink);
font-size: 13px;
font-weight: 700;
}
.field-hint {
margin: 0;
color: var(--ink-faint);
font-size: 12px;
line-height: 1.55;
}
.text-input,
.text-area,
.select-input {
width: 100%;
min-height: 50px;
border: 1px solid var(--line-strong);
border-radius: 14px;
color: var(--ink);
background: #fbfefd;
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
}
.text-input,
.select-input {
padding: 0 15px;
}
.text-area {
min-height: 128px;
padding: 14px 15px;
line-height: 1.65;
resize: vertical;
}
.text-input:hover:not(:disabled),
.text-area:hover:not(:disabled),
.select-input:hover:not(:disabled) {
border-color: #8fc4c1;
}
.text-input:focus,
.text-area:focus,
.select-input:focus {
border-color: var(--brand);
outline: none;
background: #fff;
box-shadow: 0 0 0 4px rgba(50, 185, 191, 0.12);
}
.text-input:disabled,
.text-area:disabled,
.select-input:disabled {
color: #62787d;
border-color: #dce8e6;
background: #f1f6f5;
opacity: 1;
}
.option-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin: 0;
padding: 0;
list-style: none;
}
.option-card {
position: relative;
display: grid;
width: 100%;
min-height: 92px;
align-content: center;
gap: 5px;
padding: 16px;
border: 1px solid var(--line);
border-radius: 17px;
color: var(--ink);
background: #f9fcfb;
text-align: left;
transition: transform 160ms ease, border-color 160ms ease, background 160ms ease, box-shadow 160ms ease;
}
.option-card:hover:not(:disabled) {
transform: translateY(-2px);
border-color: #91cbc7;
background: #fff;
box-shadow: 0 10px 24px rgba(35, 88, 86, 0.08);
}
.option-card--selected {
border-color: var(--brand);
background: var(--brand-soft);
box-shadow: inset 0 0 0 1px rgba(50, 185, 191, 0.24);
}
.option-card--selected::after {
position: absolute;
top: 12px;
right: 12px;
width: 9px;
height: 9px;
border: 3px solid #fff;
border-radius: 50%;
background: var(--brand-dark);
box-shadow: 0 0 0 1px var(--brand-dark);
content: "";
}
.option-card__label {
padding-right: 12px;
font-size: 15px;
font-weight: 720;
line-height: 1.35;
}
.option-card__description {
color: var(--ink-soft);
font-size: 12px;
line-height: 1.55;
}
.component-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
margin-top: 22px;
}
.primary-button,
.secondary-button,
.ghost-button {
min-height: 46px;
padding: 0 18px;
border-radius: 13px;
font-weight: 720;
transition: transform 160ms ease, box-shadow 160ms ease, background 160ms ease, border-color 160ms ease;
}
.primary-button {
border: 1px solid #197f84;
color: #fff;
background: linear-gradient(135deg, #219fa5, #167b81);
box-shadow: 0 10px 22px rgba(20, 127, 133, 0.22);
}
.primary-button:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 13px 28px rgba(20, 127, 133, 0.28);
}
.primary-button:disabled {
border-color: #9ac5c3;
background: #a8ccca;
box-shadow: none;
}
.secondary-button {
border: 1px solid var(--line-strong);
color: var(--ink);
background: #fff;
}
.secondary-button:hover:not(:disabled) {
border-color: #8fc4c1;
background: #f5fbfa;
}
.ghost-button {
border: 1px solid transparent;
color: var(--ink-soft);
background: transparent;
}
.ghost-button:hover:not(:disabled) {
color: var(--ink);
background: rgba(28, 119, 119, 0.07);
}
.read-only-value {
display: flex;
align-items: flex-start;
gap: 10px;
min-height: 50px;
padding: 14px 15px;
border: 1px solid #dfeae8;
border-radius: 14px;
color: #355158;
background: #f3f8f7;
font-size: 14px;
line-height: 1.55;
}
.read-only-value::before {
flex: none;
width: 7px;
height: 7px;
margin-top: 7px;
border-radius: 50%;
background: var(--lime);
content: "";
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 620px) {
.component-card {
padding: 21px 18px;
border-radius: 20px;
}
.component-card::before {
left: 20px;
}
.option-grid {
grid-template-columns: 1fr;
}
.component-actions {
align-items: stretch;
flex-direction: column-reverse;
}
.component-actions > button {
width: 100%;
}
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
*,
*::before,
*::after {
scroll-behavior: auto !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
+152
View File
@@ -0,0 +1,152 @@
export type ComposerMode = 'ui_only' | 'chat' | 'hybrid'
export type TimelineBlockType =
| 'text'
| 'component'
| 'resume_patch'
| 'status'
| 'error'
export type ComponentLifecycle =
| 'active'
| 'submitted'
| 'confirmed'
| 'dismissed'
| 'superseded'
| 'failed'
export type ResumeComponentName =
| 'privacy_consent'
| 'resume_phone_selector'
| 'resume_phone_input'
| 'resume_name_input'
| 'job_type_cards'
| 'anchor_type_cards'
| 'short_text'
| 'degree_selector'
| 'date_range_selector'
| 'choice_chips'
| 'experience_confirm'
| 'create_resume'
| string
export interface ChoiceOption {
value: string
label: string
description?: string
hint?: string
icon?: string
disabled?: boolean
recommended?: boolean
}
export interface ComposerConfig {
mode: ComposerMode
placeholder?: string
helper_text?: string
max_length?: number
disabled?: boolean
}
export interface RawTimelineBlock extends Record<string, unknown> {
id?: string
block_id?: string
type?: TimelineBlockType | string
stage?: string
turn?: number
role?: 'assistant' | 'user' | 'system' | string
text?: string
content?: unknown
title?: string
description?: string
component?: ResumeComponentName
component_name?: ResumeComponentName
data?: Record<string, unknown>
props?: Record<string, unknown>
value?: unknown
lifecycle?: ComponentLifecycle | string
submitted?: boolean
read_only?: boolean
created_at?: string
version?: number
}
export interface TimelineBlock {
id: string
type: TimelineBlockType
stage?: string
turn?: number
role: 'assistant' | 'user' | 'system'
text?: string
title?: string
description?: string
component?: ResumeComponentName
data: Record<string, unknown>
value?: unknown
lifecycle: ComponentLifecycle
submitted: boolean
createdAt?: string
version?: number
raw: RawTimelineBlock
}
export interface ResumeAgentEnvelope extends Record<string, unknown> {
draft_id?: string
session_id?: string
id?: string
revision?: number
stage?: string
turn?: number | Record<string, unknown> | null
turns?: unknown
session?: Record<string, unknown>
timeline?: unknown
blocks?: unknown
components?: unknown
composer?: ComposerConfig | ComposerMode | Record<string, unknown>
composer_mode?: ComposerMode
missing_fields?: string[]
gate?: string | Record<string, unknown> | null
resume_id?: string
trace_id?: string
data?: ResumeAgentEnvelope
}
export interface NormalizedResumeAgentState {
sessionId?: string
draftId?: string
revision: number
stage: string
turn: number
timeline: TimelineBlock[]
composer: ComposerConfig
missingFields: string[]
gate: ResumeAgentEnvelope['gate']
resumeId?: string
traceId?: string
}
export interface ComponentEventInput {
component_id: string
event: string
payload: unknown
}
export interface MessageInput {
content: string
}
export interface ComponentSubmission {
event?: string
payload: unknown
}
export interface ApiErrorPayload {
detail?: unknown
message?: string
trace_id?: string
error?: {
code?: string
message?: string
trace_id?: string
}
}
+86
View File
@@ -0,0 +1,86 @@
import type { ChoiceOption, ResumeComponentName } from '../types/resumeAgent'
export function stringValue(value: unknown, fallback = ''): string {
return typeof value === 'string' ? value : fallback
}
export function numberValue(value: unknown, fallback: number): number {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback
}
export function booleanValue(value: unknown, fallback = false): boolean {
return typeof value === 'boolean' ? value : fallback
}
export function stringArray(value: unknown): string[] {
if (!Array.isArray(value)) return []
return value.filter((item): item is string => typeof item === 'string')
}
export function recordValue(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: {}
}
export function optionList(value: unknown, fallback: ChoiceOption[] = []): ChoiceOption[] {
if (!Array.isArray(value)) return fallback
const options = value.flatMap((item): ChoiceOption[] => {
if (typeof item === 'string') {
const matchingFallback = fallback.find((option) => option.value === item)
return [matchingFallback || { value: item, label: item }]
}
const record = recordValue(item)
const optionValue = stringValue(record.value ?? record.id ?? record.key)
const label = stringValue(record.label ?? record.title ?? record.name, optionValue)
if (!optionValue && !label) return []
const matchingFallback = fallback.find((option) => option.value === optionValue)
return [{
value: optionValue || label,
label: label || matchingFallback?.label || optionValue,
description:
stringValue(record.description ?? record.subtitle ?? record.hint) ||
matchingFallback?.description,
hint: stringValue(record.hint) || undefined,
icon: stringValue(record.icon) || undefined,
disabled: booleanValue(record.disabled),
recommended: booleanValue(record.recommended, matchingFallback?.recommended ?? false),
}]
})
return options.length ? options : fallback
}
export function initialValue(data: Record<string, unknown>, fallback = ''): string {
return stringValue(data.value ?? data.default_value ?? data.initial_value, fallback)
}
export function canonicalComponentName(name?: ResumeComponentName): string {
const key = (name || '')
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
.replace(/[\s-]+/g, '_')
.toLowerCase()
const aliases: Record<string, string> = {
privacy_consent_card: 'privacy_consent',
resume_phone_selector_card: 'resume_phone_selector',
resume_phone_selector_input: 'resume_phone_selector',
resume_phone_input_card: 'resume_phone_input',
resume_name_input_card: 'resume_name_input',
job_type_card: 'job_type_cards',
job_type_cards_card: 'job_type_cards',
anchor_type_card: 'anchor_type_cards',
anchor_type_cards_card: 'anchor_type_cards',
short_text_input: 'short_text',
short_text_card: 'short_text',
degree_selector_card: 'degree_selector',
date_range_selector_card: 'date_range_selector',
choice_chip: 'choice_chips',
choice_chips_card: 'choice_chips',
experience_confirm_card: 'experience_confirm',
create_resume_card: 'create_resume',
}
return aliases[key] || key
}
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"noEmit": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"moduleResolution": "Bundler",
"skipLibCheck": true,
"noEmit": true,
"strict": true
},
"include": ["vite.config.ts"]
}
+20
View File
@@ -0,0 +1,20 @@
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, '.', '')
return {
plugins: [vue()],
server: {
host: '0.0.0.0',
port: 5173,
proxy: {
'/ai-api': {
target: env.VITE_API_PROXY_TARGET || 'http://localhost:8000',
changeOrigin: true,
},
},
},
}
})