feat: restore learning feedback and knowledge capabilities

This commit is contained in:
2026-07-18 00:50:11 +08:00
parent 876a23a123
commit 6bdc86e3fc
152 changed files with 30337 additions and 953 deletions
@@ -0,0 +1,171 @@
<template>
<view class="page exam-edit-page">
<view class="subpage-header">
<button class="subpage-back-button" aria-label="返回考试列表" :disabled="saving" @click="goBack">←</button>
<view class="subpage-heading"><text class="page-title">{{ examId ? '编辑考试' : '新建考试' }}</text><text class="page-subtitle">发布后冻结题目版本,已派发试卷不随草稿变化。</text></view>
</view>
<text v-if="message" class="status-message">{{ message }}</text>
<view class="panel form-panel">
<label class="field-label">考试名称</label>
<input v-model="title" class="input" maxlength="80" placeholder="例如:生活顾问岗前基础测试" />
<label class="field-label">适用岗位</label>
<picker :range="positions" :value="positionIndex" @change="selectPosition"><view class="input picker-value">{{ positions[positionIndex] }}</view></picker>
<label class="field-label">及格分</label>
<input v-model.number="passingScore" class="input" type="number" />
<label class="field-label">截止日期</label>
<picker mode="date" :value="dueDate" :start="today" @change="selectDueDate"><view class="input picker-value">{{ dueDate }}</view></picker>
<view class="quick-row"><button v-for="item in quickDates" :key="item.days" class="quick-chip" @click="setDueDate(item.days)">{{ item.label }}</button></view>
</view>
<view class="panel">
<view class="section-head"><text class="panel-title">考试对象</text><text class="muted">已选 {{ selectedMemberIds.length }} 人</text></view>
<view class="member-actions"><button class="ghost-button" @click="selectAll">全选(最多100人)</button><button class="secondary-button" @click="selectedMemberIds = []">清空</button></view>
<scroll-view class="member-list" scroll-y>
<button v-for="member in members" :key="member.extPartyId" class="member-row" :class="{ selected: selectedMemberIds.includes(member.extPartyId) }" @click="toggleMember(member.extPartyId)">
<text class="member-check">{{ selectedMemberIds.includes(member.extPartyId) ? '✓' : '' }}</text><text>{{ member.name || '未命名员工' }}</text>
</button>
</scroll-view>
<text v-if="!members.length" class="muted">当前项目暂无可派发的在职员工。</text>
</view>
<view class="panel question-editor">
<view class="section-head"><text class="panel-title">题目({{ questions.length }})· 共 {{ totalScore }} 分</text><button class="ghost-button compact-button" @click="addQuestion">添加题目</button></view>
<text v-if="totalScore !== 100" class="score-warning">发布前题目分值合计必须为 100 分</text>
<view v-for="(item, index) in questions" :key="item.id" class="question-block">
<view class="question-block-head"><text>第 {{ index + 1 }} 题</text><button class="remove-button" :disabled="questions.length === 1" @click="removeQuestion(index)">删除</button></view>
<textarea v-model="item.stem" class="textarea stem-input" maxlength="500" placeholder="输入题干" />
<picker :range="formats.map((value) => value.label)" :value="formatIndex(item.format)" @change="selectFormat(index, $event)"><view class="input picker-value">{{ formatLabel(item.format) }}</view></picker>
<label class="field-label">本题分值</label>
<input v-model.number="item.score" class="input" type="number" />
<template v-if="item.format !== 'short'">
<input v-for="(_, optionIndex) in item.options" :key="optionIndex" v-model="item.options[optionIndex]" class="input" :placeholder="`选项 ${optionIndex + 1}`" />
<input v-model="singleAnswerModels[index]" class="input" placeholder="正确答案(与选项文字一致,多选用|分隔)" @input="syncAnswer(index)" />
</template>
<textarea v-else v-model="singleAnswerModels[index]" class="textarea compact-textarea" placeholder="参考答案" @input="syncAnswer(index)" />
<textarea v-model="item.explanation" class="textarea compact-textarea" placeholder="参考依据 / SOP 要点" />
</view>
</view>
<view class="publish-dock">
<button class="secondary-button" :disabled="saving || !valid" @click="save(false)">{{ saving ? '保存中' : '保存草稿' }}</button>
<button class="primary-button" :disabled="saving || !valid || !selectedMemberIds.length" @click="save(true)">{{ saving ? '发布中' : `发布给 ${selectedMemberIds.length} 人` }}</button>
</view>
<SupervisorTabBar active="team" />
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
import type { ExamQuestion, PracticeTeamMember, SupervisorExamDraft } from '@/types/api';
import { getPracticeTeamSnapshot } from '@/services/practice';
import { getSupervisorExam, publishSupervisorExam, saveSupervisorExam, updateSupervisorExam } from '@/services/exam';
import SupervisorTabBar from '@/components/SupervisorTabBar.vue';
const positions = ['生活顾问', '客服', '保安', '保洁', '保修'];
const formats = [{ key: 'single', label: '单选题' }, { key: 'multiple', label: '多选题' }, { key: 'judge', label: '判断题' }, { key: 'short', label: '简答题' }];
const quickDates = [{ label: '当天', days: 0 }, { label: '明天', days: 1 }, { label: '3天内', days: 3 }, { label: '7天内', days: 7 }];
const examId = ref(0); const title = ref(''); const positionIndex = ref(0); const passingScore = ref(80); const dueDate = ref('');
const members = ref<PracticeTeamMember[]>([]); const selectedMemberIds = ref<string[]>([]); const questions = ref<ExamQuestion[]>([]); const singleAnswerModels = ref<string[]>([]);
const saving = ref(false); const message = ref('');
const formatDate = (date: Date) => `${date.getFullYear()}-${String(date.getMonth()+1).padStart(2,'0')}-${String(date.getDate()).padStart(2,'0')}`;
const dateAfter = (days: number) => { const date = new Date(); date.setHours(12,0,0,0); date.setDate(date.getDate()+days); return formatDate(date); };
const today = dateAfter(0);
const requestId = () => `exam-publish:${examId.value || 'new'}:${Date.now()}:${Math.random().toString(36).slice(2,8)}`;
const totalScore = computed(() => questions.value.reduce((sum, item) => sum + Number(item.score || 0), 0));
const valid = computed(() => title.value.trim().length >= 4
&& passingScore.value > 0 && passingScore.value <= 100
&& totalScore.value === 100
&& questions.value.length > 0
&& questions.value.every((item, index) => item.stem.trim()
&& Number(item.score) > 0
&& singleAnswerModels.value[index]?.trim()
&& (item.format === 'short' || item.options.filter((option) => option.trim()).length >= 2)));
const goBack = () => uni.navigateBack({ delta: 1 });
const selectPosition = (event: { detail: { value: string } }) => { positionIndex.value = Number(event.detail.value || 0); };
const selectDueDate = (event: { detail: { value: string } }) => { dueDate.value = event.detail.value; };
const setDueDate = (days: number) => { dueDate.value = dateAfter(days); };
const formatIndex = (value: string) => Math.max(0, formats.findIndex((item) => item.key === value));
const formatLabel = (value: string) => formats.find((item) => item.key === value)?.label || '单选题';
const newQuestion = (): ExamQuestion => ({ id: Date.now() + Math.floor(Math.random()*1000), format: 'single', stem: '', options: ['', '', '', ''], score: 100, answer: '', explanation: '' });
const rebalanceScores = () => {
const count = questions.value.length;
if (!count) return;
const base = Math.floor(100 / count);
questions.value.forEach((item, index) => { item.score = index === count - 1 ? 100 - base * (count - 1) : base; });
};
const addQuestion = () => { questions.value.push(newQuestion()); singleAnswerModels.value.push(''); rebalanceScores(); };
const removeQuestion = (index: number) => { questions.value.splice(index,1); singleAnswerModels.value.splice(index,1); rebalanceScores(); };
const selectFormat = (index: number, event: { detail: { value: string } }) => {
const format = formats[Number(event.detail.value || 0)]?.key || 'single'; questions.value[index].format = format;
questions.value[index].options = format === 'short' ? [] : format === 'judge' ? ['正确','错误'] : (questions.value[index].options.length ? questions.value[index].options : ['','','','']);
};
const syncAnswer = (index: number) => { questions.value[index].answer = questions.value[index].format === 'multiple' ? singleAnswerModels.value[index].split('|').map((value) => value.trim()).filter(Boolean) : singleAnswerModels.value[index]; };
const toggleMember = (id: string) => { selectedMemberIds.value = selectedMemberIds.value.includes(id) ? selectedMemberIds.value.filter((item) => item !== id) : selectedMemberIds.value.length < 100 ? [...selectedMemberIds.value,id] : selectedMemberIds.value; };
const selectAll = () => { selectedMemberIds.value = members.value.slice(0,100).map((item) => item.extPartyId); };
const draft = (): SupervisorExamDraft => ({
title: title.value.trim(),
projectCode: '',
positionCode: positions[positionIndex.value],
passScore: Number(passingScore.value),
dueDate: dueDate.value,
targets: members.value
.filter((item) => selectedMemberIds.value.includes(item.extPartyId))
.map((item) => ({ extPartyId: item.extPartyId, traineeName: item.name })),
questions: questions.value.map((item, index) => ({
...item,
answer: item.format === 'multiple'
? singleAnswerModels.value[index].split('|').map((value) => value.trim()).filter(Boolean)
: singleAnswerModels.value[index]
}))
});
const save = async (publish: boolean) => {
if (!valid.value || saving.value) return; saving.value = true; message.value = '';
try {
const saved = examId.value ? await updateSupervisorExam(examId.value, draft()) : await saveSupervisorExam(draft()); examId.value = saved.id;
if (publish) { await publishSupervisorExam(saved.id, requestId()); uni.showToast({ title: '考试已发布', icon: 'none' }); setTimeout(goBack,350); }
else message.value = '草稿已保存';
} catch (error) { message.value = error instanceof Error ? error.message : '考试保存失败'; } finally { saving.value = false; }
};
const load = async () => {
try {
const team = await getPracticeTeamSnapshot(); members.value = team.members || [];
if (examId.value) {
const existing = await getSupervisorExam(examId.value);
title.value = existing.title;
positionIndex.value = Math.max(0, positions.indexOf(existing.positionCode || '生活顾问'));
passingScore.value = existing.passingScore;
dueDate.value = existing.dueDate?.slice(0,10) || dateAfter(1);
questions.value = existing.questions;
singleAnswerModels.value = existing.questions.map((item) => Array.isArray(item.answer) ? item.answer.join('|') : String(item.answer || ''));
selectedMemberIds.value = existing.targets.map((item) => item.extPartyId);
} else { dueDate.value = dateAfter(1); addQuestion(); }
} catch (error) { message.value = error instanceof Error ? error.message : '考试配置加载失败'; }
};
onLoad((options) => { examId.value = Number(options?.id || 0); void load(); });
</script>
<style scoped>
.exam-edit-page { max-width: var(--mobile-shell-max-width); margin: 0 auto; padding-bottom: 190px; }
.status-message { display:block; margin-top:12px; color:#a9000d; }
.form-panel,.question-editor { display:grid; gap:10px; }
.field-label { display:block; margin-top:4px; font-size:13px; font-weight:900; }
.picker-value { display:flex; align-items:center; }
.two-column { display:grid; grid-template-columns:1fr 1fr; gap:10px; }
.quick-row,.member-actions,.section-head,.question-block-head { display:flex; gap:8px; align-items:center; justify-content:space-between; }
.quick-chip { min-height:36px; padding:0 12px; border:1px solid var(--line); background:#fff; color:var(--muted); }
.member-actions button { flex:1; min-width:0; margin-top:10px; }
.member-list { max-height:260px; margin-top:10px; }
.member-row { display:flex; width:100%; gap:10px; min-height:46px; align-items:center; padding:8px 10px; border-bottom:1px solid var(--line); background:#fff; color:var(--ink); text-align:left; }
.member-row.selected { background:var(--employee-primary-soft); }
.member-check { flex:0 0 24px; color:var(--brand); font-weight:900; }
.compact-button { min-height:36px; padding:0 10px; font-size:12px; }
.question-block { display:grid; gap:9px; padding:14px 0; border-top:1px solid var(--line); }
.question-block-head { font-weight:900; }
.remove-button { background:transparent; color:var(--brand); font-size:13px; }
.stem-input { min-height:96px; }
.compact-textarea { min-height:82px; }
.score-warning { color:#a86100; font-size:12px; font-weight:800; }
.publish-dock { position:fixed; z-index:10; right:0; bottom:calc(76px + env(safe-area-inset-bottom)); left:0; display:grid; grid-template-columns:1fr 1fr; gap:10px; max-width:var(--mobile-shell-max-width); margin:0 auto; padding:12px 16px; border-top:1px solid var(--line); background:rgba(255,255,255,.97); }
</style>
@@ -0,0 +1,51 @@
<template>
<view class="page supervisor-exams-page">
<view class="subpage-header">
<button class="subpage-back-button" aria-label="返回主管首页" @click="goBack">←</button>
<view class="subpage-heading"><text class="page-title">岗前测试</text><text class="page-subtitle">按岗位组卷、选择真实员工并查看完成情况。</text></view>
</view>
<button class="primary-button create-button" @click="createExam">新建考试</button>
<text v-if="message" class="status-message">{{ message }}</text>
<view v-if="loading" class="panel">考试列表加载中…</view>
<view v-else-if="exams.length" class="exam-list">
<view v-for="item in exams" :key="item.id" class="panel exam-card">
<view class="exam-head"><text class="panel-title">{{ item.title }}</text><text class="status-chip">{{ item.status }}</text></view>
<text class="muted">{{ item.positionCode || '通用岗位' }} · {{ item.questionCount }} 题 · {{ item.targetCount }} 人 · 及格 {{ item.passingScore }} 分</text>
<text v-if="item.dueDate" class="muted">截止 {{ item.dueDate }}</text>
<view class="exam-actions">
<button class="secondary-button" :disabled="item.status !== 'DRAFT'" @click="editExam(item.id)">编辑</button>
<button class="ghost-button" @click="viewResults(item.id)">查看结果</button>
</view>
</view>
</view>
<view v-else class="panel empty">暂无考试,先创建岗位题目并派发。</view>
<SupervisorTabBar active="team" />
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import type { ExamSummary } from '@/types/api';
import { listSupervisorExams } from '@/services/exam';
import SupervisorTabBar from '@/components/SupervisorTabBar.vue';
const exams = ref<ExamSummary[]>([]); const loading = ref(false); const message = ref('');
const goBack = () => uni.redirectTo({ url: '/pages/supervisor/index/index' });
const createExam = () => uni.navigateTo({ url: '/pages/supervisor/exams/edit' });
const editExam = (id: number) => uni.navigateTo({ url: `/pages/supervisor/exams/edit?id=${id}` });
const viewResults = (id: number) => uni.navigateTo({ url: `/pages/supervisor/exams/results?id=${id}` });
const load = async () => { loading.value = true; try { exams.value = await listSupervisorExams(); } catch (error) { message.value = error instanceof Error ? error.message : '考试列表读取失败'; } finally { loading.value = false; } };
onShow(() => { void load(); });
</script>
<style scoped>
.supervisor-exams-page { max-width: var(--mobile-shell-max-width); margin: 0 auto; }
.create-button { width: 100%; margin-top: 18px; }
.status-message { display: block; margin-top: 12px; color: #a9000d; }
.exam-list { display: grid; gap: 4px; }
.exam-card { display: grid; gap: 9px; }
.exam-head, .exam-actions { display: flex; gap: 10px; align-items: center; justify-content: space-between; }
.status-chip { padding: 4px 8px; border-radius: 999px; background: #eef3f9; color: var(--knowledge); font-size: 12px; }
.exam-actions button { flex: 1; min-width: 0; }
.empty { color: var(--muted); }
</style>
@@ -0,0 +1,29 @@
<template>
<view class="page exam-results-page">
<view class="subpage-header"><button class="subpage-back-button" aria-label="返回" @click="goBack">←</button><view class="subpage-heading"><text class="page-title">考试结果</text><text class="page-subtitle">按人员查看成绩、未完成与通过状态。</text></view></view>
<text v-if="message" class="status-message">{{ message }}</text>
<view class="metric-grid">
<view class="metric-card"><text>{{ results.length }}</text><label>已派发</label></view>
<view class="metric-card"><text>{{ completed }}</text><label>已完成</label></view>
<view class="metric-card"><text>{{ passed }}</text><label>已通过</label></view>
</view>
<view v-if="results.length" class="result-list">
<view v-for="item in results" :key="item.attemptId || item.extPartyId" class="panel result-row">
<view><text class="result-name">{{ item.traineeName }}</text><text class="muted">{{ item.submittedTime || '尚未提交' }} · {{ item.status === 'PENDING' ? '待完成' : '已提交' }}</text></view>
<view class="result-score"><text>{{ item.score == null ? '--' : `${item.score}分` }}</text><label>{{ item.status === 'PENDING' ? '未完成' : item.passed ? '通过' : '未通过' }}</label></view>
</view>
</view>
<view v-else class="panel empty">暂无派发结果</view>
<SupervisorTabBar active="team" />
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'; import { onLoad } from '@dcloudio/uni-app'; import type { ExamResultItem } from '@/types/api'; import { getSupervisorExamResults } from '@/services/exam'; import SupervisorTabBar from '@/components/SupervisorTabBar.vue';
const id=ref(0); const results=ref<ExamResultItem[]>([]); const message=ref(''); const completed=computed(()=>results.value.filter((item)=>item.status==='SUBMITTED').length); const passed=computed(()=>results.value.filter((item)=>item.status==='SUBMITTED'&&item.passed===true).length);
const goBack=()=>uni.navigateBack({delta:1}); const load=async()=>{try{results.value=await getSupervisorExamResults(id.value);}catch(error){message.value=error instanceof Error?error.message:'考试结果读取失败';}}; onLoad((options)=>{id.value=Number(options?.id||0);void load();});
</script>
<style scoped>
.exam-results-page{max-width:var(--mobile-shell-max-width);margin:0 auto}.status-message{display:block;margin-top:12px;color:#a9000d}.metric-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-top:18px}.metric-card{display:grid;gap:4px;padding:14px;border:1px solid var(--line);border-radius:12px;background:#fff;text-align:center}.metric-card text{font-size:22px;font-weight:900}.metric-card label,.muted{color:var(--muted);font-size:12px}.result-list{display:grid;gap:4px}.result-row{display:flex;gap:12px;align-items:center;justify-content:space-between}.result-name,.result-row .muted,.result-score text,.result-score label{display:block}.result-name{font-weight:900}.result-score{text-align:right}.result-score text{color:var(--brand);font-weight:900}.result-score label{margin-top:4px;color:var(--muted);font-size:12px}.empty{color:var(--muted)}
</style>