feat(aihr): close functional checklist gaps
This commit is contained in:
@@ -151,12 +151,18 @@
|
||||
"navigationBarTitleText": "工作成果"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/user/exam/index",
|
||||
{
|
||||
"path": "pages/user/exam/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "岗前考试"
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/user/learning/material",
|
||||
"style": {
|
||||
"navigationBarTitleText": "学习材料"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/auth/login/index",
|
||||
"style": {
|
||||
|
||||
@@ -69,6 +69,17 @@
|
||||
<text v-else class="muted block">当前项目暂无可派发的在职员工。</text>
|
||||
</view>
|
||||
|
||||
<view class="panel bank-panel">
|
||||
<text class="panel-title">审核题库组卷</text>
|
||||
<text class="muted block-sm">仅从当前岗位已审核、已启用的考试题中随机抽取,抽取后冻结题目版本</text>
|
||||
<view class="ai-count-row">
|
||||
<button v-for="option in aiCountOptions" :key="`bank-${option}`" class="count-chip" :class="{ active: bankCount === option }" @click="bankCount = option">{{ option }}题</button>
|
||||
</view>
|
||||
<button class="bank-draw-button" :disabled="saving || bankDrawing" @click="drawBankQuestions">
|
||||
{{ bankDrawing ? '组卷中' : '从已审核题库组卷' }}
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view class="panel ai-panel">
|
||||
<text class="panel-title">AI 出题</text>
|
||||
<text class="muted block-sm">输入考察提示,AI 按岗位生成题目与答案,请逐题核对后发布</text>
|
||||
@@ -99,22 +110,22 @@
|
||||
<text class="question-arrow">{{ expandedIndex === index ? '⌄' : '›' }}</text>
|
||||
</view>
|
||||
<view v-if="expandedIndex === index" class="question-editor">
|
||||
<textarea v-model="item.stem" class="textarea stem-input" maxlength="500" placeholder="输入题干" />
|
||||
<textarea v-model="item.stem" class="textarea stem-input" maxlength="500" placeholder="输入题干" @input="markManualQuestionEdit" />
|
||||
<view class="editor-grid">
|
||||
<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>
|
||||
<view class="score-field">
|
||||
<input v-model.number="item.score" class="input" type="number" />
|
||||
<input v-model.number="item.score" class="input" type="number" @input="markManualQuestionEdit" />
|
||||
<text class="score-suffix">分</text>
|
||||
</view>
|
||||
</view>
|
||||
<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-for="(_, optionIndex) in item.options" :key="optionIndex" v-model="item.options[optionIndex]" class="input" :placeholder="`选项 ${optionIndex + 1}`" @input="markManualQuestionEdit" />
|
||||
<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 要点" />
|
||||
<textarea v-model="item.explanation" class="textarea compact-textarea" placeholder="参考依据 / SOP 要点" @input="markManualQuestionEdit" />
|
||||
<button class="remove-button" :disabled="questions.length === 1" @click="removeQuestion(index)">删除本题</button>
|
||||
</view>
|
||||
</view>
|
||||
@@ -135,7 +146,7 @@ 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 { generateSupervisorExamQuestions, getSupervisorExam, publishSupervisorExam, saveSupervisorExam, updateSupervisorExam } from '@/services/exam';
|
||||
import { drawSupervisorExamQuestions, generateSupervisorExamQuestions, getSupervisorExam, publishSupervisorExam, saveSupervisorExam, updateSupervisorExam } from '@/services/exam';
|
||||
import SupervisorTabBar from '@/components/SupervisorTabBar.vue';
|
||||
|
||||
const positions = ['生活顾问', '客服', '保安', '保洁', '保修'];
|
||||
@@ -155,6 +166,9 @@ const message = ref('');
|
||||
const aiHints = ref('');
|
||||
const aiCount = ref(8);
|
||||
const aiGenerating = ref(false);
|
||||
const bankDrawing = ref(false);
|
||||
const bankCount = ref(8);
|
||||
const questionDrawRequestId = ref('');
|
||||
const aiHintPresets = ['投诉接待', '催缴沟通', '电梯故障', '服务态度', '消防安全'];
|
||||
const aiCountOptions = [5, 8, 10];
|
||||
const formatDate = (date: Date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
@@ -182,6 +196,11 @@ const selectDueDate = (event: { detail: { value: string } }) => { dueDate.value
|
||||
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 markManualQuestionEdit = () => {
|
||||
if (!questionDrawRequestId.value) return;
|
||||
questionDrawRequestId.value = '';
|
||||
message.value = '题目已手工修改,当前试卷已转为手工草稿';
|
||||
};
|
||||
const rebalanceScores = () => {
|
||||
const count = questions.value.length;
|
||||
if (!count) return;
|
||||
@@ -189,12 +208,14 @@ const rebalanceScores = () => {
|
||||
questions.value.forEach((item, index) => { item.score = index === count - 1 ? 100 - base * (count - 1) : base; });
|
||||
};
|
||||
const addQuestion = () => {
|
||||
markManualQuestionEdit();
|
||||
questions.value.push(newQuestion());
|
||||
singleAnswerModels.value.push('');
|
||||
rebalanceScores();
|
||||
expandedIndex.value = questions.value.length - 1;
|
||||
};
|
||||
const removeQuestion = (index: number) => {
|
||||
markManualQuestionEdit();
|
||||
questions.value.splice(index, 1);
|
||||
singleAnswerModels.value.splice(index, 1);
|
||||
rebalanceScores();
|
||||
@@ -204,11 +225,13 @@ const toggleQuestion = (index: number) => {
|
||||
expandedIndex.value = expandedIndex.value === index ? -1 : index;
|
||||
};
|
||||
const selectFormat = (index: number, event: { detail: { value: string } }) => {
|
||||
markManualQuestionEdit();
|
||||
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) => {
|
||||
markManualQuestionEdit();
|
||||
questions.value[index].answer = questions.value[index].format === 'multiple'
|
||||
? singleAnswerModels.value[index].split('|').map((value) => value.trim()).filter(Boolean)
|
||||
: singleAnswerModels.value[index];
|
||||
@@ -231,6 +254,7 @@ const generateAiQuestions = async () => {
|
||||
try {
|
||||
const result = await generateSupervisorExamQuestions({ positionCode: positions[positionIndex.value], hints: aiHints.value.trim(), count: aiCount.value, formats: ['single', 'multiple', 'judge'] });
|
||||
questions.value = result.questions;
|
||||
questionDrawRequestId.value = '';
|
||||
singleAnswerModels.value = result.questions.map((item) => Array.isArray(item.answer) ? item.answer.join('|') : String(item.answer || ''));
|
||||
expandedIndex.value = -1;
|
||||
message.value = result.groundedFragments > 0
|
||||
@@ -243,6 +267,30 @@ const generateAiQuestions = async () => {
|
||||
aiGenerating.value = false;
|
||||
}
|
||||
};
|
||||
const drawBankQuestions = async () => {
|
||||
if (saving.value || bankDrawing.value) return;
|
||||
bankDrawing.value = true;
|
||||
message.value = '';
|
||||
const drawId = `exam-draw:${Date.now()}:${Math.random().toString(36).slice(2, 10)}`;
|
||||
try {
|
||||
const result = await drawSupervisorExamQuestions({
|
||||
requestId: drawId,
|
||||
positionCode: positions[positionIndex.value],
|
||||
count: bankCount.value,
|
||||
formats: ['single', 'multiple', 'judge', 'short']
|
||||
});
|
||||
questions.value = result.questions;
|
||||
singleAnswerModels.value = result.questions.map((item) => Array.isArray(item.answer) ? item.answer.join('|') : String(item.answer || ''));
|
||||
questionDrawRequestId.value = result.requestId;
|
||||
expandedIndex.value = -1;
|
||||
message.value = `已从审核题库抽取 ${result.questions.length} 题,保存后按当前版本冻结`;
|
||||
uni.pageScrollTo({ selector: '.question-panel', duration: 200 });
|
||||
} catch (error) {
|
||||
message.value = error instanceof Error ? error.message : '审核题库组卷失败';
|
||||
} finally {
|
||||
bankDrawing.value = false;
|
||||
}
|
||||
};
|
||||
const draft = (): SupervisorExamDraft => ({
|
||||
title: title.value.trim(),
|
||||
projectCode: '',
|
||||
@@ -257,7 +305,8 @@ const draft = (): SupervisorExamDraft => ({
|
||||
answer: item.format === 'multiple'
|
||||
? singleAnswerModels.value[index].split('|').map((value) => value.trim()).filter(Boolean)
|
||||
: singleAnswerModels.value[index]
|
||||
}))
|
||||
})),
|
||||
questionDrawRequestId: questionDrawRequestId.value || null
|
||||
});
|
||||
const save = async (publish: boolean) => {
|
||||
if (!valid.value || saving.value) return;
|
||||
@@ -290,6 +339,7 @@ const load = async () => {
|
||||
passingScore.value = existing.passingScore;
|
||||
dueDate.value = existing.dueDate?.slice(0, 10) || dateAfter(1);
|
||||
questions.value = existing.questions;
|
||||
questionDrawRequestId.value = '';
|
||||
singleAnswerModels.value = existing.questions.map((item) => Array.isArray(item.answer) ? item.answer.join('|') : String(item.answer || ''));
|
||||
selectedMemberIds.value = existing.targets.map((item) => item.extPartyId);
|
||||
expandedIndex.value = -1;
|
||||
@@ -325,6 +375,7 @@ onLoad((options) => { examId.value = Number(options?.id || 0); void load(); });
|
||||
.info-panel,
|
||||
.target-panel,
|
||||
.ai-panel,
|
||||
.bank-panel,
|
||||
.question-panel {
|
||||
margin-top: 14px;
|
||||
}
|
||||
@@ -590,6 +641,21 @@ onLoad((options) => { examId.value = Number(options?.id || 0); void load(); });
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.bank-draw-button {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
margin-top: 12px;
|
||||
border: 1px solid #176b52;
|
||||
border-radius: 8px;
|
||||
background: #effaf6;
|
||||
color: #176b52;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.bank-draw-button::after { border: 0; }
|
||||
.bank-draw-button:disabled { opacity: 0.6; }
|
||||
|
||||
.question-summary {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
|
||||
@@ -31,9 +31,29 @@
|
||||
<text class="attachment-size">{{ formatFileSize(item.attachment.fileSize) }}</text>
|
||||
</view>
|
||||
<text v-if="item.attachment.summary" class="attachment-summary">{{ item.attachment.summary }}</text>
|
||||
<button class="download-button" :disabled="downloading" @click="downloadAttachment">
|
||||
{{ downloading ? '正在读取…' : '查看原文件' }}
|
||||
</button>
|
||||
<view v-if="item.attachment.topicTags.length" class="topic-panel">
|
||||
<text class="topic-title">文件主题</text>
|
||||
<view class="topic-list">
|
||||
<button
|
||||
v-for="tag in item.attachment.topicTags"
|
||||
:key="tag.name"
|
||||
class="topic-tag"
|
||||
@click="showTopicEvidence(tag.name, tag.evidenceQuote, tag.sourceType)"
|
||||
>{{ tag.name }}</button>
|
||||
</view>
|
||||
</view>
|
||||
<view class="attachment-actions">
|
||||
<button class="download-button primary" :disabled="previewLoading" @click="openAttachmentPreview">
|
||||
{{ previewLoading ? '正在打开…' : '在线阅读' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="item.attachment.allowDownload && item.attachment.downloadUrl"
|
||||
class="download-button"
|
||||
:disabled="downloading"
|
||||
@click="downloadAttachment"
|
||||
>{{ downloading ? '正在下载…' : '下载文件' }}</button>
|
||||
</view>
|
||||
<text v-if="!item.attachment.allowDownload" class="online-only-note">该文件仅限在线阅读</text>
|
||||
<view v-if="item.attachment.perspectives.length" class="perspective-panel">
|
||||
<view class="perspective-heading">
|
||||
<view>
|
||||
@@ -88,15 +108,36 @@
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="attachmentPreview" class="preview-mask" @click.self="closeAttachmentPreview">
|
||||
<view class="preview-sheet" role="dialog" aria-modal="true" aria-label="公司文件在线阅读">
|
||||
<view class="preview-head">
|
||||
<view class="preview-heading">
|
||||
<text class="preview-kicker">受控在线阅读</text>
|
||||
<text class="preview-title">{{ attachmentPreview.fileName }}</text>
|
||||
</view>
|
||||
<button class="preview-close" aria-label="关闭在线阅读" @click="closeAttachmentPreview"><uni-icons type="closeempty" size="22" color="#25282d" /></button>
|
||||
</view>
|
||||
<scroll-view class="preview-scroll" scroll-y>
|
||||
<view class="preview-document">
|
||||
<text class="preview-content">{{ attachmentPreview.content || '文件暂无可预览文字' }}</text>
|
||||
<view class="watermark-layer" aria-hidden="true">
|
||||
<text v-for="index in 18" :key="index" class="watermark-text">{{ attachmentPreview.watermarkText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text v-if="attachmentPreview.truncated" class="preview-truncated">文件较长,在线阅读仅展示前 20 万字</text>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { onLoad } from '@dcloudio/uni-app';
|
||||
import type { BroadcastAttachment, BroadcastMessage } from '@/services/broadcast';
|
||||
import type { BroadcastAttachment, BroadcastAttachmentView, BroadcastMessage } from '@/services/broadcast';
|
||||
import {
|
||||
clearBroadcastQuestionContext,
|
||||
getBroadcastAttachmentView,
|
||||
getBroadcastMessage,
|
||||
markBroadcastMessageRead,
|
||||
readBroadcastQuestionContext,
|
||||
@@ -112,6 +153,8 @@ const item = ref<BroadcastMessage | null>(null);
|
||||
const loading = ref(false);
|
||||
const message = ref('');
|
||||
const downloading = ref(false);
|
||||
const previewLoading = ref(false);
|
||||
const attachmentPreview = ref<BroadcastAttachmentView | null>(null);
|
||||
const selectedPerspectiveCode = ref('');
|
||||
const evidenceExpanded = ref(false);
|
||||
const selectedPerspective = computed(() =>
|
||||
@@ -148,7 +191,7 @@ const selectInitialPerspective = (attachment?: BroadcastAttachment) => {
|
||||
};
|
||||
|
||||
const downloadAttachment = async () => {
|
||||
if (!item.value?.attachment || downloading.value) return;
|
||||
if (!item.value?.attachment?.downloadUrl || !item.value.attachment.allowDownload || downloading.value) return;
|
||||
downloading.value = true;
|
||||
try {
|
||||
const localUrl = await authenticatedFileUrl(item.value.attachment.downloadUrl);
|
||||
@@ -175,6 +218,27 @@ const downloadAttachment = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const openAttachmentPreview = async () => {
|
||||
if (!item.value?.attachment || previewLoading.value) return;
|
||||
previewLoading.value = true;
|
||||
try {
|
||||
attachmentPreview.value = await getBroadcastAttachmentView(item.value.attachment.id);
|
||||
} catch (error) {
|
||||
uni.showToast({ title: error instanceof Error ? error.message : '在线阅读失败', icon: 'none' });
|
||||
} finally {
|
||||
previewLoading.value = false;
|
||||
}
|
||||
};
|
||||
const closeAttachmentPreview = () => { attachmentPreview.value = null; };
|
||||
const showTopicEvidence = (name: string, evidence: string, sourceType: 'AUTO' | 'MANUAL') => {
|
||||
uni.showModal({
|
||||
title: `${name} · ${sourceType === 'MANUAL' ? '人工确认' : '原文识别'}`,
|
||||
content: evidence,
|
||||
showCancel: false,
|
||||
confirmText: '知道了'
|
||||
});
|
||||
};
|
||||
|
||||
const askAboutMessage = () => {
|
||||
if (!item.value || !ensureLogin()) return;
|
||||
const saved = saveBroadcastQuestionContext(item.value.id, currentAccountKey());
|
||||
@@ -256,6 +320,30 @@ onLoad((options) => {
|
||||
.attachment-name { display: block; margin-top: 4px; overflow-wrap: anywhere; color: var(--ink); font-size: 14px; font-weight: 900; }
|
||||
.attachment-size { flex: none; color: var(--muted); font-size: 12px; }
|
||||
.attachment-summary { overflow-wrap: anywhere; color: #596273; font-size: 13px; line-height: 1.65; }
|
||||
.topic-panel { display: grid; gap: 7px; }
|
||||
.topic-title { color: #707785; font-size: 12px; font-weight: 800; }
|
||||
.topic-list { display: flex; flex-wrap: wrap; gap: 7px; }
|
||||
.topic-tag { min-height: 28px; margin: 0; padding: 0 10px; border: 1px solid #e4c7ca; border-radius: 8px; background: #fff; color: #8d1a24; font-size: 12px; line-height: 28px; }
|
||||
.topic-tag::after { border: 0; }
|
||||
.attachment-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
|
||||
.attachment-actions .download-button:only-child { grid-column: 1 / -1; }
|
||||
.download-button.primary { border-color: var(--brand); background: var(--brand); color: #fff; }
|
||||
.online-only-note { color: #8a6265; font-size: 12px; text-align: center; }
|
||||
.preview-mask { position: fixed; z-index: 80; inset: 0; display: flex; align-items: flex-end; background: rgba(18, 20, 24, .54); }
|
||||
.preview-sheet { display: grid; width: 100%; max-width: var(--mobile-shell-max-width); height: 88vh; margin: 0 auto; grid-template-rows: auto minmax(0, 1fr); border-radius: 12px 12px 0 0; background: #f5f6f8; overflow: hidden; }
|
||||
.preview-head { display: flex; min-height: 62px; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 14px; border-bottom: 1px solid #e0e2e6; background: #fff; }
|
||||
.preview-heading { min-width: 0; }
|
||||
.preview-kicker, .preview-title { display: block; }
|
||||
.preview-kicker { color: var(--brand); font-size: 11px; font-weight: 800; }
|
||||
.preview-title { margin-top: 3px; overflow: hidden; color: var(--ink); font-size: 14px; font-weight: 900; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.preview-close { display: flex; width: 38px; height: 38px; flex: none; align-items: center; justify-content: center; margin: 0; padding: 0; border: 0; border-radius: 8px; background: #f2f3f5; }
|
||||
.preview-close::after { border: 0; }
|
||||
.preview-scroll { min-height: 0; }
|
||||
.preview-document { position: relative; min-height: 100%; margin: 12px; padding: 18px 16px 80px; border: 1px solid #dde0e5; border-radius: 6px; background: #fff; overflow: hidden; }
|
||||
.preview-content { position: relative; z-index: 1; overflow-wrap: anywhere; color: #2f3339; font-size: 14px; line-height: 1.85; white-space: pre-wrap; }
|
||||
.watermark-layer { position: absolute; z-index: 2; inset: -60px; display: grid; grid-template-columns: repeat(2, minmax(160px, 1fr)); gap: 70px 24px; pointer-events: none; transform: rotate(-24deg); }
|
||||
.watermark-text { color: rgba(135, 18, 29, .11); font-size: 12px; font-weight: 800; text-align: center; white-space: nowrap; }
|
||||
.preview-truncated { display: block; margin: 0 12px 18px; color: #8d1a24; font-size: 12px; text-align: center; }
|
||||
.perspective-panel { display: grid; gap: 12px; margin-top: 4px; padding-top: 14px; border-top: 1px solid #f0e3e5; }
|
||||
.perspective-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.perspective-kicker { display: block; color: var(--brand); font-size: 11px; font-weight: 900; }
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
:class="{ active: activeTab === 'tasks' }"
|
||||
@click="activeTab = 'tasks'"
|
||||
>
|
||||
<text class="segmented-text">今日必练</text>
|
||||
<text class="segmented-text">学习任务</text>
|
||||
<view v-if="pendingCount > 0" class="tab-badge">{{ pendingCount }}</view>
|
||||
<view class="tab-indicator"></view>
|
||||
</button>
|
||||
@@ -88,21 +88,76 @@
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<!-- Tab 1: 今日必练 (今日任务 + 错题本) -->
|
||||
<!-- Tab 1: 学习任务(每日三题保留在独立练习入口) -->
|
||||
<view v-if="activeTab === 'tasks'" class="tab-content-view">
|
||||
<view class="panel onboarding-panel section-card">
|
||||
<view class="panel-head">
|
||||
<view class="section-title">
|
||||
<view class="section-bar" />
|
||||
<view>
|
||||
<text>必修学习</text>
|
||||
<text class="panel-caption">材料版本、完成条件与截止时间</text>
|
||||
</view>
|
||||
</view>
|
||||
<button class="icon-refresh-btn" :disabled="onboardingLoading" @click="loadOnboarding" title="刷新">
|
||||
<uni-icons type="refresh" size="16" color="#64748b" :class="{ spinning: onboardingLoading }" />
|
||||
</button>
|
||||
</view>
|
||||
<view v-if="onboardTasks.length" class="onboarding-list">
|
||||
<view v-for="item in onboardTasks" :key="item.id" class="app-task-row learning-task-row" :class="taskDueTone(item)">
|
||||
<view class="learning-task-head">
|
||||
<view class="task-badge tag-practice">
|
||||
<uni-icons type="folder-add-filled" size="12" color="#e60012" />
|
||||
<text class="task-badge-text">{{ materialTypeLabel(item.materialType) }}</text>
|
||||
</view>
|
||||
<text class="learning-due" :class="taskDueTone(item)">{{ taskDueLabel(item) }}</text>
|
||||
</view>
|
||||
<view class="onboarding-copy">
|
||||
<text class="onboarding-title">{{ item.taskType }}</text>
|
||||
<text class="learning-material-title">{{ item.materialTitle || '学习材料待补充' }}</text>
|
||||
<text v-if="item.materialVersion" class="onboarding-meta">版本:{{ item.materialVersion }}</text>
|
||||
<text class="onboarding-meta">{{ completionRuleLabel(item.completionRule) }} · {{ recurrenceTypeLabel(item.recurrenceType) }}</text>
|
||||
</view>
|
||||
<view class="learning-progress">
|
||||
<view class="learning-progress-track"><view class="learning-progress-value" :style="{ width: `${Math.max(0, Math.min(100, item.progressPercent || 0))}%` }" /></view>
|
||||
<text>{{ item.progressPercent || 0 }}%</text>
|
||||
</view>
|
||||
<view class="learning-actions">
|
||||
<button v-if="item.materialAttachmentId" class="learning-secondary-action" :disabled="materialDownloadingId === item.id" @click="openLearningMaterial(item)">
|
||||
{{ materialDownloadingId === item.id ? '读取中' : '打开材料' }}
|
||||
</button>
|
||||
<button v-if="item.status !== '已完成' && item.completionRule === 'EXAM_PASS' && item.examId && (item.progressPercent || 0) < 100" class="schedule-action primary-gradient-btn" :disabled="onboardingCompletingId === item.id" @click="completeLearningForExam(item)">
|
||||
{{ onboardingCompletingId === item.id ? '提交中' : '完成学习' }}
|
||||
</button>
|
||||
<button v-else-if="item.status !== '已完成' && item.completionRule === 'EXAM_PASS' && item.examId" class="schedule-action primary-gradient-btn" @click="openExam(item.examId)">去考试</button>
|
||||
<button v-else-if="item.status !== '已完成' && item.completionRule !== 'EXAM_PASS'" class="schedule-action primary-gradient-btn" :disabled="onboardingCompletingId === item.id" @click="completeOnboarding(item)">
|
||||
{{ onboardingCompletingId === item.id ? '提交中' : '确认完成' }}
|
||||
</button>
|
||||
<text v-else-if="item.status === '已完成'" class="mistake-completed">已完成</text>
|
||||
<text v-else class="onboarding-meta">完成学习后参加考试</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else-if="!onboardingLoading" class="app-empty-state">
|
||||
<view class="empty-icon-circle circle-blue"><uni-icons type="staff" size="22" color="#2d80ff" /></view>
|
||||
<text class="empty-title">暂无必修学习任务</text>
|
||||
<text class="empty-desc">HR 派发后将在这里显示材料和完成条件</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 今日任务 Panel -->
|
||||
<view class="panel section-card">
|
||||
<view class="panel-head">
|
||||
<view class="section-title">
|
||||
<view class="section-bar" />
|
||||
<text>今日任务</text>
|
||||
<text>专项训练</text>
|
||||
</view>
|
||||
<button class="icon-refresh-btn" :disabled="assignmentLoading" @click="loadAssignments" title="刷新">
|
||||
<uni-icons type="refresh" size="16" color="#64748b" :class="{ spinning: assignmentLoading }" />
|
||||
</button>
|
||||
</view>
|
||||
<view v-if="assignments.length" class="task-list">
|
||||
<view v-for="item in assignments" :key="item.id" class="app-task-row" :class="assignmentAccentClass(item.source)">
|
||||
<view v-if="visibleAssignments.length" class="task-list">
|
||||
<view v-for="item in visibleAssignments" :key="item.id" class="app-task-row" :class="assignmentAccentClass(item.source)">
|
||||
<view class="app-task-header">
|
||||
<view class="task-badge" :class="assignmentTagClass(item.source)">
|
||||
<uni-icons :type="assignmentTagIcon(item.source)" size="12" :color="assignmentTagColor(item.source)" />
|
||||
@@ -133,7 +188,7 @@
|
||||
<uni-icons type="checkmarkempty" size="22" color="#10b981" />
|
||||
</view>
|
||||
<text class="empty-title">暂无待完成任务</text>
|
||||
<text class="empty-desc">今日练习任务已全部搞定,保持优秀!</text>
|
||||
<text class="empty-desc">当前没有待完成的专项训练</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -406,48 +461,6 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 岗前/入职任务 Panel -->
|
||||
<view class="panel onboarding-panel section-card">
|
||||
<view class="panel-head">
|
||||
<view class="section-title">
|
||||
<view class="section-bar" />
|
||||
<view>
|
||||
<text>岗前 / 入职任务</text>
|
||||
<text class="panel-caption">组织系统已派发的任务</text>
|
||||
</view>
|
||||
</view>
|
||||
<button class="icon-refresh-btn" :disabled="onboardingLoading" @click="loadOnboarding" title="刷新">
|
||||
<uni-icons type="refresh" size="16" color="#64748b" :class="{ spinning: onboardingLoading }" />
|
||||
</button>
|
||||
</view>
|
||||
<view v-if="onboardTasks.length" class="onboarding-list">
|
||||
<view v-for="item in onboardTasks" :key="item.id" class="app-task-row accent-review">
|
||||
<view class="app-task-body">
|
||||
<view class="onboarding-copy">
|
||||
<text class="onboarding-title">{{ item.taskType }} · {{ item.positionCode || positionProfile.value }}</text>
|
||||
<text class="onboarding-meta">{{ item.status }}{{ item.projectCode ? ` · ${item.projectCode}` : '' }}</text>
|
||||
<text v-if="item.dueTime" class="onboarding-meta">截止:{{ item.dueTime }}</text>
|
||||
</view>
|
||||
<button
|
||||
v-if="item.status !== '已完成'"
|
||||
class="schedule-action primary-gradient-btn"
|
||||
:disabled="onboardingCompletingId === item.id"
|
||||
@click="completeOnboarding(item)"
|
||||
>
|
||||
{{ onboardingCompletingId === item.id ? '提交中' : '确认完成' }}
|
||||
</button>
|
||||
<text v-else class="mistake-completed">已完成</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else-if="!onboardingLoading" class="app-empty-state">
|
||||
<view class="empty-icon-circle circle-blue">
|
||||
<uni-icons type="staff" size="22" color="#2d80ff" />
|
||||
</view>
|
||||
<text class="empty-title">暂无岗前派发任务</text>
|
||||
<text class="empty-desc">等待 HR 或组织系统派发正式任务</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
@@ -466,7 +479,7 @@ import type {
|
||||
QualificationSummary,
|
||||
ExamSummary
|
||||
} from '@/types/api';
|
||||
import { completeOnboardTask, getOnboardTasks, getPracticeAssignments, getPracticeMistakes, getPracticePrepCard, getQualification } from '@/services/practice';
|
||||
import { completeOnboardTask, getOnboardTasks, getPracticeAssignments, getPracticeMistakes, getPracticePrepCard, getQualification, reportOnboardProgress } from '@/services/practice';
|
||||
import { getPositionSopSummary, searchKnowledge } from '@/services/knowledge';
|
||||
import { getSelectedPositionProfile, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||||
import { buildLearningTopics, selectLearningTopic } from '@/services/learning';
|
||||
@@ -475,6 +488,8 @@ import { ensureEmployeePosition } from '@/services/position';
|
||||
import { listMyExams } from '@/services/exam';
|
||||
import { createPageRequestScope, currentAccountKey, type RequestScopeTicket } from '@/services/request-scope';
|
||||
import { stripInternalCodes } from '@/services/text';
|
||||
import { authenticatedFileUrl } from '@/services/api';
|
||||
import { isNativeAppRuntime } from '@/services/speech-capture';
|
||||
|
||||
const defaultScenarioId = 'complaint-water';
|
||||
const pendingAssignmentKey = 'aihr_pending_assignment_id';
|
||||
@@ -499,6 +514,7 @@ const onboardTasks = ref<OnboardTask[]>([]);
|
||||
const qualification = ref<QualificationSummary | null>(null);
|
||||
const onboardingLoading = ref(false);
|
||||
const onboardingCompletingId = ref<number | null>(null);
|
||||
const materialDownloadingId = ref<number | null>(null);
|
||||
const qualificationLoading = ref(false);
|
||||
const exams = ref<ExamSummary[]>([]);
|
||||
const examLoading = ref(false);
|
||||
@@ -538,8 +554,11 @@ const topics = computed(() => buildLearningTopics(
|
||||
positionProfile.value.quickQuestions
|
||||
));
|
||||
|
||||
const pendingCount = computed(() => assignments.value.filter((item) => item.status !== '已完成').length);
|
||||
const completedCount = computed(() => assignments.value.filter((item) => item.status === '已完成').length);
|
||||
const visibleAssignments = computed(() => assignments.value.filter((item) => item.source !== 'daily'));
|
||||
const pendingCount = computed(() => visibleAssignments.value.filter((item) => item.status !== '已完成').length
|
||||
+ onboardTasks.value.filter((item) => item.status !== '已完成').length);
|
||||
const completedCount = computed(() => visibleAssignments.value.filter((item) => item.status === '已完成').length
|
||||
+ onboardTasks.value.filter((item) => item.status === '已完成').length);
|
||||
const referenceCount = computed(() => knowledgeResult.value?.snippets?.length || 0);
|
||||
const firstPracticeAssignment = computed(() =>
|
||||
assignments.value.find((item) => item.status !== '已完成' && item.source !== 'daily')
|
||||
@@ -701,14 +720,19 @@ const loadOnboardingRequest = async (request: RequestScopeTicket) => {
|
||||
const loadOnboarding = () => loadOnboardingRequest(requests.begin('onboarding'));
|
||||
|
||||
const completeOnboarding = async (task: OnboardTask) => {
|
||||
if (onboardingCompletingId.value !== null || task.status === '已完成') return;
|
||||
if (onboardingCompletingId.value !== null || task.status === '已完成' || task.completionRule === 'EXAM_PASS') return;
|
||||
const request = requests.begin('complete-onboarding');
|
||||
onboardingCompletingId.value = task.id;
|
||||
try {
|
||||
await reportOnboardProgress(task.id, {
|
||||
progressPercent: 100,
|
||||
materialVersion: task.materialVersion,
|
||||
completed: true
|
||||
});
|
||||
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, status: '已完成', progressPercent: 100, completedTime: new Date().toISOString() }
|
||||
: item);
|
||||
message.value = '任务已记录为完成,正式资格仍以 HR 复核为准';
|
||||
} catch (error) {
|
||||
@@ -719,6 +743,87 @@ const completeOnboarding = async (task: OnboardTask) => {
|
||||
}
|
||||
};
|
||||
|
||||
const materialTypeLabel = (value?: string | null) => ({ DOCUMENT: '文档', VIDEO: '视频', COURSE: '课程', SOP: 'SOP' }[String(value || '').toUpperCase()] || '材料');
|
||||
const completionRuleLabel = (value?: string | null) => value === 'EXAM_PASS' ? '通过考试后完成' : '阅读后确认完成';
|
||||
const recurrenceTypeLabel = (value?: string | null) => ({ ONCE: '单次任务', DAILY: '每日任务', WEEKLY: '每周任务', MONTHLY: '每月任务' }[String(value || '').toUpperCase()] || '单次任务');
|
||||
const parseTaskTime = (value?: string | null) => {
|
||||
if (!value) return null;
|
||||
const parsed = new Date(value.replace(' ', 'T'));
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||
};
|
||||
const taskDueLabel = (task: OnboardTask) => {
|
||||
if (task.status === '已完成') return '已完成';
|
||||
const due = parseTaskTime(task.dueTime);
|
||||
if (!due) return '无截止时间';
|
||||
const diff = due.getTime() - Date.now();
|
||||
if (diff < 0) return `已逾期 ${Math.max(1, Math.ceil(Math.abs(diff) / 86400000))} 天`;
|
||||
if (diff <= 86400000) return diff <= 3600000 ? '1 小时内到期' : `${Math.ceil(diff / 3600000)} 小时后到期`;
|
||||
return `截止 ${task.dueTime?.slice(0, 16)}`;
|
||||
};
|
||||
const taskDueTone = (task: OnboardTask) => {
|
||||
if (task.status === '已完成') return 'due-complete';
|
||||
const due = parseTaskTime(task.dueTime);
|
||||
if (!due) return 'due-normal';
|
||||
const diff = due.getTime() - Date.now();
|
||||
if (diff < 0) return 'due-overdue';
|
||||
return diff <= 86400000 ? 'due-soon' : 'due-normal';
|
||||
};
|
||||
const openLearningMaterial = async (task: OnboardTask) => {
|
||||
if (!task.materialAttachmentId || materialDownloadingId.value !== null) return;
|
||||
materialDownloadingId.value = task.id;
|
||||
try {
|
||||
await reportOnboardProgress(task.id, {
|
||||
progressPercent: Math.max(5, task.progressPercent || 0),
|
||||
materialVersion: task.materialVersion
|
||||
});
|
||||
} catch (error) {
|
||||
materialDownloadingId.value = null;
|
||||
uni.showToast({ title: error instanceof Error ? error.message : '学习进度记录失败', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
if (String(task.materialType).toUpperCase() === 'VIDEO') {
|
||||
uni.navigateTo({ url: `/pages/user/learning/material?taskId=${task.id}&attachmentId=${task.materialAttachmentId}&title=${encodeURIComponent(task.materialTitle || task.taskType)}&type=VIDEO&materialVersion=${encodeURIComponent(task.materialVersion || '')}` });
|
||||
materialDownloadingId.value = null;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const localUrl = await authenticatedFileUrl(`/api/knowledge/resources/${task.materialAttachmentId}/content`);
|
||||
if (!isNativeAppRuntime() && typeof window !== 'undefined') {
|
||||
window.open(localUrl, '_blank', 'noopener,noreferrer');
|
||||
window.setTimeout(() => URL.revokeObjectURL(localUrl), 60000);
|
||||
} else {
|
||||
uni.openDocument({ filePath: localUrl, showMenu: true, fail: () => uni.showToast({ title: '当前材料无法打开', icon: 'none' }) });
|
||||
}
|
||||
} catch (error) {
|
||||
uni.showToast({ title: error instanceof Error ? error.message : '材料读取失败', icon: 'none' });
|
||||
} finally {
|
||||
materialDownloadingId.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const completeLearningForExam = async (task: OnboardTask) => {
|
||||
if (onboardingCompletingId.value !== null || task.status === '已完成' || task.completionRule !== 'EXAM_PASS') return;
|
||||
const request = requests.begin('complete-learning-for-exam');
|
||||
onboardingCompletingId.value = task.id;
|
||||
try {
|
||||
await reportOnboardProgress(task.id, {
|
||||
progressPercent: 100,
|
||||
materialVersion: task.materialVersion,
|
||||
completed: true
|
||||
});
|
||||
if (!requests.isCurrent(request)) return;
|
||||
onboardTasks.value = onboardTasks.value.map((item) => item.id === task.id
|
||||
? { ...item, progressPercent: 100 }
|
||||
: item);
|
||||
message.value = '学习已完成,现在可以参加关联考试';
|
||||
} catch (error) {
|
||||
if (!requests.isCurrent(request)) return;
|
||||
handleRequestError(error, '学习完成记录失败');
|
||||
} finally {
|
||||
if (requests.isCurrent(request)) onboardingCompletingId.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const loadQualificationRequest = async (request: RequestScopeTicket) => {
|
||||
if (!loggedIn.value) return;
|
||||
qualificationLoading.value = true;
|
||||
@@ -1235,6 +1340,17 @@ onUnload(invalidateRequests);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.learning-task-row { display: grid; gap: 10px; border-left: 3px solid #cbd5e1; }
|
||||
.learning-task-row.due-overdue { border-left-color: #dc2626; }
|
||||
.learning-task-row.due-soon { border-left-color: #f59e0b; }
|
||||
.learning-task-row.due-complete { border-left-color: #10b981; }
|
||||
.learning-task-head, .learning-actions, .learning-progress { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
||||
.learning-material-title { color: #344054; font-size: 13px; line-height: 1.45; }
|
||||
.learning-due { color: #64748b; font-size: 11px; font-weight: 700; }
|
||||
.learning-due.due-overdue { color: #dc2626; }.learning-due.due-soon { color: #b45309; }.learning-due.due-complete { color: #047857; }
|
||||
.learning-progress { color: #667085; font-size: 11px; }.learning-progress-track { height: 7px; flex: 1; overflow: hidden; border-radius: 4px; background: #e5e7eb; }.learning-progress-value { height: 100%; border-radius: 4px; background: #e60012; }
|
||||
.learning-actions { justify-content: flex-end; flex-wrap: wrap; }.learning-secondary-action { width: auto; min-width: 82px; height: 38px; padding: 0 12px; border: 1px solid #d0d5dd; border-radius: 8px; background: #fff; color: #344054; font-size: 13px; font-weight: 700; }
|
||||
|
||||
.primary-gradient-btn {
|
||||
background: linear-gradient(135deg, #ff4d4f 0%, #e60012 100%) !important;
|
||||
box-shadow: 0 4px 12px rgba(230, 0, 18, 0.22) !important;
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<view class="material-page">
|
||||
<view class="subpage-header">
|
||||
<button class="subpage-back-button" aria-label="返回学习中心" @click="goBack"><uni-icons type="back" size="20" color="#25282d" /></button>
|
||||
<view class="subpage-heading"><text class="page-title">{{ title || '学习材料' }}</text><text class="page-subtitle">受控学习材料</text></view>
|
||||
</view>
|
||||
<view v-if="loading" class="material-state">材料加载中</view>
|
||||
<video
|
||||
v-else-if="source"
|
||||
class="material-video"
|
||||
:src="source"
|
||||
controls
|
||||
autoplay
|
||||
object-fit="contain"
|
||||
:enable-progress-gesture="false"
|
||||
@loadedmetadata="onVideoMetadata"
|
||||
@timeupdate="onVideoTimeUpdate"
|
||||
@ended="onVideoEnded"
|
||||
/>
|
||||
<view v-else class="material-state error">{{ message || '材料无法打开' }}</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { onLoad, onUnload } from '@dcloudio/uni-app';
|
||||
import { authenticatedFileUrl, releaseAuthenticatedFileUrl } from '@/services/api';
|
||||
import { reportOnboardProgress } from '@/services/practice';
|
||||
|
||||
const taskId = ref(0); const attachmentId = ref(0); const title = ref(''); const materialVersion = ref('');
|
||||
const source = ref(''); const loading = ref(true); const message = ref('');
|
||||
const mediaDuration = ref(0); const lastReportedSecond = ref(0); let lastReportAt = 0;
|
||||
const goBack = () => uni.navigateBack({ delta: 1, fail: () => uni.redirectTo({ url: '/pages/user/learning/index' }) });
|
||||
const reportVideoProgress = (currentTime: number, completed = false) => {
|
||||
if (!taskId.value || !mediaDuration.value) return;
|
||||
const second = Math.max(0, Math.floor(currentTime));
|
||||
const now = Date.now();
|
||||
if (!completed && (second <= lastReportedSecond.value || now - lastReportAt < 3000)) return;
|
||||
lastReportedSecond.value = second;
|
||||
lastReportAt = now;
|
||||
void reportOnboardProgress(taskId.value, {
|
||||
positionSeconds: second,
|
||||
durationSeconds: Math.floor(mediaDuration.value),
|
||||
materialVersion: materialVersion.value,
|
||||
progressPercent: Math.floor(second * 100 / mediaDuration.value),
|
||||
completed
|
||||
}).catch(() => undefined);
|
||||
};
|
||||
const onVideoMetadata = (event: any) => {
|
||||
const duration = Number(event?.detail?.duration || 0);
|
||||
if (Number.isFinite(duration) && duration > 0) mediaDuration.value = duration;
|
||||
};
|
||||
const onVideoTimeUpdate = (event: any) => reportVideoProgress(Number(event?.detail?.currentTime || 0));
|
||||
const onVideoEnded = (event: any) => reportVideoProgress(Number(event?.detail?.currentTime || mediaDuration.value), true);
|
||||
onLoad(async (query) => {
|
||||
taskId.value = Number(query?.taskId || 0); attachmentId.value = Number(query?.attachmentId || 0);
|
||||
title.value = decodeURIComponent(String(query?.title || ''));
|
||||
materialVersion.value = decodeURIComponent(String(query?.materialVersion || ''));
|
||||
if (!attachmentId.value) { loading.value = false; message.value = '材料参数不正确'; return; }
|
||||
try { source.value = await authenticatedFileUrl(`/api/knowledge/resources/${attachmentId.value}/content`); }
|
||||
catch (error) { message.value = error instanceof Error ? error.message : '材料读取失败'; }
|
||||
finally { loading.value = false; }
|
||||
});
|
||||
onUnload(() => { if (source.value) releaseAuthenticatedFileUrl(source.value); });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.material-page { min-height: 100vh; padding: 16px; background: #111827; box-sizing: border-box; }.subpage-header { color: #fff; }.subpage-back-button { background: #fff; }.page-title { color: #fff; }.page-subtitle { color: #cbd5e1; }.material-video { width: 100%; height: calc(100vh - 120px); background: #000; }.material-state { display: flex; min-height: 360px; align-items: center; justify-content: center; color: #e5e7eb; }.material-state.error { color: #fca5a5; }
|
||||
</style>
|
||||
@@ -113,10 +113,14 @@
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view
|
||||
v-if="(msg.voiceStatus === 'unavailable' || msg.transcriptOpen) && (msg.result.answer || msg.result.snippets?.length || !msg.result.memoryCandidate)"
|
||||
class="bubble master transcript-bubble"
|
||||
>
|
||||
<view
|
||||
v-if="(msg.voiceStatus === 'unavailable' || msg.transcriptOpen) && (msg.result.answer || msg.result.snippets?.length || !msg.result.memoryCandidate)"
|
||||
class="bubble master transcript-bubble"
|
||||
>
|
||||
<view class="answer-evidence-label">
|
||||
<uni-icons type="info" size="13" color="#2f6bff" />
|
||||
<text>AI 参考回答 · 下方引用为原文证据</text>
|
||||
</view>
|
||||
<text
|
||||
v-if="msg.result.rewrittenQuery && msg.result.rewrittenQuery !== msg.result.queryText"
|
||||
class="context-used"
|
||||
@@ -186,6 +190,19 @@
|
||||
<text v-if="msg.feedbackVerdict" class="feedback-note">
|
||||
{{ msg.feedbackVerdict === 'up' ? '已记录' : '已进入复核' }}
|
||||
</text>
|
||||
<button
|
||||
v-if="msg.feedbackVerdict === 'down' && !msg.helpQuestionId"
|
||||
class="bubble-action primary"
|
||||
:disabled="msg.helpSubmitting || !msg.result.requestId"
|
||||
@click="submitHumanHelp(msg)"
|
||||
>
|
||||
{{ msg.helpSubmitting ? '提交中' : '转人工求助' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="msg.helpQuestionId"
|
||||
class="bubble-action"
|
||||
@click="openHelpQuestion(msg.helpQuestionId)"
|
||||
>查看待审核问题</button>
|
||||
</view>
|
||||
<view v-if="msg.feedbackPanelOpen && !msg.feedbackVerdict" class="negative-feedback-panel">
|
||||
<text class="negative-feedback-title">哪里还没解决?(可多选)</text>
|
||||
@@ -473,6 +490,7 @@ import { chooseSpeechAudio, createSpeechPlaybackController, measureAudioDuration
|
||||
import { isNativeAppRuntime, type SpeechCapture } from '@/services/speech-capture';
|
||||
import { downloadSummaryCardImage } from '@/services/summary-card-image';
|
||||
import { getMemoryCandidates } from '@/services/personal-assistant';
|
||||
import { publishQuestionFromAnswerFeedback } from '@/services/questions';
|
||||
import { currentAccountKey } from '@/services/request-scope';
|
||||
import { isFileSelectionCancelled } from '@/utils/file-selection';
|
||||
import {
|
||||
@@ -504,6 +522,9 @@ interface MasterMsg {
|
||||
feedbackPanelOpen: boolean;
|
||||
feedbackReasonCodes: string[];
|
||||
feedbackComment: string;
|
||||
helpSubmitting: boolean;
|
||||
helpQuestionId: number | null;
|
||||
helpIdempotencyKey: string;
|
||||
citationsOpen: boolean;
|
||||
voiceStatus: 'preparing' | 'ready' | 'unavailable';
|
||||
voiceDuration: number | null;
|
||||
@@ -1023,6 +1044,9 @@ const sendQuestion = async (
|
||||
feedbackPanelOpen: false,
|
||||
feedbackReasonCodes: [],
|
||||
feedbackComment: '',
|
||||
helpSubmitting: false,
|
||||
helpQuestionId: null,
|
||||
helpIdempotencyKey: '',
|
||||
citationsOpen: false,
|
||||
voiceStatus: shouldPreloadVoice ? 'preparing' : 'unavailable',
|
||||
voiceDuration: null,
|
||||
@@ -1403,6 +1427,42 @@ const feedback = async (verdict: AnswerFeedbackVerdict, msg: MasterMsg) => {
|
||||
}
|
||||
};
|
||||
|
||||
const confirmHumanHelp = () => new Promise<boolean>((resolve) => {
|
||||
uni.showModal({
|
||||
title: '提交人工求助',
|
||||
content: '将使用你刚才的问题创建问题榜条目。审核通过后其他员工可见,请确认问题中不含住户姓名、电话、房号等隐私信息。',
|
||||
confirmText: '确认提交',
|
||||
cancelText: '暂不提交',
|
||||
success: (result) => resolve(Boolean(result.confirm)),
|
||||
fail: () => resolve(false)
|
||||
});
|
||||
});
|
||||
|
||||
const submitHumanHelp = async (msg: MasterMsg) => {
|
||||
if (msg.helpSubmitting || msg.helpQuestionId || !msg.result.requestId) return;
|
||||
if (!await confirmHumanHelp()) return;
|
||||
msg.helpSubmitting = true;
|
||||
if (!msg.helpIdempotencyKey) {
|
||||
msg.helpIdempotencyKey = `feedback-help-${createConversationId()}`;
|
||||
}
|
||||
try {
|
||||
const question = await publishQuestionFromAnswerFeedback({
|
||||
requestId: msg.result.requestId,
|
||||
idempotencyKey: msg.helpIdempotencyKey
|
||||
});
|
||||
msg.helpQuestionId = question.id;
|
||||
uni.showToast({ title: '已提交审核', icon: 'none' });
|
||||
} catch (error) {
|
||||
uni.showToast({ title: requestErrorText(error, '人工求助提交失败'), icon: 'none' });
|
||||
} finally {
|
||||
msg.helpSubmitting = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openHelpQuestion = (questionId: number) => {
|
||||
uni.navigateTo({ url: `/pages/user/questions/detail?id=${questionId}` });
|
||||
};
|
||||
|
||||
const buildSummaryCard = async (msg: MasterMsg) => {
|
||||
if (broadcastContext.value) {
|
||||
uni.showToast({ title: '消息咨询不支持生成总结卡,请先结束咨询', icon: 'none' });
|
||||
@@ -3286,6 +3346,16 @@ onUnload(() => {
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.answer-evidence-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: #2f6bff;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.citation-action {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface BroadcastMessageSummary {
|
||||
requiredRead: boolean;
|
||||
targeted: boolean;
|
||||
targetReason?: string;
|
||||
visibilityMode: 'ALL' | 'TARGET_ONLY';
|
||||
}
|
||||
|
||||
export interface BroadcastMessage extends BroadcastMessageSummary {
|
||||
@@ -50,13 +51,32 @@ export interface BroadcastAttachment {
|
||||
contentType: string;
|
||||
status: 'READY';
|
||||
summary?: string;
|
||||
downloadUrl: string;
|
||||
viewUrl: string;
|
||||
downloadUrl?: string;
|
||||
allowDownload: boolean;
|
||||
watermarkText?: string;
|
||||
topicTags: BroadcastTopicTag[];
|
||||
insightStatus: BroadcastInsightStatus;
|
||||
defaultPerspectiveCode?: string;
|
||||
perspectiveLabels: string[];
|
||||
perspectives: BroadcastPerspective[];
|
||||
}
|
||||
|
||||
export interface BroadcastTopicTag {
|
||||
name: string;
|
||||
sourceType: 'AUTO' | 'MANUAL';
|
||||
evidenceQuote: string;
|
||||
}
|
||||
|
||||
export interface BroadcastAttachmentView {
|
||||
id: number;
|
||||
fileName: string;
|
||||
content: string;
|
||||
truncated: boolean;
|
||||
watermarkText: string;
|
||||
topicTags: BroadcastTopicTag[];
|
||||
}
|
||||
|
||||
export interface BroadcastMessagePage {
|
||||
total: number;
|
||||
pageNum: number;
|
||||
@@ -78,11 +98,19 @@ export const listBroadcastMessages = (pageNum = 1, pageSize = 20, scope: Broadca
|
||||
url: `/api/aihr/broadcast/messages?pageNum=${pageNum}&pageSize=${pageSize}&scope=${scope}`,
|
||||
method: 'GET',
|
||||
timeout: 15000
|
||||
});
|
||||
}).then((page) => ({
|
||||
...page,
|
||||
rows: (page.rows || []).map((message) => ({
|
||||
...message,
|
||||
visibilityMode: message.visibilityMode || 'ALL'
|
||||
}))
|
||||
}));
|
||||
|
||||
const normalizeBroadcastAttachment = (attachment: BroadcastAttachment): BroadcastAttachment => ({
|
||||
...attachment,
|
||||
allowDownload: attachment.allowDownload !== false,
|
||||
insightStatus: attachment.insightStatus || 'NOT_PROCESSED',
|
||||
topicTags: Array.isArray(attachment.topicTags) ? attachment.topicTags : [],
|
||||
perspectiveLabels: Array.isArray(attachment.perspectiveLabels) ? attachment.perspectiveLabels : [],
|
||||
perspectives: Array.isArray(attachment.perspectives) ? attachment.perspectives : []
|
||||
});
|
||||
@@ -94,8 +122,8 @@ export const getBroadcastMessage = async (id: number) => {
|
||||
timeout: 15000
|
||||
});
|
||||
return message.attachment
|
||||
? { ...message, attachment: normalizeBroadcastAttachment(message.attachment) }
|
||||
: message;
|
||||
? { ...message, visibilityMode: message.visibilityMode || 'ALL', attachment: normalizeBroadcastAttachment(message.attachment) }
|
||||
: { ...message, visibilityMode: message.visibilityMode || 'ALL' };
|
||||
};
|
||||
|
||||
export const markBroadcastMessageRead = (id: number) =>
|
||||
@@ -105,6 +133,13 @@ export const markBroadcastMessageRead = (id: number) =>
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
export const getBroadcastAttachmentView = (id: number) =>
|
||||
apiRequest<BroadcastAttachmentView>({
|
||||
url: `/api/aihr/broadcast/attachments/${id}/view`,
|
||||
method: 'GET',
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
const isSafeMessageId = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ type RawExamQuestion = {
|
||||
score: number;
|
||||
explanation?: string;
|
||||
sortOrder?: number;
|
||||
sourceQuestionCode?: string | null;
|
||||
sourceContentVersion?: string | null;
|
||||
};
|
||||
|
||||
type RawExamSummary = {
|
||||
@@ -53,7 +55,9 @@ const normalizeDetail = (item: RawExamDetail): ExamDetail => ({
|
||||
options: question.options || [],
|
||||
score: question.score,
|
||||
answer: question.correctAnswers || [],
|
||||
explanation: question.explanation || ''
|
||||
explanation: question.explanation || '',
|
||||
sourceQuestionCode: question.sourceQuestionCode || null,
|
||||
sourceContentVersion: question.sourceContentVersion || null
|
||||
})),
|
||||
targets: item.targets || []
|
||||
});
|
||||
@@ -64,6 +68,7 @@ const serializeDraft = (draft: SupervisorExamDraft) => ({
|
||||
positionCode: draft.positionCode,
|
||||
passScore: draft.passScore,
|
||||
dueDate: draft.dueDate,
|
||||
questionDrawRequestId: draft.questionDrawRequestId || null,
|
||||
targets: draft.targets,
|
||||
questions: draft.questions.map((question, index) => ({
|
||||
id: question.id > 0 && question.id < 1_000_000_000 ? question.id : null,
|
||||
@@ -181,3 +186,31 @@ export const generateSupervisorExamQuestions = async (data: {
|
||||
}))
|
||||
};
|
||||
};
|
||||
|
||||
export const drawSupervisorExamQuestions = async (data: {
|
||||
requestId: string;
|
||||
positionCode: string;
|
||||
count: number;
|
||||
formats: string[];
|
||||
}): Promise<{ requestId: string; questions: ExamQuestion[] }> => {
|
||||
const response = await apiRequest<{ requestId: string; questions: RawExamQuestion[] }>({
|
||||
url: '/api/aihr/mobile/exams/supervisor/question-bank/draw',
|
||||
method: 'POST',
|
||||
data,
|
||||
timeout: 30000
|
||||
});
|
||||
return {
|
||||
requestId: response.requestId,
|
||||
questions: (response.questions || []).map((question, index) => ({
|
||||
id: Date.now() + index,
|
||||
format: question.questionType,
|
||||
stem: question.stem,
|
||||
options: question.options || [],
|
||||
score: question.score,
|
||||
answer: question.questionType === 'multiple' ? (question.correctAnswers || []) : (question.correctAnswers?.[0] || ''),
|
||||
explanation: question.explanation || '',
|
||||
sourceQuestionCode: question.sourceQuestionCode || null,
|
||||
sourceContentVersion: question.sourceContentVersion || null
|
||||
}))
|
||||
};
|
||||
};
|
||||
|
||||
@@ -203,6 +203,20 @@ export const completeOnboardTask = (id: number, evidenceRef?: string) =>
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
export const reportOnboardProgress = (id: number, data: {
|
||||
progressPercent?: number;
|
||||
positionSeconds?: number;
|
||||
durationSeconds?: number;
|
||||
materialVersion?: string | null;
|
||||
completed?: boolean;
|
||||
}) =>
|
||||
apiRequest<void>({
|
||||
url: `/api/aihr/mobile/onboard/tasks/${id}/progress`,
|
||||
method: 'POST',
|
||||
data,
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
export const getQualification = () =>
|
||||
apiRequest<QualificationSummary>({
|
||||
url: '/api/aihr/mobile/qualification',
|
||||
|
||||
@@ -109,6 +109,17 @@ export const publishQuestion = (data: { title: string; content: string; category
|
||||
timeout: 15000
|
||||
}).then(normalizeQuestion);
|
||||
|
||||
export const publishQuestionFromAnswerFeedback = (data: { requestId: string; idempotencyKey: string }) =>
|
||||
apiRequest<RawQuestion>({
|
||||
url: '/api/aihr/community/questions/from-answer-feedback',
|
||||
method: 'POST',
|
||||
data: {
|
||||
requestId: data.requestId,
|
||||
idempotencyKey: data.idempotencyKey
|
||||
},
|
||||
timeout: 15000
|
||||
}).then(normalizeQuestion);
|
||||
|
||||
export const answerQuestion = (questionId: number, content: string) =>
|
||||
apiRequest<RawAnswer>({
|
||||
url: `/api/aihr/community/questions/${questionId}/answers`,
|
||||
|
||||
@@ -786,6 +786,18 @@ export interface OnboardTask {
|
||||
dueTime?: string | null;
|
||||
completedTime?: string | null;
|
||||
evidenceRef?: string | null;
|
||||
materialType?: 'DOCUMENT' | 'VIDEO' | 'COURSE' | 'SOP' | string | null;
|
||||
materialAttachmentId?: number | null;
|
||||
materialTitle?: string | null;
|
||||
materialVersion?: string | null;
|
||||
progressPercent?: number | null;
|
||||
completionRule?: 'MANUAL_CONFIRM' | 'EXAM_PASS' | string | null;
|
||||
examId?: number | null;
|
||||
recurrenceType?: 'ONCE' | 'DAILY' | 'WEEKLY' | 'MONTHLY' | string | null;
|
||||
startedTime?: string | null;
|
||||
lastViewedTime?: string | null;
|
||||
lastPositionSeconds?: number | null;
|
||||
mediaDurationSeconds?: number | null;
|
||||
}
|
||||
|
||||
export interface QualificationGate {
|
||||
@@ -950,6 +962,8 @@ export interface ExamQuestion {
|
||||
score: number;
|
||||
answer?: string | string[];
|
||||
explanation?: string;
|
||||
sourceQuestionCode?: string | null;
|
||||
sourceContentVersion?: string | null;
|
||||
}
|
||||
|
||||
export interface ExamSummary {
|
||||
@@ -1003,6 +1017,7 @@ export interface SupervisorExamDraft {
|
||||
dueDate: string;
|
||||
targets: PracticeAssignmentTarget[];
|
||||
questions: ExamQuestion[];
|
||||
questionDrawRequestId?: string | null;
|
||||
}
|
||||
|
||||
export interface ExamResultItem {
|
||||
|
||||
@@ -90,6 +90,8 @@ test('消息咨询只以消息 ID 作为受控上下文,并让每轮文字或
|
||||
assert.match(page, /broadcastMessageId \? undefined : currentProject\.value\?\.projectCode/);
|
||||
assert.match(page, /const agentOptions = \{[\s\S]*?broadcastMessageId,[\s\S]*?\}/);
|
||||
assert.match(page, /askAgent\(text, agentOptions\)/);
|
||||
assert.match(page, /transcribeSpeechCapture\([\s\S]*?\n[\s\S]*?void sendQuestion\(text, \{ voice: true \}\)/);
|
||||
assert.match(page, /transcribeSpeechFile\([\s\S]*?\n[\s\S]*?void sendQuestion\(text, \{ voice: true \}\)/);
|
||||
assert.match(service, /broadcastMessageId\?: number/);
|
||||
assert.match(service, /broadcastMessageId: options\.broadcastMessageId/);
|
||||
assert.match(service, /\/api\/aihr\/agent\/messages/);
|
||||
|
||||
@@ -4,16 +4,22 @@ import test from 'node:test';
|
||||
|
||||
const read = (path) => readFile(new URL(path, import.meta.url), 'utf8');
|
||||
|
||||
test('公司文件在员工端展示摘要、受控原文件入口并继续复用消息ID追问', async () => {
|
||||
test('公司文件在员工端优先在线阅读、按授权下载并继续复用消息ID追问', async () => {
|
||||
const [service, detail] = await Promise.all([
|
||||
read('../src/services/broadcast.ts'),
|
||||
read('../src/pages/user/broadcast/detail.vue')
|
||||
]);
|
||||
|
||||
assert.match(service, /interface BroadcastAttachment/);
|
||||
assert.match(service, /downloadUrl: string/);
|
||||
assert.match(service, /viewUrl: string/);
|
||||
assert.match(service, /downloadUrl\?: string/);
|
||||
assert.match(service, /allowDownload: boolean/);
|
||||
assert.match(service, /getBroadcastAttachmentView/);
|
||||
assert.match(detail, /公司文件/);
|
||||
assert.match(detail, /attachment\.summary/);
|
||||
assert.match(detail, /authenticatedFileUrl/);
|
||||
assert.match(detail, /item.attachment.allowDownload && item.attachment.downloadUrl/);
|
||||
assert.match(detail, /在线阅读/);
|
||||
assert.match(detail, /watermark-layer/);
|
||||
assert.match(detail, /saveBroadcastQuestionContext\(item\.value\.id/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
const service = fs.readFileSync(new URL('../src/services/questions.ts', import.meta.url), 'utf8');
|
||||
const page = fs.readFileSync(new URL('../src/pages/user/sop/index.vue', import.meta.url), 'utf8');
|
||||
|
||||
test('negative feedback bridge sends only opaque source and idempotency references', () => {
|
||||
assert.match(service, /questions\/from-answer-feedback/);
|
||||
assert.match(service, /data:\s*\{\s*requestId: data\.requestId,\s*idempotencyKey: data\.idempotencyKey\s*\}/);
|
||||
assert.doesNotMatch(service, /publishQuestionFromAnswerFeedback[\s\S]*queryText:/);
|
||||
assert.doesNotMatch(service, /publishQuestionFromAnswerFeedback[\s\S]*fragmentIds:/);
|
||||
});
|
||||
|
||||
test('human-help conversion is explicit, privacy-aware, and remains pending moderation', () => {
|
||||
assert.match(page, /转人工求助/);
|
||||
assert.match(page, /审核通过后其他员工可见/);
|
||||
assert.match(page, /不含住户姓名、电话、房号等隐私信息/);
|
||||
assert.match(page, /已提交审核/);
|
||||
assert.doesNotMatch(page, /已公开|已发布到问题榜/);
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { after, test } from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createServer } from 'vite';
|
||||
|
||||
const storage = new Map([
|
||||
['aihr_mobile_access_token', 'expired-token'],
|
||||
['aihr_mobile_client_id', 'app-client'],
|
||||
['aihr_mobile_phone', '13800000000'],
|
||||
['aihr_mobile_tenant_id', '000000']
|
||||
]);
|
||||
const redirects = [];
|
||||
let uploadShouldFail = false;
|
||||
|
||||
globalThis.plus = {};
|
||||
globalThis.getCurrentPages = () => [{
|
||||
route: 'pages/user/sop/index',
|
||||
$page: { fullPath: '/pages/user/sop/index?source=rc006' }
|
||||
}];
|
||||
globalThis.uni = {
|
||||
getStorageSync: (key) => storage.get(key) || '',
|
||||
setStorageSync: (key, value) => storage.set(key, value),
|
||||
removeStorageSync: (key) => storage.delete(key),
|
||||
reLaunch: ({ url }) => redirects.push(url),
|
||||
request: ({ fail }) => {
|
||||
queueMicrotask(() => fail({ errMsg: 'request:fail network' }));
|
||||
return { abort: () => {} };
|
||||
},
|
||||
uploadFile: ({ fail, success }) => {
|
||||
queueMicrotask(() => uploadShouldFail
|
||||
? fail({ errMsg: 'uploadFile:fail network' })
|
||||
: success({ statusCode: 200, data: '{"code":200,"data":{"text":"ok"}}' }));
|
||||
return { abort: () => {} };
|
||||
}
|
||||
};
|
||||
|
||||
const vite = await createServer({
|
||||
root: fileURLToPath(new URL('..', import.meta.url)),
|
||||
configFile: false,
|
||||
optimizeDeps: { noDiscovery: true },
|
||||
server: { middlewareMode: true, hmr: false },
|
||||
appType: 'custom'
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await vite.close();
|
||||
delete globalThis.plus;
|
||||
delete globalThis.uni;
|
||||
delete globalThis.window;
|
||||
delete globalThis.getCurrentPages;
|
||||
});
|
||||
|
||||
test('ASR upload failure has a stable retryable result', async () => {
|
||||
uploadShouldFail = true;
|
||||
const { transcribeSpeechFile } = await vite.ssrLoadModule('/src/services/speech.ts');
|
||||
|
||||
await assert.rejects(
|
||||
transcribeSpeechFile({ path: '/tmp/voice.mp3', name: 'voice.mp3', size: 3 }),
|
||||
/语音上传失败/
|
||||
);
|
||||
});
|
||||
|
||||
test('TTS network failure falls back to device speech when supported', async () => {
|
||||
const spoken = [];
|
||||
globalThis.window = {
|
||||
SpeechSynthesisUtterance: class {
|
||||
constructor(text) { this.text = text; }
|
||||
},
|
||||
speechSynthesis: {
|
||||
speak: (utterance) => spoken.push(utterance.text),
|
||||
cancel: () => {}
|
||||
}
|
||||
};
|
||||
const states = [];
|
||||
const errors = [];
|
||||
const { createSpeechPlaybackController } = await vite.ssrLoadModule('/src/services/speech.ts');
|
||||
const controller = createSpeechPlaybackController(
|
||||
(state) => states.push(state.status),
|
||||
(message) => errors.push(message)
|
||||
);
|
||||
|
||||
await controller.toggle('answer-1', '请先确认问题,再说明处理时限');
|
||||
|
||||
assert.deepEqual(spoken, ['请先确认问题,再说明处理时限']);
|
||||
assert.ok(states.includes('loading'));
|
||||
assert.ok(states.includes('playing'));
|
||||
assert.deepEqual(errors, []);
|
||||
controller.destroy();
|
||||
});
|
||||
|
||||
test('401 clears the expired session and relaunches login while 403 does not', async () => {
|
||||
const { ApiError, readPayload } = await vite.ssrLoadModule('/src/services/api.ts');
|
||||
|
||||
assert.throws(
|
||||
() => readPayload(401, { code: 401, msg: 'login expired' }),
|
||||
(error) => error instanceof ApiError && error.statusCode === 401
|
||||
);
|
||||
assert.equal(storage.has('aihr_mobile_access_token'), false);
|
||||
assert.equal(storage.get('aihr_mobile_login_redirect'), '/pages/user/sop/index?source=rc006');
|
||||
assert.deepEqual(redirects, ['/pages/auth/login/index']);
|
||||
|
||||
storage.set('aihr_mobile_access_token', 'new-token');
|
||||
assert.throws(
|
||||
() => readPayload(403, { code: 403, msg: 'forbidden' }),
|
||||
(error) => error instanceof ApiError && error.statusCode === 403
|
||||
);
|
||||
assert.equal(storage.get('aihr_mobile_access_token'), 'new-token');
|
||||
assert.deepEqual(redirects, ['/pages/auth/login/index']);
|
||||
});
|
||||
Reference in New Issue
Block a user