feat(aihr): complete phase two pilot readiness
This commit is contained in:
@@ -61,11 +61,59 @@
|
||||
"navigationBarTitleText": "主管"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/supervisor/team/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "团队画像"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/supervisor/assign/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "指派专项"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/supervisor/reviews/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "待复盘"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/supervisor/review/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "复盘详情"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/candidate/index/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "候选人"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/candidate/interview/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "面试任务"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/candidate/materials/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "补充资料"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/candidate/progress/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "进度通知"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/candidate/study/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "岗前预习"
|
||||
}
|
||||
}
|
||||
],
|
||||
"globalStyle": {
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,432 @@
|
||||
<template>
|
||||
<view class="page assign-page">
|
||||
<view class="subpage-header">
|
||||
<button class="subpage-back-button" aria-label="返回" title="返回" :disabled="assigning" @click="goBack">←</button>
|
||||
<view class="subpage-heading">
|
||||
<text class="page-title">指派专项</text>
|
||||
<text class="page-subtitle">{{ selectedTargetLabel }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="message && !accessDenied" class="status-message">{{ message }}</view>
|
||||
|
||||
<view v-if="accessDenied" class="panel supervisor-access-panel">
|
||||
<text class="panel-title">无主管权限</text>
|
||||
<text class="muted supervisor-access-copy">当前账号不是主管或项目经理,无法查看团队训练数据。</text>
|
||||
<view class="supervisor-access-actions">
|
||||
<button class="secondary-button" @click="goToEmployee">返回员工端</button>
|
||||
<button class="primary-button" @click="switchAccount">切换账号</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else-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 assignment-form">
|
||||
<text class="field-label">训练对象</text>
|
||||
<input
|
||||
v-model="targetExtPartyId"
|
||||
class="input field-input"
|
||||
maxlength="32"
|
||||
placeholder="输入手机号或外部ID"
|
||||
@input="targetName = ''"
|
||||
/>
|
||||
<input
|
||||
v-model="targetName"
|
||||
class="input field-input"
|
||||
maxlength="40"
|
||||
placeholder="员工姓名(可选)"
|
||||
/>
|
||||
|
||||
<text class="field-label section-label">训练场景</text>
|
||||
<view class="scenario-row">
|
||||
<button
|
||||
v-for="item in supervisorScenarioOptions"
|
||||
:key="item.id"
|
||||
class="chip scenario-chip"
|
||||
:class="{ active: assignmentScenarioId === item.id }"
|
||||
:disabled="assigning"
|
||||
@click="assignmentScenarioId = item.id"
|
||||
>
|
||||
{{ item.name }}
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<text class="field-label section-label">指派原因</text>
|
||||
<textarea
|
||||
v-model="assignmentReason"
|
||||
class="textarea reason-input"
|
||||
maxlength="180"
|
||||
placeholder="填写本次专项训练原因"
|
||||
/>
|
||||
<button class="primary-button submit-button" :disabled="assigning || historyLoading" @click="assignPractice">
|
||||
{{ assigning ? '指派中' : '确认指派' }}
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view class="panel history-panel">
|
||||
<view class="section-head">
|
||||
<text class="panel-title">最近专项</text>
|
||||
<button
|
||||
class="ghost-button refresh-button"
|
||||
:disabled="historyLoading || assigning"
|
||||
@click="loadAssignments()"
|
||||
>
|
||||
{{ historyLoading ? '刷新中' : '刷新' }}
|
||||
</button>
|
||||
</view>
|
||||
<view v-if="recentAssignments.length" class="assignment-list">
|
||||
<view v-for="item in recentAssignments" :key="item.id" class="assignment-row">
|
||||
<view class="assignment-copy">
|
||||
<text class="assignment-title">{{ item.traineeName }} · {{ item.scenarioName }}</text>
|
||||
<text class="assignment-desc">{{ assignmentSummary(item) }}</text>
|
||||
</view>
|
||||
<text class="assignment-source">{{ assignmentSourceLabel(item.source) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else-if="!historyLoading" class="empty compact-empty">暂无专项指派记录</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref } from 'vue';
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app';
|
||||
import type { PracticeAssignment } from '@/types/api';
|
||||
import { clearAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||||
import { resetPageScroll } from '@/services/navigation';
|
||||
import { createPracticeAssignment, getPracticeTeamSnapshot } from '@/services/practice';
|
||||
import {
|
||||
createAsyncGenerationGuard,
|
||||
inferSupervisorScenarioId,
|
||||
isSupervisorAccessDenied,
|
||||
manualPracticeAssignments,
|
||||
supervisorScenarioOptions,
|
||||
type SupervisorScenarioId
|
||||
} from '@/services/supervisor-view';
|
||||
|
||||
const basePath = '/pages/supervisor/assign/index';
|
||||
const targetExtPartyId = ref('');
|
||||
const targetName = ref('');
|
||||
const assignmentScenarioId = ref<SupervisorScenarioId>('fee-parking');
|
||||
const assignmentReason = ref('');
|
||||
const assignments = ref<PracticeAssignment[]>([]);
|
||||
const loggedIn = ref(false);
|
||||
const accessDenied = ref(false);
|
||||
const assigning = ref(false);
|
||||
const historyLoading = ref(false);
|
||||
const message = ref('');
|
||||
const historyRequests = createAsyncGenerationGuard();
|
||||
|
||||
const selectedTargetLabel = computed(() => {
|
||||
if (!loggedIn.value) return '选择员工并配置训练任务';
|
||||
const target = targetExtPartyId.value.trim();
|
||||
if (!target) return '选择员工并配置训练任务';
|
||||
return targetName.value.trim() ? `${targetName.value.trim()} · ${target}` : target;
|
||||
});
|
||||
const recentAssignments = computed(() => manualPracticeAssignments(assignments.value).slice(0, 10));
|
||||
|
||||
const queryText = (value: unknown) => {
|
||||
if (typeof value !== 'string') return '';
|
||||
try {
|
||||
return decodeURIComponent(value).trim();
|
||||
} catch {
|
||||
return value.trim();
|
||||
}
|
||||
};
|
||||
|
||||
const isScenarioId = (value: string): value is SupervisorScenarioId =>
|
||||
supervisorScenarioOptions.some((item) => item.id === value);
|
||||
|
||||
const refreshAuthState = () => {
|
||||
loggedIn.value = isLoggedIn();
|
||||
};
|
||||
|
||||
const resetDraft = () => {
|
||||
targetExtPartyId.value = '';
|
||||
targetName.value = '';
|
||||
assignmentScenarioId.value = 'fee-parking';
|
||||
assignmentReason.value = '';
|
||||
};
|
||||
|
||||
const clearPrivateState = () => {
|
||||
resetDraft();
|
||||
assignments.value = [];
|
||||
historyLoading.value = false;
|
||||
historyRequests.invalidate();
|
||||
};
|
||||
|
||||
const handleRequestError = (error: unknown, fallback: string) => {
|
||||
if (isSupervisorAccessDenied(error)) {
|
||||
clearPrivateState();
|
||||
accessDenied.value = true;
|
||||
message.value = '';
|
||||
return;
|
||||
}
|
||||
refreshAuthState();
|
||||
if (!loggedIn.value) {
|
||||
accessDenied.value = false;
|
||||
clearPrivateState();
|
||||
message.value = '登录已过期,请重新登录';
|
||||
return;
|
||||
}
|
||||
message.value = error instanceof Error ? error.message : fallback;
|
||||
};
|
||||
|
||||
const loadAssignments = async (force = false) => {
|
||||
refreshAuthState();
|
||||
if (!loggedIn.value) {
|
||||
accessDenied.value = false;
|
||||
clearPrivateState();
|
||||
return;
|
||||
}
|
||||
if (!force && (historyLoading.value || assigning.value)) return;
|
||||
const generation = historyRequests.begin();
|
||||
historyLoading.value = true;
|
||||
try {
|
||||
const snapshot = await getPracticeTeamSnapshot();
|
||||
if (historyRequests.isCurrent(generation)) {
|
||||
accessDenied.value = false;
|
||||
assignments.value = snapshot.assignments;
|
||||
}
|
||||
} catch (error) {
|
||||
if (historyRequests.isCurrent(generation)) {
|
||||
handleRequestError(error, '最近专项加载失败');
|
||||
}
|
||||
} finally {
|
||||
if (historyRequests.isCurrent(generation)) {
|
||||
historyLoading.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const loadPage = () => {
|
||||
refreshAuthState();
|
||||
message.value = '';
|
||||
if (!loggedIn.value) {
|
||||
clearPrivateState();
|
||||
return;
|
||||
}
|
||||
void loadAssignments();
|
||||
};
|
||||
|
||||
const assignPractice = async () => {
|
||||
if (assigning.value || historyLoading.value) return;
|
||||
const extPartyId = targetExtPartyId.value.trim();
|
||||
if (!extPartyId) {
|
||||
message.value = '请填写员工手机号或外部ID';
|
||||
return;
|
||||
}
|
||||
|
||||
assigning.value = true;
|
||||
message.value = '';
|
||||
try {
|
||||
const scenario = supervisorScenarioOptions.find((item) => item.id === assignmentScenarioId.value);
|
||||
await createPracticeAssignment({
|
||||
extPartyId,
|
||||
traineeName: targetName.value.trim() || `手机用户${extPartyId.slice(-4)}`,
|
||||
scenarioId: assignmentScenarioId.value,
|
||||
scenarioName: scenario?.name,
|
||||
reason: assignmentReason.value.trim() || '主管派发专项训练'
|
||||
});
|
||||
resetDraft();
|
||||
uni.showToast({ title: '已指派', icon: 'none' });
|
||||
await loadAssignments(true);
|
||||
} catch (error) {
|
||||
handleRequestError(error, '指派失败');
|
||||
} finally {
|
||||
assigning.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const assignmentSourceLabel = (source: string) => {
|
||||
if (source === 'retry') return '再练';
|
||||
if (source === 'camp') return '训练营';
|
||||
return '专项';
|
||||
};
|
||||
|
||||
const assignmentSummary = (item: PracticeAssignment) => {
|
||||
const reason = item.reason?.trim() || '主管派发专项训练';
|
||||
return `${item.createTime} · ${item.status} · ${reason}`;
|
||||
};
|
||||
|
||||
const goLogin = () => {
|
||||
rememberLoginRedirect(basePath);
|
||||
uni.redirectTo({ url: '/pages/auth/login/index' });
|
||||
};
|
||||
|
||||
const goToEmployee = () => {
|
||||
uni.switchTab({ url: '/pages/user/today/index' });
|
||||
};
|
||||
|
||||
const switchAccount = () => {
|
||||
clearAuth();
|
||||
rememberLoginRedirect(basePath);
|
||||
uni.redirectTo({ url: '/pages/auth/login/index' });
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
if (assigning.value) return;
|
||||
if (getCurrentPages().length <= 1) {
|
||||
uni.redirectTo({ url: '/pages/supervisor/index/index' });
|
||||
return;
|
||||
}
|
||||
uni.navigateBack({
|
||||
delta: 1,
|
||||
fail: () => uni.redirectTo({ url: '/pages/supervisor/index/index' })
|
||||
});
|
||||
};
|
||||
|
||||
onLoad((query) => {
|
||||
targetExtPartyId.value = queryText(query?.extPartyId);
|
||||
targetName.value = queryText(query?.name);
|
||||
assignmentReason.value = queryText(query?.reason);
|
||||
const requestedScenario = queryText(query?.scenarioId);
|
||||
assignmentScenarioId.value = isScenarioId(requestedScenario)
|
||||
? requestedScenario
|
||||
: inferSupervisorScenarioId(assignmentReason.value);
|
||||
});
|
||||
|
||||
onShow(() => {
|
||||
resetPageScroll();
|
||||
loadPage();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
historyRequests.dispose();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.assign-page {
|
||||
padding-bottom: 42px;
|
||||
background: #f4f7fb;
|
||||
}
|
||||
|
||||
.login-panel,
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
min-width: 104px;
|
||||
}
|
||||
|
||||
.refresh-button {
|
||||
min-width: 82px;
|
||||
padding: 0 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.block {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
display: block;
|
||||
color: #334155;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.field-input,
|
||||
.reason-input,
|
||||
.scenario-row,
|
||||
.submit-button {
|
||||
margin-top: 9px;
|
||||
}
|
||||
|
||||
.field-input + .field-input {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.scenario-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.scenario-chip {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.scenario-chip.active {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.submit-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.assignment-list {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.assignment-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 13px 0;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.assignment-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.assignment-copy {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.assignment-title,
|
||||
.assignment-desc {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.assignment-title {
|
||||
color: #0f172a;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.assignment-desc {
|
||||
margin-top: 5px;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.assignment-source {
|
||||
flex: 0 0 auto;
|
||||
padding: 5px 8px;
|
||||
border-radius: 8px;
|
||||
background: #e0f2fe;
|
||||
color: #0369a1;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.compact-empty {
|
||||
padding: 18px 8px 4px;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,639 @@
|
||||
<template>
|
||||
<view class="page review-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">{{ detail ? `${detail.trainee} · ${detail.scene}` : '训练记录' }}</text>
|
||||
</view>
|
||||
<button v-if="loggedIn && reviewId && !accessDenied" class="ghost-button refresh-button" :disabled="loading" @click="loadDetail">
|
||||
{{ loading ? '刷新中' : '刷新' }}
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view v-if="message && !accessDenied" class="status-message">{{ message }}</view>
|
||||
|
||||
<view v-if="accessDenied" class="panel supervisor-access-panel">
|
||||
<text class="panel-title">无主管权限</text>
|
||||
<text class="muted supervisor-access-copy">当前账号不是主管或项目经理,无法查看团队训练数据。</text>
|
||||
<view class="supervisor-access-actions">
|
||||
<button class="secondary-button" @click="goToEmployee">返回员工端</button>
|
||||
<button class="primary-button" @click="switchAccount">切换账号</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else-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-if="detail">
|
||||
<view class="panel summary-panel">
|
||||
<view class="summary-head">
|
||||
<view class="summary-copy">
|
||||
<text class="detail-title">{{ detail.trainee }} · {{ detail.scene }}</text>
|
||||
<text class="detail-meta">{{ detail.time }} · {{ detail.status }}</text>
|
||||
</view>
|
||||
<text class="score-pill">{{ detail.score }}分</text>
|
||||
</view>
|
||||
<text class="detail-desc">{{ detail.aiComment || detail.summary }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="detail.scoreItems?.length" class="panel">
|
||||
<text class="panel-title">评分明细</text>
|
||||
<view class="score-list">
|
||||
<view v-for="item in detail.scoreItems" :key="item.label" class="score-row">
|
||||
<view class="score-copy">
|
||||
<text class="score-label">{{ item.label }}</text>
|
||||
<text class="score-note">{{ item.note }}</text>
|
||||
</view>
|
||||
<text class="score-value">{{ item.value }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="detail.dialogue?.length" class="panel">
|
||||
<text class="panel-title">对话记录</text>
|
||||
<view class="dialogue-list">
|
||||
<view v-for="(turn, index) in detail.dialogue" :key="`${turn.role}-${index}`" class="dialogue-row">
|
||||
<text class="dialogue-label">{{ turn.label }}</text>
|
||||
<text class="dialogue-text">{{ turn.text }}</text>
|
||||
<view v-if="hasAudio(turn)" class="dialogue-audio-block">
|
||||
<audio v-if="audioSrc(turn, index)" class="dialogue-audio" controls :src="audioSrc(turn, index)"></audio>
|
||||
<button
|
||||
v-else
|
||||
class="secondary-button audio-button"
|
||||
:disabled="audioLoading[audioKey(index)]"
|
||||
@click="loadAudio(turn, index)"
|
||||
>
|
||||
{{ audioLoading[audioKey(index)] ? '加载中' : '加载录音' }}
|
||||
</button>
|
||||
<text v-if="audioErrors[audioKey(index)]" class="audio-error">{{ audioErrors[audioKey(index)] }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="detail.annotations?.length || detail.mentorRewrite" class="panel">
|
||||
<text class="panel-title">标注与建议</text>
|
||||
<view v-if="detail.annotations?.length" class="annotation-list">
|
||||
<view v-for="item in detail.annotations" :key="`${item.turnIndex}-${item.issue}`" class="annotation-row">
|
||||
<text class="annotation-issue">{{ item.issue }}</text>
|
||||
<text class="annotation-rewrite">建议:{{ item.rewrite }}</text>
|
||||
<text class="annotation-sop">依据:{{ item.sopPoint }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="detail.mentorRewrite" class="mentor-block">
|
||||
<text class="subsection-title">推荐话术</text>
|
||||
<text class="mentor-text">{{ detail.mentorRewrite }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="editable" class="panel review-form">
|
||||
<text class="panel-title">主管复盘</text>
|
||||
<text class="field-label">复盘建议</text>
|
||||
<textarea
|
||||
v-model="advice"
|
||||
class="textarea review-textarea"
|
||||
maxlength="300"
|
||||
placeholder="填写复盘建议,可留空直接标记"
|
||||
/>
|
||||
|
||||
<text class="field-label">人工贡献分</text>
|
||||
<input
|
||||
v-model="incentivePoint"
|
||||
class="input point-input"
|
||||
type="number"
|
||||
maxlength="3"
|
||||
placeholder="可留空"
|
||||
/>
|
||||
|
||||
<view class="next-training-section">
|
||||
<text class="field-label">复盘后专项</text>
|
||||
<view class="scenario-row">
|
||||
<button class="chip scenario-chip" :class="{ active: !nextScenarioId }" @click="clearNextScenario">
|
||||
暂不派发
|
||||
</button>
|
||||
<button
|
||||
v-for="item in supervisorScenarioOptions"
|
||||
:key="item.id"
|
||||
class="chip scenario-chip"
|
||||
:class="{ active: nextScenarioId === item.id }"
|
||||
@click="selectNextScenario(item.id)"
|
||||
>
|
||||
{{ item.name }}
|
||||
</button>
|
||||
</view>
|
||||
<textarea
|
||||
v-if="nextScenarioId"
|
||||
v-model="nextReason"
|
||||
class="textarea next-reason-input"
|
||||
maxlength="180"
|
||||
placeholder="填写下一次训练原因"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<button class="primary-button submit-button" :disabled="marking" @click="markReviewed">
|
||||
{{ marking ? '提交中' : '标记已复盘' }}
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view v-else class="panel review-complete">
|
||||
<text class="complete-title">复盘已完成</text>
|
||||
<text class="complete-desc">{{ detail.reviewAdvice || '该训练记录已完成主管复盘。' }}</text>
|
||||
<text v-if="detail.incentivePoint != null" class="complete-point">
|
||||
人工贡献分 {{ detail.incentivePoint }}
|
||||
</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<view v-else-if="loggedIn && !loading" class="panel empty-panel">
|
||||
<view class="empty">{{ reviewId ? '复盘详情暂不可用' : '缺少有效的复盘ID' }}</view>
|
||||
<button class="secondary-button back-list-button" @click="goToReviews">返回待复盘</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref } from 'vue';
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app';
|
||||
import type { ReviewDetail, ReviewDialogue } from '@/types/api';
|
||||
import { authenticatedFileUrl, releaseAuthenticatedFileUrl } from '@/services/api';
|
||||
import { clearAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||||
import { resetPageScroll } from '@/services/navigation';
|
||||
import { getPracticeReviewDetail, markPracticeReviewed } from '@/services/practice';
|
||||
import {
|
||||
createAsyncGenerationGuard,
|
||||
isReviewEditable,
|
||||
isSupervisorAccessDenied,
|
||||
reviewCompletionAction,
|
||||
supervisorScenarioOptions,
|
||||
type SupervisorScenarioId
|
||||
} from '@/services/supervisor-view';
|
||||
|
||||
const basePath = '/pages/supervisor/review/index';
|
||||
const reviewId = ref(0);
|
||||
const detail = ref<ReviewDetail | null>(null);
|
||||
const advice = ref('');
|
||||
const incentivePoint = ref('');
|
||||
const nextScenarioId = ref<SupervisorScenarioId | ''>('');
|
||||
const nextReason = ref('');
|
||||
const loggedIn = ref(false);
|
||||
const accessDenied = ref(false);
|
||||
const loading = ref(false);
|
||||
const marking = ref(false);
|
||||
const message = ref('');
|
||||
const audioSources = ref<Record<string, string>>({});
|
||||
const audioLoading = ref<Record<string, boolean>>({});
|
||||
const audioErrors = ref<Record<string, string>>({});
|
||||
const audioRequests = createAsyncGenerationGuard();
|
||||
const editable = computed(() => isReviewEditable(detail.value?.status));
|
||||
|
||||
const audioKey = (index: number) => `${reviewId.value || 'review'}-${index}`;
|
||||
const hasAudio = (turn: ReviewDialogue) => Boolean(turn.audioOssId || turn.audioUrl);
|
||||
const audioSrc = (turn: ReviewDialogue, index: number) =>
|
||||
audioSources.value[audioKey(index)] || (!turn.audioOssId ? turn.audioUrl || '' : '');
|
||||
|
||||
const releaseAudioSources = () => {
|
||||
audioRequests.invalidate();
|
||||
Object.values(audioSources.value).forEach(releaseAuthenticatedFileUrl);
|
||||
audioSources.value = {};
|
||||
audioLoading.value = {};
|
||||
audioErrors.value = {};
|
||||
};
|
||||
|
||||
const clearDetail = () => {
|
||||
releaseAudioSources();
|
||||
detail.value = null;
|
||||
advice.value = '';
|
||||
incentivePoint.value = '';
|
||||
nextScenarioId.value = '';
|
||||
nextReason.value = '';
|
||||
};
|
||||
|
||||
const refreshAuthState = () => {
|
||||
loggedIn.value = isLoggedIn();
|
||||
};
|
||||
|
||||
const handleRequestError = (error: unknown, fallback: string) => {
|
||||
if (isSupervisorAccessDenied(error)) {
|
||||
clearDetail();
|
||||
accessDenied.value = true;
|
||||
message.value = '';
|
||||
return;
|
||||
}
|
||||
refreshAuthState();
|
||||
if (!loggedIn.value) {
|
||||
accessDenied.value = false;
|
||||
clearDetail();
|
||||
message.value = '登录已过期,请重新登录';
|
||||
return;
|
||||
}
|
||||
message.value = error instanceof Error ? error.message : fallback;
|
||||
};
|
||||
|
||||
const loadAudio = async (turn: ReviewDialogue, index: number) => {
|
||||
const key = audioKey(index);
|
||||
if (audioSrc(turn, index) || !turn.audioOssId || audioLoading.value[key]) return;
|
||||
const generation = audioRequests.capture();
|
||||
audioLoading.value = { ...audioLoading.value, [key]: true };
|
||||
audioErrors.value = { ...audioErrors.value, [key]: '' };
|
||||
try {
|
||||
const source = await authenticatedFileUrl(`/api/aihr/mobile/oss/${turn.audioOssId}`);
|
||||
if (!audioRequests.isCurrent(generation)) {
|
||||
releaseAuthenticatedFileUrl(source);
|
||||
return;
|
||||
}
|
||||
audioSources.value = {
|
||||
...audioSources.value,
|
||||
[key]: source
|
||||
};
|
||||
} catch (error) {
|
||||
if (!audioRequests.isCurrent(generation)) return;
|
||||
if (isSupervisorAccessDenied(error)) {
|
||||
handleRequestError(error, '录音加载失败');
|
||||
return;
|
||||
}
|
||||
refreshAuthState();
|
||||
if (!loggedIn.value) {
|
||||
accessDenied.value = false;
|
||||
clearDetail();
|
||||
message.value = '登录已过期,请重新登录';
|
||||
return;
|
||||
}
|
||||
audioErrors.value = { ...audioErrors.value, [key]: '录音加载失败' };
|
||||
if (error instanceof Error && error.message === '网络请求失败') {
|
||||
message.value = '录音加载失败,请检查网络后重试';
|
||||
}
|
||||
} finally {
|
||||
if (audioRequests.isCurrent(generation)) {
|
||||
audioLoading.value = { ...audioLoading.value, [key]: false };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const loadDetail = async () => {
|
||||
refreshAuthState();
|
||||
message.value = '';
|
||||
if (!loggedIn.value) {
|
||||
accessDenied.value = false;
|
||||
clearDetail();
|
||||
return;
|
||||
}
|
||||
if (!reviewId.value) {
|
||||
clearDetail();
|
||||
message.value = '缺少有效的复盘ID';
|
||||
return;
|
||||
}
|
||||
if (loading.value) return;
|
||||
loading.value = true;
|
||||
releaseAudioSources();
|
||||
try {
|
||||
const nextDetail = await getPracticeReviewDetail(reviewId.value);
|
||||
accessDenied.value = false;
|
||||
detail.value = nextDetail;
|
||||
advice.value = detail.value.reviewAdvice || '';
|
||||
incentivePoint.value = detail.value.incentivePoint == null ? '' : String(detail.value.incentivePoint);
|
||||
nextScenarioId.value = '';
|
||||
nextReason.value = '';
|
||||
} catch (error) {
|
||||
clearDetail();
|
||||
handleRequestError(error, '复盘详情加载失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const selectNextScenario = (scenarioId: SupervisorScenarioId) => {
|
||||
nextScenarioId.value = scenarioId;
|
||||
const scenario = supervisorScenarioOptions.find((item) => item.id === scenarioId);
|
||||
nextReason.value = `复盘后建议补练${scenario?.name || '专项训练'}`;
|
||||
};
|
||||
|
||||
const clearNextScenario = () => {
|
||||
nextScenarioId.value = '';
|
||||
nextReason.value = '';
|
||||
};
|
||||
|
||||
const markReviewed = async () => {
|
||||
if (marking.value || !detail.value?.id) return;
|
||||
if (!editable.value) {
|
||||
message.value = '该记录已完成复盘,不能重复提交';
|
||||
return;
|
||||
}
|
||||
const pointText = incentivePoint.value.trim();
|
||||
const point = pointText ? Number(pointText) : undefined;
|
||||
if (point !== undefined && !Number.isFinite(point)) {
|
||||
message.value = '贡献分必须是数字';
|
||||
return;
|
||||
}
|
||||
|
||||
marking.value = true;
|
||||
message.value = '';
|
||||
const assignedNext = Boolean(nextScenarioId.value);
|
||||
try {
|
||||
await markPracticeReviewed(detail.value.id, {
|
||||
reviewer: '主管',
|
||||
advice: advice.value.trim(),
|
||||
incentivePoint: point,
|
||||
nextScenarioId: nextScenarioId.value || undefined,
|
||||
nextReason: nextScenarioId.value ? nextReason.value.trim() : undefined
|
||||
});
|
||||
uni.showToast({ title: assignedNext ? '已复盘并派专项' : '已复盘', icon: 'none' });
|
||||
const pages = getCurrentPages();
|
||||
const previousRoute = pages.length > 1 ? pages[pages.length - 2]?.route : undefined;
|
||||
clearDetail();
|
||||
if (reviewCompletionAction(previousRoute) === 'back') {
|
||||
uni.navigateBack({ delta: 1, fail: goToReviews });
|
||||
} else {
|
||||
goToReviews();
|
||||
}
|
||||
} catch (error) {
|
||||
handleRequestError(error, '标记失败');
|
||||
} finally {
|
||||
marking.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const currentPath = () => reviewId.value ? `${basePath}?id=${reviewId.value}` : basePath;
|
||||
|
||||
const goLogin = () => {
|
||||
rememberLoginRedirect(currentPath());
|
||||
uni.redirectTo({ url: '/pages/auth/login/index' });
|
||||
};
|
||||
|
||||
const goToEmployee = () => {
|
||||
uni.switchTab({ url: '/pages/user/today/index' });
|
||||
};
|
||||
|
||||
const switchAccount = () => {
|
||||
clearAuth();
|
||||
rememberLoginRedirect(currentPath());
|
||||
uni.redirectTo({ url: '/pages/auth/login/index' });
|
||||
};
|
||||
|
||||
const goToReviews = () => {
|
||||
uni.redirectTo({ url: '/pages/supervisor/reviews/index' });
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
if (getCurrentPages().length <= 1) {
|
||||
goToReviews();
|
||||
return;
|
||||
}
|
||||
uni.navigateBack({ delta: 1, fail: goToReviews });
|
||||
};
|
||||
|
||||
onLoad((query) => {
|
||||
const value = Number(query?.id);
|
||||
reviewId.value = Number.isInteger(value) && value > 0 ? value : 0;
|
||||
});
|
||||
|
||||
onShow(() => {
|
||||
resetPageScroll();
|
||||
refreshAuthState();
|
||||
if (!loggedIn.value) {
|
||||
accessDenied.value = false;
|
||||
clearDetail();
|
||||
return;
|
||||
}
|
||||
if (!detail.value && !loading.value) void loadDetail();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
audioRequests.dispose();
|
||||
releaseAudioSources();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.review-page {
|
||||
padding-bottom: 42px;
|
||||
background: #f4f7fb;
|
||||
}
|
||||
|
||||
.subpage-header {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.login-panel,
|
||||
.summary-head,
|
||||
.score-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
min-width: 104px;
|
||||
}
|
||||
|
||||
.refresh-button {
|
||||
min-width: 78px;
|
||||
padding: 0 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.block {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.review-complete {
|
||||
border-left: 4px solid #16a34a;
|
||||
}
|
||||
|
||||
.complete-title,
|
||||
.complete-desc,
|
||||
.complete-point {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.complete-title {
|
||||
color: #166534;
|
||||
font-size: 16px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.complete-desc,
|
||||
.complete-point {
|
||||
margin-top: 8px;
|
||||
color: #475569;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.summary-copy,
|
||||
.score-copy {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.detail-title,
|
||||
.detail-meta,
|
||||
.detail-desc,
|
||||
.score-label,
|
||||
.score-note,
|
||||
.dialogue-label,
|
||||
.dialogue-text,
|
||||
.annotation-issue,
|
||||
.annotation-rewrite,
|
||||
.annotation-sop,
|
||||
.subsection-title,
|
||||
.mentor-text,
|
||||
.field-label {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
color: #0f172a;
|
||||
font-size: 17px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.detail-meta {
|
||||
margin-top: 5px;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.detail-desc {
|
||||
margin-top: 12px;
|
||||
color: #475569;
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.score-pill {
|
||||
flex: 0 0 auto;
|
||||
padding: 7px 10px;
|
||||
border-radius: 8px;
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
font-size: 15px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.score-list,
|
||||
.dialogue-list,
|
||||
.annotation-list {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.score-row,
|
||||
.dialogue-row,
|
||||
.annotation-row {
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.score-row:last-child,
|
||||
.dialogue-row:last-child,
|
||||
.annotation-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.score-label,
|
||||
.dialogue-label,
|
||||
.annotation-issue,
|
||||
.subsection-title,
|
||||
.field-label {
|
||||
color: #1e293b;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.score-note,
|
||||
.dialogue-text,
|
||||
.annotation-rewrite,
|
||||
.annotation-sop,
|
||||
.mentor-text {
|
||||
margin-top: 6px;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.score-value {
|
||||
flex: 0 0 auto;
|
||||
color: #0f172a;
|
||||
font-size: 20px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.dialogue-audio-block {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.dialogue-audio {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.audio-button {
|
||||
width: 104px;
|
||||
min-height: 36px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.audio-error {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
color: #dc2626;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.mentor-block {
|
||||
margin-top: 14px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.review-textarea,
|
||||
.point-input,
|
||||
.scenario-row,
|
||||
.next-reason-input,
|
||||
.submit-button {
|
||||
margin-top: 9px;
|
||||
}
|
||||
|
||||
.scenario-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.scenario-chip {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.scenario-chip.active {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.next-training-section {
|
||||
margin-top: 16px;
|
||||
padding-top: 2px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.submit-button,
|
||||
.back-list-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.empty-panel {
|
||||
padding-bottom: 18px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,284 @@
|
||||
<template>
|
||||
<view class="page reviews-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">{{ reviewCountText }}</text>
|
||||
</view>
|
||||
<button v-if="loggedIn && !accessDenied" class="ghost-button refresh-button" :disabled="loading" @click="loadPage">
|
||||
{{ loading ? '刷新中' : '刷新' }}
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view v-if="message && !accessDenied" class="status-message">{{ message }}</view>
|
||||
|
||||
<view v-if="accessDenied" class="panel supervisor-access-panel">
|
||||
<text class="panel-title">无主管权限</text>
|
||||
<text class="muted supervisor-access-copy">当前账号不是主管或项目经理,无法查看团队训练数据。</text>
|
||||
<view class="supervisor-access-actions">
|
||||
<button class="secondary-button" @click="goToEmployee">返回员工端</button>
|
||||
<button class="primary-button" @click="switchAccount">切换账号</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else-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>
|
||||
|
||||
<view v-else class="panel review-panel">
|
||||
<view class="section-head">
|
||||
<text class="panel-title">复盘队列</text>
|
||||
<text class="muted">低分优先</text>
|
||||
</view>
|
||||
<view v-if="reviews.length" class="review-list">
|
||||
<button
|
||||
v-for="item in reviews"
|
||||
:key="item.id || item.sessionId || item.time"
|
||||
class="review-row"
|
||||
@click="openReview(item)"
|
||||
>
|
||||
<view class="review-copy">
|
||||
<text class="review-title">{{ item.trainee }} · {{ item.scene }}</text>
|
||||
<text class="review-desc">{{ item.time }} · {{ item.summary }}</text>
|
||||
<text class="review-status">{{ item.status }}</text>
|
||||
</view>
|
||||
<view class="review-score">
|
||||
<text>{{ item.score }}</text>
|
||||
<text>查看</text>
|
||||
</view>
|
||||
</button>
|
||||
</view>
|
||||
<view v-else-if="!loading" class="empty">暂无待复盘记录</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { onShow } from '@dcloudio/uni-app';
|
||||
import type { PracticeAlert, PracticeRecord } from '@/types/api';
|
||||
import { clearAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||||
import { resetPageScroll } from '@/services/navigation';
|
||||
import { getPracticeAlerts, getPracticeReviews } from '@/services/practice';
|
||||
import { buildSupervisorMetrics, isSupervisorAccessDenied } from '@/services/supervisor-view';
|
||||
|
||||
const currentPath = '/pages/supervisor/reviews/index';
|
||||
const reviews = ref<PracticeRecord[]>([]);
|
||||
const alerts = ref<PracticeAlert[]>([]);
|
||||
const loggedIn = ref(false);
|
||||
const accessDenied = ref(false);
|
||||
const loading = ref(false);
|
||||
const message = ref('');
|
||||
|
||||
const pendingReviewTotal = computed(() =>
|
||||
buildSupervisorMetrics(reviews.value, [], alerts.value)
|
||||
.find((item) => item.key === 'pendingReviews')?.value || 0);
|
||||
const reviewCountText = computed(() =>
|
||||
pendingReviewTotal.value > reviews.value.length
|
||||
? `${reviews.value.length}/${pendingReviewTotal.value} 条`
|
||||
: `${reviews.value.length} 条`);
|
||||
|
||||
const clearData = () => {
|
||||
reviews.value = [];
|
||||
alerts.value = [];
|
||||
};
|
||||
|
||||
const refreshAuthState = () => {
|
||||
loggedIn.value = isLoggedIn();
|
||||
};
|
||||
|
||||
const handleRequestError = (error: unknown, fallback: string) => {
|
||||
if (isSupervisorAccessDenied(error)) {
|
||||
clearData();
|
||||
accessDenied.value = true;
|
||||
message.value = '';
|
||||
return;
|
||||
}
|
||||
refreshAuthState();
|
||||
if (!loggedIn.value) {
|
||||
accessDenied.value = false;
|
||||
clearData();
|
||||
message.value = '登录已过期,请重新登录';
|
||||
return;
|
||||
}
|
||||
message.value = error instanceof Error ? error.message : fallback;
|
||||
};
|
||||
|
||||
const loadPage = async () => {
|
||||
refreshAuthState();
|
||||
message.value = '';
|
||||
if (!loggedIn.value) {
|
||||
accessDenied.value = false;
|
||||
clearData();
|
||||
return;
|
||||
}
|
||||
if (loading.value) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const [nextReviews, nextAlerts] = await Promise.all([
|
||||
getPracticeReviews(),
|
||||
getPracticeAlerts()
|
||||
]);
|
||||
accessDenied.value = false;
|
||||
reviews.value = nextReviews;
|
||||
alerts.value = nextAlerts;
|
||||
} catch (error) {
|
||||
handleRequestError(error, '待复盘列表加载失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openReview = (item: PracticeRecord) => {
|
||||
if (!item.id) {
|
||||
message.value = '该记录缺少复盘ID';
|
||||
return;
|
||||
}
|
||||
uni.navigateTo({ url: `/pages/supervisor/review/index?id=${item.id}` });
|
||||
};
|
||||
|
||||
const goLogin = () => {
|
||||
rememberLoginRedirect(currentPath);
|
||||
uni.redirectTo({ url: '/pages/auth/login/index' });
|
||||
};
|
||||
|
||||
const goToEmployee = () => {
|
||||
uni.switchTab({ url: '/pages/user/today/index' });
|
||||
};
|
||||
|
||||
const switchAccount = () => {
|
||||
clearAuth();
|
||||
rememberLoginRedirect(currentPath);
|
||||
uni.redirectTo({ url: '/pages/auth/login/index' });
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
if (getCurrentPages().length <= 1) {
|
||||
uni.redirectTo({ url: '/pages/supervisor/index/index' });
|
||||
return;
|
||||
}
|
||||
uni.navigateBack({
|
||||
delta: 1,
|
||||
fail: () => uni.redirectTo({ url: '/pages/supervisor/index/index' })
|
||||
});
|
||||
};
|
||||
|
||||
onShow(() => {
|
||||
resetPageScroll();
|
||||
void loadPage();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.reviews-page {
|
||||
padding-bottom: 42px;
|
||||
background: #f4f7fb;
|
||||
}
|
||||
|
||||
.subpage-header {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.login-panel,
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
min-width: 104px;
|
||||
}
|
||||
|
||||
.refresh-button {
|
||||
min-width: 78px;
|
||||
padding: 0 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.block {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.review-list {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.review-row {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 88px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 13px 0;
|
||||
border-radius: 0;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.review-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.review-copy {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.review-title,
|
||||
.review-desc,
|
||||
.review-status {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.review-title {
|
||||
color: #0f172a;
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.review-desc {
|
||||
margin-top: 5px;
|
||||
overflow: hidden;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.review-status {
|
||||
margin-top: 6px;
|
||||
color: #c2410c;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.review-score {
|
||||
display: flex;
|
||||
min-width: 52px;
|
||||
flex: 0 0 auto;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.review-score text:first-child {
|
||||
color: #dc2626;
|
||||
font-size: 24px;
|
||||
font-weight: 900;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,366 @@
|
||||
<template>
|
||||
<view class="page team-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">{{ teamProfiles.length }} 名成员</text>
|
||||
</view>
|
||||
<button v-if="loggedIn && !accessDenied" class="ghost-button refresh-button" :disabled="loading" @click="loadPage">
|
||||
{{ loading ? '刷新中' : '刷新' }}
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view v-if="message && !accessDenied" class="status-message">{{ message }}</view>
|
||||
|
||||
<view v-if="accessDenied" class="panel supervisor-access-panel">
|
||||
<text class="panel-title">无主管权限</text>
|
||||
<text class="muted supervisor-access-copy">当前账号不是主管或项目经理,无法查看团队训练数据。</text>
|
||||
<view class="supervisor-access-actions">
|
||||
<button class="secondary-button" @click="goToEmployee">返回员工端</button>
|
||||
<button class="primary-button" @click="switchAccount">切换账号</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else-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="risk-summary">
|
||||
<view class="risk-item high">
|
||||
<text class="risk-value">{{ riskCounts.high }}</text>
|
||||
<text class="risk-label">高风险</text>
|
||||
</view>
|
||||
<view class="risk-item medium">
|
||||
<text class="risk-value">{{ riskCounts.medium }}</text>
|
||||
<text class="risk-label">待复盘</text>
|
||||
</view>
|
||||
<view class="risk-item low">
|
||||
<text class="risk-value">{{ riskCounts.low }}</text>
|
||||
<text class="risk-label">正常</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="panel team-panel">
|
||||
<view class="section-head">
|
||||
<text class="panel-title">成员列表</text>
|
||||
<text class="muted">按风险排序</text>
|
||||
</view>
|
||||
<view v-if="teamProfiles.length" class="team-list">
|
||||
<view v-for="member in teamProfiles" :key="member.key" class="team-row" :class="member.risk">
|
||||
<view class="team-main">
|
||||
<view class="team-title-row">
|
||||
<text class="team-name">{{ member.displayName }}</text>
|
||||
<text class="team-score">{{ member.latestScoreText }}</text>
|
||||
</view>
|
||||
<text class="team-desc">{{ member.reason }}</text>
|
||||
<view class="team-tags">
|
||||
<text class="team-tag">待复盘 {{ member.pendingReview }}</text>
|
||||
<text class="team-tag">待训练 {{ member.pendingAssignment }}</text>
|
||||
<text v-if="member.latestScene" class="team-tag">{{ member.latestScene }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<button class="secondary-button team-action" @click="assignMember(member)">派专项</button>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else-if="!loading" class="empty">暂无团队画像数据</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { onShow } from '@dcloudio/uni-app';
|
||||
import type { PracticeAssignment, PracticeRecord, PracticeTeamMember } from '@/types/api';
|
||||
import { clearAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||||
import { resetPageScroll } from '@/services/navigation';
|
||||
import { getPracticeTeamSnapshot } from '@/services/practice';
|
||||
import {
|
||||
buildTeamProfiles,
|
||||
inferSupervisorScenarioId,
|
||||
isSupervisorAccessDenied,
|
||||
type TeamProfile
|
||||
} from '@/services/supervisor-view';
|
||||
|
||||
const currentPath = '/pages/supervisor/team/index';
|
||||
const records = ref<PracticeRecord[]>([]);
|
||||
const assignments = ref<PracticeAssignment[]>([]);
|
||||
const members = ref<PracticeTeamMember[]>([]);
|
||||
const loggedIn = ref(false);
|
||||
const accessDenied = ref(false);
|
||||
const loading = ref(false);
|
||||
const message = ref('');
|
||||
|
||||
const teamProfiles = computed(() => buildTeamProfiles(records.value, assignments.value, members.value));
|
||||
const riskCounts = computed(() => teamProfiles.value.reduce(
|
||||
(counts, item) => {
|
||||
counts[item.risk] += 1;
|
||||
return counts;
|
||||
},
|
||||
{ high: 0, medium: 0, low: 0 }
|
||||
));
|
||||
|
||||
const clearData = () => {
|
||||
records.value = [];
|
||||
assignments.value = [];
|
||||
members.value = [];
|
||||
};
|
||||
|
||||
const refreshAuthState = () => {
|
||||
loggedIn.value = isLoggedIn();
|
||||
};
|
||||
|
||||
const handleRequestError = (error: unknown, fallback: string) => {
|
||||
if (isSupervisorAccessDenied(error)) {
|
||||
clearData();
|
||||
accessDenied.value = true;
|
||||
message.value = '';
|
||||
return;
|
||||
}
|
||||
refreshAuthState();
|
||||
if (!loggedIn.value) {
|
||||
accessDenied.value = false;
|
||||
clearData();
|
||||
message.value = '登录已过期,请重新登录';
|
||||
return;
|
||||
}
|
||||
message.value = error instanceof Error ? error.message : fallback;
|
||||
};
|
||||
|
||||
const loadPage = async () => {
|
||||
refreshAuthState();
|
||||
message.value = '';
|
||||
if (!loggedIn.value) {
|
||||
accessDenied.value = false;
|
||||
clearData();
|
||||
return;
|
||||
}
|
||||
if (loading.value) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const snapshot = await getPracticeTeamSnapshot();
|
||||
accessDenied.value = false;
|
||||
records.value = snapshot.records;
|
||||
assignments.value = snapshot.assignments;
|
||||
members.value = snapshot.members;
|
||||
} catch (error) {
|
||||
handleRequestError(error, '团队画像加载失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const assignMember = (member: TeamProfile) => {
|
||||
const scenarioId = inferSupervisorScenarioId(`${member.latestScene} ${member.reason}`);
|
||||
const query = [
|
||||
`extPartyId=${encodeURIComponent(member.extPartyId)}`,
|
||||
`name=${encodeURIComponent(member.name)}`,
|
||||
`scenarioId=${encodeURIComponent(scenarioId)}`,
|
||||
`reason=${encodeURIComponent(member.reason)}`
|
||||
].join('&');
|
||||
uni.navigateTo({ url: `/pages/supervisor/assign/index?${query}` });
|
||||
};
|
||||
|
||||
const goLogin = () => {
|
||||
rememberLoginRedirect(currentPath);
|
||||
uni.redirectTo({ url: '/pages/auth/login/index' });
|
||||
};
|
||||
|
||||
const goToEmployee = () => {
|
||||
uni.switchTab({ url: '/pages/user/today/index' });
|
||||
};
|
||||
|
||||
const switchAccount = () => {
|
||||
clearAuth();
|
||||
rememberLoginRedirect(currentPath);
|
||||
uni.redirectTo({ url: '/pages/auth/login/index' });
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
if (getCurrentPages().length <= 1) {
|
||||
uni.redirectTo({ url: '/pages/supervisor/index/index' });
|
||||
return;
|
||||
}
|
||||
uni.navigateBack({
|
||||
delta: 1,
|
||||
fail: () => uni.redirectTo({ url: '/pages/supervisor/index/index' })
|
||||
});
|
||||
};
|
||||
|
||||
onShow(() => {
|
||||
resetPageScroll();
|
||||
void loadPage();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.team-page {
|
||||
padding-bottom: 42px;
|
||||
background: #f4f7fb;
|
||||
}
|
||||
|
||||
.subpage-header {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
min-width: 104px;
|
||||
}
|
||||
|
||||
.refresh-button {
|
||||
min-width: 78px;
|
||||
padding: 0 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.login-panel,
|
||||
.section-head,
|
||||
.team-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;
|
||||
}
|
||||
|
||||
.risk-summary {
|
||||
display: grid;
|
||||
margin-top: 16px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.risk-item {
|
||||
padding: 13px 8px;
|
||||
border-right: 1px solid #e2e8f0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.risk-item:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.risk-value,
|
||||
.risk-label {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.risk-value {
|
||||
color: #0f172a;
|
||||
font-size: 23px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.risk-label {
|
||||
margin-top: 4px;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.risk-item.high .risk-value {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.risk-item.medium .risk-value {
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.risk-item.low .risk-value {
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.team-list {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.team-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 14px 0 14px 12px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
border-left: 4px solid #22c55e;
|
||||
}
|
||||
|
||||
.team-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.team-row.high {
|
||||
border-left-color: #dc2626;
|
||||
}
|
||||
|
||||
.team-row.medium {
|
||||
border-left-color: #f59e0b;
|
||||
}
|
||||
|
||||
.team-main {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.team-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: #0f172a;
|
||||
font-size: 15px;
|
||||
font-weight: 900;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.team-score {
|
||||
flex: 0 0 auto;
|
||||
color: #1d4ed8;
|
||||
font-size: 14px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.team-desc {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.team-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.team-tag {
|
||||
padding: 4px 7px;
|
||||
border-radius: 8px;
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.team-action {
|
||||
min-width: 72px;
|
||||
min-height: 38px;
|
||||
flex: 0 0 auto;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,130 @@
|
||||
import type { CandidateMaterial, InterviewRecord } from '@/types/api';
|
||||
|
||||
export const candidateRoutes = {
|
||||
index: '/pages/candidate/index/index',
|
||||
interview: '/pages/candidate/interview/index',
|
||||
materials: '/pages/candidate/materials/index',
|
||||
progress: '/pages/candidate/progress/index',
|
||||
study: '/pages/candidate/study/index'
|
||||
} as const;
|
||||
|
||||
export type CandidateStage = 'interview' | 'materials' | 'review' | 'complete';
|
||||
|
||||
export interface CandidateFlow {
|
||||
stage: CandidateStage;
|
||||
badge: string;
|
||||
title: string;
|
||||
desc: string;
|
||||
progress: number;
|
||||
progressText: string;
|
||||
notice: string;
|
||||
cta: string;
|
||||
route: string;
|
||||
}
|
||||
|
||||
export interface CandidateStep {
|
||||
index: number;
|
||||
title: string;
|
||||
desc: string;
|
||||
status: 'complete' | 'current' | 'pending';
|
||||
}
|
||||
|
||||
export const buildCandidateFlow = (
|
||||
records: InterviewRecord[],
|
||||
materials: CandidateMaterial[]
|
||||
): CandidateFlow => {
|
||||
if (!records.length) {
|
||||
return {
|
||||
stage: 'interview',
|
||||
badge: '待面试',
|
||||
title: '完成结构化面试',
|
||||
desc: '按岗位题目完成回答,提交后生成正式面试记录。',
|
||||
progress: 0,
|
||||
progressText: '0/2',
|
||||
notice: '待完成结构化面试',
|
||||
cta: '开始面试',
|
||||
route: candidateRoutes.interview
|
||||
};
|
||||
}
|
||||
|
||||
const materialStatus = materials[0]?.status || '';
|
||||
if (!materials.length || materialStatus === '已驳回') {
|
||||
const rejected = materialStatus === '已驳回';
|
||||
return {
|
||||
stage: 'materials',
|
||||
badge: rejected ? '资料被驳回' : '待补资料',
|
||||
title: rejected ? '重新提交资料' : '上传补充资料',
|
||||
desc: rejected ? '请按审核结果补充或替换资料。' : '上传简历、证书或相关图片,供 HR 审核。',
|
||||
progress: 50,
|
||||
progressText: '1/2',
|
||||
notice: rejected ? '资料被驳回,请重新上传' : '面试已完成,请上传补充资料',
|
||||
cta: rejected ? '重新上传' : '上传资料',
|
||||
route: candidateRoutes.materials
|
||||
};
|
||||
}
|
||||
|
||||
if (materialStatus === '已通过') {
|
||||
return {
|
||||
stage: 'complete',
|
||||
badge: '资料已通过',
|
||||
title: '等待后续通知',
|
||||
desc: '面试和补充资料已完成,请留意 HR 后续通知。',
|
||||
progress: 100,
|
||||
progressText: '2/2',
|
||||
notice: '资料已通过,等待后续通知',
|
||||
cta: '查看进度',
|
||||
route: candidateRoutes.progress
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
stage: 'review',
|
||||
badge: '审核中',
|
||||
title: '资料审核中',
|
||||
desc: '资料已提交,审核结果会同步到进度页。',
|
||||
progress: 75,
|
||||
progressText: '1/2',
|
||||
notice: '资料已提交,等待 HR 审核',
|
||||
cta: '查看进度',
|
||||
route: candidateRoutes.progress
|
||||
};
|
||||
};
|
||||
|
||||
export const buildCandidateSteps = (flow: CandidateFlow): CandidateStep[] => {
|
||||
const interviewComplete = flow.stage !== 'interview';
|
||||
const materialsComplete = flow.stage === 'complete';
|
||||
const materialsCurrent = flow.stage === 'materials' || flow.stage === 'review';
|
||||
|
||||
return [
|
||||
{
|
||||
index: 1,
|
||||
title: '完成面试',
|
||||
desc: '按岗位题目作答并生成面试记录',
|
||||
status: interviewComplete ? 'complete' : 'current'
|
||||
},
|
||||
{
|
||||
index: 2,
|
||||
title: '提交资料',
|
||||
desc: '上传简历、证书或相关图片',
|
||||
status: materialsComplete ? 'complete' : materialsCurrent ? 'current' : 'pending'
|
||||
},
|
||||
{
|
||||
index: 3,
|
||||
title: '等待通知',
|
||||
desc: 'HR 审核后同步展示最新状态',
|
||||
status: materialsComplete ? 'current' : 'pending'
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
export const formatCandidateFileSize = (size: number) => {
|
||||
if (!size) return '0KB';
|
||||
if (size < 1024 * 1024) return `${Math.ceil(size / 1024)}KB`;
|
||||
return `${(size / 1024 / 1024).toFixed(1)}MB`;
|
||||
};
|
||||
|
||||
export const materialStatusTone = (status: string) => {
|
||||
if (status === '已通过') return 'passed';
|
||||
if (status === '已驳回') return 'rejected';
|
||||
return 'pending';
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
PracticeFinishResponse,
|
||||
PracticePrepCard,
|
||||
PracticeRecord,
|
||||
PracticeTeamSnapshot,
|
||||
PracticeStartResponse,
|
||||
PracticeTurnResponse,
|
||||
CompetencyProfile,
|
||||
@@ -105,6 +106,13 @@ export const getPracticeReviews = () =>
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
export const getPracticeTeamSnapshot = () =>
|
||||
apiRequest<PracticeTeamSnapshot>({
|
||||
url: '/api/aihr/mobile/practice/team',
|
||||
method: 'GET',
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
export const getPracticeReviewDetail = (id: number) =>
|
||||
apiRequest<ReviewDetail>({
|
||||
url: `/api/aihr/mobile/practice/reviews/${id}`,
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
import type {
|
||||
PracticeAlert,
|
||||
PracticeAssignment,
|
||||
PracticeRecord,
|
||||
PracticeTeamMember
|
||||
} from '@/types/api';
|
||||
|
||||
export type SupervisorMetricKey = 'pendingReviews' | 'pendingAssignments' | 'lowScores' | 'alerts';
|
||||
|
||||
export interface SupervisorMetric {
|
||||
key: SupervisorMetricKey;
|
||||
title: string;
|
||||
value: number;
|
||||
desc: string;
|
||||
primary?: boolean;
|
||||
}
|
||||
|
||||
export interface TeamProfile {
|
||||
key: string;
|
||||
extPartyId: string;
|
||||
name: string;
|
||||
displayName: string;
|
||||
latestScore?: number;
|
||||
latestScoreText: string;
|
||||
latestScene: string;
|
||||
lastPracticeAt: string;
|
||||
pendingReview: number;
|
||||
pendingAssignment: number;
|
||||
reason: string;
|
||||
risk: 'high' | 'medium' | 'low';
|
||||
}
|
||||
|
||||
export const supervisorScenarioOptions = [
|
||||
{ id: 'fee-parking', name: '催费沟通' },
|
||||
{ id: 'complaint-water', name: '投诉接待' },
|
||||
{ id: 'repair-elevator-delay', name: '电梯故障' }
|
||||
] as const;
|
||||
|
||||
export type SupervisorScenarioId = typeof supervisorScenarioOptions[number]['id'];
|
||||
|
||||
export interface AsyncGenerationGuard {
|
||||
capture: () => number;
|
||||
begin: () => number;
|
||||
invalidate: () => void;
|
||||
isCurrent: (candidate: number) => boolean;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
export const createAsyncGenerationGuard = (): AsyncGenerationGuard => {
|
||||
let generation = 0;
|
||||
let disposed = false;
|
||||
|
||||
return {
|
||||
capture: () => generation,
|
||||
begin: () => ++generation,
|
||||
invalidate: () => {
|
||||
generation += 1;
|
||||
},
|
||||
isCurrent: (candidate) => !disposed && candidate === generation,
|
||||
dispose: () => {
|
||||
disposed = true;
|
||||
generation += 1;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const inferSupervisorScenarioId = (text: string): SupervisorScenarioId => {
|
||||
if (/电梯/.test(text)) return 'repair-elevator-delay';
|
||||
if (/投诉|报修|漏水|噪音|摔倒/.test(text)) return 'complaint-water';
|
||||
return 'fee-parking';
|
||||
};
|
||||
|
||||
interface TeamProfileDraft {
|
||||
key: string;
|
||||
extPartyId: string;
|
||||
name: string;
|
||||
hasReadableName: boolean;
|
||||
latestScene: string;
|
||||
latestPractice?: {
|
||||
score: number;
|
||||
scene: string;
|
||||
time: string;
|
||||
timeValue: number;
|
||||
};
|
||||
pendingLowScore?: number;
|
||||
pendingReview: number;
|
||||
pendingAssignment: number;
|
||||
}
|
||||
|
||||
export const manualPracticeAssignments = (assignments: PracticeAssignment[]) =>
|
||||
assignments.filter((item) => item.source !== 'daily');
|
||||
|
||||
export const actionablePracticeAlerts = (alerts: PracticeAlert[]) =>
|
||||
alerts.filter((item) => item.type !== 'stable');
|
||||
|
||||
export const isReviewEditable = (status: string | null | undefined) => status === '待复盘';
|
||||
|
||||
export const isSupervisorAccessDenied = (error: unknown) => {
|
||||
const message = error instanceof Error
|
||||
? error.message
|
||||
: typeof error === 'string' ? error : '';
|
||||
return message.includes('无主管权限');
|
||||
};
|
||||
|
||||
export const reviewCompletionAction = (previousRoute: string | undefined): 'back' | 'replace' =>
|
||||
previousRoute?.replace(/^\/+/, '') === 'pages/supervisor/reviews/index' ? 'back' : 'replace';
|
||||
|
||||
const alertCount = (alerts: PracticeAlert[], type: string) => {
|
||||
const match = alerts.find((item) => item.type === type)?.desc.match(/\d+/);
|
||||
return match ? Number(match[0]) : 0;
|
||||
};
|
||||
|
||||
export const buildSupervisorMetrics = (
|
||||
reviews: PracticeRecord[],
|
||||
assignments: PracticeAssignment[],
|
||||
alerts: PracticeAlert[]
|
||||
): SupervisorMetric[] => {
|
||||
const pendingReviewAlertCount = alertCount(alerts, 'pending_review');
|
||||
const pendingReviews = pendingReviewAlertCount
|
||||
|| reviews.filter((item) => item.status === '待复盘').length;
|
||||
const pendingAssignments = manualPracticeAssignments(assignments)
|
||||
.filter((item) => item.status !== '已完成').length;
|
||||
const lowScores = alertCount(alerts, 'low_score');
|
||||
const actionableAlertCount = actionablePracticeAlerts(alerts).length;
|
||||
|
||||
return [
|
||||
{
|
||||
key: 'pendingReviews',
|
||||
title: '当前待复盘',
|
||||
value: pendingReviews,
|
||||
desc: pendingReviews ? '优先处理低分和待处理记录' : '暂无待复盘记录',
|
||||
primary: true
|
||||
},
|
||||
{
|
||||
key: 'pendingAssignments',
|
||||
title: '当前待训练',
|
||||
value: pendingAssignments,
|
||||
desc: pendingAssignments ? '仍有专项训练待完成' : '暂无未完成专项'
|
||||
},
|
||||
{
|
||||
key: 'lowScores',
|
||||
title: '低分预警',
|
||||
value: lowScores,
|
||||
desc: lowScores ? '训练低于 80 分' : '暂无低分预警'
|
||||
},
|
||||
{
|
||||
key: 'alerts',
|
||||
title: '团队预警',
|
||||
value: actionableAlertCount,
|
||||
desc: actionableAlertCount ? '请及时跟进风险项' : '暂无团队预警'
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
export const buildTeamProfiles = (
|
||||
reviews: PracticeRecord[],
|
||||
assignments: PracticeAssignment[],
|
||||
members: PracticeTeamMember[] = []
|
||||
): TeamProfile[] => {
|
||||
const rows = new Map<string, TeamProfileDraft>();
|
||||
const ensure = (rawKey: string | undefined, rawName: string, rawExtPartyId?: string) => {
|
||||
const extPartyId = rawExtPartyId?.trim() || '';
|
||||
const candidateName = rawName.trim();
|
||||
const hasReadableName = candidateName.length > 0 && !/^[??]{3,}/.test(candidateName);
|
||||
const key = rawKey?.trim() || extPartyId || candidateName || 'unknown';
|
||||
const identity = extPartyId || key;
|
||||
const name = hasReadableName ? candidateName : `员工 ${identitySuffix(identity)}`;
|
||||
const current = rows.get(key);
|
||||
if (current) {
|
||||
if (hasReadableName && !current.hasReadableName) {
|
||||
current.name = candidateName;
|
||||
current.hasReadableName = true;
|
||||
}
|
||||
current.extPartyId = current.extPartyId || extPartyId || key;
|
||||
return current;
|
||||
}
|
||||
|
||||
const next: TeamProfileDraft = {
|
||||
key,
|
||||
extPartyId: extPartyId || key,
|
||||
name,
|
||||
hasReadableName,
|
||||
latestScene: '',
|
||||
pendingReview: 0,
|
||||
pendingAssignment: 0
|
||||
};
|
||||
rows.set(key, next);
|
||||
return next;
|
||||
};
|
||||
|
||||
members.forEach((item) => {
|
||||
ensure(item.extPartyId, item.name, item.extPartyId);
|
||||
});
|
||||
|
||||
reviews.forEach((item) => {
|
||||
const row = ensure(item.extPartyId || item.trainee, item.trainee, item.extPartyId);
|
||||
if (isReviewEditable(item.status)) {
|
||||
row.pendingReview += 1;
|
||||
if (typeof item.score === 'number' && Number.isFinite(item.score) && item.score < 80) {
|
||||
row.pendingLowScore = Math.min(row.pendingLowScore ?? item.score, item.score);
|
||||
}
|
||||
}
|
||||
if (typeof item.score === 'number' && Number.isFinite(item.score)) {
|
||||
const timeValue = practiceTimeValue(item.time);
|
||||
if (!row.latestPractice || timeValue > row.latestPractice.timeValue) {
|
||||
row.latestPractice = {
|
||||
score: item.score,
|
||||
scene: item.scene.trim(),
|
||||
time: item.time,
|
||||
timeValue
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
manualPracticeAssignments(assignments).forEach((item) => {
|
||||
const row = ensure(item.extPartyId || item.traineeName, item.traineeName, item.extPartyId);
|
||||
if (item.status !== '已完成') row.pendingAssignment += 1;
|
||||
row.latestScene ||= item.scenarioName.trim();
|
||||
});
|
||||
|
||||
const nameCounts = Array.from(rows.values()).reduce<Record<string, number>>((counts, row) => {
|
||||
counts[row.name] = (counts[row.name] || 0) + 1;
|
||||
return counts;
|
||||
}, {});
|
||||
const riskRank: Record<TeamProfile['risk'], number> = { high: 0, medium: 1, low: 2 };
|
||||
|
||||
return Array.from(rows.values())
|
||||
.map<TeamProfile>((row) => {
|
||||
const latestScore = row.latestPractice?.score;
|
||||
const latestScene = row.latestPractice?.scene || row.latestScene;
|
||||
const risk: TeamProfile['risk'] = row.pendingLowScore !== undefined
|
||||
? 'high'
|
||||
: row.pendingReview > 0
|
||||
? 'medium'
|
||||
: 'low';
|
||||
const reason = risk === 'high'
|
||||
? `${row.name} 有未复盘低分记录(${row.pendingLowScore}分),建议定向再练`
|
||||
: risk === 'medium'
|
||||
? `${row.name} 有 ${row.pendingReview} 条待复盘,建议完成点评后派专项`
|
||||
: row.pendingAssignment > 0
|
||||
? `${row.name} 有 ${row.pendingAssignment} 项待训练,建议跟进完成`
|
||||
: `${row.name} 当前无待处理训练,可按需派发专项`;
|
||||
|
||||
return {
|
||||
key: row.key,
|
||||
extPartyId: row.extPartyId,
|
||||
name: row.name,
|
||||
displayName: nameCounts[row.name] > 1
|
||||
? `${row.name} · ${identitySuffix(row.extPartyId)}`
|
||||
: row.name,
|
||||
latestScore,
|
||||
latestScoreText: latestScore === undefined ? '暂无评分' : `${latestScore}分`,
|
||||
latestScene,
|
||||
lastPracticeAt: row.latestPractice?.time || '',
|
||||
pendingReview: row.pendingReview,
|
||||
pendingAssignment: row.pendingAssignment,
|
||||
reason,
|
||||
risk
|
||||
};
|
||||
})
|
||||
.sort((a, b) =>
|
||||
riskRank[a.risk] - riskRank[b.risk]
|
||||
|| b.pendingReview - a.pendingReview
|
||||
|| b.pendingAssignment - a.pendingAssignment
|
||||
|| (a.latestScore ?? 100) - (b.latestScore ?? 100));
|
||||
};
|
||||
|
||||
function identitySuffix(value: string) {
|
||||
if (/^1[3-9]\d{9}$/.test(value)) return `尾号${value.slice(-4)}`;
|
||||
if (value.length > 8) return `末尾${value.slice(-6)}`;
|
||||
return value;
|
||||
}
|
||||
|
||||
function practiceTimeValue(value: string) {
|
||||
const parts = value.match(/\d+/g)?.map(Number) || [];
|
||||
if (parts.length < 4 || parts.some((part) => !Number.isFinite(part))) {
|
||||
return Number.NEGATIVE_INFINITY;
|
||||
}
|
||||
const normalized = parts.length >= 5 ? parts.slice(0, 6) : [0, ...parts.slice(0, 5)];
|
||||
while (normalized.length < 6) normalized.push(0);
|
||||
const [year, month, day, hour, minute, second] = normalized;
|
||||
return (((((year * 13 + month) * 32 + day) * 24 + hour) * 60 + minute) * 60) + second;
|
||||
}
|
||||
@@ -183,6 +183,31 @@ button::after {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.supervisor-access-panel {
|
||||
border-left: 4px solid #e60012;
|
||||
}
|
||||
|
||||
.supervisor-access-copy {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.supervisor-access-actions {
|
||||
display: grid;
|
||||
margin-top: 18px;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.supervisor-access-actions button {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 0 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 24px 12px;
|
||||
color: #64748b;
|
||||
|
||||
@@ -186,6 +186,17 @@ export interface PracticeRecord {
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface PracticeTeamMember {
|
||||
extPartyId: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface PracticeTeamSnapshot {
|
||||
members: PracticeTeamMember[];
|
||||
records: PracticeRecord[];
|
||||
assignments: PracticeAssignment[];
|
||||
}
|
||||
|
||||
export interface ReviewDialogue {
|
||||
role: string;
|
||||
label: string;
|
||||
|
||||
Reference in New Issue
Block a user