feat(mobile): AI question generation panel in supervisor exam editor

Hints input + preset chips + count picker; generated questions land in
the existing editor blocks for the supervisor to review before publish.
This commit is contained in:
2026-07-20 10:16:40 +08:00
parent 4e9ccfa8b5
commit 7716f0040d
2 changed files with 68 additions and 2 deletions
+35 -1
View File
@@ -29,6 +29,15 @@
<text v-if="!members.length" class="muted">当前项目暂无可派发的在职员工。</text> <text v-if="!members.length" class="muted">当前项目暂无可派发的在职员工。</text>
</view> </view>
<view class="panel ai-panel">
<view class="section-head"><text class="panel-title">AI 出题</text></view>
<text class="muted">输入考察提示,AI 按岗位生成题目与答案,请逐题核对后发布</text>
<input v-model="aiHints" class="input" maxlength="120" placeholder="例如:投诉接待、停车费催缴、电梯故障应对" />
<view class="quick-row ai-chip-row"><button v-for="hint in aiHintPresets" :key="hint" class="quick-chip ai-chip" @click="appendAiHint(hint)">{{ hint }}</button></view>
<view class="quick-row ai-chip-row"><button v-for="option in aiCountOptions" :key="option" class="quick-chip ai-chip" :class="{ active: aiCount === option }" @click="aiCount = option">{{ option }} 题</button></view>
<button class="primary-button ai-generate-button" :disabled="saving || aiGenerating" @click="generateAiQuestions">{{ aiGenerating ? '生成中…约需10-30秒' : 'AI 生成题目' }}</button>
</view>
<view class="panel question-editor"> <view class="panel question-editor">
<view class="section-head"><text class="panel-title">题目({{ questions.length }})· 共 {{ totalScore }} 分</text><button class="ghost-button compact-button" @click="addQuestion">添加题目</button></view> <view class="section-head"><text class="panel-title">题目({{ questions.length }})· 共 {{ totalScore }} 分</text><button class="ghost-button compact-button" @click="addQuestion">添加题目</button></view>
<text v-if="totalScore !== 100" class="score-warning">发布前题目分值合计必须为 100 分</text> <text v-if="totalScore !== 100" class="score-warning">发布前题目分值合计必须为 100 分</text>
@@ -60,7 +69,7 @@ import { computed, ref } from 'vue';
import { onLoad } from '@dcloudio/uni-app'; import { onLoad } from '@dcloudio/uni-app';
import type { ExamQuestion, PracticeTeamMember, SupervisorExamDraft } from '@/types/api'; import type { ExamQuestion, PracticeTeamMember, SupervisorExamDraft } from '@/types/api';
import { getPracticeTeamSnapshot } from '@/services/practice'; import { getPracticeTeamSnapshot } from '@/services/practice';
import { getSupervisorExam, publishSupervisorExam, saveSupervisorExam, updateSupervisorExam } from '@/services/exam'; import { generateSupervisorExamQuestions, getSupervisorExam, publishSupervisorExam, saveSupervisorExam, updateSupervisorExam } from '@/services/exam';
import SupervisorTabBar from '@/components/SupervisorTabBar.vue'; import SupervisorTabBar from '@/components/SupervisorTabBar.vue';
const positions = ['生活顾问', '客服', '保安', '保洁', '保修']; const positions = ['生活顾问', '客服', '保安', '保洁', '保修'];
@@ -69,6 +78,9 @@ const quickDates = [{ label: '当天', days: 0 }, { label: '明天', days: 1 },
const examId = ref(0); const title = ref(''); const positionIndex = ref(0); const passingScore = ref(80); const dueDate = ref(''); const examId = ref(0); const title = ref(''); const positionIndex = ref(0); const passingScore = ref(80); const dueDate = ref('');
const members = ref<PracticeTeamMember[]>([]); const selectedMemberIds = ref<string[]>([]); const questions = ref<ExamQuestion[]>([]); const singleAnswerModels = ref<string[]>([]); const members = ref<PracticeTeamMember[]>([]); const selectedMemberIds = ref<string[]>([]); const questions = ref<ExamQuestion[]>([]); const singleAnswerModels = ref<string[]>([]);
const saving = ref(false); const message = ref(''); const saving = ref(false); const message = ref('');
const aiHints = ref(''); const aiCount = ref(5); const aiGenerating = ref(false);
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')}`; const formatDate = (date: Date) => `${date.getFullYear()}-${String(date.getMonth()+1).padStart(2,'0')}-${String(date.getDate()).padStart(2,'0')}`;
const dateAfter = (days: number) => { const date = new Date(); date.setHours(12,0,0,0); date.setDate(date.getDate()+days); return formatDate(date); }; const dateAfter = (days: number) => { const date = new Date(); date.setHours(12,0,0,0); date.setDate(date.getDate()+days); return formatDate(date); };
const today = dateAfter(0); const today = dateAfter(0);
@@ -104,6 +116,23 @@ const selectFormat = (index: number, event: { detail: { value: string } }) => {
const syncAnswer = (index: number) => { questions.value[index].answer = questions.value[index].format === 'multiple' ? singleAnswerModels.value[index].split('|').map((value) => value.trim()).filter(Boolean) : singleAnswerModels.value[index]; }; const syncAnswer = (index: number) => { questions.value[index].answer = questions.value[index].format === 'multiple' ? singleAnswerModels.value[index].split('|').map((value) => value.trim()).filter(Boolean) : singleAnswerModels.value[index]; };
const toggleMember = (id: string) => { selectedMemberIds.value = selectedMemberIds.value.includes(id) ? selectedMemberIds.value.filter((item) => item !== id) : selectedMemberIds.value.length < 100 ? [...selectedMemberIds.value,id] : selectedMemberIds.value; }; const toggleMember = (id: string) => { selectedMemberIds.value = selectedMemberIds.value.includes(id) ? selectedMemberIds.value.filter((item) => item !== id) : selectedMemberIds.value.length < 100 ? [...selectedMemberIds.value,id] : selectedMemberIds.value; };
const selectAll = () => { selectedMemberIds.value = members.value.slice(0,100).map((item) => item.extPartyId); }; const selectAll = () => { selectedMemberIds.value = members.value.slice(0,100).map((item) => item.extPartyId); };
const appendAiHint = (hint: string) => {
const parts = aiHints.value.split(/[、,,\s]+/).map((value) => value.trim()).filter(Boolean);
if (!parts.includes(hint)) parts.push(hint);
aiHints.value = parts.join('、');
};
const generateAiQuestions = async () => {
if (saving.value || aiGenerating.value) return; aiGenerating.value = true; message.value = '';
try {
const result = await generateSupervisorExamQuestions({ positionCode: positions[positionIndex.value], hints: aiHints.value.trim(), count: aiCount.value, formats: ['single','multiple','judge'] });
questions.value = result.questions;
singleAnswerModels.value = result.questions.map((item) => Array.isArray(item.answer) ? item.answer.join('|') : String(item.answer || ''));
message.value = result.groundedFragments > 0
? `已生成 ${result.questions.length} 题(参考 ${result.groundedFragments} 条 SOP 片段),请逐题核对修改后再发布`
: `已生成 ${result.questions.length} 题,请逐题核对修改后再发布`;
uni.pageScrollTo({ selector: '.question-editor', duration: 200 });
} catch (error) { message.value = error instanceof Error ? error.message : 'AI 出题失败,请稍后重试'; } finally { aiGenerating.value = false; }
};
const draft = (): SupervisorExamDraft => ({ const draft = (): SupervisorExamDraft => ({
title: title.value.trim(), title: title.value.trim(),
projectCode: '', projectCode: '',
@@ -168,4 +197,9 @@ onLoad((options) => { examId.value = Number(options?.id || 0); void load(); });
.compact-textarea { min-height:82px; } .compact-textarea { min-height:82px; }
.score-warning { color:#a86100; font-size:12px; font-weight:800; } .score-warning { color:#a86100; font-size:12px; font-weight:800; }
.publish-dock { position:fixed; z-index:10; right:0; bottom:calc(76px + env(safe-area-inset-bottom)); left:0; display:grid; grid-template-columns:1fr 1fr; gap:10px; max-width:var(--mobile-shell-max-width); margin:0 auto; padding:12px 16px; border-top:1px solid var(--line); background:rgba(255,255,255,.97); } .publish-dock { position:fixed; z-index:10; right:0; bottom:calc(76px + env(safe-area-inset-bottom)); left:0; display:grid; grid-template-columns:1fr 1fr; gap:10px; max-width:var(--mobile-shell-max-width); margin:0 auto; padding:12px 16px; border-top:1px solid var(--line); background:rgba(255,255,255,.97); }
.ai-panel { display:grid; gap:10px; }
.ai-chip-row { justify-content:flex-start; flex-wrap:wrap; }
.ai-chip { display:flex; align-items:center; justify-content:center; min-height:34px; padding:0 12px; line-height:1; }
.ai-chip.active { border-color:var(--brand); color:var(--brand); font-weight:900; }
.ai-generate-button { display:flex; align-items:center; justify-content:center; min-height:44px; line-height:1; }
</style> </style>
+33 -1
View File
@@ -1,4 +1,4 @@
import type { ExamDetail, ExamResultItem, ExamSubmitResult, ExamSummary, SupervisorExamDraft } from '@/types/api'; import type { ExamDetail, ExamQuestion, ExamResultItem, ExamSubmitResult, ExamSummary, SupervisorExamDraft } from '@/types/api';
import { apiRequest } from './api'; import { apiRequest } from './api';
type RawExamQuestion = { type RawExamQuestion = {
@@ -149,3 +149,35 @@ export const getSupervisorExamResults = (id: number) =>
method: 'GET', method: 'GET',
timeout: 15000 timeout: 15000
}); });
type RawAiGenerateResponse = {
questions: RawExamQuestion[];
groundedFragments?: number;
};
export const generateSupervisorExamQuestions = async (data: {
positionCode: string;
hints: string;
count: number;
formats: string[];
}): Promise<{ questions: ExamQuestion[]; groundedFragments: number }> => {
const response = await apiRequest<RawAiGenerateResponse>({
url: '/api/aihr/mobile/exams/supervisor/ai-generate',
method: 'POST',
data,
timeout: 60000
});
return {
groundedFragments: response.groundedFragments || 0,
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 || ''
}))
};
};