generated from kgod/ai-review-template
529 lines
15 KiB
Vue
529 lines
15 KiB
Vue
<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>
|