Files
prop-ai-hr/mobile-uni/src/pages/candidate/interview/index.vue
T
admin 19788bedb0 style(mobile): 优化候选人和成长页视觉细节
统一候选人、主管、案例、学习和我的成长页面中的状态色、徽标与摘要卡样式,不改变原有数据流和交互逻辑。
2026-07-11 01:13:57 +08:00

441 lines
12 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.
<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: inline-block;
margin-top: 8px;
padding: 4px 8px;
background: #f1f5f9;
color: #475569;
border-radius: 4px;
font-size: 12px;
font-weight: 800;
}
.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>