Files
resume-agent/frontend/src/App.vue
T

529 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import AgentTimeline from './components/AgentTimeline.vue'
import AppHeader from './components/AppHeader.vue'
import ComposerBar from './components/ComposerBar.vue'
import EditResumePreview from './components/EditResumePreview.vue'
import FeatureNavigation from './components/FeatureNavigation.vue'
import ResumeImportPanel from './components/ResumeImportPanel.vue'
import { useResumeAgent } from './composables/useResumeAgent'
import { useResumeDocument } from './composables/useResumeDocument'
const {
hasLandingToken,
sessionId,
revision,
stage,
timeline,
composer,
missingFields,
resumeId,
resumeHook: agentResumeHook,
traceId,
initializing,
pendingBlockId,
sendingMessage,
creatingResume,
resetting,
errorMessage,
aiStatus,
streamedAssistantText,
isBusy,
start,
refreshTimeline,
submitComponent,
sendMessage,
createResume,
resetSession,
clearError,
} = useResumeAgent()
const resumeDocument = useResumeDocument(sessionId)
const mobilePanel = ref<'chat' | 'resume'>('chat')
const displayedRevision = computed(() => resumeDocument.resume.value?.revision ?? revision.value)
const chatEnabled = computed(() => composer.value.mode !== 'ui_only')
const stageCode = computed(() => stage.value.toUpperCase().replaceAll('_', ' / '))
const REMOTE_REFRESH_INTERVAL_MS = 20_000
let remoteRefreshTimer: number | undefined
let remoteRefreshController: AbortController | null = null
const remoteRefreshBlocked = computed(
() =>
!hasLandingToken ||
!sessionId.value ||
isBusy.value ||
Boolean(resumeDocument.busyEntryId.value) ||
resumeDocument.importBusy.value ||
resumeDocument.skillsBusy.value ||
resumeDocument.summaryBusy.value,
)
function cancelRemoteRefresh() {
remoteRefreshController?.abort()
remoteRefreshController = null
}
async function refreshExternalChanges() {
if (
remoteRefreshBlocked.value ||
document.visibilityState !== 'visible' ||
remoteRefreshController
) return
const activeController = new AbortController()
remoteRefreshController = activeController
try {
await refreshTimeline(activeController.signal)
} catch {
// Background synchronization retries on the next interval or focus event.
} finally {
if (remoteRefreshController === activeController) remoteRefreshController = null
}
}
function handleWindowFocus() {
void refreshExternalChanges()
}
function handleVisibilityChange() {
if (document.visibilityState === 'visible') {
void refreshExternalChanges()
} else {
cancelRemoteRefresh()
}
}
const stageLabels: Record<string, string> = {
starting: '准备会话',
PRIVACY_CONSENT: '隐私确认',
RESUME_SOURCE_SELECT: '选择创建方式',
RESUME_IMPORT_UPLOAD: '导入简历',
PHONE_SELECTION: '手机号授权',
MANUAL_PHONE_INPUT: '填写手机号',
PERSONAL_INFO: '基本信息',
JOB_TYPE_SELECT: '求职类型',
TARGET_POSITION: '目标职位',
TARGET_POSITION_MAJOR: '专业与方向',
TARGET_POSITION_RECOMMENDATION: '职位建议',
MINIMUM_READY: '准备创建',
RESUME_CREATING: '正在创建',
CREATE_FAILED: '等待重试',
BUILDER_CONVERSATION: '简历共创',
}
const stageLabel = computed(() => stageLabels[stage.value] || '整理简历')
watch(agentResumeHook, (value) => {
resumeDocument.resume.value = value ?? null
})
watch(sessionId, (value, previous) => {
if (!value || value !== previous) void resumeDocument.restoreOptimizationRuns()
})
watch(() => resumeDocument.resumeImport.value?.status, (status) => {
if (status === 'applied') void refreshTimeline()
})
watch(remoteRefreshBlocked, (blocked) => {
if (blocked) cancelRemoteRefresh()
}, { flush: 'sync' })
async function retryConnection() {
clearError()
if (!sessionId.value) {
await start()
return
}
try {
await refreshTimeline()
} catch {
await start()
}
}
async function confirmReset() {
if (window.confirm('重新开始会清空当前简历共创记录。确定继续吗?')) await resetSession()
}
onMounted(() => {
void start()
remoteRefreshTimer = window.setInterval(() => {
void refreshExternalChanges()
}, REMOTE_REFRESH_INTERVAL_MS)
window.addEventListener('focus', handleWindowFocus)
document.addEventListener('visibilitychange', handleVisibilityChange)
})
onBeforeUnmount(() => {
if (remoteRefreshTimer !== undefined) window.clearInterval(remoteRefreshTimer)
window.removeEventListener('focus', handleWindowFocus)
document.removeEventListener('visibilitychange', handleVisibilityChange)
cancelRemoteRefresh()
})
</script>
<template>
<div id="top" class="app-shell">
<main v-if="!sessionId" class="auth-gate" aria-live="polite">
<section class="auth-gate__card" :aria-busy="initializing">
<div class="auth-gate__brand" aria-label="OfferPai Resume Agent">
<span aria-hidden="true">OP</span>
<strong>OfferPai Resume Agent</strong>
</div>
<p class="auth-gate__eyebrow">
{{ initializing ? 'VERIFYING ACCESS' : 'ACCESS REQUIRED' }}
</p>
<h1>
{{ initializing ? '正在验证 OfferPai 登录状态' : '需要先完成 OfferPai 鉴权' }}
</h1>
<p v-if="initializing" class="auth-gate__message">
正在校验登录凭证和账号信息验证通过后会自动进入简历服务
</p>
<p v-else class="auth-gate__message" role="alert">
{{ errorMessage || '当前登录凭证不可用,请重新从 OfferPai 进入。' }}
</p>
<div v-if="initializing" class="auth-gate__progress" aria-hidden="true"><i /></div>
<button
v-else-if="hasLandingToken"
type="button"
class="auth-gate__retry"
@click="retryConnection"
>
重新验证
</button>
<p v-else class="auth-gate__hint">
请使用 OfferPai 提供的带 <code>?token=</code> 入口重新打开本页
</p>
</section>
</main>
<template v-else>
<AppHeader
:stage-label="stageLabel"
:revision="displayedRevision"
:session-id="sessionId"
:resetting="resetting"
@reset="confirmReset"
/>
<FeatureNavigation current="builder" />
<main class="workspace">
<div class="mobile-view-tabs" role="tablist" aria-label="移动端工作区视图">
<button
type="button"
role="tab"
:aria-selected="mobilePanel === 'chat'"
:class="{ 'mobile-view-tabs__tab--active': mobilePanel === 'chat' }"
@click="mobilePanel = 'chat'"
>对话</button>
<button
type="button"
role="tab"
:aria-selected="mobilePanel === 'resume'"
:class="{ 'mobile-view-tabs__tab--active': mobilePanel === 'resume' }"
@click="mobilePanel = 'resume'"
>简历</button>
</div>
<section
class="preview-panel"
:class="{ 'mobile-panel--active': mobilePanel === 'resume' }"
aria-label="简历预览与编辑"
>
<EditResumePreview :document="resumeDocument" />
</section>
<section
class="conversation"
:class="{ 'mobile-panel--active': mobilePanel === 'chat' }"
aria-labelledby="conversation-title"
>
<header class="conversation-heading">
<div>
<p>{{ stageCode }}</p>
<h1 id="conversation-title">把经历一步步写成简历</h1>
</div>
<span class="conversation-heading__live" :class="{ 'conversation-heading__live--busy': aiStatus }">
<i aria-hidden="true" />
{{ aiStatus || 'AI 共创中' }}
</span>
</header>
<ResumeImportPanel
v-if="stage === 'RESUME_IMPORT_UPLOAD'"
:document="resumeDocument"
:disabled="!sessionId || initializing"
/>
<AgentTimeline
:timeline="timeline"
:initializing="initializing"
:pending-block-id="pendingBlockId"
:creating-resume="creatingResume"
:error-message="errorMessage"
:trace-id="traceId"
:resume-id="resumeId"
:missing-fields="missingFields"
:ai-status="aiStatus"
:streamed-assistant-text="streamedAssistantText"
@submit="submitComponent"
@create="createResume"
@retry="retryConnection"
@clear-error="clearError"
/>
<ComposerBar
:config="composer"
:sending="sendingMessage"
:disabled="composer.mode !== 'ui_only' && !chatEnabled"
@send="sendMessage"
/>
</section>
</main>
<footer class="app-footer">
<span>OfferPai Resume Agent</span>
<span>你的内容会保留在本次简历会话中</span>
</footer>
</template>
</div>
</template>
<style scoped>
.app-shell { min-height: 100vh; }
.auth-gate {
display: grid;
min-height: 100vh;
place-items: center;
padding: 28px;
background:
radial-gradient(circle at 18% 12%, rgba(93, 177, 165, .17), transparent 34%),
radial-gradient(circle at 82% 78%, rgba(146, 188, 112, .13), transparent 32%),
#f5faf8;
}
.auth-gate__card {
width: min(100%, 520px);
padding: clamp(30px, 6vw, 54px);
border: 1px solid rgba(174, 207, 201, .82);
border-radius: 28px;
background: rgba(255, 255, 255, .9);
box-shadow: 0 24px 70px rgba(31, 82, 75, .12);
}
.auth-gate__brand {
display: flex;
align-items: center;
gap: 12px;
color: var(--ink);
font-size: 14px;
}
.auth-gate__brand span {
display: grid;
width: 38px;
height: 38px;
place-items: center;
border-radius: 12px;
color: #fff;
background: var(--brand-dark);
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
font-size: 11px;
font-weight: 800;
letter-spacing: .08em;
}
.auth-gate__eyebrow {
margin: 48px 0 0;
color: var(--brand-dark);
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
font-size: 10px;
font-weight: 800;
letter-spacing: .14em;
}
.auth-gate h1 {
margin: 12px 0 0;
color: var(--ink);
font-family: "Aptos Display", "MiSans", "PingFang SC", sans-serif;
font-size: clamp(28px, 6vw, 40px);
line-height: 1.18;
}
.auth-gate__message {
margin: 18px 0 0;
color: var(--ink-muted);
font-size: 14px;
line-height: 1.8;
}
.auth-gate__progress {
height: 4px;
margin-top: 34px;
overflow: hidden;
border-radius: 999px;
background: #e4efec;
}
.auth-gate__progress i {
display: block;
width: 42%;
height: 100%;
border-radius: inherit;
background: var(--brand);
animation: auth-progress 1.15s ease-in-out infinite alternate;
}
.auth-gate__retry {
min-height: 44px;
margin-top: 28px;
padding: 0 22px;
border: 0;
border-radius: 12px;
color: #fff;
background: var(--brand-dark);
font-size: 14px;
font-weight: 750;
cursor: pointer;
}
.auth-gate__retry:hover { filter: brightness(.94); }
.auth-gate__retry:focus-visible { outline: 3px solid rgba(57, 139, 128, .28); outline-offset: 3px; }
.auth-gate__hint {
margin: 24px 0 0;
padding-top: 20px;
border-top: 1px solid var(--line);
color: var(--ink-faint);
font-size: 12px;
line-height: 1.7;
}
.auth-gate__hint code {
padding: 2px 5px;
border-radius: 5px;
color: var(--brand-dark);
background: #eaf4f1;
}
.workspace {
display: grid;
width: min(1280px, calc(100% - 40px));
grid-template-columns: minmax(330px, .88fr) minmax(0, 1.35fr);
align-items: start;
gap: clamp(28px, 4vw, 64px);
margin: 0 auto;
padding: 38px 0 0;
}
.preview-panel {
position: sticky;
top: 92px;
display: flex;
min-width: 0;
height: calc(100vh - 116px);
align-items: center;
}
.preview-panel :deep(.edit-preview) {
position: static;
width: 100%;
max-height: 100%;
}
.conversation { min-width: 0; }
.mobile-view-tabs { display: none; }
.conversation-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
margin: 0 0 30px;
padding-bottom: 20px;
border-bottom: 1px solid rgba(190, 217, 213, .78);
}
.conversation-heading p {
margin: 0;
color: var(--brand-dark);
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
font-size: 9px;
font-weight: 800;
letter-spacing: .08em;
}
.conversation-heading h1 {
max-width: 22ch;
margin: 8px 0 0;
color: var(--ink);
font-family: "Aptos Display", "MiSans", "PingFang SC", sans-serif;
font-size: 30px;
font-weight: 780;
letter-spacing: 0;
line-height: 1.18;
}
.conversation-heading__live {
display: inline-flex;
flex: none;
align-items: center;
gap: 6px;
max-width: 180px;
padding: 7px 10px;
border: 1px solid #cfe4d0;
border-radius: 999px;
color: #4b7551;
background: rgba(243, 250, 239, .86);
font-size: 10px;
font-weight: 750;
}
.conversation-heading__live i {
width: 6px;
height: 6px;
flex: none;
border-radius: 50%;
background: #7db75f;
box-shadow: 0 0 0 3px rgba(125, 183, 95, .14);
}
.conversation-heading__live--busy { color: var(--brand-dark); border-color: #a9d3cd; background: #eff9f7; }
.conversation-heading__live--busy i { background: var(--brand); animation: live-pulse 1s ease-in-out infinite; }
.app-footer {
display: flex;
width: min(1280px, calc(100% - 40px));
align-items: center;
justify-content: space-between;
gap: 20px;
margin: 28px auto 0;
padding: 24px 0 28px;
border-top: 1px solid rgba(194, 217, 214, .66);
color: var(--ink-faint);
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
font-size: 8px;
letter-spacing: .04em;
}
@keyframes live-pulse { 50% { opacity: .4; } }
@keyframes auth-progress { from { transform: translateX(-10%); } to { transform: translateX(150%); } }
@media (max-width: 850px) {
.workspace { width: min(100% - 28px, 760px); grid-template-columns: 1fr; gap: 22px; padding-top: 22px; }
.mobile-view-tabs { display: grid; grid-column: 1; grid-template-columns: 1fr 1fr; width: 100%; border-bottom: 1px solid var(--line); }
.mobile-view-tabs button { min-height: 42px; border: 0; border-bottom: 2px solid transparent; color: var(--ink-faint); background: transparent; font-size: 13px; font-weight: 700; }
.mobile-view-tabs__tab--active { border-bottom-color: var(--brand) !important; color: var(--ink) !important; }
.preview-panel, .conversation { display: none; grid-column: 1; }
.preview-panel { position: static; height: auto; align-items: stretch; }
.preview-panel :deep(.edit-preview) { max-height: none; }
.mobile-panel--active { display: block; }
.conversation-heading { margin-bottom: 22px; }
.app-footer { width: calc(100% - 28px); align-items: flex-start; flex-direction: column; gap: 5px; }
}
@media (max-width: 520px) {
.workspace { width: min(100% - 20px, 760px); }
.conversation-heading h1 { font-size: 26px; }
.conversation-heading__live { max-width: 130px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
}
</style>