feat(mobile): redesign supervisor exam list, results and edit pages
This commit is contained in:
@@ -1,64 +1,129 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="page exam-edit-page">
|
<view class="page exam-edit-page">
|
||||||
<view class="subpage-header">
|
<view class="subpage-header">
|
||||||
<button class="subpage-back-button" aria-label="返回考试列表" :disabled="saving" @click="goBack"><uni-icons type="back" size="20" color="#25282d" /></button>
|
<button class="subpage-back-button" aria-label="返回考试列表" title="返回考试列表" :disabled="saving" @click="goBack"><uni-icons type="back" size="20" color="#25282d" /></button>
|
||||||
<view class="subpage-heading"><text class="page-title">{{ examId ? '编辑考试' : '新建考试' }}</text><text class="page-subtitle">发布后冻结题目版本,已派发试卷不随草稿变化。</text></view>
|
<view class="subpage-heading">
|
||||||
|
<text class="page-title">{{ examId ? '编辑考试' : '新建考试' }}</text>
|
||||||
|
<text class="page-subtitle">发布后冻结题目版本,已派发试卷不随草稿变化</text>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<text v-if="message" class="status-message">{{ message }}</text>
|
<text v-if="message" class="status-message">{{ message }}</text>
|
||||||
|
|
||||||
<view class="panel form-panel">
|
<view class="panel info-panel">
|
||||||
<label class="field-label">考试名称</label>
|
<text class="panel-title">基本信息</text>
|
||||||
<input v-model="title" class="input" maxlength="80" placeholder="例如:生活顾问岗前基础测试" />
|
<view class="info-row">
|
||||||
<label class="field-label">适用岗位</label>
|
<text class="info-label">考试名称</text>
|
||||||
<picker :range="positions" :value="positionIndex" @change="selectPosition"><view class="input picker-value">{{ positions[positionIndex] }}</view></picker>
|
<input v-model="title" class="info-input" maxlength="80" placeholder="例如:生活顾问岗前基础测试" />
|
||||||
<label class="field-label">及格分</label>
|
</view>
|
||||||
<input v-model.number="passingScore" class="input" type="number" />
|
<view class="info-row">
|
||||||
<label class="field-label">截止日期</label>
|
<text class="info-label">岗位</text>
|
||||||
<picker mode="date" :value="dueDate" :start="today" @change="selectDueDate"><view class="input picker-value">{{ dueDate }}</view></picker>
|
<picker :range="positions" :value="positionIndex" @change="selectPosition">
|
||||||
<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 class="info-picker">
|
||||||
|
<text class="info-value">{{ positions[positionIndex] }}</text>
|
||||||
|
<text class="info-arrow">›</text>
|
||||||
|
</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
<view class="info-row">
|
||||||
|
<text class="info-label">及格分数</text>
|
||||||
|
<view class="info-picker">
|
||||||
|
<input v-model.number="passingScore" class="info-input score-input" type="number" />
|
||||||
|
<text class="info-suffix">分</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="info-row last">
|
||||||
|
<text class="info-label">截止日期</text>
|
||||||
|
<picker mode="date" :value="dueDate" :start="today" @change="selectDueDate">
|
||||||
|
<view class="info-picker">
|
||||||
|
<text class="info-value">{{ dueDate }}</text>
|
||||||
|
<text class="info-arrow">›</text>
|
||||||
|
</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="panel">
|
<view class="panel target-panel">
|
||||||
<view class="section-head"><text class="panel-title">考试对象</text><text class="muted">已选 {{ selectedMemberIds.length }} 人</text></view>
|
<view class="section-head">
|
||||||
<view class="member-actions"><button class="ghost-button" @click="selectAll">全选前100人</button><button class="secondary-button" @click="selectedMemberIds = []">清空</button></view>
|
<text class="panel-title">考试对象</text>
|
||||||
<scroll-view class="member-list" scroll-y>
|
<view class="head-actions">
|
||||||
<button v-for="member in members" :key="member.extPartyId" class="member-row" :class="{ selected: selectedMemberIds.includes(member.extPartyId) }" @click="toggleMember(member.extPartyId)">
|
<button class="text-button" @click="selectAll">全选</button>
|
||||||
<text class="member-check">{{ selectedMemberIds.includes(member.extPartyId) ? '✓' : '' }}</text><text>{{ member.name || '未命名员工' }}</text>
|
<button class="text-button" :disabled="!selectedMemberIds.length" @click="selectedMemberIds = []">清空</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<text class="selected-count">已选 {{ selectedMemberIds.length }} 人</text>
|
||||||
|
<scroll-view v-if="members.length" class="member-list" scroll-y>
|
||||||
|
<view class="member-chip-wrap">
|
||||||
|
<button
|
||||||
|
v-for="member in members"
|
||||||
|
:key="member.extPartyId"
|
||||||
|
class="member-option"
|
||||||
|
:class="{ selected: selectedMemberIds.includes(member.extPartyId) }"
|
||||||
|
:aria-pressed="selectedMemberIds.includes(member.extPartyId)"
|
||||||
|
@click="toggleMember(member.extPartyId)"
|
||||||
|
>
|
||||||
|
<text class="member-check" aria-hidden="true">{{ selectedMemberIds.includes(member.extPartyId) ? '✓' : '' }}</text>
|
||||||
|
<text class="member-name">{{ member.name || '未命名员工' }}</text>
|
||||||
</button>
|
</button>
|
||||||
|
</view>
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
<text v-if="!members.length" class="muted">当前项目暂无可派发的在职员工。</text>
|
<text v-else class="muted block">当前项目暂无可派发的在职员工。</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="panel ai-panel">
|
<view class="panel ai-panel">
|
||||||
<view class="section-head"><text class="panel-title">AI 出题</text></view>
|
<text class="panel-title">AI 出题</text>
|
||||||
<text class="muted">输入考察提示,AI 按岗位生成题目与答案,请逐题核对后发布</text>
|
<text class="muted block-sm">输入考察提示,AI 按岗位生成题目与答案,请逐题核对后发布</text>
|
||||||
<input v-model="aiHints" class="input" maxlength="120" placeholder="例如:投诉接待、停车费催缴、电梯故障应对" />
|
<input v-model="aiHints" class="input ai-hints-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="ai-chip-row">
|
||||||
<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 v-for="hint in aiHintPresets" :key="hint" class="quick-chip ai-chip" @click="appendAiHint(hint)">{{ hint }}</button>
|
||||||
<button class="primary-button ai-generate-button" :disabled="saving || aiGenerating" @click="generateAiQuestions">{{ aiGenerating ? '生成中…约需10-30秒' : 'AI 生成题目' }}</button>
|
</view>
|
||||||
|
<view class="ai-count-row">
|
||||||
|
<button v-for="option in aiCountOptions" :key="option" class="count-chip" :class="{ active: aiCount === option }" @click="aiCount = option">{{ option }}题</button>
|
||||||
|
</view>
|
||||||
|
<button class="ai-generate-button" :disabled="saving || aiGenerating" @click="generateAiQuestions">
|
||||||
|
{{ aiGenerating ? '生成中…约需10-30秒' : '✦ AI 生成题目' }}
|
||||||
|
</button>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="panel question-editor">
|
<view class="panel question-panel">
|
||||||
<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">题目预览</text>
|
||||||
|
<button class="text-button" @click="addQuestion">添加题目</button>
|
||||||
|
</view>
|
||||||
|
<text class="question-summary">{{ questions.length }}题 · 共{{ totalScore }}分</text>
|
||||||
<text v-if="totalScore !== 100" class="score-warning">发布前题目分值合计必须为 100 分</text>
|
<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-list">
|
||||||
<view class="question-block-head"><text>第 {{ index + 1 }} 题</text><button class="remove-button" :disabled="questions.length === 1" @click="removeQuestion(index)">删除</button></view>
|
<view v-for="(item, index) in questions" :key="item.id" class="question-item">
|
||||||
|
<view class="question-row" @click="toggleQuestion(index)">
|
||||||
|
<text class="question-stem">{{ index + 1 }}. {{ item.stem || '未填写题干' }}</text>
|
||||||
|
<text class="question-meta">{{ formatLabel(item.format) }} · {{ item.score }}分</text>
|
||||||
|
<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="输入题干" />
|
||||||
<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="editor-grid">
|
||||||
<label class="field-label">本题分值</label>
|
<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" />
|
||||||
|
<text class="score-suffix">分</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
<template v-if="item.format !== 'short'">
|
<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 v-model="singleAnswerModels[index]" class="input" placeholder="正确答案(与选项文字一致,多选用|分隔)" @input="syncAnswer(index)" />
|
<input v-model="singleAnswerModels[index]" class="input" placeholder="正确答案(与选项文字一致,多选用|分隔)" @input="syncAnswer(index)" />
|
||||||
</template>
|
</template>
|
||||||
<textarea v-else v-model="singleAnswerModels[index]" class="textarea compact-textarea" placeholder="参考答案" @input="syncAnswer(index)" />
|
<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 要点" />
|
||||||
|
<button class="remove-button" :disabled="questions.length === 1" @click="removeQuestion(index)">删除本题</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="publish-dock">
|
<view class="publish-dock">
|
||||||
<button class="secondary-button" :disabled="saving || !valid" @click="save(false)">{{ saving ? '保存中' : '保存草稿' }}</button>
|
<button class="dock-secondary" :disabled="saving || !valid" @click="save(false)">{{ saving ? '保存中' : '保存草稿' }}</button>
|
||||||
<button class="primary-button" :disabled="saving || !valid || !selectedMemberIds.length" @click="save(true)">{{ saving ? '发布中' : `发布给 ${selectedMemberIds.length} 人` }}</button>
|
<button class="dock-primary" :disabled="saving || !valid || !selectedMemberIds.length" @click="save(true)">{{ saving ? '发布中' : `发布给 ${selectedMemberIds.length} 人` }}</button>
|
||||||
</view>
|
</view>
|
||||||
<SupervisorTabBar active="team" />
|
<SupervisorTabBar active="team" />
|
||||||
</view>
|
</view>
|
||||||
@@ -74,11 +139,21 @@ import SupervisorTabBar from '@/components/SupervisorTabBar.vue';
|
|||||||
|
|
||||||
const positions = ['生活顾问', '客服', '保安', '保洁', '保修'];
|
const positions = ['生活顾问', '客服', '保安', '保洁', '保修'];
|
||||||
const formats = [{ key: 'single', label: '单选题' }, { key: 'multiple', label: '多选题' }, { key: 'judge', label: '判断题' }, { key: 'short', label: '简答题' }];
|
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 examId = ref(0); const title = ref(''); const positionIndex = ref(0); const passingScore = ref(80); const dueDate = ref('');
|
const title = ref('');
|
||||||
const members = ref<PracticeTeamMember[]>([]); const selectedMemberIds = ref<string[]>([]); const questions = ref<ExamQuestion[]>([]); const singleAnswerModels = ref<string[]>([]);
|
const positionIndex = ref(0);
|
||||||
const saving = ref(false); const message = ref('');
|
const passingScore = ref(80);
|
||||||
const aiHints = ref(''); const aiCount = ref(5); const aiGenerating = ref(false);
|
const dueDate = ref('');
|
||||||
|
const members = ref<PracticeTeamMember[]>([]);
|
||||||
|
const selectedMemberIds = ref<string[]>([]);
|
||||||
|
const questions = ref<ExamQuestion[]>([]);
|
||||||
|
const singleAnswerModels = ref<string[]>([]);
|
||||||
|
const expandedIndex = ref(0);
|
||||||
|
const saving = ref(false);
|
||||||
|
const message = ref('');
|
||||||
|
const aiHints = ref('');
|
||||||
|
const aiCount = ref(8);
|
||||||
|
const aiGenerating = ref(false);
|
||||||
const aiHintPresets = ['投诉接待', '催缴沟通', '电梯故障', '服务态度', '消防安全'];
|
const aiHintPresets = ['投诉接待', '催缴沟通', '电梯故障', '服务态度', '消防安全'];
|
||||||
const aiCountOptions = [5, 8, 10];
|
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')}`;
|
||||||
@@ -94,10 +169,9 @@ const valid = computed(() => title.value.trim().length >= 4
|
|||||||
&& Number(item.score) > 0
|
&& Number(item.score) > 0
|
||||||
&& singleAnswerModels.value[index]?.trim()
|
&& singleAnswerModels.value[index]?.trim()
|
||||||
&& (item.format === 'short' || item.options.filter((option) => option.trim()).length >= 2)));
|
&& (item.format === 'short' || item.options.filter((option) => option.trim()).length >= 2)));
|
||||||
const goBack = () => uni.navigateBack({ delta: 1 });
|
const goBack = () => uni.navigateBack({ delta: 1, fail: () => uni.redirectTo({ url: '/pages/supervisor/exams/index' }) });
|
||||||
const selectPosition = (event: { detail: { value: string } }) => { positionIndex.value = Number(event.detail.value || 0); };
|
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 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 formatIndex = (value: string) => Math.max(0, formats.findIndex((item) => item.key === value));
|
||||||
const formatLabel = (value: string) => formats.find((item) => item.key === value)?.label || '单选题';
|
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 newQuestion = (): ExamQuestion => ({ id: Date.now() + Math.floor(Math.random() * 1000), format: 'single', stem: '', options: ['', '', '', ''], score: 100, answer: '', explanation: '' });
|
||||||
@@ -107,14 +181,36 @@ const rebalanceScores = () => {
|
|||||||
const base = Math.floor(100 / count);
|
const base = Math.floor(100 / count);
|
||||||
questions.value.forEach((item, index) => { item.score = index === count - 1 ? 100 - base * (count - 1) : base; });
|
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 addQuestion = () => {
|
||||||
const removeQuestion = (index: number) => { questions.value.splice(index,1); singleAnswerModels.value.splice(index,1); rebalanceScores(); };
|
questions.value.push(newQuestion());
|
||||||
|
singleAnswerModels.value.push('');
|
||||||
|
rebalanceScores();
|
||||||
|
expandedIndex.value = questions.value.length - 1;
|
||||||
|
};
|
||||||
|
const removeQuestion = (index: number) => {
|
||||||
|
questions.value.splice(index, 1);
|
||||||
|
singleAnswerModels.value.splice(index, 1);
|
||||||
|
rebalanceScores();
|
||||||
|
expandedIndex.value = Math.min(expandedIndex.value, questions.value.length - 1);
|
||||||
|
};
|
||||||
|
const toggleQuestion = (index: number) => {
|
||||||
|
expandedIndex.value = expandedIndex.value === index ? -1 : index;
|
||||||
|
};
|
||||||
const selectFormat = (index: number, event: { detail: { value: string } }) => {
|
const selectFormat = (index: number, event: { detail: { value: string } }) => {
|
||||||
const format = formats[Number(event.detail.value || 0)]?.key || 'single'; questions.value[index].format = format;
|
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 : ['', '', '', '']);
|
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 syncAnswer = (index: number) => {
|
||||||
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; };
|
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 selectAll = () => { selectedMemberIds.value = members.value.slice(0, 100).map((item) => item.extPartyId); };
|
||||||
const appendAiHint = (hint: string) => {
|
const appendAiHint = (hint: string) => {
|
||||||
const parts = aiHints.value.split(/[、,,\s]+/).map((value) => value.trim()).filter(Boolean);
|
const parts = aiHints.value.split(/[、,,\s]+/).map((value) => value.trim()).filter(Boolean);
|
||||||
@@ -122,16 +218,23 @@ const appendAiHint = (hint: string) => {
|
|||||||
aiHints.value = parts.join('、');
|
aiHints.value = parts.join('、');
|
||||||
};
|
};
|
||||||
const generateAiQuestions = async () => {
|
const generateAiQuestions = async () => {
|
||||||
if (saving.value || aiGenerating.value) return; aiGenerating.value = true; message.value = '';
|
if (saving.value || aiGenerating.value) return;
|
||||||
|
aiGenerating.value = true;
|
||||||
|
message.value = '';
|
||||||
try {
|
try {
|
||||||
const result = await generateSupervisorExamQuestions({ positionCode: positions[positionIndex.value], hints: aiHints.value.trim(), count: aiCount.value, formats: ['single', 'multiple', 'judge'] });
|
const result = await generateSupervisorExamQuestions({ positionCode: positions[positionIndex.value], hints: aiHints.value.trim(), count: aiCount.value, formats: ['single', 'multiple', 'judge'] });
|
||||||
questions.value = result.questions;
|
questions.value = result.questions;
|
||||||
singleAnswerModels.value = result.questions.map((item) => Array.isArray(item.answer) ? item.answer.join('|') : String(item.answer || ''));
|
singleAnswerModels.value = result.questions.map((item) => Array.isArray(item.answer) ? item.answer.join('|') : String(item.answer || ''));
|
||||||
|
expandedIndex.value = -1;
|
||||||
message.value = result.groundedFragments > 0
|
message.value = result.groundedFragments > 0
|
||||||
? `已生成 ${result.questions.length} 题(参考 ${result.groundedFragments} 条 SOP 片段),请逐题核对修改后再发布`
|
? `已生成 ${result.questions.length} 题(参考 ${result.groundedFragments} 条 SOP 片段),请逐题核对修改后再发布`
|
||||||
: `已生成 ${result.questions.length} 题,请逐题核对修改后再发布`;
|
: `已生成 ${result.questions.length} 题,请逐题核对修改后再发布`;
|
||||||
uni.pageScrollTo({ selector: '.question-editor', duration: 200 });
|
uni.pageScrollTo({ selector: '.question-panel', duration: 200 });
|
||||||
} catch (error) { message.value = error instanceof Error ? error.message : 'AI 出题失败,请稍后重试'; } finally { aiGenerating.value = false; }
|
} 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(),
|
||||||
@@ -150,16 +253,29 @@ const draft = (): SupervisorExamDraft => ({
|
|||||||
}))
|
}))
|
||||||
});
|
});
|
||||||
const save = async (publish: boolean) => {
|
const save = async (publish: boolean) => {
|
||||||
if (!valid.value || saving.value) return; saving.value = true; message.value = '';
|
if (!valid.value || saving.value) return;
|
||||||
|
saving.value = true;
|
||||||
|
message.value = '';
|
||||||
try {
|
try {
|
||||||
const saved = examId.value ? await updateSupervisorExam(examId.value, draft()) : await saveSupervisorExam(draft()); examId.value = saved.id;
|
const saved = examId.value ? await updateSupervisorExam(examId.value, draft()) : await saveSupervisorExam(draft());
|
||||||
if (publish) { await publishSupervisorExam(saved.id, requestId()); uni.showToast({ title: '考试已发布', icon: 'none' }); setTimeout(goBack,350); }
|
examId.value = saved.id;
|
||||||
else message.value = '草稿已保存';
|
if (publish) {
|
||||||
} catch (error) { message.value = error instanceof Error ? error.message : '考试保存失败'; } finally { saving.value = false; }
|
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 () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
const team = await getPracticeTeamSnapshot(); members.value = team.members || [];
|
const team = await getPracticeTeamSnapshot();
|
||||||
|
members.value = team.members || [];
|
||||||
if (examId.value) {
|
if (examId.value) {
|
||||||
const existing = await getSupervisorExam(examId.value);
|
const existing = await getSupervisorExam(examId.value);
|
||||||
title.value = existing.title;
|
title.value = existing.title;
|
||||||
@@ -169,37 +285,464 @@ const load = async () => {
|
|||||||
questions.value = existing.questions;
|
questions.value = existing.questions;
|
||||||
singleAnswerModels.value = existing.questions.map((item) => Array.isArray(item.answer) ? item.answer.join('|') : String(item.answer || ''));
|
singleAnswerModels.value = existing.questions.map((item) => Array.isArray(item.answer) ? item.answer.join('|') : String(item.answer || ''));
|
||||||
selectedMemberIds.value = existing.targets.map((item) => item.extPartyId);
|
selectedMemberIds.value = existing.targets.map((item) => item.extPartyId);
|
||||||
} else { dueDate.value = dateAfter(1); addQuestion(); }
|
expandedIndex.value = -1;
|
||||||
} catch (error) { message.value = error instanceof Error ? error.message : '考试配置加载失败'; }
|
} 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(); });
|
onLoad((options) => { examId.value = Number(options?.id || 0); void load(); });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.exam-edit-page { max-width: var(--mobile-shell-max-width); margin: 0 auto; padding-bottom: 190px; }
|
.exam-edit-page {
|
||||||
.status-message { display:block; margin-top:12px; color:#a9000d; }
|
padding-bottom: 190px;
|
||||||
.form-panel,.question-editor { display:grid; gap:10px; }
|
background: linear-gradient(180deg, var(--employee-bg-soft) 0%, var(--employee-bg) 100%);
|
||||||
.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; }
|
.subpage-header {
|
||||||
.quick-row,.member-actions,.section-head,.question-block-head { display:flex; gap:8px; align-items:center; justify-content:space-between; }
|
align-items: center;
|
||||||
.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; white-space:nowrap; }
|
|
||||||
.member-list { max-height:260px; margin-top:10px; }
|
.status-message {
|
||||||
.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; }
|
display: block;
|
||||||
.member-row.selected { background:var(--employee-primary-soft); }
|
margin-top: 12px;
|
||||||
.member-check { flex:0 0 24px; color:var(--brand); font-weight:900; }
|
color: #a9000d;
|
||||||
.compact-button { min-height:36px; padding:0 10px; font-size:12px; }
|
font-size: 13px;
|
||||||
.question-block { display:grid; gap:9px; padding:14px 0; border-top:1px solid var(--line); }
|
line-height: 1.5;
|
||||||
.question-block-head { font-weight:900; }
|
}
|
||||||
.remove-button { background:transparent; color:var(--brand); font-size:13px; }
|
|
||||||
.stem-input { min-height:96px; }
|
.info-panel,
|
||||||
.compact-textarea { min-height:82px; }
|
.target-panel,
|
||||||
.score-warning { color:#a86100; font-size:12px; font-weight:800; }
|
.ai-panel,
|
||||||
.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); }
|
.question-panel {
|
||||||
.ai-panel { display:grid; gap:10px; }
|
margin-top: 14px;
|
||||||
.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; }
|
.section-head {
|
||||||
.ai-generate-button { display:flex; align-items:center; justify-content:center; min-height:44px; line-height:1; }
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-button {
|
||||||
|
display: inline-flex;
|
||||||
|
min-height: 32px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 8px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--employee-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-button::after {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 46px;
|
||||||
|
border-bottom: 1px solid #e5edf7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row.last {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: var(--employee-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-input {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
color: var(--employee-text);
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-picker {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
color: var(--employee-text);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-arrow {
|
||||||
|
color: #c0c4cc;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-suffix {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: var(--employee-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-input {
|
||||||
|
flex: 0 1 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-count {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8px;
|
||||||
|
color: var(--employee-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-list {
|
||||||
|
max-height: 190px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-chip-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-option {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 38px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid #dbe4f0;
|
||||||
|
border-radius: 19px;
|
||||||
|
background: #fff;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-option::after {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-option.selected {
|
||||||
|
border-color: var(--employee-primary);
|
||||||
|
background: var(--employee-primary-soft);
|
||||||
|
color: var(--employee-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-check {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin-right: 6px;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-option.selected .member-check {
|
||||||
|
border-color: var(--employee-primary);
|
||||||
|
background: var(--employee-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-name {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block,
|
||||||
|
.block-sm {
|
||||||
|
display: block;
|
||||||
|
color: var(--employee-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block-sm {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-hints-input {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-chip-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 7px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-chip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 32px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid #d7e0eb;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: var(--employee-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-chip::after {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-count-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count-chip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 36px;
|
||||||
|
flex: 1;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid #d7e0eb;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: var(--employee-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count-chip::after {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count-chip.active {
|
||||||
|
border-color: #ef9da5;
|
||||||
|
background: var(--employee-primary-soft);
|
||||||
|
color: var(--employee-primary);
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-generate-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 44px;
|
||||||
|
margin: 12px 0 0;
|
||||||
|
border: 1px solid var(--brand);
|
||||||
|
border-radius: 22px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: var(--brand);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-generate-button::after {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-generate-button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-summary {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8px;
|
||||||
|
color: var(--employee-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-warning {
|
||||||
|
display: block;
|
||||||
|
margin-top: 6px;
|
||||||
|
color: #a86100;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-list {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-item {
|
||||||
|
border-bottom: 1px solid #e5edf7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-item:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-stem {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
flex: 1;
|
||||||
|
color: var(--employee-text);
|
||||||
|
font-size: 13px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-meta {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: var(--employee-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-arrow {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: #c0c4cc;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-editor {
|
||||||
|
display: grid;
|
||||||
|
gap: 9px;
|
||||||
|
padding: 0 0 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stem-input {
|
||||||
|
min-height: 88px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-textarea {
|
||||||
|
min-height: 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 110px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-value {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-field {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-suffix {
|
||||||
|
position: absolute;
|
||||||
|
right: 12px;
|
||||||
|
color: var(--employee-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-button {
|
||||||
|
justify-self: end;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 4px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--brand);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-button::after {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 #e5edf7;
|
||||||
|
background: rgba(255, 255, 255, 0.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dock-primary,
|
||||||
|
.dock-secondary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 46px;
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 23px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dock-primary::after,
|
||||||
|
.dock-secondary::after {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dock-primary {
|
||||||
|
border: 0;
|
||||||
|
background: var(--brand);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dock-primary:disabled,
|
||||||
|
.dock-secondary:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dock-secondary {
|
||||||
|
border: 1px solid var(--brand);
|
||||||
|
background: #ffffff;
|
||||||
|
color: var(--brand);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,51 +1,398 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="page supervisor-exams-page">
|
<view class="page supervisor-exams-page">
|
||||||
<view class="subpage-header">
|
<view class="subpage-header">
|
||||||
<button class="subpage-back-button" aria-label="返回主管首页" @click="goBack"><uni-icons type="back" size="20" color="#25282d" /></button>
|
<button class="subpage-back-button" aria-label="返回主管首页" title="返回主管首页" @click="goBack"><uni-icons type="back" size="20" color="#25282d" /></button>
|
||||||
<view class="subpage-heading"><text class="page-title">岗前测试</text><text class="page-subtitle">按岗位组卷、选择真实员工并查看完成情况。</text></view>
|
<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>
|
</view>
|
||||||
|
|
||||||
|
<button class="create-button" @click="createExam">
|
||||||
|
<text class="create-button-plus">+</text>
|
||||||
|
<text>新建考试</text>
|
||||||
|
</button>
|
||||||
<text v-if="message" class="status-message">{{ message }}</text>
|
<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 class="exam-stats">
|
||||||
<view v-for="item in exams" :key="item.id" class="panel exam-card">
|
<view class="exam-stat">
|
||||||
<view class="exam-head"><text class="panel-title">{{ item.title }}</text><text class="status-chip">{{ item.status }}</text></view>
|
<text class="exam-stat-value active">{{ statusCounts.active }}</text>
|
||||||
<text class="muted">{{ item.positionCode || '通用岗位' }} · {{ item.questionCount }} 题 · {{ item.targetCount }} 人 · 及格 {{ item.passingScore }} 分</text>
|
<text class="exam-stat-label">进行中</text>
|
||||||
<text v-if="item.dueDate" class="muted">截止 {{ item.dueDate }}</text>
|
</view>
|
||||||
|
<view class="exam-stat">
|
||||||
|
<text class="exam-stat-value draft">{{ statusCounts.draft }}</text>
|
||||||
|
<text class="exam-stat-label">待发布</text>
|
||||||
|
</view>
|
||||||
|
<view class="exam-stat">
|
||||||
|
<text class="exam-stat-value">{{ statusCounts.ended }}</text>
|
||||||
|
<text class="exam-stat-label">已结束</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="panel exam-list-panel">
|
||||||
|
<view class="exam-tabs" role="group" aria-label="考试状态筛选">
|
||||||
|
<button
|
||||||
|
v-for="tab in statusTabs"
|
||||||
|
:key="tab.key"
|
||||||
|
class="exam-tab"
|
||||||
|
:class="{ active: statusFilter === tab.key }"
|
||||||
|
@click="statusFilter = tab.key"
|
||||||
|
>{{ tab.label }}</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="loading" class="empty compact-empty">考试列表加载中…</view>
|
||||||
|
<template v-else-if="filteredExams.length">
|
||||||
|
<view v-for="item in filteredExams" :key="item.id" class="exam-card">
|
||||||
|
<view class="exam-head">
|
||||||
|
<text class="exam-title">{{ item.title }}</text>
|
||||||
|
<text class="exam-badge" :class="examState(item).key">{{ examState(item).label }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="exam-meta">{{ item.positionCode || '通用岗位' }} · {{ item.questionCount }}题 · {{ item.targetCount }}人 · 及格{{ item.passingScore }}分</text>
|
||||||
|
<template v-if="examState(item).key === 'active'">
|
||||||
|
<text class="exam-due">{{ dueDateLabel(item.dueDate) }}</text>
|
||||||
|
<view class="exam-progress-row">
|
||||||
|
<text class="exam-progress-text">完成{{ item.submissionCount }}/{{ item.targetCount }}</text>
|
||||||
|
<view class="exam-progress-track">
|
||||||
|
<view class="exam-progress-fill" :style="{ width: `${progressPercent(item)}%` }"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
<view class="exam-actions">
|
<view class="exam-actions">
|
||||||
<button class="secondary-button" :disabled="item.status !== 'DRAFT'" @click="editExam(item.id)">编辑</button>
|
<button v-if="examState(item).key === 'draft'" class="exam-link" @click="editExam(item.id)">继续编辑<text class="exam-link-arrow">›</text></button>
|
||||||
<button class="ghost-button" @click="viewResults(item.id)">查看结果</button>
|
<button v-else class="exam-link" @click="viewResults(item.id)">查看结果<text class="exam-link-arrow">›</text></button>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
</template>
|
||||||
|
<view v-else class="empty compact-empty">{{ emptyLabel }}</view>
|
||||||
</view>
|
</view>
|
||||||
<view v-else class="panel empty">暂无考试,先创建岗位题目并派发。</view>
|
|
||||||
<SupervisorTabBar active="team" />
|
<SupervisorTabBar active="team" />
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { onShow } from '@dcloudio/uni-app';
|
import { onShow } from '@dcloudio/uni-app';
|
||||||
import type { ExamSummary } from '@/types/api';
|
import type { ExamSummary } from '@/types/api';
|
||||||
import { listSupervisorExams } from '@/services/exam';
|
import { listSupervisorExams } from '@/services/exam';
|
||||||
import SupervisorTabBar from '@/components/SupervisorTabBar.vue';
|
import SupervisorTabBar from '@/components/SupervisorTabBar.vue';
|
||||||
const exams = ref<ExamSummary[]>([]); const loading = ref(false); const message = ref('');
|
|
||||||
|
type ExamStateKey = 'active' | 'draft' | 'ended';
|
||||||
|
|
||||||
|
const exams = ref<ExamSummary[]>([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const message = ref('');
|
||||||
|
const statusTabs: Array<{ key: 'all' | ExamStateKey; label: string }> = [
|
||||||
|
{ key: 'all', label: '全部' },
|
||||||
|
{ key: 'active', label: '进行中' },
|
||||||
|
{ key: 'draft', label: '待发布' },
|
||||||
|
{ key: 'ended', label: '已结束' }
|
||||||
|
];
|
||||||
|
const statusFilter = ref<'all' | ExamStateKey>('all');
|
||||||
|
|
||||||
|
const todayStr = () => {
|
||||||
|
const now = new Date();
|
||||||
|
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const examState = (item: ExamSummary): { key: ExamStateKey; label: string } => {
|
||||||
|
if (item.status === 'DRAFT') return { key: 'draft', label: '待发布' };
|
||||||
|
const due = item.dueDate?.slice(0, 10) || '';
|
||||||
|
if (due && due < todayStr()) return { key: 'ended', label: '已结束' };
|
||||||
|
return { key: 'active', label: '进行中' };
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusCounts = computed(() => exams.value.reduce<Record<ExamStateKey, number>>((counts, item) => {
|
||||||
|
counts[examState(item).key] += 1;
|
||||||
|
return counts;
|
||||||
|
}, { active: 0, draft: 0, ended: 0 }));
|
||||||
|
|
||||||
|
const filteredExams = computed(() => (statusFilter.value === 'all'
|
||||||
|
? exams.value
|
||||||
|
: exams.value.filter((item) => examState(item).key === statusFilter.value)));
|
||||||
|
|
||||||
|
const emptyLabel = computed(() => (statusFilter.value === 'all' ? '暂无考试,先创建岗位题目并派发。' : '当前状态下暂无考试'));
|
||||||
|
|
||||||
|
const dueDateLabel = (value?: string | null) => {
|
||||||
|
const due = value?.slice(0, 10) || '';
|
||||||
|
if (!due) return '长期有效';
|
||||||
|
const [, month, day] = due.split('-');
|
||||||
|
return `截止${Number(month)}月${Number(day)}日`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const progressPercent = (item: ExamSummary) => {
|
||||||
|
if (!item.targetCount) return 0;
|
||||||
|
return Math.min(100, Math.round((item.submissionCount / item.targetCount) * 100));
|
||||||
|
};
|
||||||
|
|
||||||
const goBack = () => uni.redirectTo({ url: '/pages/supervisor/index/index' });
|
const goBack = () => uni.redirectTo({ url: '/pages/supervisor/index/index' });
|
||||||
const createExam = () => uni.navigateTo({ url: '/pages/supervisor/exams/edit' });
|
const createExam = () => uni.navigateTo({ url: '/pages/supervisor/exams/edit' });
|
||||||
const editExam = (id: number) => uni.navigateTo({ url: `/pages/supervisor/exams/edit?id=${id}` });
|
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 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; } };
|
|
||||||
|
const load = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
message.value = '';
|
||||||
|
try {
|
||||||
|
exams.value = await listSupervisorExams();
|
||||||
|
} catch (error) {
|
||||||
|
message.value = error instanceof Error ? error.message : '考试列表读取失败';
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
onShow(() => { void load(); });
|
onShow(() => { void load(); });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.supervisor-exams-page { max-width: var(--mobile-shell-max-width); margin: 0 auto; }
|
.supervisor-exams-page {
|
||||||
.create-button { width: 100%; margin-top: 18px; }
|
padding-bottom: 112px;
|
||||||
.status-message { display: block; margin-top: 12px; color: #a9000d; }
|
background: linear-gradient(180deg, var(--employee-bg-soft) 0%, var(--employee-bg) 100%);
|
||||||
.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; }
|
.subpage-header {
|
||||||
.status-chip { padding: 4px 8px; border-radius: 999px; background: #eef3f9; color: var(--knowledge); font-size: 12px; }
|
align-items: center;
|
||||||
.exam-actions button { flex: 1; min-width: 0; }
|
}
|
||||||
.empty { color: var(--muted); }
|
|
||||||
|
.status-message {
|
||||||
|
display: block;
|
||||||
|
margin-top: 12px;
|
||||||
|
color: #a9000d;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 46px;
|
||||||
|
margin: 16px 0 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--brand);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-button::after {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-button-plus {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-stats {
|
||||||
|
display: grid;
|
||||||
|
margin-top: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #e5edf7;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #ffffff;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-stat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 13px 8px;
|
||||||
|
border-right: 1px solid #e5edf7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-stat:last-child {
|
||||||
|
border-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-stat-value {
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 23px;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-stat-value.active {
|
||||||
|
color: var(--employee-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-stat-value.draft {
|
||||||
|
color: #d97706;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-stat-label {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-list-panel {
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 18px;
|
||||||
|
border-bottom: 1px solid #e5edf7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-tab {
|
||||||
|
position: relative;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 2px 10px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--employee-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-tab::after {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-tab.active {
|
||||||
|
color: var(--employee-primary);
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-tab.active::before {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: -1px;
|
||||||
|
left: 0;
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--employee-primary);
|
||||||
|
content: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-card {
|
||||||
|
padding: 14px 0;
|
||||||
|
border-bottom: 1px solid #e5edf7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-card:last-child {
|
||||||
|
padding-bottom: 2px;
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-title {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--employee-text);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 900;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-badge {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-badge.active {
|
||||||
|
background: var(--employee-primary-soft);
|
||||||
|
color: var(--employee-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-badge.draft {
|
||||||
|
background: #fff7ed;
|
||||||
|
color: #d97706;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-badge.ended {
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-meta,
|
||||||
|
.exam-due {
|
||||||
|
display: block;
|
||||||
|
margin-top: 7px;
|
||||||
|
color: var(--employee-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-progress-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-progress-text {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: var(--employee-text);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-progress-track {
|
||||||
|
height: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
flex: 1;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #eef2f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 2px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--employee-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-link::after {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-link-arrow {
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-empty {
|
||||||
|
padding: 22px 8px 12px;
|
||||||
|
color: var(--employee-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,29 +1,393 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="page exam-results-page">
|
<view class="page exam-results-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">考试结果</text><text class="page-subtitle">按人员查看成绩、未完成与通过状态。</text></view></view>
|
<view class="subpage-header">
|
||||||
|
<button class="subpage-back-button" aria-label="返回" title="返回" @click="goBack"><uni-icons type="back" size="20" color="#25282d" /></button>
|
||||||
|
<view class="subpage-heading">
|
||||||
|
<text class="page-title">考试结果</text>
|
||||||
|
<text class="page-subtitle">按人员查看成绩与通过状态</text>
|
||||||
|
</view>
|
||||||
|
<button v-if="results.length" class="export-button" @click="exportResults">导出结果</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
<text v-if="message" class="status-message">{{ message }}</text>
|
<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 v-if="examTitle" class="exam-name">{{ examTitle }}</view>
|
||||||
<view class="metric-card"><text>{{ completed }}</text><label>已完成</label></view>
|
|
||||||
<view class="metric-card"><text>{{ passed }}</text><label>已通过</label></view>
|
<view class="results-summary">
|
||||||
|
<view class="summary-cells">
|
||||||
|
<view class="summary-cell">
|
||||||
|
<text class="summary-value assigned">{{ totalCount }}</text>
|
||||||
|
<text class="summary-label">已派发</text>
|
||||||
</view>
|
</view>
|
||||||
<view v-if="results.length" class="result-list">
|
<view class="summary-cell">
|
||||||
<view v-for="item in results" :key="item.attemptId || item.extPartyId" class="panel result-row">
|
<text class="summary-value">{{ completedCount }}</text>
|
||||||
<view><text class="result-name">{{ item.traineeName }}</text><text class="muted">{{ item.submittedTime || '尚未提交' }} · {{ item.status === 'PENDING' ? '待完成' : '已提交' }}</text></view>
|
<text class="summary-label">已完成</text>
|
||||||
<view class="result-score"><text>{{ item.score == null ? '--' : `${item.score}分` }}</text><label>{{ item.status === 'PENDING' ? '未完成' : item.passed ? '通过' : '未通过' }}</label></view>
|
</view>
|
||||||
|
<view class="summary-cell">
|
||||||
|
<text class="summary-value passed">{{ passedCount }}</text>
|
||||||
|
<text class="summary-label">已通过</text>
|
||||||
|
</view>
|
||||||
|
<view class="summary-cell">
|
||||||
|
<text class="summary-label ratio-label">完成率</text>
|
||||||
|
<text class="summary-value ratio">{{ completionRate }}%</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view v-else class="panel empty">暂无派发结果</view>
|
<view class="summary-track">
|
||||||
|
<view class="summary-fill" :style="{ width: `${completionRate}%` }"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="result-filters" role="group" aria-label="结果筛选">
|
||||||
|
<button
|
||||||
|
v-for="tab in filterTabs"
|
||||||
|
:key="tab.key"
|
||||||
|
class="filter-chip"
|
||||||
|
:class="{ active: resultFilter === tab.key }"
|
||||||
|
@click="resultFilter = tab.key"
|
||||||
|
>{{ tab.label }}</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="filteredResults.length" class="panel result-panel">
|
||||||
|
<view v-for="item in filteredResults" :key="item.attemptId || item.extPartyId" class="result-row">
|
||||||
|
<view class="result-avatar">
|
||||||
|
<uni-icons type="person" size="20" color="#9aa0a6" />
|
||||||
|
</view>
|
||||||
|
<view class="result-main">
|
||||||
|
<text class="result-name">{{ item.traineeName || '未命名员工' }}</text>
|
||||||
|
<text class="result-sub">{{ resultSub(item) }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="result-score">{{ item.score == null ? '--' : `${item.score}分` }}</text>
|
||||||
|
<text class="result-badge" :class="statusClass(item)">{{ statusLabel(item) }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view v-else-if="!loading" class="panel empty-panel">
|
||||||
|
<view class="empty">暂无符合筛选的派发结果</view>
|
||||||
|
</view>
|
||||||
<SupervisorTabBar active="team" />
|
<SupervisorTabBar active="team" />
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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';
|
import { computed, ref } from '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);
|
import { onLoad } from '@dcloudio/uni-app';
|
||||||
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();});
|
import type { ExamResultItem } from '@/types/api';
|
||||||
|
import { getSupervisorExam, getSupervisorExamResults } from '@/services/exam';
|
||||||
|
import SupervisorTabBar from '@/components/SupervisorTabBar.vue';
|
||||||
|
|
||||||
|
type ResultFilter = 'all' | 'passed' | 'failed' | 'pending';
|
||||||
|
|
||||||
|
const id = ref(0);
|
||||||
|
const examTitle = ref('');
|
||||||
|
const results = ref<ExamResultItem[]>([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const message = ref('');
|
||||||
|
const filterTabs: Array<{ key: ResultFilter; label: string }> = [
|
||||||
|
{ key: 'all', label: '全部' },
|
||||||
|
{ key: 'passed', label: '已通过' },
|
||||||
|
{ key: 'failed', label: '未通过' },
|
||||||
|
{ key: 'pending', label: '未完成' }
|
||||||
|
];
|
||||||
|
const resultFilter = ref<ResultFilter>('all');
|
||||||
|
|
||||||
|
const isPending = (item: ExamResultItem) => item.status === 'PENDING' || item.score == null;
|
||||||
|
const isPassed = (item: ExamResultItem) => !isPending(item) && item.passed === true;
|
||||||
|
|
||||||
|
const totalCount = computed(() => results.value.length);
|
||||||
|
const completedCount = computed(() => results.value.filter((item) => !isPending(item)).length);
|
||||||
|
const passedCount = computed(() => results.value.filter(isPassed).length);
|
||||||
|
const completionRate = computed(() => (totalCount.value ? Math.round((completedCount.value / totalCount.value) * 100) : 0));
|
||||||
|
|
||||||
|
const filteredResults = computed(() => results.value.filter((item) => {
|
||||||
|
if (resultFilter.value === 'passed') return isPassed(item);
|
||||||
|
if (resultFilter.value === 'failed') return !isPending(item) && !isPassed(item);
|
||||||
|
if (resultFilter.value === 'pending') return isPending(item);
|
||||||
|
return true;
|
||||||
|
}));
|
||||||
|
|
||||||
|
const statusLabel = (item: ExamResultItem) => (isPending(item) ? '未完成' : isPassed(item) ? '通过' : '未通过');
|
||||||
|
const statusClass = (item: ExamResultItem) => (isPending(item) ? 'pending' : isPassed(item) ? 'passed' : 'failed');
|
||||||
|
|
||||||
|
const formatSubmitted = (value: string | null) => {
|
||||||
|
if (!value) return '';
|
||||||
|
const time = new Date(value.replace(/-/g, '/'));
|
||||||
|
if (Number.isNaN(time.getTime())) return value;
|
||||||
|
const hhmm = `${String(time.getHours()).padStart(2, '0')}:${String(time.getMinutes()).padStart(2, '0')}`;
|
||||||
|
const now = new Date();
|
||||||
|
const startOf = (date: Date) => new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
|
||||||
|
const dayDiff = Math.round((startOf(now) - startOf(time)) / 86400000);
|
||||||
|
if (dayDiff === 0) return `今天${hhmm}`;
|
||||||
|
if (dayDiff === 1) return `昨天${hhmm}`;
|
||||||
|
return `${time.getMonth() + 1}月${time.getDate()}日 ${hhmm}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resultSub = (item: ExamResultItem) => (isPending(item) ? '尚未提交' : `${formatSubmitted(item.submittedTime)} · 已提交`);
|
||||||
|
|
||||||
|
const exportResults = () => {
|
||||||
|
if (!results.value.length) return;
|
||||||
|
const rows: string[][] = [['姓名', '状态', '分数', '提交时间']];
|
||||||
|
results.value.forEach((item) => {
|
||||||
|
rows.push([
|
||||||
|
item.traineeName || '未命名员工',
|
||||||
|
statusLabel(item),
|
||||||
|
item.score == null ? '' : String(item.score),
|
||||||
|
item.submittedTime || ''
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
const csv = '' + rows
|
||||||
|
.map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(','))
|
||||||
|
.join('\n');
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = URL.createObjectURL(blob);
|
||||||
|
link.download = `${examTitle.value || '考试结果'}-结果.csv`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(link.href);
|
||||||
|
};
|
||||||
|
|
||||||
|
const goBack = () => uni.navigateBack({ delta: 1, fail: () => uni.redirectTo({ url: '/pages/supervisor/exams/index' }) });
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
message.value = '';
|
||||||
|
try {
|
||||||
|
const [detail, items] = await Promise.all([getSupervisorExam(id.value), getSupervisorExamResults(id.value)]);
|
||||||
|
examTitle.value = detail.title || '';
|
||||||
|
results.value = items;
|
||||||
|
} catch (error) {
|
||||||
|
message.value = error instanceof Error ? error.message : '考试结果读取失败';
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onLoad((options) => {
|
||||||
|
id.value = Number(options?.id || 0);
|
||||||
|
void load();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<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)}
|
.exam-results-page {
|
||||||
|
padding-bottom: 112px;
|
||||||
|
background: linear-gradient(180deg, var(--employee-bg-soft) 0%, var(--employee-bg) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subpage-header {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-button {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 4px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--employee-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.4;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-button::after {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-message {
|
||||||
|
display: block;
|
||||||
|
margin-top: 12px;
|
||||||
|
color: #a9000d;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-name {
|
||||||
|
margin-top: 14px;
|
||||||
|
color: var(--employee-text);
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-summary {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid #e5edf7;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-cells {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-value {
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-value.assigned {
|
||||||
|
color: var(--employee-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-value.passed {
|
||||||
|
color: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-value.ratio {
|
||||||
|
color: var(--employee-primary);
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-label {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ratio-label {
|
||||||
|
order: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-track {
|
||||||
|
height: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #eef2f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-chip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 32px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 14px;
|
||||||
|
border: 1px solid #dbe4f0;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: var(--employee-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-chip::after {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-chip.active {
|
||||||
|
border-color: var(--employee-primary);
|
||||||
|
background: var(--employee-primary);
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-panel {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 13px 0;
|
||||||
|
border-bottom: 1px solid #e5edf7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-row:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-avatar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #eef0f3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-main {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-name {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--employee-text);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 900;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-sub {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--employee-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-score {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: 44px;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 900;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-badge {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: 52px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.3;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-badge.passed {
|
||||||
|
background: #f0fdf4;
|
||||||
|
color: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-badge.failed {
|
||||||
|
background: var(--employee-primary-soft);
|
||||||
|
color: var(--employee-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-badge.pending {
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-panel {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user