generated from kgod/ai-review-template
feat: builder 简历生成 + 轻度优化 + 简历导入交付副本
自内部仓库剥离深度优化与 RAG 知识库后的交付版本: - Builder 对话式简历生成(FSM + 意图路由 LLM 兜底增强) - 条目级轻度优化:事实覆盖门禁 + STAR/bullet 修复链,功能/简介/成果与技术栈同级保护 - 简历导入:DOCX/PDF 解析、结构归一、手机号脱敏 - PostgreSQL 运行时 + Alembic 迁移链 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
.DS_Store
|
||||
*.local
|
||||
*.tsbuildinfo
|
||||
@@ -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>
|
||||
Generated
+1537
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 = ''
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+11
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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"; }
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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] || '其他信息'
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user