feat: restore learning feedback and knowledge capabilities
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { onLaunch } from '@dcloudio/uni-app';
|
||||
import { applyStoredFontSize } from '@/services/accessibility';
|
||||
|
||||
onLaunch(() => {
|
||||
// App 只保留全局生命周期。业务页面放在 pages 下。
|
||||
applyStoredFontSize();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -49,6 +49,42 @@
|
||||
"navigationBarTitleText": "学"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/user/web-ai/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "问 AI(全网)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/user/questions/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "问题榜"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/user/questions/detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "问题详情"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/user/questions/publish",
|
||||
"style": {
|
||||
"navigationBarTitleText": "发起求助"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/user/points/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "我的积分"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/user/exam/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "岗前考试"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/auth/login/index",
|
||||
"style": {
|
||||
@@ -103,6 +139,24 @@
|
||||
"navigationBarTitleText": "我的主管工作台"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/supervisor/exams/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "岗前测试"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/supervisor/exams/edit",
|
||||
"style": {
|
||||
"navigationBarTitleText": "考试配置"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/supervisor/exams/results",
|
||||
"style": {
|
||||
"navigationBarTitleText": "考试结果"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/candidate/index/index",
|
||||
"style": {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
</view>
|
||||
|
||||
<view class="position-head">
|
||||
<text class="page-title">确认岗位</text>
|
||||
<text class="page-title">同步岗位</text>
|
||||
<text class="page-subtitle">{{ phone }}</text>
|
||||
</view>
|
||||
|
||||
@@ -16,27 +16,16 @@
|
||||
</view>
|
||||
|
||||
<view class="panel">
|
||||
<text class="panel-title">选择当前岗位</text>
|
||||
<view class="position-grid">
|
||||
<button
|
||||
v-for="item in positionCards"
|
||||
:key="item.value"
|
||||
class="position-card"
|
||||
:class="{ active: item.value === selectedPosition }"
|
||||
:disabled="saving"
|
||||
@click="selectPosition(item.value)"
|
||||
>
|
||||
<view class="position-copy">
|
||||
<text class="position-name">{{ item.value }}</text>
|
||||
<text class="position-title">{{ item.heroTitle }}</text>
|
||||
</view>
|
||||
<view class="position-radio" :class="{ checked: item.value === selectedPosition }" />
|
||||
</button>
|
||||
<text class="panel-title">岗位由人力系统统一维护</text>
|
||||
<text class="position-help">员工端不支持手动切换岗位。系统会根据登录手机号匹配最新在职组织身份。</text>
|
||||
<view v-if="selectedPosition" class="position-result">
|
||||
<text class="position-name">{{ selectedPosition }}</text>
|
||||
<text class="position-title">已匹配正式岗位</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<button class="confirm-button" :disabled="!selectedPosition || saving" @click="confirmPosition">
|
||||
{{ saving ? '保存中…' : '确认岗位' }}
|
||||
<button class="confirm-button" :disabled="saving" @click="autoFillFromOrg">
|
||||
{{ saving ? '同步中…' : '重新同步岗位' }}
|
||||
</button>
|
||||
</view>
|
||||
</template>
|
||||
@@ -46,11 +35,8 @@ import { computed, ref } from 'vue';
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app';
|
||||
import {
|
||||
getAuth,
|
||||
getPositionProfile,
|
||||
isLoggedIn,
|
||||
rememberLoginRedirect,
|
||||
saveSelectedPosition,
|
||||
userPositionOptions,
|
||||
type UserPosition
|
||||
} from '@/services/auth';
|
||||
import { goToMobileTarget, hydratePositionFromOrg } from '@/services/position';
|
||||
@@ -61,38 +47,31 @@ const selectedPosition = ref<UserPosition | ''>('');
|
||||
const saving = ref(false);
|
||||
const message = ref('');
|
||||
const phone = computed(() => getAuth().phone || '未登录');
|
||||
const positionCards = userPositionOptions.map((item) => getPositionProfile(item));
|
||||
|
||||
const finish = () => {
|
||||
goToMobileTarget(redirect.value || '/pages/user/today/index');
|
||||
};
|
||||
|
||||
const selectPosition = (position: UserPosition) => {
|
||||
if (saving.value) return;
|
||||
selectedPosition.value = position;
|
||||
};
|
||||
|
||||
const confirmPosition = () => {
|
||||
if (!selectedPosition.value || saving.value) return;
|
||||
saving.value = true;
|
||||
const saved = saveSelectedPosition(selectedPosition.value);
|
||||
selectedPosition.value = saved;
|
||||
finish();
|
||||
};
|
||||
|
||||
const autoFillFromOrg = async () => {
|
||||
if (saving.value) return;
|
||||
if (!isLoggedIn()) {
|
||||
rememberLoginRedirect(redirect.value);
|
||||
uni.redirectTo({ url: '/pages/auth/login/index' });
|
||||
return;
|
||||
}
|
||||
const officialPosition = await hydratePositionFromOrg();
|
||||
if (officialPosition) {
|
||||
selectedPosition.value = officialPosition;
|
||||
finish();
|
||||
return;
|
||||
saving.value = true;
|
||||
message.value = '';
|
||||
try {
|
||||
const officialPosition = await hydratePositionFromOrg();
|
||||
if (officialPosition) {
|
||||
selectedPosition.value = officialPosition;
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
message.value = '人力系统中未匹配到当前手机号的在职岗位,请联系 HR 核对手机号、在职状态和岗位信息。';
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
message.value = '未从组织数据匹配到岗位,可能是组织手机号未开放或已脱敏,请手动确认当前岗位';
|
||||
};
|
||||
|
||||
onLoad((query) => {
|
||||
@@ -186,37 +165,20 @@ onShow(() => {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.position-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
.position-help {
|
||||
display: block;
|
||||
margin-top: 12px;
|
||||
color: #7d8794;
|
||||
font-size: 14px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.position-result {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.position-card {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
min-height: 112px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 16px 14px;
|
||||
border: 1px solid #e0e4e9;
|
||||
border-radius: 16px;
|
||||
background: #ffffff;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.position-card.active {
|
||||
border-color: #e70014;
|
||||
background: #fffafa;
|
||||
}
|
||||
|
||||
.position-copy {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
padding: 16px;
|
||||
border: 1px solid #f2b9bf;
|
||||
border-radius: 14px;
|
||||
background: #fff7f8;
|
||||
}
|
||||
|
||||
.position-name {
|
||||
@@ -231,21 +193,6 @@ onShow(() => {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.position-radio {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
flex: none;
|
||||
border: 2px solid #b8bec7;
|
||||
border-radius: 50%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.position-radio.checked {
|
||||
border-color: #e70014;
|
||||
box-shadow: inset 0 0 0 5px #ffffff;
|
||||
background: #e70014;
|
||||
}
|
||||
|
||||
.confirm-button {
|
||||
width: 100%;
|
||||
min-height: 56px;
|
||||
|
||||
@@ -39,7 +39,16 @@
|
||||
class="question-block"
|
||||
>
|
||||
<text class="question-meta">第 {{ item.seq }} 题 · {{ item.scenario }}</text>
|
||||
<text class="question-title">{{ item.questionText }}</text>
|
||||
<view class="question-title-row">
|
||||
<text class="question-title">{{ item.questionText }}</text>
|
||||
<button
|
||||
class="text-button question-voice-button"
|
||||
:disabled="speechState.status === 'loading' && speechState.key !== `question-${item.questionId}`"
|
||||
@click="speakQuestion(item)"
|
||||
>
|
||||
{{ speechButtonLabel(`question-${item.questionId}`) }}
|
||||
</button>
|
||||
</view>
|
||||
<text class="question-point">考察点:{{ item.point }}</text>
|
||||
<textarea
|
||||
v-model="interviewAnswers[item.questionId]"
|
||||
@@ -84,6 +93,13 @@
|
||||
<text class="result-score">{{ interviewResult.totalScore }}分</text>
|
||||
</view>
|
||||
<text class="result-desc">{{ interviewResult.aiSummary }}</text>
|
||||
<button
|
||||
class="secondary-button result-voice-button"
|
||||
:disabled="speechState.status === 'loading' && speechState.key !== 'interview-result'"
|
||||
@click="speechPlayback.toggle('interview-result', interviewResultSpeech())"
|
||||
>
|
||||
{{ speechButtonLabel('interview-result') }}
|
||||
</button>
|
||||
<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">
|
||||
@@ -120,8 +136,14 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { onShow } from '@dcloudio/uni-app';
|
||||
import type { AsrResponse, InterviewFinishResponse, InterviewQuestion, InterviewRecord } from '@/types/api';
|
||||
import { onHide, onShow, onUnload } from '@dcloudio/uni-app';
|
||||
import type {
|
||||
AsrResponse,
|
||||
InterviewFinishResponse,
|
||||
InterviewQuestion,
|
||||
InterviewRecord,
|
||||
SpeechPlaybackStatus
|
||||
} from '@/types/api';
|
||||
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||||
import {
|
||||
finishCandidateInterview,
|
||||
@@ -131,7 +153,12 @@ import {
|
||||
} from '@/services/candidate';
|
||||
import { candidateRoutes } from '@/services/candidate-view';
|
||||
import { resetPageScroll } from '@/services/navigation';
|
||||
import { chooseSpeechAudio, transcribeSpeechBlob, transcribeSpeechFile } from '@/services/speech';
|
||||
import {
|
||||
chooseSpeechAudio,
|
||||
createSpeechPlaybackController,
|
||||
transcribeSpeechBlob,
|
||||
transcribeSpeechFile
|
||||
} from '@/services/speech';
|
||||
|
||||
const currentPath = '/pages/candidate/interview/index';
|
||||
const records = ref<InterviewRecord[]>([]);
|
||||
@@ -146,8 +173,20 @@ const loggedIn = ref(false);
|
||||
const message = ref('');
|
||||
const transcribingQuestionId = ref('');
|
||||
const recordingQuestionId = ref('');
|
||||
const recordingQuestionStarting = ref(false);
|
||||
const mediaRecorder = ref<MediaRecorder | null>(null);
|
||||
const mediaStream = ref<MediaStream | null>(null);
|
||||
let questionCaptureGeneration = 0;
|
||||
let questionTranscriptionGeneration = 0;
|
||||
let abortQuestionTranscription: (() => void) | null = null;
|
||||
const speechState = ref<{ key: string; status: SpeechPlaybackStatus }>({ key: '', status: 'idle' });
|
||||
const speechPlayback = createSpeechPlaybackController(
|
||||
(state) => { speechState.value = state; },
|
||||
(text) => {
|
||||
message.value = text;
|
||||
uni.showToast({ title: text, icon: 'none' });
|
||||
}
|
||||
);
|
||||
|
||||
const interviewBusy = computed(() => interviewStatus.value === 'starting' || interviewStatus.value === 'submitting');
|
||||
const answeredCount = computed(() => interviewQuestions.value.filter((item) =>
|
||||
@@ -158,6 +197,7 @@ const refreshAuthState = () => {
|
||||
};
|
||||
|
||||
const clearDraft = () => {
|
||||
speechPlayback.stop();
|
||||
cancelQuestionRecording();
|
||||
interviewQuestions.value = [];
|
||||
interviewAnswers.value = {};
|
||||
@@ -207,7 +247,9 @@ const startInterview = async () => {
|
||||
goLogin();
|
||||
return;
|
||||
}
|
||||
cancelQuestionRecording();
|
||||
interviewStatus.value = 'starting';
|
||||
speechPlayback.stop();
|
||||
interviewQuestions.value = [];
|
||||
interviewAnswers.value = {};
|
||||
interviewAudioOssIds.value = {};
|
||||
@@ -228,12 +270,46 @@ const startInterview = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const speechButtonLabel = (key: string) => {
|
||||
if (speechState.value.key !== key) return '语音播报';
|
||||
if (speechState.value.status === 'loading') return '生成中';
|
||||
if (speechState.value.status === 'playing') return '停止播报';
|
||||
return '语音播报';
|
||||
};
|
||||
|
||||
const speakQuestion = (item: InterviewQuestion) => {
|
||||
void speechPlayback.toggle(
|
||||
`question-${item.questionId}`,
|
||||
[`第${item.seq}题`, item.questionText, item.point ? `考察点,${item.point}` : ''].filter(Boolean).join('。')
|
||||
);
|
||||
};
|
||||
|
||||
const interviewResultSpeech = () => {
|
||||
if (!interviewResult.value) return '';
|
||||
return [
|
||||
`本次面试${interviewResult.value.totalScore}分`,
|
||||
interviewResult.value.result,
|
||||
interviewResult.value.aiSummary,
|
||||
...(interviewResult.value.dimensions || []).map((item) => `${item.label}${item.value}分,${item.note}`)
|
||||
].filter(Boolean).join('。');
|
||||
};
|
||||
|
||||
const stopQuestionMediaTracks = () => {
|
||||
mediaStream.value?.getTracks().forEach((track) => track.stop());
|
||||
mediaStream.value = null;
|
||||
};
|
||||
|
||||
const cancelQuestionTranscription = () => {
|
||||
questionTranscriptionGeneration += 1;
|
||||
abortQuestionTranscription?.();
|
||||
abortQuestionTranscription = null;
|
||||
transcribingQuestionId.value = '';
|
||||
};
|
||||
|
||||
const cancelQuestionRecording = () => {
|
||||
questionCaptureGeneration += 1;
|
||||
cancelQuestionTranscription();
|
||||
recordingQuestionStarting.value = false;
|
||||
const recorder = mediaRecorder.value;
|
||||
if (recorder && recorder.state !== 'inactive') {
|
||||
recorder.ondataavailable = null;
|
||||
@@ -259,41 +335,67 @@ const applyQuestionAsrResult = (questionId: string, result: AsrResponse) => {
|
||||
|
||||
const transcribeQuestionFile = async (questionId: string) => {
|
||||
if (interviewBusy.value || transcribingQuestionId.value) return;
|
||||
const generation = ++questionTranscriptionGeneration;
|
||||
transcribingQuestionId.value = questionId;
|
||||
message.value = '语音转写中';
|
||||
try {
|
||||
applyQuestionAsrResult(questionId, await transcribeSpeechFile(await chooseSpeechAudio()));
|
||||
const file = await chooseSpeechAudio();
|
||||
if (generation !== questionTranscriptionGeneration) return;
|
||||
const result = await transcribeSpeechFile(file, (abort) => {
|
||||
if (generation === questionTranscriptionGeneration) abortQuestionTranscription = abort;
|
||||
});
|
||||
if (generation !== questionTranscriptionGeneration) return;
|
||||
applyQuestionAsrResult(questionId, result);
|
||||
} catch (error) {
|
||||
if (generation !== questionTranscriptionGeneration) return;
|
||||
handleRequestError(error, '语音转写失败');
|
||||
} finally {
|
||||
transcribingQuestionId.value = '';
|
||||
if (generation === questionTranscriptionGeneration) {
|
||||
abortQuestionTranscription = null;
|
||||
transcribingQuestionId.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const transcribeQuestionBlob = async (questionId: string, blob: Blob) => {
|
||||
if (transcribingQuestionId.value) return;
|
||||
const generation = ++questionTranscriptionGeneration;
|
||||
transcribingQuestionId.value = questionId;
|
||||
message.value = '语音转写中';
|
||||
try {
|
||||
applyQuestionAsrResult(questionId, await transcribeSpeechBlob(blob));
|
||||
const result = await transcribeSpeechBlob(blob, 'candidate-answer.webm', (abort) => {
|
||||
if (generation === questionTranscriptionGeneration) abortQuestionTranscription = abort;
|
||||
});
|
||||
if (generation !== questionTranscriptionGeneration) return;
|
||||
applyQuestionAsrResult(questionId, result);
|
||||
} catch (error) {
|
||||
if (generation !== questionTranscriptionGeneration) return;
|
||||
handleRequestError(error, '语音转写失败');
|
||||
} finally {
|
||||
transcribingQuestionId.value = '';
|
||||
if (generation === questionTranscriptionGeneration) {
|
||||
abortQuestionTranscription = null;
|
||||
transcribingQuestionId.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const startQuestionRecording = async (questionId: string) => {
|
||||
if (interviewBusy.value || transcribingQuestionId.value) return;
|
||||
if (interviewBusy.value || recordingQuestionStarting.value || recordingQuestionId.value || transcribingQuestionId.value) return;
|
||||
if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
|
||||
await transcribeQuestionFile(questionId);
|
||||
return;
|
||||
}
|
||||
const generation = ++questionCaptureGeneration;
|
||||
recordingQuestionStarting.value = true;
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
if (generation !== questionCaptureGeneration) {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
return;
|
||||
}
|
||||
mediaStream.value = stream;
|
||||
const recorder = new MediaRecorder(stream);
|
||||
const chunks: Blob[] = [];
|
||||
mediaStream.value = stream;
|
||||
mediaRecorder.value = recorder;
|
||||
recordingQuestionId.value = questionId;
|
||||
recorder.ondataavailable = (event) => {
|
||||
@@ -314,8 +416,10 @@ const startQuestionRecording = async (questionId: string) => {
|
||||
message.value = '正在录音';
|
||||
} catch {
|
||||
recordingQuestionId.value = '';
|
||||
cancelQuestionRecording();
|
||||
await transcribeQuestionFile(questionId);
|
||||
stopQuestionMediaTracks();
|
||||
if (generation === questionCaptureGeneration) await transcribeQuestionFile(questionId);
|
||||
} finally {
|
||||
if (generation === questionCaptureGeneration) recordingQuestionStarting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -327,6 +431,7 @@ const stopQuestionRecording = () => {
|
||||
};
|
||||
|
||||
const toggleQuestionRecording = (questionId: string) => {
|
||||
if (recordingQuestionStarting.value) return;
|
||||
if (recordingQuestionId.value === questionId) {
|
||||
stopQuestionRecording();
|
||||
} else if (!recordingQuestionId.value) {
|
||||
@@ -401,6 +506,16 @@ onShow(() => {
|
||||
}
|
||||
void loadRecords();
|
||||
});
|
||||
|
||||
onHide(() => {
|
||||
speechPlayback.stop();
|
||||
cancelQuestionRecording();
|
||||
});
|
||||
|
||||
onUnload(() => {
|
||||
speechPlayback.destroy();
|
||||
cancelQuestionRecording();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -426,6 +541,29 @@ onShow(() => {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.question-title-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.question-title-row .question-title {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.question-voice-button {
|
||||
flex: none;
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.result-voice-button {
|
||||
width: 100%;
|
||||
margin: 12px 0 0;
|
||||
}
|
||||
|
||||
.block {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
|
||||
@@ -276,7 +276,7 @@ onShow(() => {
|
||||
border-radius: 50%;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
|
||||
@@ -29,20 +29,44 @@
|
||||
|
||||
<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="员工姓名(可选)"
|
||||
/>
|
||||
<view class="field-heading">
|
||||
<text class="field-label">训练对象</text>
|
||||
<view v-if="teamMembers.length" class="member-actions">
|
||||
<button class="text-button" :disabled="assigning" @click="selectAllMembers">{{ selectAllLabel }}</button>
|
||||
<button class="text-button" :disabled="assigning || !selectedMemberIds.length" @click="clearSelectedMembers">清空</button>
|
||||
</view>
|
||||
</view>
|
||||
<scroll-view v-if="teamMembers.length" class="member-list" scroll-y>
|
||||
<button
|
||||
v-for="member in teamMembers"
|
||||
:key="member.extPartyId"
|
||||
class="member-option"
|
||||
:class="{ selected: isMemberSelected(member.extPartyId) }"
|
||||
:aria-pressed="isMemberSelected(member.extPartyId)"
|
||||
:disabled="assigning || historyLoading"
|
||||
@click="toggleTeamMember(member.extPartyId)"
|
||||
>
|
||||
<text class="member-check" aria-hidden="true">{{ isMemberSelected(member.extPartyId) ? '✓' : '' }}</text>
|
||||
<text class="member-name">{{ member.name || '未命名员工' }}</text>
|
||||
</button>
|
||||
</scroll-view>
|
||||
<text v-else class="muted block">当前项目暂无可指派的在职员工,请先同步人力系统组织数据。</text>
|
||||
<text v-if="selectedMemberIds.length" class="selected-summary">已选择 {{ selectedMemberIds.length }} 人:{{ selectedMemberNames }}</text>
|
||||
<text v-if="teamMembers.length > maxAssignmentTargets" class="selection-limit">单次最多派发 {{ maxAssignmentTargets }} 人,超出人员请分批派发。</text>
|
||||
|
||||
<text class="field-label section-label">岗位内容</text>
|
||||
<view class="scenario-row position-row">
|
||||
<button
|
||||
v-for="position in assignmentPositions"
|
||||
:key="position"
|
||||
class="chip scenario-chip"
|
||||
:class="{ active: assignmentPosition === position }"
|
||||
:aria-pressed="assignmentPosition === position"
|
||||
:disabled="assigning || scenarioLoading"
|
||||
@click="selectPosition(position)"
|
||||
>{{ position }}</button>
|
||||
</view>
|
||||
<text class="position-note">场景、SOP 与题目按岗位版本加载,不跨岗位混用。</text>
|
||||
|
||||
<text class="field-label section-label">训练场景</text>
|
||||
<view v-if="scenarioOptions.length" class="scenario-row">
|
||||
@@ -51,26 +75,64 @@
|
||||
:key="item.id"
|
||||
class="chip scenario-chip"
|
||||
:class="{ active: assignmentScenarioId === item.id }"
|
||||
:aria-pressed="assignmentScenarioId === item.id"
|
||||
:disabled="assigning"
|
||||
@click="assignmentScenarioId = item.id"
|
||||
@click="selectScenario(item.id)"
|
||||
>
|
||||
{{ item.name }}
|
||||
</button>
|
||||
</view>
|
||||
<text v-else class="muted block">暂无可派发的已启用场景</text>
|
||||
|
||||
<text class="field-label section-label">指派原因</text>
|
||||
<text class="field-label section-label">截止日期</text>
|
||||
<view class="due-date-row">
|
||||
<picker mode="date" :value="dueDate" :start="todayDate" :disabled="assigning" @change="selectDueDate">
|
||||
<view class="input due-date-picker">{{ dueDate }}</view>
|
||||
</picker>
|
||||
<view class="quick-date-row">
|
||||
<button
|
||||
v-for="option in dueDateOptions"
|
||||
:key="option.days"
|
||||
class="chip quick-date-chip"
|
||||
:class="{ active: dueDate === dateAfterDays(option.days) }"
|
||||
:aria-pressed="dueDate === dateAfterDays(option.days)"
|
||||
:disabled="assigning"
|
||||
@click="setDueDate(option.days)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<text class="field-label section-label">指派说明(选填)</text>
|
||||
<textarea
|
||||
v-model="assignmentReason"
|
||||
class="textarea reason-input"
|
||||
maxlength="180"
|
||||
placeholder="填写本次专项训练原因"
|
||||
placeholder="例如:针对近期投诉沟通进行巩固"
|
||||
@input="touchDraft"
|
||||
/>
|
||||
<button class="primary-button submit-button" :disabled="assigning || historyLoading || !scenarioOptions.length" @click="assignPractice">
|
||||
{{ assigning ? '指派中' : '确认指派' }}
|
||||
<button class="primary-button submit-button" :disabled="assigning || historyLoading || !scenarioOptions.length || !selectedMemberIds.length" @click="assignPractice">
|
||||
{{ assigning ? '指派中' : `确认派发(${selectedMemberIds.length}人)` }}
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view v-if="batchResultItems.length" class="panel batch-result-panel">
|
||||
<view class="section-head">
|
||||
<text class="panel-title">本次派发结果</text>
|
||||
<text class="muted">{{ batchResultSummary }}</text>
|
||||
</view>
|
||||
<view class="batch-result-list">
|
||||
<view v-for="item in batchResultItems" :key="`${item.extPartyId}-${item.status}`" class="batch-result-row">
|
||||
<view>
|
||||
<text class="batch-result-name">{{ item.traineeName || '未命名员工' }}</text>
|
||||
<text class="batch-result-message">{{ item.message }}</text>
|
||||
</view>
|
||||
<text class="batch-result-status" :class="item.status.toLowerCase()">{{ batchStatusLabel(item.status) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="panel history-panel">
|
||||
<view class="section-head">
|
||||
<text class="panel-title">最近专项</text>
|
||||
@@ -120,10 +182,16 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref } from 'vue';
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app';
|
||||
import type { PracticeAlert, PracticeAssignment } from '@/types/api';
|
||||
import { clearAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||||
import type {
|
||||
PracticeAlert,
|
||||
PracticeAssignment,
|
||||
PracticeAssignmentBatchItemResult,
|
||||
PracticeAssignmentBatchItemStatus,
|
||||
PracticeTeamMember
|
||||
} from '@/types/api';
|
||||
import { clearAuth, getAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||||
import { resetPageScroll } from '@/services/navigation';
|
||||
import { createPracticeAssignment, getPracticeAlerts, getPracticeScenarios, getPracticeTeamSnapshot } from '@/services/practice';
|
||||
import { createPracticeAssignmentsBatch, getPracticeAlerts, getPracticeScenarios, getPracticeTeamSnapshot } from '@/services/practice';
|
||||
import SupervisorTabBar from '@/components/SupervisorTabBar.vue';
|
||||
import {
|
||||
createAsyncGenerationGuard,
|
||||
@@ -136,12 +204,31 @@ import {
|
||||
} from '@/services/supervisor-view';
|
||||
|
||||
const basePath = '/pages/supervisor/assign/index';
|
||||
const targetExtPartyId = ref('');
|
||||
const targetName = ref('');
|
||||
const maxAssignmentTargets = 100;
|
||||
const pendingBatchStoragePrefix = 'aihr_supervisor_assignment_pending_v1';
|
||||
const pendingBatchTtlMs = 24 * 60 * 60 * 1000;
|
||||
interface PendingBatchDraft {
|
||||
requestId: string;
|
||||
fingerprint: string;
|
||||
selectedMemberIds: string[];
|
||||
scenarioId: string;
|
||||
reason: string;
|
||||
dueDate: string;
|
||||
createdAt: number;
|
||||
}
|
||||
const selectedMemberIds = ref<string[]>([]);
|
||||
const assignmentScenarioId = ref<SupervisorScenarioId>('fee-parking');
|
||||
const scenarioOptions = ref<SupervisorScenarioOption[]>([...supervisorScenarioOptions]);
|
||||
const assignmentPositions = ['生活顾问', '客服', '保安', '保洁', '保修'] as const;
|
||||
type AssignmentPosition = typeof assignmentPositions[number];
|
||||
const assignmentPosition = ref<AssignmentPosition>('生活顾问');
|
||||
const scenarioLoading = ref(false);
|
||||
const assignmentReason = ref('');
|
||||
const dueDate = ref('');
|
||||
const pendingRequestId = ref('');
|
||||
const batchResultItems = ref<PracticeAssignmentBatchItemResult[]>([]);
|
||||
const assignments = ref<PracticeAssignment[]>([]);
|
||||
const teamMembers = ref<PracticeTeamMember[]>([]);
|
||||
const alerts = ref<PracticeAlert[]>([]);
|
||||
const loggedIn = ref(false);
|
||||
const accessDenied = ref(false);
|
||||
@@ -149,13 +236,46 @@ const assigning = ref(false);
|
||||
const historyLoading = ref(false);
|
||||
const message = ref('');
|
||||
const historyRequests = createAsyncGenerationGuard();
|
||||
const loginRedirectPath = ref(basePath);
|
||||
|
||||
const selectedTargetLabel = computed(() => {
|
||||
if (!loggedIn.value) return '选择员工并配置训练任务';
|
||||
const target = targetExtPartyId.value.trim();
|
||||
if (!target) return '选择员工并配置训练任务';
|
||||
return targetName.value.trim() ? `${targetName.value.trim()} · ${target}` : target;
|
||||
if (!selectedMemberIds.value.length) return '选择员工并配置训练任务';
|
||||
return `已选择 ${selectedMemberIds.value.length} 人`;
|
||||
});
|
||||
const selectedMemberNames = computed(() => {
|
||||
const names = teamMembers.value
|
||||
.filter((item) => selectedMemberIds.value.includes(item.extPartyId))
|
||||
.map((item) => item.name || '未命名员工');
|
||||
const preview = names.slice(0, 3).join('、');
|
||||
return names.length > 3 ? `${preview} 等 ${names.length} 人` : preview;
|
||||
});
|
||||
const selectAllLabel = computed(() => teamMembers.value.length > maxAssignmentTargets ? `选前${maxAssignmentTargets}人` : '全选');
|
||||
const batchResultSummary = computed(() => {
|
||||
const created = batchResultItems.value.filter((item) => item.status === 'CREATED').length;
|
||||
const duplicate = batchResultItems.value.filter((item) => item.status === 'DUPLICATE').length;
|
||||
const failed = batchResultItems.value.filter((item) => item.status === 'FAILED').length;
|
||||
return `成功 ${created + duplicate} · 失败 ${failed}`;
|
||||
});
|
||||
const dueDateOptions = [
|
||||
{ label: '当天', days: 0 },
|
||||
{ label: '明天', days: 1 },
|
||||
{ label: '3天内', days: 3 },
|
||||
{ label: '7天内', days: 7 }
|
||||
];
|
||||
const formatDate = (date: Date) => {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
const dateAfterDays = (days: number) => {
|
||||
const date = new Date();
|
||||
date.setHours(12, 0, 0, 0);
|
||||
date.setDate(date.getDate() + days);
|
||||
return formatDate(date);
|
||||
};
|
||||
const todayDate = dateAfterDays(0);
|
||||
const recentAssignments = computed(() => manualPracticeAssignments(assignments.value).slice(0, 10));
|
||||
const progressCounts = computed(() => {
|
||||
const items = recentAssignments.value;
|
||||
@@ -176,6 +296,86 @@ const queryText = (value: unknown) => {
|
||||
}
|
||||
};
|
||||
|
||||
const assignmentDeepLink = (extPartyId: string, reason: string, scenarioId: string) => {
|
||||
const parameters = [
|
||||
['extPartyId', extPartyId],
|
||||
['reason', reason],
|
||||
['scenarioId', scenarioId]
|
||||
]
|
||||
.filter(([, value]) => Boolean(value))
|
||||
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`);
|
||||
return parameters.length ? `${basePath}?${parameters.join('&')}` : basePath;
|
||||
};
|
||||
|
||||
const pendingBatchStorageKey = () => `${pendingBatchStoragePrefix}:${getAuth().phone || 'anonymous'}`;
|
||||
|
||||
const clearPendingBatchDraft = () => {
|
||||
pendingRequestId.value = '';
|
||||
uni.removeStorageSync(pendingBatchStorageKey());
|
||||
};
|
||||
|
||||
const isPendingBatchDraft = (value: unknown): value is PendingBatchDraft => {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
const draft = value as Record<string, unknown>;
|
||||
return typeof draft.requestId === 'string'
|
||||
&& typeof draft.fingerprint === 'string'
|
||||
&& Array.isArray(draft.selectedMemberIds)
|
||||
&& draft.selectedMemberIds.every((item) => typeof item === 'string' && Boolean(item.trim()))
|
||||
&& typeof draft.scenarioId === 'string'
|
||||
&& typeof draft.reason === 'string'
|
||||
&& typeof draft.dueDate === 'string'
|
||||
&& typeof draft.createdAt === 'number'
|
||||
&& Number.isFinite(draft.createdAt);
|
||||
};
|
||||
|
||||
const readPendingBatchDraft = (): PendingBatchDraft | null => {
|
||||
const stored = uni.getStorageSync(pendingBatchStorageKey()) as unknown;
|
||||
try {
|
||||
const parsed: unknown = typeof stored === 'string' ? JSON.parse(stored) : stored;
|
||||
if (!isPendingBatchDraft(parsed) || !parsed.requestId || !parsed.fingerprint
|
||||
|| parsed.selectedMemberIds.length > maxAssignmentTargets
|
||||
|| !parsed.createdAt || Date.now() - parsed.createdAt > pendingBatchTtlMs) {
|
||||
clearPendingBatchDraft();
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
clearPendingBatchDraft();
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const draftFingerprint = (memberIds = selectedMemberIds.value) => JSON.stringify({
|
||||
memberIds: [...memberIds].sort(),
|
||||
scenarioId: assignmentScenarioId.value,
|
||||
reason: assignmentReason.value.trim(),
|
||||
dueDate: dueDate.value
|
||||
});
|
||||
|
||||
const persistPendingBatchDraft = (requestId: string, fingerprint: string) => {
|
||||
const draft: PendingBatchDraft = {
|
||||
requestId,
|
||||
fingerprint,
|
||||
selectedMemberIds: [...selectedMemberIds.value],
|
||||
scenarioId: assignmentScenarioId.value,
|
||||
reason: assignmentReason.value,
|
||||
dueDate: dueDate.value,
|
||||
createdAt: Date.now()
|
||||
};
|
||||
uni.setStorageSync(pendingBatchStorageKey(), draft);
|
||||
};
|
||||
|
||||
const restorePendingBatchDraft = () => {
|
||||
const draft = readPendingBatchDraft();
|
||||
if (!draft) return false;
|
||||
pendingRequestId.value = draft.requestId;
|
||||
selectedMemberIds.value = draft.selectedMemberIds.slice(0, maxAssignmentTargets);
|
||||
assignmentScenarioId.value = draft.scenarioId;
|
||||
assignmentReason.value = draft.reason;
|
||||
dueDate.value = draft.dueDate;
|
||||
return true;
|
||||
};
|
||||
|
||||
const isScenarioId = (value: string): value is SupervisorScenarioId =>
|
||||
scenarioOptions.value.some((item) => item.id === value);
|
||||
|
||||
@@ -184,15 +384,18 @@ const refreshAuthState = () => {
|
||||
};
|
||||
|
||||
const resetDraft = () => {
|
||||
targetExtPartyId.value = '';
|
||||
targetName.value = '';
|
||||
assignmentScenarioId.value = 'fee-parking';
|
||||
selectedMemberIds.value = [];
|
||||
assignmentScenarioId.value = scenarioOptions.value[0]?.id || 'fee-parking';
|
||||
assignmentReason.value = '';
|
||||
dueDate.value = dateAfterDays(1);
|
||||
clearPendingBatchDraft();
|
||||
};
|
||||
|
||||
const clearPrivateState = () => {
|
||||
resetDraft();
|
||||
assignments.value = [];
|
||||
teamMembers.value = [];
|
||||
batchResultItems.value = [];
|
||||
alerts.value = [];
|
||||
historyLoading.value = false;
|
||||
historyRequests.invalidate();
|
||||
@@ -229,8 +432,11 @@ const loadAssignments = async (force = false) => {
|
||||
const [snapshot, nextAlerts] = await Promise.all([getPracticeTeamSnapshot(), getPracticeAlerts()]);
|
||||
if (historyRequests.isCurrent(generation)) {
|
||||
accessDenied.value = false;
|
||||
teamMembers.value = snapshot.members || [];
|
||||
assignments.value = snapshot.assignments;
|
||||
alerts.value = nextAlerts;
|
||||
const activeIds = new Set(teamMembers.value.map((item) => item.extPartyId));
|
||||
selectedMemberIds.value = selectedMemberIds.value.filter((id) => activeIds.has(id));
|
||||
}
|
||||
} catch (error) {
|
||||
if (historyRequests.isCurrent(generation)) {
|
||||
@@ -244,8 +450,9 @@ const loadAssignments = async (force = false) => {
|
||||
};
|
||||
|
||||
const loadScenarioOptions = async () => {
|
||||
scenarioLoading.value = true;
|
||||
try {
|
||||
const rows = await getPracticeScenarios('生活顾问');
|
||||
const rows = await getPracticeScenarios(assignmentPosition.value);
|
||||
const nextOptions = rows
|
||||
.filter((item) => item.enabled !== false && item.id && item.name)
|
||||
.map((item) => ({ id: item.id, name: item.name }));
|
||||
@@ -259,10 +466,12 @@ const loadScenarioOptions = async () => {
|
||||
scenarioOptions.value = [];
|
||||
assignmentScenarioId.value = '';
|
||||
} catch {
|
||||
scenarioOptions.value = [...supervisorScenarioOptions];
|
||||
scenarioOptions.value = assignmentPosition.value === '生活顾问' ? [...supervisorScenarioOptions] : [];
|
||||
if (!isScenarioId(assignmentScenarioId.value)) {
|
||||
assignmentScenarioId.value = supervisorScenarioOptions[0].id;
|
||||
}
|
||||
} finally {
|
||||
scenarioLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -278,25 +487,47 @@ const loadPage = () => {
|
||||
|
||||
const assignPractice = async () => {
|
||||
if (assigning.value || historyLoading.value) return;
|
||||
const extPartyId = targetExtPartyId.value.trim();
|
||||
if (!extPartyId) {
|
||||
message.value = '请填写员工手机号或外部ID';
|
||||
const selectedMembers = teamMembers.value.filter((item) => selectedMemberIds.value.includes(item.extPartyId));
|
||||
if (!selectedMembers.length) {
|
||||
message.value = '请至少选择一名员工';
|
||||
return;
|
||||
}
|
||||
if (selectedMembers.length > maxAssignmentTargets) {
|
||||
message.value = `单次最多派发${maxAssignmentTargets}名员工,请分批派发`;
|
||||
return;
|
||||
}
|
||||
|
||||
assigning.value = true;
|
||||
message.value = '';
|
||||
try {
|
||||
const scenario = scenarioOptions.value.find((item) => item.id === assignmentScenarioId.value);
|
||||
await createPracticeAssignment({
|
||||
extPartyId,
|
||||
traineeName: targetName.value.trim() || `手机用户${extPartyId.slice(-4)}`,
|
||||
const fingerprint = draftFingerprint(selectedMembers.map((item) => item.extPartyId));
|
||||
const persistedDraft = readPendingBatchDraft();
|
||||
pendingRequestId.value = persistedDraft?.fingerprint === fingerprint
|
||||
? persistedDraft.requestId
|
||||
: createBatchRequestId();
|
||||
persistPendingBatchDraft(pendingRequestId.value, fingerprint);
|
||||
const result = await createPracticeAssignmentsBatch({
|
||||
requestId: pendingRequestId.value,
|
||||
targets: selectedMembers.map((member) => ({
|
||||
extPartyId: member.extPartyId,
|
||||
traineeName: member.name
|
||||
})),
|
||||
scenarioId: assignmentScenarioId.value,
|
||||
scenarioName: scenario?.name,
|
||||
reason: assignmentReason.value.trim() || '主管派发专项训练'
|
||||
reason: assignmentReason.value.trim() || undefined,
|
||||
dueDate: dueDate.value
|
||||
});
|
||||
resetDraft();
|
||||
uni.showToast({ title: '已指派', icon: 'none' });
|
||||
batchResultItems.value = result.results || [];
|
||||
clearPendingBatchDraft();
|
||||
const completedIds = new Set(batchResultItems.value
|
||||
.filter((item) => item.status !== 'FAILED')
|
||||
.map((item) => item.extPartyId));
|
||||
selectedMemberIds.value = selectedMemberIds.value.filter((id) => !completedIds.has(id));
|
||||
if (!result.failed) {
|
||||
resetDraft();
|
||||
uni.showToast({ title: `已派发${result.created + result.duplicate}人`, icon: 'none' });
|
||||
} else {
|
||||
message.value = `${result.created + result.duplicate}人派发成功,${result.failed}人失败,请查看明细`;
|
||||
}
|
||||
await loadAssignments(true);
|
||||
} catch (error) {
|
||||
handleRequestError(error, '指派失败');
|
||||
@@ -305,6 +536,77 @@ const assignPractice = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const createBatchRequestId = () => {
|
||||
const randomPart = Math.random().toString(36).slice(2, 10);
|
||||
return `assign-${Date.now().toString(36)}-${randomPart}`;
|
||||
};
|
||||
|
||||
const touchDraft = () => {
|
||||
clearPendingBatchDraft();
|
||||
batchResultItems.value = [];
|
||||
};
|
||||
|
||||
const isMemberSelected = (extPartyId: string) => selectedMemberIds.value.includes(extPartyId);
|
||||
|
||||
const toggleTeamMember = (extPartyId: string) => {
|
||||
if (isMemberSelected(extPartyId)) {
|
||||
selectedMemberIds.value = selectedMemberIds.value.filter((id) => id !== extPartyId);
|
||||
} else {
|
||||
if (selectedMemberIds.value.length >= maxAssignmentTargets) {
|
||||
message.value = `单次最多选择${maxAssignmentTargets}名员工,请分批派发`;
|
||||
uni.showToast({ title: `单次最多${maxAssignmentTargets}人`, icon: 'none' });
|
||||
return;
|
||||
}
|
||||
selectedMemberIds.value = [...selectedMemberIds.value, extPartyId];
|
||||
}
|
||||
touchDraft();
|
||||
};
|
||||
|
||||
const selectAllMembers = () => {
|
||||
selectedMemberIds.value = teamMembers.value.slice(0, maxAssignmentTargets).map((item) => item.extPartyId);
|
||||
touchDraft();
|
||||
if (teamMembers.value.length > maxAssignmentTargets) {
|
||||
message.value = `已选前${maxAssignmentTargets}人,其余员工请分批派发`;
|
||||
}
|
||||
};
|
||||
|
||||
const clearSelectedMembers = () => {
|
||||
selectedMemberIds.value = [];
|
||||
touchDraft();
|
||||
};
|
||||
|
||||
const setDueDate = (days: number) => {
|
||||
dueDate.value = dateAfterDays(days);
|
||||
touchDraft();
|
||||
};
|
||||
|
||||
const selectScenario = (scenarioId: string) => {
|
||||
assignmentScenarioId.value = scenarioId;
|
||||
touchDraft();
|
||||
};
|
||||
|
||||
const selectPosition = (position: AssignmentPosition) => {
|
||||
if (assignmentPosition.value === position) return;
|
||||
assignmentPosition.value = position;
|
||||
assignmentScenarioId.value = '';
|
||||
scenarioOptions.value = [];
|
||||
touchDraft();
|
||||
void loadScenarioOptions();
|
||||
};
|
||||
|
||||
const selectDueDate = (event: { detail?: { value?: string } }) => {
|
||||
const value = event.detail?.value?.trim();
|
||||
if (!value) return;
|
||||
dueDate.value = value;
|
||||
touchDraft();
|
||||
};
|
||||
|
||||
const batchStatusLabel = (status: PracticeAssignmentBatchItemStatus) => {
|
||||
if (status === 'CREATED') return '成功';
|
||||
if (status === 'DUPLICATE') return '已存在';
|
||||
return '失败';
|
||||
};
|
||||
|
||||
const assignmentSourceLabel = (source: string) => {
|
||||
if (source === 'retry') return '再练';
|
||||
if (source === 'camp') return '训练营';
|
||||
@@ -313,11 +615,13 @@ const assignmentSourceLabel = (source: string) => {
|
||||
|
||||
const assignmentSummary = (item: PracticeAssignment) => {
|
||||
const reason = item.reason?.trim() || '主管派发专项训练';
|
||||
return `${item.createTime} · ${item.status} · ${reason}`;
|
||||
const due = item.dueDate ? `截止 ${item.dueDate}` : '未设置截止日期';
|
||||
const version = item.contentVersion ? `内容 ${item.contentVersion}` : '';
|
||||
return [due, item.status, version, reason].filter(Boolean).join(' · ');
|
||||
};
|
||||
|
||||
const goLogin = () => {
|
||||
rememberLoginRedirect(basePath);
|
||||
rememberLoginRedirect(loginRedirectPath.value);
|
||||
uni.redirectTo({ url: '/pages/auth/login/index' });
|
||||
};
|
||||
|
||||
@@ -327,7 +631,7 @@ const goToEmployee = () => {
|
||||
|
||||
const switchAccount = () => {
|
||||
clearAuth();
|
||||
rememberLoginRedirect(basePath);
|
||||
rememberLoginRedirect(loginRedirectPath.value);
|
||||
uni.redirectTo({ url: '/pages/auth/login/index' });
|
||||
};
|
||||
|
||||
@@ -344,10 +648,23 @@ const goBack = () => {
|
||||
};
|
||||
|
||||
onLoad((query) => {
|
||||
targetExtPartyId.value = queryText(query?.extPartyId);
|
||||
targetName.value = queryText(query?.name);
|
||||
assignmentReason.value = queryText(query?.reason);
|
||||
const requestedExtPartyId = queryText(query?.extPartyId);
|
||||
const requestedReason = queryText(query?.reason);
|
||||
const requestedScenario = queryText(query?.scenarioId);
|
||||
loginRedirectPath.value = assignmentDeepLink(requestedExtPartyId, requestedReason, requestedScenario);
|
||||
const hasExplicitDraft = Boolean(requestedExtPartyId || requestedReason || requestedScenario);
|
||||
const pendingDraft = readPendingBatchDraft();
|
||||
const pendingMatchesDeepLink = Boolean(pendingDraft
|
||||
&& (!requestedExtPartyId || pendingDraft.selectedMemberIds.includes(requestedExtPartyId))
|
||||
&& (!requestedReason || pendingDraft.reason.trim() === requestedReason)
|
||||
&& (!requestedScenario || pendingDraft.scenarioId === requestedScenario));
|
||||
if (pendingDraft && (!hasExplicitDraft || pendingMatchesDeepLink) && restorePendingBatchDraft()) {
|
||||
return;
|
||||
}
|
||||
clearPendingBatchDraft();
|
||||
selectedMemberIds.value = requestedExtPartyId ? [requestedExtPartyId] : [];
|
||||
assignmentReason.value = requestedReason;
|
||||
dueDate.value = dateAfterDays(1);
|
||||
assignmentScenarioId.value = requestedScenario || inferSupervisorScenarioId(assignmentReason.value);
|
||||
});
|
||||
|
||||
@@ -403,6 +720,32 @@ onBeforeUnmount(() => {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.field-heading,
|
||||
.member-actions,
|
||||
.quick-date-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.field-heading {
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.member-actions {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.text-button {
|
||||
min-height: 32px;
|
||||
padding: 0 8px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--employee-primary);
|
||||
font-size: 12px;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.field-input,
|
||||
.reason-input,
|
||||
.scenario-row,
|
||||
@@ -410,16 +753,136 @@ onBeforeUnmount(() => {
|
||||
margin-top: 9px;
|
||||
}
|
||||
|
||||
.field-input + .field-input {
|
||||
.member-list {
|
||||
max-height: 216px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.member-option {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: calc(50% - 5px);
|
||||
min-height: 44px;
|
||||
margin: 0 10px 8px 0;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #dbe4f0;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
color: #0f172a;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.member-option:nth-child(2n) {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.member-option.selected {
|
||||
border-color: var(--employee-primary);
|
||||
background: var(--employee-primary-soft);
|
||||
color: var(--employee-primary);
|
||||
}
|
||||
|
||||
.member-check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-right: 8px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 5px;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.member-option.selected .member-check {
|
||||
border-color: var(--employee-primary);
|
||||
background: var(--employee-primary);
|
||||
}
|
||||
|
||||
.member-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.selected-summary {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.selection-limit {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: #b45309;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.due-date-row {
|
||||
margin-top: 9px;
|
||||
}
|
||||
|
||||
.due-date-picker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #dbe4f0;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
color: #0f172a;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.quick-date-row {
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.quick-date-chip {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0 6px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.quick-date-chip.active {
|
||||
background: var(--employee-primary-soft);
|
||||
color: var(--employee-primary);
|
||||
}
|
||||
|
||||
.scenario-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.position-row {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.position-row .scenario-chip {
|
||||
flex: 1 1 calc(33.333% - 6px);
|
||||
min-width: 88px;
|
||||
}
|
||||
|
||||
.position-note {
|
||||
display: block;
|
||||
margin-top: 7px;
|
||||
color: var(--employee-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.scenario-chip {
|
||||
border-radius: 8px;
|
||||
}
|
||||
@@ -433,6 +896,59 @@ onBeforeUnmount(() => {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.batch-result-panel {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.batch-result-list {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.batch-result-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 11px 0;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.batch-result-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.batch-result-name,
|
||||
.batch-result-message {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.batch-result-name {
|
||||
color: #0f172a;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.batch-result-message {
|
||||
margin-top: 3px;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.batch-result-status {
|
||||
flex: 0 0 auto;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.batch-result-status.created {
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.batch-result-status.failed {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.assignment-list {
|
||||
margin-top: 8px;
|
||||
}
|
||||
@@ -516,7 +1032,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
.progress-metrics label {
|
||||
color: var(--employee-muted);
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.assignment-alert-list {
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
<template>
|
||||
<view class="page exam-edit-page">
|
||||
<view class="subpage-header">
|
||||
<button class="subpage-back-button" aria-label="返回考试列表" :disabled="saving" @click="goBack">←</button>
|
||||
<view class="subpage-heading"><text class="page-title">{{ examId ? '编辑考试' : '新建考试' }}</text><text class="page-subtitle">发布后冻结题目版本,已派发试卷不随草稿变化。</text></view>
|
||||
</view>
|
||||
<text v-if="message" class="status-message">{{ message }}</text>
|
||||
|
||||
<view class="panel form-panel">
|
||||
<label class="field-label">考试名称</label>
|
||||
<input v-model="title" class="input" maxlength="80" placeholder="例如:生活顾问岗前基础测试" />
|
||||
<label class="field-label">适用岗位</label>
|
||||
<picker :range="positions" :value="positionIndex" @change="selectPosition"><view class="input picker-value">{{ positions[positionIndex] }}</view></picker>
|
||||
<label class="field-label">及格分</label>
|
||||
<input v-model.number="passingScore" class="input" type="number" />
|
||||
<label class="field-label">截止日期</label>
|
||||
<picker mode="date" :value="dueDate" :start="today" @change="selectDueDate"><view class="input picker-value">{{ dueDate }}</view></picker>
|
||||
<view class="quick-row"><button v-for="item in quickDates" :key="item.days" class="quick-chip" @click="setDueDate(item.days)">{{ item.label }}</button></view>
|
||||
</view>
|
||||
|
||||
<view class="panel">
|
||||
<view class="section-head"><text class="panel-title">考试对象</text><text class="muted">已选 {{ selectedMemberIds.length }} 人</text></view>
|
||||
<view class="member-actions"><button class="ghost-button" @click="selectAll">全选(最多100人)</button><button class="secondary-button" @click="selectedMemberIds = []">清空</button></view>
|
||||
<scroll-view class="member-list" scroll-y>
|
||||
<button v-for="member in members" :key="member.extPartyId" class="member-row" :class="{ selected: selectedMemberIds.includes(member.extPartyId) }" @click="toggleMember(member.extPartyId)">
|
||||
<text class="member-check">{{ selectedMemberIds.includes(member.extPartyId) ? '✓' : '' }}</text><text>{{ member.name || '未命名员工' }}</text>
|
||||
</button>
|
||||
</scroll-view>
|
||||
<text v-if="!members.length" class="muted">当前项目暂无可派发的在职员工。</text>
|
||||
</view>
|
||||
|
||||
<view class="panel question-editor">
|
||||
<view class="section-head"><text class="panel-title">题目({{ questions.length }})· 共 {{ totalScore }} 分</text><button class="ghost-button compact-button" @click="addQuestion">添加题目</button></view>
|
||||
<text v-if="totalScore !== 100" class="score-warning">发布前题目分值合计必须为 100 分</text>
|
||||
<view v-for="(item, index) in questions" :key="item.id" class="question-block">
|
||||
<view class="question-block-head"><text>第 {{ index + 1 }} 题</text><button class="remove-button" :disabled="questions.length === 1" @click="removeQuestion(index)">删除</button></view>
|
||||
<textarea v-model="item.stem" class="textarea stem-input" maxlength="500" placeholder="输入题干" />
|
||||
<picker :range="formats.map((value) => value.label)" :value="formatIndex(item.format)" @change="selectFormat(index, $event)"><view class="input picker-value">{{ formatLabel(item.format) }}</view></picker>
|
||||
<label class="field-label">本题分值</label>
|
||||
<input v-model.number="item.score" class="input" type="number" />
|
||||
<template v-if="item.format !== 'short'">
|
||||
<input v-for="(_, optionIndex) in item.options" :key="optionIndex" v-model="item.options[optionIndex]" class="input" :placeholder="`选项 ${optionIndex + 1}`" />
|
||||
<input v-model="singleAnswerModels[index]" class="input" placeholder="正确答案(与选项文字一致,多选用|分隔)" @input="syncAnswer(index)" />
|
||||
</template>
|
||||
<textarea v-else v-model="singleAnswerModels[index]" class="textarea compact-textarea" placeholder="参考答案" @input="syncAnswer(index)" />
|
||||
<textarea v-model="item.explanation" class="textarea compact-textarea" placeholder="参考依据 / SOP 要点" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="publish-dock">
|
||||
<button class="secondary-button" :disabled="saving || !valid" @click="save(false)">{{ saving ? '保存中' : '保存草稿' }}</button>
|
||||
<button class="primary-button" :disabled="saving || !valid || !selectedMemberIds.length" @click="save(true)">{{ saving ? '发布中' : `发布给 ${selectedMemberIds.length} 人` }}</button>
|
||||
</view>
|
||||
<SupervisorTabBar active="team" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { onLoad } from '@dcloudio/uni-app';
|
||||
import type { ExamQuestion, PracticeTeamMember, SupervisorExamDraft } from '@/types/api';
|
||||
import { getPracticeTeamSnapshot } from '@/services/practice';
|
||||
import { getSupervisorExam, publishSupervisorExam, saveSupervisorExam, updateSupervisorExam } from '@/services/exam';
|
||||
import SupervisorTabBar from '@/components/SupervisorTabBar.vue';
|
||||
|
||||
const positions = ['生活顾问', '客服', '保安', '保洁', '保修'];
|
||||
const formats = [{ key: 'single', label: '单选题' }, { key: 'multiple', label: '多选题' }, { key: 'judge', label: '判断题' }, { key: 'short', label: '简答题' }];
|
||||
const quickDates = [{ label: '当天', days: 0 }, { label: '明天', days: 1 }, { label: '3天内', days: 3 }, { label: '7天内', days: 7 }];
|
||||
const examId = ref(0); const title = ref(''); const positionIndex = ref(0); const passingScore = ref(80); const dueDate = ref('');
|
||||
const members = ref<PracticeTeamMember[]>([]); const selectedMemberIds = ref<string[]>([]); const questions = ref<ExamQuestion[]>([]); const singleAnswerModels = ref<string[]>([]);
|
||||
const saving = ref(false); const message = ref('');
|
||||
const formatDate = (date: Date) => `${date.getFullYear()}-${String(date.getMonth()+1).padStart(2,'0')}-${String(date.getDate()).padStart(2,'0')}`;
|
||||
const dateAfter = (days: number) => { const date = new Date(); date.setHours(12,0,0,0); date.setDate(date.getDate()+days); return formatDate(date); };
|
||||
const today = dateAfter(0);
|
||||
const requestId = () => `exam-publish:${examId.value || 'new'}:${Date.now()}:${Math.random().toString(36).slice(2,8)}`;
|
||||
const totalScore = computed(() => questions.value.reduce((sum, item) => sum + Number(item.score || 0), 0));
|
||||
const valid = computed(() => title.value.trim().length >= 4
|
||||
&& passingScore.value > 0 && passingScore.value <= 100
|
||||
&& totalScore.value === 100
|
||||
&& questions.value.length > 0
|
||||
&& questions.value.every((item, index) => item.stem.trim()
|
||||
&& Number(item.score) > 0
|
||||
&& singleAnswerModels.value[index]?.trim()
|
||||
&& (item.format === 'short' || item.options.filter((option) => option.trim()).length >= 2)));
|
||||
const goBack = () => uni.navigateBack({ delta: 1 });
|
||||
const selectPosition = (event: { detail: { value: string } }) => { positionIndex.value = Number(event.detail.value || 0); };
|
||||
const selectDueDate = (event: { detail: { value: string } }) => { dueDate.value = event.detail.value; };
|
||||
const setDueDate = (days: number) => { dueDate.value = dateAfter(days); };
|
||||
const formatIndex = (value: string) => Math.max(0, formats.findIndex((item) => item.key === value));
|
||||
const formatLabel = (value: string) => formats.find((item) => item.key === value)?.label || '单选题';
|
||||
const newQuestion = (): ExamQuestion => ({ id: Date.now() + Math.floor(Math.random()*1000), format: 'single', stem: '', options: ['', '', '', ''], score: 100, answer: '', explanation: '' });
|
||||
const rebalanceScores = () => {
|
||||
const count = questions.value.length;
|
||||
if (!count) return;
|
||||
const base = Math.floor(100 / count);
|
||||
questions.value.forEach((item, index) => { item.score = index === count - 1 ? 100 - base * (count - 1) : base; });
|
||||
};
|
||||
const addQuestion = () => { questions.value.push(newQuestion()); singleAnswerModels.value.push(''); rebalanceScores(); };
|
||||
const removeQuestion = (index: number) => { questions.value.splice(index,1); singleAnswerModels.value.splice(index,1); rebalanceScores(); };
|
||||
const selectFormat = (index: number, event: { detail: { value: string } }) => {
|
||||
const format = formats[Number(event.detail.value || 0)]?.key || 'single'; questions.value[index].format = format;
|
||||
questions.value[index].options = format === 'short' ? [] : format === 'judge' ? ['正确','错误'] : (questions.value[index].options.length ? questions.value[index].options : ['','','','']);
|
||||
};
|
||||
const syncAnswer = (index: number) => { questions.value[index].answer = questions.value[index].format === 'multiple' ? singleAnswerModels.value[index].split('|').map((value) => value.trim()).filter(Boolean) : singleAnswerModels.value[index]; };
|
||||
const toggleMember = (id: string) => { selectedMemberIds.value = selectedMemberIds.value.includes(id) ? selectedMemberIds.value.filter((item) => item !== id) : selectedMemberIds.value.length < 100 ? [...selectedMemberIds.value,id] : selectedMemberIds.value; };
|
||||
const selectAll = () => { selectedMemberIds.value = members.value.slice(0,100).map((item) => item.extPartyId); };
|
||||
const draft = (): SupervisorExamDraft => ({
|
||||
title: title.value.trim(),
|
||||
projectCode: '',
|
||||
positionCode: positions[positionIndex.value],
|
||||
passScore: Number(passingScore.value),
|
||||
dueDate: dueDate.value,
|
||||
targets: members.value
|
||||
.filter((item) => selectedMemberIds.value.includes(item.extPartyId))
|
||||
.map((item) => ({ extPartyId: item.extPartyId, traineeName: item.name })),
|
||||
questions: questions.value.map((item, index) => ({
|
||||
...item,
|
||||
answer: item.format === 'multiple'
|
||||
? singleAnswerModels.value[index].split('|').map((value) => value.trim()).filter(Boolean)
|
||||
: singleAnswerModels.value[index]
|
||||
}))
|
||||
});
|
||||
const save = async (publish: boolean) => {
|
||||
if (!valid.value || saving.value) return; saving.value = true; message.value = '';
|
||||
try {
|
||||
const saved = examId.value ? await updateSupervisorExam(examId.value, draft()) : await saveSupervisorExam(draft()); examId.value = saved.id;
|
||||
if (publish) { await publishSupervisorExam(saved.id, requestId()); uni.showToast({ title: '考试已发布', icon: 'none' }); setTimeout(goBack,350); }
|
||||
else message.value = '草稿已保存';
|
||||
} catch (error) { message.value = error instanceof Error ? error.message : '考试保存失败'; } finally { saving.value = false; }
|
||||
};
|
||||
const load = async () => {
|
||||
try {
|
||||
const team = await getPracticeTeamSnapshot(); members.value = team.members || [];
|
||||
if (examId.value) {
|
||||
const existing = await getSupervisorExam(examId.value);
|
||||
title.value = existing.title;
|
||||
positionIndex.value = Math.max(0, positions.indexOf(existing.positionCode || '生活顾问'));
|
||||
passingScore.value = existing.passingScore;
|
||||
dueDate.value = existing.dueDate?.slice(0,10) || dateAfter(1);
|
||||
questions.value = existing.questions;
|
||||
singleAnswerModels.value = existing.questions.map((item) => Array.isArray(item.answer) ? item.answer.join('|') : String(item.answer || ''));
|
||||
selectedMemberIds.value = existing.targets.map((item) => item.extPartyId);
|
||||
} else { dueDate.value = dateAfter(1); addQuestion(); }
|
||||
} catch (error) { message.value = error instanceof Error ? error.message : '考试配置加载失败'; }
|
||||
};
|
||||
onLoad((options) => { examId.value = Number(options?.id || 0); void load(); });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.exam-edit-page { max-width: var(--mobile-shell-max-width); margin: 0 auto; padding-bottom: 190px; }
|
||||
.status-message { display:block; margin-top:12px; color:#a9000d; }
|
||||
.form-panel,.question-editor { display:grid; gap:10px; }
|
||||
.field-label { display:block; margin-top:4px; font-size:13px; font-weight:900; }
|
||||
.picker-value { display:flex; align-items:center; }
|
||||
.two-column { display:grid; grid-template-columns:1fr 1fr; gap:10px; }
|
||||
.quick-row,.member-actions,.section-head,.question-block-head { display:flex; gap:8px; align-items:center; justify-content:space-between; }
|
||||
.quick-chip { min-height:36px; padding:0 12px; border:1px solid var(--line); background:#fff; color:var(--muted); }
|
||||
.member-actions button { flex:1; min-width:0; margin-top:10px; }
|
||||
.member-list { max-height:260px; margin-top:10px; }
|
||||
.member-row { display:flex; width:100%; gap:10px; min-height:46px; align-items:center; padding:8px 10px; border-bottom:1px solid var(--line); background:#fff; color:var(--ink); text-align:left; }
|
||||
.member-row.selected { background:var(--employee-primary-soft); }
|
||||
.member-check { flex:0 0 24px; color:var(--brand); font-weight:900; }
|
||||
.compact-button { min-height:36px; padding:0 10px; font-size:12px; }
|
||||
.question-block { display:grid; gap:9px; padding:14px 0; border-top:1px solid var(--line); }
|
||||
.question-block-head { font-weight:900; }
|
||||
.remove-button { background:transparent; color:var(--brand); font-size:13px; }
|
||||
.stem-input { min-height:96px; }
|
||||
.compact-textarea { min-height:82px; }
|
||||
.score-warning { color:#a86100; font-size:12px; font-weight:800; }
|
||||
.publish-dock { position:fixed; z-index:10; right:0; bottom:calc(76px + env(safe-area-inset-bottom)); left:0; display:grid; grid-template-columns:1fr 1fr; gap:10px; max-width:var(--mobile-shell-max-width); margin:0 auto; padding:12px 16px; border-top:1px solid var(--line); background:rgba(255,255,255,.97); }
|
||||
</style>
|
||||
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<view class="page supervisor-exams-page">
|
||||
<view class="subpage-header">
|
||||
<button class="subpage-back-button" aria-label="返回主管首页" @click="goBack">←</button>
|
||||
<view class="subpage-heading"><text class="page-title">岗前测试</text><text class="page-subtitle">按岗位组卷、选择真实员工并查看完成情况。</text></view>
|
||||
</view>
|
||||
<button class="primary-button create-button" @click="createExam">新建考试</button>
|
||||
<text v-if="message" class="status-message">{{ message }}</text>
|
||||
<view v-if="loading" class="panel">考试列表加载中…</view>
|
||||
<view v-else-if="exams.length" class="exam-list">
|
||||
<view v-for="item in exams" :key="item.id" class="panel exam-card">
|
||||
<view class="exam-head"><text class="panel-title">{{ item.title }}</text><text class="status-chip">{{ item.status }}</text></view>
|
||||
<text class="muted">{{ item.positionCode || '通用岗位' }} · {{ item.questionCount }} 题 · {{ item.targetCount }} 人 · 及格 {{ item.passingScore }} 分</text>
|
||||
<text v-if="item.dueDate" class="muted">截止 {{ item.dueDate }}</text>
|
||||
<view class="exam-actions">
|
||||
<button class="secondary-button" :disabled="item.status !== 'DRAFT'" @click="editExam(item.id)">编辑</button>
|
||||
<button class="ghost-button" @click="viewResults(item.id)">查看结果</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="panel empty">暂无考试,先创建岗位题目并派发。</view>
|
||||
<SupervisorTabBar active="team" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { onShow } from '@dcloudio/uni-app';
|
||||
import type { ExamSummary } from '@/types/api';
|
||||
import { listSupervisorExams } from '@/services/exam';
|
||||
import SupervisorTabBar from '@/components/SupervisorTabBar.vue';
|
||||
const exams = ref<ExamSummary[]>([]); const loading = ref(false); const message = ref('');
|
||||
const goBack = () => uni.redirectTo({ url: '/pages/supervisor/index/index' });
|
||||
const createExam = () => uni.navigateTo({ url: '/pages/supervisor/exams/edit' });
|
||||
const editExam = (id: number) => uni.navigateTo({ url: `/pages/supervisor/exams/edit?id=${id}` });
|
||||
const viewResults = (id: number) => uni.navigateTo({ url: `/pages/supervisor/exams/results?id=${id}` });
|
||||
const load = async () => { loading.value = true; try { exams.value = await listSupervisorExams(); } catch (error) { message.value = error instanceof Error ? error.message : '考试列表读取失败'; } finally { loading.value = false; } };
|
||||
onShow(() => { void load(); });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.supervisor-exams-page { max-width: var(--mobile-shell-max-width); margin: 0 auto; }
|
||||
.create-button { width: 100%; margin-top: 18px; }
|
||||
.status-message { display: block; margin-top: 12px; color: #a9000d; }
|
||||
.exam-list { display: grid; gap: 4px; }
|
||||
.exam-card { display: grid; gap: 9px; }
|
||||
.exam-head, .exam-actions { display: flex; gap: 10px; align-items: center; justify-content: space-between; }
|
||||
.status-chip { padding: 4px 8px; border-radius: 999px; background: #eef3f9; color: var(--knowledge); font-size: 12px; }
|
||||
.exam-actions button { flex: 1; min-width: 0; }
|
||||
.empty { color: var(--muted); }
|
||||
</style>
|
||||
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<view class="page exam-results-page">
|
||||
<view class="subpage-header"><button class="subpage-back-button" aria-label="返回" @click="goBack">←</button><view class="subpage-heading"><text class="page-title">考试结果</text><text class="page-subtitle">按人员查看成绩、未完成与通过状态。</text></view></view>
|
||||
<text v-if="message" class="status-message">{{ message }}</text>
|
||||
<view class="metric-grid">
|
||||
<view class="metric-card"><text>{{ results.length }}</text><label>已派发</label></view>
|
||||
<view class="metric-card"><text>{{ completed }}</text><label>已完成</label></view>
|
||||
<view class="metric-card"><text>{{ passed }}</text><label>已通过</label></view>
|
||||
</view>
|
||||
<view v-if="results.length" class="result-list">
|
||||
<view v-for="item in results" :key="item.attemptId || item.extPartyId" class="panel result-row">
|
||||
<view><text class="result-name">{{ item.traineeName }}</text><text class="muted">{{ item.submittedTime || '尚未提交' }} · {{ item.status === 'PENDING' ? '待完成' : '已提交' }}</text></view>
|
||||
<view class="result-score"><text>{{ item.score == null ? '--' : `${item.score}分` }}</text><label>{{ item.status === 'PENDING' ? '未完成' : item.passed ? '通过' : '未通过' }}</label></view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="panel empty">暂无派发结果</view>
|
||||
<SupervisorTabBar active="team" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'; import { onLoad } from '@dcloudio/uni-app'; import type { ExamResultItem } from '@/types/api'; import { getSupervisorExamResults } from '@/services/exam'; import SupervisorTabBar from '@/components/SupervisorTabBar.vue';
|
||||
const id=ref(0); const results=ref<ExamResultItem[]>([]); const message=ref(''); const completed=computed(()=>results.value.filter((item)=>item.status==='SUBMITTED').length); const passed=computed(()=>results.value.filter((item)=>item.status==='SUBMITTED'&&item.passed===true).length);
|
||||
const goBack=()=>uni.navigateBack({delta:1}); const load=async()=>{try{results.value=await getSupervisorExamResults(id.value);}catch(error){message.value=error instanceof Error?error.message:'考试结果读取失败';}}; onLoad((options)=>{id.value=Number(options?.id||0);void load();});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.exam-results-page{max-width:var(--mobile-shell-max-width);margin:0 auto}.status-message{display:block;margin-top:12px;color:#a9000d}.metric-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-top:18px}.metric-card{display:grid;gap:4px;padding:14px;border:1px solid var(--line);border-radius:12px;background:#fff;text-align:center}.metric-card text{font-size:22px;font-weight:900}.metric-card label,.muted{color:var(--muted);font-size:12px}.result-list{display:grid;gap:4px}.result-row{display:flex;gap:12px;align-items:center;justify-content:space-between}.result-name,.result-row .muted,.result-score text,.result-score label{display:block}.result-name{font-weight:900}.result-score{text-align:right}.result-score text{color:var(--brand);font-weight:900}.result-score label{margin-top:4px;color:var(--muted);font-size:12px}.empty{color:var(--muted)}
|
||||
</style>
|
||||
@@ -120,6 +120,13 @@
|
||||
</view>
|
||||
<text class="task-arrow">›</text>
|
||||
</button>
|
||||
<button class="task-row" @click="openPage('/pages/supervisor/exams/index')">
|
||||
<view class="task-copy">
|
||||
<text class="task-title">岗前测试</text>
|
||||
<text class="task-meta">组卷、派发并查看考试结果</text>
|
||||
</view>
|
||||
<text class="task-arrow">›</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
@@ -129,7 +136,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { onShow } from '@dcloudio/uni-app';
|
||||
import { onHide, onShow, onUnload } from '@dcloudio/uni-app';
|
||||
import type {
|
||||
PracticeAlert,
|
||||
PracticeAssignment,
|
||||
@@ -146,6 +153,7 @@ import {
|
||||
buildTeamProfiles,
|
||||
isSupervisorAccessDenied
|
||||
} from '@/services/supervisor-view';
|
||||
import { createPageRequestScope, currentAccountKey } from '@/services/request-scope';
|
||||
|
||||
const currentPath = '/pages/supervisor/index/index';
|
||||
const pendingReviews = ref<PracticeRecord[]>([]);
|
||||
@@ -157,6 +165,8 @@ const loggedIn = ref(false);
|
||||
const accessDenied = ref(false);
|
||||
const loading = ref(false);
|
||||
const message = ref('');
|
||||
const requests = createPageRequestScope();
|
||||
let visibleAccountKey = '';
|
||||
|
||||
const supervisorMetrics = computed(() =>
|
||||
buildSupervisorMetrics(pendingReviews.value, assignments.value, alerts.value));
|
||||
@@ -198,6 +208,7 @@ const handleRequestError = (error: unknown, fallback: string) => {
|
||||
};
|
||||
|
||||
const loadPage = async () => {
|
||||
const request = requests.begin('page');
|
||||
refreshAuthState();
|
||||
message.value = '';
|
||||
if (!loggedIn.value) {
|
||||
@@ -205,7 +216,6 @@ const loadPage = async () => {
|
||||
clearData();
|
||||
return;
|
||||
}
|
||||
if (loading.value) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const [nextReviews, nextAlerts, snapshot] = await Promise.all([
|
||||
@@ -213,6 +223,7 @@ const loadPage = async () => {
|
||||
getPracticeAlerts(),
|
||||
getPracticeTeamSnapshot()
|
||||
]);
|
||||
if (!requests.isCurrent(request)) return;
|
||||
accessDenied.value = false;
|
||||
pendingReviews.value = nextReviews;
|
||||
alerts.value = nextAlerts;
|
||||
@@ -220,12 +231,18 @@ const loadPage = async () => {
|
||||
assignments.value = snapshot.assignments;
|
||||
members.value = snapshot.members;
|
||||
} catch (error) {
|
||||
if (!requests.isCurrent(request)) return;
|
||||
handleRequestError(error, '主管端数据加载失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
if (requests.isCurrent(request)) loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const invalidateRequests = () => {
|
||||
requests.invalidate();
|
||||
loading.value = false;
|
||||
};
|
||||
|
||||
const openPage = (url: string) => {
|
||||
uni.navigateTo({ url });
|
||||
};
|
||||
@@ -254,9 +271,20 @@ const switchAccount = () => {
|
||||
};
|
||||
|
||||
onShow(() => {
|
||||
const nextAccountKey = currentAccountKey();
|
||||
if (visibleAccountKey && visibleAccountKey !== nextAccountKey) {
|
||||
clearData();
|
||||
message.value = '';
|
||||
accessDenied.value = false;
|
||||
}
|
||||
visibleAccountKey = nextAccountKey;
|
||||
requests.activate();
|
||||
resetPageScroll();
|
||||
void loadPage();
|
||||
});
|
||||
|
||||
onHide(invalidateRequests);
|
||||
onUnload(invalidateRequests);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -25,6 +25,12 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="panel learner-mode-card">
|
||||
<text class="panel-title">我的学习</text>
|
||||
<text class="muted learner-mode-desc">以学员界面完成经理安排的学习与训练,不会修改你在人力系统中的主管身份。</text>
|
||||
<button class="primary-button learner-mode-button" @click="enterLearnerMode">进入学员模式</button>
|
||||
</view>
|
||||
|
||||
<button class="secondary-button logout-button" @click="logout">退出登录</button>
|
||||
<SupervisorTabBar active="profile" />
|
||||
</view>
|
||||
@@ -34,7 +40,13 @@
|
||||
import { ref } from 'vue';
|
||||
import { onShow } from '@dcloudio/uni-app';
|
||||
import SupervisorTabBar from '@/components/SupervisorTabBar.vue';
|
||||
import { clearAuth, getAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||||
import {
|
||||
clearAuth,
|
||||
enterSupervisorLearnerMode,
|
||||
getAuth,
|
||||
isLoggedIn,
|
||||
rememberLoginRedirect
|
||||
} from '@/services/auth';
|
||||
import { resetPageScroll } from '@/services/navigation';
|
||||
|
||||
const phone = ref('');
|
||||
@@ -43,6 +55,14 @@ const refreshAuth = () => {
|
||||
phone.value = getAuth().phone;
|
||||
};
|
||||
|
||||
const enterLearnerMode = () => {
|
||||
if (!enterSupervisorLearnerMode()) {
|
||||
uni.showToast({ title: '当前账号不是主管', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
uni.switchTab({ url: '/pages/user/today/index' });
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
clearAuth();
|
||||
rememberLoginRedirect('/pages/supervisor/profile/index');
|
||||
@@ -105,4 +125,19 @@ onShow(() => {
|
||||
width: 100%;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.learner-mode-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.learner-mode-desc {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.learner-mode-button {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -48,11 +48,14 @@
|
||||
|
||||
<view class="panel mistake-panel">
|
||||
<view class="section-head">
|
||||
<text class="panel-title">团队错题本</text>
|
||||
<text class="muted">按项目范围聚合</text>
|
||||
<text class="panel-title">常见难题</text>
|
||||
<text class="muted">近30天 · 仅当前项目</text>
|
||||
</view>
|
||||
<view v-if="mistakes.length" class="mistake-list">
|
||||
<view v-for="item in mistakes" :key="`${item.scenarioId}-${item.cause}`" class="mistake-row">
|
||||
<view class="difficulty-sort" role="group" aria-label="难题排序">
|
||||
<button v-for="item in difficultySorts" :key="item.key" :class="{ active: difficultySort === item.key }" @click="difficultySort = item.key">{{ item.label }}</button>
|
||||
</view>
|
||||
<view v-if="sortedDifficulties.length" class="mistake-list">
|
||||
<view v-for="item in sortedDifficulties" :key="`${item.scenarioId}-${item.cause}`" class="mistake-row">
|
||||
<view class="mistake-main">
|
||||
<view class="mistake-title-row">
|
||||
<text class="mistake-title">{{ item.scenarioName }}</text>
|
||||
@@ -61,7 +64,9 @@
|
||||
<text class="mistake-cause">{{ item.cause }} · {{ item.affectedPeople }} 名成员 · 平均 {{ item.averageScore }} 分</text>
|
||||
<text class="mistake-time">最近发生 {{ item.latestTime || '暂无时间' }}</text>
|
||||
</view>
|
||||
<button class="secondary-button mistake-action" @click="openReviews">去待复盘</button>
|
||||
<button class="secondary-button mistake-action" :disabled="assigningDifficultyKey === `${item.scenarioId}-${item.cause}`" @click="assignDifficulty(item)">
|
||||
{{ assigningDifficultyKey === `${item.scenarioId}-${item.cause}` ? '派发中' : '一键派专项' }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="empty compact-empty">暂无团队错题记录</view>
|
||||
@@ -140,11 +145,11 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { onShow } from '@dcloudio/uni-app';
|
||||
import type { PracticeAssignment, PracticeMistakeAggregate, PracticeRecord, PracticeTeamMember } from '@/types/api';
|
||||
import { onHide, onShow, onUnload } from '@dcloudio/uni-app';
|
||||
import type { PracticeAssignment, PracticeDifficulty, PracticeRecord, PracticeTeamMember } from '@/types/api';
|
||||
import { clearAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||||
import { resetPageScroll } from '@/services/navigation';
|
||||
import { getPracticeTeamSnapshot } from '@/services/practice';
|
||||
import { assignPracticeDifficulty, getPracticeDifficulties, getPracticeTeamSnapshot } from '@/services/practice';
|
||||
import SupervisorTabBar from '@/components/SupervisorTabBar.vue';
|
||||
import {
|
||||
buildTeamProfiles,
|
||||
@@ -152,17 +157,29 @@ import {
|
||||
isSupervisorAccessDenied,
|
||||
type TeamProfile
|
||||
} from '@/services/supervisor-view';
|
||||
import { createPageRequestScope, currentAccountKey } from '@/services/request-scope';
|
||||
|
||||
const currentPath = '/pages/supervisor/team/index';
|
||||
const records = ref<PracticeRecord[]>([]);
|
||||
const assignments = ref<PracticeAssignment[]>([]);
|
||||
const members = ref<PracticeTeamMember[]>([]);
|
||||
const mistakes = ref<PracticeMistakeAggregate[]>([]);
|
||||
const mistakes = ref<PracticeDifficulty[]>([]);
|
||||
const loggedIn = ref(false);
|
||||
const accessDenied = ref(false);
|
||||
const loading = ref(false);
|
||||
const message = ref('');
|
||||
const selectedMember = ref<TeamProfile | null>(null);
|
||||
const difficultySorts = [
|
||||
{ key: 'count', label: '出现次数' },
|
||||
{ key: 'people', label: '涉及人数' },
|
||||
{ key: 'score', label: '低分优先' },
|
||||
{ key: 'latest', label: '最近发生' }
|
||||
] as const;
|
||||
type DifficultySort = typeof difficultySorts[number]['key'];
|
||||
const difficultySort = ref<DifficultySort>('count');
|
||||
const assigningDifficultyKey = ref('');
|
||||
const requests = createPageRequestScope();
|
||||
let visibleAccountKey = '';
|
||||
|
||||
const teamProfiles = computed(() => buildTeamProfiles(records.value, assignments.value, members.value));
|
||||
const riskCounts = computed(() => teamProfiles.value.reduce(
|
||||
@@ -178,6 +195,12 @@ const memberRecords = computed(() => {
|
||||
.filter((item) => item.extPartyId === selectedMember.value?.extPartyId)
|
||||
.slice(0, 6);
|
||||
});
|
||||
const sortedDifficulties = computed(() => [...mistakes.value].sort((a, b) => {
|
||||
if (difficultySort.value === 'people') return b.affectedPeople - a.affectedPeople;
|
||||
if (difficultySort.value === 'score') return a.averageScore - b.averageScore;
|
||||
if (difficultySort.value === 'latest') return String(b.latestTime).localeCompare(String(a.latestTime));
|
||||
return b.count - a.count;
|
||||
}));
|
||||
|
||||
const clearData = () => {
|
||||
records.value = [];
|
||||
@@ -209,6 +232,7 @@ const handleRequestError = (error: unknown, fallback: string) => {
|
||||
};
|
||||
|
||||
const loadPage = async () => {
|
||||
const request = requests.begin('page');
|
||||
refreshAuthState();
|
||||
message.value = '';
|
||||
if (!loggedIn.value) {
|
||||
@@ -216,19 +240,20 @@ const loadPage = async () => {
|
||||
clearData();
|
||||
return;
|
||||
}
|
||||
if (loading.value) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const snapshot = await getPracticeTeamSnapshot();
|
||||
const [snapshot, difficulties] = await Promise.all([getPracticeTeamSnapshot(), getPracticeDifficulties(30)]);
|
||||
if (!requests.isCurrent(request)) return;
|
||||
accessDenied.value = false;
|
||||
records.value = snapshot.records;
|
||||
assignments.value = snapshot.assignments;
|
||||
members.value = snapshot.members;
|
||||
mistakes.value = snapshot.mistakes ?? [];
|
||||
mistakes.value = difficulties?.length ? difficulties : (snapshot.mistakes ?? []);
|
||||
} catch (error) {
|
||||
if (!requests.isCurrent(request)) return;
|
||||
handleRequestError(error, '团队画像加载失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
if (requests.isCurrent(request)) loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -247,8 +272,43 @@ const selectMember = (member: TeamProfile) => {
|
||||
selectedMember.value = selectedMember.value?.key === member.key ? null : member;
|
||||
};
|
||||
|
||||
const openReviews = () => {
|
||||
uni.navigateTo({ url: '/pages/supervisor/reviews/index' });
|
||||
const tomorrow = () => {
|
||||
const date = new Date();
|
||||
date.setHours(12, 0, 0, 0);
|
||||
date.setDate(date.getDate() + 1);
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const assignDifficulty = async (item: PracticeDifficulty) => {
|
||||
const key = `${item.scenarioId}-${item.cause}`;
|
||||
if (assigningDifficultyKey.value) return;
|
||||
const request = requests.begin('assign-difficulty');
|
||||
assigningDifficultyKey.value = key;
|
||||
message.value = '';
|
||||
try {
|
||||
const result = await assignPracticeDifficulty({
|
||||
scenarioId: item.scenarioId,
|
||||
cause: item.cause,
|
||||
dueDate: tomorrow(),
|
||||
requestId: `difficulty:${item.scenarioId}:${Date.now()}`,
|
||||
windowDays: 30,
|
||||
position: item.position
|
||||
});
|
||||
if (!requests.isCurrent(request)) return;
|
||||
message.value = `专项已派发:成功 ${result.created + result.duplicate} 人,失败 ${result.failed} 人`;
|
||||
await loadPage();
|
||||
} catch (error) {
|
||||
if (!requests.isCurrent(request)) return;
|
||||
handleRequestError(error, '常见难题专项派发失败');
|
||||
} finally {
|
||||
if (requests.isCurrent(request)) assigningDifficultyKey.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const invalidateRequests = () => {
|
||||
requests.invalidate();
|
||||
loading.value = false;
|
||||
assigningDifficultyKey.value = '';
|
||||
};
|
||||
|
||||
const goLogin = () => {
|
||||
@@ -278,9 +338,20 @@ const goBack = () => {
|
||||
};
|
||||
|
||||
onShow(() => {
|
||||
const nextAccountKey = currentAccountKey();
|
||||
if (visibleAccountKey && visibleAccountKey !== nextAccountKey) {
|
||||
clearData();
|
||||
message.value = '';
|
||||
accessDenied.value = false;
|
||||
}
|
||||
visibleAccountKey = nextAccountKey;
|
||||
requests.activate();
|
||||
resetPageScroll();
|
||||
void loadPage();
|
||||
});
|
||||
|
||||
onHide(invalidateRequests);
|
||||
onUnload(invalidateRequests);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -372,6 +443,29 @@ onShow(() => {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.difficulty-sort {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.difficulty-sort button {
|
||||
min-height: 34px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #d7e0eb;
|
||||
background: #ffffff;
|
||||
color: var(--employee-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.difficulty-sort button.active {
|
||||
border-color: #ef9da5;
|
||||
background: var(--employee-primary-soft);
|
||||
color: var(--employee-primary);
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.mistake-list {
|
||||
margin-top: 8px;
|
||||
}
|
||||
@@ -427,7 +521,7 @@ onShow(() => {
|
||||
.mistake-time {
|
||||
margin-top: 5px;
|
||||
color: var(--employee-muted);
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@@ -509,7 +603,7 @@ onShow(() => {
|
||||
border-radius: 8px;
|
||||
background: var(--employee-primary-soft);
|
||||
color: var(--employee-primary);
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
@@ -563,7 +657,7 @@ onShow(() => {
|
||||
.member-summary-label {
|
||||
margin-top: 4px;
|
||||
color: var(--employee-muted);
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.subsection-title {
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
<template>
|
||||
<view class="page employee-page-shell exam-page">
|
||||
<view class="subpage-header">
|
||||
<button class="subpage-back-button" aria-label="返回学习" :disabled="submitting" @click="goBack">←</button>
|
||||
<view class="subpage-heading">
|
||||
<text class="employee-kicker">岗前 / 入职考试</text>
|
||||
<text class="page-title">{{ exam?.title || '考试任务' }}</text>
|
||||
<text class="page-subtitle">{{ examMeta }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text v-if="message" class="status-message">{{ message }}</text>
|
||||
<view v-if="loading" class="panel employee-card">考试加载中…</view>
|
||||
<template v-else-if="exam">
|
||||
<view class="summary-card employee-card">
|
||||
<view><text class="summary-value">{{ exam.questionCount }}</text><text class="summary-label">题目</text></view>
|
||||
<view><text class="summary-value">{{ exam.passingScore }}</text><text class="summary-label">及格分</text></view>
|
||||
<view><text class="summary-value">{{ exam.totalScore }}</text><text class="summary-label">总分</text></view>
|
||||
</view>
|
||||
|
||||
<view v-if="result" class="panel employee-card result-panel" :class="result.passed ? 'passed' : 'failed'">
|
||||
<text class="result-title">{{ result.passed ? '考试通过' : '本次未通过' }}</text>
|
||||
<text class="result-score">{{ result.score }} 分</text>
|
||||
<text class="muted">{{ result.passed ? '上岗资格会按正式规则刷新。' : '请根据错题依据复习后再参加补考。' }}</text>
|
||||
</view>
|
||||
|
||||
<view class="question-list">
|
||||
<view v-for="(item, index) in exam.questions" :key="item.id" class="panel employee-card question-card">
|
||||
<view class="question-head">
|
||||
<text class="question-index">{{ index + 1 }} / {{ exam.questions.length }}</text>
|
||||
<button class="ghost-button speech-button" @click="speakQuestion(item)">{{ speechLabel(item) }}</button>
|
||||
</view>
|
||||
<text class="question-stem">{{ item.stem }}</text>
|
||||
<view v-if="item.options?.length" class="option-list">
|
||||
<button
|
||||
v-for="option in item.options"
|
||||
:key="option"
|
||||
class="option-button"
|
||||
:class="{ selected: isSelected(item, option) }"
|
||||
:disabled="Boolean(result)"
|
||||
@click="toggleOption(item, option)"
|
||||
>
|
||||
<text class="option-marker">{{ isSelected(item, option) ? '✓' : '' }}</text>
|
||||
<text>{{ option }}</text>
|
||||
</button>
|
||||
</view>
|
||||
<textarea
|
||||
v-else
|
||||
class="textarea short-answer"
|
||||
:value="String(answers[String(item.id)] || '')"
|
||||
:disabled="Boolean(result)"
|
||||
maxlength="500"
|
||||
placeholder="请输入回答"
|
||||
@input="setShortAnswer(item, $event)"
|
||||
/>
|
||||
<view v-if="result && item.explanation" class="explanation">
|
||||
<text class="explanation-title">参考依据</text>
|
||||
<text>{{ item.explanation }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="!result" class="submit-dock">
|
||||
<text>已答 {{ answeredCount }} / {{ exam.questions.length }} 题</text>
|
||||
<button class="primary-button" :disabled="submitting || answeredCount !== exam.questions.length" @click="submit">
|
||||
{{ submitting ? '提交中' : '提交试卷' }}
|
||||
</button>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref } from 'vue';
|
||||
import { onLoad } from '@dcloudio/uni-app';
|
||||
import type { ExamDetail, ExamQuestion, ExamSubmitResult } from '@/types/api';
|
||||
import { getMyExam, submitMyExam } from '@/services/exam';
|
||||
import { getAuth } from '@/services/auth';
|
||||
import { createSpeechPlaybackController, type SpeechPlaybackSnapshot } from '@/services/speech';
|
||||
|
||||
const id = ref(0);
|
||||
const exam = ref<ExamDetail | null>(null);
|
||||
const answers = ref<Record<string, string | string[]>>({});
|
||||
const result = ref<ExamSubmitResult | null>(null);
|
||||
const loading = ref(false);
|
||||
const submitting = ref(false);
|
||||
const message = ref('');
|
||||
const speech = ref<SpeechPlaybackSnapshot>({ key: '', status: 'idle' });
|
||||
const speechController = createSpeechPlaybackController(
|
||||
(snapshot) => { speech.value = snapshot; },
|
||||
(error) => { message.value = error; }
|
||||
);
|
||||
const examMeta = computed(() => exam.value
|
||||
? `${exam.value.positionCode || '通用岗位'}${exam.value.dueDate ? ` · 截止 ${exam.value.dueDate}` : ''}`
|
||||
: '读取正式发布的考试内容');
|
||||
const answeredCount = computed(() => Object.values(answers.value).filter((value) => Array.isArray(value) ? value.length : String(value).trim()).length);
|
||||
const storageKey = computed(() => `aihr_exam_draft:${getAuth().phone || 'anonymous'}:${id.value}`);
|
||||
|
||||
const goBack = () => uni.navigateBack({ delta: 1 });
|
||||
const requestId = () => `exam:${id.value}:${Date.now()}:${Math.random().toString(36).slice(2, 8)}`;
|
||||
const isSelected = (question: ExamQuestion, option: string) => {
|
||||
const value = answers.value[String(question.id)];
|
||||
return Array.isArray(value) ? value.includes(option) : value === option;
|
||||
};
|
||||
const toggleOption = (question: ExamQuestion, option: string) => {
|
||||
const key = String(question.id);
|
||||
if (question.format === 'multiple') {
|
||||
const current = Array.isArray(answers.value[key]) ? [...answers.value[key] as string[]] : [];
|
||||
answers.value[key] = current.includes(option) ? current.filter((item) => item !== option) : [...current, option];
|
||||
} else {
|
||||
answers.value[key] = option;
|
||||
}
|
||||
uni.setStorageSync(storageKey.value, answers.value);
|
||||
};
|
||||
const setShortAnswer = (question: ExamQuestion, event: unknown) => {
|
||||
const value = typeof event === 'object' && event !== null && 'detail' in event
|
||||
&& typeof (event as { detail?: unknown }).detail === 'object'
|
||||
&& (event as { detail?: { value?: unknown } }).detail !== null
|
||||
? String((event as { detail: { value?: unknown } }).detail.value || '')
|
||||
: '';
|
||||
answers.value[String(question.id)] = value;
|
||||
uni.setStorageSync(storageKey.value, answers.value);
|
||||
};
|
||||
const speakQuestion = (question: ExamQuestion) => speechController.toggle(`q-${question.id}`, [question.stem, ...(question.options || [])].join('。'));
|
||||
const speechLabel = (question: ExamQuestion) => speech.value.key === `q-${question.id}`
|
||||
? (speech.value.status === 'playing' ? '停止' : '生成中')
|
||||
: '播报题目';
|
||||
const load = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
exam.value = await getMyExam(id.value);
|
||||
const stored = uni.getStorageSync(storageKey.value);
|
||||
if (stored && typeof stored === 'object') answers.value = stored as Record<string, string | string[]>;
|
||||
} catch (error) { message.value = error instanceof Error ? error.message : '考试读取失败'; }
|
||||
finally { loading.value = false; }
|
||||
};
|
||||
const submit = async () => {
|
||||
if (!exam.value || submitting.value || answeredCount.value !== exam.value.questions.length) return;
|
||||
submitting.value = true;
|
||||
try {
|
||||
result.value = await submitMyExam(id.value, answers.value, requestId());
|
||||
const explanations = new Map(result.value.results.map((item) => [item.questionId, item.explanation]));
|
||||
exam.value.questions = exam.value.questions.map((item) => ({
|
||||
...item,
|
||||
explanation: explanations.get(item.id) || item.explanation
|
||||
}));
|
||||
uni.removeStorageSync(storageKey.value);
|
||||
message.value = result.value.passed ? '考试已通过,资格状态将刷新' : '试卷已提交,请根据错题复习';
|
||||
} catch (error) { message.value = error instanceof Error ? error.message : '试卷提交失败'; }
|
||||
finally { submitting.value = false; }
|
||||
};
|
||||
onLoad((options) => { id.value = Number(options?.id || 0); void load(); });
|
||||
onBeforeUnmount(() => speechController.destroy());
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.exam-page { max-width: var(--mobile-shell-max-width); margin: 0 auto; padding-bottom: 140px; }
|
||||
.status-message { display: block; margin-top: 14px; padding: 12px; border-radius: 10px; background: #fff0f1; color: #a9000d; }
|
||||
.summary-card { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 18px; padding: 16px; text-align: center; }
|
||||
.summary-value, .summary-label { display: block; }
|
||||
.summary-value { font-size: 20px; font-weight: 900; }
|
||||
.summary-label { margin-top: 4px; color: var(--muted); font-size: 12px; }
|
||||
.result-panel { display: grid; gap: 6px; border-width: 2px; }
|
||||
.result-panel.passed { border-color: #7bc99a; }
|
||||
.result-panel.failed { border-color: #f0b06a; }
|
||||
.result-title { font-size: 18px; font-weight: 900; }
|
||||
.result-score { color: var(--brand); font-size: 36px; font-weight: 900; }
|
||||
.question-list { display: grid; gap: 4px; }
|
||||
.question-card { display: grid; gap: 14px; }
|
||||
.question-head { display: flex; align-items: center; justify-content: space-between; }
|
||||
.question-index { color: var(--knowledge); font-size: 13px; font-weight: 900; }
|
||||
.speech-button { min-height: 36px; padding: 0 10px; font-size: 12px; }
|
||||
.question-stem { overflow-wrap: anywhere; font-size: 17px; font-weight: 900; line-height: 1.65; }
|
||||
.option-list { display: grid; gap: 9px; }
|
||||
.option-button { display: flex; gap: 10px; min-height: 48px; align-items: center; padding: 10px 12px; border: 1px solid var(--line); background: #fff; color: var(--ink); text-align: left; }
|
||||
.option-button.selected { border-color: #ef9ca4; background: var(--employee-primary-soft); }
|
||||
.option-marker { flex: 0 0 24px; height: 24px; border: 1px solid #c8d0db; border-radius: 50%; color: var(--brand); line-height: 22px; text-align: center; }
|
||||
.short-answer { min-height: 120px; }
|
||||
.explanation { display: grid; gap: 6px; padding: 12px; border-radius: 10px; background: #f1f6fb; color: #435268; font-size: 13px; line-height: 1.6; }
|
||||
.explanation-title { font-weight: 900; }
|
||||
.submit-dock { position: fixed; z-index: 10; right: 0; bottom: 0; left: 0; display: grid; gap: 8px; max-width: var(--mobile-shell-max-width); margin: 0 auto; padding: 12px 16px calc(12px + env(safe-area-inset-bottom)); border-top: 1px solid var(--line); background: rgba(255,255,255,.96); color: var(--muted); font-size: 12px; }
|
||||
</style>
|
||||
@@ -156,6 +156,26 @@
|
||||
<view v-else-if="!onboardingLoading" class="empty">暂无正式岗前/入职任务,等待 HR 或组织系统派发</view>
|
||||
</view>
|
||||
|
||||
<view v-if="loggedIn" class="panel exam-panel">
|
||||
<view class="panel-head">
|
||||
<view>
|
||||
<text class="panel-title">考试任务</text>
|
||||
<text class="panel-caption">主管或经理发布的岗位考试,提交后自动评分并保留题目版本</text>
|
||||
</view>
|
||||
<button class="ghost-button compact-button" :disabled="examLoading" @click="loadExams">{{ examLoading ? '加载中' : '刷新' }}</button>
|
||||
</view>
|
||||
<view v-if="exams.length" class="exam-list">
|
||||
<view v-for="item in exams" :key="item.id" class="exam-row">
|
||||
<view class="exam-copy">
|
||||
<text class="exam-title">{{ item.title }}</text>
|
||||
<text class="exam-meta">{{ item.status }} · {{ item.questionCount }}题 · 及格{{ item.passingScore }}分{{ item.dueDate ? ` · 截止${item.dueDate}` : '' }}</text>
|
||||
</view>
|
||||
<button class="secondary-button compact-button" @click="openExam(item.id)">{{ item.submissionCount ? '再次查看' : '去考试' }}</button>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else-if="!examLoading" class="empty">暂无考试任务</view>
|
||||
</view>
|
||||
|
||||
<view v-if="loggedIn" class="panel qualification-panel">
|
||||
<view class="panel-head">
|
||||
<view>
|
||||
@@ -258,7 +278,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { onShow } from '@dcloudio/uni-app';
|
||||
import { onHide, onShow, onUnload } from '@dcloudio/uni-app';
|
||||
import type {
|
||||
KnowledgeSearchResponse,
|
||||
OnboardTask,
|
||||
@@ -266,7 +286,8 @@ import type {
|
||||
PracticeAssignment,
|
||||
PracticeMistake,
|
||||
PracticePrepCard,
|
||||
QualificationSummary
|
||||
QualificationSummary,
|
||||
ExamSummary
|
||||
} from '@/types/api';
|
||||
import { completeOnboardTask, getOnboardTasks, getPracticeAssignments, getPracticeMistakes, getPracticePrepCard, getQualification } from '@/services/practice';
|
||||
import { getPositionSopSummary, searchKnowledge } from '@/services/knowledge';
|
||||
@@ -274,6 +295,8 @@ import { getSelectedPositionProfile, isLoggedIn, rememberLoginRedirect } from '@
|
||||
import { buildLearningTopics, selectLearningTopic } from '@/services/learning';
|
||||
import { navigateBackToToday, resetPageScroll } from '@/services/navigation';
|
||||
import { ensureEmployeePosition } from '@/services/position';
|
||||
import { listMyExams } from '@/services/exam';
|
||||
import { createPageRequestScope, currentAccountKey, type RequestScopeTicket } from '@/services/request-scope';
|
||||
|
||||
const defaultScenarioId = 'complaint-water';
|
||||
const pendingAssignmentKey = 'aihr_pending_assignment_id';
|
||||
@@ -299,6 +322,10 @@ const qualification = ref<QualificationSummary | null>(null);
|
||||
const onboardingLoading = ref(false);
|
||||
const onboardingCompletingId = ref<number | null>(null);
|
||||
const qualificationLoading = ref(false);
|
||||
const exams = ref<ExamSummary[]>([]);
|
||||
const examLoading = ref(false);
|
||||
const requests = createPageRequestScope();
|
||||
let visibleAccountKey = '';
|
||||
|
||||
const defaultTopic = computed(() => `${positionProfile.value.value} 岗前 SOP`);
|
||||
const topics = computed(() => buildLearningTopics(
|
||||
@@ -390,112 +417,166 @@ const cleanOrderedText = (text = '') =>
|
||||
.replace(/^(?:[一二三四五六七八九十]+[、..]|[0-9]+[.)、..])\s*/, '')
|
||||
.trim();
|
||||
|
||||
const loadAssignments = async () => {
|
||||
const loadAssignmentsRequest = async (request: RequestScopeTicket) => {
|
||||
if (!loggedIn.value) return;
|
||||
assignmentLoading.value = true;
|
||||
try {
|
||||
assignments.value = await getPracticeAssignments(true);
|
||||
const nextAssignments = await getPracticeAssignments(true);
|
||||
if (!requests.isCurrent(request)) return;
|
||||
assignments.value = nextAssignments;
|
||||
} catch (error) {
|
||||
if (!requests.isCurrent(request)) return;
|
||||
assignments.value = [];
|
||||
handleRequestError(error, '学习任务加载失败');
|
||||
} finally {
|
||||
assignmentLoading.value = false;
|
||||
if (requests.isCurrent(request)) assignmentLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadMistakes = async () => {
|
||||
const loadAssignments = () => loadAssignmentsRequest(requests.begin('assignments'));
|
||||
|
||||
const loadMistakesRequest = async (request: RequestScopeTicket) => {
|
||||
if (!loggedIn.value) return;
|
||||
mistakeLoading.value = true;
|
||||
try {
|
||||
mistakes.value = await getPracticeMistakes();
|
||||
const nextMistakes = await getPracticeMistakes();
|
||||
if (!requests.isCurrent(request)) return;
|
||||
mistakes.value = nextMistakes;
|
||||
} catch (error) {
|
||||
if (!requests.isCurrent(request)) return;
|
||||
mistakes.value = [];
|
||||
handleRequestError(error, '错题本加载失败');
|
||||
} finally {
|
||||
mistakeLoading.value = false;
|
||||
if (requests.isCurrent(request)) mistakeLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadOnboarding = async () => {
|
||||
const loadMistakes = () => loadMistakesRequest(requests.begin('mistakes'));
|
||||
|
||||
const loadOnboardingRequest = async (request: RequestScopeTicket) => {
|
||||
if (!loggedIn.value) return;
|
||||
onboardingLoading.value = true;
|
||||
try {
|
||||
onboardTasks.value = await getOnboardTasks();
|
||||
const nextTasks = await getOnboardTasks();
|
||||
if (!requests.isCurrent(request)) return;
|
||||
onboardTasks.value = nextTasks;
|
||||
} catch (error) {
|
||||
if (!requests.isCurrent(request)) return;
|
||||
onboardTasks.value = [];
|
||||
exams.value = [];
|
||||
handleRequestError(error, '岗前任务加载失败');
|
||||
} finally {
|
||||
onboardingLoading.value = false;
|
||||
if (requests.isCurrent(request)) onboardingLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadOnboarding = () => loadOnboardingRequest(requests.begin('onboarding'));
|
||||
|
||||
const completeOnboarding = async (task: OnboardTask) => {
|
||||
if (onboardingCompletingId.value !== null || task.status === '已完成') return;
|
||||
const request = requests.begin('complete-onboarding');
|
||||
onboardingCompletingId.value = task.id;
|
||||
try {
|
||||
await completeOnboardTask(task.id);
|
||||
if (!requests.isCurrent(request)) return;
|
||||
onboardTasks.value = onboardTasks.value.map((item) => item.id === task.id
|
||||
? { ...item, status: '已完成', completedTime: new Date().toISOString() }
|
||||
: item);
|
||||
message.value = '任务已记录为完成,正式资格仍以 HR 复核为准';
|
||||
} catch (error) {
|
||||
if (!requests.isCurrent(request)) return;
|
||||
handleRequestError(error, '任务完成记录失败');
|
||||
} finally {
|
||||
onboardingCompletingId.value = null;
|
||||
if (requests.isCurrent(request)) onboardingCompletingId.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const loadQualification = async () => {
|
||||
const loadQualificationRequest = async (request: RequestScopeTicket) => {
|
||||
if (!loggedIn.value) return;
|
||||
qualificationLoading.value = true;
|
||||
try {
|
||||
qualification.value = await getQualification();
|
||||
const nextQualification = await getQualification();
|
||||
if (!requests.isCurrent(request)) return;
|
||||
qualification.value = nextQualification;
|
||||
} catch (error) {
|
||||
if (!requests.isCurrent(request)) return;
|
||||
qualification.value = { status: 'NOT_CONFIGURED', positionCode: '', gates: [] };
|
||||
handleRequestError(error, '上岗资格加载失败');
|
||||
} finally {
|
||||
qualificationLoading.value = false;
|
||||
if (requests.isCurrent(request)) qualificationLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadPrepCard = async () => {
|
||||
const loadQualification = () => loadQualificationRequest(requests.begin('qualification'));
|
||||
|
||||
const loadExamsRequest = async (request: RequestScopeTicket) => {
|
||||
if (!loggedIn.value) return;
|
||||
examLoading.value = true;
|
||||
try {
|
||||
const nextExams = await listMyExams();
|
||||
if (!requests.isCurrent(request)) return;
|
||||
exams.value = nextExams;
|
||||
} catch (error) {
|
||||
if (!requests.isCurrent(request)) return;
|
||||
exams.value = [];
|
||||
handleRequestError(error, '考试任务加载失败');
|
||||
} finally {
|
||||
if (requests.isCurrent(request)) examLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadExams = () => loadExamsRequest(requests.begin('exams'));
|
||||
|
||||
const loadPrepCardRequest = async (request: RequestScopeTicket) => {
|
||||
if (!loggedIn.value) return;
|
||||
prepLoading.value = true;
|
||||
try {
|
||||
prepCard.value = await getPracticePrepCard(prepScenarioId.value);
|
||||
const nextPrepCard = await getPracticePrepCard(prepScenarioId.value);
|
||||
if (!requests.isCurrent(request)) return;
|
||||
prepCard.value = nextPrepCard;
|
||||
} catch (error) {
|
||||
if (!requests.isCurrent(request)) return;
|
||||
prepCard.value = null;
|
||||
handleRequestError(error, '预习卡加载失败');
|
||||
} finally {
|
||||
prepLoading.value = false;
|
||||
if (requests.isCurrent(request)) prepLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadKnowledge = async (topic: string) => {
|
||||
const loadPrepCard = () => loadPrepCardRequest(requests.begin('prep-card'));
|
||||
|
||||
const loadKnowledge = async (topic: string, request: RequestScopeTicket = requests.begin('knowledge')) => {
|
||||
if (!loggedIn.value || knowledgeLoading.value) return;
|
||||
activeTopic.value = topic;
|
||||
knowledgeLoading.value = true;
|
||||
try {
|
||||
knowledgeResult.value = await searchKnowledge(topic);
|
||||
const nextKnowledge = await searchKnowledge(topic);
|
||||
if (!requests.isCurrent(request)) return;
|
||||
knowledgeResult.value = nextKnowledge;
|
||||
} catch (error) {
|
||||
if (!requests.isCurrent(request)) return;
|
||||
knowledgeResult.value = null;
|
||||
handleRequestError(error, '岗位 SOP 检索失败');
|
||||
} finally {
|
||||
knowledgeLoading.value = false;
|
||||
if (requests.isCurrent(request)) knowledgeLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadPositionSopSummary = async () => {
|
||||
const loadPositionSopSummary = async (request: RequestScopeTicket = requests.begin('position-sop')) => {
|
||||
if (!loggedIn.value) return;
|
||||
try {
|
||||
positionSopSummary.value = await getPositionSopSummary(positionProfile.value.value);
|
||||
const nextSummary = await getPositionSopSummary(positionProfile.value.value);
|
||||
if (!requests.isCurrent(request)) return;
|
||||
positionSopSummary.value = nextSummary;
|
||||
} catch (error) {
|
||||
if (!requests.isCurrent(request)) return;
|
||||
positionSopSummary.value = null;
|
||||
handleRequestError(error, '岗位学习范围加载失败');
|
||||
}
|
||||
};
|
||||
|
||||
const loadPage = async () => {
|
||||
const pageRequest = requests.begin('page');
|
||||
refreshAuthState();
|
||||
message.value = '';
|
||||
if (!loggedIn.value) {
|
||||
@@ -508,19 +589,49 @@ const loadPage = async () => {
|
||||
positionSopSummary.value = null;
|
||||
return;
|
||||
}
|
||||
if (!(await ensureEmployeePosition('/pages/user/learning/index'))) {
|
||||
if (!(await ensureEmployeePosition('/pages/user/learning/index')) || !requests.isCurrent(pageRequest)) {
|
||||
return;
|
||||
}
|
||||
refreshAuthState();
|
||||
await Promise.all([loadAssignments(), loadMistakes(), loadOnboarding(), loadQualification()]);
|
||||
await Promise.all([
|
||||
loadAssignmentsRequest(requests.begin('assignments')),
|
||||
loadMistakesRequest(requests.begin('mistakes')),
|
||||
loadOnboardingRequest(requests.begin('onboarding')),
|
||||
loadQualificationRequest(requests.begin('qualification')),
|
||||
loadExamsRequest(requests.begin('exams'))
|
||||
]);
|
||||
if (!requests.isCurrent(pageRequest)) return;
|
||||
activeTopic.value = selectLearningTopic(assignments.value, defaultTopic.value);
|
||||
await Promise.all([
|
||||
loadPositionSopSummary(),
|
||||
loadKnowledge(activeTopic.value),
|
||||
loadPrepCard()
|
||||
loadPositionSopSummary(requests.begin('position-sop')),
|
||||
loadKnowledge(activeTopic.value, requests.begin('knowledge')),
|
||||
loadPrepCardRequest(requests.begin('prep-card'))
|
||||
]);
|
||||
};
|
||||
|
||||
const invalidateRequests = () => {
|
||||
requests.invalidate();
|
||||
assignmentLoading.value = false;
|
||||
mistakeLoading.value = false;
|
||||
prepLoading.value = false;
|
||||
knowledgeLoading.value = false;
|
||||
onboardingLoading.value = false;
|
||||
onboardingCompletingId.value = null;
|
||||
qualificationLoading.value = false;
|
||||
examLoading.value = false;
|
||||
};
|
||||
|
||||
const clearPageData = () => {
|
||||
assignments.value = [];
|
||||
mistakes.value = [];
|
||||
onboardTasks.value = [];
|
||||
qualification.value = null;
|
||||
prepCard.value = null;
|
||||
knowledgeResult.value = null;
|
||||
positionSopSummary.value = null;
|
||||
exams.value = [];
|
||||
};
|
||||
|
||||
const goLogin = () => {
|
||||
rememberLoginRedirect('/pages/user/learning/index');
|
||||
uni.navigateTo({ url: '/pages/auth/login/index' });
|
||||
@@ -537,6 +648,10 @@ const openMistake = (item: PracticeMistake) => {
|
||||
uni.switchTab({ url: '/pages/user/practice/index' });
|
||||
};
|
||||
|
||||
const openExam = (id: number) => {
|
||||
uni.navigateTo({ url: `/pages/user/exam/index?id=${id}` });
|
||||
};
|
||||
|
||||
const goPractice = () => {
|
||||
if (firstPracticeAssignment.value) {
|
||||
uni.setStorageSync(pendingAssignmentKey, String(firstPracticeAssignment.value.id));
|
||||
@@ -554,9 +669,19 @@ const goCases = () => {
|
||||
};
|
||||
|
||||
onShow(() => {
|
||||
const nextAccountKey = currentAccountKey();
|
||||
if (visibleAccountKey && visibleAccountKey !== nextAccountKey) {
|
||||
clearPageData();
|
||||
message.value = '';
|
||||
}
|
||||
visibleAccountKey = nextAccountKey;
|
||||
requests.activate();
|
||||
resetPageScroll();
|
||||
void loadPage();
|
||||
});
|
||||
|
||||
onHide(invalidateRequests);
|
||||
onUnload(invalidateRequests);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -571,6 +696,45 @@ onShow(() => {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.exam-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.exam-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: #fafcff;
|
||||
}
|
||||
|
||||
.exam-copy {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.exam-title,
|
||||
.exam-meta {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.exam-title {
|
||||
font-weight: 900;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.exam-meta {
|
||||
margin-top: 5px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.position-sop-status {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<view class="page employee-page-shell points-page">
|
||||
<view class="subpage-header">
|
||||
<button class="subpage-back-button" aria-label="返回" @click="goBack">←</button>
|
||||
<view class="subpage-heading">
|
||||
<text class="employee-kicker">积分与学分分账</text>
|
||||
<text class="page-title">我的积分</text>
|
||||
<text class="page-subtitle">这里只记录积分和学习学分,不是现金、不能提现。</text>
|
||||
</view>
|
||||
</view>
|
||||
<text v-if="message" class="status-message">{{ message }}</text>
|
||||
<view class="balance-grid">
|
||||
<view v-for="item in balances" :key="item.accountType" class="balance-card employee-card">
|
||||
<text class="balance-label">{{ item.displayName }}</text>
|
||||
<text class="balance-value">{{ item.balance }}</text>
|
||||
<text class="balance-unit">{{ item.unitName }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="panel employee-card rules-card">
|
||||
<text class="panel-title">获得规则</text>
|
||||
<text>问题或回答审核通过后,按当前规则记基础积分;最佳答案可获额外积分。只有经人工确认并沉淀知识库的成果才可能记学习学分。</text>
|
||||
</view>
|
||||
<view class="panel employee-card ledger-card">
|
||||
<text class="panel-title">最近明细</text>
|
||||
<view v-if="ledger.length" class="ledger-list">
|
||||
<view v-for="item in ledger" :key="item.id" class="ledger-row">
|
||||
<view>
|
||||
<text class="ledger-title">{{ item.remark || item.sourceType }} · {{ item.accountType === 'STUDY_CREDIT' ? '学习学分' : '奖励积分' }}</text>
|
||||
<text class="ledger-time">{{ item.createTime }}</text>
|
||||
</view>
|
||||
<text class="ledger-points" :class="{ reverse: item.direction === 'REVERSE' }">
|
||||
{{ item.direction === 'REVERSE' ? '-' : '+' }}{{ Math.abs(item.points) }}{{ item.accountType === 'STUDY_CREDIT' ? ' 学分' : ' 积分' }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<text v-else class="muted empty">暂无积分明细</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { onShow } from '@dcloudio/uni-app';
|
||||
import type { IncentiveBalance, IncentiveLedgerItem } from '@/types/api';
|
||||
import { getMyIncentiveBalances, getMyIncentiveLedger } from '@/services/questions';
|
||||
|
||||
const balances = ref<IncentiveBalance[]>([]);
|
||||
const ledger = ref<IncentiveLedgerItem[]>([]);
|
||||
const message = ref('');
|
||||
const goBack = () => uni.navigateBack({ delta: 1 });
|
||||
const load = async () => {
|
||||
message.value = '';
|
||||
try { [balances.value, ledger.value] = await Promise.all([getMyIncentiveBalances(), getMyIncentiveLedger()]); }
|
||||
catch (error) { message.value = error instanceof Error ? error.message : '积分读取失败'; }
|
||||
};
|
||||
onShow(() => { void load(); });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.points-page { max-width: var(--mobile-shell-max-width); margin: 0 auto; }
|
||||
.status-message { display: block; margin-top: 14px; color: #a9000d; }
|
||||
.balance-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin-top: 18px; }
|
||||
.balance-card { display: grid; gap: 4px; padding: 18px; }
|
||||
.balance-label, .balance-unit { color: var(--muted); font-size: 12px; }
|
||||
.balance-value { color: var(--brand); font-size: 34px; font-weight: 900; }
|
||||
.rules-card { color: var(--muted); font-size: 13px; line-height: 1.65; }
|
||||
.rules-card .panel-title { display: block; margin-bottom: 8px; }
|
||||
.ledger-card { display: grid; gap: 12px; }
|
||||
.ledger-list { display: grid; }
|
||||
.ledger-row { display: flex; gap: 12px; align-items: center; justify-content: space-between; padding: 12px 0; border-bottom: 1px solid var(--line); }
|
||||
.ledger-title, .ledger-time { display: block; }
|
||||
.ledger-title { font-weight: 800; }
|
||||
.ledger-time { margin-top: 4px; color: var(--muted); font-size: 12px; }
|
||||
.ledger-points { color: var(--success); font-size: 18px; font-weight: 900; }
|
||||
.ledger-points.reverse { color: var(--brand); }
|
||||
.empty { padding: 8px 0; }
|
||||
</style>
|
||||
@@ -84,6 +84,13 @@
|
||||
<view class="assignment-copy">
|
||||
<text class="assignment-title">{{ item.question || item.scenarioName }}</text>
|
||||
<text v-if="assignmentDesc(item)" class="assignment-desc">{{ assignmentDesc(item) }}</text>
|
||||
<button
|
||||
class="turn-voice-button daily-question-voice"
|
||||
:disabled="speechState.status === 'loading' && speechState.key !== `daily-${item.id}`"
|
||||
@click.stop="speakGenerated(`daily-${item.id}`, item.question || item.scenarioName)"
|
||||
>
|
||||
{{ speechButtonLabel(`daily-${item.id}`) }}
|
||||
</button>
|
||||
</view>
|
||||
<view class="daily-answer">
|
||||
<textarea
|
||||
@@ -111,6 +118,25 @@
|
||||
</button>
|
||||
</view>
|
||||
<text v-if="dailyAudioUrls[item.id]" class="voice-state">已附带语音回放</text>
|
||||
<view v-if="item.status === '已完成'" class="daily-feedback-card">
|
||||
<view class="daily-feedback-head">
|
||||
<text class="daily-feedback-title">点评与参考答案</text>
|
||||
<button
|
||||
class="turn-voice-button"
|
||||
:disabled="speechState.status === 'loading' && speechState.key !== `daily-result-${item.id}`"
|
||||
@click.stop="speakGenerated(`daily-result-${item.id}`, dailyFeedbackSpeech(item))"
|
||||
>
|
||||
{{ speechButtonLabel(`daily-result-${item.id}`) }}
|
||||
</button>
|
||||
</view>
|
||||
<text v-if="item.feedback" class="daily-feedback-copy">点评:{{ item.feedback }}</text>
|
||||
<text v-if="item.referenceAnswer" class="daily-reference-copy">参考答案:{{ item.referenceAnswer }}</text>
|
||||
<view class="item-feedback-actions">
|
||||
<text>这道题对工作有帮助吗?</text>
|
||||
<button :class="{ active: item.itemFeedback === 'useful' }" :disabled="feedbackSubmittingId === item.id" @click.stop="submitItemFeedback(item.id, 'useful')">有用</button>
|
||||
<button :class="{ active: item.itemFeedback === 'not_useful' }" :disabled="feedbackSubmittingId === item.id" @click.stop="submitItemFeedback(item.id, 'not_useful')">无用</button>
|
||||
</view>
|
||||
</view>
|
||||
<button class="primary-button small-button" :disabled="Boolean(answeringId) || item.status === '已完成'" @click="submitDaily(item)">
|
||||
{{ item.status === '已完成' ? '已完成' : '提交' }}
|
||||
</button>
|
||||
@@ -154,7 +180,17 @@
|
||||
</view>
|
||||
<view class="turn-list">
|
||||
<view v-for="(turn, index) in turns" :key="index" class="turn-row" :class="turn.role">
|
||||
<text class="turn-role">{{ roleLabel(turn.role) }}</text>
|
||||
<view class="turn-heading">
|
||||
<text class="turn-role">{{ roleLabel(turn.role) }}</text>
|
||||
<button
|
||||
v-if="turn.role !== 'trainee'"
|
||||
class="turn-voice-button"
|
||||
:disabled="speechState.status === 'loading' && speechState.key !== `turn-${index}`"
|
||||
@click="speakGenerated(`turn-${index}`, turn.text)"
|
||||
>
|
||||
{{ speechButtonLabel(`turn-${index}`) }}
|
||||
</button>
|
||||
</view>
|
||||
<text class="turn-text">{{ turn.text }}</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -206,13 +242,27 @@
|
||||
</view>
|
||||
|
||||
<view v-if="result" class="panel result-card employee-card">
|
||||
<text class="result-label">本次评分</text>
|
||||
<view class="result-title-row">
|
||||
<text class="result-label">本次评分</text>
|
||||
<button
|
||||
class="turn-voice-button result-voice-button"
|
||||
:disabled="speechState.status === 'loading' && speechState.key !== 'practice-result'"
|
||||
@click="speakGenerated('practice-result', practiceResultSpeech())"
|
||||
>
|
||||
{{ speechButtonLabel('practice-result') }}
|
||||
</button>
|
||||
</view>
|
||||
<text class="result-score">{{ result.totalScore }}</text>
|
||||
<text class="result-comment">{{ result.aiComment }}</text>
|
||||
<view v-if="result.mentorRewrite" class="mentor-rewrite">
|
||||
<text>老师傅建议</text>
|
||||
<text>{{ result.mentorRewrite }}</text>
|
||||
</view>
|
||||
<view v-if="completedAssignmentId" class="item-feedback-actions result-feedback-actions">
|
||||
<text>这个专项对工作有帮助吗?</text>
|
||||
<button :class="{ active: completedAssignmentFeedback === 'useful' }" :disabled="feedbackSubmittingId === completedAssignmentId" @click="submitItemFeedback(completedAssignmentId, 'useful')">有用</button>
|
||||
<button :class="{ active: completedAssignmentFeedback === 'not_useful' }" :disabled="feedbackSubmittingId === completedAssignmentId" @click="submitItemFeedback(completedAssignmentId, 'not_useful')">无用</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="result" class="panel satisfaction-card employee-card">
|
||||
@@ -258,21 +308,23 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { onShow } from '@dcloudio/uni-app';
|
||||
import { onHide, onShow, onUnload } from '@dcloudio/uni-app';
|
||||
import type {
|
||||
PracticeFinishResponse,
|
||||
PracticePrepCard,
|
||||
PracticeAssignment,
|
||||
PracticeRole,
|
||||
SpeechPlaybackStatus,
|
||||
PracticeTurn,
|
||||
PracticeRecord
|
||||
} from '@/types/api';
|
||||
import { answerDailyDrill, finishPractice, getPracticeAssignments, getPracticeHistory, getPracticePrepCard, startPractice, submitPracticeSatisfaction, submitPracticeTurn } from '@/services/practice';
|
||||
import { answerDailyDrill, finishPractice, getPracticeAssignments, getPracticeHistory, getPracticePrepCard, startPractice, submitPracticeQuestionFeedback, submitPracticeSatisfaction, submitPracticeTurn } from '@/services/practice';
|
||||
import { getSelectedPosition, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||||
import { searchKnowledge } from '@/services/knowledge';
|
||||
import { resetPageScroll } from '@/services/navigation';
|
||||
import { ensureEmployeePosition } from '@/services/position';
|
||||
import { chooseSpeechAudio, transcribeSpeechBlob, transcribeSpeechFile } from '@/services/speech';
|
||||
import { chooseSpeechAudio, createSpeechPlaybackController, transcribeSpeechBlob, transcribeSpeechFile } from '@/services/speech';
|
||||
import { createPageRequestScope, currentAccountKey, type RequestScopeTicket } from '@/services/request-scope';
|
||||
|
||||
type PracticeStatus = 'idle' | 'starting' | 'active' | 'submitting' | 'finished';
|
||||
type RecentPracticeItem = {
|
||||
@@ -322,15 +374,32 @@ const dailyAudioOssIds = ref<Record<number, number | undefined>>({});
|
||||
const assignmentLoading = ref(false);
|
||||
const answeringId = ref<number | null>(null);
|
||||
const activeAssignmentId = ref<number | undefined>();
|
||||
const completedAssignmentId = ref<number | undefined>();
|
||||
const completedAssignmentFeedback = ref<'useful' | 'not_useful' | null>(null);
|
||||
const feedbackSubmittingId = ref<number | null>(null);
|
||||
const highlightedAssignmentId = ref<number | null>(null);
|
||||
const loggedIn = ref(false);
|
||||
const recording = ref(false);
|
||||
const recordingStarting = ref(false);
|
||||
const recordingAssignmentId = ref<number | null>(null);
|
||||
const transcribing = ref(false);
|
||||
const draftAudioUrl = ref('');
|
||||
const draftAudioOssId = ref<number | undefined>();
|
||||
const speechState = ref<{ key: string; status: SpeechPlaybackStatus }>({ key: '', status: 'idle' });
|
||||
const mediaRecorder = ref<MediaRecorder | null>(null);
|
||||
const mediaStream = ref<MediaStream | null>(null);
|
||||
let recordingCaptureGeneration = 0;
|
||||
let transcriptionGeneration = 0;
|
||||
let abortTranscription: (() => void) | null = null;
|
||||
let visibleAccountKey = '';
|
||||
const requests = createPageRequestScope();
|
||||
const speechPlayback = createSpeechPlaybackController(
|
||||
(state) => { speechState.value = state; },
|
||||
(text) => {
|
||||
message.value = text;
|
||||
uni.showToast({ title: text, icon: 'none' });
|
||||
}
|
||||
);
|
||||
const busy = computed(() => status.value === 'starting' || status.value === 'submitting');
|
||||
const practiceView = computed<'prep' | 'active' | 'result'>(() => {
|
||||
if (result.value) return 'result';
|
||||
@@ -385,6 +454,32 @@ const roleLabel = (role: PracticeRole) => {
|
||||
return '我';
|
||||
};
|
||||
|
||||
const speechButtonLabel = (key: string) => {
|
||||
if (speechState.value.key !== key) return '语音播报';
|
||||
if (speechState.value.status === 'loading') return '生成中';
|
||||
if (speechState.value.status === 'playing') return '停止播报';
|
||||
return '语音播报';
|
||||
};
|
||||
|
||||
const speakGenerated = (key: string, text?: string) => {
|
||||
void speechPlayback.toggle(key, text || '');
|
||||
};
|
||||
|
||||
const dailyFeedbackSpeech = (item: PracticeAssignment) => [
|
||||
typeof item.score === 'number' ? `本题得分${item.score}分` : '',
|
||||
item.feedback ? `点评,${item.feedback}` : '',
|
||||
item.referenceAnswer ? `参考答案,${item.referenceAnswer}` : ''
|
||||
].filter(Boolean).join('。');
|
||||
|
||||
const practiceResultSpeech = () => {
|
||||
if (!result.value) return '';
|
||||
return [
|
||||
`本次评分${result.value.totalScore}分`,
|
||||
result.value.aiComment ? `点评,${result.value.aiComment}` : '',
|
||||
result.value.mentorRewrite ? `老师傅建议,${result.value.mentorRewrite}` : ''
|
||||
].filter(Boolean).join('。');
|
||||
};
|
||||
|
||||
const assignmentDesc = (item: PracticeAssignment) => {
|
||||
if (item.source === 'daily' && item.status !== '已完成') return '提交后显示反馈与参考答案';
|
||||
if (item.source === 'daily' && item.status === '已完成') {
|
||||
@@ -411,6 +506,7 @@ const refreshAuthState = () => {
|
||||
};
|
||||
|
||||
const clearPracticeData = () => {
|
||||
speechPlayback.stop();
|
||||
cancelRecording();
|
||||
assignments.value = [];
|
||||
practiceHistory.value = [];
|
||||
@@ -443,6 +539,9 @@ const clearPracticeData = () => {
|
||||
answeringId.value = null;
|
||||
recordingAssignmentId.value = null;
|
||||
activeAssignmentId.value = undefined;
|
||||
completedAssignmentId.value = undefined;
|
||||
completedAssignmentFeedback.value = null;
|
||||
feedbackSubmittingId.value = null;
|
||||
highlightedAssignmentId.value = null;
|
||||
transcribing.value = false;
|
||||
clearDraftAudio();
|
||||
@@ -471,7 +570,17 @@ const stopMediaTracks = () => {
|
||||
mediaStream.value = null;
|
||||
};
|
||||
|
||||
const cancelTranscription = () => {
|
||||
transcriptionGeneration += 1;
|
||||
abortTranscription?.();
|
||||
abortTranscription = null;
|
||||
transcribing.value = false;
|
||||
};
|
||||
|
||||
const cancelRecording = () => {
|
||||
recordingCaptureGeneration += 1;
|
||||
cancelTranscription();
|
||||
recordingStarting.value = false;
|
||||
const recorder = mediaRecorder.value;
|
||||
if (recorder && recorder.state !== 'inactive') {
|
||||
recorder.ondataavailable = null;
|
||||
@@ -517,54 +626,77 @@ const applyDailyAsrResult = (assignmentId: number, result: { text?: string; audi
|
||||
|
||||
const transcribeBlob = async (blob: Blob, assignmentId?: number) => {
|
||||
if (transcribing.value) return;
|
||||
const generation = ++transcriptionGeneration;
|
||||
transcribing.value = true;
|
||||
message.value = '语音转写中';
|
||||
try {
|
||||
const result = await transcribeSpeechBlob(blob);
|
||||
const result = await transcribeSpeechBlob(blob, 'practice-audio.webm', (abort) => {
|
||||
if (generation === transcriptionGeneration) abortTranscription = abort;
|
||||
});
|
||||
if (generation !== transcriptionGeneration) return;
|
||||
if (assignmentId === undefined) {
|
||||
applyAsrResult(result);
|
||||
} else {
|
||||
applyDailyAsrResult(assignmentId, result);
|
||||
}
|
||||
} catch (error) {
|
||||
if (generation !== transcriptionGeneration) return;
|
||||
handleError(error, '语音转写失败');
|
||||
} finally {
|
||||
transcribing.value = false;
|
||||
if (generation === transcriptionGeneration) {
|
||||
abortTranscription = null;
|
||||
transcribing.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const pickAudio = async (assignmentId?: number) => {
|
||||
if (busy.value || recording.value || transcribing.value) return;
|
||||
if (!requireLogin()) return;
|
||||
const generation = ++transcriptionGeneration;
|
||||
transcribing.value = true;
|
||||
message.value = '语音转写中';
|
||||
try {
|
||||
const file = await chooseSpeechAudio();
|
||||
const result = await transcribeSpeechFile(file);
|
||||
if (generation !== transcriptionGeneration) return;
|
||||
const result = await transcribeSpeechFile(file, (abort) => {
|
||||
if (generation === transcriptionGeneration) abortTranscription = abort;
|
||||
});
|
||||
if (generation !== transcriptionGeneration) return;
|
||||
if (assignmentId === undefined) {
|
||||
applyAsrResult(result);
|
||||
} else {
|
||||
applyDailyAsrResult(assignmentId, result);
|
||||
}
|
||||
} catch (error) {
|
||||
if (generation !== transcriptionGeneration) return;
|
||||
handleError(error, '语音转写失败');
|
||||
} finally {
|
||||
transcribing.value = false;
|
||||
if (generation === transcriptionGeneration) {
|
||||
abortTranscription = null;
|
||||
transcribing.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const startRecording = async (assignmentId?: number) => {
|
||||
if (busy.value || transcribing.value) return;
|
||||
if (busy.value || recordingStarting.value || recording.value || transcribing.value) return;
|
||||
if (!requireLogin()) return;
|
||||
if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
|
||||
await pickAudio(assignmentId);
|
||||
return;
|
||||
}
|
||||
const generation = ++recordingCaptureGeneration;
|
||||
recordingStarting.value = true;
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
if (generation !== recordingCaptureGeneration) {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
return;
|
||||
}
|
||||
mediaStream.value = stream;
|
||||
const recorder = new MediaRecorder(stream);
|
||||
const chunks: Blob[] = [];
|
||||
mediaStream.value = stream;
|
||||
mediaRecorder.value = recorder;
|
||||
recordingAssignmentId.value = assignmentId ?? null;
|
||||
recorder.ondataavailable = (event) => {
|
||||
@@ -588,7 +720,10 @@ const startRecording = async (assignmentId?: number) => {
|
||||
message.value = '正在录音';
|
||||
} catch (error) {
|
||||
recordingAssignmentId.value = null;
|
||||
await pickAudio(assignmentId);
|
||||
stopMediaTracks();
|
||||
if (generation === recordingCaptureGeneration) await pickAudio(assignmentId);
|
||||
} finally {
|
||||
if (generation === recordingCaptureGeneration) recordingStarting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -600,6 +735,7 @@ const stopRecording = () => {
|
||||
};
|
||||
|
||||
const toggleRecording = (assignmentId?: number) => {
|
||||
if (recordingStarting.value) return;
|
||||
if (recording.value) {
|
||||
stopRecording();
|
||||
return;
|
||||
@@ -622,7 +758,7 @@ const handleError = (error: unknown, fallback: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const loadAssignments = async () => {
|
||||
const loadAssignments = async (request: RequestScopeTicket = requests.begin('assignments')) => {
|
||||
refreshAuthState();
|
||||
if (!loggedIn.value) {
|
||||
clearPracticeData();
|
||||
@@ -630,26 +766,32 @@ const loadAssignments = async () => {
|
||||
}
|
||||
assignmentLoading.value = true;
|
||||
try {
|
||||
assignments.value = await getPracticeAssignments(true);
|
||||
const nextAssignments = await getPracticeAssignments(true);
|
||||
if (!requests.isCurrent(request)) return;
|
||||
let nextHistory: PracticeRecord[] = [];
|
||||
try {
|
||||
practiceHistory.value = await getPracticeHistory();
|
||||
nextHistory = await getPracticeHistory();
|
||||
} catch {
|
||||
practiceHistory.value = [];
|
||||
nextHistory = [];
|
||||
}
|
||||
drillAnswers.value = Object.fromEntries(assignments.value.map((item) => [item.id, item.userAnswer || '']));
|
||||
if (!requests.isCurrent(request)) return;
|
||||
assignments.value = nextAssignments;
|
||||
practiceHistory.value = nextHistory;
|
||||
drillAnswers.value = Object.fromEntries(nextAssignments.map((item) => [item.id, item.userAnswer || '']));
|
||||
dailyAudioUrls.value = Object.fromEntries(
|
||||
assignments.value.filter((item) => item.audioUrl).map((item) => [item.id, item.audioUrl || ''])
|
||||
nextAssignments.filter((item) => item.audioUrl).map((item) => [item.id, item.audioUrl || ''])
|
||||
);
|
||||
dailyAudioOssIds.value = Object.fromEntries(
|
||||
assignments.value
|
||||
nextAssignments
|
||||
.filter((item) => item.audioOssId !== undefined && item.audioOssId !== null)
|
||||
.map((item) => [item.id, Number(item.audioOssId)])
|
||||
);
|
||||
consumePendingAssignment();
|
||||
} catch (error) {
|
||||
if (!requests.isCurrent(request)) return;
|
||||
handleError(error, '今日任务加载失败');
|
||||
} finally {
|
||||
assignmentLoading.value = false;
|
||||
if (requests.isCurrent(request)) assignmentLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -693,10 +835,12 @@ const submitDaily = async (item: PracticeAssignment) => {
|
||||
message.value = '请先输入或录制处理话术';
|
||||
return;
|
||||
}
|
||||
const request = requests.begin(`daily-${item.id}`);
|
||||
answeringId.value = item.id;
|
||||
message.value = '';
|
||||
try {
|
||||
const result = await answerDailyDrill(item.id, answer, audioUrl, audioOssId);
|
||||
if (!requests.isCurrent(request)) return;
|
||||
assignments.value = assignments.value.map((row) =>
|
||||
row.id === item.id
|
||||
? {
|
||||
@@ -715,9 +859,28 @@ const submitDaily = async (item: PracticeAssignment) => {
|
||||
);
|
||||
message.value = `每日三题得分 ${result.score}(${result.scoreMode === 'LLM' ? 'AI语义评估' : '规则兜底'})`;
|
||||
} catch (error) {
|
||||
if (!requests.isCurrent(request)) return;
|
||||
handleError(error, '每日三题提交失败');
|
||||
} finally {
|
||||
answeringId.value = null;
|
||||
if (requests.isCurrent(request)) answeringId.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const submitItemFeedback = async (assignmentId: number, verdict: 'useful' | 'not_useful') => {
|
||||
if (feedbackSubmittingId.value) return;
|
||||
const request = requests.begin(`feedback-${assignmentId}`);
|
||||
feedbackSubmittingId.value = assignmentId;
|
||||
try {
|
||||
await submitPracticeQuestionFeedback(assignmentId, verdict);
|
||||
if (!requests.isCurrent(request)) return;
|
||||
assignments.value = assignments.value.map((item) => item.id === assignmentId ? { ...item, itemFeedback: verdict } : item);
|
||||
if (completedAssignmentId.value === assignmentId) completedAssignmentFeedback.value = verdict;
|
||||
message.value = verdict === 'useful' ? '已记录为有用' : '已记录为无用,题目会进入优化队列';
|
||||
} catch (error) {
|
||||
if (!requests.isCurrent(request)) return;
|
||||
handleError(error, '题目反馈提交失败');
|
||||
} finally {
|
||||
if (requests.isCurrent(request)) feedbackSubmittingId.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -729,7 +892,10 @@ const startAssignment = (item: PracticeAssignment) => {
|
||||
void start(item.scenarioId || defaultScenarioId, item.id);
|
||||
};
|
||||
|
||||
const loadPrepCard = async (scenarioId = selectedScenarioId.value) => {
|
||||
const loadPrepCard = async (
|
||||
scenarioId = selectedScenarioId.value,
|
||||
request: RequestScopeTicket = requests.begin('prep-card')
|
||||
) => {
|
||||
refreshAuthState();
|
||||
if (!loggedIn.value) {
|
||||
prepCard.value = null;
|
||||
@@ -737,18 +903,24 @@ const loadPrepCard = async (scenarioId = selectedScenarioId.value) => {
|
||||
}
|
||||
prepLoading.value = true;
|
||||
try {
|
||||
prepCard.value = await getPracticePrepCard(scenarioId);
|
||||
const nextPrepCard = await getPracticePrepCard(scenarioId);
|
||||
if (!requests.isCurrent(request)) return;
|
||||
prepCard.value = nextPrepCard;
|
||||
} catch (error) {
|
||||
if (!requests.isCurrent(request)) return;
|
||||
prepCard.value = null;
|
||||
handleError(error, '预习卡加载失败');
|
||||
} finally {
|
||||
prepLoading.value = false;
|
||||
if (requests.isCurrent(request)) prepLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const start = async (scenarioId = defaultScenarioId, assignmentId?: number) => {
|
||||
if (!canStartPractice.value) return;
|
||||
if (!requireLogin()) return;
|
||||
const request = requests.begin('session-start');
|
||||
speechPlayback.stop();
|
||||
cancelRecording();
|
||||
selectedScenarioId.value = scenarioId;
|
||||
status.value = 'starting';
|
||||
message.value = '';
|
||||
@@ -771,6 +943,7 @@ const start = async (scenarioId = defaultScenarioId, assignmentId?: number) => {
|
||||
activeAssignmentId.value = assignmentId;
|
||||
try {
|
||||
const data = await startPractice(scenarioId, assignmentId);
|
||||
if (!requests.isCurrent(request)) return;
|
||||
activeScenarioName.value = data.scenarioName || activeScenarioName.value;
|
||||
emotionScore.value = data.emotion ?? 0;
|
||||
trustScore.value = data.trust ?? 0;
|
||||
@@ -778,6 +951,7 @@ const start = async (scenarioId = defaultScenarioId, assignmentId?: number) => {
|
||||
appendTurn('customer', data.customerText);
|
||||
status.value = 'active';
|
||||
} catch (error) {
|
||||
if (!requests.isCurrent(request)) return;
|
||||
status.value = 'idle';
|
||||
activeAssignmentId.value = undefined;
|
||||
handleError(error, '训练启动失败');
|
||||
@@ -787,19 +961,22 @@ const start = async (scenarioId = defaultScenarioId, assignmentId?: number) => {
|
||||
const loadHelp = async () => {
|
||||
if (helpLoading.value || busy.value) return;
|
||||
if (!requireLogin()) return;
|
||||
const request = requests.begin('help');
|
||||
helpLoading.value = true;
|
||||
message.value = '';
|
||||
try {
|
||||
const data = await searchKnowledge(`${activeScenarioName.value} SOP 处理要点`);
|
||||
if (!requests.isCurrent(request)) return;
|
||||
if (!data.answer) {
|
||||
throw new Error('未检索到可用 SOP 提示');
|
||||
}
|
||||
helpAnswer.value = data.answer;
|
||||
helpRefs.value = (data.snippets || []).map((item) => item.docTitle).filter(Boolean).slice(0, 3);
|
||||
} catch (error) {
|
||||
if (!requests.isCurrent(request)) return;
|
||||
handleError(error, '求助失败');
|
||||
} finally {
|
||||
helpLoading.value = false;
|
||||
if (requests.isCurrent(request)) helpLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -824,6 +1001,7 @@ const discardLastCoachAttempt = () => {
|
||||
const adjustCoachHint = async (style: string) => {
|
||||
if (busy.value || adjustingStyle.value) return;
|
||||
if (!sessionId.value || lastCoachRoundIndex.value === null || !lastTraineeText.value) return;
|
||||
const request = requests.begin('coach-adjust');
|
||||
adjustingStyle.value = style;
|
||||
message.value = '';
|
||||
try {
|
||||
@@ -834,15 +1012,17 @@ const adjustCoachHint = async (style: string) => {
|
||||
regenerate: true,
|
||||
style
|
||||
});
|
||||
if (!requests.isCurrent(request)) return;
|
||||
replaceLastCoachHint(data.coachHint);
|
||||
emotionScore.value = data.emotion ?? emotionScore.value;
|
||||
trustScore.value = data.trust ?? trustScore.value;
|
||||
redFlagActive.value = Boolean(data.redFlag);
|
||||
if (data.message) message.value = data.message;
|
||||
} catch (error) {
|
||||
if (!requests.isCurrent(request)) return;
|
||||
handleError(error, '建议调整失败');
|
||||
} finally {
|
||||
adjustingStyle.value = '';
|
||||
if (requests.isCurrent(request)) adjustingStyle.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -853,6 +1033,7 @@ const submit = async () => {
|
||||
message.value = '请先输入回应';
|
||||
return;
|
||||
}
|
||||
const request = requests.begin('turn-submit');
|
||||
status.value = 'submitting';
|
||||
message.value = '';
|
||||
const audioUrl = draftAudioUrl.value || undefined;
|
||||
@@ -872,6 +1053,7 @@ const submit = async () => {
|
||||
traineeAudioUrl: audioUrl,
|
||||
traineeAudioOssId: audioOssId
|
||||
});
|
||||
if (!requests.isCurrent(request)) return;
|
||||
lastTraineeText.value = text;
|
||||
lastCoachRoundIndex.value = submittedRound;
|
||||
emotionScore.value = data.emotion ?? emotionScore.value;
|
||||
@@ -891,12 +1073,14 @@ const submit = async () => {
|
||||
roundIndex.value = data.roundIndex;
|
||||
status.value = 'active';
|
||||
} catch (error) {
|
||||
if (!requests.isCurrent(request)) return;
|
||||
status.value = 'active';
|
||||
turns.value = previousTurns;
|
||||
draft.value = text;
|
||||
draftAudioUrl.value = audioUrl || '';
|
||||
draftAudioOssId.value = audioOssId;
|
||||
handleError(error, '提交失败');
|
||||
uni.showToast({ title: message.value || '提交失败', icon: 'none', duration: 3000 });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -907,15 +1091,22 @@ const finish = async (force = false) => {
|
||||
message.value = redFlagActive.value ? '请先按老师傅建议重说本轮' : '请先完成至少一轮回应';
|
||||
return;
|
||||
}
|
||||
const request = requests.begin('finish');
|
||||
status.value = 'submitting';
|
||||
message.value = '';
|
||||
try {
|
||||
result.value = await finishPractice(sessionId.value, activeAssignmentId.value);
|
||||
const assignmentId = activeAssignmentId.value;
|
||||
const nextResult = await finishPractice(sessionId.value, assignmentId);
|
||||
if (!requests.isCurrent(request)) return;
|
||||
result.value = nextResult;
|
||||
status.value = 'finished';
|
||||
message.value = '训练完成,主管端待复盘已更新';
|
||||
completedAssignmentId.value = assignmentId;
|
||||
completedAssignmentFeedback.value = null;
|
||||
activeAssignmentId.value = undefined;
|
||||
await loadAssignments();
|
||||
} catch (error) {
|
||||
if (!requests.isCurrent(request)) return;
|
||||
status.value = 'active';
|
||||
handleError(error, '评分失败');
|
||||
}
|
||||
@@ -923,32 +1114,66 @@ const finish = async (force = false) => {
|
||||
|
||||
const sendSatisfaction = async () => {
|
||||
if (!sessionId.value || satisfactionScore.value < 1 || satisfactionScore.value > 5 || satisfactionBusy.value) return;
|
||||
const request = requests.begin('satisfaction');
|
||||
satisfactionBusy.value = true;
|
||||
message.value = '';
|
||||
try {
|
||||
await submitPracticeSatisfaction(sessionId.value, satisfactionScore.value, satisfactionComment.value.trim());
|
||||
if (!requests.isCurrent(request)) return;
|
||||
satisfactionSubmitted.value = true;
|
||||
} catch (error) {
|
||||
if (!requests.isCurrent(request)) return;
|
||||
handleError(error, '体验反馈提交失败');
|
||||
} finally {
|
||||
satisfactionBusy.value = false;
|
||||
if (requests.isCurrent(request)) satisfactionBusy.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleShow = async () => {
|
||||
const pageRequest = requests.begin('page');
|
||||
resetPageScroll();
|
||||
refreshAuthState();
|
||||
if (loggedIn.value && !(await ensureEmployeePosition('/pages/user/practice/index'))) {
|
||||
if (loggedIn.value && (!(await ensureEmployeePosition('/pages/user/practice/index')) || !requests.isCurrent(pageRequest))) {
|
||||
return;
|
||||
}
|
||||
if (!requests.isCurrent(pageRequest)) return;
|
||||
positionLabel.value = getSelectedPosition() || '生活顾问';
|
||||
void loadAssignments();
|
||||
void loadPrepCard();
|
||||
void loadAssignments(requests.begin('assignments'));
|
||||
void loadPrepCard(selectedScenarioId.value, requests.begin('prep-card'));
|
||||
};
|
||||
|
||||
const invalidateRequests = () => {
|
||||
requests.invalidate();
|
||||
assignmentLoading.value = false;
|
||||
prepLoading.value = false;
|
||||
answeringId.value = null;
|
||||
feedbackSubmittingId.value = null;
|
||||
helpLoading.value = false;
|
||||
adjustingStyle.value = '';
|
||||
satisfactionBusy.value = false;
|
||||
if (status.value === 'starting') status.value = 'idle';
|
||||
if (status.value === 'submitting') status.value = sessionId.value ? 'active' : 'idle';
|
||||
};
|
||||
|
||||
onShow(() => {
|
||||
const nextAccountKey = currentAccountKey();
|
||||
if (visibleAccountKey && visibleAccountKey !== nextAccountKey) clearPracticeData();
|
||||
visibleAccountKey = nextAccountKey;
|
||||
requests.activate();
|
||||
void handleShow();
|
||||
});
|
||||
|
||||
onHide(() => {
|
||||
invalidateRequests();
|
||||
speechPlayback.stop();
|
||||
cancelRecording();
|
||||
});
|
||||
|
||||
onUnload(() => {
|
||||
invalidateRequests();
|
||||
speechPlayback.destroy();
|
||||
cancelRecording();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -1694,7 +1919,7 @@ onShow(() => {
|
||||
|
||||
.prep-mini text:last-child {
|
||||
color: var(--employee-muted);
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
@@ -1833,10 +2058,16 @@ onShow(() => {
|
||||
}
|
||||
|
||||
.daily-row .daily-answer {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.daily-row .small-button {
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
background: linear-gradient(135deg, #ed1f28, #ff4b4d);
|
||||
box-shadow: 0 6px 12px rgba(230, 0, 18, 0.16);
|
||||
}
|
||||
@@ -1872,7 +2103,7 @@ onShow(() => {
|
||||
|
||||
.capability-strip text:last-child {
|
||||
color: var(--employee-muted);
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.practice-head .page-title {
|
||||
@@ -2019,4 +2250,101 @@ onShow(() => {
|
||||
background: linear-gradient(135deg, #ed1f28, #ff4b4d);
|
||||
box-shadow: 0 8px 16px rgba(231, 0, 20, 0.18);
|
||||
}
|
||||
|
||||
.turn-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.turn-voice-button {
|
||||
width: auto;
|
||||
min-width: 76px;
|
||||
min-height: 32px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #f2b9bf;
|
||||
border-radius: 999px;
|
||||
background: #ffffff;
|
||||
color: #e60012;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
line-height: 30px;
|
||||
}
|
||||
|
||||
.daily-question-voice {
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
|
||||
.daily-feedback-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
padding: 12px;
|
||||
border: 1px solid #f4c9cd;
|
||||
border-radius: 14px;
|
||||
background: #fff8f8;
|
||||
}
|
||||
|
||||
.daily-feedback-head,
|
||||
.result-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.daily-feedback-title {
|
||||
color: var(--employee-text);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.daily-feedback-copy,
|
||||
.daily-reference-copy {
|
||||
color: var(--employee-text);
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.item-feedback-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-top: 4px;
|
||||
color: var(--employee-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.item-feedback-actions button {
|
||||
min-height: 34px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #d4dce6;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
color: #4f5c70;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.item-feedback-actions button.active {
|
||||
border-color: #ef9da5;
|
||||
background: var(--employee-primary-soft);
|
||||
color: var(--employee-primary);
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.result-feedback-actions {
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--employee-line);
|
||||
}
|
||||
|
||||
.daily-reference-copy {
|
||||
color: var(--employee-muted);
|
||||
}
|
||||
|
||||
.result-voice-button {
|
||||
flex: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -22,22 +22,42 @@
|
||||
<view>
|
||||
<text class="panel-title">岗位身份</text>
|
||||
<text class="position-title">{{ positionProfile.value }} · {{ positionProfile.heroTitle }}</text>
|
||||
<text class="position-source">岗位来自人力系统组织同步,如需调整请联系 HR。</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="position-actions">
|
||||
<button
|
||||
v-for="item in positionCards"
|
||||
:key="item.value"
|
||||
class="position-chip"
|
||||
:class="{ active: item.value === positionProfile.value }"
|
||||
@click="changePosition(item.value)"
|
||||
>
|
||||
{{ item.value }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="profile" class="panel profile-summary">
|
||||
<view v-if="supervisorLearning" class="panel supervisor-mode-panel">
|
||||
<view>
|
||||
<text class="panel-title">当前为学员模式</text>
|
||||
<text class="position-source">主管组织身份未改变,仅切换到个人学习与训练界面。</text>
|
||||
</view>
|
||||
<button class="secondary-button account-action" @click="returnToSupervisor">返回主管工作台</button>
|
||||
</view>
|
||||
|
||||
<view class="panel display-panel">
|
||||
<view class="display-heading">
|
||||
<view>
|
||||
<text class="panel-title">显示与辅助</text>
|
||||
<text class="display-current">字体大小 · {{ currentFontSizeLabel }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="font-size-options" role="group" aria-label="字体大小">
|
||||
<button
|
||||
v-for="option in appFontSizeOptions"
|
||||
:key="option.value"
|
||||
class="font-size-option"
|
||||
:class="{ active: fontSize === option.value }"
|
||||
:aria-pressed="fontSize === option.value"
|
||||
@click="changeFontSize(option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</view>
|
||||
<text class="display-note">选择后会同步到今日、练、问、我等全部页面,并保留你的设置。</text>
|
||||
</view>
|
||||
|
||||
<view v-if="loggedIn && profile" class="panel profile-summary">
|
||||
<text class="panel-title">成长摘要</text>
|
||||
<text class="ai-score-disclaimer">AI训练综合分仅作训练参考,不等于BRD五维绩效总分,人可复核/否决</text>
|
||||
<view class="score-row">
|
||||
@@ -92,7 +112,15 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="evidence" class="panel evidence-panel">
|
||||
<button v-if="loggedIn" class="panel points-entry" @click="openPoints">
|
||||
<view>
|
||||
<text class="panel-title">我的积分</text>
|
||||
<text class="points-entry-desc">查看问题榜奖励积分与学习学分明细</text>
|
||||
</view>
|
||||
<text class="points-entry-arrow">›</text>
|
||||
</button>
|
||||
|
||||
<view v-if="loggedIn && evidence" class="panel evidence-panel">
|
||||
<text class="panel-title">成长证据包(待人工复核)</text>
|
||||
<view class="evidence-grid">
|
||||
<view class="evidence-metric">
|
||||
@@ -153,20 +181,24 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { onShow } from '@dcloudio/uni-app';
|
||||
import type { CompetencyProfile, PracticeRecord, PromotionEvidence } from '@/types/api';
|
||||
import {
|
||||
clearAuth,
|
||||
getAuth,
|
||||
getPositionProfile,
|
||||
getSelectedPositionProfile,
|
||||
isLoggedIn,
|
||||
rememberLoginRedirect,
|
||||
saveSelectedPosition,
|
||||
userPositionOptions,
|
||||
type UserPosition
|
||||
isSupervisorLearnerMode,
|
||||
leaveSupervisorLearnerMode,
|
||||
rememberLoginRedirect
|
||||
} from '@/services/auth';
|
||||
import {
|
||||
appFontSizeOptions,
|
||||
getAppFontSize,
|
||||
setAppFontSize,
|
||||
type AppFontSize
|
||||
} from '@/services/accessibility';
|
||||
import { getCompetencyProfile, getPracticeHistory, getPromotionEvidence } from '@/services/practice';
|
||||
import { resetPageScroll } from '@/services/navigation';
|
||||
import { ensureEmployeePosition } from '@/services/position';
|
||||
@@ -179,31 +211,62 @@ const profile = ref<CompetencyProfile | null>(null);
|
||||
const history = ref<PracticeRecord[]>([]);
|
||||
const evidence = ref<PromotionEvidence | null>(null);
|
||||
const positionProfile = ref(getSelectedPositionProfile());
|
||||
const positionCards = userPositionOptions.map((item) => getPositionProfile(item));
|
||||
const supervisorLearning = ref(false);
|
||||
const fontSize = ref<AppFontSize>(getAppFontSize());
|
||||
const currentFontSizeLabel = computed(() =>
|
||||
appFontSizeOptions.find((item) => item.value === fontSize.value)?.label || '标准'
|
||||
);
|
||||
let profileLoadGeneration = 0;
|
||||
let profileAccountPhone = '';
|
||||
|
||||
const refreshAuth = () => {
|
||||
const auth = getAuth();
|
||||
loggedIn.value = isLoggedIn();
|
||||
phone.value = auth.phone;
|
||||
positionProfile.value = getSelectedPositionProfile();
|
||||
supervisorLearning.value = isSupervisorLearnerMode();
|
||||
fontSize.value = getAppFontSize();
|
||||
};
|
||||
|
||||
const changeFontSize = (value: AppFontSize) => {
|
||||
fontSize.value = setAppFontSize(value);
|
||||
uni.showToast({ title: `已切换为${currentFontSizeLabel.value}`, icon: 'none' });
|
||||
};
|
||||
|
||||
const returnToSupervisor = () => {
|
||||
leaveSupervisorLearnerMode();
|
||||
supervisorLearning.value = false;
|
||||
uni.redirectTo({ url: '/pages/supervisor/index/index' });
|
||||
};
|
||||
|
||||
const openPoints = () => {
|
||||
uni.navigateTo({ url: '/pages/user/points/index' });
|
||||
};
|
||||
|
||||
const clearProfileData = () => {
|
||||
profile.value = null;
|
||||
history.value = [];
|
||||
evidence.value = null;
|
||||
profileAccountPhone = '';
|
||||
};
|
||||
|
||||
const loadData = async () => {
|
||||
const generation = ++profileLoadGeneration;
|
||||
refreshAuth();
|
||||
if (!loggedIn.value) {
|
||||
clearProfileData();
|
||||
return;
|
||||
}
|
||||
const requestedPhone = getAuth().phone;
|
||||
if (profileAccountPhone && profileAccountPhone !== requestedPhone) {
|
||||
clearProfileData();
|
||||
}
|
||||
if (!(await ensureEmployeePosition('/pages/user/profile/index'))) {
|
||||
return;
|
||||
}
|
||||
refreshAuth();
|
||||
if (generation !== profileLoadGeneration || !loggedIn.value) return;
|
||||
const accountPhone = getAuth().phone;
|
||||
loading.value = true;
|
||||
message.value = '';
|
||||
try {
|
||||
@@ -212,19 +275,27 @@ const loadData = async () => {
|
||||
getCompetencyProfile(),
|
||||
getPromotionEvidence()
|
||||
]);
|
||||
refreshAuth();
|
||||
if (generation !== profileLoadGeneration) return;
|
||||
if (!loggedIn.value || getAuth().phone !== accountPhone) {
|
||||
clearProfileData();
|
||||
return;
|
||||
}
|
||||
history.value = nextHistory || [];
|
||||
profile.value = nextProfile || null;
|
||||
evidence.value = nextEvidence || null;
|
||||
profileAccountPhone = accountPhone;
|
||||
} catch (error) {
|
||||
refreshAuth();
|
||||
if (!loggedIn.value) {
|
||||
if (generation !== profileLoadGeneration) return;
|
||||
if (!loggedIn.value || getAuth().phone !== accountPhone) {
|
||||
clearProfileData();
|
||||
message.value = '登录已过期,请重新登录';
|
||||
message.value = loggedIn.value ? '账号已切换,请刷新' : '登录已过期,请重新登录';
|
||||
return;
|
||||
}
|
||||
message.value = error instanceof Error ? error.message : '我的数据加载失败';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
if (generation === profileLoadGeneration) loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -234,19 +305,14 @@ const goLogin = () => {
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
profileLoadGeneration += 1;
|
||||
loading.value = false;
|
||||
clearAuth();
|
||||
clearProfileData();
|
||||
refreshAuth();
|
||||
uni.showToast({ title: '已退出', icon: 'none' });
|
||||
};
|
||||
|
||||
const changePosition = (position: UserPosition) => {
|
||||
const saved = saveSelectedPosition(position);
|
||||
if (!saved) return;
|
||||
positionProfile.value = getSelectedPositionProfile();
|
||||
uni.showToast({ title: '岗位已更新', icon: 'none' });
|
||||
};
|
||||
|
||||
const openHistory = (id?: number) => {
|
||||
if (!id) {
|
||||
message.value = '这条训练记录暂不支持查看详情';
|
||||
@@ -313,13 +379,58 @@ onShow(() => {
|
||||
}
|
||||
|
||||
.account-card,
|
||||
.history-head {
|
||||
.history-head,
|
||||
.supervisor-mode-panel,
|
||||
.display-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.display-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.display-current,
|
||||
.display-note {
|
||||
display: block;
|
||||
margin-top: 7px;
|
||||
color: var(--employee-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.font-size-options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
padding: 4px;
|
||||
border-radius: 14px;
|
||||
background: #f0f3f8;
|
||||
}
|
||||
|
||||
.font-size-option {
|
||||
min-width: 0;
|
||||
min-height: 42px;
|
||||
padding: 0 2px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 11px;
|
||||
background: transparent;
|
||||
color: var(--employee-text);
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.font-size-option.active {
|
||||
border-color: #f0c8cc;
|
||||
background: #ffffff;
|
||||
color: var(--employee-primary);
|
||||
box-shadow: 0 4px 12px rgba(24, 34, 48, 0.08);
|
||||
}
|
||||
|
||||
.account-card {
|
||||
margin-top: 16px;
|
||||
border-left: 4px solid var(--employee-primary);
|
||||
@@ -352,31 +463,12 @@ onShow(() => {
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.position-actions {
|
||||
display: grid;
|
||||
margin-top: 14px;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.position-chip {
|
||||
display: flex;
|
||||
min-height: 38px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid #dce5f2;
|
||||
border-radius: var(--employee-chip-radius);
|
||||
background: #f8fbff;
|
||||
color: #526176;
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.position-chip.active {
|
||||
border-color: var(--employee-primary);
|
||||
background: var(--employee-primary-soft);
|
||||
color: var(--employee-primary);
|
||||
.position-source {
|
||||
display: block;
|
||||
margin-top: 7px;
|
||||
color: var(--employee-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.profile-summary {
|
||||
@@ -572,6 +664,28 @@ onShow(() => {
|
||||
background: var(--employee-primary);
|
||||
}
|
||||
|
||||
.points-entry {
|
||||
display: flex;
|
||||
width: calc(100% - 0px);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: var(--ink);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.points-entry-desc {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.points-entry-arrow {
|
||||
color: var(--muted);
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.dimension-row,
|
||||
.history-row {
|
||||
display: flex;
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
<template>
|
||||
<view class="page employee-page-shell detail-page">
|
||||
<view class="subpage-header">
|
||||
<button class="subpage-back-button" aria-label="返回问题榜" @click="goBack">←</button>
|
||||
<view class="subpage-heading">
|
||||
<text class="page-title">问题详情</text>
|
||||
<text class="page-subtitle">回答经审核后公开,最佳答案按当前规则获得积分或学习学分。</text>
|
||||
</view>
|
||||
</view>
|
||||
<text v-if="message" class="status-message">{{ message }}</text>
|
||||
<view v-if="loading" class="panel employee-card">加载中…</view>
|
||||
<template v-else-if="question">
|
||||
<view class="panel employee-card question-panel">
|
||||
<view class="question-meta">
|
||||
<text>{{ question.category || '工作求助' }} · {{ statusLabel(question.status) }}</text>
|
||||
<text v-if="question.rewardPoints > 0" class="reward">奖励 {{ question.rewardPoints }} {{ rewardUnit(question.rewardAccountType) }}</text>
|
||||
</view>
|
||||
<text class="question-title">{{ question.title }}</text>
|
||||
<text class="question-content">{{ question.content }}</text>
|
||||
<text class="author">{{ question.authorName }}{{ question.position ? ` · ${question.position}` : '' }} · {{ question.createTime }}</text>
|
||||
<view v-if="bestAnswer" class="best-summary">
|
||||
<text class="best-summary-title">回答已被采纳</text>
|
||||
<text>最佳答案:{{ bestAnswer.authorName }} · 奖励按后台规则幂等记账</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="canAnswer" class="panel employee-card answer-compose">
|
||||
<text class="panel-title">写下你的经验</text>
|
||||
<textarea v-model="answer" class="textarea" maxlength="1000" placeholder="说明具体做法、依据和注意事项;不要填写业主隐私。" />
|
||||
<button class="primary-button" :disabled="submitting || !answer.trim()" @click="submitAnswer">
|
||||
{{ submitting ? '提交中' : '提交回答' }}
|
||||
</button>
|
||||
<text class="muted hint">提交后进入审核;最佳答案按后台规则记入积分或学习学分账户。</text>
|
||||
</view>
|
||||
|
||||
<view class="answers-section">
|
||||
<text class="section-title">全部回答({{ question.answers?.length || 0 }})</text>
|
||||
<view v-if="question.answers?.length" class="answer-list">
|
||||
<view v-for="item in orderedAnswers" :key="item.id" class="answer-card employee-card" :class="{ best: item.best }">
|
||||
<view class="answer-head">
|
||||
<text>{{ item.authorName }}{{ item.authorPosition ? ` · ${item.authorPosition}` : '' }}</text>
|
||||
<text v-if="item.best" class="best-badge">最佳答案</text>
|
||||
<text v-else-if="item.recommended" class="recommend-badge">主管推荐</text>
|
||||
</view>
|
||||
<text class="answer-content">{{ item.content }}</text>
|
||||
<text class="answer-time">{{ item.createTime }}</text>
|
||||
<button
|
||||
v-if="canChooseBest(item)"
|
||||
class="ghost-button best-button"
|
||||
:disabled="choosingBestId === item.id"
|
||||
@click="chooseBest(item.id)"
|
||||
>{{ choosingBestId === item.id ? '采纳中' : '采纳为最佳答案' }}</button>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="panel employee-card empty">还没有公开回答,来分享第一条可复用经验吧。</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app';
|
||||
import type { QuestionBoardAnswer, QuestionBoardItem } from '@/types/api';
|
||||
import { answerQuestion, chooseBestAnswer, getQuestion } from '@/services/questions';
|
||||
|
||||
const id = ref(0);
|
||||
const question = ref<QuestionBoardItem | null>(null);
|
||||
const answer = ref('');
|
||||
const loading = ref(false);
|
||||
const submitting = ref(false);
|
||||
const choosingBestId = ref(0);
|
||||
const message = ref('');
|
||||
const orderedAnswers = computed(() => [...(question.value?.answers || [])].sort((a, b) => Number(b.best) - Number(a.best)));
|
||||
const bestAnswer = computed(() => orderedAnswers.value.find((item) => item.best));
|
||||
const canAnswer = computed(() => question.value?.status === 'PUBLISHED' && !question.value.mine);
|
||||
const canChooseBest = (item?: QuestionBoardAnswer) => Boolean(
|
||||
item
|
||||
&& question.value?.mine
|
||||
&& question.value.status === 'PUBLISHED'
|
||||
&& !question.value.bestAnswerId
|
||||
&& !item.mine
|
||||
&& item.status === 'PUBLISHED'
|
||||
);
|
||||
const statusLabel = (value: string) => ({ PENDING: '待审核', PUBLISHED: '讨论中', CLOSED: '已解决', REJECTED: '未通过' }[value] || value);
|
||||
const rewardUnit = (accountType?: string) => accountType === 'STUDY_CREDIT' ? '学分' : '积分';
|
||||
const goBack = () => uni.navigateBack({ delta: 1 });
|
||||
const load = async () => {
|
||||
if (!id.value) return;
|
||||
loading.value = true;
|
||||
try { question.value = await getQuestion(id.value); }
|
||||
catch (error) { message.value = error instanceof Error ? error.message : '问题读取失败'; }
|
||||
finally { loading.value = false; }
|
||||
};
|
||||
const submitAnswer = async () => {
|
||||
if (!answer.value.trim() || submitting.value) return;
|
||||
submitting.value = true;
|
||||
message.value = '';
|
||||
try {
|
||||
await answerQuestion(id.value, answer.value.trim());
|
||||
answer.value = '';
|
||||
message.value = '回答已提交,审核通过后会公开';
|
||||
await load();
|
||||
} catch (error) { message.value = error instanceof Error ? error.message : '回答提交失败'; }
|
||||
finally { submitting.value = false; }
|
||||
};
|
||||
const chooseBest = async (answerId: number) => {
|
||||
if (!canChooseBest(orderedAnswers.value.find((item) => item.id === answerId)) || choosingBestId.value) return;
|
||||
choosingBestId.value = answerId;
|
||||
message.value = '';
|
||||
try {
|
||||
await chooseBestAnswer(id.value, answerId);
|
||||
message.value = '已采纳最佳答案,奖励已按后台规则记账';
|
||||
await load();
|
||||
} catch (error) {
|
||||
message.value = error instanceof Error ? error.message : '最佳答案设置失败';
|
||||
} finally {
|
||||
choosingBestId.value = 0;
|
||||
}
|
||||
};
|
||||
onLoad((options) => { id.value = Number(options?.id || 0); });
|
||||
onShow(() => { void load(); });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.detail-page { max-width: var(--mobile-shell-max-width); margin: 0 auto; }
|
||||
.status-message { display: block; margin-top: 14px; padding: 12px; border-radius: 10px; background: #fff0f1; color: #a9000d; }
|
||||
.question-panel, .answer-compose { display: grid; gap: 12px; }
|
||||
.question-meta, .answer-head { display: flex; gap: 8px; align-items: center; justify-content: space-between; color: var(--knowledge); font-size: 12px; font-weight: 900; }
|
||||
.reward { color: #a86100; }
|
||||
.question-title { overflow-wrap: anywhere; font-size: 20px; font-weight: 900; line-height: 1.45; }
|
||||
.question-content, .answer-content { overflow-wrap: anywhere; font-size: 15px; line-height: 1.75; white-space: pre-wrap; }
|
||||
.author, .answer-time, .hint { color: var(--muted); font-size: 12px; line-height: 1.5; }
|
||||
.answers-section { margin-top: 20px; }
|
||||
.section-title { display: block; font-size: 18px; font-weight: 900; }
|
||||
.answer-list { display: grid; gap: 12px; margin-top: 12px; }
|
||||
.answer-card { display: grid; gap: 10px; padding: 16px; }
|
||||
.answer-card.best { border: 2px solid #f0b64b; background: #fffaf0; }
|
||||
.best-badge { padding: 4px 8px; border-radius: 999px; background: #f3b23c; color: #fff; }
|
||||
.recommend-badge { color: var(--knowledge); }
|
||||
.empty { color: var(--muted); line-height: 1.6; }
|
||||
.best-summary { display:grid; gap:5px; padding:12px; border-radius:12px; background:#fff7df; color:#79500b; font-size:12px; line-height:1.55; }
|
||||
.best-summary-title { font-size:15px; font-weight:900; }
|
||||
.best-button { justify-self:start; min-height:40px; padding:0 14px; color:#8a5a00; }
|
||||
</style>
|
||||
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<view class="page employee-page-shell question-page">
|
||||
<view class="subpage-header">
|
||||
<button class="subpage-back-button" aria-label="返回今日" @click="goToday">←</button>
|
||||
<view class="subpage-heading">
|
||||
<text class="employee-kicker">广谱师傅</text>
|
||||
<text class="page-title">开放问题榜</text>
|
||||
<text class="page-subtitle">全公司都能回答,用真实经验帮助一线同事。</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="hero-actions">
|
||||
<button class="hero-action employee-card" @click="goPublish">
|
||||
<text class="hero-action-title">今天遇到什么难题了?</text>
|
||||
<text class="hero-action-copy">发布真实工作求助</text>
|
||||
<text class="hero-action-link">立即发布 ›</text>
|
||||
</button>
|
||||
<button class="hero-action employee-card rewarded" @click="switchTab('REWARDED')">
|
||||
<text class="hero-action-title">有奖征问题</text>
|
||||
<text class="hero-action-copy">贡献值得讨论的好问题</text>
|
||||
<text class="hero-action-link">立即查看 ›</text>
|
||||
</button>
|
||||
</view>
|
||||
<button class="points-link" @click="goPoints">我的积分与学分 ›</button>
|
||||
|
||||
<view class="tab-scroll">
|
||||
<view class="tab-row">
|
||||
<button
|
||||
v-for="item in tabs"
|
||||
:key="item.key"
|
||||
class="filter-chip"
|
||||
:class="{ active: tab === item.key }"
|
||||
:aria-pressed="tab === item.key"
|
||||
@click="switchTab(item.key)"
|
||||
>{{ item.label }}</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="message" class="status-message">{{ message }}</view>
|
||||
<view v-if="loading" class="panel employee-card empty">问题加载中…</view>
|
||||
<view v-else-if="!questions.length" class="panel employee-card empty">
|
||||
<text class="empty-title">当前暂无问题</text>
|
||||
<text class="muted">你可以发起第一条真实工作求助。</text>
|
||||
</view>
|
||||
<view v-else class="question-list">
|
||||
<button v-for="item in questions" :key="item.id" class="question-card employee-card" @click="openQuestion(item.id)">
|
||||
<view class="question-meta">
|
||||
<text class="category">{{ item.category || '工作求助' }}</text>
|
||||
<text v-if="item.rewardPoints > 0" class="reward">奖励 {{ item.rewardPoints }} {{ rewardUnit(item.rewardAccountType) }}</text>
|
||||
</view>
|
||||
<text class="question-title">{{ item.title }}</text>
|
||||
<text v-if="item.content" class="question-excerpt">{{ excerpt(item.content) }}</text>
|
||||
<view class="question-foot">
|
||||
<text>{{ item.authorName }}{{ item.position ? ` · ${item.position}` : '' }}</text>
|
||||
<text>{{ item.answerCount }} 个回答 · {{ statusLabel(item.status) }}</text>
|
||||
</view>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { onShow } from '@dcloudio/uni-app';
|
||||
import type { QuestionBoardItem, QuestionBoardTab } from '@/types/api';
|
||||
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||||
import { listQuestions } from '@/services/questions';
|
||||
import { resetPageScroll } from '@/services/navigation';
|
||||
|
||||
const path = '/pages/user/questions/index';
|
||||
const tabs: { key: QuestionBoardTab; label: string }[] = [
|
||||
{ key: 'HOT', label: '热门' },
|
||||
{ key: 'UNANSWERED', label: '待回答' },
|
||||
{ key: 'REWARDED', label: '有奖励' },
|
||||
{ key: 'MINE', label: '我的提问' }
|
||||
];
|
||||
const tab = ref<QuestionBoardTab>('HOT');
|
||||
const questions = ref<QuestionBoardItem[]>([]);
|
||||
const loading = ref(false);
|
||||
const message = ref('');
|
||||
|
||||
const ensureLogin = () => {
|
||||
if (isLoggedIn()) return true;
|
||||
rememberLoginRedirect(path);
|
||||
uni.navigateTo({ url: '/pages/auth/login/index' });
|
||||
return false;
|
||||
};
|
||||
const load = async () => {
|
||||
if (!ensureLogin()) return;
|
||||
loading.value = true;
|
||||
message.value = '';
|
||||
try {
|
||||
questions.value = await listQuestions(tab.value);
|
||||
} catch (error) {
|
||||
questions.value = [];
|
||||
message.value = error instanceof Error ? error.message : '问题榜加载失败';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
const switchTab = (value: QuestionBoardTab) => { tab.value = value; void load(); };
|
||||
const excerpt = (value: string) => value.length > 92 ? `${value.slice(0, 92)}…` : value;
|
||||
const rewardUnit = (accountType?: string) => accountType === 'STUDY_CREDIT' ? '学分' : '积分';
|
||||
const statusLabel = (value: string) => ({
|
||||
PENDING: '待审核',
|
||||
PUBLISHED: '讨论中',
|
||||
CLOSED: '已解决',
|
||||
REJECTED: '未通过'
|
||||
}[value] || value);
|
||||
const goToday = () => uni.switchTab({ url: '/pages/user/today/index' });
|
||||
const goPublish = () => { if (ensureLogin()) uni.navigateTo({ url: '/pages/user/questions/publish' }); };
|
||||
const goPoints = () => { if (ensureLogin()) uni.navigateTo({ url: '/pages/user/points/index' }); };
|
||||
const openQuestion = (id: number) => uni.navigateTo({ url: `/pages/user/questions/detail?id=${id}` });
|
||||
|
||||
onShow(() => { resetPageScroll(); void load(); });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.question-page { max-width: var(--mobile-shell-max-width); margin: 0 auto; }
|
||||
.hero-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 18px; }
|
||||
.hero-action { display:grid; gap:7px; min-height:132px; padding:16px; color:var(--ink); text-align:left; }
|
||||
.hero-action-title,.hero-action-copy,.hero-action-link { display:block; overflow-wrap:anywhere; }
|
||||
.hero-action-title { color:var(--brand); font-size:16px; font-weight:900; line-height:1.45; }
|
||||
.hero-action-copy { color:var(--muted); font-size:12px; line-height:1.5; }
|
||||
.hero-action-link { margin-top:auto; color:var(--brand); font-size:13px; font-weight:900; }
|
||||
.hero-action.rewarded .hero-action-title,.hero-action.rewarded .hero-action-link { color:#d66d00; }
|
||||
.points-link { display:block; min-height:40px; margin:8px 0 0 auto; background:transparent; color:var(--knowledge); font-size:13px; font-weight:800; }
|
||||
.tab-scroll { margin-top: 18px; }
|
||||
.tab-row { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px; }
|
||||
.filter-chip { width: 100%; min-width: 0; min-height: 40px; padding: 5px 4px; border: 1px solid var(--line); border-radius: 999px; background: #fff; color: var(--muted); font-size: 13px; line-height: 1.25; }
|
||||
.filter-chip.active { border-color: #f2a7ad; background: var(--employee-primary-soft); color: var(--brand); font-weight: 900; }
|
||||
.status-message, .empty { display: grid; gap: 6px; margin-top: 16px; padding: 16px; }
|
||||
.status-message { border-radius: 12px; background: #fff0f1; color: #a9000d; }
|
||||
.empty-title { font-weight: 900; }
|
||||
.question-list { display: grid; gap: 12px; margin-top: 16px; }
|
||||
.question-card { display: grid; width: 100%; gap: 10px; padding: 16px; color: inherit; text-align: left; }
|
||||
.question-meta, .question-foot { display: flex; gap: 8px; align-items: center; justify-content: space-between; }
|
||||
.category { color: var(--knowledge); font-size: 12px; font-weight: 900; }
|
||||
.reward { color: #a86100; font-size: 12px; font-weight: 900; }
|
||||
.question-title { overflow-wrap: anywhere; color: var(--ink); font-size: 17px; font-weight: 900; line-height: 1.45; }
|
||||
.question-excerpt { overflow-wrap: anywhere; color: var(--muted); font-size: 14px; line-height: 1.6; }
|
||||
.question-foot { color: #7b8494; font-size: 12px; }
|
||||
</style>
|
||||
@@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<view class="page employee-page-shell publish-page">
|
||||
<view class="subpage-header">
|
||||
<button class="subpage-back-button" aria-label="返回" @click="goBack">←</button>
|
||||
<view class="subpage-heading">
|
||||
<text class="page-title">发起求助</text>
|
||||
<text class="page-subtitle">员工可发布真实工作问题;有奖征题由运营审核发布。</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="panel employee-card form-panel">
|
||||
<label class="field-label">问题标题</label>
|
||||
<input v-model="title" class="input" maxlength="80" placeholder="一句话说清楚遇到的难题" />
|
||||
<label class="field-label">分类</label>
|
||||
<picker :range="categories" :value="categoryIndex" @change="selectCategory">
|
||||
<view class="input picker-value">{{ categories[categoryIndex] }}</view>
|
||||
</picker>
|
||||
<label class="field-label">补充说明</label>
|
||||
<textarea v-model="content" class="textarea" maxlength="1000" placeholder="写清场景、已尝试的方法和希望获得的帮助;请删除业主隐私。" />
|
||||
<view class="privacy-note">提交后进入内容审核。不要填写姓名、手机号、房号、门禁、身份证或其他敏感信息。</view>
|
||||
<button class="primary-button" :disabled="submitting || !valid" @click="submit">
|
||||
{{ submitting ? '提交中' : '提交审核' }}
|
||||
</button>
|
||||
<text v-if="message" class="status-message">{{ message }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { publishQuestion } from '@/services/questions';
|
||||
|
||||
const categories = ['客户沟通', '工程报修', '秩序安全', '环境保洁', '收费服务', '其他'];
|
||||
const categoryIndex = ref(0);
|
||||
const title = ref('');
|
||||
const content = ref('');
|
||||
const submitting = ref(false);
|
||||
const message = ref('');
|
||||
const valid = computed(() => title.value.trim().length >= 6 && content.value.trim().length >= 12);
|
||||
const goBack = () => uni.navigateBack({ delta: 1 });
|
||||
const selectCategory = (event: { detail: { value: string } }) => { categoryIndex.value = Number(event.detail.value || 0); };
|
||||
const submit = async () => {
|
||||
if (!valid.value || submitting.value) return;
|
||||
submitting.value = true;
|
||||
message.value = '';
|
||||
try {
|
||||
const item = await publishQuestion({ title: title.value.trim(), content: content.value.trim(), category: categories[categoryIndex.value] });
|
||||
uni.showToast({ title: '已提交审核', icon: 'none' });
|
||||
setTimeout(() => uni.redirectTo({ url: `/pages/user/questions/detail?id=${item.id}` }), 350);
|
||||
} catch (error) { message.value = error instanceof Error ? error.message : '提交失败'; }
|
||||
finally { submitting.value = false; }
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.publish-page { max-width: var(--mobile-shell-max-width); margin: 0 auto; }
|
||||
.form-panel { display: grid; gap: 10px; }
|
||||
.field-label { margin-top: 4px; font-size: 14px; font-weight: 900; }
|
||||
.picker-value { display: flex; align-items: center; }
|
||||
.privacy-note { padding: 12px; border-radius: 10px; background: #fff9e9; color: #78500a; font-size: 12px; line-height: 1.55; }
|
||||
.status-message { color: #a9000d; }
|
||||
</style>
|
||||
@@ -8,6 +8,11 @@
|
||||
<text class="toolbar-tip">先把问题问清楚,再按需生成总结卡,不伪造分类能力。</text>
|
||||
</view>
|
||||
|
||||
<view class="source-switch employee-card" aria-label="问答来源">
|
||||
<button class="source-option active" aria-current="page">企业问师傅</button>
|
||||
<button class="source-option" @click="openWebAi">问 AI(全网)</button>
|
||||
</view>
|
||||
|
||||
<view class="panel query-panel employee-card">
|
||||
<view class="query-hero">
|
||||
<view class="query-copy">
|
||||
@@ -25,13 +30,47 @@
|
||||
maxlength="200"
|
||||
:placeholder="quickQuestionPlaceholder"
|
||||
/>
|
||||
<view class="voice-query-actions">
|
||||
<button
|
||||
class="secondary-button voice-query-button"
|
||||
:class="{ 'recording-active': voiceRecording }"
|
||||
:disabled="loading || voiceTranscribing"
|
||||
@click="toggleVoiceRecording"
|
||||
>
|
||||
{{ voiceRecording ? '停止录音' : voiceTranscribing ? '转写中' : '语音提问' }}
|
||||
</button>
|
||||
<button
|
||||
class="ghost-button voice-query-button"
|
||||
:disabled="loading || voiceRecording || voiceTranscribing"
|
||||
@click="pickVoiceAudio"
|
||||
>
|
||||
{{ voiceTranscribing ? '转写中' : '选择音频' }}
|
||||
</button>
|
||||
</view>
|
||||
<text class="voice-query-hint">录音会先转成文字,确认问题后再提交;麦克风不可用时可选择音频。</text>
|
||||
<view class="media-actions">
|
||||
<button class="ghost-button media-button" :disabled="loading" @click="pickMedia">
|
||||
+ 上传图片或视频
|
||||
</button>
|
||||
<text class="media-hint">图片≤10MB,视频≤100MB;仅用于本次现场分析</text>
|
||||
</view>
|
||||
<view v-if="selectedMedia" class="media-file">
|
||||
<view class="media-file-copy">
|
||||
<text class="media-kind">{{ selectedMedia.kind === 'video' ? '现场视频' : '现场图片' }}</text>
|
||||
<text class="media-name">{{ selectedMedia.name }}</text>
|
||||
</view>
|
||||
<button class="media-remove" :disabled="loading" aria-label="移除附件" @click="clearMedia">×</button>
|
||||
</view>
|
||||
<text class="query-caption">常见问题可以直接点下面快捷问法,结果会保留引用依据。</text>
|
||||
<view class="chip-row">
|
||||
<button v-for="item in quickQuestions" :key="item" class="chip" :disabled="loading" @click="useQuickQuestion(item)">
|
||||
{{ item }}
|
||||
</button>
|
||||
</view>
|
||||
<button class="primary-button query-submit" :disabled="loading" @click="search">
|
||||
<button class="secondary-button data-tool-button" :disabled="loading" @click="runDataTool(defaultToolCode)">
|
||||
{{ defaultToolCode === 'TEAM_PRACTICE_SUMMARY' ? '团队训练概况' : '我的训练概况' }}
|
||||
</button>
|
||||
<button class="primary-button query-submit" :disabled="loading || voiceRecording || voiceTranscribing" @click="search">
|
||||
{{ loading ? '检索中' : '问老师傅' }}
|
||||
</button>
|
||||
<text v-if="message" class="status-message">{{ message }}</text>
|
||||
@@ -61,7 +100,12 @@
|
||||
</view>
|
||||
|
||||
<view v-if="result" class="panel answer-panel employee-card">
|
||||
<text class="panel-title">师傅回答</text>
|
||||
<view class="answer-title-row">
|
||||
<text class="panel-title">师傅回答</text>
|
||||
<button class="voice-button" :disabled="speechState.status === 'loading' && speechState.key !== 'answer'" @click="toggleAnswerSpeech">
|
||||
{{ speechButtonLabel('answer') }}
|
||||
</button>
|
||||
</view>
|
||||
<view class="question-card">
|
||||
<text class="question-label">我的问题</text>
|
||||
<text class="question-text">{{ query }}</text>
|
||||
@@ -69,6 +113,7 @@
|
||||
<view class="answer-copy-card">
|
||||
<text class="answer-text">{{ result.answer || '未生成直接答案,请查看下方引用。' }}</text>
|
||||
</view>
|
||||
<text v-if="result.noEvidence" class="status-message">当前授权空间内没有足够依据。</text>
|
||||
|
||||
<view v-if="result.keyPoints?.length" class="keypoints">
|
||||
<text v-for="(point, index) in result.keyPoints" :key="point" class="keypoint">
|
||||
@@ -80,7 +125,7 @@
|
||||
<text v-for="item in result.cautions" :key="item">{{ item }}</text>
|
||||
</view>
|
||||
|
||||
<view class="feedback-actions">
|
||||
<view v-if="currentToolCode === ''" class="feedback-actions">
|
||||
<button
|
||||
class="secondary-button"
|
||||
:class="{ active: feedbackVerdict === 'up' }"
|
||||
@@ -107,12 +152,13 @@
|
||||
<text class="panel-title">引用依据</text>
|
||||
<view v-for="(snippet, index) in result.snippets" :key="`${snippet.docTitle}-${index}`" class="snippet">
|
||||
<text class="snippet-title">{{ snippet.docTitle }}</text>
|
||||
<text v-if="snippet.spaceCode" class="snippet-id">知识空间:{{ snippet.spaceCode }}</text>
|
||||
<text class="snippet-content">{{ snippetExcerpt(snippet.content) }}</text>
|
||||
<text v-if="snippet.fragmentId" class="snippet-id">片段 #{{ snippet.fragmentId }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="result" class="panel summary-panel employee-card">
|
||||
<view v-if="result && currentToolCode === ''" class="panel summary-panel employee-card">
|
||||
<text class="panel-title">总结卡</text>
|
||||
<text class="muted summary-copy">需要时再生成,适合把一段 SOP 整理成步骤卡、话术卡和提醒卡。</text>
|
||||
<button class="ghost-button" :disabled="summaryLoading" @click="buildSummaryCard">
|
||||
@@ -185,7 +231,13 @@
|
||||
</view>
|
||||
</scroll-view>
|
||||
<view class="modal-footer">
|
||||
<button class="primary-button footer-btn" @click="closeSummary">我知道了</button>
|
||||
<button class="secondary-button footer-btn" :disabled="downloadingCard" @click="downloadSummary">
|
||||
{{ downloadingCard ? '生成中' : '下载图片' }}
|
||||
</button>
|
||||
<button class="secondary-button footer-btn" @click="toggleSummarySpeech">
|
||||
{{ speechButtonLabel('summary') }}
|
||||
</button>
|
||||
<button class="primary-button footer-btn footer-close" @click="closeSummary">我知道了</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -194,20 +246,39 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { onShow } from '@dcloudio/uni-app';
|
||||
import { onHide, onShow, onUnload } from '@dcloudio/uni-app';
|
||||
import type {
|
||||
AnswerFeedbackVerdict,
|
||||
KnowledgeMediaSelectedFile,
|
||||
KnowledgeSearchResponse,
|
||||
SpeechPlaybackStatus,
|
||||
SummaryCardResponse
|
||||
} from '@/types/api';
|
||||
import {
|
||||
chooseKnowledgeMedia,
|
||||
generateSummaryCard,
|
||||
queryKnowledgeTool,
|
||||
searchKnowledge,
|
||||
searchKnowledgeWithMedia,
|
||||
submitAnswerFeedback
|
||||
} from '@/services/knowledge';
|
||||
import { getSelectedPosition, getSelectedPositionProfile, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||||
import {
|
||||
getSelectedPosition,
|
||||
getSelectedPositionProfile,
|
||||
isLoggedIn,
|
||||
isSupervisorLearnerMode,
|
||||
rememberLoginRedirect,
|
||||
roleKey
|
||||
} from '@/services/auth';
|
||||
import { resetPageScroll } from '@/services/navigation';
|
||||
import { ensureEmployeePosition } from '@/services/position';
|
||||
import {
|
||||
chooseSpeechAudio,
|
||||
createSpeechPlaybackController,
|
||||
transcribeSpeechBlob,
|
||||
transcribeSpeechFile
|
||||
} from '@/services/speech';
|
||||
import { downloadSummaryCardImage } from '@/services/summary-card-image';
|
||||
|
||||
const positionProfile = ref(getSelectedPositionProfile());
|
||||
const positionLabel = ref(getSelectedPosition());
|
||||
@@ -218,23 +289,62 @@ const result = ref<KnowledgeSearchResponse | null>(null);
|
||||
const summaryCard = ref<SummaryCardResponse | null>(null);
|
||||
const loading = ref(false);
|
||||
const summaryLoading = ref(false);
|
||||
const downloadingCard = ref(false);
|
||||
const selectedMedia = ref<KnowledgeMediaSelectedFile | null>(null);
|
||||
const speechState = ref<{ key: string; status: SpeechPlaybackStatus }>({ key: '', status: 'idle' });
|
||||
const voiceRecording = ref(false);
|
||||
const voiceTranscribing = ref(false);
|
||||
const voiceRecorder = ref<MediaRecorder | null>(null);
|
||||
const voiceStream = ref<MediaStream | null>(null);
|
||||
const voiceStarting = ref(false);
|
||||
const feedbackSubmitting = ref(false);
|
||||
const feedbackVerdict = ref<AnswerFeedbackVerdict | ''>('');
|
||||
const message = ref('');
|
||||
const loggedIn = ref(false);
|
||||
const pendingSopQueryKey = 'aihr_sop_pending_query';
|
||||
const pendingToolKey = 'aihr_sop_pending_tool';
|
||||
const currentToolCode = ref<'' | 'MY_PRACTICE_SUMMARY' | 'TEAM_PRACTICE_SUMMARY'>('');
|
||||
const currentRole = ref(String(uni.getStorageSync(roleKey) || ''));
|
||||
const supervisorLearner = ref(isSupervisorLearnerMode());
|
||||
let voiceCaptureGeneration = 0;
|
||||
let voiceTranscriptionGeneration = 0;
|
||||
let abortVoiceTranscription: (() => void) | null = null;
|
||||
let knowledgeSearchGeneration = 0;
|
||||
let activeMediaUploadTask: ReturnType<typeof uni.uploadFile> | null = null;
|
||||
const defaultToolCode = computed<'MY_PRACTICE_SUMMARY' | 'TEAM_PRACTICE_SUMMARY'>(() =>
|
||||
currentRole.value === 'supervisor' && !supervisorLearner.value
|
||||
? 'TEAM_PRACTICE_SUMMARY'
|
||||
: 'MY_PRACTICE_SUMMARY'
|
||||
);
|
||||
const speechPlayback = createSpeechPlaybackController(
|
||||
(state) => { speechState.value = state; },
|
||||
(text) => {
|
||||
message.value = text;
|
||||
uni.showToast({ title: text, icon: 'none' });
|
||||
}
|
||||
);
|
||||
|
||||
const resetResultState = () => {
|
||||
result.value = null;
|
||||
summaryCard.value = null;
|
||||
feedbackVerdict.value = '';
|
||||
feedbackSubmitting.value = false;
|
||||
currentToolCode.value = '';
|
||||
};
|
||||
|
||||
const refreshAuthState = () => {
|
||||
loggedIn.value = isLoggedIn();
|
||||
positionLabel.value = getSelectedPosition();
|
||||
positionProfile.value = getSelectedPositionProfile();
|
||||
currentRole.value = String(uni.getStorageSync(roleKey) || '');
|
||||
supervisorLearner.value = isSupervisorLearnerMode();
|
||||
};
|
||||
|
||||
const cancelPendingMediaSearch = () => {
|
||||
knowledgeSearchGeneration += 1;
|
||||
activeMediaUploadTask?.abort();
|
||||
activeMediaUploadTask = null;
|
||||
loading.value = false;
|
||||
};
|
||||
|
||||
const handleRequestError = (error: unknown, fallback: string, wasLoggedIn = loggedIn.value) => {
|
||||
@@ -249,12 +359,13 @@ const handleRequestError = (error: unknown, fallback: string, wasLoggedIn = logg
|
||||
};
|
||||
|
||||
const search = async () => {
|
||||
if (loading.value) return;
|
||||
const value = query.value.trim();
|
||||
if (loading.value || voiceRecording.value || voiceTranscribing.value) return;
|
||||
const value = query.value.trim() || (selectedMedia.value ? '请根据附件现场情况,告诉我应该按什么 SOP 处理' : '');
|
||||
if (!value) {
|
||||
message.value = '请先输入问题';
|
||||
return;
|
||||
}
|
||||
query.value = value;
|
||||
refreshAuthState();
|
||||
if (!loggedIn.value) {
|
||||
uni.setStorageSync(pendingSopQueryKey, value);
|
||||
@@ -267,25 +378,224 @@ const search = async () => {
|
||||
positionProfile.value = getSelectedPositionProfile();
|
||||
positionLabel.value = getSelectedPosition();
|
||||
loading.value = true;
|
||||
const generation = ++knowledgeSearchGeneration;
|
||||
message.value = '';
|
||||
resetResultState();
|
||||
try {
|
||||
result.value = await searchKnowledge(value);
|
||||
const nextResult = selectedMedia.value
|
||||
? await searchKnowledgeWithMedia(selectedMedia.value, value, getSelectedPosition(), (task) => {
|
||||
if (generation === knowledgeSearchGeneration) activeMediaUploadTask = task;
|
||||
})
|
||||
: await searchKnowledge(value);
|
||||
if (generation !== knowledgeSearchGeneration) return;
|
||||
result.value = nextResult;
|
||||
if (!result.value.answer && !result.value.snippets?.length) {
|
||||
message.value = '未检索到可引用内容';
|
||||
}
|
||||
} catch (error) {
|
||||
if (generation !== knowledgeSearchGeneration) return;
|
||||
handleRequestError(error, '检索失败', true);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
if (generation === knowledgeSearchGeneration) {
|
||||
activeMediaUploadTask = null;
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const runDataTool = async (toolCode: 'MY_PRACTICE_SUMMARY' | 'TEAM_PRACTICE_SUMMARY') => {
|
||||
if (loading.value) return;
|
||||
refreshAuthState();
|
||||
if (!loggedIn.value) {
|
||||
uni.setStorageSync(pendingToolKey, toolCode);
|
||||
goLogin();
|
||||
return;
|
||||
}
|
||||
const effectiveToolCode = supervisorLearner.value && toolCode === 'TEAM_PRACTICE_SUMMARY'
|
||||
? 'MY_PRACTICE_SUMMARY'
|
||||
: toolCode;
|
||||
const generation = ++knowledgeSearchGeneration;
|
||||
loading.value = true;
|
||||
message.value = '';
|
||||
resetResultState();
|
||||
selectedMedia.value = null;
|
||||
currentToolCode.value = effectiveToolCode;
|
||||
const question = effectiveToolCode === 'TEAM_PRACTICE_SUMMARY' ? '查询我的团队训练概况' : '查询我的训练概况';
|
||||
query.value = question;
|
||||
try {
|
||||
const nextResult = await queryKnowledgeTool(effectiveToolCode, question);
|
||||
if (generation !== knowledgeSearchGeneration) return;
|
||||
result.value = nextResult;
|
||||
} catch (error) {
|
||||
if (generation !== knowledgeSearchGeneration) return;
|
||||
handleRequestError(error, '训练概况查询失败', true);
|
||||
} finally {
|
||||
if (generation === knowledgeSearchGeneration) loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const useQuickQuestion = (value: string) => {
|
||||
selectedMedia.value = null;
|
||||
query.value = value;
|
||||
void search();
|
||||
};
|
||||
|
||||
const pickMedia = async () => {
|
||||
if (loading.value) return;
|
||||
try {
|
||||
selectedMedia.value = await chooseKnowledgeMedia();
|
||||
message.value = selectedMedia.value.kind === 'video'
|
||||
? '已选择视频,提交后会提取语音、画面文字和场景信息'
|
||||
: '已选择图片,可补充一句现场问题后提交';
|
||||
} catch (error) {
|
||||
const text = error instanceof Error ? error.message : '附件选择失败';
|
||||
if (text !== '未选择附件') message.value = text;
|
||||
}
|
||||
};
|
||||
|
||||
const clearMedia = () => {
|
||||
if (loading.value) return;
|
||||
selectedMedia.value = null;
|
||||
};
|
||||
|
||||
const stopVoiceTracks = () => {
|
||||
voiceStream.value?.getTracks().forEach((track) => track.stop());
|
||||
voiceStream.value = null;
|
||||
};
|
||||
|
||||
const cancelVoiceTranscription = () => {
|
||||
voiceTranscriptionGeneration += 1;
|
||||
abortVoiceTranscription?.();
|
||||
abortVoiceTranscription = null;
|
||||
voiceTranscribing.value = false;
|
||||
};
|
||||
|
||||
const cancelVoiceRecording = () => {
|
||||
voiceCaptureGeneration += 1;
|
||||
cancelVoiceTranscription();
|
||||
voiceStarting.value = false;
|
||||
const recorder = voiceRecorder.value;
|
||||
if (recorder && recorder.state !== 'inactive') {
|
||||
recorder.ondataavailable = null;
|
||||
recorder.onstop = null;
|
||||
recorder.stop();
|
||||
}
|
||||
voiceRecorder.value = null;
|
||||
stopVoiceTracks();
|
||||
voiceRecording.value = false;
|
||||
};
|
||||
|
||||
const applyVoiceTranscription = (text?: string) => {
|
||||
const value = (text || '').trim();
|
||||
if (!value) throw new Error('语音没有识别到文字');
|
||||
const current = query.value.trim();
|
||||
query.value = current ? `${current}\n${value}` : value;
|
||||
message.value = '语音已转成文字,请确认问题后提交';
|
||||
};
|
||||
|
||||
const transcribeVoiceBlob = async (blob: Blob) => {
|
||||
if (voiceTranscribing.value) return;
|
||||
const generation = ++voiceTranscriptionGeneration;
|
||||
voiceTranscribing.value = true;
|
||||
message.value = '语音转写中';
|
||||
try {
|
||||
const result = await transcribeSpeechBlob(blob, 'sop-question.webm', (abort) => {
|
||||
if (generation === voiceTranscriptionGeneration) abortVoiceTranscription = abort;
|
||||
});
|
||||
if (generation !== voiceTranscriptionGeneration) return;
|
||||
applyVoiceTranscription(result.text);
|
||||
} catch (error) {
|
||||
if (generation !== voiceTranscriptionGeneration) return;
|
||||
message.value = error instanceof Error ? error.message : '语音转写失败,请改用文字输入';
|
||||
} finally {
|
||||
if (generation === voiceTranscriptionGeneration) {
|
||||
abortVoiceTranscription = null;
|
||||
voiceTranscribing.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const pickVoiceAudio = async () => {
|
||||
if (loading.value || voiceRecording.value || voiceTranscribing.value) return;
|
||||
const generation = ++voiceTranscriptionGeneration;
|
||||
voiceTranscribing.value = true;
|
||||
message.value = '语音转写中';
|
||||
try {
|
||||
const file = await chooseSpeechAudio();
|
||||
if (generation !== voiceTranscriptionGeneration) return;
|
||||
const result = await transcribeSpeechFile(file, (abort) => {
|
||||
if (generation === voiceTranscriptionGeneration) abortVoiceTranscription = abort;
|
||||
});
|
||||
if (generation !== voiceTranscriptionGeneration) return;
|
||||
applyVoiceTranscription(result.text);
|
||||
} catch (error) {
|
||||
if (generation !== voiceTranscriptionGeneration) return;
|
||||
const text = error instanceof Error ? error.message : '语音转写失败,请改用文字输入';
|
||||
if (text !== '未选择音频') message.value = text;
|
||||
} finally {
|
||||
if (generation === voiceTranscriptionGeneration) {
|
||||
abortVoiceTranscription = null;
|
||||
voiceTranscribing.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const startVoiceRecording = async () => {
|
||||
if (loading.value || voiceStarting.value || voiceRecording.value || voiceTranscribing.value) return;
|
||||
if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
|
||||
await pickVoiceAudio();
|
||||
return;
|
||||
}
|
||||
const generation = ++voiceCaptureGeneration;
|
||||
voiceStarting.value = true;
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
if (generation !== voiceCaptureGeneration) {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
return;
|
||||
}
|
||||
voiceStream.value = stream;
|
||||
const recorder = new MediaRecorder(stream);
|
||||
const chunks: Blob[] = [];
|
||||
voiceRecorder.value = recorder;
|
||||
recorder.ondataavailable = (event) => {
|
||||
if (event.data?.size) chunks.push(event.data);
|
||||
};
|
||||
recorder.onstop = () => {
|
||||
voiceRecording.value = false;
|
||||
voiceRecorder.value = null;
|
||||
stopVoiceTracks();
|
||||
const blob = new Blob(chunks, { type: recorder.mimeType || 'audio/webm' });
|
||||
if (!blob.size) {
|
||||
message.value = '未录到音频,请改用文字输入';
|
||||
return;
|
||||
}
|
||||
void transcribeVoiceBlob(blob);
|
||||
};
|
||||
recorder.start();
|
||||
voiceRecording.value = true;
|
||||
message.value = '正在录音,再点一次结束';
|
||||
} catch {
|
||||
stopVoiceTracks();
|
||||
if (generation === voiceCaptureGeneration) await pickVoiceAudio();
|
||||
} finally {
|
||||
if (generation === voiceCaptureGeneration) voiceStarting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const toggleVoiceRecording = () => {
|
||||
if (voiceStarting.value) return;
|
||||
if (voiceRecording.value) {
|
||||
const recorder = voiceRecorder.value;
|
||||
if (recorder && recorder.state !== 'inactive') {
|
||||
message.value = '录音结束,正在转写';
|
||||
recorder.stop();
|
||||
}
|
||||
return;
|
||||
}
|
||||
void startVoiceRecording();
|
||||
};
|
||||
|
||||
const snippetExcerpt = (text = '') => {
|
||||
const value = text.trim();
|
||||
return value.length > 260 ? `${value.slice(0, 260)}...` : value;
|
||||
@@ -350,14 +660,67 @@ const buildSummaryCard = async () => {
|
||||
};
|
||||
|
||||
const closeSummary = () => {
|
||||
if (speechState.value.key === 'summary') speechPlayback.stop();
|
||||
summaryCard.value = null;
|
||||
};
|
||||
|
||||
const speechButtonLabel = (key: string) => {
|
||||
if (speechState.value.key !== key) return '语音播报';
|
||||
if (speechState.value.status === 'loading') return '生成语音中';
|
||||
if (speechState.value.status === 'playing') return '停止播报';
|
||||
return '语音播报';
|
||||
};
|
||||
|
||||
const answerSpeechText = () => {
|
||||
if (!result.value) return '';
|
||||
return [
|
||||
result.value.answer,
|
||||
...(result.value.keyPoints || []).map((item, index) => `第${index + 1}点,${cleanOrderedText(item)}`),
|
||||
...(result.value.cautions || []).map((item) => `注意,${item}`)
|
||||
].filter(Boolean).join('。');
|
||||
};
|
||||
|
||||
const summarySpeechText = () => {
|
||||
if (!summaryCard.value) return '';
|
||||
return [
|
||||
summaryCard.value.title,
|
||||
...(summaryCard.value.steps || []).map((item, index) => `第${index + 1}步,${item.label},${item.desc}`),
|
||||
...(summaryCard.value.reminders || []).map((item) => `师傅提醒,${item}`)
|
||||
].filter(Boolean).join('。');
|
||||
};
|
||||
|
||||
const toggleAnswerSpeech = () => {
|
||||
void speechPlayback.toggle('answer', answerSpeechText());
|
||||
};
|
||||
|
||||
const toggleSummarySpeech = () => {
|
||||
void speechPlayback.toggle('summary', summarySpeechText());
|
||||
};
|
||||
|
||||
const downloadSummary = async () => {
|
||||
if (!summaryCard.value || downloadingCard.value) return;
|
||||
downloadingCard.value = true;
|
||||
try {
|
||||
await downloadSummaryCardImage(summaryCard.value);
|
||||
uni.showToast({ title: '总结卡图片已下载', icon: 'none' });
|
||||
} catch (error) {
|
||||
const text = error instanceof Error ? error.message : '总结卡下载失败';
|
||||
message.value = text;
|
||||
uni.showToast({ title: text, icon: 'none' });
|
||||
} finally {
|
||||
downloadingCard.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const goLogin = () => {
|
||||
rememberLoginRedirect('/pages/user/sop/index');
|
||||
uni.navigateTo({ url: '/pages/auth/login/index' });
|
||||
};
|
||||
|
||||
const openWebAi = () => {
|
||||
uni.navigateTo({ url: '/pages/user/web-ai/index' });
|
||||
};
|
||||
|
||||
const handleShow = async () => {
|
||||
resetPageScroll();
|
||||
refreshAuthState();
|
||||
@@ -369,6 +732,17 @@ const handleShow = async () => {
|
||||
resetResultState();
|
||||
}
|
||||
const pending = uni.getStorageSync(pendingSopQueryKey);
|
||||
const pendingTool = uni.getStorageSync(pendingToolKey);
|
||||
if (pendingTool === 'MY_PRACTICE_SUMMARY' || pendingTool === 'TEAM_PRACTICE_SUMMARY') {
|
||||
if (!loggedIn.value) {
|
||||
message.value = '登录后继续查询训练概况';
|
||||
goLogin();
|
||||
return;
|
||||
}
|
||||
uni.removeStorageSync(pendingToolKey);
|
||||
void runDataTool(pendingTool);
|
||||
return;
|
||||
}
|
||||
if (!pending) {
|
||||
if (loggedIn.value && query.value.trim() && message.value === '登录后继续查询该问题') {
|
||||
void search();
|
||||
@@ -387,6 +761,17 @@ const handleShow = async () => {
|
||||
onShow(() => {
|
||||
void handleShow();
|
||||
});
|
||||
|
||||
onHide(() => {
|
||||
speechPlayback.stop();
|
||||
cancelVoiceRecording();
|
||||
cancelPendingMediaSearch();
|
||||
});
|
||||
|
||||
onUnload(() => {
|
||||
speechPlayback.destroy();
|
||||
cancelVoiceRecording();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -414,6 +799,26 @@ onShow(() => {
|
||||
content: '';
|
||||
}
|
||||
|
||||
.source-switch {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 4px;
|
||||
margin-top: 16px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.source-option {
|
||||
min-height: 44px;
|
||||
background: transparent;
|
||||
color: var(--employee-muted);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.source-option.active {
|
||||
background: var(--employee-primary-soft);
|
||||
color: var(--employee-primary);
|
||||
}
|
||||
|
||||
.sop-eyebrow {
|
||||
display: inline-flex;
|
||||
margin-bottom: 12px;
|
||||
@@ -798,7 +1203,7 @@ onShow(() => {
|
||||
background: #168243;
|
||||
color: #ffffff;
|
||||
border-radius: 50%;
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -814,13 +1219,13 @@ onShow(() => {
|
||||
/* Steps Timeline */
|
||||
.timeline {
|
||||
position: relative;
|
||||
padding-left: 14px;
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
.timeline::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 5px;
|
||||
left: 8px;
|
||||
top: 6px;
|
||||
bottom: 6px;
|
||||
width: 2px;
|
||||
@@ -840,16 +1245,16 @@ onShow(() => {
|
||||
|
||||
.timeline-node {
|
||||
position: absolute;
|
||||
left: -14px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
left: -18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: #168243;
|
||||
color: #ffffff;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
@@ -921,9 +1326,9 @@ onShow(() => {
|
||||
|
||||
.q-badge,
|
||||
.a-badge {
|
||||
flex: 0 0 18px;
|
||||
height: 18px;
|
||||
font-size: 10px;
|
||||
flex: 0 0 22px;
|
||||
height: 22px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
@@ -1283,4 +1688,131 @@ onShow(() => {
|
||||
.query-panel .query-caption {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.media-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.voice-query-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.voice-query-button {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.voice-query-button.recording-active {
|
||||
border-color: var(--employee-primary);
|
||||
background: #fff0f1;
|
||||
color: var(--employee-primary);
|
||||
}
|
||||
|
||||
.voice-query-hint {
|
||||
display: block;
|
||||
margin-top: -2px;
|
||||
color: var(--employee-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.media-button {
|
||||
min-height: 42px;
|
||||
margin: 0;
|
||||
padding: 0 14px;
|
||||
border-color: #cfe0ff;
|
||||
color: var(--employee-primary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.media-hint {
|
||||
flex: 1;
|
||||
color: var(--employee-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.media-file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #cfe0ff;
|
||||
border-radius: 14px;
|
||||
background: var(--employee-primary-soft);
|
||||
}
|
||||
|
||||
.media-file-copy {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.media-kind {
|
||||
color: var(--employee-primary);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.media-name {
|
||||
overflow: hidden;
|
||||
color: var(--employee-text);
|
||||
font-size: 13px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.media-remove {
|
||||
width: 32px;
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
min-height: 32px;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
background: #ffffff;
|
||||
color: var(--employee-muted);
|
||||
font-size: 21px;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.answer-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.voice-button {
|
||||
min-height: 38px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #f3b7bd;
|
||||
border-radius: 999px;
|
||||
background: #fff7f8;
|
||||
color: var(--employee-primary);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.modal-footer .footer-btn {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.modal-footer .footer-close {
|
||||
grid-column: span 2;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -165,19 +165,36 @@
|
||||
<text class="tip-text">{{ tipBody }}</text>
|
||||
<text class="tip-arrow">›</text>
|
||||
</button>
|
||||
<button class="tip-card" @click="openPracticeSummary">
|
||||
<uni-icons class="tip-mark" type="staff" size="22" color="#e60012" />
|
||||
<text class="tip-label">训练</text>
|
||||
<text class="tip-divider">|</text>
|
||||
<text class="tip-text">查看我的训练概况</text>
|
||||
<text class="tip-arrow">›</text>
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<button class="question-board-card employee-card" @click="openQuestions">
|
||||
<view class="question-board-icon">榜</view>
|
||||
<view class="question-board-copy">
|
||||
<text class="question-board-title">问题榜</text>
|
||||
<text class="question-board-desc">看看大家常遇到的难题,也可以发起真实工作求助</text>
|
||||
</view>
|
||||
<text class="question-board-arrow">›</text>
|
||||
</button>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { onShow } from '@dcloudio/uni-app';
|
||||
import { onHide, onShow, onUnload } from '@dcloudio/uni-app';
|
||||
import type { PracticeAssignment, RoleHome } from '@/types/api';
|
||||
import { getMobileHome } from '@/services/home';
|
||||
import { getPracticeAssignments } from '@/services/practice';
|
||||
import { getSelectedPositionProfile, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||||
import { resetPageScroll } from '@/services/navigation';
|
||||
import { ensureEmployeePosition } from '@/services/position';
|
||||
import { createPageRequestScope, currentAccountKey } from '@/services/request-scope';
|
||||
|
||||
const home = ref<RoleHome | null>(null);
|
||||
const assignments = ref<PracticeAssignment[]>([]);
|
||||
@@ -187,7 +204,10 @@ const positionProfile = ref(getSelectedPositionProfile());
|
||||
const quickQuestions = computed(() => positionProfile.value.quickQuestions);
|
||||
const primaryTipQuestion = computed(() => quickQuestions.value[0] || '岗位SOP');
|
||||
const pendingSopQueryKey = 'aihr_sop_pending_query';
|
||||
const pendingToolKey = 'aihr_sop_pending_tool';
|
||||
const pendingAssignmentKey = 'aihr_pending_assignment_id';
|
||||
const requests = createPageRequestScope();
|
||||
let visibleAccountKey = '';
|
||||
|
||||
const chipIcons = ['chatbubble', 'help', 'wallet'];
|
||||
const chipIcon = (index: number) => chipIcons[index] || 'chatbubble';
|
||||
@@ -293,14 +313,18 @@ const tipBody = computed(() => {
|
||||
|
||||
const mentorBubbleText = computed(() => {
|
||||
if (!loggedIn.value) return '登录后看今天任务';
|
||||
if (primaryAssignment.value?.source === 'daily') return '今天先完成每日三题';
|
||||
if (primaryScheduleTitle.value) {
|
||||
const topic = primaryScheduleTitle.value.replace(/专项|训练|练习/g, '').trim();
|
||||
if (topic) return `今天先把${topic}练熟`;
|
||||
const topic = (primaryAssignment.value?.scenarioName || primaryScheduleTitle.value)
|
||||
.replace(/专项|训练|练习/g, '')
|
||||
.trim();
|
||||
if (topic) return `今天先把${topic.slice(0, 8)}${topic.length > 8 ? '…' : ''}练熟`;
|
||||
}
|
||||
return '今天先把投诉处理练熟';
|
||||
});
|
||||
|
||||
const loadHome = async () => {
|
||||
const request = requests.begin('home');
|
||||
loggedIn.value = isLoggedIn();
|
||||
positionProfile.value = getSelectedPositionProfile();
|
||||
message.value = '';
|
||||
@@ -309,7 +333,7 @@ const loadHome = async () => {
|
||||
home.value = null;
|
||||
return;
|
||||
}
|
||||
if (!(await ensureEmployeePosition('/pages/user/today/index'))) {
|
||||
if (!(await ensureEmployeePosition('/pages/user/today/index')) || !requests.isCurrent(request)) {
|
||||
return;
|
||||
}
|
||||
positionProfile.value = getSelectedPositionProfile();
|
||||
@@ -317,6 +341,7 @@ const loadHome = async () => {
|
||||
getMobileHome('user'),
|
||||
getPracticeAssignments(true)
|
||||
]);
|
||||
if (!requests.isCurrent(request)) return;
|
||||
if (homeResult.status === 'fulfilled') {
|
||||
home.value = homeResult.value;
|
||||
} else {
|
||||
@@ -345,6 +370,11 @@ const ask = (query: string) => {
|
||||
uni.switchTab({ url: '/pages/user/sop/index' });
|
||||
};
|
||||
|
||||
const openPracticeSummary = () => {
|
||||
uni.setStorageSync(pendingToolKey, 'MY_PRACTICE_SUMMARY');
|
||||
uni.switchTab({ url: '/pages/user/sop/index' });
|
||||
};
|
||||
|
||||
const goLogin = () => {
|
||||
rememberLoginRedirect('/pages/user/today/index');
|
||||
uni.navigateTo({ url: '/pages/auth/login/index' });
|
||||
@@ -357,6 +387,15 @@ const openPrimarySchedule = () => {
|
||||
uni.switchTab({ url: '/pages/user/practice/index' });
|
||||
};
|
||||
|
||||
const openQuestions = () => {
|
||||
if (!loggedIn.value) {
|
||||
rememberLoginRedirect('/pages/user/questions/index');
|
||||
uni.navigateTo({ url: '/pages/auth/login/index' });
|
||||
return;
|
||||
}
|
||||
uni.navigateTo({ url: '/pages/user/questions/index' });
|
||||
};
|
||||
|
||||
const openBuilding = (title: string) => {
|
||||
uni.navigateTo({ url: `/pages/common/building/index?title=${encodeURIComponent(title)}` });
|
||||
};
|
||||
@@ -382,9 +421,25 @@ const openTool = (title: string) => {
|
||||
};
|
||||
|
||||
onShow(() => {
|
||||
const nextAccountKey = currentAccountKey();
|
||||
if (visibleAccountKey && visibleAccountKey !== nextAccountKey) {
|
||||
home.value = null;
|
||||
assignments.value = [];
|
||||
message.value = '';
|
||||
}
|
||||
visibleAccountKey = nextAccountKey;
|
||||
requests.activate();
|
||||
resetPageScroll();
|
||||
void loadHome();
|
||||
});
|
||||
|
||||
onHide(() => {
|
||||
requests.invalidate();
|
||||
});
|
||||
|
||||
onUnload(() => {
|
||||
requests.invalidate();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -394,6 +449,57 @@ onShow(() => {
|
||||
background: #f7f7f7;
|
||||
}
|
||||
|
||||
.question-board-card {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-top: 16px;
|
||||
padding: 16px;
|
||||
color: var(--ink);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.question-board-icon {
|
||||
display: flex;
|
||||
flex: 0 0 44px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 14px;
|
||||
background: #fff0f1;
|
||||
color: var(--brand);
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.question-board-copy {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.question-board-title,
|
||||
.question-board-desc {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.question-board-title {
|
||||
font-size: 17px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.question-board-desc {
|
||||
margin-top: 4px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.question-board-arrow {
|
||||
color: var(--muted);
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.brand-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1030,7 +1136,7 @@ onShow(() => {
|
||||
|
||||
.map-desc {
|
||||
color: #8a8d93;
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
white-space: pre-line;
|
||||
}
|
||||
@@ -1123,7 +1229,7 @@ onShow(() => {
|
||||
}
|
||||
|
||||
.voice-desc {
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.chip-text {
|
||||
@@ -1138,4 +1244,103 @@ onShow(() => {
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
:global(html[data-aihr-font-size='extra-large'] .mentor-hero) {
|
||||
min-height: 270px;
|
||||
}
|
||||
|
||||
:global(html[data-aihr-font-size='extra-large'] .hero-copy) {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
padding-right: 18px;
|
||||
}
|
||||
|
||||
:global(html[data-aihr-font-size='extra-large'] .hero-tip) {
|
||||
max-width: 160px;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
:global(html[data-aihr-font-size='extra-large'] .mentor-stage) {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 154px;
|
||||
height: 154px;
|
||||
}
|
||||
|
||||
:global(html[data-aihr-font-size='extra-large'] .mentor-bubble) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:global(html[data-aihr-font-size='extra-large'] .mentor-image) {
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 144px;
|
||||
height: 154px;
|
||||
}
|
||||
|
||||
:global(html[data-aihr-font-size='extra-large'] .voice-panel) {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
:global(html[data-aihr-font-size='extra-large'] .voice-primary),
|
||||
:global(html[data-aihr-font-size='extra-large'] .voice-secondary) {
|
||||
width: 100%;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
:global(html[data-aihr-font-size='extra-large'] .voice-secondary) {
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid #f0f1f3;
|
||||
}
|
||||
|
||||
:global(html[data-aihr-font-size='extra-large'] .secondary-copy) {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
:global(html[data-aihr-font-size='extra-large'] .secondary-copy .voice-desc) {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
:global(html[data-aihr-font-size='extra-large'] .chip-row),
|
||||
:global(html[data-aihr-font-size='extra-large'] .map-grid) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
:global(html[data-aihr-font-size='extra-large'] .chip-text) {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
:global(html[data-aihr-font-size='extra-large'] .login-section) {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
:global(html[data-aihr-font-size='extra-large'] .login-button),
|
||||
:global(html[data-aihr-font-size='extra-large'] .schedule-action) {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
flex: 0 0 100%;
|
||||
}
|
||||
|
||||
:global(html[data-aihr-font-size='extra-large'] .schedule-row) {
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
:global(html[data-aihr-font-size='extra-large'] .schedule-copy) {
|
||||
flex-basis: calc(100% - 64px);
|
||||
}
|
||||
|
||||
:global(html[data-aihr-font-size='extra-large'] .tip-card) {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
:global(html[data-aihr-font-size='extra-large'] .tip-text) {
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
white-space: normal;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
<template>
|
||||
<view class="page employee-page-shell web-ai-page">
|
||||
<view class="subpage-header">
|
||||
<button class="subpage-back-button" aria-label="返回问师傅" @click="goBack">←</button>
|
||||
<view class="subpage-heading">
|
||||
<text class="employee-kicker">互联网信息 · 独立来源</text>
|
||||
<text class="page-title">问 AI(全网)</text>
|
||||
<text class="page-subtitle">检索公开网页并保留可核验引用,不与企业 SOP 混用。</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="source-switch employee-card">
|
||||
<button class="source-option" @click="goBack">企业问师傅</button>
|
||||
<button class="source-option active" aria-current="page">问 AI(全网)</button>
|
||||
</view>
|
||||
|
||||
<view class="warning-card">
|
||||
<text class="warning-title">发送前请注意</text>
|
||||
<text>问题会先脱敏,再发送给外部搜索与 AI 服务。答案来自互联网,不代表公司制度;涉及作业、消防、电梯或法律事项,请以公司 SOP 和官方渠道为准。</text>
|
||||
</view>
|
||||
|
||||
<view v-if="!loggedIn" class="panel employee-card login-panel">
|
||||
<view>
|
||||
<text class="panel-title">需要登录</text>
|
||||
<text class="muted block">登录后才能使用独立的全网检索额度和隐私告知。</text>
|
||||
</view>
|
||||
<button class="primary-button" @click="goLogin">去登录</button>
|
||||
</view>
|
||||
|
||||
<template v-else>
|
||||
<view class="panel employee-card query-panel">
|
||||
<text class="panel-title">你的问题</text>
|
||||
<textarea
|
||||
v-model="query"
|
||||
class="textarea query-input"
|
||||
:maxlength="capabilities?.maxQuestionLength || 500"
|
||||
placeholder="例如:最近物业服务行业有哪些官方新规?"
|
||||
/>
|
||||
<text class="query-hint">不要输入业主姓名、手机号、房号、身份证、门禁或公司未公开资料。最多 {{ capabilities?.maxQuestionLength || 500 }} 字。</text>
|
||||
<view v-if="capabilities && !capabilities.enabled" class="status-message">
|
||||
全网 AI 尚未由管理员启用,不会静默改用企业知识库。
|
||||
</view>
|
||||
<label v-if="capabilities?.enabled && capabilities.requiresConsent" class="consent-row">
|
||||
<checkbox :checked="consentChecked" color="#e60012" @click="consentChecked = !consentChecked" />
|
||||
<text>我已阅读并同意将脱敏后的问题发送到外部公开检索服务</text>
|
||||
</label>
|
||||
<button
|
||||
class="primary-button submit-button"
|
||||
:disabled="loading || !capabilities?.enabled || (capabilities.requiresConsent && !consentChecked) || !query.trim()"
|
||||
@click="search"
|
||||
>
|
||||
{{ loading ? '正在检索并核对来源' : '开始全网查询' }}
|
||||
</button>
|
||||
<text v-if="message" class="status-message">{{ message }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="result" class="panel employee-card answer-panel">
|
||||
<view class="answer-head">
|
||||
<text class="panel-title">全网回答</text>
|
||||
<button class="ghost-button compact-button" :disabled="speech.status === 'loading'" @click="toggleSpeech">
|
||||
{{ speech.status === 'playing' ? '停止播报' : speech.status === 'loading' ? '生成语音中' : '语音播报' }}
|
||||
</button>
|
||||
</view>
|
||||
<text v-if="result.status === 'NO_ANSWER'" class="no-evidence">已找到公开来源,但外部服务未返回可核验答案,请直接查看下方来源。</text>
|
||||
<text v-else-if="result.noEvidence" class="no-evidence">未找到可核验的公开依据,本次不生成推断答案。</text>
|
||||
<text v-else class="answer-text">{{ result.answer }}</text>
|
||||
<text v-if="result.redacted" class="redacted-note">已自动隐藏问题中的个人或门禁信息</text>
|
||||
<text class="answer-warning">{{ result.warning }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="result?.citations?.length" class="panel employee-card sources-panel">
|
||||
<text class="panel-title">公开来源</text>
|
||||
<a
|
||||
v-for="(item, index) in result.citations"
|
||||
:key="`${item.url}-${index}`"
|
||||
class="source-card"
|
||||
:href="item.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<text class="source-index">{{ index + 1 }}</text>
|
||||
<view class="source-copy">
|
||||
<text class="source-title">{{ item.title }}</text>
|
||||
<text class="source-domain">{{ item.domain }}{{ item.publishedAt ? ` · ${item.publishedAt}` : '' }}</text>
|
||||
<text class="source-snippet">{{ item.snippet }}</text>
|
||||
</view>
|
||||
</a>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, ref } from 'vue';
|
||||
import { onShow } from '@dcloudio/uni-app';
|
||||
import type { WebAiCapabilities, WebAiResponse } from '@/types/api';
|
||||
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||||
import { createSpeechPlaybackController, type SpeechPlaybackSnapshot } from '@/services/speech';
|
||||
import { getWebAiCapabilities, queryWebAi } from '@/services/web-ai';
|
||||
|
||||
const path = '/pages/user/web-ai/index';
|
||||
const loggedIn = ref(false);
|
||||
const query = ref('');
|
||||
const message = ref('');
|
||||
const loading = ref(false);
|
||||
const consentChecked = ref(false);
|
||||
const capabilities = ref<WebAiCapabilities | null>(null);
|
||||
const result = ref<WebAiResponse | null>(null);
|
||||
const speech = ref<SpeechPlaybackSnapshot>({ key: '', status: 'idle' });
|
||||
const speechController = createSpeechPlaybackController(
|
||||
(snapshot) => { speech.value = snapshot; },
|
||||
(error) => { message.value = error; }
|
||||
);
|
||||
|
||||
const goBack = () => uni.switchTab({ url: '/pages/user/sop/index' });
|
||||
const goLogin = () => {
|
||||
rememberLoginRedirect(path);
|
||||
uni.navigateTo({ url: '/pages/auth/login/index' });
|
||||
};
|
||||
|
||||
const loadCapabilities = async () => {
|
||||
if (!loggedIn.value) return;
|
||||
try {
|
||||
capabilities.value = await getWebAiCapabilities();
|
||||
} catch (error) {
|
||||
message.value = error instanceof Error ? error.message : '全网 AI 状态读取失败';
|
||||
}
|
||||
};
|
||||
|
||||
const search = async () => {
|
||||
const value = query.value.trim();
|
||||
if (!value || !capabilities.value?.enabled) return;
|
||||
loading.value = true;
|
||||
message.value = '';
|
||||
result.value = null;
|
||||
speechController.stop();
|
||||
try {
|
||||
result.value = await queryWebAi(value, consentChecked.value);
|
||||
} catch (error) {
|
||||
message.value = error instanceof Error ? error.message : '全网查询失败';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSpeech = () => {
|
||||
if (!result.value?.answer) return;
|
||||
speechController.toggle('web-answer', result.value.answer);
|
||||
};
|
||||
|
||||
onShow(() => {
|
||||
loggedIn.value = isLoggedIn();
|
||||
capabilities.value = null;
|
||||
result.value = null;
|
||||
consentChecked.value = false;
|
||||
void loadCapabilities();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => speechController.destroy());
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.web-ai-page { max-width: var(--mobile-shell-max-width); margin: 0 auto; }
|
||||
.source-switch { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; margin-top: 18px; padding: 4px; }
|
||||
.source-option { min-height: 44px; background: transparent; color: var(--muted); font-weight: 800; }
|
||||
.source-option.active { background: var(--employee-primary-soft); color: var(--brand); }
|
||||
.warning-card { display: grid; gap: 6px; margin-top: 14px; padding: 14px 16px; border: 1px solid #f0c879; border-radius: 16px; background: #fff9e9; color: #78500a; font-size: 13px; line-height: 1.6; }
|
||||
.warning-title { font-weight: 900; }
|
||||
.login-panel { display: grid; gap: 14px; }
|
||||
.block { display: block; margin-top: 6px; }
|
||||
.query-panel { display: grid; gap: 12px; }
|
||||
.query-input { min-height: 148px; }
|
||||
.query-hint { color: var(--muted); font-size: 12px; line-height: 1.55; }
|
||||
.consent-row { display: flex; gap: 8px; align-items: flex-start; color: #435268; font-size: 13px; line-height: 1.55; }
|
||||
.submit-button { width: 100%; }
|
||||
.status-message { display: block; padding: 10px 12px; border-radius: 8px; background: #fff0f1; color: #a9000d; font-size: 13px; line-height: 1.5; }
|
||||
.answer-head { display: flex; gap: 12px; align-items: center; justify-content: space-between; }
|
||||
.compact-button { min-height: 38px; padding: 0 12px; font-size: 13px; }
|
||||
.answer-text, .no-evidence { display: block; margin-top: 14px; color: var(--ink); font-size: 16px; line-height: 1.8; white-space: pre-wrap; }
|
||||
.no-evidence { color: var(--warning); font-weight: 800; }
|
||||
.answer-warning { display: block; margin-top: 12px; color: var(--warning); font-size: 12px; line-height: 1.5; }
|
||||
.redacted-note { display:block; margin-top:10px; color:var(--success); font-size:12px; font-weight:800; }
|
||||
.sources-panel { display: grid; gap: 12px; }
|
||||
.source-card { display: flex; gap: 10px; padding: 14px; border: 1px solid var(--line); border-radius: 12px; color: inherit; text-decoration: none; overflow: hidden; }
|
||||
.source-index { flex: 0 0 24px; height: 24px; border-radius: 50%; background: #eef3f9; color: #315b8f; font-weight: 900; line-height: 24px; text-align: center; }
|
||||
.source-copy { min-width: 0; }
|
||||
.source-title, .source-domain, .source-snippet { display: block; overflow-wrap: anywhere; }
|
||||
.source-title { font-weight: 900; line-height: 1.45; }
|
||||
.source-domain { margin-top: 4px; color: var(--knowledge); font-size: 12px; }
|
||||
.source-snippet { margin-top: 7px; color: var(--muted); font-size: 13px; line-height: 1.55; }
|
||||
</style>
|
||||
@@ -0,0 +1,31 @@
|
||||
export type AppFontSize = 'standard' | 'large' | 'extra-large';
|
||||
|
||||
export const appFontSizeKey = 'aihr_mobile_font_size';
|
||||
|
||||
export const appFontSizeOptions: Array<{ value: AppFontSize; label: string }> = [
|
||||
{ value: 'standard', label: '标准' },
|
||||
{ value: 'large', label: '大字' },
|
||||
{ value: 'extra-large', label: '超大字' }
|
||||
];
|
||||
|
||||
const normalizeAppFontSize = (value: unknown): AppFontSize => {
|
||||
if (value === 'large' || value === 'extra-large') return value;
|
||||
return 'standard';
|
||||
};
|
||||
|
||||
export const getAppFontSize = (): AppFontSize =>
|
||||
normalizeAppFontSize(uni.getStorageSync(appFontSizeKey));
|
||||
|
||||
export const applyAppFontSize = (value: AppFontSize) => {
|
||||
if (typeof document === 'undefined') return;
|
||||
document.documentElement.setAttribute('data-aihr-font-size', value);
|
||||
};
|
||||
|
||||
export const setAppFontSize = (value: AppFontSize) => {
|
||||
const normalized = normalizeAppFontSize(value);
|
||||
uni.setStorageSync(appFontSizeKey, normalized);
|
||||
applyAppFontSize(normalized);
|
||||
return normalized;
|
||||
};
|
||||
|
||||
export const applyStoredFontSize = () => applyAppFontSize(getAppFontSize());
|
||||
@@ -5,10 +5,12 @@ export const clientIdKey = 'aihr_mobile_client_id';
|
||||
export const phoneKey = 'aihr_mobile_phone';
|
||||
export const loginRedirectKey = 'aihr_mobile_login_redirect';
|
||||
export const positionKey = 'aihr_mobile_position';
|
||||
export const positionSourceKey = 'aihr_mobile_position_source';
|
||||
export const roleKey = 'aihr_mobile_role';
|
||||
export const supervisorLearnerModeKey = 'aihr_mobile_supervisor_learner_mode';
|
||||
export const phonePattern = /^1[3-9]\d{9}$/;
|
||||
|
||||
export const userPositionOptions = ['生活顾问', '保安', '保洁', '保修', '客服'] as const;
|
||||
export const userPositionOptions = ['生活顾问', '保安', '保洁', '保修', '客服', '主管'] as const;
|
||||
|
||||
export type UserPosition = typeof userPositionOptions[number];
|
||||
|
||||
@@ -56,6 +58,12 @@ const positionProfiles: Record<UserPosition, UserPositionProfile> = {
|
||||
heroTitle: '服务前台',
|
||||
quickQuestions: ['来访接待', '投诉登记', '工单跟进'],
|
||||
tip: '今日小贴士:复杂诉求先复述确认,再记录责任人与时限。'
|
||||
},
|
||||
主管: {
|
||||
value: '主管',
|
||||
heroTitle: '项目管理',
|
||||
quickQuestions: ['团队沟通', '现场协调', '投诉复盘'],
|
||||
tip: '今日小贴士:先确认事实与责任人,再明确动作、时限和复盘节点。'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -71,10 +79,13 @@ export const normalizePosition = (value: unknown): UserPosition | '' => {
|
||||
if (text.includes('保洁') || text.includes('环境')) return '保洁';
|
||||
if (text.includes('保修') || text.includes('维修') || text.includes('工程')) return '保修';
|
||||
if (text.includes('客服') || text.includes('前台')) return '客服';
|
||||
if (text.includes('主管') || text.includes('经理') || text.includes('负责人') || text.includes('组长')) return '主管';
|
||||
return (userPositionOptions as readonly string[]).includes(text) ? (text as UserPosition) : '';
|
||||
};
|
||||
|
||||
export const hasSelectedPosition = () => Boolean(normalizePosition(uni.getStorageSync(positionKey)));
|
||||
export const hasOrgPosition = () =>
|
||||
uni.getStorageSync(positionSourceKey) === 'org'
|
||||
&& Boolean(normalizePosition(uni.getStorageSync(positionKey)));
|
||||
|
||||
export const getSelectedPosition = (): UserPosition =>
|
||||
normalizePosition(uni.getStorageSync(positionKey)) || '生活顾问';
|
||||
@@ -84,13 +95,19 @@ export const getPositionProfile = (position: unknown) =>
|
||||
|
||||
export const getSelectedPositionProfile = () => getPositionProfile(getSelectedPosition());
|
||||
|
||||
export const saveSelectedPosition = (position: unknown) => {
|
||||
export const saveOrgPosition = (position: unknown) => {
|
||||
const normalized = normalizePosition(position);
|
||||
if (!normalized) return '';
|
||||
uni.setStorageSync(positionKey, normalized);
|
||||
uni.setStorageSync(positionSourceKey, 'org');
|
||||
return normalized;
|
||||
};
|
||||
|
||||
export const clearOrgPosition = () => {
|
||||
uni.removeStorageSync(positionKey);
|
||||
uni.removeStorageSync(positionSourceKey);
|
||||
};
|
||||
|
||||
export const getAuth = (): AuthState => ({
|
||||
token: uni.getStorageSync(tokenKey) || '',
|
||||
clientId: uni.getStorageSync(clientIdKey) || '',
|
||||
@@ -103,11 +120,26 @@ export const isLoggedIn = () => {
|
||||
return Boolean(auth.token && auth.clientId && auth.phone);
|
||||
};
|
||||
|
||||
export const isSupervisorLearnerMode = () =>
|
||||
uni.getStorageSync(roleKey) === 'supervisor'
|
||||
&& uni.getStorageSync(supervisorLearnerModeKey) === '1';
|
||||
|
||||
export const enterSupervisorLearnerMode = () => {
|
||||
if (uni.getStorageSync(roleKey) !== 'supervisor') return false;
|
||||
uni.setStorageSync(supervisorLearnerModeKey, '1');
|
||||
return true;
|
||||
};
|
||||
|
||||
export const leaveSupervisorLearnerMode = () => {
|
||||
uni.removeStorageSync(supervisorLearnerModeKey);
|
||||
};
|
||||
|
||||
export const saveAuth = (result: LoginResult, phone: string) => {
|
||||
const previousPhone = uni.getStorageSync(phoneKey) || '';
|
||||
if (previousPhone && previousPhone !== phone) {
|
||||
uni.removeStorageSync(positionKey);
|
||||
if (previousPhone !== phone) {
|
||||
clearOrgPosition();
|
||||
uni.removeStorageSync(roleKey);
|
||||
leaveSupervisorLearnerMode();
|
||||
}
|
||||
uni.setStorageSync(tokenKey, result.access_token);
|
||||
uni.setStorageSync(clientIdKey, result.client_id);
|
||||
@@ -118,8 +150,9 @@ export const clearAuth = () => {
|
||||
uni.removeStorageSync(tokenKey);
|
||||
uni.removeStorageSync(clientIdKey);
|
||||
uni.removeStorageSync(phoneKey);
|
||||
uni.removeStorageSync(positionKey);
|
||||
clearOrgPosition();
|
||||
uni.removeStorageSync(roleKey);
|
||||
leaveSupervisorLearnerMode();
|
||||
uni.removeStorageSync(loginRedirectKey);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import type { ExamDetail, ExamResultItem, ExamSubmitResult, ExamSummary, SupervisorExamDraft } from '@/types/api';
|
||||
import { apiRequest } from './api';
|
||||
|
||||
type RawExamQuestion = {
|
||||
id: number;
|
||||
questionType: string;
|
||||
stem: string;
|
||||
options?: string[];
|
||||
correctAnswers?: string[];
|
||||
score: number;
|
||||
explanation?: string;
|
||||
sortOrder?: number;
|
||||
};
|
||||
|
||||
type RawExamSummary = {
|
||||
id: number;
|
||||
title: string;
|
||||
projectCode?: string;
|
||||
positionCode?: string;
|
||||
status: string;
|
||||
totalScore: number;
|
||||
passScore: number;
|
||||
dueDate?: string | null;
|
||||
questionCount: number;
|
||||
targetCount: number;
|
||||
submissionCount: number;
|
||||
bestScore?: number | null;
|
||||
passed?: boolean | null;
|
||||
};
|
||||
|
||||
type RawExamDetail = Omit<RawExamSummary, 'questionCount' | 'targetCount' | 'submissionCount' | 'bestScore' | 'passed'> & {
|
||||
questions: RawExamQuestion[];
|
||||
targets: ExamDetail['targets'];
|
||||
};
|
||||
|
||||
const normalizeSummary = (item: RawExamSummary): ExamSummary => ({
|
||||
...item,
|
||||
passingScore: item.passScore
|
||||
});
|
||||
|
||||
const normalizeDetail = (item: RawExamDetail): ExamDetail => ({
|
||||
...item,
|
||||
passingScore: item.passScore,
|
||||
questionCount: item.questions?.length || 0,
|
||||
targetCount: item.targets?.length || 0,
|
||||
submissionCount: 0,
|
||||
bestScore: null,
|
||||
passed: null,
|
||||
questions: (item.questions || []).map((question) => ({
|
||||
id: question.id,
|
||||
format: question.questionType,
|
||||
stem: question.stem,
|
||||
options: question.options || [],
|
||||
score: question.score,
|
||||
answer: question.correctAnswers || [],
|
||||
explanation: question.explanation || ''
|
||||
})),
|
||||
targets: item.targets || []
|
||||
});
|
||||
|
||||
const serializeDraft = (draft: SupervisorExamDraft) => ({
|
||||
title: draft.title,
|
||||
projectCode: draft.projectCode,
|
||||
positionCode: draft.positionCode,
|
||||
passScore: draft.passScore,
|
||||
dueDate: draft.dueDate,
|
||||
targets: draft.targets,
|
||||
questions: draft.questions.map((question, index) => ({
|
||||
id: question.id > 0 && question.id < 1_000_000_000 ? question.id : null,
|
||||
questionType: question.format,
|
||||
stem: question.stem,
|
||||
options: question.options,
|
||||
correctAnswers: Array.isArray(question.answer) ? question.answer : [String(question.answer || '')],
|
||||
score: question.score,
|
||||
explanation: question.explanation || '',
|
||||
sortOrder: index + 1
|
||||
}))
|
||||
});
|
||||
|
||||
export const listMyExams = async () =>
|
||||
(await apiRequest<RawExamSummary[]>({
|
||||
url: '/api/aihr/mobile/exams',
|
||||
method: 'GET',
|
||||
timeout: 15000
|
||||
})).map(normalizeSummary);
|
||||
|
||||
export const getMyExam = async (id: number) =>
|
||||
normalizeDetail(await apiRequest<RawExamDetail>({
|
||||
url: `/api/aihr/mobile/exams/${id}`,
|
||||
method: 'GET',
|
||||
timeout: 15000
|
||||
}));
|
||||
|
||||
export const submitMyExam = (id: number, answers: Record<string, string | string[]>, requestId: string) =>
|
||||
apiRequest<ExamSubmitResult>({
|
||||
url: `/api/aihr/mobile/exams/${id}/submit`,
|
||||
method: 'POST',
|
||||
data: {
|
||||
requestId,
|
||||
answers: Object.entries(answers).map(([questionId, value]) => ({
|
||||
questionId: Number(questionId),
|
||||
answers: Array.isArray(value) ? value : [String(value)]
|
||||
}))
|
||||
},
|
||||
timeout: 30000
|
||||
});
|
||||
|
||||
export const listSupervisorExams = async () =>
|
||||
(await apiRequest<RawExamSummary[]>({
|
||||
url: '/api/aihr/mobile/exams/supervisor',
|
||||
method: 'GET',
|
||||
timeout: 15000
|
||||
})).map(normalizeSummary);
|
||||
|
||||
export const getSupervisorExam = async (id: number) =>
|
||||
normalizeDetail(await apiRequest<RawExamDetail>({
|
||||
url: `/api/aihr/mobile/exams/supervisor/${id}`,
|
||||
method: 'GET',
|
||||
timeout: 15000
|
||||
}));
|
||||
|
||||
export const saveSupervisorExam = async (draft: SupervisorExamDraft) =>
|
||||
normalizeDetail(await apiRequest<RawExamDetail>({
|
||||
url: '/api/aihr/mobile/exams/supervisor',
|
||||
method: 'POST',
|
||||
data: serializeDraft(draft),
|
||||
timeout: 30000
|
||||
}));
|
||||
|
||||
export const updateSupervisorExam = async (id: number, draft: SupervisorExamDraft) =>
|
||||
normalizeDetail(await apiRequest<RawExamDetail>({
|
||||
url: `/api/aihr/mobile/exams/supervisor/${id}`,
|
||||
method: 'PUT',
|
||||
data: serializeDraft(draft),
|
||||
timeout: 30000
|
||||
}));
|
||||
|
||||
export const publishSupervisorExam = async (id: number, requestId: string) =>
|
||||
normalizeDetail(await apiRequest<RawExamDetail>({
|
||||
url: `/api/aihr/mobile/exams/supervisor/${id}/publish`,
|
||||
method: 'POST',
|
||||
data: { requestId },
|
||||
timeout: 30000
|
||||
}));
|
||||
|
||||
export const getSupervisorExamResults = (id: number) =>
|
||||
apiRequest<ExamResultItem[]>({
|
||||
url: `/api/aihr/mobile/exams/supervisor/${id}/results`,
|
||||
method: 'GET',
|
||||
timeout: 15000
|
||||
});
|
||||
@@ -1,11 +1,13 @@
|
||||
import type {
|
||||
AnswerFeedbackVerdict,
|
||||
KnowledgeSearchResponse,
|
||||
KnowledgeMediaSelectedFile,
|
||||
UnifiedKnowledgeResponse,
|
||||
KnowledgeSnippet,
|
||||
PositionSopSummary,
|
||||
SummaryCardResponse
|
||||
} from '@/types/api';
|
||||
import { apiRequest } from './api';
|
||||
import { apiRequest, apiUrl, authHeaders, readTextPayload } from './api';
|
||||
import { getSelectedPosition } from './auth';
|
||||
|
||||
type RawKnowledgeSnippet = KnowledgeSnippet & {
|
||||
@@ -13,6 +15,38 @@ type RawKnowledgeSnippet = KnowledgeSnippet & {
|
||||
text?: string;
|
||||
};
|
||||
|
||||
const mediaExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v'];
|
||||
const videoExtensions = new Set(['.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v']);
|
||||
|
||||
export const chooseKnowledgeMedia = () =>
|
||||
new Promise<KnowledgeMediaSelectedFile>((resolve, reject) => {
|
||||
uni.chooseFile({
|
||||
count: 1,
|
||||
type: 'all',
|
||||
extension: mediaExtensions,
|
||||
success: (result) => {
|
||||
const tempFiles = Array.isArray(result.tempFiles) ? result.tempFiles : [];
|
||||
const file = (tempFiles[0] || {}) as { path?: string; name?: string; size?: number };
|
||||
const path = file.path || result.tempFilePaths?.[0] || '';
|
||||
const name = file.name || path.split('/').pop() || '现场附件';
|
||||
const extension = `.${name.split('.').pop()?.toLowerCase() || ''}`;
|
||||
const kind = videoExtensions.has(extension) ? 'video' : 'image';
|
||||
const size = Number(file.size || 0);
|
||||
const maxSize = kind === 'video' ? 100 * 1024 * 1024 : 10 * 1024 * 1024;
|
||||
if (!path) {
|
||||
reject(new Error('未获取到附件文件'));
|
||||
return;
|
||||
}
|
||||
if (size > maxSize) {
|
||||
reject(new Error(kind === 'video' ? '视频不能超过 100MB' : '图片不能超过 10MB'));
|
||||
return;
|
||||
}
|
||||
resolve({ path, name, size, kind });
|
||||
},
|
||||
fail: () => reject(new Error('未选择附件'))
|
||||
});
|
||||
});
|
||||
|
||||
const normalizeSnippet = (snippet: RawKnowledgeSnippet): KnowledgeSnippet => ({
|
||||
...snippet,
|
||||
docTitle: snippet.docTitle || snippet.title || '',
|
||||
@@ -20,8 +54,8 @@ const normalizeSnippet = (snippet: RawKnowledgeSnippet): KnowledgeSnippet => ({
|
||||
});
|
||||
|
||||
export const searchKnowledge = async (queryText: string, position = getSelectedPosition()) => {
|
||||
const response = await apiRequest<KnowledgeSearchResponse>({
|
||||
url: '/api/knowledge/search',
|
||||
const response = await apiRequest<UnifiedKnowledgeResponse>({
|
||||
url: '/api/knowledge/query',
|
||||
method: 'POST',
|
||||
data: {
|
||||
queryText,
|
||||
@@ -31,9 +65,74 @@ export const searchKnowledge = async (queryText: string, position = getSelectedP
|
||||
},
|
||||
timeout: 30000
|
||||
});
|
||||
return normalizeUnifiedResponse(response);
|
||||
};
|
||||
|
||||
export const searchKnowledgeWithMedia = (
|
||||
file: KnowledgeMediaSelectedFile,
|
||||
queryText: string,
|
||||
position = getSelectedPosition(),
|
||||
onUploadTask?: (task: ReturnType<typeof uni.uploadFile> | null) => void
|
||||
) => new Promise<KnowledgeSearchResponse>((resolve, reject) => {
|
||||
const task = uni.uploadFile({
|
||||
url: apiUrl('/api/knowledge/query-media'),
|
||||
filePath: file.path,
|
||||
name: 'file',
|
||||
header: authHeaders(false),
|
||||
formData: { queryText, position },
|
||||
timeout: 360000,
|
||||
success: (response) => {
|
||||
try {
|
||||
const payload = readTextPayload<UnifiedKnowledgeResponse>(response.statusCode, response.data);
|
||||
resolve(normalizeUnifiedResponse(payload));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
},
|
||||
fail: () => reject(new Error(file.kind === 'video' ? '视频上传或分析超时' : '图片上传失败')),
|
||||
complete: () => onUploadTask?.(null)
|
||||
});
|
||||
onUploadTask?.(task);
|
||||
});
|
||||
|
||||
export const queryKnowledgeTool = async (
|
||||
toolCode: 'MY_PRACTICE_SUMMARY' | 'TEAM_PRACTICE_SUMMARY',
|
||||
queryText: string
|
||||
) => {
|
||||
const response = await apiRequest<UnifiedKnowledgeResponse>({
|
||||
url: '/api/knowledge/query',
|
||||
method: 'POST',
|
||||
data: { queryText, source: 'mobile_uni_tool', limit: 5, toolCode },
|
||||
timeout: 30000
|
||||
});
|
||||
return normalizeUnifiedResponse(response);
|
||||
};
|
||||
|
||||
const normalizeUnifiedResponse = (response: UnifiedKnowledgeResponse): KnowledgeSearchResponse => {
|
||||
const legacy = response.legacy || ({} as KnowledgeSearchResponse);
|
||||
const citationSnippets = (response.citations || []).map((item) => normalizeSnippet({
|
||||
docTitle: item.title,
|
||||
content: item.snippet,
|
||||
fragmentId: item.fragmentId,
|
||||
spaceCode: item.spaceCode,
|
||||
sourceType: item.sourceType
|
||||
}));
|
||||
return {
|
||||
...response,
|
||||
snippets: (response.snippets || []).map((item) => normalizeSnippet(item as RawKnowledgeSnippet))
|
||||
...legacy,
|
||||
requestId: response.requestId,
|
||||
queryText: response.queryText,
|
||||
category: legacy.category || 'sop',
|
||||
answer: response.answer,
|
||||
reference: legacy.reference || (response.citations || []).map((item) => item.title).join('、'),
|
||||
docs: legacy.docs || [],
|
||||
snippets: (legacy.snippets?.length ? legacy.snippets : citationSnippets).map((item) => normalizeSnippet(item as RawKnowledgeSnippet)),
|
||||
training: legacy.training || [],
|
||||
records: legacy.records || [],
|
||||
citations: response.citations || [],
|
||||
usedSpaceCodes: response.usedSpaceCodes || [],
|
||||
noEvidence: response.noEvidence,
|
||||
promptVersion: response.promptVersion,
|
||||
data: response.data
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import type { MobileIdentity, RoleKey } from '@/types/api';
|
||||
import { apiRequest } from './api';
|
||||
import {
|
||||
hasSelectedPosition,
|
||||
clearOrgPosition,
|
||||
getSelectedPosition,
|
||||
hasOrgPosition,
|
||||
isSupervisorLearnerMode,
|
||||
leaveSupervisorLearnerMode,
|
||||
normalizePosition,
|
||||
roleKey,
|
||||
saveSelectedPosition,
|
||||
saveOrgPosition,
|
||||
type UserPosition
|
||||
} from './auth';
|
||||
|
||||
@@ -20,7 +24,7 @@ export const isUserRoute = (url: string) => url.startsWith('/pages/user/');
|
||||
export const isSupervisorRoute = (url: string) => url.startsWith('/pages/supervisor/');
|
||||
|
||||
export const routeForMobileRole = (role: RoleKey, requested: string) => {
|
||||
if (role === 'supervisor' && (isUserRoute(requested) || isSupervisorRoute(requested))) {
|
||||
if (role === 'supervisor' && isUserRoute(requested) && !isSupervisorLearnerMode()) {
|
||||
return '/pages/supervisor/index/index';
|
||||
}
|
||||
if (role === 'user' && isSupervisorRoute(requested)) {
|
||||
@@ -39,6 +43,7 @@ export const hydrateMobileRole = async (): Promise<RoleKey> => {
|
||||
});
|
||||
const role = identity.role === 'supervisor' ? 'supervisor' : 'user';
|
||||
uni.setStorageSync(roleKey, role);
|
||||
if (role !== 'supervisor') leaveSupervisorLearnerMode();
|
||||
return role;
|
||||
} catch {
|
||||
// ponytail: role lookup failure fails closed; never reuse a stale supervisor cache.
|
||||
@@ -66,9 +71,11 @@ export const hydratePositionFromOrg = async (): Promise<UserPosition | ''> => {
|
||||
timeout: 15000,
|
||||
clearAuthOnUnauthorized: false
|
||||
});
|
||||
return saveSelectedPosition(identity.positionName);
|
||||
const officialPosition = saveOrgPosition(identity.positionName);
|
||||
if (!officialPosition) clearOrgPosition();
|
||||
return officialPosition;
|
||||
} catch {
|
||||
return '';
|
||||
return hasOrgPosition() ? getSelectedPosition() : '';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -78,7 +85,7 @@ export const nextUserRouteAfterLogin = async (redirect: string) => {
|
||||
if (roleRoute !== redirect) return roleRoute;
|
||||
if (!isUserRoute(redirect)) return redirect;
|
||||
const officialPosition = await hydratePositionFromOrg();
|
||||
if (normalizePosition(officialPosition) || hasSelectedPosition()) return redirect;
|
||||
if (normalizePosition(officialPosition) || hasOrgPosition()) return redirect;
|
||||
return positionSetupUrl(redirect);
|
||||
};
|
||||
|
||||
|
||||
@@ -13,10 +13,14 @@ import type {
|
||||
OnboardTask,
|
||||
QualificationSummary,
|
||||
PracticeAssignmentRequest,
|
||||
PracticeAssignmentBatchRequest,
|
||||
PracticeAssignmentBatchResponse,
|
||||
DailyDrillAnswerResponse,
|
||||
PracticeAlert,
|
||||
PromotionEvidence,
|
||||
PracticeScenarioOption
|
||||
PracticeScenarioOption,
|
||||
PracticeDifficulty,
|
||||
PracticeQuestionFeedback
|
||||
} from '@/types/api';
|
||||
import { apiRequest } from './api';
|
||||
import { getAuth, getSelectedPosition } from './auth';
|
||||
@@ -206,6 +210,14 @@ export const createPracticeAssignment = (data: PracticeAssignmentRequest) =>
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
export const createPracticeAssignmentsBatch = (data: PracticeAssignmentBatchRequest) =>
|
||||
apiRequest<PracticeAssignmentBatchResponse>({
|
||||
url: '/api/aihr/mobile/practice/assignments/batch',
|
||||
method: 'POST',
|
||||
data,
|
||||
timeout: 30000
|
||||
});
|
||||
|
||||
export const answerDailyDrill = (id: number, answer: string, audioUrl?: string, audioOssId?: number) =>
|
||||
apiRequest<DailyDrillAnswerResponse>({
|
||||
url: `/api/aihr/mobile/practice/assignments/${id}/answer`,
|
||||
@@ -220,3 +232,53 @@ export const getPracticeAlerts = () =>
|
||||
method: 'GET',
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
export const submitPracticeQuestionFeedback = (
|
||||
assignmentId: number,
|
||||
verdict: PracticeQuestionFeedback['verdict'],
|
||||
comment = ''
|
||||
) =>
|
||||
apiRequest<PracticeQuestionFeedback>({
|
||||
url: `/api/aihr/mobile/practice/assignments/${assignmentId}/feedback`,
|
||||
method: 'POST',
|
||||
data: { verdict, comment },
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
export const getPracticeDifficulties = async (windowDays = 30, position = ''): Promise<PracticeDifficulty[]> => {
|
||||
const rows = await apiRequest<Array<{
|
||||
scenarioId: string;
|
||||
scenarioName: string;
|
||||
position?: string;
|
||||
attempts: number;
|
||||
mistakeCount: number;
|
||||
affectedPeople: number;
|
||||
averageScore: number;
|
||||
difficultyScore: number;
|
||||
latestTime: string;
|
||||
}>>({
|
||||
url: `/api/aihr/mobile/practice/difficulties?windowDays=${windowDays}${position ? `&position=${encodeURIComponent(position)}` : ''}`,
|
||||
method: 'GET',
|
||||
timeout: 15000
|
||||
});
|
||||
return rows.map((item) => ({
|
||||
...item,
|
||||
cause: item.mistakeCount >= Math.max(2, Math.ceil(item.attempts / 2)) ? '高频错题或低分' : '近期低分记录',
|
||||
count: item.mistakeCount
|
||||
}));
|
||||
};
|
||||
|
||||
export const assignPracticeDifficulty = (data: {
|
||||
scenarioId: string;
|
||||
cause: string;
|
||||
dueDate: string;
|
||||
requestId: string;
|
||||
windowDays: number;
|
||||
position?: string;
|
||||
}) =>
|
||||
apiRequest<PracticeAssignmentBatchResponse>({
|
||||
url: '/api/aihr/mobile/practice/difficulties/assign',
|
||||
method: 'POST',
|
||||
data,
|
||||
timeout: 30000
|
||||
});
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import type {
|
||||
IncentiveBalance,
|
||||
IncentiveLedgerItem,
|
||||
QuestionBoardAnswer,
|
||||
QuestionBoardItem,
|
||||
QuestionBoardTab
|
||||
} from '@/types/api';
|
||||
import { apiRequest } from './api';
|
||||
|
||||
type RawAnswer = {
|
||||
id: number;
|
||||
questionId: number;
|
||||
authorName: string;
|
||||
content: string;
|
||||
status: string;
|
||||
best: boolean;
|
||||
mine: boolean;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type RawQuestion = {
|
||||
id: number;
|
||||
type: string;
|
||||
category: string;
|
||||
title: string;
|
||||
content: string;
|
||||
authorName: string;
|
||||
rewardPoints: number;
|
||||
rewardAccountType: string;
|
||||
ruleCode: string;
|
||||
deadline?: string | null;
|
||||
status: string;
|
||||
bestAnswerId?: number | null;
|
||||
answerCount: number;
|
||||
mine: boolean;
|
||||
createdAt: string;
|
||||
answers?: RawAnswer[];
|
||||
};
|
||||
|
||||
type RawPoints = {
|
||||
balance: number;
|
||||
pointsBalance: number;
|
||||
studyCreditBalance: number;
|
||||
entries: Array<{
|
||||
id: number;
|
||||
type: string;
|
||||
businessId: number;
|
||||
points: number;
|
||||
accountType: 'POINTS' | 'STUDY_CREDIT' | string;
|
||||
reason: string;
|
||||
createdAt: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
const normalizeAnswer = (item: RawAnswer): QuestionBoardAnswer => ({
|
||||
id: item.id,
|
||||
questionId: item.questionId,
|
||||
content: item.content,
|
||||
authorName: item.authorName,
|
||||
status: item.status,
|
||||
best: item.best,
|
||||
mine: item.mine,
|
||||
recommended: false,
|
||||
createTime: item.createdAt
|
||||
});
|
||||
|
||||
const normalizeQuestion = (item: RawQuestion): QuestionBoardItem => ({
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
title: item.title,
|
||||
content: item.content,
|
||||
category: item.category || '工作求助',
|
||||
authorName: item.authorName,
|
||||
status: item.status,
|
||||
rewardPoints: item.rewardPoints || 0,
|
||||
rewardAccountType: item.rewardAccountType || 'POINTS',
|
||||
answerCount: item.answerCount || 0,
|
||||
deadline: item.deadline || null,
|
||||
createTime: item.createdAt,
|
||||
bestAnswerId: item.bestAnswerId || null,
|
||||
mine: item.mine,
|
||||
answers: (item.answers || []).map(normalizeAnswer)
|
||||
});
|
||||
|
||||
export const listQuestions = async (tab: QuestionBoardTab) => {
|
||||
const rows = await apiRequest<RawQuestion[]>({
|
||||
url: `/api/aihr/community/questions${tab === 'MINE' ? '?mine=true' : ''}`,
|
||||
method: 'GET',
|
||||
timeout: 15000
|
||||
});
|
||||
const normalized = rows.map(normalizeQuestion);
|
||||
if (tab === 'UNANSWERED') return normalized.filter((item) => item.answerCount === 0);
|
||||
if (tab === 'REWARDED') return normalized.filter((item) => item.rewardPoints > 0);
|
||||
return normalized;
|
||||
};
|
||||
|
||||
export const getQuestion = async (id: number) =>
|
||||
normalizeQuestion(await apiRequest<RawQuestion>({
|
||||
url: `/api/aihr/community/questions/${id}`,
|
||||
method: 'GET',
|
||||
timeout: 15000
|
||||
}));
|
||||
|
||||
export const publishQuestion = (data: { title: string; content: string; category: string }) =>
|
||||
apiRequest<RawQuestion>({
|
||||
url: '/api/aihr/community/questions',
|
||||
method: 'POST',
|
||||
data: data,
|
||||
timeout: 15000
|
||||
}).then(normalizeQuestion);
|
||||
|
||||
export const answerQuestion = (questionId: number, content: string) =>
|
||||
apiRequest<RawAnswer>({
|
||||
url: `/api/aihr/community/questions/${questionId}/answers`,
|
||||
method: 'POST',
|
||||
data: { content },
|
||||
timeout: 15000
|
||||
}).then(normalizeAnswer);
|
||||
|
||||
export const chooseBestAnswer = (questionId: number, answerId: number) =>
|
||||
apiRequest<unknown>({
|
||||
url: `/api/aihr/community/questions/${questionId}/best-answer`,
|
||||
method: 'POST',
|
||||
data: { answerId },
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
export const getMyIncentiveBalances = async (): Promise<IncentiveBalance[]> => {
|
||||
const result = await apiRequest<RawPoints>({
|
||||
url: '/api/aihr/community/points/me',
|
||||
method: 'GET',
|
||||
timeout: 15000
|
||||
});
|
||||
return [
|
||||
{ accountType: 'POINTS', displayName: '奖励积分', balance: result.pointsBalance ?? result.balance ?? 0, unitName: '积分' },
|
||||
{ accountType: 'STUDY_CREDIT', displayName: '学习学分', balance: result.studyCreditBalance || 0, unitName: '学分' }
|
||||
];
|
||||
};
|
||||
|
||||
export const getMyIncentiveLedger = async (): Promise<IncentiveLedgerItem[]> => {
|
||||
const result = await apiRequest<RawPoints>({
|
||||
url: '/api/aihr/community/points/me',
|
||||
method: 'GET',
|
||||
timeout: 15000
|
||||
});
|
||||
return (result.entries || []).map((item) => ({
|
||||
id: item.id,
|
||||
accountType: item.accountType,
|
||||
points: item.points,
|
||||
direction: item.points < 0 ? 'REVERSE' : 'EARN',
|
||||
sourceType: item.type,
|
||||
remark: item.reason,
|
||||
createTime: item.createdAt
|
||||
}));
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
import { getAuth } from './auth';
|
||||
|
||||
export interface RequestScopeTicket {
|
||||
accountKey: string;
|
||||
pageGeneration: number;
|
||||
lane: string;
|
||||
laneGeneration: number;
|
||||
}
|
||||
|
||||
export interface PageRequestScope {
|
||||
activate: () => void;
|
||||
begin: (lane: string) => RequestScopeTicket;
|
||||
invalidate: () => void;
|
||||
isCurrent: (ticket: RequestScopeTicket) => boolean;
|
||||
}
|
||||
|
||||
const tokenFingerprint = (token: string) => {
|
||||
let hash = 2166136261;
|
||||
for (let index = 0; index < token.length; index += 1) {
|
||||
hash ^= token.charCodeAt(index);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return (hash >>> 0).toString(16).padStart(8, '0');
|
||||
};
|
||||
|
||||
export const currentAccountKey = () => {
|
||||
const auth = getAuth();
|
||||
return `${auth.phone}|${auth.clientId}|${tokenFingerprint(auth.token)}`;
|
||||
};
|
||||
|
||||
export const createPageRequestScope = (): PageRequestScope => {
|
||||
let active = false;
|
||||
let accountKey = '';
|
||||
let pageGeneration = 0;
|
||||
const laneGenerations = new Map<string, number>();
|
||||
|
||||
const activate = () => {
|
||||
active = true;
|
||||
accountKey = currentAccountKey();
|
||||
pageGeneration += 1;
|
||||
laneGenerations.clear();
|
||||
};
|
||||
|
||||
const begin = (lane: string): RequestScopeTicket => {
|
||||
const nextAccountKey = currentAccountKey();
|
||||
if (active && nextAccountKey !== accountKey) {
|
||||
accountKey = nextAccountKey;
|
||||
pageGeneration += 1;
|
||||
laneGenerations.clear();
|
||||
}
|
||||
const laneGeneration = (laneGenerations.get(lane) || 0) + 1;
|
||||
laneGenerations.set(lane, laneGeneration);
|
||||
return { accountKey, pageGeneration, lane, laneGeneration };
|
||||
};
|
||||
|
||||
return {
|
||||
activate,
|
||||
begin,
|
||||
invalidate: () => {
|
||||
active = false;
|
||||
pageGeneration += 1;
|
||||
laneGenerations.clear();
|
||||
},
|
||||
isCurrent: (ticket) => active
|
||||
&& ticket.pageGeneration === pageGeneration
|
||||
&& ticket.accountKey === accountKey
|
||||
&& ticket.accountKey === currentAccountKey()
|
||||
&& ticket.laneGeneration === laneGenerations.get(ticket.lane)
|
||||
};
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AsrResponse, SpeechSelectedFile } from '@/types/api';
|
||||
import { apiUrl, authHeaders, readTextPayload } from './api';
|
||||
import type { AsrResponse, SpeechPlaybackStatus, SpeechSelectedFile, TtsResponse } from '@/types/api';
|
||||
import { apiRequest, apiUrl, authHeaders, readTextPayload } from './api';
|
||||
|
||||
const audioExtensions = ['.mp3', '.wav', '.m4a', '.webm', '.aac', '.ogg'];
|
||||
|
||||
@@ -27,9 +27,11 @@ export const chooseSpeechAudio = () =>
|
||||
});
|
||||
});
|
||||
|
||||
export const transcribeSpeechFile = (file: SpeechSelectedFile) =>
|
||||
export type SpeechAbortRegistrar = (abort: (() => void) | null) => void;
|
||||
|
||||
export const transcribeSpeechFile = (file: SpeechSelectedFile, registerAbort?: SpeechAbortRegistrar) =>
|
||||
new Promise<AsrResponse>((resolve, reject) => {
|
||||
uni.uploadFile({
|
||||
const task = uni.uploadFile({
|
||||
url: apiUrl('/api/ai/asr'),
|
||||
filePath: file.path,
|
||||
name: 'file',
|
||||
@@ -41,11 +43,17 @@ export const transcribeSpeechFile = (file: SpeechSelectedFile) =>
|
||||
reject(error);
|
||||
}
|
||||
},
|
||||
fail: () => reject(new Error('语音上传失败'))
|
||||
fail: () => reject(new Error('语音上传失败')),
|
||||
complete: () => registerAbort?.(null)
|
||||
});
|
||||
registerAbort?.(() => task.abort());
|
||||
});
|
||||
|
||||
export const transcribeSpeechBlob = (blob: Blob, filename = 'practice-audio.webm') =>
|
||||
export const transcribeSpeechBlob = (
|
||||
blob: Blob,
|
||||
filename = 'practice-audio.webm',
|
||||
registerAbort?: SpeechAbortRegistrar
|
||||
) =>
|
||||
new Promise<AsrResponse>((resolve, reject) => {
|
||||
const form = new FormData();
|
||||
form.append('file', blob, filename);
|
||||
@@ -62,6 +70,144 @@ export const transcribeSpeechBlob = (blob: Blob, filename = 'practice-audio.webm
|
||||
}
|
||||
};
|
||||
xhr.onerror = () => reject(new Error('语音上传失败'));
|
||||
xhr.onabort = () => reject(new Error('语音转写已取消'));
|
||||
xhr.ontimeout = () => reject(new Error('语音转写超时'));
|
||||
xhr.onloadend = () => registerAbort?.(null);
|
||||
registerAbort?.(() => xhr.abort());
|
||||
xhr.send(form);
|
||||
});
|
||||
|
||||
export const synthesizeSpeech = (text: string) =>
|
||||
apiRequest<TtsResponse>({
|
||||
url: '/api/ai/tts',
|
||||
method: 'POST',
|
||||
data: { text: text.trim().slice(0, 300) },
|
||||
timeout: 60000
|
||||
});
|
||||
|
||||
export interface SpeechPlaybackSnapshot {
|
||||
key: string;
|
||||
status: SpeechPlaybackStatus;
|
||||
}
|
||||
|
||||
export const createSpeechPlaybackController = (
|
||||
onState: (snapshot: SpeechPlaybackSnapshot) => void,
|
||||
onError: (message: string) => void
|
||||
) => {
|
||||
let audio: ReturnType<typeof uni.createInnerAudioContext> | null = null;
|
||||
let browserUtterance: SpeechSynthesisUtterance | null = null;
|
||||
let activeKey = '';
|
||||
let generation = 0;
|
||||
const sourceCache = new Map<string, string>();
|
||||
const maxCachedSources = 4;
|
||||
|
||||
const publish = (key = '', status: SpeechPlaybackStatus = 'idle') => {
|
||||
activeKey = key;
|
||||
onState({ key, status });
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
generation += 1;
|
||||
if (audio) {
|
||||
audio.stop();
|
||||
audio.destroy();
|
||||
audio = null;
|
||||
}
|
||||
if (typeof window !== 'undefined' && window.speechSynthesis && browserUtterance) {
|
||||
window.speechSynthesis.cancel();
|
||||
browserUtterance = null;
|
||||
}
|
||||
publish();
|
||||
};
|
||||
|
||||
const playWithBrowserSpeech = (key: string, value: string, currentGeneration: number) => {
|
||||
if (
|
||||
typeof window === 'undefined'
|
||||
|| !window.speechSynthesis
|
||||
|| typeof window.SpeechSynthesisUtterance !== 'function'
|
||||
) return false;
|
||||
|
||||
const utterance = new window.SpeechSynthesisUtterance(value.slice(0, 600));
|
||||
utterance.lang = 'zh-CN';
|
||||
utterance.rate = 0.95;
|
||||
utterance.onend = () => {
|
||||
if (currentGeneration === generation && activeKey === key) stop();
|
||||
};
|
||||
utterance.onerror = () => {
|
||||
if (currentGeneration !== generation || activeKey !== key) return;
|
||||
stop();
|
||||
onError('当前设备语音播报不可用');
|
||||
};
|
||||
browserUtterance = utterance;
|
||||
publish(key, 'playing');
|
||||
window.speechSynthesis.speak(utterance);
|
||||
return true;
|
||||
};
|
||||
|
||||
const playAudioSource = (key: string, source: string, cacheKey: string, currentGeneration: number) => {
|
||||
const context = uni.createInnerAudioContext();
|
||||
audio = context;
|
||||
const isCurrent = () => currentGeneration === generation && audio === context && activeKey === key;
|
||||
context.onPlay(() => {
|
||||
if (isCurrent()) publish(key, 'playing');
|
||||
});
|
||||
context.onEnded(() => {
|
||||
if (isCurrent()) stop();
|
||||
});
|
||||
context.onStop(() => {
|
||||
if (isCurrent()) publish();
|
||||
});
|
||||
context.onError(() => {
|
||||
if (!isCurrent()) return;
|
||||
sourceCache.delete(cacheKey);
|
||||
stop();
|
||||
onError('语音播放失败,请稍后重试');
|
||||
});
|
||||
context.src = source;
|
||||
context.play();
|
||||
};
|
||||
|
||||
const toggle = async (key: string, text: string) => {
|
||||
if (activeKey === key) {
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
stop();
|
||||
const value = text.trim();
|
||||
if (!value) return;
|
||||
const currentGeneration = generation;
|
||||
publish(key, 'loading');
|
||||
try {
|
||||
const cachedSource = sourceCache.get(value);
|
||||
if (cachedSource) {
|
||||
sourceCache.delete(value);
|
||||
sourceCache.set(value, cachedSource);
|
||||
playAudioSource(key, cachedSource, value, currentGeneration);
|
||||
return;
|
||||
}
|
||||
const result = await synthesizeSpeech(value);
|
||||
if (currentGeneration !== generation || activeKey !== key) return;
|
||||
const source = result.inlineAudioUrl || result.audioUrl;
|
||||
if (!source) throw new Error('语音合成未返回音频');
|
||||
sourceCache.set(value, source);
|
||||
while (sourceCache.size > maxCachedSources) {
|
||||
const oldestKey = sourceCache.keys().next().value as string | undefined;
|
||||
if (!oldestKey) break;
|
||||
sourceCache.delete(oldestKey);
|
||||
}
|
||||
playAudioSource(key, source, value, currentGeneration);
|
||||
} catch (error) {
|
||||
if (currentGeneration !== generation) return;
|
||||
if (playWithBrowserSpeech(key, value, currentGeneration)) return;
|
||||
stop();
|
||||
onError(error instanceof Error ? error.message : '语音生成失败');
|
||||
}
|
||||
};
|
||||
|
||||
const destroy = () => {
|
||||
stop();
|
||||
sourceCache.clear();
|
||||
};
|
||||
|
||||
return { toggle, stop, destroy };
|
||||
};
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { SummaryCardResponse } from '@/types/api';
|
||||
|
||||
const WIDTH = 1080;
|
||||
const PADDING = 84;
|
||||
const CONTENT_WIDTH = WIDTH - PADDING * 2;
|
||||
|
||||
const safeFileName = (value: string) =>
|
||||
(value || 'SOP总结卡').replace(/[\\/:*?"<>|\r\n]+/g, '-').slice(0, 48);
|
||||
|
||||
const wrapText = (context: CanvasRenderingContext2D, text: string, maxWidth: number) => {
|
||||
const lines: string[] = [];
|
||||
let current = '';
|
||||
for (const char of String(text || '').trim()) {
|
||||
const next = current + char;
|
||||
if (current && context.measureText(next).width > maxWidth) {
|
||||
lines.push(current);
|
||||
current = char;
|
||||
} else {
|
||||
current = next;
|
||||
}
|
||||
}
|
||||
if (current) lines.push(current);
|
||||
return lines.length ? lines : [''];
|
||||
};
|
||||
|
||||
export const downloadSummaryCardImage = async (card: SummaryCardResponse) => {
|
||||
if (typeof document === 'undefined' || typeof URL === 'undefined') {
|
||||
throw new Error('当前终端暂不支持下载图片');
|
||||
}
|
||||
if (document.fonts?.ready) await document.fonts.ready;
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = WIDTH;
|
||||
canvas.height = 1;
|
||||
const measureContext = canvas.getContext('2d');
|
||||
if (!measureContext) throw new Error('图片生成失败');
|
||||
|
||||
measureContext.font = '800 56px system-ui, sans-serif';
|
||||
const titleLines = wrapText(measureContext, card.title || '工作总结卡', CONTENT_WIDTH);
|
||||
const sectionRows = [
|
||||
['核心步骤', (card.steps || []).map((item) => `${item.label}:${item.desc}`)],
|
||||
['推荐话术', card.scripts || []],
|
||||
['常见异议与回应', (card.objections || []).map((item) => `${item.concern};回应:${item.response}`)],
|
||||
['师傅提醒', card.reminders || []]
|
||||
] as const;
|
||||
measureContext.font = '400 28px system-ui, sans-serif';
|
||||
const sections = sectionRows
|
||||
.filter(([, rows]) => rows.length)
|
||||
.map(([title, rows]) => ({
|
||||
title,
|
||||
rows: rows.map((row) => wrapText(measureContext, row, CONTENT_WIDTH - 64))
|
||||
}));
|
||||
|
||||
let measuredY = 118 + 80 + titleLines.length * 76 + 14 + 70;
|
||||
sections.forEach((section) => {
|
||||
measuredY += 48;
|
||||
section.rows.forEach((lines) => {
|
||||
measuredY += Math.max(54, lines.length * 44 + 18);
|
||||
});
|
||||
measuredY += 36;
|
||||
});
|
||||
const height = Math.max(720, measuredY + 100);
|
||||
canvas.height = height;
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) throw new Error('图片生成失败');
|
||||
|
||||
context.fillStyle = '#f7f9fc';
|
||||
context.fillRect(0, 0, WIDTH, height);
|
||||
context.fillStyle = '#ffffff';
|
||||
context.fillRect(36, 36, WIDTH - 72, height - 72);
|
||||
|
||||
let y = 118;
|
||||
context.fillStyle = '#e60012';
|
||||
context.font = '700 28px system-ui, sans-serif';
|
||||
context.fillText('银城数字师傅 · SOP 总结卡', PADDING, y);
|
||||
y += 80;
|
||||
context.fillStyle = '#172033';
|
||||
context.font = '800 56px system-ui, sans-serif';
|
||||
titleLines.forEach((line) => {
|
||||
context.fillText(line, PADDING, y);
|
||||
y += 76;
|
||||
});
|
||||
y += 14;
|
||||
context.fillStyle = '#e60012';
|
||||
context.fillRect(PADDING, y, 88, 8);
|
||||
y += 70;
|
||||
|
||||
sections.forEach((section) => {
|
||||
context.fillStyle = '#172033';
|
||||
context.font = '800 34px system-ui, sans-serif';
|
||||
context.fillText(section.title, PADDING, y);
|
||||
y += 48;
|
||||
section.rows.forEach((lines, index) => {
|
||||
context.fillStyle = '#fff0f1';
|
||||
context.beginPath();
|
||||
context.arc(PADDING + 18, y + 19, 18, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
context.fillStyle = '#e60012';
|
||||
context.font = '800 20px system-ui, sans-serif';
|
||||
context.textAlign = 'center';
|
||||
context.fillText(String(index + 1), PADDING + 18, y + 27);
|
||||
context.textAlign = 'left';
|
||||
context.fillStyle = '#3f4a5c';
|
||||
context.font = '400 28px system-ui, sans-serif';
|
||||
lines.forEach((line, lineIndex) => context.fillText(line, PADDING + 56, y + 28 + lineIndex * 44));
|
||||
y += Math.max(54, lines.length * 44 + 18);
|
||||
});
|
||||
y += 36;
|
||||
});
|
||||
|
||||
context.fillStyle = '#8a94a5';
|
||||
context.font = '400 22px system-ui, sans-serif';
|
||||
context.fillText('内容由数字师傅基于已授权 SOP 生成,请结合现场情况执行。', PADDING, y + 10);
|
||||
|
||||
const blob = await new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob((value) => value ? resolve(value) : reject(new Error('图片生成失败')), 'image/png');
|
||||
});
|
||||
canvas.width = 1;
|
||||
canvas.height = 1;
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${safeFileName(card.title)}.png`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { WebAiCapabilities, WebAiResponse } from '@/types/api';
|
||||
import { apiRequest } from './api';
|
||||
|
||||
type RawCapabilities = {
|
||||
enabled: boolean;
|
||||
provider: string;
|
||||
requiresConsent: boolean;
|
||||
maxQuestionLength: number;
|
||||
notice: string;
|
||||
};
|
||||
|
||||
type RawWebAiResponse = {
|
||||
mode: string;
|
||||
status: string;
|
||||
answer: string;
|
||||
sources?: Array<{ title: string; url: string; snippet: string; score?: number }>;
|
||||
provider: string;
|
||||
notice: string;
|
||||
redacted: boolean;
|
||||
requiresConsent: boolean;
|
||||
};
|
||||
|
||||
const domainOf = (value: string) => {
|
||||
try { return new URL(value).hostname; } catch { return ''; }
|
||||
};
|
||||
|
||||
export const getWebAiCapabilities = async (): Promise<WebAiCapabilities> => {
|
||||
const raw = await apiRequest<RawCapabilities>({
|
||||
url: '/api/aihr/web-ai/capabilities',
|
||||
method: 'GET',
|
||||
timeout: 15000
|
||||
});
|
||||
return {
|
||||
enabled: raw.enabled,
|
||||
requiresConsent: raw.requiresConsent,
|
||||
providerName: raw.provider,
|
||||
maxQuestionLength: raw.maxQuestionLength,
|
||||
notice: raw.notice
|
||||
};
|
||||
};
|
||||
|
||||
export const queryWebAi = async (question: string, consent: boolean): Promise<WebAiResponse> => {
|
||||
const raw = await apiRequest<RawWebAiResponse>({
|
||||
url: '/api/aihr/web-ai/query',
|
||||
method: 'POST',
|
||||
data: { question, consent },
|
||||
timeout: 45000
|
||||
});
|
||||
return {
|
||||
requestId: `web:${Date.now()}`,
|
||||
status: raw.status,
|
||||
provider: raw.provider,
|
||||
answer: raw.answer || '',
|
||||
citations: (raw.sources || []).map((source) => ({
|
||||
title: source.title,
|
||||
url: source.url,
|
||||
domain: domainOf(source.url),
|
||||
snippet: source.snippet
|
||||
})),
|
||||
noEvidence: raw.status !== 'OK',
|
||||
warning: raw.notice,
|
||||
redacted: raw.redacted
|
||||
};
|
||||
};
|
||||
@@ -34,6 +34,34 @@ page {
|
||||
--employee-card-shadow: 0 16px 40px rgba(230, 0, 18, 0.08);
|
||||
}
|
||||
|
||||
html[data-aihr-font-size='standard'] {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
html[data-aihr-font-size='large'] {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
html[data-aihr-font-size='extra-large'] {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
/*
|
||||
* H5 中大量既有页面仍使用 px 字号,单独依赖 text-size-adjust 在桌面
|
||||
* Chromium 和部分 WebView 中不会生效。缩放页面布局并反向收窄 body,
|
||||
* 能在不改写各业务页字号层级的前提下让三档设置稳定覆盖全部页面。
|
||||
*/
|
||||
html[data-aihr-font-size='large'] body {
|
||||
zoom: 1.12;
|
||||
}
|
||||
|
||||
html[data-aihr-font-size='extra-large'] body {
|
||||
zoom: 1.25;
|
||||
}
|
||||
|
||||
view,
|
||||
text,
|
||||
button {
|
||||
@@ -207,6 +235,7 @@ textarea:focus-visible {
|
||||
}
|
||||
|
||||
.input {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 46px;
|
||||
padding: 0 14px;
|
||||
@@ -218,6 +247,7 @@ textarea:focus-visible {
|
||||
}
|
||||
|
||||
.textarea {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 112px;
|
||||
padding: 12px 14px;
|
||||
|
||||
@@ -18,6 +18,15 @@ export interface MobileIdentity {
|
||||
positionLevel: string;
|
||||
}
|
||||
|
||||
export interface TtsResponse {
|
||||
audioUrl: string;
|
||||
inlineAudioUrl?: string;
|
||||
source?: string;
|
||||
ossId?: number | string;
|
||||
}
|
||||
|
||||
export type SpeechPlaybackStatus = 'idle' | 'loading' | 'playing';
|
||||
|
||||
export interface ToolItem {
|
||||
title: string;
|
||||
desc: string;
|
||||
@@ -82,6 +91,8 @@ export interface KnowledgeSnippet {
|
||||
content: string;
|
||||
source?: string;
|
||||
fragmentId?: number | null;
|
||||
spaceCode?: string | null;
|
||||
sourceType?: 'DOCUMENT' | 'DATA_TOOL' | string;
|
||||
}
|
||||
|
||||
export interface KnowledgeDoc {
|
||||
@@ -91,6 +102,7 @@ export interface KnowledgeDoc {
|
||||
}
|
||||
|
||||
export interface KnowledgeSearchResponse {
|
||||
requestId?: string;
|
||||
queryText: string;
|
||||
category: string;
|
||||
answer: string;
|
||||
@@ -103,6 +115,38 @@ export interface KnowledgeSearchResponse {
|
||||
cautions?: string[];
|
||||
promptVersion?: string | null;
|
||||
reviewId?: number | null;
|
||||
citations?: KnowledgeCitation[];
|
||||
usedSpaceCodes?: string[];
|
||||
noEvidence?: boolean;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
export interface KnowledgeMediaSelectedFile {
|
||||
path: string;
|
||||
name: string;
|
||||
size: number;
|
||||
kind: 'image' | 'video';
|
||||
}
|
||||
|
||||
export interface KnowledgeCitation {
|
||||
spaceCode?: string | null;
|
||||
sourceType: 'DOCUMENT' | 'DATA_TOOL' | string;
|
||||
docId?: string | null;
|
||||
title: string;
|
||||
snippet: string;
|
||||
fragmentId?: number | null;
|
||||
}
|
||||
|
||||
export interface UnifiedKnowledgeResponse {
|
||||
requestId: string;
|
||||
queryText: string;
|
||||
answer: string;
|
||||
citations: KnowledgeCitation[];
|
||||
usedSpaceCodes: string[];
|
||||
noEvidence: boolean;
|
||||
promptVersion?: string | null;
|
||||
legacy?: KnowledgeSearchResponse | null;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
export interface CaseCapabilities {
|
||||
@@ -313,6 +357,33 @@ export interface PracticeAssignment {
|
||||
scoreMode?: string;
|
||||
audioUrl?: string;
|
||||
audioOssId?: number | string;
|
||||
dueDate?: string;
|
||||
contentVersion?: string;
|
||||
contentHash?: string;
|
||||
questionCode?: string;
|
||||
itemFeedback?: 'useful' | 'not_useful' | null;
|
||||
}
|
||||
|
||||
export interface PracticeQuestionFeedback {
|
||||
assignmentId: number;
|
||||
verdict: 'useful' | 'not_useful';
|
||||
comment?: string;
|
||||
savedAt?: string;
|
||||
}
|
||||
|
||||
export interface PracticeDifficulty {
|
||||
scenarioId: string;
|
||||
scenarioName: string;
|
||||
cause: string;
|
||||
count: number;
|
||||
affectedPeople: number;
|
||||
averageScore: number;
|
||||
latestTime: string;
|
||||
position?: string;
|
||||
assignmentStatus?: string;
|
||||
attempts?: number;
|
||||
mistakeCount?: number;
|
||||
difficultyScore?: number;
|
||||
}
|
||||
|
||||
export interface OnboardTask {
|
||||
@@ -355,6 +426,40 @@ export interface PracticeAssignmentRequest {
|
||||
scenarioName?: string;
|
||||
source?: string;
|
||||
reason?: string;
|
||||
dueDate?: string;
|
||||
requestId?: string;
|
||||
}
|
||||
|
||||
export interface PracticeAssignmentTarget {
|
||||
extPartyId: string;
|
||||
traineeName: string;
|
||||
}
|
||||
|
||||
export interface PracticeAssignmentBatchRequest {
|
||||
requestId: string;
|
||||
targets: PracticeAssignmentTarget[];
|
||||
scenarioId: string;
|
||||
reason?: string;
|
||||
dueDate: string;
|
||||
}
|
||||
|
||||
export type PracticeAssignmentBatchItemStatus = 'CREATED' | 'DUPLICATE' | 'FAILED';
|
||||
|
||||
export interface PracticeAssignmentBatchItemResult {
|
||||
extPartyId: string;
|
||||
traineeName: string;
|
||||
status: PracticeAssignmentBatchItemStatus;
|
||||
message: string;
|
||||
assignment?: PracticeAssignment | null;
|
||||
}
|
||||
|
||||
export interface PracticeAssignmentBatchResponse {
|
||||
requestId: string;
|
||||
total: number;
|
||||
created: number;
|
||||
duplicate: number;
|
||||
failed: number;
|
||||
results: PracticeAssignmentBatchItemResult[];
|
||||
}
|
||||
|
||||
export interface DailyDrillAnswerResponse {
|
||||
@@ -369,6 +474,159 @@ export interface DailyDrillAnswerResponse {
|
||||
audioOssId?: number | string;
|
||||
}
|
||||
|
||||
export interface WebAiCapabilities {
|
||||
enabled: boolean;
|
||||
requiresConsent: boolean;
|
||||
providerName?: string;
|
||||
maxQuestionLength: number;
|
||||
notice?: string;
|
||||
}
|
||||
|
||||
export interface WebAiCitation {
|
||||
title: string;
|
||||
url: string;
|
||||
domain: string;
|
||||
snippet: string;
|
||||
publishedAt?: string | null;
|
||||
retrievedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface WebAiResponse {
|
||||
requestId: string;
|
||||
status: string;
|
||||
provider: string;
|
||||
answer: string;
|
||||
citations: WebAiCitation[];
|
||||
noEvidence: boolean;
|
||||
warning: string;
|
||||
redacted: boolean;
|
||||
}
|
||||
|
||||
export type QuestionBoardTab = 'HOT' | 'UNANSWERED' | 'REWARDED' | 'MINE';
|
||||
|
||||
export interface QuestionBoardAnswer {
|
||||
id: number;
|
||||
questionId: number;
|
||||
content: string;
|
||||
authorName: string;
|
||||
authorPosition?: string;
|
||||
status: string;
|
||||
best: boolean;
|
||||
recommended?: boolean;
|
||||
mine?: boolean;
|
||||
createTime: string;
|
||||
}
|
||||
|
||||
export interface QuestionBoardItem {
|
||||
id: number;
|
||||
type: 'HELP' | 'BOUNTY' | string;
|
||||
title: string;
|
||||
content?: string;
|
||||
category: string;
|
||||
authorName: string;
|
||||
position?: string;
|
||||
status: string;
|
||||
rewardPoints: number;
|
||||
rewardAccountType?: 'POINTS' | 'STUDY_CREDIT' | string;
|
||||
answerCount: number;
|
||||
viewCount?: number;
|
||||
deadline?: string | null;
|
||||
createTime: string;
|
||||
bestAnswerId?: number | null;
|
||||
mine?: boolean;
|
||||
answers?: QuestionBoardAnswer[];
|
||||
}
|
||||
|
||||
export interface IncentiveBalance {
|
||||
accountType: 'REWARD' | 'STUDY_CREDIT' | string;
|
||||
displayName: string;
|
||||
balance: number;
|
||||
unitName: string;
|
||||
}
|
||||
|
||||
export interface IncentiveLedgerItem {
|
||||
id: number;
|
||||
accountType: string;
|
||||
points: number;
|
||||
direction: 'EARN' | 'REVERSE' | string;
|
||||
sourceType: string;
|
||||
remark: string;
|
||||
createTime: string;
|
||||
}
|
||||
|
||||
export interface ExamQuestion {
|
||||
id: number;
|
||||
format: 'single' | 'multiple' | 'judge' | 'short' | string;
|
||||
stem: string;
|
||||
options: string[];
|
||||
score: number;
|
||||
answer?: string | string[];
|
||||
explanation?: string;
|
||||
}
|
||||
|
||||
export interface ExamSummary {
|
||||
id: number;
|
||||
title: string;
|
||||
projectCode?: string;
|
||||
positionCode?: string;
|
||||
status: string;
|
||||
totalScore: number;
|
||||
passingScore: number;
|
||||
dueDate?: string | null;
|
||||
questionCount: number;
|
||||
targetCount: number;
|
||||
submissionCount: number;
|
||||
bestScore?: number | null;
|
||||
passed?: boolean | null;
|
||||
}
|
||||
|
||||
export interface ExamTarget {
|
||||
extPartyId: string;
|
||||
traineeName: string;
|
||||
projectCode?: string;
|
||||
positionName?: string;
|
||||
}
|
||||
|
||||
export interface ExamDetail extends ExamSummary {
|
||||
questions: ExamQuestion[];
|
||||
targets: ExamTarget[];
|
||||
}
|
||||
|
||||
export interface ExamSubmitResult {
|
||||
attemptId: number;
|
||||
score: number;
|
||||
passed: boolean;
|
||||
status: string;
|
||||
submittedTime: string;
|
||||
results: Array<{
|
||||
questionId: number;
|
||||
correct: boolean;
|
||||
earnedScore: number;
|
||||
maxScore: number;
|
||||
explanation: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface SupervisorExamDraft {
|
||||
title: string;
|
||||
projectCode: string;
|
||||
positionCode: string;
|
||||
passScore: number;
|
||||
dueDate: string;
|
||||
targets: PracticeAssignmentTarget[];
|
||||
questions: ExamQuestion[];
|
||||
}
|
||||
|
||||
export interface ExamResultItem {
|
||||
attemptId: number | null;
|
||||
extPartyId: string;
|
||||
traineeName: string;
|
||||
score: number | null;
|
||||
passed: boolean | null;
|
||||
submittedTime: string | null;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface PracticeAlert {
|
||||
type: string;
|
||||
title: string;
|
||||
|
||||
Reference in New Issue
Block a user