feat(aihr): close functional checklist gaps

This commit is contained in:
key
2026-08-02 12:15:45 +08:00
parent 699cc08050
commit 6ad8a017a4
80 changed files with 4930 additions and 430 deletions
+10 -4
View File
@@ -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": {
+72 -6
View File
@@ -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;
+93 -5
View File
@@ -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; }
+169 -53
View File
@@ -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>
+74 -4
View File
@@ -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%;
+39 -4
View File
@@ -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;
+34 -1
View File
@@ -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
}))
};
};
+14
View File
@@ -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',
+11
View File
@@ -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`,
+15
View File
@@ -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 {
+2
View File
@@ -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, /已公开|已发布到问题榜/);
});
+109
View File
@@ -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']);
});