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