feat(aihr): complete phase two pilot readiness

This commit is contained in:
2026-07-10 19:13:31 +08:00
parent 0219de6fa8
commit 13230e6b9a
48 changed files with 7040 additions and 1647 deletions
+231 -603
View File
@@ -3,9 +3,11 @@
<view class="page-head">
<view>
<text class="page-title">候选人</text>
<text class="page-subtitle">{{ candidateSubtitle }}</text>
<text class="page-subtitle">{{ subtitle }}</text>
</view>
<button v-if="loggedIn" class="ghost-button refresh-button" :disabled="loading" @click="loadPage">刷新</button>
<button v-if="loggedIn" class="ghost-button refresh-button" :disabled="loading" @click="loadPage">
{{ loading ? '刷新中' : '刷新' }}
</button>
</view>
<view v-if="message" class="status-message">{{ message }}</view>
@@ -13,396 +15,178 @@
<view v-if="!loggedIn" class="panel login-panel">
<view>
<text class="panel-title">需要登录</text>
<text class="muted block">登录后查看面试任务并提交补充资料。</text>
<text class="muted block">登录后查看面试任务、资料和审核进度。</text>
</view>
<button class="primary-button login-button" @click="goLogin">去登录</button>
</view>
<view v-if="candidatePrimary" class="panel task-panel">
<text class="task-badge">{{ candidatePrimary.badge }}</text>
<text class="task-title">{{ candidatePrimary.title }}</text>
<text class="task-desc">{{ candidatePrimary.desc }}</text>
<view class="progress-bar">
<view class="progress-fill" :style="{ width: `${candidatePrimary.progress}%` }" />
</view>
<text class="muted">{{ candidatePrimary.progressText }}</text>
<button class="primary-button interview-start" :disabled="primaryBusy" @click="runPrimaryAction">
{{ interviewStatus === 'active' && !needsMaterialUpload ? '重新生成题目' : primaryBusy ? '处理中' : candidatePrimary.cta }}
</button>
</view>
<view class="panel progress-panel">
<text class="panel-title">进度通知</text>
<text class="progress-notice">{{ progressNotice }}</text>
</view>
<view v-if="interviewQuestions.length" class="panel">
<view class="section-head">
<text class="panel-title">面试问答</text>
</view>
<view class="question-list">
<view v-for="item in interviewQuestions" :key="item.questionId" class="question-card">
<text class="question-meta">{{ item.seq }} · {{ item.scenario }}</text>
<text class="question-title">{{ item.questionText }}</text>
<text class="question-point">{{ item.point }}</text>
<textarea
v-model="interviewAnswers[item.questionId]"
class="textarea answer-textarea"
maxlength="400"
:disabled="interviewStatus !== 'active'"
placeholder="输入你的回答"
/>
<template v-else>
<view class="panel task-panel">
<view class="task-head">
<text class="task-badge">{{ flow.badge }}</text>
<text class="task-progress">{{ flow.progressText }}</text>
</view>
</view>
<button class="primary-button finish-button" :disabled="interviewStatus !== 'active'" @click="finishInterview">
提交评分
</button>
</view>
<view v-if="interviewResult" class="panel result-panel">
<view class="section-head">
<text class="panel-title">面试结果</text>
<text class="result-score">{{ interviewResult.totalScore }}分</text>
</view>
<text class="result-title">{{ interviewResult.result }}</text>
<text class="result-desc">{{ interviewResult.aiSummary }}</text>
<view v-if="interviewResult.dimensions?.length" class="dimension-list">
<view v-for="item in interviewResult.dimensions" :key="item.label" class="dimension-row">
<view>
<text class="dimension-title">{{ item.label }}</text>
<text class="dimension-note">{{ item.note }}</text>
</view>
<text class="dimension-value">{{ item.value }}</text>
<text class="task-title">{{ flow.title }}</text>
<text class="task-desc">{{ flow.desc }}</text>
<view class="progress-bar">
<view class="progress-fill" :style="{ width: `${flow.progress}%` }" />
</view>
</view>
</view>
<view v-if="loggedIn" class="panel">
<view class="section-head">
<text class="panel-title">最近面试</text>
<text class="muted">{{ interviewRecords.length }} 条</text>
</view>
<view v-if="interviewRecords.length" class="interview-list">
<view v-for="item in interviewRecords" :key="`${item.time}-${item.candidate}-${item.score}`" class="interview-row">
<view class="interview-copy">
<text class="interview-title">{{ item.candidate }} · {{ item.position }}</text>
<text class="interview-desc">{{ item.time }} · {{ item.summary }}</text>
</view>
<view class="interview-score">
<text>{{ item.score }}</text>
<text>{{ item.result }}</text>
</view>
</view>
</view>
<view v-else class="empty">暂无面试记录</view>
</view>
<view class="panel">
<view class="section-head">
<text class="panel-title">岗前预习</text>
<button class="ghost-button study-button" :disabled="studyLoading" @click="loadStudy">
{{ loggedIn ? studyLoading ? '检索中' : '生成预习' : '登录查看' }}
<button class="primary-button task-button" :disabled="loading" @click="openRoute(flow.route)">
{{ flow.cta }}
</button>
</view>
<text v-if="studyAnswer" class="study-answer">{{ studyAnswer }}</text>
<view v-if="studyRefs.length" class="study-refs">
<text v-for="item in studyRefs" :key="item" class="study-ref">{{ item }}</text>
</view>
<view v-if="!studyAnswer" class="empty">{{ loggedIn ? '点击生成预习,从正式知识库生成要点' : '登录后从正式知识库生成预习要点' }}</view>
</view>
<view v-if="loggedIn" class="panel">
<view class="section-head">
<text class="panel-title">补充资料</text>
<button class="primary-button upload-button" :disabled="uploading" @click="uploadMaterial">
{{ uploading ? '上传中' : '上传' }}
</button>
</view>
<view v-if="materials.length" class="material-list">
<view v-for="item in materials" :key="item.id" class="material-row">
<view class="material-copy">
<text class="material-title">{{ item.fileName }}</text>
<text class="material-desc">OSS {{ item.ossId }} · {{ item.materialType }} · {{ formatSize(item.fileSize) }}</text>
</view>
<view class="status-pill" :class="statusTone(item.status)">
{{ item.status }}
</view>
<view class="panel action-panel">
<view class="section-head">
<text class="panel-title">办理事项</text>
<text class="muted">按步骤完成</text>
</view>
<view class="action-list">
<button
v-for="item in actionItems"
:key="item.route"
class="action-row"
@click="openRoute(item.route)"
>
<text class="action-index">{{ item.index }}</text>
<view class="action-copy">
<text class="action-title">{{ item.title }}</text>
<text class="action-desc">{{ item.desc }}</text>
</view>
<text class="action-status" :class="item.tone">{{ item.status }}</text>
<text class="action-arrow">›</text>
</button>
</view>
</view>
<view v-else class="empty">暂无已提交资料</view>
</view>
<view class="panel">
<text class="panel-title">流程</text>
<view class="step-list">
<view v-for="step in steps" :key="step.title" class="step-row">
<text class="step-dot">{{ step.index }}</text>
<view>
<text class="step-title">{{ step.title }}</text>
<text class="step-desc">{{ step.desc }}</text>
<view class="panel activity-panel">
<view class="section-head">
<text class="panel-title">最新动态</text>
<button class="text-button" @click="openRoute('/pages/candidate/progress/index')">查看进度</button>
</view>
<view v-if="latestRecord" class="activity-row">
<text class="activity-label">面试</text>
<view class="activity-copy">
<text class="activity-title">{{ latestRecord.position }} · {{ latestRecord.score }}分</text>
<text class="activity-desc">{{ latestRecord.time }} · {{ latestRecord.result }}</text>
</view>
</view>
<view v-if="latestMaterial" class="activity-row">
<text class="activity-label">资料</text>
<view class="activity-copy">
<text class="activity-title">{{ latestMaterial.fileName }}</text>
<text class="activity-desc">{{ latestMaterial.time }} · {{ latestMaterial.status }}</text>
</view>
</view>
<view v-if="!latestRecord && !latestMaterial && !loading" class="empty compact-empty">暂无办理记录</view>
</view>
</view>
</template>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import type { CandidateMaterial, InterviewFinishResponse, InterviewQuestion, InterviewRecord, PrimaryTask, RoleHome } from '@/types/api';
import { getMobileHome } from '@/services/home';
import type { CandidateMaterial, InterviewRecord } from '@/types/api';
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { searchKnowledge } from '@/services/knowledge';
import { getCandidateInterviewRecords, getCandidateMaterials, getCandidateName } from '@/services/candidate';
import { buildCandidateFlow } from '@/services/candidate-view';
import { resetPageScroll } from '@/services/navigation';
import {
chooseCandidateFile,
finishCandidateInterview,
getCandidateInterviewRecords,
getCandidateName,
getCandidateMaterials,
saveInterviewAnswer,
startCandidateInterview,
uploadCandidateMaterial
} from '@/services/candidate';
const home = ref<RoleHome | null>(null);
const currentPath = '/pages/candidate/index/index';
const records = ref<InterviewRecord[]>([]);
const materials = ref<CandidateMaterial[]>([]);
const interviewRecords = ref<InterviewRecord[]>([]);
const interviewQuestions = ref<InterviewQuestion[]>([]);
const interviewAnswers = ref<Record<string, string>>({});
const interviewResult = ref<InterviewFinishResponse | null>(null);
const interviewSessionId = ref('');
const interviewStatus = ref<'idle' | 'starting' | 'active' | 'submitting' | 'finished'>('idle');
const loggedIn = ref(false);
const loading = ref(false);
const uploading = ref(false);
const studyLoading = ref(false);
const studyAnswer = ref('');
const studyRefs = ref<string[]>([]);
const message = ref('');
const interviewBusy = computed(() => interviewStatus.value === 'starting' || interviewStatus.value === 'submitting');
const latestMaterialStatus = computed(() => materials.value[0]?.status || '');
const candidateSubtitle = computed(() => loggedIn.value ? `尾号${getCandidateName().slice(-4)},面试、补资料、等通知` : '面试、补资料、等通知');
const hasInterview = computed(() => interviewRecords.value.length > 0 || interviewStatus.value === 'finished');
const needsMaterialUpload = computed(() =>
loggedIn.value && hasInterview.value && (!materials.value.length || latestMaterialStatus.value === '已驳回')
);
const primaryBusy = computed(() => loading.value || interviewBusy.value || (needsMaterialUpload.value && uploading.value));
const defaultCandidateTask: PrimaryTask = {
badge: '待面试',
title: '结构化面试',
desc: '按岗位题目完成回答,提交后生成面试记录。',
progress: 0,
progressText: '0/2',
cta: '开始面试'
};
const candidatePrimary = computed<PrimaryTask | null>(() => {
if (!loggedIn.value) return null;
const base = home.value?.primary || defaultCandidateTask;
const materialStatus = latestMaterialStatus.value;
if (!hasInterview.value) return { ...base, badge: '待面试', progress: 0, progressText: '0/2', cta: '开始面试' };
if (materialStatus === '已通过') return { ...base, badge: '资料已通过', title: '等待后续通知', desc: '面试和补充资料已完成,留意 HR 通知。', progress: 100, progressText: '2/2', cta: '刷新进度' };
if (materialStatus === '已驳回') return { ...base, badge: '资料被驳回', title: '重新提交资料', desc: '请按驳回结果补充或替换资料。', progress: 50, progressText: '1/2', cta: '重新上传' };
if (materials.value.length) return { ...base, badge: '审核中', title: '资料审核中', desc: '资料已提交,等待 HR 审核。', progress: 75, progressText: '1/2', cta: '刷新进度' };
return { ...base, badge: '待补资料', title: '上传补充资料', desc: '面试已完成,请补充简历、证书或图片资料。', progress: 50, progressText: '1/2', cta: '上传资料' };
});
const progressNotice = computed(() => {
if (!loggedIn.value) return '登录后查看面试和资料进度';
if (!hasInterview.value) return '待完成结构化面试';
if (!materials.value.length) return '面试已完成,请上传补充资料';
if (latestMaterialStatus.value === '已通过') return '资料已通过,等待后续通知';
if (latestMaterialStatus.value === '已驳回') return '资料被驳回,请重新上传';
return '资料已提交,等待 HR 审核';
});
const steps = [
{ index: 1, title: '完成面试', desc: '按岗位题目回答,生成面试记录' },
{ index: 2, title: '补充资料', desc: '上传简历、证书或图片资料' },
{ index: 3, title: '等待审核', desc: 'HR 审核后同步显示状态' }
];
const clearCandidatePrivateData = () => {
const flow = computed(() => buildCandidateFlow(records.value, materials.value));
const latestRecord = computed(() => records.value[0] || null);
const latestMaterial = computed(() => materials.value[0] || null);
const subtitle = computed(() => loggedIn.value
? `尾号${getCandidateName().slice(-4)},按当前任务办理`
: '面试、补资料、查进度');
const actionItems = computed(() => [
{
index: 1,
title: '面试任务',
desc: records.value.length ? '查看最近结果或重新面试' : '完成岗位结构化问答',
status: records.value.length ? '已完成' : '待完成',
tone: records.value.length ? 'done' : 'current',
route: '/pages/candidate/interview/index'
},
{
index: 2,
title: '补充资料',
desc: latestMaterial.value ? latestMaterial.value.fileName : '上传简历、证书或图片',
status: latestMaterial.value?.status || '未提交',
tone: latestMaterial.value?.status === '已通过' ? 'done' : flow.value.stage === 'materials' ? 'current' : 'pending',
route: '/pages/candidate/materials/index'
},
{
index: 3,
title: '进度通知',
desc: flow.value.notice,
status: flow.value.progressText,
tone: flow.value.stage === 'complete' ? 'done' : 'pending',
route: '/pages/candidate/progress/index'
},
{
index: 4,
title: '岗前预习',
desc: '从正式知识库生成岗位要点',
status: '按需查看',
tone: 'pending',
route: '/pages/candidate/study/index'
}
]);
const clearData = () => {
records.value = [];
materials.value = [];
interviewRecords.value = [];
interviewQuestions.value = [];
interviewAnswers.value = {};
interviewResult.value = null;
interviewSessionId.value = '';
interviewStatus.value = 'idle';
studyAnswer.value = '';
studyRefs.value = [];
};
const clearInterviewDraft = () => {
interviewQuestions.value = [];
interviewAnswers.value = {};
interviewResult.value = null;
interviewSessionId.value = '';
};
const refreshAuthState = () => {
loggedIn.value = isLoggedIn();
};
const handleRequestError = (error: unknown, fallback: string, wasLoggedIn = loggedIn.value) => {
refreshAuthState();
if (wasLoggedIn && !loggedIn.value) {
clearCandidatePrivateData();
message.value = '登录已过期,请重新登录';
return true;
}
message.value = error instanceof Error ? error.message : fallback;
return false;
};
const loadPage = async () => {
refreshAuthState();
const wasLoggedIn = loggedIn.value;
loading.value = true;
message.value = '';
try {
home.value = await getMobileHome('candidate');
} catch (error) {
home.value = null;
if (!loggedIn.value) {
clearData();
return;
}
if (loading.value) return;
loading.value = true;
try {
if (!loggedIn.value) {
clearCandidatePrivateData();
return;
}
const [nextMaterials, nextRecords] = await Promise.all([
getCandidateMaterials(),
getCandidateInterviewRecords()
]);
materials.value = nextMaterials;
interviewRecords.value = nextRecords;
records.value = nextRecords;
} catch (error) {
handleRequestError(error, '候选人数据加载失败', wasLoggedIn);
refreshAuthState();
if (!loggedIn.value) {
clearData();
message.value = '登录已过期,请重新登录';
} else {
message.value = error instanceof Error ? error.message : '候选人进度加载失败';
}
} finally {
loading.value = false;
}
};
const uploadMaterial = async () => {
if (uploading.value) return;
if (!loggedIn.value) {
goLogin();
return;
}
uploading.value = true;
message.value = '';
try {
const file = await chooseCandidateFile();
const saved = await uploadCandidateMaterial(file);
message.value = `资料已上传:#${saved.id} / OSS ${saved.ossId}`;
materials.value = await getCandidateMaterials();
} catch (error) {
handleRequestError(error, '资料上传失败', true);
} finally {
uploading.value = false;
}
};
const loadStudy = async () => {
if (studyLoading.value) return;
if (!loggedIn.value) {
goLogin();
return;
}
studyLoading.value = true;
message.value = '';
try {
const result = await searchKnowledge('生活顾问岗前预习 投诉处理 报事报修 客户沟通', '生活顾问');
studyAnswer.value = result.answer;
studyRefs.value = (result.docs || []).map((item) => item.title).filter(Boolean).slice(0, 3);
} catch (error) {
handleRequestError(error, '岗前预习生成失败', true);
} finally {
studyLoading.value = false;
}
};
const startInterview = async () => {
if (interviewBusy.value) return;
if (!loggedIn.value) {
goLogin();
return;
}
interviewStatus.value = 'starting';
clearInterviewDraft();
message.value = '';
try {
const data = await startCandidateInterview();
interviewSessionId.value = data.sessionId;
interviewQuestions.value = data.questions || [];
interviewAnswers.value = Object.fromEntries(interviewQuestions.value.map((item) => [item.questionId, '']));
interviewStatus.value = 'active';
message.value = `已生成 ${interviewQuestions.value.length} 道题`;
} catch (error) {
interviewStatus.value = 'idle';
handleRequestError(error, '面试启动失败', true);
}
};
const runPrimaryAction = () => {
if (needsMaterialUpload.value) {
void uploadMaterial();
return;
}
if (hasInterview.value && materials.value.length) {
void loadPage();
return;
}
void startInterview();
};
const finishInterview = async () => {
if (interviewBusy.value) return;
if (interviewStatus.value !== 'active' || !interviewSessionId.value) {
message.value = '请先开始面试';
return;
}
const answers = Object.fromEntries(
Object.entries(interviewAnswers.value).map(([key, value]) => [key, value.trim()])
);
if (interviewQuestions.value.some((item) => !answers[item.questionId])) {
message.value = `请填写全部 ${interviewQuestions.value.length} 题回答`;
return;
}
interviewStatus.value = 'submitting';
message.value = '';
try {
await Promise.all(Object.entries(answers).filter(([, value]) => value).map(([questionId, answer]) =>
saveInterviewAnswer(interviewSessionId.value, questionId, answer)
));
interviewResult.value = await finishCandidateInterview(interviewSessionId.value, answers);
interviewRecords.value = interviewResult.value.records?.length
? interviewResult.value.records
: await getCandidateInterviewRecords();
interviewStatus.value = 'finished';
message.value = '面试完成,已生成面试记录';
} catch (error) {
if (!handleRequestError(error, '面试提交失败', true)) {
interviewStatus.value = 'active';
}
}
};
const formatSize = (size: number) => {
if (!size) return '0KB';
if (size < 1024 * 1024) return `${Math.ceil(size / 1024)}KB`;
return `${(size / 1024 / 1024).toFixed(1)}MB`;
};
const statusTone = (status: string) => {
if (status === '已通过') return 'passed';
if (status === '已驳回') return 'rejected';
return 'pending';
const openRoute = (route: string) => {
uni.navigateTo({ url: route });
};
const goLogin = () => {
rememberLoginRedirect('/pages/candidate/index/index');
uni.navigateTo({ url: '/pages/auth/login/index' });
rememberLoginRedirect(currentPath);
uni.redirectTo({ url: '/pages/auth/login/index' });
};
onShow(() => {
@@ -417,12 +201,16 @@ onShow(() => {
background: #f4f7fb;
}
.candidate-page .panel,
.candidate-page button {
border-radius: 8px;
}
.page-head,
.section-head,
.login-panel,
.interview-row,
.material-row,
.step-row {
.task-head,
.activity-row {
display: flex;
align-items: center;
justify-content: space-between;
@@ -430,9 +218,7 @@ onShow(() => {
}
.refresh-button,
.login-button,
.upload-button,
.study-button {
.login-button {
min-width: 84px;
}
@@ -440,28 +226,26 @@ onShow(() => {
display: block;
margin-top: 8px;
font-size: 14px;
line-height: 1.5;
}
.task-panel {
background: #fff7ed;
border-left: 4px solid #e60012;
}
.task-badge {
display: inline-flex;
padding: 5px 9px;
border-radius: 999px;
background: #fed7aa;
color: #9a3412;
font-size: 12px;
.task-badge,
.task-progress {
color: #b91c1c;
font-size: 13px;
font-weight: 800;
}
.task-title {
display: block;
margin-top: 12px;
margin-top: 14px;
color: #0f172a;
font-size: 22px;
font-weight: 900;
font-weight: 800;
}
.task-desc {
@@ -469,282 +253,126 @@ onShow(() => {
margin-top: 8px;
color: #64748b;
font-size: 14px;
line-height: 1.5;
line-height: 1.55;
}
.progress-bar {
height: 8px;
margin: 16px 0 10px;
margin-top: 16px;
overflow: hidden;
border-radius: 999px;
background: #ffedd5;
border-radius: 4px;
background: #fee2e2;
}
.progress-fill {
height: 100%;
border-radius: inherit;
background: #ea580c;
background: #e60012;
}
.progress-panel {
background: #eff6ff;
}
.progress-notice {
display: block;
margin-top: 10px;
color: #1e3a8a;
font-size: 15px;
font-weight: 800;
line-height: 1.45;
}
.interview-start,
.finish-button {
.task-button {
width: 100%;
margin-top: 16px;
}
.question-list,
.dimension-list {
margin-top: 14px;
}
.question-card {
.action-list {
margin-top: 12px;
padding: 14px;
border-radius: 14px;
background: #f8fafc;
border-top: 1px solid #e2e8f0;
}
.question-meta {
color: #ea580c;
font-size: 12px;
font-weight: 800;
.action-row {
display: grid;
width: 100%;
min-height: 72px;
padding: 12px 0;
border-bottom: 1px solid #e2e8f0;
border-radius: 0 !important;
background: transparent;
text-align: left;
grid-template-columns: 28px minmax(0, 1fr) auto 12px;
align-items: center;
gap: 10px;
}
.question-title {
display: block;
margin-top: 8px;
color: #0f172a;
font-size: 16px;
font-weight: 900;
line-height: 1.45;
}
.question-point {
display: block;
margin-top: 8px;
color: #64748b;
font-size: 13px;
line-height: 1.45;
}
.answer-textarea {
margin-top: 12px;
}
.result-score {
padding: 6px 10px;
border-radius: 999px;
background: #dcfce7;
color: #166534;
font-size: 14px;
font-weight: 900;
}
.result-title {
display: block;
margin-top: 12px;
color: #0f172a;
font-size: 20px;
font-weight: 900;
}
.result-desc {
display: block;
margin-top: 8px;
color: #64748b;
font-size: 14px;
line-height: 1.55;
}
.dimension-row {
display: flex;
justify-content: space-between;
gap: 12px;
margin-top: 10px;
padding: 12px;
border-radius: 12px;
background: #f8fafc;
}
.dimension-title {
display: block;
color: #0f172a;
font-size: 14px;
font-weight: 800;
}
.dimension-note {
display: block;
margin-top: 6px;
color: #64748b;
font-size: 13px;
line-height: 1.45;
}
.dimension-value {
color: #0f172a;
font-size: 20px;
font-weight: 900;
}
.interview-list {
margin-top: 14px;
}
.interview-row {
margin-top: 10px;
padding: 12px;
border-radius: 12px;
background: #f8fafc;
}
.interview-copy {
flex: 1;
min-width: 0;
}
.interview-title {
display: block;
color: #0f172a;
font-size: 15px;
font-weight: 800;
}
.interview-desc {
display: block;
margin-top: 6px;
color: #64748b;
font-size: 13px;
line-height: 1.45;
}
.interview-score {
display: flex;
min-width: 64px;
flex-direction: column;
align-items: flex-end;
gap: 4px;
color: #0f172a;
font-size: 13px;
font-weight: 800;
}
.interview-score text:first-child {
color: #ea580c;
font-size: 22px;
font-weight: 900;
}
.study-answer {
display: block;
margin-top: 12px;
color: #0f172a;
font-size: 14px;
line-height: 1.6;
}
.study-refs {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.study-ref {
padding: 5px 8px;
border-radius: 999px;
background: #f1f5f9;
color: #475569;
font-size: 12px;
font-weight: 800;
}
.material-list,
.step-list {
margin-top: 14px;
}
.material-row {
margin-top: 10px;
padding: 12px;
border-radius: 12px;
background: #f8fafc;
}
.material-copy {
flex: 1;
min-width: 0;
}
.material-title,
.step-title {
display: block;
color: #0f172a;
font-size: 15px;
font-weight: 800;
}
.material-desc,
.step-desc {
display: block;
margin-top: 6px;
color: #64748b;
font-size: 13px;
line-height: 1.45;
}
.status-pill {
flex: 0 0 auto;
padding: 6px 9px;
border-radius: 999px;
font-size: 12px;
font-weight: 800;
}
.status-pill.pending {
background: #fef3c7;
color: #92400e;
}
.status-pill.passed {
background: #dcfce7;
color: #166534;
}
.status-pill.rejected {
background: #fee2e2;
color: #991b1b;
}
.step-row {
justify-content: flex-start;
margin-top: 12px;
align-items: flex-start;
}
.step-dot {
.action-index,
.activity-label {
display: flex;
width: 28px;
height: 28px;
flex: 0 0 28px;
align-items: center;
justify-content: center;
border-radius: 50%;
background: #e0f2fe;
color: #0369a1;
background: #f1f5f9;
color: #334155;
font-size: 12px;
font-weight: 800;
}
.action-copy,
.activity-copy {
min-width: 0;
flex: 1;
}
.action-title,
.activity-title {
display: block;
color: #172033;
font-size: 15px;
font-weight: 800;
}
.action-desc,
.activity-desc {
display: block;
margin-top: 5px;
overflow-wrap: anywhere;
color: #64748b;
font-size: 12px;
line-height: 1.45;
}
.action-status {
font-size: 12px;
font-weight: 700;
white-space: nowrap;
}
.action-status.current {
color: #b91c1c;
}
.action-status.done {
color: #15803d;
}
.action-status.pending {
color: #64748b;
}
.action-arrow {
color: #94a3b8;
font-size: 20px;
}
.text-button {
min-height: 36px;
padding: 0;
background: transparent;
color: #1d4ed8;
font-size: 13px;
font-weight: 900;
font-weight: 700;
}
.activity-row {
justify-content: flex-start;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #e2e8f0;
align-items: flex-start;
}
.compact-empty {
padding-bottom: 4px;
}
</style>
@@ -0,0 +1,437 @@
<template>
<view class="page candidate-subpage interview-page">
<view class="subpage-header">
<button class="subpage-back-button" aria-label="返回候选首页" title="返回候选首页" @click="goBack">←</button>
<view class="subpage-heading">
<text class="page-title">面试任务</text>
<text class="page-subtitle">生活顾问结构化面试</text>
</view>
</view>
<view v-if="message" class="status-message">{{ message }}</view>
<view v-if="!loggedIn" class="panel login-panel">
<view>
<text class="panel-title">需要登录</text>
<text class="muted block">登录后开始面试并查看本人记录。</text>
</view>
<button class="primary-button login-button" @click="goLogin">去登录</button>
</view>
<template v-else>
<view class="panel intro-panel">
<text class="intro-label">当前任务</text>
<text class="intro-title">完成岗位问答</text>
<text class="intro-desc">题目由已配置模型生成,全部作答后形成正式面试记录。</text>
<button class="primary-button start-button" :disabled="interviewBusy" @click="startInterview">
{{ interviewStatus === 'active' ? '重新生成题目' : interviewBusy ? '处理中' : '开始面试' }}
</button>
</view>
<view v-if="interviewQuestions.length" class="question-section">
<view class="section-heading">
<text class="section-title">面试问答</text>
<text class="muted">{{ answeredCount }}/{{ interviewQuestions.length }} 已填写</text>
</view>
<view
v-for="item in interviewQuestions"
:key="item.questionId"
class="question-block"
>
<text class="question-meta">第 {{ item.seq }} 题 · {{ item.scenario }}</text>
<text class="question-title">{{ item.questionText }}</text>
<text class="question-point">考察点:{{ item.point }}</text>
<textarea
v-model="interviewAnswers[item.questionId]"
class="textarea answer-textarea"
maxlength="400"
:disabled="interviewStatus !== 'active'"
placeholder="输入你的真实回答"
/>
</view>
<button class="primary-button finish-button" :disabled="interviewStatus !== 'active'" @click="finishInterview">
{{ interviewStatus === 'submitting' ? '评分中' : '提交评分' }}
</button>
</view>
<view v-if="interviewResult" class="panel result-panel">
<view class="result-head">
<view>
<text class="panel-title">本次结果</text>
<text class="result-title">{{ interviewResult.result }}</text>
</view>
<text class="result-score">{{ interviewResult.totalScore }}分</text>
</view>
<text class="result-desc">{{ interviewResult.aiSummary }}</text>
<view v-if="interviewResult.dimensions?.length" class="dimension-list">
<view v-for="item in interviewResult.dimensions" :key="item.label" class="dimension-row">
<view class="dimension-copy">
<text class="dimension-title">{{ item.label }}</text>
<text class="dimension-note">{{ item.note }}</text>
</view>
<text class="dimension-value">{{ item.value }}</text>
</view>
</view>
<button class="secondary-button progress-button" @click="openProgress">查看办理进度</button>
</view>
<view class="panel records-panel">
<view class="section-head">
<text class="panel-title">最近面试</text>
<button class="text-button" :disabled="recordsLoading" @click="loadRecords">
{{ recordsLoading ? '刷新中' : '刷新' }}
</button>
</view>
<view v-if="records.length" class="record-list">
<view v-for="item in records" :key="`${item.time}-${item.score}`" class="record-row">
<view class="record-copy">
<text class="record-title">{{ item.position }} · {{ item.result }}</text>
<text class="record-desc">{{ item.time }} · {{ item.summary }}</text>
</view>
<text class="record-score">{{ item.score }}</text>
</view>
</view>
<view v-else-if="!recordsLoading" class="empty compact-empty">暂无面试记录</view>
</view>
</template>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import type { InterviewFinishResponse, InterviewQuestion, InterviewRecord } from '@/types/api';
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import {
finishCandidateInterview,
getCandidateInterviewRecords,
saveInterviewAnswer,
startCandidateInterview
} from '@/services/candidate';
import { candidateRoutes } from '@/services/candidate-view';
import { resetPageScroll } from '@/services/navigation';
const currentPath = '/pages/candidate/interview/index';
const records = ref<InterviewRecord[]>([]);
const interviewQuestions = ref<InterviewQuestion[]>([]);
const interviewAnswers = ref<Record<string, string>>({});
const interviewResult = ref<InterviewFinishResponse | null>(null);
const interviewSessionId = ref('');
const interviewStatus = ref<'idle' | 'starting' | 'active' | 'submitting' | 'finished'>('idle');
const recordsLoading = ref(false);
const loggedIn = ref(false);
const message = ref('');
const interviewBusy = computed(() => interviewStatus.value === 'starting' || interviewStatus.value === 'submitting');
const answeredCount = computed(() => interviewQuestions.value.filter((item) =>
Boolean(interviewAnswers.value[item.questionId]?.trim())).length);
const refreshAuthState = () => {
loggedIn.value = isLoggedIn();
};
const clearDraft = () => {
interviewQuestions.value = [];
interviewAnswers.value = {};
interviewResult.value = null;
interviewSessionId.value = '';
interviewStatus.value = 'idle';
};
const clearPrivateData = () => {
clearDraft();
records.value = [];
};
const handleRequestError = (error: unknown, fallback: string) => {
refreshAuthState();
if (!loggedIn.value) {
clearPrivateData();
message.value = '登录已过期,请重新登录';
return true;
}
message.value = error instanceof Error ? error.message : fallback;
return false;
};
const loadRecords = async () => {
refreshAuthState();
if (!loggedIn.value) {
clearPrivateData();
return;
}
if (recordsLoading.value) return;
recordsLoading.value = true;
try {
records.value = await getCandidateInterviewRecords();
} catch (error) {
handleRequestError(error, '面试记录加载失败');
} finally {
recordsLoading.value = false;
}
};
const startInterview = async () => {
if (interviewBusy.value) return;
refreshAuthState();
if (!loggedIn.value) {
goLogin();
return;
}
interviewStatus.value = 'starting';
interviewQuestions.value = [];
interviewAnswers.value = {};
interviewResult.value = null;
interviewSessionId.value = '';
message.value = '';
try {
const data = await startCandidateInterview();
interviewSessionId.value = data.sessionId;
interviewQuestions.value = data.questions || [];
interviewAnswers.value = Object.fromEntries(interviewQuestions.value.map((item) => [item.questionId, '']));
interviewStatus.value = 'active';
message.value = `已生成 ${interviewQuestions.value.length} 道题`;
} catch (error) {
interviewStatus.value = 'idle';
handleRequestError(error, '面试启动失败');
}
};
const finishInterview = async () => {
if (interviewBusy.value) return;
if (interviewStatus.value !== 'active' || !interviewSessionId.value) {
message.value = '请先开始面试';
return;
}
const answers = Object.fromEntries(
Object.entries(interviewAnswers.value).map(([key, value]) => [key, value.trim()])
);
if (interviewQuestions.value.some((item) => !answers[item.questionId])) {
message.value = `请填写全部 ${interviewQuestions.value.length} 题回答`;
return;
}
interviewStatus.value = 'submitting';
message.value = '';
try {
await Promise.all(Object.entries(answers).map(([questionId, answer]) =>
saveInterviewAnswer(interviewSessionId.value, questionId, answer)
));
interviewResult.value = await finishCandidateInterview(interviewSessionId.value, answers);
records.value = interviewResult.value.records?.length
? interviewResult.value.records
: await getCandidateInterviewRecords();
interviewStatus.value = 'finished';
message.value = '面试完成,已生成面试记录';
} catch (error) {
if (!handleRequestError(error, '面试提交失败')) {
interviewStatus.value = 'active';
}
}
};
const openProgress = () => {
uni.navigateTo({ url: candidateRoutes.progress });
};
const goLogin = () => {
rememberLoginRedirect(currentPath);
uni.redirectTo({ url: '/pages/auth/login/index' });
};
const goBack = () => {
if (getCurrentPages().length <= 1) {
uni.redirectTo({ url: candidateRoutes.index });
return;
}
uni.navigateBack({ delta: 1, fail: () => uni.redirectTo({ url: candidateRoutes.index }) });
};
onShow(() => {
resetPageScroll();
refreshAuthState();
if (!loggedIn.value) {
clearPrivateData();
return;
}
void loadRecords();
});
</script>
<style scoped>
.candidate-subpage {
background: #f4f7fb;
}
.candidate-subpage .panel,
.candidate-subpage button,
.question-block {
border-radius: 8px;
}
.login-panel,
.section-head,
.result-head,
.record-row,
.dimension-row,
.section-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.block {
display: block;
margin-top: 8px;
font-size: 14px;
line-height: 1.5;
}
.login-button {
min-width: 96px;
}
.intro-panel {
border-left: 4px solid #e60012;
}
.intro-label,
.question-meta {
color: #b91c1c;
font-size: 12px;
font-weight: 800;
}
.intro-title {
display: block;
margin-top: 10px;
color: #172033;
font-size: 22px;
font-weight: 800;
}
.intro-desc,
.result-desc {
display: block;
margin-top: 8px;
color: #64748b;
font-size: 14px;
line-height: 1.55;
}
.start-button,
.finish-button,
.progress-button {
width: 100%;
margin-top: 16px;
}
.question-section {
margin-top: 22px;
}
.section-title {
color: #172033;
font-size: 20px;
font-weight: 800;
}
.question-block {
margin-top: 12px;
padding: 16px;
border: 1px solid #e2e8f0;
background: #ffffff;
}
.question-title {
display: block;
margin-top: 8px;
color: #172033;
font-size: 16px;
font-weight: 800;
line-height: 1.5;
}
.question-point {
display: block;
margin-top: 8px;
color: #64748b;
font-size: 13px;
line-height: 1.45;
}
.answer-textarea {
margin-top: 12px;
}
.result-title {
display: block;
margin-top: 8px;
color: #15803d;
font-size: 15px;
font-weight: 800;
}
.result-score,
.record-score {
color: #b91c1c;
font-size: 24px;
font-weight: 800;
}
.dimension-list,
.record-list {
margin-top: 14px;
border-top: 1px solid #e2e8f0;
}
.dimension-row,
.record-row {
padding: 12px 0;
border-bottom: 1px solid #e2e8f0;
align-items: flex-start;
}
.dimension-copy,
.record-copy {
min-width: 0;
flex: 1;
}
.dimension-title,
.record-title {
display: block;
color: #172033;
font-size: 14px;
font-weight: 800;
}
.dimension-note,
.record-desc {
display: block;
margin-top: 5px;
overflow-wrap: anywhere;
color: #64748b;
font-size: 12px;
line-height: 1.45;
}
.dimension-value {
color: #172033;
font-size: 18px;
font-weight: 800;
}
.text-button {
min-height: 36px;
padding: 0;
background: transparent;
color: #1d4ed8;
font-size: 13px;
}
.compact-empty {
padding-bottom: 4px;
}
</style>
@@ -0,0 +1,293 @@
<template>
<view class="page candidate-subpage materials-page">
<view class="subpage-header">
<button class="subpage-back-button" aria-label="返回候选首页" title="返回候选首页" @click="goBack">←</button>
<view class="subpage-heading">
<text class="page-title">补充资料</text>
<text class="page-subtitle">提交简历、证书或相关图片</text>
</view>
</view>
<view v-if="message" class="status-message">{{ message }}</view>
<view v-if="!loggedIn" class="panel login-panel">
<view>
<text class="panel-title">需要登录</text>
<text class="muted block">登录后上传并查看本人资料。</text>
</view>
<button class="primary-button login-button" @click="goLogin">去登录</button>
</view>
<template v-else>
<view class="panel upload-panel">
<text class="upload-label">支持 PDF、Word 和常见图片</text>
<text class="upload-title">选择一份补充资料</text>
<text class="upload-desc">提交后由 HR 审核,最新一条资料状态决定当前办理进度。</text>
<button class="primary-button upload-button" :disabled="uploading || loading" @click="uploadMaterial">
{{ uploading ? '上传中' : '选择并上传' }}
</button>
</view>
<view class="panel material-panel">
<view class="section-head">
<text class="panel-title">已提交资料</text>
<button class="text-button" :disabled="loading || uploading" @click="loadMaterials">
{{ loading ? '刷新中' : '刷新' }}
</button>
</view>
<view v-if="materials.length" class="material-list">
<view v-for="item in materials" :key="item.id" class="material-row">
<view class="material-main">
<view class="material-title-row">
<text class="material-title">{{ item.fileName }}</text>
<text class="status-pill" :class="materialStatusTone(item.status)">{{ item.status }}</text>
</view>
<text class="material-desc">{{ item.time }} · {{ item.materialType }} · {{ formatCandidateFileSize(item.fileSize) }}</text>
<text class="material-id">资料编号 {{ item.id }} · OSS {{ item.ossId }}</text>
</view>
</view>
</view>
<view v-else-if="!loading" class="empty compact-empty">暂无已提交资料</view>
</view>
<button class="secondary-button progress-button" @click="openProgress">查看审核进度</button>
</template>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import type { CandidateMaterial } from '@/types/api';
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import {
chooseCandidateFile,
getCandidateMaterials,
uploadCandidateMaterial
} from '@/services/candidate';
import {
candidateRoutes,
formatCandidateFileSize,
materialStatusTone
} from '@/services/candidate-view';
import { resetPageScroll } from '@/services/navigation';
const currentPath = '/pages/candidate/materials/index';
const materials = ref<CandidateMaterial[]>([]);
const loggedIn = ref(false);
const loading = ref(false);
const uploading = ref(false);
const message = ref('');
const refreshAuthState = () => {
loggedIn.value = isLoggedIn();
};
const clearData = () => {
materials.value = [];
};
const handleRequestError = (error: unknown, fallback: string) => {
refreshAuthState();
if (!loggedIn.value) {
clearData();
message.value = '登录已过期,请重新登录';
return;
}
message.value = error instanceof Error ? error.message : fallback;
};
const loadMaterials = async () => {
refreshAuthState();
if (!loggedIn.value) {
clearData();
return;
}
if (loading.value || uploading.value) return;
loading.value = true;
try {
materials.value = await getCandidateMaterials();
} catch (error) {
handleRequestError(error, '资料列表加载失败');
} finally {
loading.value = false;
}
};
const uploadMaterial = async () => {
if (uploading.value || loading.value) return;
refreshAuthState();
if (!loggedIn.value) {
goLogin();
return;
}
uploading.value = true;
message.value = '';
try {
const file = await chooseCandidateFile();
const saved = await uploadCandidateMaterial(file);
message.value = `资料已上传:#${saved.id}`;
materials.value = await getCandidateMaterials();
} catch (error) {
handleRequestError(error, '资料上传失败');
} finally {
uploading.value = false;
}
};
const openProgress = () => {
uni.navigateTo({ url: candidateRoutes.progress });
};
const goLogin = () => {
rememberLoginRedirect(currentPath);
uni.redirectTo({ url: '/pages/auth/login/index' });
};
const goBack = () => {
if (getCurrentPages().length <= 1) {
uni.redirectTo({ url: candidateRoutes.index });
return;
}
uni.navigateBack({ delta: 1, fail: () => uni.redirectTo({ url: candidateRoutes.index }) });
};
onShow(() => {
resetPageScroll();
void loadMaterials();
});
</script>
<style scoped>
.candidate-subpage {
background: #f4f7fb;
}
.candidate-subpage .panel,
.candidate-subpage button {
border-radius: 8px;
}
.login-panel,
.section-head,
.material-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.block {
display: block;
margin-top: 8px;
font-size: 14px;
line-height: 1.5;
}
.login-button {
min-width: 96px;
}
.upload-panel {
border-left: 4px solid #e60012;
}
.upload-label {
color: #b91c1c;
font-size: 12px;
font-weight: 800;
}
.upload-title {
display: block;
margin-top: 10px;
color: #172033;
font-size: 22px;
font-weight: 800;
}
.upload-desc {
display: block;
margin-top: 8px;
color: #64748b;
font-size: 14px;
line-height: 1.55;
}
.upload-button,
.progress-button {
width: 100%;
margin-top: 16px;
}
.text-button {
min-height: 36px;
padding: 0;
background: transparent;
color: #1d4ed8;
font-size: 13px;
}
.material-list {
margin-top: 12px;
border-top: 1px solid #e2e8f0;
}
.material-row {
padding: 14px 0;
border-bottom: 1px solid #e2e8f0;
}
.material-main {
min-width: 0;
}
.material-title {
min-width: 0;
overflow-wrap: anywhere;
color: #172033;
font-size: 15px;
font-weight: 800;
}
.material-desc,
.material-id {
display: block;
margin-top: 6px;
overflow-wrap: anywhere;
color: #64748b;
font-size: 12px;
line-height: 1.45;
}
.material-id {
color: #94a3b8;
}
.status-pill {
flex: 0 0 auto;
padding: 5px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 800;
}
.status-pill.pending {
background: #fef3c7;
color: #92400e;
}
.status-pill.passed {
background: #dcfce7;
color: #166534;
}
.status-pill.rejected {
background: #fee2e2;
color: #991b1b;
}
.compact-empty {
padding-bottom: 4px;
}
</style>
@@ -0,0 +1,401 @@
<template>
<view class="page candidate-subpage progress-page">
<view class="subpage-header">
<button class="subpage-back-button" aria-label="返回候选首页" title="返回候选首页" @click="goBack">←</button>
<view class="subpage-heading">
<text class="page-title">进度通知</text>
<text class="page-subtitle">面试与资料审核状态</text>
</view>
<button v-if="loggedIn" class="ghost-button refresh-button" :disabled="loading" @click="loadPage">
{{ loading ? '刷新中' : '刷新' }}
</button>
</view>
<view v-if="message" class="status-message">{{ message }}</view>
<view v-if="!loggedIn" class="panel login-panel">
<view>
<text class="panel-title">需要登录</text>
<text class="muted block">登录后查看本人面试和资料审核进度。</text>
</view>
<button class="primary-button login-button" @click="goLogin">去登录</button>
</view>
<template v-else>
<view class="panel status-panel">
<view class="status-head">
<text class="status-badge">{{ flow.badge }}</text>
<text class="status-progress">{{ flow.progressText }}</text>
</view>
<text class="status-title">{{ flow.notice }}</text>
<text class="status-desc">{{ flow.desc }}</text>
<view class="progress-bar">
<view class="progress-fill" :style="{ width: `${flow.progress}%` }" />
</view>
<button class="primary-button next-button" :disabled="loading" @click="runCurrentAction">
{{ flow.stage === 'review' || flow.stage === 'complete' ? '刷新进度' : flow.cta }}
</button>
</view>
<view class="panel steps-panel">
<text class="panel-title">办理流程</text>
<view class="step-list">
<view v-for="step in steps" :key="step.index" class="step-row" :class="step.status">
<view class="step-rail">
<text class="step-dot">{{ step.status === 'complete' ? '✓' : step.index }}</text>
<view v-if="step.index < steps.length" class="step-line" />
</view>
<view class="step-copy">
<text class="step-title">{{ step.title }}</text>
<text class="step-desc">{{ step.desc }}</text>
</view>
<text class="step-status">{{ stepStatusLabel(step.status) }}</text>
</view>
</view>
</view>
<view class="panel evidence-panel">
<text class="panel-title">办理依据</text>
<view v-if="latestRecord" class="evidence-row">
<text class="evidence-label">面试记录</text>
<view class="evidence-copy">
<text class="evidence-title">{{ latestRecord.position }} · {{ latestRecord.score }}分</text>
<text class="evidence-desc">{{ latestRecord.time }} · {{ latestRecord.result }}</text>
</view>
</view>
<view v-if="latestMaterial" class="evidence-row">
<text class="evidence-label">最新资料</text>
<view class="evidence-copy">
<view class="material-title-row">
<text class="evidence-title">{{ latestMaterial.fileName }}</text>
<text class="status-pill" :class="materialStatusTone(latestMaterial.status)">{{ latestMaterial.status }}</text>
</view>
<text class="evidence-desc">{{ latestMaterial.time }} · {{ formatCandidateFileSize(latestMaterial.fileSize) }}</text>
</view>
</view>
<view v-if="!latestRecord && !latestMaterial && !loading" class="empty compact-empty">暂无办理记录</view>
</view>
</template>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import type { CandidateMaterial, InterviewRecord } from '@/types/api';
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { getCandidateInterviewRecords, getCandidateMaterials } from '@/services/candidate';
import {
buildCandidateFlow,
buildCandidateSteps,
candidateRoutes,
formatCandidateFileSize,
materialStatusTone
} from '@/services/candidate-view';
import { resetPageScroll } from '@/services/navigation';
const currentPath = '/pages/candidate/progress/index';
const records = ref<InterviewRecord[]>([]);
const materials = ref<CandidateMaterial[]>([]);
const loggedIn = ref(false);
const loading = ref(false);
const message = ref('');
const flow = computed(() => buildCandidateFlow(records.value, materials.value));
const steps = computed(() => buildCandidateSteps(flow.value));
const latestRecord = computed(() => records.value[0] || null);
const latestMaterial = computed(() => materials.value[0] || null);
const refreshAuthState = () => {
loggedIn.value = isLoggedIn();
};
const clearData = () => {
records.value = [];
materials.value = [];
};
const loadPage = async () => {
refreshAuthState();
message.value = '';
if (!loggedIn.value) {
clearData();
return;
}
if (loading.value) return;
loading.value = true;
try {
const [nextMaterials, nextRecords] = await Promise.all([
getCandidateMaterials(),
getCandidateInterviewRecords()
]);
materials.value = nextMaterials;
records.value = nextRecords;
} catch (error) {
refreshAuthState();
if (!loggedIn.value) {
clearData();
message.value = '登录已过期,请重新登录';
} else {
message.value = error instanceof Error ? error.message : '办理进度加载失败';
}
} finally {
loading.value = false;
}
};
const runCurrentAction = () => {
if (flow.value.stage === 'review' || flow.value.stage === 'complete') {
void loadPage();
return;
}
uni.navigateTo({ url: flow.value.route });
};
const stepStatusLabel = (status: 'complete' | 'current' | 'pending') => {
if (status === 'complete') return '已完成';
if (status === 'current') return '当前';
return '待办理';
};
const goLogin = () => {
rememberLoginRedirect(currentPath);
uni.redirectTo({ url: '/pages/auth/login/index' });
};
const goBack = () => {
if (getCurrentPages().length <= 1) {
uni.redirectTo({ url: candidateRoutes.index });
return;
}
uni.navigateBack({ delta: 1, fail: () => uni.redirectTo({ url: candidateRoutes.index }) });
};
onShow(() => {
resetPageScroll();
void loadPage();
});
</script>
<style scoped>
.candidate-subpage {
background: #f4f7fb;
}
.candidate-subpage .panel,
.candidate-subpage button {
border-radius: 8px;
}
.login-panel,
.status-head,
.evidence-row,
.material-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.refresh-button {
min-width: 76px;
padding: 0 10px;
font-size: 13px;
}
.block {
display: block;
margin-top: 8px;
font-size: 14px;
line-height: 1.5;
}
.login-button {
min-width: 96px;
}
.status-panel {
border-left: 4px solid #e60012;
}
.status-badge,
.status-progress {
color: #b91c1c;
font-size: 13px;
font-weight: 800;
}
.status-title {
display: block;
margin-top: 14px;
color: #172033;
font-size: 21px;
font-weight: 800;
}
.status-desc {
display: block;
margin-top: 8px;
color: #64748b;
font-size: 14px;
line-height: 1.55;
}
.progress-bar {
height: 8px;
margin-top: 16px;
overflow: hidden;
border-radius: 4px;
background: #fee2e2;
}
.progress-fill {
height: 100%;
border-radius: inherit;
background: #e60012;
}
.next-button {
width: 100%;
margin-top: 16px;
}
.step-list {
margin-top: 16px;
}
.step-row {
display: grid;
min-height: 76px;
grid-template-columns: 30px minmax(0, 1fr) auto;
gap: 12px;
}
.step-rail {
display: flex;
flex-direction: column;
align-items: center;
}
.step-dot {
display: flex;
width: 28px;
height: 28px;
align-items: center;
justify-content: center;
border-radius: 50%;
background: #e2e8f0;
color: #475569;
font-size: 12px;
font-weight: 800;
}
.step-row.complete .step-dot {
background: #dcfce7;
color: #166534;
}
.step-row.current .step-dot {
background: #fee2e2;
color: #b91c1c;
}
.step-line {
width: 2px;
flex: 1;
background: #e2e8f0;
}
.step-title {
display: block;
color: #172033;
font-size: 15px;
font-weight: 800;
}
.step-desc {
display: block;
margin-top: 5px;
color: #64748b;
font-size: 12px;
line-height: 1.45;
}
.step-status {
color: #64748b;
font-size: 12px;
font-weight: 700;
white-space: nowrap;
}
.step-row.current .step-status {
color: #b91c1c;
}
.step-row.complete .step-status {
color: #15803d;
}
.evidence-row {
justify-content: flex-start;
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid #e2e8f0;
align-items: flex-start;
}
.evidence-label {
flex: 0 0 64px;
color: #64748b;
font-size: 12px;
font-weight: 700;
}
.evidence-copy {
min-width: 0;
flex: 1;
}
.evidence-title {
min-width: 0;
overflow-wrap: anywhere;
color: #172033;
font-size: 14px;
font-weight: 800;
}
.evidence-desc {
display: block;
margin-top: 6px;
color: #64748b;
font-size: 12px;
line-height: 1.45;
}
.status-pill {
flex: 0 0 auto;
padding: 5px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 800;
}
.status-pill.pending {
background: #fef3c7;
color: #92400e;
}
.status-pill.passed {
background: #dcfce7;
color: #166534;
}
.status-pill.rejected {
background: #fee2e2;
color: #991b1b;
}
.compact-empty {
padding-bottom: 4px;
}
</style>
@@ -0,0 +1,333 @@
<template>
<view class="page candidate-subpage study-page">
<view class="subpage-header">
<button class="subpage-back-button" aria-label="返回候选首页" title="返回候选首页" @click="goBack">←</button>
<view class="subpage-heading">
<text class="page-title">岗前预习</text>
<text class="page-subtitle">从正式知识库生成岗位要点</text>
</view>
</view>
<view v-if="message" class="status-message">{{ message }}</view>
<view v-if="!loggedIn" class="panel login-panel">
<view>
<text class="panel-title">需要登录</text>
<text class="muted block">登录后生成生活顾问岗前预习。</text>
</view>
<button class="primary-button login-button" @click="goLogin">去登录</button>
</view>
<template v-else>
<view class="panel query-panel">
<text class="query-label">预习主题</text>
<textarea
v-model="queryText"
class="textarea query-input"
maxlength="120"
placeholder="输入要预习的岗位问题"
/>
<view class="topic-row">
<button
v-for="topic in topics"
:key="topic"
class="topic-button"
:class="{ active: queryText === topic }"
:disabled="loading"
@click="queryText = topic"
>
{{ topic }}
</button>
</view>
<button class="primary-button generate-button" :disabled="loading || !queryText.trim()" @click="loadStudy">
{{ loading ? '生成中' : '生成预习' }}
</button>
</view>
<view v-if="answer" class="panel answer-panel">
<text class="panel-title">预习要点</text>
<text class="answer-text">{{ answer }}</text>
<view v-if="keyPoints.length" class="answer-section">
<text class="section-title">关键要点</text>
<view v-for="(item, index) in keyPoints" :key="`${index}-${item}`" class="point-row">
<text class="point-index">{{ index + 1 }}</text>
<text class="point-text">{{ item }}</text>
</view>
</view>
<view v-if="cautions.length" class="answer-section caution-section">
<text class="section-title">注意事项</text>
<text v-for="item in cautions" :key="item" class="caution-text">{{ item }}</text>
</view>
</view>
<view v-if="references.length" class="panel reference-panel">
<text class="panel-title">引用依据</text>
<view v-for="item in references" :key="item" class="reference-row">
<text class="reference-dot">•</text>
<text class="reference-title">{{ item }}</text>
</view>
</view>
<view v-if="!answer && !loading" class="empty-state">
<text class="empty-title">尚未生成预习</text>
<text class="empty-desc">选择主题后调用正式 SOP 知识库,不使用前端示例答案。</text>
</view>
</template>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { candidateRoutes } from '@/services/candidate-view';
import { searchKnowledge } from '@/services/knowledge';
import { resetPageScroll } from '@/services/navigation';
const currentPath = '/pages/candidate/study/index';
const topics = [
'生活顾问接到业主投诉时,应该如何安抚、记录、跟进和反馈?',
'生活顾问接到报事报修后,应该如何登记、派单和回访?',
'生活顾问与情绪激动的业主沟通时,有哪些原则和禁忌?'
];
const queryText = ref(topics[0]);
const answer = ref('');
const keyPoints = ref<string[]>([]);
const cautions = ref<string[]>([]);
const references = ref<string[]>([]);
const loggedIn = ref(false);
const loading = ref(false);
const message = ref('');
const refreshAuthState = () => {
loggedIn.value = isLoggedIn();
};
const clearResult = () => {
answer.value = '';
keyPoints.value = [];
cautions.value = [];
references.value = [];
};
const loadStudy = async () => {
if (loading.value) return;
refreshAuthState();
if (!loggedIn.value) {
goLogin();
return;
}
const query = queryText.value.trim();
if (!query) {
message.value = '请输入预习主题';
return;
}
loading.value = true;
message.value = '';
clearResult();
try {
const result = await searchKnowledge(query, '生活顾问');
answer.value = result.answer;
keyPoints.value = result.keyPoints || [];
cautions.value = result.cautions || [];
references.value = (result.docs || []).map((item) => item.title).filter(Boolean).slice(0, 5);
if (!answer.value) message.value = '正式知识库暂未返回可用答案';
} catch (error) {
refreshAuthState();
if (!loggedIn.value) {
clearResult();
message.value = '登录已过期,请重新登录';
} else {
message.value = error instanceof Error ? error.message : '岗前预习生成失败';
}
} finally {
loading.value = false;
}
};
const goLogin = () => {
rememberLoginRedirect(currentPath);
uni.redirectTo({ url: '/pages/auth/login/index' });
};
const goBack = () => {
if (getCurrentPages().length <= 1) {
uni.redirectTo({ url: candidateRoutes.index });
return;
}
uni.navigateBack({ delta: 1, fail: () => uni.redirectTo({ url: candidateRoutes.index }) });
};
onShow(() => {
resetPageScroll();
refreshAuthState();
if (!loggedIn.value) clearResult();
});
</script>
<style scoped>
.candidate-subpage {
background: #f4f7fb;
}
.candidate-subpage .panel,
.candidate-subpage button {
border-radius: 8px;
}
.login-panel {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.block {
display: block;
margin-top: 8px;
font-size: 14px;
line-height: 1.5;
}
.login-button {
min-width: 96px;
}
.query-panel {
border-left: 4px solid #e60012;
}
.query-label {
color: #172033;
font-size: 14px;
font-weight: 800;
}
.query-input {
min-height: 88px;
margin-top: 10px;
}
.topic-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.topic-button {
min-height: 34px;
padding: 0 10px;
border: 1px solid #cbd5e1;
background: #ffffff;
color: #475569;
font-size: 12px;
}
.topic-button.active {
border-color: #e60012;
background: #fff1f2;
color: #b91c1c;
}
.generate-button {
width: 100%;
margin-top: 16px;
}
.answer-text {
display: block;
margin-top: 12px;
color: #172033;
font-size: 15px;
line-height: 1.7;
white-space: pre-wrap;
}
.answer-section {
margin-top: 18px;
padding-top: 14px;
border-top: 1px solid #e2e8f0;
}
.section-title {
color: #172033;
font-size: 14px;
font-weight: 800;
}
.point-row {
display: flex;
gap: 10px;
margin-top: 10px;
align-items: flex-start;
}
.point-index {
display: flex;
width: 22px;
height: 22px;
flex: 0 0 22px;
align-items: center;
justify-content: center;
border-radius: 50%;
background: #f1f5f9;
color: #475569;
font-size: 11px;
font-weight: 800;
}
.point-text,
.caution-text {
color: #334155;
font-size: 14px;
line-height: 1.6;
}
.caution-text {
display: block;
margin-top: 8px;
}
.reference-row {
display: flex;
gap: 8px;
margin-top: 10px;
align-items: flex-start;
}
.reference-dot {
color: #b91c1c;
font-weight: 800;
}
.reference-title {
overflow-wrap: anywhere;
color: #475569;
font-size: 13px;
line-height: 1.5;
}
.empty-state {
padding: 56px 24px;
text-align: center;
}
.empty-title {
display: block;
color: #172033;
font-size: 17px;
font-weight: 800;
}
.empty-desc {
display: block;
margin-top: 8px;
color: #64748b;
font-size: 13px;
line-height: 1.55;
}
</style>