feat: builder 简历生成 + 轻度优化 + 简历导入交付副本

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
hyp
2026-08-05 11:08:30 +08:00
co-authored by Claude
commit ae2d9b128d
191 changed files with 27719 additions and 0 deletions
+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
+309
View File
@@ -0,0 +1,309 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import AgentTimeline from './components/AgentTimeline.vue'
import AppHeader from './components/AppHeader.vue'
import ComposerBar from './components/ComposerBar.vue'
import EditResumePreview from './components/EditResumePreview.vue'
import FeatureNavigation from './components/FeatureNavigation.vue'
import ResumeImportPanel from './components/ResumeImportPanel.vue'
import { useResumeAgent } from './composables/useResumeAgent'
import { useResumeDocument } from './composables/useResumeDocument'
const {
sessionId,
revision,
stage,
timeline,
composer,
missingFields,
resumeId,
resumeHook: agentResumeHook,
traceId,
initializing,
pendingBlockId,
sendingMessage,
creatingResume,
resetting,
errorMessage,
aiStatus,
streamedAssistantText,
start,
refreshTimeline,
submitComponent,
sendMessage,
createResume,
resetSession,
clearError,
} = useResumeAgent()
const resumeDocument = useResumeDocument(sessionId)
const mobilePanel = ref<'chat' | 'resume'>('chat')
const displayedRevision = computed(() => resumeDocument.resume.value?.revision ?? revision.value)
const chatEnabled = computed(() => composer.value.mode !== 'ui_only')
const stageCode = computed(() => stage.value.toUpperCase().replaceAll('_', ' / '))
const stageLabels: Record<string, string> = {
starting: '准备会话',
PRIVACY_CONSENT: '隐私确认',
RESUME_SOURCE_SELECT: '选择创建方式',
RESUME_IMPORT_UPLOAD: '导入简历',
PHONE_SELECTION: '手机号授权',
MANUAL_PHONE_INPUT: '填写手机号',
PERSONAL_INFO: '基本信息',
JOB_TYPE_SELECT: '求职类型',
TARGET_POSITION: '目标职位',
TARGET_POSITION_MAJOR: '专业与方向',
TARGET_POSITION_RECOMMENDATION: '职位建议',
MINIMUM_READY: '准备创建',
RESUME_CREATING: '正在创建',
CREATE_FAILED: '等待重试',
BUILDER_CONVERSATION: '简历共创',
}
const stageLabel = computed(() => stageLabels[stage.value] || '整理简历')
watch(agentResumeHook, (value) => {
resumeDocument.resume.value = value ?? null
})
watch(sessionId, (value, previous) => {
if (!value || value !== previous) void resumeDocument.restoreOptimizationRuns()
})
watch(() => resumeDocument.resumeImport.value?.status, (status) => {
if (status === 'applied') void refreshTimeline()
})
async function retryConnection() {
clearError()
if (!sessionId.value) {
await start()
return
}
try {
await refreshTimeline()
} catch {
await start()
}
}
async function confirmReset() {
if (window.confirm('重新开始会清空当前简历共创记录。确定继续吗?')) await resetSession()
}
onMounted(start)
</script>
<template>
<div id="top" class="app-shell">
<AppHeader
:stage-label="stageLabel"
:revision="displayedRevision"
:session-id="sessionId"
:resetting="resetting"
@reset="confirmReset"
/>
<FeatureNavigation current="builder" />
<main class="workspace">
<div class="mobile-view-tabs" role="tablist" aria-label="移动端工作区视图">
<button
type="button"
role="tab"
:aria-selected="mobilePanel === 'chat'"
:class="{ 'mobile-view-tabs__tab--active': mobilePanel === 'chat' }"
@click="mobilePanel = 'chat'"
>对话</button>
<button
type="button"
role="tab"
:aria-selected="mobilePanel === 'resume'"
:class="{ 'mobile-view-tabs__tab--active': mobilePanel === 'resume' }"
@click="mobilePanel = 'resume'"
>简历</button>
</div>
<section
class="preview-panel"
:class="{ 'mobile-panel--active': mobilePanel === 'resume' }"
aria-label="简历预览与编辑"
>
<EditResumePreview :document="resumeDocument" />
</section>
<section
class="conversation"
:class="{ 'mobile-panel--active': mobilePanel === 'chat' }"
aria-labelledby="conversation-title"
>
<header class="conversation-heading">
<div>
<p>{{ stageCode }}</p>
<h1 id="conversation-title">把经历一步步写成简历</h1>
</div>
<span class="conversation-heading__live" :class="{ 'conversation-heading__live--busy': aiStatus }">
<i aria-hidden="true" />
{{ aiStatus || 'AI 共创中' }}
</span>
</header>
<ResumeImportPanel
v-if="stage === 'RESUME_IMPORT_UPLOAD'"
:document="resumeDocument"
:disabled="!sessionId || initializing"
/>
<AgentTimeline
:timeline="timeline"
:initializing="initializing"
:pending-block-id="pendingBlockId"
:creating-resume="creatingResume"
:error-message="errorMessage"
:trace-id="traceId"
:resume-id="resumeId"
:missing-fields="missingFields"
:ai-status="aiStatus"
:streamed-assistant-text="streamedAssistantText"
@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(1280px, calc(100% - 40px));
grid-template-columns: minmax(330px, .88fr) minmax(0, 1.35fr);
align-items: start;
gap: clamp(28px, 4vw, 64px);
margin: 0 auto;
padding: 38px 0 0;
}
.preview-panel {
position: sticky;
top: 92px;
display: flex;
min-width: 0;
height: calc(100vh - 116px);
align-items: center;
}
.preview-panel :deep(.edit-preview) {
position: static;
width: 100%;
max-height: 100%;
}
.conversation { min-width: 0; }
.mobile-view-tabs { display: none; }
.conversation-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
margin: 0 0 30px;
padding-bottom: 20px;
border-bottom: 1px solid rgba(190, 217, 213, .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: .08em;
}
.conversation-heading h1 {
max-width: 22ch;
margin: 8px 0 0;
color: var(--ink);
font-family: "Aptos Display", "MiSans", "PingFang SC", sans-serif;
font-size: 30px;
font-weight: 780;
letter-spacing: 0;
line-height: 1.18;
}
.conversation-heading__live {
display: inline-flex;
flex: none;
align-items: center;
gap: 6px;
max-width: 180px;
padding: 7px 10px;
border: 1px solid #cfe4d0;
border-radius: 999px;
color: #4b7551;
background: rgba(243, 250, 239, .86);
font-size: 10px;
font-weight: 750;
}
.conversation-heading__live i {
width: 6px;
height: 6px;
flex: none;
border-radius: 50%;
background: #7db75f;
box-shadow: 0 0 0 3px rgba(125, 183, 95, .14);
}
.conversation-heading__live--busy { color: var(--brand-dark); border-color: #a9d3cd; background: #eff9f7; }
.conversation-heading__live--busy i { background: var(--brand); animation: live-pulse 1s ease-in-out infinite; }
.app-footer {
display: flex;
width: min(1280px, calc(100% - 40px));
align-items: center;
justify-content: space-between;
gap: 20px;
margin: 28px auto 0;
padding: 24px 0 28px;
border-top: 1px solid rgba(194, 217, 214, .66);
color: var(--ink-faint);
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
font-size: 8px;
letter-spacing: .04em;
}
@keyframes live-pulse { 50% { opacity: .4; } }
@media (max-width: 850px) {
.workspace { width: min(100% - 28px, 760px); grid-template-columns: 1fr; gap: 22px; padding-top: 22px; }
.mobile-view-tabs { display: grid; grid-column: 1; grid-template-columns: 1fr 1fr; width: 100%; border-bottom: 1px solid var(--line); }
.mobile-view-tabs button { min-height: 42px; border: 0; border-bottom: 2px solid transparent; color: var(--ink-faint); background: transparent; font-size: 13px; font-weight: 700; }
.mobile-view-tabs__tab--active { border-bottom-color: var(--brand) !important; color: var(--ink) !important; }
.preview-panel, .conversation { display: none; grid-column: 1; }
.preview-panel { position: static; height: auto; align-items: stretch; }
.preview-panel :deep(.edit-preview) { max-height: none; }
.mobile-panel--active { display: block; }
.conversation-heading { margin-bottom: 22px; }
.app-footer { width: calc(100% - 28px); align-items: flex-start; flex-direction: column; gap: 5px; }
}
@media (max-width: 520px) {
.workspace { width: min(100% - 20px, 760px); }
.conversation-heading h1 { font-size: 26px; }
.conversation-heading__live { max-width: 130px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
}
</style>
+328
View File
@@ -0,0 +1,328 @@
import type {
ApiErrorPayload,
ComponentEventInput,
MessageInput,
ResumeAgentEnvelope,
ResumeImportView,
OptimizationRunView,
ResumePatchOperationInput,
SkillRecommendationCandidate,
BuilderStreamEvent,
} 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 && !(init.body instanceof FormData) && !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('Unable to connect to the resume service. Please retry.', 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) ||
`Resume service returned ${response.status}.`
throw new ResumeAgentApiError(message, response.status, payload)
}
return (body ?? {}) as T
}
async function requestBuilderSse(
path: string,
init: RequestInit,
onEvent: (event: BuilderStreamEvent) => void,
): Promise<ResumeAgentEnvelope> {
const headers = new Headers(init.headers)
headers.set('Accept', 'text/event-stream')
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('Unable to connect to the resume service. Please retry.', 0)
}
if (!response.ok) {
const body = await response.json().catch(() => undefined)
const payload = body && typeof body === 'object' ? (body as ApiErrorPayload) : undefined
throw new ResumeAgentApiError(payload?.error?.message || `Resume service returned ${response.status}.`, response.status, payload)
}
if (!response.body) throw new ResumeAgentApiError('The resume service did not return a stream.', 502)
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
let finalResponse: ResumeAgentEnvelope | null = null
const consumeFrame = (frame: string) => {
const event = frame.match(/^event:\s*(.+)$/m)?.[1]?.trim()
const dataLine = frame.match(/^data:\s*(.+)$/m)?.[1]
if (!event || !dataLine) return
let data: BuilderStreamEvent['data']
try {
data = JSON.parse(dataLine) as BuilderStreamEvent['data']
} catch {
return
}
const parsed = { event, data } as BuilderStreamEvent
onEvent(parsed)
if (event === 'error') {
const error = data as { code?: string; message?: string; status_code?: number }
throw new ResumeAgentApiError(error.message || 'Resume assistant stream failed. Please retry.', error.status_code || 502, {
error: { code: error.code, message: error.message },
})
}
if (event === 'complete') finalResponse = data as ResumeAgentEnvelope
}
while (true) {
const { value, done } = await reader.read()
buffer += decoder.decode(value || new Uint8Array(), { stream: !done })
let boundary = buffer.indexOf('\n\n')
while (boundary >= 0) {
consumeFrame(buffer.slice(0, boundary))
buffer = buffer.slice(boundary + 2)
boundary = buffer.indexOf('\n\n')
}
if (done) break
}
if (!finalResponse) throw new ResumeAgentApiError('Resume assistant did not return a final result.', 502)
return finalResponse
}
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,
})
},
sendMessageStream(
sessionId: string,
input: MessageInput,
onEvent: (event: BuilderStreamEvent) => void,
signal?: AbortSignal,
) {
return requestBuilderSse(
sessionPath(sessionId, '/messages/stream'),
{ method: 'POST', body: JSON.stringify(input), signal },
onEvent,
)
},
createResume(sessionId: string, signal?: AbortSignal) {
return request<ResumeAgentEnvelope>(sessionPath(sessionId, '/create'), {
method: 'POST',
body: JSON.stringify({}),
signal,
})
},
uploadResumeImport(sessionId: string, file: File, signal?: AbortSignal) {
const form = new FormData()
form.append("file", file)
return request<ResumeImportView>(sessionPath(sessionId, "/resume-imports"), {
method: "POST",
body: form,
signal,
})
},
getResumeImport(sessionId: string, importId: string, signal?: AbortSignal) {
return request<ResumeImportView>(
sessionPath(sessionId, `/resume-imports/${encodeURIComponent(importId)}`),
{ signal },
)
},
applyResumeImport(
sessionId: string,
importId: string,
expectedRevision: number,
signal?: AbortSignal,
) {
return request<ResumeAgentEnvelope>(
sessionPath(sessionId, `/resume-imports/${encodeURIComponent(importId)}/apply`),
{ method: "POST", body: JSON.stringify({ expected_revision: expectedRevision }), signal },
)
},
cancelResumeImport(sessionId: string, importId: string, signal?: AbortSignal) {
return request<ResumeImportView>(
sessionPath(sessionId, `/resume-imports/${encodeURIComponent(importId)}`),
{ method: "DELETE", signal },
)
},
deleteSession(sessionId: string, signal?: AbortSignal) {
return request<Record<string, unknown>>(sessionPath(sessionId), {
method: 'DELETE',
signal,
})
},
recommendSkills(sessionId: string, question: string, signal?: AbortSignal) {
return request<{ candidates: SkillRecommendationCandidate[] }>(
sessionPath(sessionId, '/resume/skills/recommend'),
{ method: 'POST', body: JSON.stringify({ question }), signal },
)
},
patchResume(
sessionId: string,
input: { expected_revision: number; operation: ResumePatchOperationInput },
signal?: AbortSignal,
) {
return request<ResumeAgentEnvelope>(sessionPath(sessionId, '/resume'), {
method: 'PATCH',
body: JSON.stringify(input),
signal,
})
},
generateProfileSummary(sessionId: string, signal?: AbortSignal) {
return request<ResumeAgentEnvelope>(sessionPath(sessionId, '/resume/profile-summary/generate'), {
method: 'POST',
body: JSON.stringify({}),
signal,
})
},
confirmProfileSummary(sessionId: string, signal?: AbortSignal) {
return request<ResumeAgentEnvelope>(sessionPath(sessionId, '/resume/profile-summary/confirm'), {
method: 'POST',
body: JSON.stringify({}),
signal,
})
},
rejectProfileSummary(sessionId: string, signal?: AbortSignal) {
return request<ResumeAgentEnvelope>(sessionPath(sessionId, '/resume/profile-summary/reject'), {
method: 'POST',
body: JSON.stringify({}),
signal,
})
},
optimizeEntry(sessionId: string, entryId: string, signal?: AbortSignal) {
return request<ResumeAgentEnvelope>(sessionPath(sessionId, '/resume/optimize'), {
method: 'POST',
body: JSON.stringify({ entry_id: entryId }),
signal,
})
},
confirmOptimize(sessionId: string, entryId: string, signal?: AbortSignal) {
return optimizeAction(sessionId, 'confirm', entryId, signal)
},
rejectOptimize(sessionId: string, entryId: string, signal?: AbortSignal) {
return optimizeAction(sessionId, 'reject', entryId, signal)
},
undoOptimize(sessionId: string, entryId: string, signal?: AbortSignal) {
return optimizeAction(sessionId, 'undo', entryId, signal)
},
setTargetPosition(sessionId: string, targetPosition: string, signal?: AbortSignal) {
return request<{ target_position: string; target_position_confirmed: boolean }>(
sessionPath(sessionId, '/target-position'),
{ method: 'POST', body: JSON.stringify({ target_position: targetPosition }), signal },
)
},
optimizeLight(sessionId: string, entryId: string, instruction?: string, signal?: AbortSignal) {
return request<OptimizationRunView>(sessionPath(sessionId, '/resume/optimize/light'), {
method: 'POST',
body: JSON.stringify({ entry_id: entryId, instruction }),
signal,
})
},
listActiveOptimizationRuns(sessionId: string, signal?: AbortSignal) {
return request<OptimizationRunView[]>(sessionPath(sessionId, '/resume/optimize/runs/active'), {
signal,
})
},
confirmOptimization(sessionId: string, runId: string, signal?: AbortSignal) {
return request<OptimizationRunView>(optimizationRunPath(sessionId, runId, '/confirm'), {
method: 'POST',
body: JSON.stringify({}),
signal,
})
},
rejectOptimization(sessionId: string, runId: string, signal?: AbortSignal) {
return request<OptimizationRunView>(optimizationRunPath(sessionId, runId, '/reject'), {
method: 'POST',
body: JSON.stringify({}),
signal,
})
},
}
function optimizeAction(sessionId: string, action: string, entryId: string, signal?: AbortSignal) {
return request<ResumeAgentEnvelope>(sessionPath(sessionId, `/resume/optimize/${action}`), {
method: 'POST',
body: JSON.stringify({ entry_id: entryId }),
signal,
})
}
function optimizationRunPath(sessionId: string, runId: string, suffix: string): string {
return sessionPath(sessionId, `/resume/optimize/runs/${encodeURIComponent(runId)}${suffix}`)
}
@@ -0,0 +1,60 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { ComponentSubmission } from '../types/resumeAgent'
import { 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 options = computed(() =>
optionList(props.data.options, [
{ value: 'again', label: '再添加一段' },
{ value: 'next', label: '进入下一项' },
]),
)
const summaryLabel = computed(() => {
const selected = typeof props.value === 'string' ? props.value : stringValue(props.data.value)
return options.value.find((option) => option.value === selected)?.label || '已记录'
})
function choose(value: string) {
if (props.readOnly || props.pending) return
emit('submit', { event: 'select', payload: { value } })
}
</script>
<template>
<FormCard
:eyebrow="stringValue(data.eyebrow, '继续完善')"
:title="stringValue(data.title, '还要再添加一段吗?')"
:description="stringValue(data.description, '可以继续添加同类经历,或进入下一项。')"
:read-only="readOnly"
:pending="pending"
>
<div v-if="readOnly" class="read-only-value">{{ summaryLabel }}</div>
<div v-else class="option-grid">
<button
v-for="option in options"
:key="option.value"
class="option-card"
type="button"
:disabled="pending || option.disabled"
@click="choose(option.value)"
>
<span class="option-card__label">{{ option.label }}</span>
<span v-if="option.description" class="option-card__description">{{ option.description }}</span>
</button>
</div>
</FormCard>
</template>
+312
View File
@@ -0,0 +1,312 @@
<script setup lang="ts">
import { nextTick, ref, watch } from 'vue'
import type { ComponentSubmission, TimelineBlock } from '../types/resumeAgent'
import BlockRenderer from './BlockRenderer.vue'
import ErrorCard from './ErrorCard.vue'
const props = withDefaults(
defineProps<{
timeline: TimelineBlock[]
initializing?: boolean
pendingBlockId?: string
creatingResume?: boolean
errorMessage?: string
traceId?: string
resumeId?: string
missingFields?: string[]
aiStatus?: string
streamedAssistantText?: string
}>(),
{
initializing: false,
pendingBlockId: '',
creatingResume: false,
errorMessage: '',
traceId: '',
resumeId: '',
missingFields: () => [],
aiStatus: '',
streamedAssistantText: '',
},
)
const emit = defineEmits<{
submit: [blockId: string, submission: ComponentSubmission]
create: []
retry: []
clearError: []
}>()
const endMarker = ref<HTMLElement | null>(null)
function forwardSubmit(blockId: string, submission: ComponentSubmission) {
emit('submit', blockId, submission)
}
watch(
() => [props.timeline.length, props.pendingBlockId, props.errorMessage, props.aiStatus, props.streamedAssistantText],
async ([nextLength, , nextError, nextStatus, nextStream], [previousLength, , previousError, previousStatus, previousStream]) => {
if (
Number(nextLength) <= Number(previousLength) &&
nextError === previousError &&
nextStatus === previousStatus &&
nextStream === previousStream
) return
await nextTick()
endMarker.value?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
},
)
</script>
<template>
<div class="timeline-wrap" aria-live="polite">
<div v-if="initializing && !timeline.length" class="timeline-skeleton" aria-label="正在打开简历对话">
<span style="--delay: 0ms" />
<span style="--delay: 90ms" />
<span style="--delay: 180ms" />
</div>
<div v-else-if="!timeline.length && !errorMessage" class="timeline-empty">
<span aria-hidden="true">+</span>
<h2>正在准备第一步</h2>
<p>简历共创会在这里逐步展开</p>
</div>
<ol v-else class="timeline-list">
<li
v-for="(block, index) in timeline"
:key="`${block.id}-${block.version || 1}`"
class="timeline-item"
:class="[`timeline-item--${block.type}`, `timeline-item--${block.role}`]"
>
<div class="timeline-item__rail" aria-hidden="true">
<span class="timeline-item__index">{{ String(index + 1).padStart(2, '0') }}</span>
<i />
</div>
<div class="timeline-item__content">
<BlockRenderer
:block="block"
:pending="pendingBlockId === block.id"
:creating-resume="creatingResume"
:resume-id="resumeId"
:missing-fields="missingFields"
@submit="forwardSubmit"
@create="emit('create')"
/>
</div>
</li>
<li v-if="errorMessage" class="timeline-item timeline-item--error-client">
<div class="timeline-item__rail" aria-hidden="true">
<span class="timeline-item__index">!</span>
<i />
</div>
<div class="timeline-item__content">
<ErrorCard
:message="errorMessage"
:trace-id="traceId"
retry-label="重新连接"
dismissible
@retry="emit('retry')"
@dismiss="emit('clearError')"
/>
</div>
</li>
<li v-if="aiStatus || streamedAssistantText" class="timeline-item timeline-item--stream timeline-item--assistant">
<div class="timeline-item__rail" aria-hidden="true">
<span class="timeline-item__index">AI</span>
<i />
</div>
<div class="timeline-item__content timeline-stream">
<p v-if="aiStatus" class="timeline-stream__status"><i aria-hidden="true" />{{ aiStatus }}</p>
<p v-if="streamedAssistantText" class="timeline-stream__text">{{ streamedAssistantText }}</p>
</div>
</li>
</ol>
<span ref="endMarker" class="timeline-end" aria-hidden="true" />
</div>
</template>
<style scoped>
.timeline-wrap {
position: relative;
min-height: 320px;
}
.timeline-list {
position: relative;
display: grid;
gap: 0;
margin: 0;
padding: 0;
list-style: none;
}
.timeline-list::before {
position: absolute;
top: 14px;
bottom: 8px;
left: 21px;
width: 1px;
background: linear-gradient(to bottom, var(--brand), #bddbd7 86%, transparent);
content: "";
}
.timeline-item {
position: relative;
display: grid;
grid-template-columns: 44px minmax(0, 1fr);
gap: 15px;
padding-bottom: 24px;
}
.timeline-item__rail {
position: relative;
z-index: 1;
display: grid;
justify-items: center;
padding-top: 6px;
}
.timeline-item__rail i {
position: absolute;
top: 11px;
width: 11px;
height: 11px;
border: 3px solid #f4fbfa;
border-radius: 50%;
background: var(--brand);
box-shadow: 0 0 0 1px #8acbc7;
}
.timeline-item__index {
position: absolute;
top: 27px;
padding: 2px 3px;
color: var(--ink-faint);
background: #f2faf8;
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
font-size: 8px;
font-weight: 700;
letter-spacing: 0.06em;
}
.timeline-item__content {
min-width: 0;
}
.timeline-item--text {
padding-bottom: 15px;
}
.timeline-item--text .timeline-item__rail i {
width: 8px;
height: 8px;
border-width: 2px;
background: #a1cbc7;
}
.timeline-item--user .timeline-item__rail i {
background: var(--ink-soft);
}
.timeline-item--resume_patch .timeline-item__rail i {
background: var(--lime);
}
.timeline-item--error-client .timeline-item__rail i,
.timeline-item--error .timeline-item__rail i {
background: var(--danger);
}
.timeline-item--stream .timeline-item__rail i { background: var(--brand-dark); }
.timeline-stream { display: grid; gap: 8px; padding: 12px 14px; border: 1px solid #cfe4e0; border-radius: 7px; background: #fff; }
.timeline-stream__status { display: inline-flex; align-items: center; gap: 7px; margin: 0; color: var(--brand-dark); font-size: 11px; font-weight: 700; }
.timeline-stream__status i { width: 7px; height: 7px; border-radius: 50%; background: #75b495; animation: status-pulse 1s ease-in-out infinite; }
.timeline-stream__text { margin: 0; color: var(--ink-soft); font-size: 13px; line-height: 1.7; white-space: pre-wrap; }
.timeline-end {
display: block;
height: 1px;
}
.timeline-skeleton {
display: grid;
gap: 14px;
padding: 10px 0 0 58px;
}
.timeline-skeleton span {
display: block;
width: min(100%, 580px);
height: 76px;
border-radius: 18px;
background: linear-gradient(100deg, rgba(221, 237, 234, 0.7) 30%, rgba(255, 255, 255, 0.95) 48%, rgba(221, 237, 234, 0.7) 66%) 0 0 / 260% 100%;
animation: skeleton-sheen 1.5s ease-in-out infinite;
animation-delay: var(--delay);
}
.timeline-skeleton span:nth-child(2) {
height: 210px;
}
.timeline-empty {
display: grid;
min-height: 260px;
place-items: center;
align-content: center;
text-align: center;
}
.timeline-empty > span {
display: grid;
width: 46px;
height: 46px;
place-items: center;
border-radius: 15px;
color: var(--brand-dark);
background: var(--brand-soft);
font-size: 22px;
}
.timeline-empty h2 {
margin: 13px 0 0;
color: var(--ink);
font-size: 16px;
}
.timeline-empty p {
margin: 5px 0 0;
color: var(--ink-faint);
font-size: 12px;
}
@keyframes skeleton-sheen {
to {
background-position: 100% 0;
}
}
@keyframes status-pulse {
50% { opacity: .35; }
}
@media (max-width: 620px) {
.timeline-item {
grid-template-columns: 29px minmax(0, 1fr);
gap: 9px;
}
.timeline-list::before {
left: 13px;
}
.timeline-item__index {
display: none;
}
.timeline-skeleton {
padding-left: 38px;
}
}
</style>
@@ -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>
+243
View File
@@ -0,0 +1,243 @@
<script setup lang="ts">
withDefaults(
defineProps<{
stageLabel: string
revision?: number
sessionId?: string
resetting?: boolean
showReset?: boolean
}>(),
{ revision: 0, sessionId: '', resetting: false, showReset: true },
)
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
v-if="showReset"
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>
+261
View File
@@ -0,0 +1,261 @@
<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 AddAnotherCard from './AddAnotherCard.vue'
import ChoiceChips from './ChoiceChips.vue'
import CompetitionFields from './CompetitionFields.vue'
import CreateResumeCard from './CreateResumeCard.vue'
import CustomCardPicker from './CustomCardPicker.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 ProgressCard from './ProgressCard.vue'
import RecordFields from './RecordFields.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 TagsInput from './TagsInput.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"
/>
<CustomCardPicker
v-else-if="componentName === 'custom_card_picker'"
: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"
/>
<RecordFields
v-else-if="componentName === 'anchor_fields' || componentName === 'record_fields'"
: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"
/>
<TagsInput
v-else-if="componentName === 'tags_input'"
:data="block.data"
:value="block.value"
:read-only="readOnly"
:pending="pending"
@submit="submit"
/>
<CompetitionFields
v-else-if="componentName === 'competition_fields'"
:data="block.data"
:value="block.value"
:read-only="readOnly"
:pending="pending"
@submit="submit"
/>
<AddAnotherCard
v-else-if="componentName === 'add_another'"
:data="block.data"
:value="block.value"
:read-only="readOnly"
:pending="pending"
@submit="submit"
/>
<ProgressCard
v-else-if="componentName === 'progress_card'"
:data="block.data"
:read-only="readOnly"
:pending="pending"
@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>
+144
View File
@@ -0,0 +1,144 @@
<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 skippable = computed(() => booleanValue(props.data.skippable))
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 },
})
}
function skip() {
if (props.readOnly || props.pending) return
emit('submit', { event: 'skip', payload: {} })
}
</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 v-if="skippable" class="secondary-button" type="button" :disabled="pending" @click="skip">
暂时跳过
</button>
<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>
@@ -0,0 +1,111 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { ComponentSubmission } from '../types/resumeAgent'
import { 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 sourceValues = computed(() => recordValue(props.value ?? props.data.value))
const name = ref(stringValue(sourceValues.value.name))
const award = ref(stringValue(sourceValues.value.award))
const date = ref(stringValue(sourceValues.value.date))
const description = ref(stringValue(sourceValues.value.description))
const validationError = ref('')
watch(sourceValues, (next) => {
name.value = stringValue(next.name)
award.value = stringValue(next.award)
date.value = stringValue(next.date)
description.value = stringValue(next.description)
})
const summaryLabel = computed(() =>
[name.value, award.value, date.value].filter(Boolean).join(' · ') || '已跳过',
)
function submit() {
if (props.readOnly || props.pending) return
if (!name.value.trim()) {
validationError.value = '请填写竞赛名称。'
return
}
if (!award.value.trim()) {
validationError.value = '请填写获奖名称。'
return
}
if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(date.value.trim())) {
validationError.value = '获奖时间格式为 YYYY-MM,例如 2024-04。'
return
}
validationError.value = ''
emit('submit', {
event: 'submit',
payload: {
value: name.value.trim(),
name: name.value.trim(),
award: award.value.trim(),
date: date.value.trim(),
description: description.value.trim(),
},
})
}
function skip() {
if (props.readOnly || props.pending) return
emit('submit', { event: 'skip', payload: {} })
}
</script>
<template>
<FormCard
:eyebrow="stringValue(data.eyebrow, '竞赛经历')"
:title="stringValue(data.title, '补充竞赛获奖')"
:description="stringValue(data.description, '填写竞赛名称、奖项和获奖月份,描述可以留空。')"
:read-only="readOnly"
:pending="pending"
>
<div v-if="readOnly" class="read-only-value">{{ summaryLabel }}</div>
<form v-else @submit.prevent="submit">
<div class="field-group">
<label class="field-label" for="competition-name">竞赛名称</label>
<input id="competition-name" v-model="name" class="text-input" type="text" :disabled="pending" placeholder="例如:蓝桥杯" />
</div>
<div class="field-group">
<label class="field-label" for="competition-award">获奖名称</label>
<input id="competition-award" v-model="award" class="text-input" type="text" :disabled="pending" placeholder="例如:全国二等奖" />
</div>
<div class="field-group">
<label class="field-label" for="competition-date">获奖时间</label>
<input id="competition-date" v-model="date" class="text-input" type="month" :disabled="pending" />
</div>
<div class="field-group">
<label class="field-label" for="competition-description">经历描述可选</label>
<textarea id="competition-description" v-model="description" class="text-area" rows="2" :disabled="pending" placeholder="可以补充赛题、排名或收获"></textarea>
</div>
<p v-if="validationError" class="validation-error" role="alert">{{ validationError }}</p>
<div class="component-actions">
<button class="secondary-button" type="button" :disabled="pending" @click="skip">暂时跳过</button>
<button class="primary-button" type="submit" :disabled="pending">确认</button>
</div>
</form>
</FormCard>
</template>
<style scoped>
.validation-error {
margin: 10px 0 0;
color: var(--danger);
font-size: 12px;
}
</style>
+192
View File
@@ -0,0 +1,192 @@
<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);
pointer-events: none;
}
.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,30 @@
<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] }>()
</script>
<template>
<SingleChoiceCards
:data="data"
:value="value"
:read-only="readOnly"
:pending="pending"
default-title="添加简历卡片"
default-description="选择一类内容继续补充或完成本次完善"
eyebrow="继续完善"
field="card_type"
@submit="emit('submit', $event)"
/>
</template>
@@ -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>
@@ -0,0 +1,94 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
const props = defineProps<{ basics: Record<string, unknown>; targetPosition: string; busy: boolean }>()
const emit = defineEmits<{ save: [fields: Record<string, string>] }>()
const basicFields: Array<[string, string]> = [
['name', '姓名'],
['phone', '手机号'],
['email', '邮箱'],
['city', '所在地'],
['portfolio_url', '作品集链接'],
]
const editing = ref(false)
const draft = ref<Record<string, string>>({})
const error = ref('')
const display = computed(() => basicFields.map(([key, label]) => ({
key,
label,
value: String(props.basics[key] || (key === 'phone' ? props.basics.masked_phone || '' : '') || ''),
})))
function startEdit() {
error.value = ''
draft.value = Object.fromEntries(display.value.map(({ key, value }) => [key, value]))
editing.value = true
}
function save() {
error.value = ''
const changed = Object.fromEntries(
Object.entries(draft.value)
.map(([key, value]) => [key, value.trim()])
.filter(([key, value]) => value !== String(props.basics[key] || '').trim()),
)
const phone = String(changed.phone || '')
if (phone && phone === String(props.basics.masked_phone || '')) {
delete changed.phone
} else if (phone.includes('*')) {
error.value = '当前手机号已脱敏显示,修改请输入完整的 11 位手机号。'
return
}
editing.value = false
if (Object.keys(changed).length) emit('save', changed)
}
</script>
<template>
<section class="edit-preview__block" :aria-busy="busy">
<header>
<div>
<h3>{{ display[0]?.value || '未命名简历' }}</h3>
<p>{{ targetPosition }}</p>
</div>
<button v-if="!editing" type="button" class="preview-button" :disabled="busy" @click="startEdit">编辑</button>
</header>
<form v-if="editing" class="edit-preview__form" @submit.prevent="save">
<label v-for="field in display" :key="field.key">
<span>{{ field.label }}</span>
<input v-model="draft[field.key]" :type="field.key === 'email' ? 'email' : field.key === 'phone' ? 'tel' : 'text'" />
</label>
<p v-if="error" class="edit-preview__error" role="alert">{{ error }}</p>
<div class="edit-preview__actions">
<button type="button" class="preview-button" @click="editing = false">取消</button>
<button type="submit" class="preview-button preview-button--primary">保存</button>
</div>
</form>
<dl v-else class="edit-preview__fields">
<template v-for="field in display.slice(1)" :key="field.key">
<dt>{{ field.label }}</dt>
<dd>{{ field.value || '未填写' }}</dd>
</template>
</dl>
</section>
</template>
<style scoped>
.edit-preview__block { display: grid; gap: 10px; padding: 17px 0; border-bottom: 1px solid var(--line); }
.edit-preview__block > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
.edit-preview__block h3 { margin: 0; color: var(--ink); font-size: 13px; }
.edit-preview__block header p { margin: 4px 0 0; color: var(--brand-dark); font-size: 10px; font-weight: 700; }
.preview-button { min-height: 31px; padding: 0 9px; border: 1px solid var(--line-strong); border-radius: 6px; color: var(--ink-soft); background: #fff; font-size: 10px; font-weight: 700; }
.preview-button--primary { color: #fff; border-color: #1c858a; background: #1c858a; }
.preview-button:disabled { opacity: .55; }
.edit-preview__fields { display: grid; grid-template-columns: minmax(64px, auto) minmax(0, 1fr); gap: 5px 12px; margin: 0; font-size: 11px; }
.edit-preview__fields dt { color: var(--ink-faint); }
.edit-preview__fields dd { min-width: 0; margin: 0; overflow-wrap: anywhere; color: var(--ink-soft); }
.edit-preview__form { display: grid; gap: 8px; }
.edit-preview__form label { display: grid; gap: 4px; color: var(--ink-soft); font-size: 10px; font-weight: 700; }
.edit-preview__form input { width: 100%; min-width: 0; padding: 8px 9px; border: 1px solid var(--line-strong); border-radius: 6px; color: var(--ink); background: #fbfefd; font: inherit; font-size: 11px; }
.edit-preview__actions { display: flex; justify-content: flex-end; gap: 6px; }
.edit-preview__error { margin: 0; color: #b3402a; font-size: 10px; }
</style>
@@ -0,0 +1,113 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { useResumeDocument } from '../composables/useResumeDocument'
import EditBasicsCard from './EditBasicsCard.vue'
import EditSkillsCard from './EditSkillsCard.vue'
import ResumeEntryCard from './ResumeEntryCard.vue'
const props = defineProps<{ document: ReturnType<typeof useResumeDocument> }>()
const resume = computed(() => props.document.resume.value)
const basics = computed(() => resume.value?.content.basics || {})
const targetPosition = computed(() =>
String(resume.value?.content.target?.position || resume.value?.content.target?.target_position || '通用简历'),
)
const profileSummary = computed(() => resume.value?.content.profile_summary || null)
const skillGroups = computed(() => resume.value?.content.skill_groups || [])
const basicsBusy = computed(() => props.document.busyEntryId.value === 'basics')
const skillsBusy = computed(() => props.document.skillsBusy.value || props.document.busyEntryId.value === 'skills')
const summaryBusy = computed(() => props.document.summaryBusy.value)
const summaryEditing = ref(false)
const summaryDraft = ref('')
function startSummaryEdit() {
summaryDraft.value = profileSummary.value?.content || ''
summaryEditing.value = true
}
async function saveSummary() {
const content = summaryDraft.value.trim()
if (!content) return
if (await props.document.updateProfileSummary(content)) summaryEditing.value = false
}
</script>
<template>
<aside class="edit-preview" aria-label="简历预览和编辑">
<header class="edit-preview__heading">
<div>
<p>EDIT PREVIEW</p>
<h2>简历预览</h2>
</div>
<span v-if="resume">R{{ resume.revision }}</span>
</header>
<div v-if="!resume" class="edit-preview__empty">
<strong>尚未创建简历</strong>
<p>完成引导后简历内容会实时显示在这里</p>
</div>
<template v-else>
<EditBasicsCard :basics="basics" :target-position="targetPosition" :busy="basicsBusy" @save="document.updateBasics" />
<section class="edit-preview__block" :aria-busy="summaryBusy">
<header>
<h3>个人总结</h3>
<button v-if="!summaryEditing" type="button" class="preview-button" :disabled="summaryBusy" @click="startSummaryEdit">编辑</button>
</header>
<form v-if="summaryEditing" class="edit-preview__form" @submit.prevent="saveSummary">
<textarea v-model="summaryDraft" rows="5" maxlength="600" placeholder="填写个人总结" />
<div class="edit-preview__actions">
<button type="button" class="preview-button" @click="summaryEditing = false">取消</button>
<button type="submit" class="preview-button preview-button--primary" :disabled="!summaryDraft.trim()">保存</button>
</div>
</form>
<p v-else class="edit-preview__copy">{{ profileSummary?.content || '未填写个人总结' }}</p>
</section>
<EditSkillsCard :skill-groups="skillGroups" :busy="skillsBusy" @save="document.updateSkillGroups" />
<section v-for="section in resume.content.sections" :key="section.id" class="edit-preview__section">
<header><h3>{{ section.heading }}</h3><span>{{ section.items.length }}</span></header>
<div class="edit-preview__entries">
<ResumeEntryCard
v-for="item in section.items"
:key="item.id"
mode="edit"
:entry="item"
:kind="section.kind"
:busy="document.busyEntryId.value === item.id"
@update-entry="document.updateEntry"
@delete-entry="document.deleteEntry"
@undo-optimize="document.undoOptimize"
/>
</div>
</section>
</template>
</aside>
</template>
<style scoped>
.edit-preview { min-width: 0; max-height: calc(100vh - 154px); overflow-y: auto; padding: 0 5px 26px 0; scrollbar-color: #b9d3cf transparent; scrollbar-width: thin; }
.edit-preview__heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding-bottom: 15px; border-bottom: 1px solid var(--line); }
.edit-preview__heading p { margin: 0; color: var(--brand-dark); font-family: ui-monospace, "SFMono-Regular", Consolas, monospace; font-size: 9px; font-weight: 800; }
.edit-preview__heading h2 { margin: 5px 0 0; color: var(--ink); font-size: 18px; }
.edit-preview__heading > span { padding: 5px 7px; border: 1px solid var(--line); border-radius: 6px; color: var(--ink-faint); background: #fff; font-size: 9px; }
.edit-preview__empty { display: grid; min-height: 220px; place-content: center; gap: 7px; color: var(--ink-faint); text-align: center; }
.edit-preview__empty strong { color: var(--ink); font-size: 14px; }
.edit-preview__empty p { margin: 0; font-size: 11px; }
.edit-preview__block { display: grid; gap: 10px; padding: 17px 0; border-bottom: 1px solid var(--line); }
.edit-preview__block > header, .edit-preview__section > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
.edit-preview__block h3, .edit-preview__section h3 { margin: 0; color: var(--ink); font-size: 13px; }
.preview-button { min-height: 31px; padding: 0 9px; border: 1px solid var(--line-strong); border-radius: 6px; color: var(--ink-soft); background: #fff; font-size: 10px; font-weight: 700; }
.preview-button--primary { color: #fff; border-color: #1c858a; background: #1c858a; }
.preview-button:disabled { opacity: .55; }
.edit-preview__copy { margin: 0; color: var(--ink-soft); font-size: 11px; line-height: 1.7; white-space: pre-wrap; }
.edit-preview__form { display: grid; gap: 8px; }
.edit-preview__form textarea { width: 100%; min-width: 0; padding: 8px 9px; border: 1px solid var(--line-strong); border-radius: 6px; color: var(--ink); background: #fbfefd; font: inherit; font-size: 11px; resize: vertical; line-height: 1.6; }
.edit-preview__actions { display: flex; justify-content: flex-end; gap: 6px; }
.edit-preview__section { display: grid; gap: 9px; padding-top: 18px; }
.edit-preview__section + .edit-preview__section { margin-top: 18px; border-top: 1px solid var(--line); }
.edit-preview__section > header span { color: var(--ink-faint); font-size: 9px; }
.edit-preview__entries { display: grid; gap: 9px; }
</style>
@@ -0,0 +1,83 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { SkillGroup } from '../types/resumeAgent'
const props = defineProps<{ skillGroups: SkillGroup[]; busy: boolean }>()
const emit = defineEmits<{ save: [skills: string[]] }>()
const flatSkills = computed(() => props.skillGroups.flatMap((group) => group.skills))
const editing = ref(false)
const draft = ref<string[]>([])
const input = ref('')
function startEdit() {
draft.value = [...new Set(flatSkills.value)]
input.value = ''
editing.value = true
}
function addSkill() {
const skill = input.value.trim()
if (!skill || draft.value.some((item) => item.toLocaleLowerCase() === skill.toLocaleLowerCase())) return
draft.value = [...draft.value, skill]
input.value = ''
}
function removeSkill(skill: string) {
draft.value = draft.value.filter((item) => item !== skill)
}
function save() {
editing.value = false
emit('save', draft.value)
}
</script>
<template>
<section class="edit-preview__block" :aria-busy="busy">
<header>
<h3>技能</h3>
<button v-if="!editing" type="button" class="preview-button" :disabled="busy" @click="startEdit">编辑</button>
</header>
<form v-if="editing" class="edit-preview__form" @submit.prevent="save">
<div class="edit-preview__tags">
<span v-for="skill in draft" :key="skill">{{ skill }}<button type="button" :aria-label="`删除 ${skill}`" @click="removeSkill(skill)">x</button></span>
</div>
<div class="edit-preview__skill-add">
<input v-model="input" type="text" placeholder="添加技能" @keydown.enter.prevent="addSkill" />
<button type="button" class="preview-button" @click="addSkill">添加</button>
</div>
<div class="edit-preview__actions">
<button type="button" class="preview-button" @click="editing = false">取消</button>
<button type="submit" class="preview-button preview-button--primary">保存</button>
</div>
</form>
<div v-else-if="skillGroups.length" class="edit-preview__skill-groups">
<div v-for="group in skillGroups" :key="group.category">
<strong>{{ group.category }}</strong>
<span>{{ group.skills.join(' / ') }}</span>
</div>
</div>
<p v-else class="edit-preview__copy">未填写技能</p>
</section>
</template>
<style scoped>
.edit-preview__block { display: grid; gap: 10px; padding: 17px 0; border-bottom: 1px solid var(--line); }
.edit-preview__block > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
.edit-preview__block h3 { margin: 0; color: var(--ink); font-size: 13px; }
.preview-button { min-height: 31px; padding: 0 9px; border: 1px solid var(--line-strong); border-radius: 6px; color: var(--ink-soft); background: #fff; font-size: 10px; font-weight: 700; }
.preview-button--primary { color: #fff; border-color: #1c858a; background: #1c858a; }
.preview-button:disabled { opacity: .55; }
.edit-preview__copy { margin: 0; color: var(--ink-soft); font-size: 11px; line-height: 1.7; white-space: pre-wrap; }
.edit-preview__form { display: grid; gap: 8px; }
.edit-preview__form input { width: 100%; min-width: 0; padding: 8px 9px; border: 1px solid var(--line-strong); border-radius: 6px; color: var(--ink); background: #fbfefd; font: inherit; font-size: 11px; }
.edit-preview__actions { display: flex; justify-content: flex-end; gap: 6px; }
.edit-preview__tags { display: flex; min-height: 36px; flex-wrap: wrap; gap: 5px; padding: 6px; border: 1px solid var(--line); border-radius: 6px; }
.edit-preview__tags span { display: inline-flex; align-items: center; gap: 4px; padding: 3px 6px; border-radius: 5px; color: #31565b; background: #e7f3f0; font-size: 10px; }
.edit-preview__tags button { width: 15px; height: 15px; padding: 0; border: 0; color: currentColor; background: transparent; }
.edit-preview__skill-add { display: flex; gap: 6px; }
.edit-preview__skill-groups { display: grid; gap: 4px; font-size: 11px; }
.edit-preview__skill-groups strong { margin-right: 8px; color: var(--ink-faint); font-size: 10px; }
.edit-preview__skill-groups span { color: var(--ink-soft); overflow-wrap: anywhere; }
</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,131 @@
<script setup lang="ts">
import { computed } 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 summary = computed(() => recordValue(props.data.summary ?? props.data.experience ?? props.data.value ?? props.value))
const proposal = computed(() => (props.data.ai_proposal ?? null) as { optimized_description: string; changes?: string[]; uncovered_facts?: string[] } | null)
const uncoveredFacts = computed(() => (proposal.value?.uncovered_facts ?? []).filter((fact) => String(fact).trim()))
const originalDescription = computed(() => stringValue(summary.value.description))
const optimizationUnavailable = computed(() => booleanValue(props.data.optimization_unavailable))
const FIELD_LABELS: Record<string, string> = {
school: '学校名称',
major: '专业',
degree: '学历',
company: '公司名称',
position: '职位',
project_name: '项目名称',
project_role: '项目角色',
organization: '组织名称',
role: '担任角色',
start_date: '开始时间',
end_date_or_present: '结束时间',
description: '经历描述',
title: '名称',
name: '名称',
award: '奖项',
date: '日期',
value: '内容',
}
const fields = computed(() => {
const ignored = new Set(['id', 'provenance', 'highlights', 'bullets', 'description'])
const labels = recordValue(props.data.labels)
return Object.entries(summary.value)
.filter(([key, value]) => !ignored.has(key) && ['string', 'number'].includes(typeof value))
.map(([key, value]) => ({ key, label: stringValue(labels[key], FIELD_LABELS[key] || key), value: String(value) }))
})
const confirmed = computed(() => booleanValue(props.data.confirmed, booleanValue(recordValue(props.value).confirmed)))
function confirm(useOptimized: boolean) {
emit('submit', { event: 'confirm', payload: { value: true, confirmed: true, use_optimized: useOptimized } })
}
function revise() {
emit('submit', { event: 'edit', payload: { value: false, confirmed: false, field: props.data.edit_field } })
}
function reviseWithUncovered() {
emit('submit', {
event: 'revise',
payload: { instruction: `请将以下未覆盖的事实补进优化稿:${uncoveredFacts.value.join('')},其他内容保持不变。` },
})
}
</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>
<section v-if="originalDescription || proposal" class="experience-copy">
<div><h4>原始描述</h4><p>{{ originalDescription || '未填写经历描述。' }}</p></div>
<div v-if="proposal" class="experience-copy__proposal"><h4>候选优化稿</h4><p>{{ proposal.optimized_description }}</p>
<section v-if="uncoveredFacts.length" class="experience-copy__uncovered" aria-label="优化稿未覆盖的事实">
<h4>优化稿未覆盖以下事实选择保留原文可避免丢失</h4>
<ul><li v-for="fact in uncoveredFacts" :key="fact">{{ fact }}</li></ul>
</section>
</div>
</section>
<p v-if="optimizationUnavailable" class="experience-unavailable" role="status">
优化服务暂时不可用已保留你的原始内容请稍后重试或直接确认加入简历
</p>
<p v-if="!fields.length && !originalDescription" 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 v-if="proposal && uncoveredFacts.length" class="secondary-button" type="button" :disabled="pending" @click="reviseWithUncovered">将未覆盖事实补进优化稿</button>
<button v-if="proposal" class="secondary-button" type="button" :disabled="pending" @click="confirm(false)">保留原文</button>
<button class="primary-button" type="button" :disabled="pending" @click="confirm(Boolean(proposal))">{{ proposal ? '使用优化稿' : '确认加入简历' }}</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; 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-empty, .experience-unavailable { margin: 14px 0 0; color: var(--ink-soft); font-size: 13px; line-height: 1.65; }
.experience-unavailable { color: #766131; }
.experience-copy { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--line); }
.experience-copy > div { min-width: 0; }
.experience-copy__proposal { padding-left: 14px; border-left: 2px solid #78a66d; }
.experience-copy h4 { margin: 0 0 6px; color: var(--ink-faint); font-size: 11px; }
.experience-copy p { margin: 0; overflow-wrap: anywhere; color: var(--ink-soft); font-size: 13px; line-height: 1.65; white-space: pre-wrap; }
.experience-copy__uncovered { margin-top: 10px; padding-top: 8px; border-top: 1px dashed var(--line); }
.experience-copy__uncovered ul { margin: 4px 0 0; padding-left: 18px; color: #766131; font-size: 12px; line-height: 1.6; }
.confirmation-note { margin-top: 14px; color: #4f765b; font-size: 13px; font-weight: 700; }
@media (max-width: 540px) { .experience-fields, .experience-copy { grid-template-columns: 1fr; } .experience-copy__proposal { padding-top: 12px; padding-left: 0; border-top: 1px solid var(--line); border-left: 0; } }
</style>
@@ -0,0 +1,66 @@
<script setup lang="ts">
defineProps<{ current: 'builder' | 'deep' }>()
</script>
<template>
<nav class="feature-nav" aria-label="简历功能">
<div class="feature-nav__inner">
<a href="/builder" :aria-current="current === 'builder' ? 'page' : undefined">
<span class="feature-nav__index">01</span>
<span><strong>简历生成</strong><small>引导录入与 STAR 优化</small></span>
</a>
</div>
</nav>
</template>
<style scoped>
.feature-nav {
border-bottom: 1px solid rgba(190, 218, 214, 0.56);
background: #fff;
}
.feature-nav__inner {
display: flex;
width: min(1180px, calc(100% - 36px));
min-height: 54px;
align-items: stretch;
gap: 3px;
margin: 0 auto;
}
.feature-nav a {
position: relative;
display: flex;
min-width: 220px;
align-items: center;
gap: 10px;
padding: 9px 16px;
color: var(--ink-faint);
text-decoration: none;
}
.feature-nav a::after {
position: absolute;
right: 16px;
bottom: -1px;
left: 16px;
height: 2px;
background: transparent;
content: '';
}
.feature-nav a:hover,
.feature-nav a[aria-current='page'] { color: var(--ink); }
.feature-nav a[aria-current='page']::after { background: var(--brand); }
.feature-nav__index { font-family: ui-monospace, "SFMono-Regular", Consolas, monospace; font-size: 9px; font-weight: 800; }
.feature-nav a > span:last-child { display: grid; gap: 2px; }
.feature-nav strong { font-size: 12px; }
.feature-nav small { color: var(--ink-faint); font-size: 9px; }
@media (max-width: 560px) {
.feature-nav__inner { width: 100%; }
.feature-nav a { min-width: 0; flex: 1; justify-content: center; padding-inline: 8px; }
.feature-nav small { display: none; }
}
</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: 'internship', 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,73 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { useResumeDocument } from '../composables/useResumeDocument'
import ResumeEntryCard from './ResumeEntryCard.vue'
const props = defineProps<{ document: ReturnType<typeof useResumeDocument> }>()
const resume = computed(() => props.document.resume.value)
const sections = computed(() => resume.value?.content.sections.filter((section) => section.items.length) || [])
const entryCount = computed(() => sections.value.reduce((count, section) => count + section.items.length, 0))
</script>
<template>
<section class="light-workspace" aria-labelledby="light-workspace-title">
<header class="light-workspace__heading">
<div>
<p>STAR POLISH</p>
<h2 id="light-workspace-title">轻度优化</h2>
<span>仅整理已有事实不分析具体 JD也不会追加深度追问</span>
</div>
<strong>{{ entryCount }} 段经历</strong>
</header>
<div v-if="document.errorMessage.value" class="light-workspace__error" role="alert">
<p>{{ document.errorMessage.value }}</p>
<button type="button" aria-label="关闭错误提示" @click="document.clearError">x</button>
</div>
<div v-if="!entryCount" class="light-workspace__empty">
完成至少一段经历录入后可以在这里生成 STAR 结构化候选稿
</div>
<section v-for="section in sections" :key="section.id" class="light-workspace__section">
<header><h3>{{ section.heading }}</h3><span>{{ section.items.length }}</span></header>
<div class="light-workspace__entries">
<ResumeEntryCard
v-for="item in section.items"
:key="item.id"
mode="light"
:entry="item"
:kind="section.kind"
:busy="document.busyEntryId.value === item.id"
:optimization-run="document.optimizationRuns.value[item.id]"
@optimize-light="document.optimizeLight"
@confirm-optimization="document.confirmOptimization"
@reject-optimization="document.rejectOptimization"
/>
</div>
</section>
</section>
</template>
<style scoped>
.light-workspace { margin: 34px 0 0 59px; padding-top: 24px; border-top: 1px solid var(--line); }
.light-workspace__heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 18px; }
.light-workspace__heading p { margin: 0; color: var(--brand-dark); font-family: ui-monospace, "SFMono-Regular", Consolas, monospace; font-size: 9px; font-weight: 800; }
.light-workspace__heading h2 { margin: 5px 0 0; color: var(--ink); font-size: 18px; }
.light-workspace__heading span { display: block; margin-top: 6px; color: var(--ink-faint); font-size: 11px; line-height: 1.55; }
.light-workspace__heading > strong { flex: none; padding: 5px 8px; border: 1px solid var(--line); border-radius: 6px; color: var(--ink-faint); background: #fff; font-size: 9px; }
.light-workspace__error { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 12px; padding: 10px 11px; border: 1px solid #efc1bb; border-radius: 6px; color: #943e35; background: var(--danger-soft); font-size: 11px; }
.light-workspace__error p { margin: 0; }
.light-workspace__error button { width: 22px; height: 22px; padding: 0; border: 0; color: currentColor; background: transparent; }
.light-workspace__empty { padding: 22px 0; color: var(--ink-faint); font-size: 11px; line-height: 1.6; }
.light-workspace__section { display: grid; gap: 9px; }
.light-workspace__section + .light-workspace__section { margin-top: 18px; padding-top: 18px; border-top: 1px dashed var(--line); }
.light-workspace__section > header { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.light-workspace__section h3 { margin: 0; color: var(--ink); font-size: 13px; }
.light-workspace__section > header span { color: var(--ink-faint); font-size: 9px; }
.light-workspace__entries { display: grid; gap: 9px; }
@media (max-width: 760px) {
.light-workspace { margin-left: 38px; }
}
</style>
@@ -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>
+76
View File
@@ -0,0 +1,76 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { ComponentSubmission } from '../types/resumeAgent'
import { numberValue, stringArray } 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 percent = computed(() => Math.min(100, Math.max(0, numberValue(props.data.percent, 0))))
const completed = computed(() => numberValue(props.data.completed, 0))
const skipped = computed(() => numberValue(props.data.skipped, 0))
const total = computed(() => numberValue(props.data.total, 0))
const actions = computed(() => stringArray(props.data.actions))
const interactive = computed(() => !props.readOnly && actions.value.length > 0)
function act(action: string) {
if (props.readOnly || props.pending) return
emit('submit', { event: action, payload: {} })
}
</script>
<template>
<FormCard
eyebrow="完成度"
title="简历完善进度"
:read-only="readOnly"
:pending="pending"
>
<div class="progress">
<div class="progress__track" role="progressbar" :aria-valuenow="percent" aria-valuemin="0" aria-valuemax="100">
<div class="progress__bar" :style="{ width: `${percent}%` }"></div>
</div>
<p class="progress__label">已完成 {{ completed }} · 已跳过 {{ skipped }} · {{ total }} {{ percent }}%</p>
</div>
<div v-if="interactive" class="component-actions">
<button v-if="actions.includes('skip')" class="secondary-button" type="button" :disabled="pending" @click="act('skip')">
暂时跳过
</button>
<button v-if="actions.includes('defer')" class="ghost-button" type="button" :disabled="pending" @click="act('defer')">
稍后再说
</button>
</div>
</FormCard>
</template>
<style scoped>
.progress__track {
height: 8px;
border-radius: 999px;
background: var(--surface-muted);
overflow: hidden;
}
.progress__bar {
height: 100%;
border-radius: 999px;
background: var(--brand);
transition: width 240ms ease;
}
.progress__label {
margin: 8px 0 0;
color: var(--ink-soft);
font-size: 12px;
}
</style>
+210
View File
@@ -0,0 +1,210 @@
<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'
interface FieldSpec {
key: string
label: string
kind?: string
required?: boolean
options?: string[]
}
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 MONTH_RE = /^\d{4}-(0[1-9]|1[0-2])$/
const fields = computed<FieldSpec[]>(() =>
Array.isArray(props.data.fields) ? (props.data.fields as FieldSpec[]) : [],
)
const skippable = computed(() => booleanValue(props.data.skippable))
const skipLabel = computed(() => stringValue(props.data.skip_label, '暂时跳过'))
const showDescription = computed(() => booleanValue(props.data.show_description))
const requireDescription = computed(() => booleanValue(props.data.require_description))
const source = computed(() => ({
...recordValue(props.data.value),
...recordValue(props.value),
}))
const form = ref<Record<string, string>>({})
const current = ref(false)
const descriptionText = ref('')
const validationError = ref('')
function sync() {
const next: Record<string, string> = {}
for (const field of fields.value) next[field.key] = stringValue(source.value[field.key])
current.value = next.end_date_or_present === 'present' || booleanValue(source.value.current)
if (current.value) next.end_date_or_present = ''
form.value = next
descriptionText.value = stringValue(source.value.description)
}
watch([fields, source], sync, { immediate: true, deep: true })
const summary = computed(() => {
const parts = fields.value.map((field) => {
if (field.kind === 'month_end') return current.value ? '至今' : form.value[field.key]
return form.value[field.key]
})
return parts.filter(Boolean).join(' · ')
})
function validate(): string {
for (const field of fields.value) {
const value = (form.value[field.key] || '').trim()
if (field.kind === 'month_end') {
if (field.required && !current.value && !value) return `请填写${field.label},或勾选"至今"。`
if (!current.value && value && !MONTH_RE.test(value)) return `${field.label}格式应为 YYYY-MM。`
continue
}
if (field.required && !value) return `请填写${field.label}`
if (field.kind === 'month' && value && !MONTH_RE.test(value)) return `${field.label}格式应为 YYYY-MM。`
}
const start = form.value.start_date
const end = form.value.end_date_or_present
if (!current.value && start && end && MONTH_RE.test(start) && MONTH_RE.test(end) && end < start) {
return '结束时间不能早于开始时间。'
}
if (requireDescription.value && !descriptionText.value.trim()) return '请填写经历描述。'
return ''
}
function submit() {
validationError.value = validate()
if (validationError.value || props.readOnly || props.pending) return
const payload: Record<string, unknown> = {}
for (const field of fields.value) payload[field.key] = (form.value[field.key] || '').trim()
if (fields.value.some((field) => field.kind === 'month_end')) {
payload.end_date_or_present = current.value ? 'present' : payload.end_date_or_present
}
if (showDescription.value && descriptionText.value.trim()) {
payload.description = descriptionText.value.trim()
}
emit('submit', { event: 'submit', payload })
}
function skip() {
if (props.readOnly || props.pending) return
emit('submit', { event: 'skip', payload: {} })
}
</script>
<template>
<FormCard
:eyebrow="stringValue(data.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="record-grid">
<div v-for="field in fields" :key="field.key" class="field-group">
<div class="field-label-row">
<label class="field-label" :for="`rf-${field.key}`">
{{ field.label }}<span v-if="field.required" aria-hidden="true"> *</span>
</label>
<label v-if="field.kind === 'month_end'" class="current-check">
<input v-model="current" type="checkbox" :disabled="pending" @change="validationError = ''" />
<span>至今</span>
</label>
</div>
<select
v-if="field.kind === 'degree'"
:id="`rf-${field.key}`"
v-model="form[field.key]"
class="text-input"
:disabled="pending"
>
<option value="" disabled>请选择</option>
<option v-for="option in field.options || []" :key="option" :value="option">{{ option }}</option>
</select>
<input
v-else
:id="`rf-${field.key}`"
v-model="form[field.key]"
class="text-input"
:type="field.kind === 'month' || field.kind === 'month_end' ? 'month' : 'text'"
autocomplete="off"
:disabled="pending || (field.kind === 'month_end' && current)"
@input="validationError = ''"
/>
</div>
</div>
<div v-if="showDescription" class="field-group">
<label class="field-label" for="rf-description">
经历描述<span v-if="!requireDescription">可选</span>
</label>
<textarea
id="rf-description"
v-model="descriptionText"
class="text-input record-description"
rows="3"
:placeholder="stringValue(data.description_placeholder, '做了什么、取得了什么成果,一两句即可。')"
:disabled="pending"
></textarea>
</div>
<p v-if="validationError" class="validation-error" role="alert">{{ validationError }}</p>
<div class="component-actions">
<button v-if="skippable" class="secondary-button" type="button" :disabled="pending" @click="skip">{{ skipLabel }}</button>
<button class="primary-button" type="submit" :disabled="pending">确认填写</button>
</div>
</form>
</FormCard>
</template>
<style scoped>
.record-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.field-label-row {
display: flex;
min-height: 21px;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
}
.current-check {
display: inline-flex;
flex: none;
align-items: center;
gap: 8px;
color: var(--ink-soft);
font-size: 13px;
font-weight: 620;
}
.current-check input {
width: 17px;
height: 17px;
margin: 0;
accent-color: var(--brand-dark);
}
.record-description {
resize: vertical;
min-height: 72px;
}
.validation-error {
margin: 10px 0 0;
color: var(--danger);
font-size: 12px;
}
@media (max-width: 540px) {
.record-grid {
grid-template-columns: 1fr;
}
}
</style>
+165
View File
@@ -0,0 +1,165 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { OptimizationRunView, ResumeEntry } from '../types/resumeAgent'
const props = withDefaults(
defineProps<{
entry: ResumeEntry
kind: string
busy: boolean
optimizationRun?: OptimizationRunView
mode?: 'edit' | 'light'
}>(),
{ mode: 'edit' },
)
const emit = defineEmits<{
updateEntry: [entryId: string, fields: Record<string, string>]
deleteEntry: [entryId: string]
optimizeLight: [entryId: string]
confirmOptimization: [entryId: string]
rejectOptimization: [entryId: string]
undoOptimize: [entryId: string]
}>()
const DISPLAY_FIELDS: Record<string, Array<[string, string]>> = {
education: [['school', '学校'], ['major', '专业'], ['degree', '学历'], ['start_date', '开始时间'], ['end_date_or_present', '结束时间'], ['description', '经历描述']],
work_experience: [['company', '公司'], ['position', '职位'], ['start_date', '开始时间'], ['end_date_or_present', '结束时间'], ['description', '经历描述']],
internship_experience: [['company', '公司'], ['position', '职位'], ['start_date', '开始时间'], ['end_date_or_present', '结束时间'], ['description', '经历描述']],
project_experience: [['project_name', '项目名称'], ['project_role', '项目角色'], ['start_date', '开始时间'], ['end_date_or_present', '结束时间'], ['description', '经历描述']],
campus_experience: [['organization', '组织'], ['role', '角色'], ['start_date', '开始时间'], ['end_date_or_present', '结束时间'], ['description', '经历描述']],
competition: [['name', '竞赛名称'], ['award', '获奖情况'], ['date', '获奖时间'], ['description', '经历描述']],
additional_experience: [['organization', '组织'], ['role', '角色'], ['description', '经历描述']],
skills: [['value', '内容']], certificates: [['value', '内容']],
}
const FALLBACK_DISPLAY_FIELDS: Array<[string, string]> = [['title', '标题'], ['organization', '组织'], ['role', '角色'], ['description', '经历描述']]
const DATE_KEYS = new Set(['start_date', 'end_date_or_present', 'date'])
const editing = ref(false)
const draft = ref<Record<string, string>>({})
const visibleFields = computed(() => (DISPLAY_FIELDS[props.kind] || FALLBACK_DISPLAY_FIELDS).filter(([key]) => Object.prototype.hasOwnProperty.call(props.entry, key)).map(([key, label]) => ({ key, label, value: String(props.entry[key] ?? '') })))
const title = computed(() => {
const keysByKind: Record<string, string[]> = { education: ['school'], work_experience: ['position', 'company'], internship_experience: ['position', 'company'], project_experience: ['project_name'], campus_experience: ['role', 'organization'], competition: ['name'], additional_experience: ['role', 'organization', 'title'], skills: ['value'], certificates: ['value'] }
const key = (keysByKind[props.kind] || ['title', 'value']).find((candidate) => String(props.entry[candidate] ?? '').trim())
return key ? String(props.entry[key]) : '简历条目'
})
const run = computed(() => props.optimizationRun?.mode === props.mode ? props.optimizationRun : undefined)
const proposal = computed(() => run.value?.proposal)
const canUndo = computed(() => props.mode === 'edit' && Boolean(props.entry.previous_version))
const hasOtherActiveRun = computed(() => Boolean(props.optimizationRun && !run.value))
const originalDescription = computed(() => String(props.entry.description || '').trim())
const provenanceLabel = computed(() => ({ user_provided: '原始内容', user_edited: '手动编辑', ai_expanded: 'AI 优化', rule_polish: '结构化草稿' }[String(props.entry.provenance || '')] || '简历内容'))
const proposalPending = computed(() => run.value?.status === 'proposal_pending')
const supplementalSuggestions = computed(() => {
const labels: Record<string, string> = {
'verifiable result or impact': '\u53ef\u9a8c\u8bc1\u7684\u7ed3\u679c\u6216\u5b9e\u9645\u5f71\u54cd',
'method, tool, or collaboration approach': '\u6240\u7528\u65b9\u6cd5\u3001\u5de5\u5177\u6216\u534f\u4f5c\u65b9\u5f0f',
'scope of responsibility': '\u5177\u4f53\u804c\u8d23\u4e0e\u4ea4\u4ed8\u8303\u56f4',
}
const source = [
...(proposal.value?.optional_enhancements || []),
...(proposal.value?.unconfirmed_suggestions || []),
...(proposal.value?.missing_facts || []).map((item) => labels[item] || item),
]
const known = new Set<string>()
return source.filter((item) => {
const normalized = item.trim().toLocaleLowerCase()
if (!normalized || known.has(normalized)) return false
known.add(normalized)
return true
})
})
const suggestionsHeading = computed(() => '\u8fd8\u53ef\u8865\u5145\u5e76\u786e\u8ba4')
function formatBullets(value: string) { return value.replace(/•/g, '\n•').replace(/^\n/, '') }
function displayValue(key: string, value: string) {
if (key === 'end_date_or_present' && value === 'present') return '至今'
const text = value || '未填写'
return key === 'description' && value ? formatBullets(text) : text
}
function inputType(key: string) { return DATE_KEYS.has(key) ? 'month' : 'text' }
function startEdit() { draft.value = Object.fromEntries(visibleFields.value.map((field) => [field.key, field.value])); editing.value = true }
function saveEdit() { const changed = Object.fromEntries(Object.entries(draft.value).map(([key, value]) => [key, value.trim()]).filter(([key, value]) => value !== String(props.entry[key] ?? '').trim())); editing.value = false; if (Object.keys(changed).length) emit('updateEntry', props.entry.id, changed) }
function removeEntry() { if (window.confirm('确定从简历中删除这条经历吗?')) emit('deleteEntry', props.entry.id) }
</script>
<template>
<article class="entry-card" :class="{ 'entry-card--busy': busy }" :aria-busy="busy">
<header class="entry-card__head">
<div class="entry-card__identity"><strong>{{ title }}</strong><span>{{ provenanceLabel }}</span></div>
<div class="entry-card__toolbar" aria-label="经历操作">
<button v-if="mode === 'edit'" type="button" class="action-button" :disabled="busy" @click="startEdit">编辑</button>
<button v-if="mode === 'light'" type="button" class="action-button action-button--primary" :disabled="busy || proposalPending || hasOtherActiveRun" title="整理已有事实并提取 STAR 结构,不追加追问。" @click="emit('optimizeLight', entry.id)">生成 STAR 优化稿</button>
<button v-if="canUndo" type="button" class="action-button" :disabled="busy" @click="emit('undoOptimize', entry.id)">撤销</button>
<button v-if="mode === 'edit'" type="button" class="action-button action-button--danger" :disabled="busy" @click="removeEntry">删除</button>
</div>
</header>
<form v-if="mode === 'edit' && editing" class="entry-card__editing" @submit.prevent="saveEdit">
<label v-for="field in visibleFields" :key="field.key"><span>{{ field.label }}</span><textarea v-if="field.key === 'description'" v-model="draft[field.key]" rows="4" /><input v-else v-model="draft[field.key]" :type="inputType(field.key)" /></label>
<div class="entry-card__form-actions"><button type="button" class="action-button" @click="editing = false">取消</button><button type="submit" class="action-button action-button--primary">保存</button></div>
</form>
<dl v-else-if="visibleFields.length" class="entry-card__fields"><template v-for="field in visibleFields" :key="field.key"><dt>{{ field.label }}</dt><dd>{{ displayValue(field.key, field.value) }}</dd></template></dl>
<section v-if="mode !== 'edit' && proposalPending && proposal" class="entry-card__proposal" aria-label="候选优化稿">
<header><strong>轻度优化候选稿</strong><span>{{ proposal.source === 'ai_expanded' ? 'AI 拓展' : '基于已有事实整理' }}</span></header>
<div class="entry-card__diff"><div><h4>原始描述</h4><p>{{ formatBullets(originalDescription) || '未填写经历描述。' }}</p></div><div class="entry-card__diff-proposal"><h4>候选优化稿</h4><p>{{ formatBullets(proposal.optimized_description) }}</p><section v-if="supplementalSuggestions.length" class="entry-card__suggestions"><h4>{{ suggestionsHeading }}</h4><ul><li v-for="suggestion in supplementalSuggestions" :key="suggestion">{{ suggestion }}</li></ul></section></div></div>
<div class="entry-card__proposal-actions"><button type="button" class="action-button" :disabled="busy" @click="emit('rejectOptimization', entry.id)">保留原文</button><button type="button" class="action-button action-button--primary" :disabled="busy" @click="emit('confirmOptimization', entry.id)">使用优化稿</button></div>
</section>
<div v-if="busy" class="entry-card__busy" role="status">更新中</div>
</article>
</template>
<style scoped>
.entry-card {
position: relative;
min-width: 0;
padding: 14px;
border: 1px solid #d6e5e2;
border-radius: 8px;
background: #fff;
box-shadow: 0 5px 18px rgba(35, 88, 86, 0.05);
}
.entry-card--busy > :not(.entry-card__busy) { opacity: 0.55; }
.entry-card__head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.entry-card__identity { display: grid; min-width: 0; gap: 3px; }
.entry-card__identity strong { overflow-wrap: anywhere; color: var(--ink); font-size: 14px; line-height: 1.4; }
.entry-card__identity span { color: var(--ink-faint); font-size: 10px; }
.entry-card__toolbar, .entry-card__form-actions, .entry-card__proposal-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 6px; }
.action-button { min-height: 31px; padding: 0 9px; border: 1px solid var(--line); border-radius: 6px; color: var(--ink-soft); background: #fff; font-size: 11px; font-weight: 700; }
.action-button:hover:not(:disabled) { color: var(--ink); border-color: var(--line-strong); background: var(--surface-muted); }
.action-button--primary { color: #fff; border-color: #1c858a; background: #1c858a; }
.action-button--primary:hover:not(:disabled) { color: #fff; border-color: #146e73; background: #146e73; }
.action-button--danger { color: #b84c42; }
.action-button:disabled { opacity: 0.55; }
.entry-card__fields { display: grid; grid-template-columns: minmax(72px, auto) minmax(0, 1fr); gap: 5px 12px; margin: 13px 0 0; font-size: 12px; line-height: 1.55; }
.entry-card__fields dt { color: var(--ink-faint); }
.entry-card__fields dd { min-width: 0; margin: 0; overflow-wrap: anywhere; color: var(--ink-soft); white-space: pre-wrap; }
.entry-card__editing { display: grid; gap: 9px; margin-top: 13px; }
.entry-card__editing label { display: grid; gap: 5px; color: var(--ink-soft); font-size: 11px; font-weight: 700; }
.entry-card__editing input, .entry-card__editing textarea { width: 100%; min-width: 0; border: 1px solid var(--line-strong); border-radius: 6px; color: var(--ink); background: #fbfefd; }
.entry-card__editing input { min-height: 36px; padding: 0 10px; }
.entry-card__editing textarea { padding: 9px 10px; line-height: 1.5; resize: vertical; }
.entry-card__proposal > header { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.entry-card__proposal { margin-top: 14px; padding: 12px 0 0 12px; border-top: 1px solid #dcebd6; border-left: 3px solid #7cad63; }
.entry-card__proposal > header strong { color: #315c37; font-size: 12px; }
.entry-card__proposal > header span { color: #587b5c; font-size: 10px; }
.entry-card__diff { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin-top: 10px; }
.entry-card__diff > div { min-width: 0; }
.entry-card__diff-proposal { padding-left: 12px; border-left: 1px solid #cfe2c7; }
.entry-card__diff h4 { margin: 0 0 6px; color: var(--ink-faint); font-size: 10px; font-weight: 800; }
.entry-card__diff p { margin: 0; overflow-wrap: anywhere; color: var(--ink-soft); font-size: 11px; line-height: 1.65; white-space: pre-wrap; }
.entry-card__changes { display: grid; gap: 4px; margin: 8px 0 0; padding-left: 16px; color: #587b5c; font-size: 10px; line-height: 1.5; }
.entry-card__missing { margin-top: 8px !important; color: #766131 !important; }
.entry-card__suggestions { margin-top: 9px; padding: 8px 10px; border: 1px dashed #c9d9b8; border-radius: 6px; background: #fbfdf6; }
.entry-card__suggestions h4 { margin: 0 0 5px; color: #587b5c; }
.entry-card__suggestions ul { display: grid; gap: 3px; margin: 0; padding-left: 16px; color: var(--ink-soft); font-size: 10px; line-height: 1.5; }
.entry-card__proposal-actions { margin-top: 11px; }
.entry-card__busy { position: absolute; top: 11px; right: 11px; padding: 4px 7px; border-radius: 6px; color: #fff; background: #31565b; font-size: 10px; font-weight: 700; }
@media (max-width: 520px) {
.entry-card__head { align-items: stretch; flex-direction: column; }
.entry-card__toolbar { justify-content: flex-start; }
.entry-card__diff { grid-template-columns: 1fr; }
.entry-card__diff-proposal { padding-top: 10px; padding-left: 0; border-top: 1px solid #cfe2c7; border-left: 0; }
}
</style>
@@ -0,0 +1,145 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue'
import type { useResumeDocument } from '../composables/useResumeDocument'
const props = defineProps<{
document: ReturnType<typeof useResumeDocument>
disabled?: boolean
}>()
const input = ref<HTMLInputElement | null>(null)
const selectedName = ref('')
const accepted = '.pdf,.docx,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document'
const reviewCount = computed(() => props.document.resumeImport.value?.field_reviews.length ?? 0)
const importStatus = computed(() => props.document.resumeImport.value?.status)
const hasContent = computed(() => {
const content = props.document.resume.value?.content
if (!content) return false
if (String(content.basics?.name || '').trim()) return true
if ((content.skill_groups || []).length) return true
return (content.sections || []).some((section) => (section.items || []).length > 0)
})
const cannotImport = computed(() => Boolean(props.disabled || hasContent.value || props.document.importBusy.value))
function selectFile() {
if (!cannotImport.value) input.value?.click()
}
function onFileChange(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0]
if (!file || cannotImport.value) return
selectedName.value = file.name
void props.document.uploadImport(file)
}
function clearSelection() {
selectedName.value = ''
if (input.value) input.value.value = ''
}
async function cancel() {
await props.document.cancelImport()
clearSelection()
}
onBeforeUnmount(() => {
// The panel only lives during RESUME_IMPORT_UPLOAD. When it unmounts (stage
// advanced or 重新开始 reset the session) the import view must not leak into
// the next session — a stale "导入完成" card blocks selecting a new file.
props.document.resumeImport.value = null
})
</script>
<template>
<section class="resume-import" aria-label="简历导入">
<input
ref="input"
class="resume-import__input"
type="file"
:accept="accepted"
:disabled="cannotImport"
@change="onFileChange"
/>
<template v-if="!document.resumeImport.value || importStatus === 'cancelled'">
<div class="resume-import__copy">
<p>简历导入</p>
<h2>导入已有简历</h2>
<span>支持 PDF / DOCX不超过 10 MB</span>
</div>
<button
type="button"
class="resume-import__select"
:disabled="cannotImport"
@click="selectFile"
>
{{ document.importBusy.value ? '解析中...' : '选择文件' }}
</button>
<small v-if="hasContent">简历预览已有内容如需导入请先从头部重新开始</small>
<small v-else-if="selectedName">{{ selectedName }}</small>
</template>
<template v-else-if="importStatus === 'awaiting_review'">
<div class="resume-import__copy">
<p>导入预览</p>
<h2>{{ document.resumeImport.value.file_name }}</h2>
<span>已解析出 {{ reviewCount }} 个字段确认后应用到简历</span>
</div>
<div class="resume-import__actions">
<button
type="button"
class="resume-import__button resume-import__button--primary"
:disabled="document.importBusy.value"
@click="document.applyImport"
>
{{ document.importBusy.value ? '应用中...' : '应用到简历' }}
</button>
<button
type="button"
class="resume-import__button"
:disabled="document.importBusy.value"
@click="cancel"
>
放弃导入
</button>
</div>
</template>
<template v-else-if="importStatus === 'applied'">
<div class="resume-import__copy">
<p>导入完成</p>
<h2>{{ document.resumeImport.value.file_name }}</h2>
<span>导入内容已进入右侧简历预览</span>
</div>
<button type="button" class="resume-import__button" @click="clearSelection">
完成
</button>
</template>
</section>
</template>
<style scoped>
.resume-import {
display: grid;
gap: 12px;
margin: 0 0 20px 59px;
padding: 14px;
border: 1px solid var(--line-strong);
border-radius: 8px;
background: #f9fdfc;
}
.resume-import__input { display: none; }
.resume-import__copy { display: grid; gap: 4px; min-width: 0; }
.resume-import__copy p { margin: 0; color: var(--brand-dark); font-family: ui-monospace, Consolas, monospace; font-size: 9px; font-weight: 800; }
.resume-import__copy h2 { margin: 0; overflow-wrap: anywhere; color: var(--ink); font-size: 14px; line-height: 1.35; }
.resume-import__copy span, .resume-import small { color: var(--ink-faint); font-size: 11px; line-height: 1.45; }
.resume-import__select, .resume-import__button { min-height: 34px; width: fit-content; padding: 0 10px; border: 1px solid var(--line-strong); border-radius: 6px; color: var(--ink-soft); background: #fff; font-size: 11px; font-weight: 750; }
.resume-import__select:hover:not(:disabled), .resume-import__button:hover:not(:disabled) { border-color: #8fc4c1; color: var(--ink); background: var(--surface-muted); }
.resume-import__select:disabled, .resume-import__button:disabled { opacity: .55; }
.resume-import__actions { display: flex; flex-wrap: wrap; gap: 8px; }
.resume-import__button--primary { color: #fff; border-color: #1c858a; background: #1c858a; }
.resume-import__button--primary:hover:not(:disabled) { color: #fff; border-color: #146e73; background: #146e73; }
@media (max-width: 760px) { .resume-import { margin-left: 38px; } }
</style>
@@ -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,610 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { useResumeDocument } from '../composables/useResumeDocument'
import type { ResumeEntry } from '../types/resumeAgent'
import ResumeEntryCard from './ResumeEntryCard.vue'
const props = defineProps<{ document: ReturnType<typeof useResumeDocument> }>()
const resume = computed(() => props.document.resume.value)
const basicsEditing = ref(false)
const basicsDraft = ref<Record<string, string>>({})
const phoneReplacing = ref(false)
const skillsEditing = ref(false)
const skillsDraft = ref<string[]>([])
const skillInput = ref('')
const summaryEditing = ref(false)
const summaryDraft = ref('')
const skillQuestion = ref('还有哪些与目标岗位匹配的技术栈?')
const BASICS_FIELDS: Array<[string, string]> = [['name', '姓名'], ['phone', '手机号'], ['email', '邮箱'], ['city', '所在地'], ['portfolio_url', '作品集链接']]
const basics = computed(() => resume.value?.content.basics || {})
const skillGroups = computed(() => resume.value?.content.skill_groups || [])
const profileSummary = computed(() => resume.value?.content.profile_summary || null)
const flatSkills = computed(() => skillGroups.value.flatMap((group) => group.skills).filter((skill, index, values) => values.findIndex((value) => value.toLocaleLowerCase() === skill.toLocaleLowerCase()) === index))
const skillsBusy = computed(() => props.document.skillsBusy.value || props.document.busyEntryId.value === 'skills')
const summaryBusy = computed(() => props.document.summaryBusy.value)
const basicsDisplay = computed(() => BASICS_FIELDS.map(([key, label]) => ({ key, label, value: String(basics.value[key] || (key === 'phone' ? basics.value.masked_phone || '' : '') || '') })))
const contactFields = computed(() => basicsDisplay.value.filter((field) => field.key !== 'name'))
const basicsBusy = computed(() => props.document.busyEntryId.value === 'basics')
function startSummaryEdit() { summaryDraft.value = profileSummary.value?.content || ''; summaryEditing.value = true }
function cancelSummaryEdit() { summaryEditing.value = false; summaryDraft.value = '' }
async function saveProfileSummary() {
const content = summaryDraft.value.trim()
if (!content) return
if (await props.document.updateProfileSummary(content)) cancelSummaryEdit()
}
function startSkillsEdit() { skillsDraft.value = [...flatSkills.value]; skillInput.value = ''; skillsEditing.value = true }
function addSkill(value = skillInput.value) { const clean = value.trim(); if (!clean || skillsDraft.value.some((skill) => skill.toLocaleLowerCase() === clean.toLocaleLowerCase())) return; skillsDraft.value = [...skillsDraft.value, clean]; skillInput.value = '' }
function toggleCandidate(skill: string, event: Event) { const input = event.target as HTMLInputElement; if (input.checked) addSkill(skill); else removeSkill(skill) }
function removeSkill(skill: string) { skillsDraft.value = skillsDraft.value.filter((item) => item.toLocaleLowerCase() !== skill.toLocaleLowerCase()) }
function saveSkills() { skillsEditing.value = false; props.document.updateSkillGroups(skillsDraft.value) }
async function recommendSkills() { await props.document.recommendSkills(skillQuestion.value.trim() || '推荐与目标岗位匹配的技能') }
function startBasicsEdit() { basicsDraft.value = Object.fromEntries(BASICS_FIELDS.map(([key]) => [key, String(basics.value[key] || '')])); phoneReplacing.value = false; basicsEditing.value = true }
function saveBasics() { const changed = Object.fromEntries(Object.entries(basicsDraft.value).filter(([key]) => key !== 'phone' || phoneReplacing.value || Boolean(basics.value.phone)).map(([key, value]) => [key, value.trim()]).filter(([key, value]) => value !== String(basics.value[key] || '').trim())); basicsEditing.value = false; phoneReplacing.value = false; if (Object.keys(changed).length) props.document.updateBasics(changed) }
</script>
<template>
<aside class="preview-panel" aria-label="简历预览">
<header class="preview-panel__heading"><div><p>简历预览</p><h2>实时简历</h2></div><span v-if="resume">R{{ resume.revision }}</span></header>
<div v-if="!resume" class="preview-panel__empty"><span aria-hidden="true">+</span><strong>尚未创建简历</strong><p>完成流程后简历内容会实时显示在这里</p></div>
<template v-else>
<section class="preview-panel__basics" :aria-busy="basicsBusy">
<template v-if="!basicsEditing"><div class="preview-panel__basics-head"><div><h3>{{ basicsDisplay.find((field) => field.key === 'name')?.value || '未命名简历' }}</h3><p>{{ resume.content.target?.position || resume.content.target?.target_position || '简历' }}</p></div><button type="button" class="preview-action" :disabled="basicsBusy" @click="startBasicsEdit">编辑</button></div><dl class="preview-panel__contacts"><template v-for="field in contactFields" :key="field.key"><dt>{{ field.label }}</dt><dd>{{ field.value || '未填写' }}</dd></template></dl></template>
<form v-else class="preview-panel__basics-form" @submit.prevent="saveBasics"><label v-for="field in basicsDisplay" :key="field.key"><span>{{ field.label }}</span><div v-if="field.key === 'phone' && !phoneReplacing && field.value" class="preview-panel__phone-current"><strong>{{ field.value }}</strong><button type="button" class="preview-action" @click="phoneReplacing = true">更换</button></div><input v-else v-model="basicsDraft[field.key]" :type="field.key === 'email' ? 'email' : field.key === 'phone' ? 'tel' : 'text'" :autocomplete="field.key === 'name' ? 'name' : field.key === 'phone' ? 'tel' : field.key" :placeholder="field.key === 'phone' && phoneReplacing ? '请输入新的手机号' : ''" /></label><div class="preview-panel__form-actions"><button type="button" class="preview-action" @click="basicsEditing = false">取消</button><button type="submit" class="preview-action preview-action--primary">保存</button></div></form>
<span v-if="basicsBusy" class="preview-panel__saving" role="status">保存中</span>
</section>
<div v-if="document.errorMessage.value" class="preview-panel__error" role="alert"><p>{{ document.errorMessage.value }}</p><button type="button" aria-label="关闭错误提示" @click="document.clearError">×</button></div>
<section class="preview-panel__summary" :aria-busy="summaryBusy">
<header class="preview-panel__summary-head">
<div>
<h3>个人总结</h3>
<p v-if="profileSummary?.stale">简历内容已更新建议重新生成个人总结</p>
</div>
<div class="preview-panel__summary-actions">
<button type="button" class="preview-action" :disabled="summaryBusy" @click="startSummaryEdit">编辑</button>
<button type="button" class="preview-action preview-action--primary" :disabled="summaryBusy" @click="document.generateProfileSummary">
{{ profileSummary?.content ? '重新生成' : '生成总结' }}
</button>
</div>
</header>
<form v-if="summaryEditing" class="preview-panel__summary-editor" @submit.prevent="saveProfileSummary">
<textarea v-model="summaryDraft" maxlength="600" placeholder="填写个人总结" />
<div class="preview-panel__form-actions">
<button type="button" class="preview-action" :disabled="summaryBusy" @click="cancelSummaryEdit">取消</button>
<button type="submit" class="preview-action preview-action--primary" :disabled="summaryBusy || !summaryDraft.trim()">保存</button>
</div>
</form>
<p v-else-if="profileSummary?.content" class="preview-panel__summary-content">{{ profileSummary.content }}</p>
<p v-else class="preview-panel__summary-empty">完成简历后会自动生成个人总结也可以在这里手动生成</p>
<div v-if="profileSummary?.pending_proposal" class="preview-panel__summary-proposal">
<h4>候选个人总结</h4>
<p>{{ profileSummary.pending_proposal.content }}</p>
<div class="preview-panel__form-actions">
<button type="button" class="preview-action" :disabled="summaryBusy" @click="document.rejectProfileSummary">保留当前版本</button>
<button type="button" class="preview-action preview-action--primary" :disabled="summaryBusy" @click="document.confirmProfileSummary">使用候选稿</button>
</div>
</div>
</section>
<section class="preview-panel__skills" :aria-busy="skillsBusy"><header class="preview-panel__skills-head"><h3>技能</h3><button v-if="!skillsEditing" type="button" class="preview-action" :disabled="skillsBusy" @click="startSkillsEdit">编辑</button></header>
<template v-if="!skillsEditing"><div v-if="skillGroups.length" class="preview-panel__skill-list"><div v-for="group in skillGroups" :key="group.category" class="preview-panel__skill-group"><strong>{{ group.category }}</strong><span>{{ group.skills.join(' / ') }}</span></div></div><p v-else class="preview-panel__skills-empty">尚未添加技能</p></template>
<form v-else class="preview-panel__skills-editor" @submit.prevent="saveSkills"><div class="preview-panel__skill-tags"><span v-for="skill in skillsDraft" :key="skill" class="preview-panel__skill-tag">{{ skill }}<button type="button" :aria-label="`删除 ${skill}`" @click="removeSkill(skill)">×</button></span></div><div class="preview-panel__skill-add"><input v-model="skillInput" type="text" placeholder="添加技能" @keydown.enter.prevent="addSkill()" /><button type="button" class="preview-action" @click="addSkill()">添加</button></div><div class="preview-panel__skill-recommend"><label><span>向 AI 提问</span><input v-model="skillQuestion" type="text" maxlength="240" /></label><button type="button" class="preview-action" :disabled="skillsBusy" @click="recommendSkills">推荐</button></div><div v-if="document.skillCandidates.value.length" class="preview-panel__skill-candidates"><label v-for="candidate in document.skillCandidates.value" :key="candidate.skill"><input type="checkbox" :checked="skillsDraft.some((skill) => skill.toLocaleLowerCase() === candidate.skill.toLocaleLowerCase())" @change="toggleCandidate(candidate.skill, $event)" /><span><strong>{{ candidate.skill }}</strong><small>{{ candidate.category }} · {{ candidate.reason }}</small></span></label></div><div class="preview-panel__form-actions"><button type="button" class="preview-action" @click="skillsEditing = false">取消</button><button type="submit" class="preview-action preview-action--primary" :disabled="skillsBusy">保存技能</button></div></form>
</section>
<div v-if="!resume.content.sections.length" class="preview-panel__no-sections">尚未添加经历</div>
<section v-for="section in resume.content.sections" :key="section.id" class="preview-panel__section"><header><h3>{{ section.heading }}</h3><span>{{ section.items.length }}</span></header><div class="preview-panel__entries">
<div v-for="item in section.items" :key="item.id" class="preview-panel__entry-stack">
<ResumeEntryCard :entry="item" :kind="section.kind" :busy="document.busyEntryId.value === item.id" :optimization-run="document.optimizationRuns.value[item.id]" @update-entry="document.updateEntry" @delete-entry="document.deleteEntry" @optimize-light="document.optimizeLight" @confirm-optimization="document.confirmOptimization" @reject-optimization="document.rejectOptimization" @undo-optimize="document.undoOptimize" />
</div>
</div></section>
</template>
</aside>
</template>
<style scoped>
.preview-panel {
position: sticky;
top: 92px;
min-width: 0;
max-height: calc(100vh - 116px);
align-self: start;
overflow-y: auto;
padding: 0 5px 26px 0;
scrollbar-color: #b9d3cf transparent;
scrollbar-width: thin;
}
.preview-panel__heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 0 0 15px;
border-bottom: 1px solid rgba(190, 217, 213, 0.78);
}
.preview-panel__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;
}
.preview-panel__heading h2 {
margin: 5px 0 0;
color: var(--ink);
font-size: 18px;
line-height: 1.3;
}
.preview-panel__heading > span {
padding: 5px 7px;
border: 1px solid var(--line);
border-radius: 6px;
color: var(--ink-faint);
background: rgba(255, 255, 255, 0.72);
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
font-size: 9px;
}
.preview-panel__empty {
display: grid;
min-height: 240px;
place-items: center;
align-content: center;
gap: 8px;
padding: 28px;
border-bottom: 1px solid var(--line);
color: var(--ink-faint);
text-align: center;
}
.preview-panel__empty > span {
display: grid;
width: 42px;
height: 42px;
place-items: center;
border: 1px solid var(--line-strong);
border-radius: 8px;
color: var(--brand-dark);
background: #fff;
font-family: serif;
font-size: 18px;
}
.preview-panel__empty strong {
color: var(--ink);
font-size: 14px;
}
.preview-panel__empty p {
max-width: 28ch;
margin: 0;
font-size: 11px;
line-height: 1.65;
}
.preview-panel__basics {
position: relative;
padding: 18px 0;
border-bottom: 1px solid var(--line);
}
.preview-panel__basics-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.preview-panel__basics-head h3 {
margin: 0;
overflow-wrap: anywhere;
color: var(--ink);
font-size: 20px;
line-height: 1.25;
}
.preview-panel__basics-head p {
margin: 5px 0 0;
color: var(--brand-dark);
font-size: 11px;
font-weight: 700;
}
.preview-action {
min-height: 32px;
padding: 0 10px;
border: 1px solid var(--line-strong);
border-radius: 6px;
color: var(--ink-soft);
background: #fff;
font-size: 11px;
font-weight: 700;
}
.preview-action:hover:not(:disabled) {
color: var(--ink);
border-color: #8fc4c1;
background: var(--surface-muted);
}
.preview-action--primary {
color: #fff;
border-color: #1c858a;
background: #1c858a;
}
.preview-action--primary:hover:not(:disabled) {
color: #fff;
border-color: #146e73;
background: #146e73;
}
.preview-action:disabled {
opacity: 0.55;
}
.preview-panel__contacts {
display: grid;
grid-template-columns: minmax(64px, auto) minmax(0, 1fr);
gap: 5px 12px;
margin: 14px 0 0;
font-size: 11px;
line-height: 1.55;
}
.preview-panel__contacts dt {
color: var(--ink-faint);
}
.preview-panel__contacts dd {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
color: var(--ink-soft);
}
.preview-panel__basics-form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 9px;
}
.preview-panel__basics-form label {
display: grid;
gap: 5px;
color: var(--ink-soft);
font-size: 10px;
font-weight: 700;
}
.preview-panel__basics-form label:first-child,
.preview-panel__basics-form label:last-of-type {
grid-column: 1 / -1;
}
.preview-panel__basics-form input {
width: 100%;
min-width: 0;
min-height: 36px;
padding: 0 10px;
border: 1px solid var(--line-strong);
border-radius: 6px;
color: var(--ink);
background: #fbfefd;
}
.preview-panel__phone-current {
display: flex;
min-height: 36px;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 0 5px 0 10px;
border: 1px solid var(--line-strong);
border-radius: 6px;
color: var(--ink);
background: #f4f8f7;
}
.preview-panel__phone-current strong {
overflow-wrap: anywhere;
font-size: 12px;
}
.preview-panel__form-actions {
display: flex;
grid-column: 1 / -1;
justify-content: flex-end;
gap: 6px;
}
.preview-panel__saving {
position: absolute;
top: 15px;
right: 0;
padding: 4px 7px;
border-radius: 6px;
color: #fff;
background: #31565b;
font-size: 10px;
font-weight: 700;
}
.target-confirm-overlay,
.replace-confirm-overlay {
position: fixed;
inset: 0;
z-index: 1000;
display: grid;
place-items: center;
padding: 20px;
background: rgba(25, 48, 47, 0.32);
}
.target-confirm-overlay :deep(.target-confirm) {
width: min(460px, 100%);
margin: 0;
box-shadow: 0 18px 48px rgba(20, 57, 55, 0.24);
}
.replace-confirm {
display: grid;
width: min(420px, 100%);
gap: 14px;
padding: 18px;
border: 1px solid #b8d9d3;
border-radius: 8px;
background: #fff;
box-shadow: 0 18px 48px rgba(20, 57, 55, 0.24);
}
.replace-confirm h3, .replace-confirm p { margin: 0; }
.replace-confirm h3 { color: var(--ink); font-size: 15px; }
.replace-confirm p { color: var(--ink-soft); font-size: 12px; line-height: 1.55; }
.replace-confirm__actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 6px; }
.preview-panel__notice {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin: 12px 0;
padding: 10px 11px;
border: 1px solid #ead4ab;
border-radius: 6px;
color: #7c5619;
background: #fff9eb;
font-size: 11px;
line-height: 1.5;
}
.preview-panel__notice p { margin: 0; }
.preview-panel__notice button { flex: none; width: 24px; height: 24px; padding: 0; border: 0; color: currentColor; background: transparent; font-size: 15px; }
.preview-panel__error {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin: 12px 0;
padding: 10px 11px;
border: 1px solid #efc1bb;
border-radius: 6px;
color: #943e35;
background: var(--danger-soft);
font-size: 11px;
line-height: 1.5;
}
.preview-panel__error p {
margin: 0;
}
.preview-panel__error button {
flex: none;
width: 24px;
height: 24px;
padding: 0;
border: 0;
color: currentColor;
background: transparent;
font-size: 17px;
}
.preview-panel__summary {
display: grid;
gap: 9px;
padding: 18px 0;
border-bottom: 1px solid var(--line);
}
.preview-panel__summary-head,
.preview-panel__summary-actions {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
}
.preview-panel__summary-actions {
flex: none;
justify-content: flex-end;
}
.preview-panel__summary h3,
.preview-panel__summary h4 {
margin: 0;
color: var(--ink);
font-size: 13px;
}
.preview-panel__summary-head p,
.preview-panel__summary-empty {
margin: 4px 0 0;
color: var(--ink-faint);
font-size: 10px;
line-height: 1.5;
}
.preview-panel__summary-content,
.preview-panel__summary-proposal p {
margin: 0;
color: var(--ink-soft);
font-size: 11px;
line-height: 1.7;
white-space: pre-wrap;
}
.preview-panel__summary-editor { display: grid; gap: 8px; }
.preview-panel__summary-editor textarea {
width: 100%;
min-height: 118px;
resize: vertical;
padding: 9px 10px;
border: 1px solid var(--line-strong);
border-radius: 6px;
color: var(--ink);
background: #fbfefd;
font: inherit;
font-size: 11px;
line-height: 1.6;
}
.preview-panel__summary-proposal {
display: grid;
gap: 8px;
padding: 10px;
border: 1px dashed #8fc4c1;
border-radius: 6px;
background: #f7fbfa;
}
.preview-panel__skills {
display: grid;
gap: 9px;
padding: 18px 0 0;
border-top: 1px solid var(--line);
}
.preview-panel__skills-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.preview-panel__skills-head h3 { margin: 0; color: var(--ink); font-size: 13px; }
.preview-panel__skill-list { display: grid; gap: 2px; }
.preview-panel__skill-group { display: grid; grid-template-columns: minmax(78px, auto) minmax(0, 1fr); gap: 10px; padding: 8px 0; border-bottom: 1px dashed var(--line); font-size: 11px; line-height: 1.55; }
.preview-panel__skill-group strong { color: var(--ink-soft); }
.preview-panel__skill-group span { min-width: 0; overflow-wrap: anywhere; color: var(--ink-faint); }
.preview-panel__skills-empty { margin: 0; color: var(--ink-faint); font-size: 11px; }
.preview-panel__skills-editor { display: grid; gap: 8px; }
.preview-panel__skill-tags { display: flex; min-height: 36px; flex-wrap: wrap; gap: 6px; padding: 7px; border: 1px solid var(--line-strong); border-radius: 6px; background: #fbfefd; }
.preview-panel__skill-tag { display: inline-flex; min-height: 24px; align-items: center; gap: 4px; padding: 0 6px; border: 1px solid #b8d9d3; border-radius: 5px; color: #31565b; background: #f3faf8; font-size: 10px; }
.preview-panel__skill-tag button { width: 16px; height: 16px; padding: 0; border: 0; color: #31565b; background: transparent; font-size: 15px; line-height: 1; }
.preview-panel__skill-add, .preview-panel__skill-recommend { display: flex; align-items: end; gap: 6px; }
.preview-panel__skill-add input, .preview-panel__skill-recommend input { width: 100%; min-width: 0; min-height: 34px; padding: 0 9px; border: 1px solid var(--line-strong); border-radius: 6px; color: var(--ink); background: #fff; font-size: 11px; }
.preview-panel__skill-recommend label { display: grid; flex: 1; gap: 4px; color: var(--ink-soft); font-size: 10px; font-weight: 700; }
.preview-panel__skill-candidates { display: grid; gap: 6px; padding: 9px; border: 1px dashed #b8d9d3; border-radius: 6px; background: #f7fbfa; }
.preview-panel__skill-candidates label { display: flex; align-items: flex-start; gap: 7px; color: var(--ink-soft); font-size: 10px; line-height: 1.45; }
.preview-panel__skill-candidates input { margin-top: 2px; accent-color: #1c858a; }
.preview-panel__skill-candidates span { display: grid; gap: 2px; min-width: 0; }
.preview-panel__skill-candidates strong { color: var(--ink); font-size: 11px; }
.preview-panel__skill-candidates small { color: var(--ink-faint); font-size: 10px; overflow-wrap: anywhere; }
.preview-panel__no-sections {
padding: 28px 0;
color: var(--ink-faint);
font-size: 12px;
text-align: center;
}
.preview-panel__section {
display: grid;
gap: 10px;
padding: 18px 0 0;
}
.preview-panel__section + .preview-panel__section {
margin-top: 18px;
border-top: 1px solid var(--line);
}
.preview-panel__section > header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.preview-panel__section > header h3 {
margin: 0;
color: var(--ink);
font-size: 13px;
}
.preview-panel__section > header span {
display: grid;
width: 22px;
height: 22px;
place-items: center;
border-radius: 50%;
color: var(--ink-faint);
background: var(--surface-muted);
font-size: 9px;
font-weight: 800;
}
.preview-panel__entries {
display: grid;
gap: 9px;
}
.preview-panel__entry-stack { display: grid; gap: 8px; }
.deep-progress {
display: flex;
align-items: center;
gap: 8px;
min-height: 40px;
padding: 10px 12px;
border: 1px dashed #8fc4c1;
border-radius: 7px;
color: var(--ink-soft);
background: #f7fbfa;
font-size: 11px;
line-height: 1.5;
}
.deep-progress__indicator {
width: 12px;
height: 12px;
flex: none;
border: 2px solid #b8d9d3;
border-top-color: #1c858a;
border-radius: 50%;
animation: deep-progress-spin 0.8s linear infinite;
}
@keyframes deep-progress-spin {
to { transform: rotate(360deg); }
}
.panel-hint { padding: 10px 12px; border: 1px dashed #b8d9d3; border-radius: 7px; color: var(--ink-faint); background: #f7fbfa; font-size: 11px; line-height: 1.5; }
@media (max-width: 1120px) {
.preview-panel {
position: static;
max-height: none;
padding-right: 0;
overflow: visible;
}
}
@media (max-width: 520px) {
.preview-panel__basics-form {
grid-template-columns: 1fr;
}
.preview-panel__basics-form label:first-child,
.preview-panel__basics-form label:last-of-type,
.preview-panel__form-actions {
grid-column: auto;
}
}</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', 'PERSONAL_INFO', 'NAME_CAPTURE'] },
{ key: 'direction', label: '求职方向', stages: ['JOB_TYPE_SELECT', 'TARGET_POSITION', 'TARGET_POSITION_MAJOR', 'TARGET_POSITION_RECOMMENDATION', '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>
+237
View File
@@ -0,0 +1,237 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { ComponentSubmission } from '../types/resumeAgent'
import { initialValue, 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 field = computed(() => stringValue(props.data.field, 'skills'))
const sourceValues = computed(() => {
if (Array.isArray(props.value)) return stringArray(props.value)
return stringArray(props.data.values ?? props.data.value ?? initialValue(props.data))
})
const tags = ref<string[]>([...sourceValues.value])
const suggestions = computed(() => stringArray(props.data.suggestions))
const visibleSuggestions = computed(() => {
const selected = new Set(tags.value.map((item) => item.toLowerCase()))
return suggestions.value.filter((item) => !selected.has(item.toLowerCase()))
})
const draft = ref('')
const validationError = ref('')
watch(sourceValues, (next) => {
tags.value = [...next]
})
function addTag() {
const value = draft.value.trim()
if (!value) return
if (value.length > 32) {
validationError.value = '单个标签最长 32 字。'
return
}
if (tags.value.some((item) => item.toLowerCase() === value.toLowerCase())) {
validationError.value = '这个标签已添加。'
return
}
if (tags.value.length >= 20) {
validationError.value = '最多添加 20 个标签。'
return
}
validationError.value = ''
tags.value = [...tags.value, value]
draft.value = ''
}
function removeTag(index: number) {
if (props.readOnly || props.pending) return
tags.value = tags.value.filter((_, item) => item !== index)
}
function addSuggestion(value: string) {
if (props.readOnly || props.pending || tags.value.length >= 20) return
if (!tags.value.some((item) => item.toLowerCase() === value.toLowerCase())) {
tags.value = [...tags.value, value]
}
}
function submit() {
if (props.readOnly || props.pending) return
emit('submit', { event: 'submit', payload: { value: tags.value, field: field.value } })
}
function skip() {
if (props.readOnly || props.pending) return
emit('submit', { event: 'skip', payload: {} })
}
</script>
<template>
<FormCard
:eyebrow="stringValue(data.eyebrow, '标签')"
:title="stringValue(data.title, '添加标签')"
:description="stringValue(data.description, '输入后按回车添加,可以留空。')"
:read-only="readOnly"
:pending="pending"
>
<div v-if="readOnly" class="read-only-value">{{ tags.join('') || '已跳过' }}</div>
<form v-else @submit.prevent="addTag">
<div class="field-group">
<label class="field-label" :for="`tag-input-${field}`">逐个输入后按回车添加</label>
<input
:id="`tag-input-${field}`"
v-model="draft"
class="text-input"
type="text"
:disabled="pending"
placeholder="例如:Python"
/>
</div>
<section v-if="visibleSuggestions.length" class="suggestions" aria-label="AI 推荐技能">
<div class="suggestions__heading">
<strong>AI 推荐</strong>
<span>根据目标岗位和已填写经历生成</span>
</div>
<div class="suggestions__list">
<button
v-for="suggestion in visibleSuggestions"
:key="suggestion"
type="button"
class="suggestion-chip"
:disabled="pending"
@click="addSuggestion(suggestion)"
>
<span aria-hidden="true">+</span>{{ suggestion }}
</button>
</div>
</section>
<div v-if="tags.length" class="tags" role="group" aria-label="已添加的标签">
<span v-for="(tag, index) in tags" :key="tag" class="tag-chip">
{{ tag }}
<button
type="button"
class="tag-chip__remove"
:disabled="pending"
:aria-label="`删除 ${tag}`"
@click="removeTag(index)"
>×</button>
</span>
</div>
<p v-if="validationError" class="validation-error" role="alert">{{ validationError }}</p>
<div class="component-actions">
<button class="secondary-button" type="button" :disabled="pending" @click="skip">暂时跳过</button>
<button class="primary-button" type="button" :disabled="pending" @click="submit">确认</button>
</div>
</form>
</FormCard>
</template>
<style scoped>
.suggestions {
display: grid;
gap: 9px;
margin-top: 12px;
padding: 11px 0;
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
}
.suggestions__heading {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
}
.suggestions__heading strong {
color: var(--ink);
font-size: 12px;
}
.suggestions__heading span {
color: var(--ink-faint);
font-size: 10px;
text-align: right;
}
.suggestions__list {
display: flex;
flex-wrap: wrap;
gap: 7px;
}
.suggestion-chip {
display: inline-flex;
min-height: 30px;
align-items: center;
gap: 5px;
padding: 0 9px;
border: 1px solid #9bc3b7;
border-radius: 6px;
color: #275f55;
background: #f3faf7;
cursor: pointer;
font-size: 12px;
font-weight: 700;
}
.suggestion-chip:hover:not(:disabled) {
border-color: #4f9280;
background: #e8f5f0;
}
.suggestion-chip:disabled {
cursor: default;
opacity: 0.55;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.tag-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--brand-soft);
color: var(--ink);
font-size: 13px;
}
.tag-chip__remove {
border: none;
background: transparent;
color: var(--ink-faint);
cursor: pointer;
font-size: 14px;
line-height: 1;
padding: 0;
}
.tag-chip__remove:hover:not(:disabled) {
color: var(--danger);
}
.validation-error {
margin: 10px 0 0;
color: var(--danger);
font-size: 12px;
}
</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>
+451
View File
@@ -0,0 +1,451 @@
import { computed, onBeforeUnmount, ref } from 'vue'
import { ResumeAgentApiError, resumeAgentApi } from '../api/resumeAgent'
import type {
ComponentSubmission,
ComposerConfig,
ComposerMode,
NormalizedResumeAgentState,
RawTimelineBlock,
ResumeAgentEnvelope,
BuilderStreamEvent,
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 resumeHook = ref<ResumeAgentEnvelope['resume']>(null)
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('')
const aiStatus = ref('')
const streamedAssistantText = 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
resumeHook.value = unwrapEnvelope(response).resume ?? resumeHook.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 = ''
aiStatus.value = '正在理解你的补充'
streamedAssistantText.value = ''
try {
const response = await resumeAgentApi.sendMessageStream(
sessionId.value,
{ content: cleanMessage },
(event: BuilderStreamEvent) => {
if (event.event === 'status') {
const data = event.data as { label?: string }
aiStatus.value = data.label || '正在整理经历信息'
}
if (event.event === 'delta') {
const data = event.data as { text?: string }
streamedAssistantText.value += data.text || ''
}
if (event.event === 'complete') aiStatus.value = '正在同步简历'
},
controller?.signal,
)
await applyMutationResponse(response)
} catch (error) {
errorMessage.value = formatError(error)
} finally {
sendingMessage.value = false
window.setTimeout(() => {
aiStatus.value = ''
streamedAssistantText.value = ''
}, 500)
}
}
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 = ''
resumeHook.value = null
resetting.value = false
await start()
}
function clearError() {
errorMessage.value = ''
}
onBeforeUnmount(() => controller?.abort())
return {
sessionId,
draftId,
revision,
stage,
turn,
timeline,
composer,
missingFields,
gate,
resumeId,
resumeHook,
traceId,
initializing,
pendingBlockId,
sendingMessage,
creatingResume,
resetting,
errorMessage,
aiStatus,
streamedAssistantText,
isBusy,
start,
refreshTimeline,
submitComponent,
sendMessage,
createResume,
resetSession,
clearError,
}
}
@@ -0,0 +1,307 @@
import { computed, ref, watch, type Ref } from 'vue'
import { ResumeAgentApiError, resumeAgentApi } from '../api/resumeAgent'
import type {
OptimizationRunView,
ResumeAgentEnvelope,
ResumeImportView,
ResumePatchOperationInput,
ResumeView,
SkillRecommendationCandidate,
} from '../types/resumeAgent'
function unwrapResume(envelope: ResumeAgentEnvelope): ResumeView | null {
const source = (envelope.data && typeof envelope.data === 'object'
? { ...envelope, ...envelope.data }
: envelope) as ResumeAgentEnvelope
return source.resume?.content ? source.resume : null
}
export function useResumeDocument(sessionId: Ref<string>) {
const resume = ref<ResumeView | null>(null)
const busyEntryId = ref('')
const errorMessage = ref('')
const resumeImport = ref<ResumeImportView | null>(null)
const importBusy = ref(false)
const optimizationRuns = ref<Record<string, OptimizationRunView>>({})
const skillCandidates = ref<SkillRecommendationCandidate[]>([])
const skillsBusy = ref(false)
const summaryBusy = ref(false)
const targetPositionOverride = ref('')
const targetPosition = computed(() => {
const target = resume.value?.content.target || {}
return targetPositionOverride.value || String(target.position || target.target_position || '').trim()
})
watch(sessionId, () => {
targetPositionOverride.value = ''
})
function syncFrom(envelope: ResumeAgentEnvelope | null | undefined) {
if (!envelope) return
const next = unwrapResume(envelope)
if (next) resume.value = next
}
function formatError(error: unknown): string {
if (error instanceof ResumeAgentApiError) {
if (error.status === 429) {
return '操作过于频繁,请稍后再试(轻度优化每小时最多 20 次)。'
}
if (error.status === 403 && error.payload?.error?.code === 'deep_requires_vip') {
return '深度优化为 VIP 功能,升级后可继续进行多轮追问与改写。'
}
if (error.payload?.error?.code === 'resume_import_not_allowed') {
return '简历预览已有内容,如需导入请先从头部重新开始。'
}
return error.message
}
if (error instanceof Error && error.message) return error.message
return 'Unable to update resume content. Please retry.'
}
async function setTargetPosition(position: string): Promise<boolean> {
const normalized = position.trim()
if (!sessionId.value || !normalized || normalized.length > 32 || busyEntryId.value) return false
busyEntryId.value = 'target-position'
errorMessage.value = ''
try {
const result = await resumeAgentApi.setTargetPosition(sessionId.value, normalized)
targetPositionOverride.value = result.target_position
if (resume.value) {
resume.value = {
...resume.value,
content: {
...resume.value.content,
target: {
...resume.value.content.target,
position: result.target_position,
target_position: result.target_position,
},
},
}
}
return true
} catch (error) {
errorMessage.value = formatError(error)
return false
} finally {
busyEntryId.value = ''
}
}
async function run(entryId: string, mutator: () => Promise<ResumeAgentEnvelope>) {
if (!sessionId.value || busyEntryId.value) return
busyEntryId.value = entryId || 'basics'
errorMessage.value = ''
try {
syncFrom(await mutator())
} catch (error) {
errorMessage.value = formatError(error)
} finally {
busyEntryId.value = ''
}
}
async function runOptimization(
entryId: string,
mutator: () => Promise<OptimizationRunView>,
): Promise<boolean> {
if (!sessionId.value || busyEntryId.value) return false
busyEntryId.value = entryId
errorMessage.value = ''
try {
const next = await mutator()
optimizationRuns.value = { ...optimizationRuns.value, [entryId]: next }
syncFrom(next.action)
if (next.status === 'confirmed' || next.status === 'rejected') {
const { [entryId]: _finished, ...remaining } = optimizationRuns.value
optimizationRuns.value = remaining
}
return true
} catch (error) {
errorMessage.value = formatError(error)
return false
} finally {
busyEntryId.value = ''
}
}
async function resolveOptimizationRun(entryId: string): Promise<OptimizationRunView | null> {
const inMemory = optimizationRuns.value[entryId]
if (inMemory) return inMemory
if (!sessionId.value) return null
try {
const runs = await resumeAgentApi.listActiveOptimizationRuns(sessionId.value)
const restored = Object.fromEntries(runs.map((run) => [run.entry_id, run]))
optimizationRuns.value = { ...optimizationRuns.value, ...restored }
const run = restored[entryId]
if (run) return run
errorMessage.value = '未找到与候选优化稿对应的优化任务。该任务可能已失效,请重新发起优化。'
} catch (error) {
errorMessage.value = formatError(error)
}
return null
}
async function restoreOptimizationRuns() {
if (!sessionId.value) {
optimizationRuns.value = {}
return
}
try {
const runs = await resumeAgentApi.listActiveOptimizationRuns(sessionId.value)
optimizationRuns.value = Object.fromEntries(runs.map((run) => [run.entry_id, run]))
} catch (error) {
errorMessage.value = formatError(error)
}
}
async function patch(operation: ResumePatchOperationInput, entryId = '') {
const revision = resume.value?.revision
if (revision === undefined) return
await run(entryId, () =>
resumeAgentApi.patchResume(sessionId.value, { expected_revision: revision, operation }),
)
}
async function recommendSkills(question: string) {
if (!sessionId.value || skillsBusy.value) return
skillsBusy.value = true
errorMessage.value = ''
try {
const result = await resumeAgentApi.recommendSkills(sessionId.value, question)
skillCandidates.value = result.candidates
} catch (error) {
errorMessage.value = formatError(error)
} finally {
skillsBusy.value = false
}
}
async function runProfileSummary(mutator: () => Promise<ResumeAgentEnvelope>): Promise<boolean> {
if (!sessionId.value || summaryBusy.value || busyEntryId.value) return false
summaryBusy.value = true
errorMessage.value = ''
try {
syncFrom(await mutator())
return true
} catch (error) {
errorMessage.value = formatError(error)
return false
} finally {
summaryBusy.value = false
}
}
async function uploadImport(file: File) {
if (!sessionId.value || importBusy.value) return
if (resume.value) {
errorMessage.value = '简历预览已有内容,如需导入请先从头部重新开始。'
return
}
importBusy.value = true
errorMessage.value = ''
try {
resumeImport.value = await resumeAgentApi.uploadResumeImport(sessionId.value, file)
} catch (error) {
errorMessage.value = formatError(error)
} finally {
importBusy.value = false
}
}
async function applyImport() {
if (!sessionId.value || !resumeImport.value || importBusy.value) return
importBusy.value = true
errorMessage.value = ''
try {
const response = await resumeAgentApi.applyResumeImport(
sessionId.value,
resumeImport.value.id,
resume.value?.revision ?? 0,
)
syncFrom(response)
resumeImport.value = { ...resumeImport.value, status: 'applied' }
} catch (error) {
errorMessage.value = formatError(error)
} finally {
importBusy.value = false
}
}
async function cancelImport() {
if (!sessionId.value || !resumeImport.value || importBusy.value) return
importBusy.value = true
errorMessage.value = ''
try {
await resumeAgentApi.cancelResumeImport(sessionId.value, resumeImport.value.id)
resumeImport.value = null
} catch (error) {
errorMessage.value = formatError(error)
} finally {
importBusy.value = false
}
}
return {
resume,
busyEntryId,
errorMessage,
resumeImport,
importBusy,
optimizationRuns,
skillCandidates,
skillsBusy,
summaryBusy,
targetPosition,
syncFrom,
setTargetPosition,
restoreOptimizationRuns,
uploadImport,
applyImport,
cancelImport,
updateBasics: (fields: Record<string, string>) => patch({ type: 'update_basics', fields }),
updateSkillGroups: (skills: string[]) => patch({ type: 'update_skill_groups', skills }, 'skills'),
updateProfileSummary: (content: string) =>
runProfileSummary(() => {
const revision = resume.value?.revision
if (revision === undefined) throw new Error('Resume is not ready')
return resumeAgentApi.patchResume(sessionId.value, {
expected_revision: revision,
operation: { type: 'update_profile_summary', fields: { content } },
})
}),
generateProfileSummary: () =>
runProfileSummary(() => resumeAgentApi.generateProfileSummary(sessionId.value)),
confirmProfileSummary: () =>
runProfileSummary(() => resumeAgentApi.confirmProfileSummary(sessionId.value)),
rejectProfileSummary: () =>
runProfileSummary(() => resumeAgentApi.rejectProfileSummary(sessionId.value)),
recommendSkills,
updateEntry: (entryId: string, fields: Record<string, string>) =>
patch({ type: 'update_entry', entry_id: entryId, fields }, entryId),
updateBullet: (entryId: string, bulletId: string, text: string) =>
patch({ type: 'update_bullet', entry_id: entryId, bullet_id: bulletId, text }, entryId),
deleteEntry: (entryId: string) => patch({ type: 'delete_entry', entry_id: entryId }, entryId),
deleteBullet: (entryId: string, bulletId: string) =>
patch({ type: 'delete_bullet', entry_id: entryId, bullet_id: bulletId }, entryId),
optimizeLight: (entryId: string) =>
runOptimization(entryId, () => resumeAgentApi.optimizeLight(sessionId.value, entryId)),
confirmOptimization: async (entryId: string) => {
const runView = await resolveOptimizationRun(entryId)
if (!runView) return false
return runOptimization(entryId, () =>
resumeAgentApi.confirmOptimization(sessionId.value, runView.id),
)
},
rejectOptimization: async (entryId: string) => {
const runView = await resolveOptimizationRun(entryId)
if (!runView) return false
return runOptimization(entryId, () =>
resumeAgentApi.rejectOptimization(sessionId.value, runView.id),
)
},
undoOptimize: (entryId: string) =>
run(entryId, () => resumeAgentApi.undoOptimize(sessionId.value, entryId)),
clearError: () => {
errorMessage.value = ''
},
}
}
+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
}
+11
View File
@@ -0,0 +1,11 @@
import { createApp } from 'vue'
import App from './App.vue'
import './styles/base.css'
const pathname = window.location.pathname.replace(/\/+$/, '') || '/'
const pages = {
'/builder': App,
} as const
const rootComponent = pages[pathname as keyof typeof pages] || App
createApp(rootComponent).mount('#app')
+476
View File
@@ -0,0 +1,476 @@
: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;
}
}
/* Skill group labels read as "分类:技能" (ResumePreviewPanel is over the 200-line edit gate) */
.preview-panel__skill-group strong::after { content: "\FF1A"; }
+342
View File
@@ -0,0 +1,342 @@
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'
| 'custom_card_picker'
| 'anchor_type_cards'
| 'short_text'
| 'degree_selector'
| 'date_range_selector'
| 'choice_chips'
| 'experience_confirm'
| 'create_resume'
| 'anchor_fields'
| 'record_fields'
| 'tags_input'
| 'competition_fields'
| 'add_another'
| 'progress_card'
| 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
resume?: ResumeView | null
trace_id?: string
builder_stream_phases?: BuilderStreamPhase[]
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
}
}
export interface ResumeBullet {
id: string
text: string
}
export interface PendingProposal {
optimized_description: string
changes?: string[]
generation_source?: 'llm' | 'llm_failed' | 'rule' | 'rule_fallback'
fallback_reason?: string
missing_facts?: string[]
unconfirmed_suggestions?: string[]
optional_enhancements?: string[]
validation_warnings?: string[]
omitted_fact_ids?: string[]
star?: Record<string, string | null>
source: string
based_on: string
created_at: string
}
export interface OptimizationQuestion {
id: string
text: string
dimension?: string
reason?: string
decision_source?: string
}
export interface OptimizationProposal {
optimized_description: string
changes?: string[]
generation_source?: 'llm' | 'llm_failed' | 'rule' | 'rule_fallback'
fallback_reason?: string
missing_facts?: string[]
unconfirmed_suggestions?: string[]
optional_enhancements?: string[]
validation_warnings?: string[]
omitted_fact_ids?: string[]
star?: Record<string, string | null>
source: string
}
export interface OptimizationGap {
dimension: string
severity: number
askability: number
evidence: string
job_weight: number
value?: number
}
export interface OptimizationRunView {
id: string
mode: 'light' | 'deep'
status: 'question_pending' | 'proposal_pending' | 'confirmed' | 'rejected' | string
entry_id: string
question_count: number
question?: OptimizationQuestion | null
proposal?: OptimizationProposal | null
covered_dimensions?: string[]
remaining_high_priority_gaps?: string[]
decision_source?: string | null
error_code?: string | null
gap_report?: OptimizationGap[] | null
tier?: string | null
action?: ResumeAgentEnvelope | null
}
export interface PreviousVersion {
description?: string
provenance: string
}
export interface ResumeEntry {
id: string
provenance?: string
resume_bullets?: ResumeBullet[]
pending_proposal?: PendingProposal
gap_report?: {
gaps: OptimizationGap[]
based_on?: string
stale?: boolean
} | null
previous_version?: PreviousVersion
[key: string]: unknown
}
export interface ResumeSection {
id: string
kind: string
heading: string
items: ResumeEntry[]
}
export interface SkillGroup {
category: string
skills: string[]
}
export interface ResumeDocument {
schema_version: number
basics: Record<string, unknown>
target?: Record<string, unknown>
sections: ResumeSection[]
skill_groups: SkillGroup[]
profile_summary?: {
content: string
source: 'ai_generated' | 'user_edited'
generated_at?: string
stale: boolean
pending_proposal?: {
content: string
source?: 'ai_generated'
generated_at?: string
} | null
} | null
}
export interface ResumeImportEvidence {
page?: number | null
paragraph?: number | null
text: string
}
export interface ResumeImportFieldReview {
field_path: string
value: unknown
confidence: number
status: "needs_review" | "verified"
evidence: ResumeImportEvidence[]
}
export interface ResumeImportView {
id: string
session_id: string
file_name: string
mime_type: string
size_bytes: number
sha256: string
status: "awaiting_review" | "applied" | "failed" | "cancelled"
document: ResumeDocument | null
field_reviews: ResumeImportFieldReview[]
error_code?: string | null
created_at: string
updated_at: string
}
export interface SkillRecommendationCandidate {
skill: string
category: string
reason: string
evidence_supported: boolean
}
export interface ResumeView {
id: string
session_id?: string
revision: number
content: ResumeDocument
}
export type ResumePatchOperationInput =
| { type: 'update_basics'; fields: Record<string, string> }
| { type: 'update_entry'; entry_id: string; fields: Record<string, string> }
| { type: 'update_bullet'; entry_id: string; bullet_id: string; text: string }
| { type: 'delete_entry'; entry_id: string }
| { type: 'delete_bullet'; entry_id: string; bullet_id: string }
| { type: 'update_skill_groups'; skills: string[] }
| { type: 'update_profile_summary'; fields: { content: string } }
export type BuilderStreamPhase = 'suggesting_next' | 'structuring' | 'checking_gaps' | 'rewriting' | 'saving'
export interface BuilderStreamEvent {
event: 'status' | 'delta' | 'complete' | 'error'
data: { phase?: BuilderStreamPhase; label?: string; text?: string } | ResumeAgentEnvelope | {
code?: string
message?: string
status_code?: number
}
}
+88
View File
@@ -0,0 +1,88 @@
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',
anchor_fields_card: 'anchor_fields',
record_fields_card: 'record_fields',
}
return aliases[key] || key
}
+23
View File
@@ -0,0 +1,23 @@
const DIMENSION_LABELS: Record<string, string> = {
personal_contribution: '个人贡献',
responsibility_scope: '职责范围',
method_or_technology: '方法与技术',
business_action: '业务行动',
outcome_or_delivery: '交付成果',
quantified_outcome: '量化成果',
coursework_or_practice: '课程实践',
relevant_capability: '相关能力',
work_or_solution: '方案实现',
activity_execution: '活动执行',
collaboration_scope: '协作范围',
academic_result: '学业成果',
project_or_activity: '项目或活动',
project_context: '项目背景',
business_context: '业务背景',
team_scope: '团队规模',
organization_scope: '组织规模',
}
export function dimensionLabel(dimension: string): string {
return DIMENSION_LABELS[dimension] || '其他信息'
}
+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,
},
},
},
}
})