新增答题模块:题库管理、按题型抽题、判分、成绩/排行、错题重做与学习模式

- 后端 routers/apiQuiz.go:题库/题目 CRUD(quiz_admin)、按题型随机抽题(单选20/多选10/判断20/填空10,不足全抽)、整卷时限、服务端宽松判分(填空忽略空格大小写,|多变体;多选全对得分)、成绩历史/按题库排行榜、错题重做(判分不落库)、学习模式分页(含答案/解析/录入人)、题库作者与头像
- 前端 quiz 模块:题库卡片页(/questions)、独立管理页(/questions/manage)、答题页(/quiz/play 左右布局+答题卡)、成绩解析/错题重做(/quiz/redo/:id)、学习页(/quiz/learn 瀑布流+阅读位置保存恢复+左右布局)
- 权限:isQuizAdmin 兼容系统管理员、/questions/manage 守卫、导航入口与题库管理分流
- i18n 中英文补全;数据表 AutoMigrate 自动建列
This commit is contained in:
2026-09-09 19:21:51 +08:00
parent 086e8f524f
commit f186524476
17 files changed
+5123 -5

No files matched your search

+8 -1
View File
@@ -20,7 +20,14 @@
"file_not_found":-57,
"file_part_err":-58,
"schedule_event_not_find":-61,
"schedule_permission_denied":-62
"schedule_permission_denied":-62,
"quiz_no_questions":-71,
"quiz_question_not_found":-72,
"quiz_session_not_found":-73,
"quiz_permission_denied":-74,
"quiz_session_finished":-75,
"quiz_answer_format_err":-76,
"quiz_bank_not_found":-77
}
+1
View File
@@ -82,6 +82,7 @@ func main() {
routers.ApiWarehouseInit()
routers.ApiCustomerInit()
routers.ApiCalendarInit()
routers.ApiQuizInit()
routers.ApiAIChatInit()
routers.BindsInit() //最后初始化绑定数据表
+1
View File
@@ -46,6 +46,7 @@ func ApiRoot(r *gin.RouterGroup) {
ApiSysAdmin(r.Group("/admin"))
ApiCustomer(r.Group("/customer"))
ApiCalendar(r.Group("/calendar"))
ApiQuiz(r.Group("/quiz"))
ApiAIChat(r.Group("/aichat"))
r.GET("/", func(ctx *gin.Context) {
ReturnJson(ctx, "apiOK", gin.H{
File diff suppressed because it is too large. Load diff
+68
View File
@@ -0,0 +1,68 @@
import { api } from './index'
export const quizApi = {
/** 新增题库 */
addBank(data) {
return api.post('/quiz/bank/add', data)
},
/** 修改题库 */
updateBank(data) {
return api.post('/quiz/bank/update', data)
},
/** 删除题库 */
deleteBank(id) {
return api.post('/quiz/bank/delete', { id })
},
/** 题库列表(管理) */
listBanks(params = {}) {
return api.post('/quiz/banks/list', params)
},
/** 用户可用题库列表 */
availableBanks(params = {}) {
return api.post('/quiz/banks/available', params)
},
/** 学习模式:分页取题目(含答案与解析) */
learnBank(bankId, page = 1, pageSize = 10) {
return api.post('/quiz/bank/learn', { bankId, page, pageSize })
},
/** 新增题目 */
addQuestion(data) {
return api.post('/quiz/question/add', data)
},
/** 修改题目 */
updateQuestion(data) {
return api.post('/quiz/question/update', data)
},
/** 删除题目 */
deleteQuestion(id) {
return api.post('/quiz/question/delete', { id })
},
/** 题目列表(题库管理) */
listQuestions(params = {}) {
return api.post('/quiz/questions/list', params)
},
/** 开始答题 */
startQuiz(bankId) {
return api.post('/quiz/start', { bankId })
},
/** 提交答题 */
submitQuiz(sessionId, durationSec, answers) {
return api.post('/quiz/submit', { sessionId, durationSec, answers })
},
/** 我的成绩历史 */
mySessions(params = {}) {
return api.post('/quiz/sessions', params)
},
/** 答题回顾详情 */
getSession(id) {
return api.post('/quiz/session', { id })
},
/** 错题重做判分(仅判分不落库) */
redoQuiz(sessionId, durationSec, answers) {
return api.post('/quiz/redo', { sessionId, durationSec, answers })
},
/** 排行榜 */
leaderboard(params = {}) {
return api.post('/quiz/leaderboard', params)
},
}
@@ -59,6 +59,7 @@ const navItems = computed(() => [
{ label: t("appname.purchase"), to: "/purchase" },
{ label: t("appname.work_order"), to: "/work_order" },
{ label: t("appname.warehouse"), to: "/warehouse" },
{ label: t("appname.quiz_bank"), to: "/questions" },
{ label: t("appname.ae_proxy"), href: "http://192.168.3.116:8187/asteamobile/", external: true },
]);
</script>
+150 -2
View File
@@ -39,7 +39,10 @@
"calendar": "Calendar",
"calendar_stream": "Stream Calendar",
"aichat": "AI Assistant",
"ae_proxy": "AE Proxy"
"ae_proxy": "AE Proxy",
"quiz": "Quiz",
"quiz_questions": "Question Bank",
"quiz_bank": "Quiz Bank"
},
"aichat": {
"title": "AI Assistant",
@@ -241,7 +244,7 @@
"commit_create": "Order created",
"edit_order": "Edit Order",
"repurchase": "Repurchase",
"submit_changes":"Submit changes",
"submit_changes": "Submit changes",
"confirm_delete_commit": "Are you sure you want to delete this progress?"
},
"work_order": {
@@ -803,5 +806,150 @@
"drag_hint": "Drag event to change date",
"all_day": "All day",
"auto_scroll_today": "Auto-scroll to today daily"
},
"quiz": {
"tab_play": "Take Quiz",
"tab_history": "My Scores",
"tab_leaderboard": "Leaderboard",
"start_title": "Take a Quiz",
"start_btn": "Start Quiz",
"no_questions": "No questions available yet",
"type_single": "Single Choice",
"type_multiple": "Multiple Choice",
"type_blank": "Fill in the Blank",
"type_judge": "True / False",
"points": "pts",
"blank": "Blank",
"blank_placeholder": "Your answer",
"true": "True",
"false": "False",
"submit": "Submit",
"submit_confirm": "Are you sure you want to submit?",
"submit_confirm_title": "Submit quiz",
"question": "Question",
"questions_title": "Questions",
"score": "Score",
"total_score": "Total Score",
"correct": "Correct",
"wrong": "Wrong",
"duration": "Duration",
"seconds": "s",
"date": "Date",
"no_answer": "No answer",
"your_answer": "Your answer",
"correct_answer": "Correct answer",
"review": "Review",
"review_empty": "No review data",
"result_pass": "Passed",
"result_fail": "Failed",
"history_empty": "No records yet",
"board_empty": "No records yet",
"rank": "Rank",
"player": "Player",
"best_score": "Best Score",
"attempts": "Attempts",
"last_at": "Last Attempt",
"session_finished": "This quiz has already been submitted",
"show": "Show",
"entries": "entries",
"total_items": "Total:",
"back_home": "Back to Quiz",
"banks_title": "Choose a Quiz Bank",
"bank": "Bank",
"draw_count": "Draw Count",
"time_limit": "Time Limit",
"remaining": "Remaining",
"elapsed": "Elapsed",
"questions_available": "Available",
"my_best": "My Best",
"no_banks": "No available quiz banks",
"bank_empty": "No questions yet",
"bank_unavailable": "Bank is unavailable",
"timeout": "Time is up, auto submitted",
"minutes": "m",
"unlimited": "No limit",
"permission_denied": "Permission denied",
"tab_banks": "Quiz Banks",
"explain": "Explanation",
"redo": "Redo",
"redo_title": "Redo wrong questions",
"redo_local_hint": "Local practice · score is NOT saved",
"redo_again": "Redo again",
"redo_none": "No wrong questions in this attempt",
"actions": "Actions",
"card_title": "Question card",
"answered_q": "Answered",
"learn": "Study",
"learn_title": "Study Mode",
"learn_empty": "No questions in this bank yet",
"loading_more": "Loading...",
"all_loaded": "All questions loaded"
},
"questions": {
"title": "Question Bank",
"add": "Add Question",
"edit": "Edit Question",
"type": "Type",
"question": "Question",
"options": "Options",
"answer": "Answer",
"score": "Score",
"active": "Active",
"creator": "Creator",
"created_at": "Created At",
"actions": "Actions",
"search": "Search",
"search_placeholder": "Search question...",
"filter_all": "All Types",
"type_single": "Single Choice",
"type_multiple": "Multiple Choice",
"type_blank": "Fill in the Blank",
"type_judge": "True / False",
"add_option": "Add Option",
"option_placeholder": "Option text",
"add_blank": "Add Blank",
"blank_hint": "One line per blank; separate accepted answers with | (e.g. color|colour)",
"blank_placeholder": "Accepted answers separated by |",
"title_placeholder": "Question content",
"correct": "Correct",
"delete": "Delete",
"delete_title": "Delete Question",
"delete_msg": "Are you sure you want to delete this question? \"{title}\"",
"empty": "No questions yet",
"err_title_required": "Please enter the question content",
"err_options_required": "At least 2 options are required",
"err_answer_required": "Please set the correct answer",
"err_answer_format": "Invalid answer format",
"explain": "Explanation",
"explain_placeholder": "Answer explanation (optional, shown in review)"
},
"banks": {
"title": "Quiz Banks",
"add": "New Bank",
"edit": "Edit Bank",
"name": "Name",
"description": "Description",
"name_placeholder": "Bank name",
"description_placeholder": "Brief description (optional)",
"question_count": "Questions",
"active": "Active",
"created_at": "Created At",
"actions": "Actions",
"delete": "Delete",
"search": "Search",
"search_placeholder": "Search bank name...",
"empty": "No banks yet",
"delete_title": "Delete Bank",
"delete_msg": "Deleting \"{name}\" will also delete all its questions. Continue?",
"err_name_required": "Please enter the bank name",
"draw_count_hint": "Random draw count per type; draws all of that type if fewer are available",
"duration_seconds_hint": "Seconds, 0 = no time limit",
"back": "Back to banks",
"manage_questions": "Manage Questions",
"single_short": "S",
"multiple_short": "M",
"judge_short": "J",
"blank_short": "B",
"manage": "Manage"
}
}
+150 -2
View File
@@ -39,7 +39,10 @@
"calendar": "日历",
"calendar_stream": "流式日历",
"aichat": "AI 助手",
"ae_proxy": "AE代理"
"ae_proxy": "AE代理",
"quiz": "答题",
"quiz_questions": "题库管理",
"quiz_bank": "题库"
},
"aichat": {
"title": "AI 助手",
@@ -241,7 +244,7 @@
"commit_create": "订单创建",
"edit_order": "编辑订单",
"repurchase": "再次采购",
"submit_changes":"提交修改",
"submit_changes": "提交修改",
"confirm_delete_commit": "确定要删除此进度吗?"
},
"work_order": {
@@ -804,5 +807,150 @@
"drag_hint": "拖动日程可改变日期",
"all_day": "全天",
"auto_scroll_today": "每日自动定位今天"
},
"quiz": {
"tab_play": "开始答题",
"tab_history": "我的成绩",
"tab_leaderboard": "排行榜",
"start_title": "开始答题",
"start_btn": "开始答题",
"no_questions": "暂时没有可用的题目",
"type_single": "单选题",
"type_multiple": "多选题",
"type_blank": "填空题",
"type_judge": "判断题",
"points": "分",
"blank": "填空",
"blank_placeholder": "请输入答案",
"true": "正确",
"false": "错误",
"submit": "提交",
"submit_confirm": "确定提交答案吗?",
"submit_confirm_title": "提交试卷",
"question": "题目",
"questions_title": "题数",
"score": "得分",
"total_score": "总分",
"correct": "答对",
"wrong": "答错",
"duration": "用时",
"seconds": "秒",
"date": "时间",
"no_answer": "未作答",
"your_answer": "你的答案",
"correct_answer": "正确答案",
"review": "答题解析",
"review_empty": "暂无解析数据",
"result_pass": "通过",
"result_fail": "未通过",
"history_empty": "暂无记录",
"board_empty": "暂无记录",
"rank": "排名",
"player": "用户",
"best_score": "最高分",
"attempts": "次数",
"last_at": "最近挑战",
"session_finished": "该试卷已提交,请勿重复提交",
"show": "显示",
"entries": "条",
"total_items": "共",
"back_home": "返回答题首页",
"banks_title": "选择题库",
"bank": "题库",
"draw_count": "抽题数",
"time_limit": "时限",
"remaining": "剩余",
"elapsed": "已用",
"questions_available": "题量",
"my_best": "我的最高分",
"no_banks": "暂无可用题库",
"bank_empty": "暂无题目",
"bank_unavailable": "题库已下架",
"timeout": "时间到,已自动提交",
"minutes": "分 ",
"unlimited": "不限时",
"permission_denied": "无权限操作",
"tab_banks": "题库列表",
"explain": "答案解析",
"redo": "错题重做",
"redo_title": "错题重做",
"redo_local_hint": "本地练习 · 成绩不保存",
"redo_again": "再练一次",
"redo_none": "该答卷没有错题",
"actions": "操作",
"card_title": "答题卡",
"answered_q": "已答",
"learn": "开始学习",
"learn_title": "学习模式",
"learn_empty": "该题库暂无题目",
"loading_more": "加载中…",
"all_loaded": "已加载全部"
},
"questions": {
"title": "题库管理",
"add": "新增题目",
"edit": "编辑题目",
"type": "题型",
"question": "题目",
"options": "选项",
"answer": "答案",
"score": "分值",
"active": "启用",
"creator": "创建人",
"created_at": "创建时间",
"actions": "操作",
"search": "搜索",
"search_placeholder": "搜索题目内容...",
"filter_all": "全部题型",
"type_single": "单选题",
"type_multiple": "多选题",
"type_blank": "填空题",
"type_judge": "判断题",
"add_option": "添加选项",
"option_placeholder": "选项内容",
"add_blank": "添加空格",
"blank_hint": "每行一个空;每个空用 | 分隔多种可接受答案,如 颜色|colour",
"blank_placeholder": "多种答案用 | 分隔",
"title_placeholder": "请输入题目内容",
"correct": "正确答案",
"delete": "删除",
"delete_title": "删除题目",
"delete_msg": "确认删除该题目?\"{title}\"",
"empty": "暂无题目",
"err_title_required": "请填写题目内容",
"err_options_required": "至少需要 2 个选项",
"err_answer_required": "请设置正确答案",
"err_answer_format": "答案格式不正确",
"explain": "答案解析",
"explain_placeholder": "答案解析(选填,答题回顾时显示)"
},
"banks": {
"title": "题库管理",
"add": "新增题库",
"edit": "编辑题库",
"name": "题库名称",
"description": "描述",
"name_placeholder": "请输入题库名称",
"description_placeholder": "简单描述(可选)",
"question_count": "题量",
"active": "启用",
"created_at": "创建时间",
"actions": "操作",
"delete": "删除",
"search": "搜索",
"search_placeholder": "搜索题库名称...",
"empty": "暂无题库",
"delete_title": "删除题库",
"delete_msg": "删除题库\"{name}\"将同时删除其下所有题目,确认继续?",
"err_name_required": "请填写题库名称",
"draw_count_hint": "每种题型随机抽取数量,题目不足则该题型全部抽取",
"duration_seconds_hint": "单位秒,0 表示不限时",
"back": "返回题库列表",
"manage_questions": "管理题目",
"single_short": "单",
"multiple_short": "多",
"judge_short": "判",
"blank_short": "填",
"manage": "题库管理"
}
}
+46
View File
@@ -166,6 +166,47 @@ const router = createRouter({
name: 'aichat',
component: () => import('@/views/aichat/AiChatView.vue'),
},
{
path: 'quiz',
redirect: '/questions',
},
{
path: 'quiz/play',
name: 'quiz-play',
component: () => import('@/views/quiz/QuizPlay.vue'),
},
{
path: 'quiz/learn',
name: 'quiz-learn',
component: () => import('@/views/quiz/QuizLearn.vue'),
},
{
path: 'quiz/result/:id',
name: 'quiz-result',
component: () => import('@/views/quiz/QuizResult.vue'),
},
{
path: 'quiz/redo/:id',
name: 'quiz-redo',
component: () => import('@/views/quiz/QuizRedo.vue'),
},
{
path: 'questions',
name: 'quiz-questions',
component: () => import('@/views/quiz/QuestionList.vue'),
},
{
path: 'questions/manage',
name: 'quiz-banks-manage',
component: () => import('@/views/quiz/ManageBanks.vue'),
meta: { requireQuizAdmin: true },
},
{
path: 'questions/bank/:id',
name: 'quiz-bank-questions',
component: () => import('@/views/quiz/BankQuestions.vue'),
meta: { requireQuizAdmin: true },
},
],
},
@@ -236,6 +277,11 @@ router.beforeEach((to) => {
return { name: 'home' }
}
// 需要题库管理员权限
if (to.meta.requireQuizAdmin && !userStore.isQuizAdmin) {
return { name: 'home' }
}
return true
})
+6
View File
@@ -69,6 +69,11 @@ export const useUserStore = defineStore('user', () => {
groups.value.some(g => g.name === 'calendar_admin')
)
// 是否为题库管理员(在 quiz_admin 群组中或系统管理员)
const isQuizAdmin = computed(() =>
isSysAdmin.value || groups.value.some(g => g.name === 'quiz_admin')
)
// 用户加入的群组名称列表(计算属性)
const groupNames = computed(() => groups.value.map(g => g.name))
@@ -136,6 +141,7 @@ export const useUserStore = defineStore('user', () => {
isLoggedIn,
isSysAdmin,
isCalendarAdmin,
isQuizAdmin,
groups,
groupNames,
cookieValue,
@@ -0,0 +1,702 @@
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useToastStore } from '@/stores/toast'
import { usePageTitle } from '@/composables/usePageTitle'
import { quizApi } from '@/api/quiz'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import {
IconPlus,
IconPencil,
IconTrash,
IconX,
IconChevronLeftPipe,
IconChevronRightPipe,
IconChevronsLeft,
IconChevronsRight,
IconArrowLeft,
} from '@tabler/icons-vue'
usePageTitle('appname.quiz_questions')
const { t, locale } = useI18n()
const route = useRoute()
const router = useRouter()
const toast = useToastStore()
const bankId = Number(route.params.id)
const bankInfo = ref(null)
const items = ref([])
const totalCount = ref(0)
const pageSize = ref(10)
const currentPage = ref(1)
const typeFilter = ref('')
const keyword = ref('')
const loading = ref(false)
const typeOptions = [
{ value: '', labelKey: 'questions.filter_all' },
{ value: 'single', labelKey: 'questions.type_single' },
{ value: 'multiple', labelKey: 'questions.type_multiple' },
{ value: 'blank', labelKey: 'questions.type_blank' },
{ value: 'judge', labelKey: 'questions.type_judge' },
]
const typeLabels = {
single: 'questions.type_single',
multiple: 'questions.type_multiple',
blank: 'questions.type_blank',
judge: 'questions.type_judge',
}
const typeColors = {
single: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400',
multiple: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-400',
blank: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-400',
judge: 'bg-teal-100 text-teal-700 dark:bg-teal-900/40 dark:text-teal-400',
}
const totalPages = computed(() => Math.ceil(totalCount.value / pageSize.value) || 1)
const pageRange = computed(() => {
const total = totalPages.value
const cur = currentPage.value
let start = Math.max(1, cur - 2)
let end = Math.min(cur + 4, total)
if (end - start < 4) start = Math.max(1, end - 4)
return Array.from({ length: end - start + 1 }, (_, i) => start + i)
})
async function fetchBankInfo() {
try {
const { errCode, data } = await quizApi.listBanks({ page: 1, pageSize: 100 })
if (errCode === 0) {
const found = (data.items ?? []).find(b => b.id === bankId)
if (found) bankInfo.value = found
}
} catch {
// 拦截器已处理
}
}
async function fetchQuestions() {
loading.value = true
try {
const { errCode, data } = await quizApi.listQuestions({
bankId,
type: typeFilter.value,
keyword: keyword.value,
page: currentPage.value,
pageSize: pageSize.value,
})
if (errCode === 0) {
items.value = data.items ?? []
totalCount.value = data.total ?? 0
} else if (errCode === -74) {
toast.error(t('quiz.permission_denied'))
router.replace('/questions')
} else {
toast.error(t('message.server_error'))
}
} catch {
// 拦截器已处理
} finally {
loading.value = false
}
}
function goToPage(page) {
if (page < 1 || page > totalPages.value) return
currentPage.value = page
fetchQuestions()
}
function handlePageSizeInput(e) {
let val = parseInt(e.target.value) || 10
if (val > 100) val = 100
if (val < 1) val = 1
pageSize.value = val
currentPage.value = 1
fetchQuestions()
}
function handleJumpPageInput(e) {
const val = parseInt(e.target.value)
if (val > 0 && val <= totalPages.value) {
currentPage.value = val
fetchQuestions()
}
}
function handleSearch() {
currentPage.value = 1
fetchQuestions()
}
function formatDate(dateStr) {
if (!dateStr) return ''
return new Intl.DateTimeFormat(locale.value, {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
}).format(new Date(dateStr))
}
// ── 新增/编辑弹窗 ──
const showModal = ref(false)
const saving = ref(false)
const editingId = ref(0)
const form = reactive({
type: 'single',
title: '',
options: ['', ''],
singleAnswer: null,
multipleAnswer: [],
blankAnswers: [''],
judgeAnswer: true,
explain: '',
score: 5,
active: true,
})
const isOptionType = computed(() => form.type === 'single' || form.type === 'multiple')
function openAdd() {
editingId.value = 0
form.type = 'single'
form.title = ''
form.options = ['', '']
form.singleAnswer = null
form.multipleAnswer = []
form.blankAnswers = ['']
form.judgeAnswer = true
form.explain = ''
form.score = 5
form.active = true
showModal.value = true
}
function openEdit(row) {
editingId.value = row.id
form.type = row.type
form.title = row.title
form.score = row.score
form.active = row.active
form.explain = row.explain || ''
form.options = row.options?.length ? [...row.options] : ['', '']
form.singleAnswer = null
form.multipleAnswer = []
form.blankAnswers = ['']
form.judgeAnswer = true
const ans = row.answer
if (row.type === 'single') {
form.singleAnswer = typeof ans === 'number' ? ans : parseInt(ans) || null
} else if (row.type === 'multiple') {
form.multipleAnswer = Array.isArray(ans) ? ans.map(Number) : []
} else if (row.type === 'blank') {
form.blankAnswers = Array.isArray(ans) && ans.length ? ans.map(String) : ['']
} else if (row.type === 'judge') {
form.judgeAnswer = ans === true || ans === 'true'
}
showModal.value = true
}
function toggleMultiple(i) {
const arr = form.multipleAnswer
const idx = arr.indexOf(i)
if (idx >= 0) arr.splice(idx, 1)
else arr.push(i)
}
function addOption() {
form.options.push('')
}
function removeOption(idx) {
if (form.options.length <= 2) return
form.options.splice(idx, 1)
if (form.type === 'single' && form.singleAnswer === idx) form.singleAnswer = null
if (form.singleAnswer !== null && form.singleAnswer >= idx) {
form.singleAnswer -= 1
}
if (form.type === 'multiple') {
form.multipleAnswer = form.multipleAnswer
.filter(i => i !== idx)
.map(i => (i > idx ? i - 1 : i))
}
}
function addBlank() {
form.blankAnswers.push('')
}
function removeBlank(idx) {
if (form.blankAnswers.length <= 1) return
form.blankAnswers.splice(idx, 1)
}
function validate() {
if (!form.title.trim()) {
toast.error(t('questions.err_title_required'))
return false
}
if (isOptionType.value) {
const opts = form.options.filter(o => o.trim() !== '')
if (opts.length < 2) {
toast.error(t('questions.err_options_required'))
return false
}
form.options = form.options.map(o => (o.trim() === '' ? ' ' : o))
}
if (form.type === 'single' && (form.singleAnswer === null || form.singleAnswer === undefined)) {
toast.error(t('questions.err_answer_required'))
return false
}
if (form.type === 'multiple' && form.multipleAnswer.length === 0) {
toast.error(t('questions.err_answer_required'))
return false
}
if (form.type === 'blank' && !form.blankAnswers.some(b => b.trim() !== '')) {
toast.error(t('questions.err_answer_required'))
return false
}
return true
}
function buildAnswer() {
if (form.type === 'single') return form.singleAnswer
if (form.type === 'multiple') return form.multipleAnswer
if (form.type === 'blank') return form.blankAnswers.filter(b => b.trim() !== '')
return form.judgeAnswer
}
async function handleSave() {
if (saving.value || !validate()) return
saving.value = true
try {
const payload = {
type: form.type,
title: form.title.trim(),
options: isOptionType.value ? form.options.filter(o => o.trim() !== '') : [],
answer: buildAnswer(),
explain: form.explain.trim(),
score: form.score,
active: form.active,
}
const { errCode } = editingId.value
? await quizApi.updateQuestion({ id: editingId.value, ...payload })
: await quizApi.addQuestion({ bankId, ...payload })
if (errCode === 0) {
toast.success(t('message.save_success'))
showModal.value = false
fetchQuestions()
fetchBankInfo()
} else if (errCode === -76) {
toast.error(t('questions.err_answer_format'))
} else {
toast.error(t('message.server_error'))
}
} catch {
// 拦截器已处理
} finally {
saving.value = false
}
}
// ── 删除 ──
const deleteTarget = ref(null)
const confirmDelete = ref(false)
function askDelete(row) {
deleteTarget.value = row
confirmDelete.value = true
}
async function doDelete() {
if (!deleteTarget.value) return
try {
const { errCode } = await quizApi.deleteQuestion(deleteTarget.value.id)
if (errCode === 0) {
toast.success(t('message.delete_success'))
confirmDelete.value = false
deleteTarget.value = null
fetchQuestions()
fetchBankInfo()
} else {
toast.error(t('message.server_error'))
}
} catch {
// 拦截器已处理
}
}
async function toggleActive(row) {
try {
const { errCode } = await quizApi.updateQuestion({ id: row.id, active: !row.active })
if (errCode === 0) {
row.active = !row.active
} else {
toast.error(t('message.server_error'))
}
} catch {
// 拦截器已处理
}
}
onMounted(() => {
fetchBankInfo()
fetchQuestions()
})
</script>
<template>
<div class="mx-auto max-w-6xl px-6 py-6">
<div class="flex flex-col gap-6 rounded-xl border border-gray-200 bg-white shadow-lg dark:border-dk-muted dark:bg-dk-card">
<!-- Header -->
<div class="flex items-center gap-3 border-b border-gray-100 px-6 py-4 dark:border-dk-muted">
<button
class="inline-flex items-center gap-1 rounded-lg border border-gray-300 px-2.5 py-1.5 text-xs text-gray-600 transition-colors hover:bg-gray-50 dark:border-dk-muted dark:bg-dk-base dark:text-gray-300 dark:hover:bg-dk-muted"
@click="router.push('/questions')"
>
<IconArrowLeft :size="14" />
{{ t('banks.back') }}
</button>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ bankInfo?.name || '...' }}</h3>
<span v-if="bankInfo" class="rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-semibold text-blue-700 dark:bg-blue-900/40 dark:text-blue-400">
{{ t('banks.question_count') }} {{ bankInfo.questionCnt }}
</span>
<button
class="ml-auto inline-flex items-center gap-1.5 rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-blue-700"
@click="openAdd"
>
<IconPlus :size="16" />
{{ t('questions.add') }}
</button>
</div>
<!-- Toolbar -->
<div class="flex flex-col gap-3 px-6 py-3 sm:flex-row sm:items-center">
<div class="flex flex-wrap items-center gap-2">
<select
v-model="typeFilter"
class="rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-sm dark:border-dk-muted dark:bg-dk-base dark:text-white"
@change="currentPage = 1; fetchQuestions()"
>
<option v-for="opt in typeOptions" :key="opt.value" :value="opt.value">
{{ t(opt.labelKey) }}
</option>
</select>
</div>
<div class="flex items-center gap-2 sm:ml-auto">
<input
v-model="keyword"
type="text"
:placeholder="t('questions.search_placeholder')"
class="w-48 rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-900 placeholder-gray-400 outline-none transition-colors focus:border-blue-500 dark:border-dk-muted dark:bg-dk-base dark:text-white dark:placeholder-gray-500"
@input="handleSearch"
@keydown.enter="handleSearch"
/>
<button
class="inline-flex items-center gap-1 rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-600 transition-colors hover:bg-gray-50 dark:border-dk-muted dark:bg-dk-base dark:text-gray-300 dark:hover:bg-dk-muted"
@click="handleSearch"
>
{{ t('questions.search') }}
</button>
</div>
</div>
<!-- Table -->
<div class="overflow-x-auto px-0">
<table class="w-full text-left text-sm text-gray-900">
<thead>
<tr class="border-b border-gray-200 bg-gray-50 text-gray-500 dark:border-dk-muted dark:bg-dk-base">
<th class="w-16 px-6 py-3 font-medium text-gray-500 dark:text-gray-400">No.</th>
<th class="w-32 px-6 py-3 font-medium text-gray-500 dark:text-gray-400">{{ t('questions.type') }}</th>
<th class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400">{{ t('questions.question') }}</th>
<th class="w-16 px-6 py-3 font-medium text-gray-500 dark:text-gray-400">{{ t('questions.score') }}</th>
<th class="w-16 px-6 py-3 font-medium text-gray-500 dark:text-gray-400">{{ t('questions.active') }}</th>
<th class="w-32 whitespace-nowrap px-6 py-3 font-medium text-gray-500 dark:text-gray-400">{{ t('questions.creator') }}</th>
<th class="w-40 whitespace-nowrap px-6 py-3 font-medium text-gray-500 dark:text-gray-400">{{ t('questions.created_at') }}</th>
<th class="w-28 px-6 py-3 font-medium text-gray-500 dark:text-gray-400">{{ t('questions.actions') }}</th>
</tr>
</thead>
<tbody>
<tr v-if="loading">
<td colspan="8" class="px-6 py-8 text-center text-gray-400">Loading...</td>
</tr>
<tr v-for="row in items" :key="row.id" class="border-b border-gray-100 transition-colors hover:bg-blue-50/50 dark:border-dk-muted/50 dark:hover:bg-dk-base/50">
<td class="px-6 py-3 text-gray-400">{{ row.id }}</td>
<td class="px-6 py-3">
<span class="rounded-full px-2.5 py-0.5 text-xs font-semibold" :class="typeColors[row.type]">
{{ t(typeLabels[row.type]) }}
</span>
</td>
<td class="max-w-[260px] truncate px-6 py-3 font-medium text-gray-900 dark:text-white">{{ row.title }}</td>
<td class="px-6 py-3 text-gray-600 dark:text-gray-300">{{ row.score }}</td>
<td class="px-6 py-3">
<button
class="relative inline-flex h-5 w-9 items-center rounded-full transition-colors"
:class="row.active ? 'bg-blue-600' : 'bg-gray-300 dark:bg-gray-600'"
:title="t('questions.active')"
@click="toggleActive(row)"
>
<span
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
:class="row.active ? 'translate-x-4' : 'translate-x-0.5'"
></span>
</button>
</td>
<td class="px-6 py-3 text-gray-600 dark:text-gray-300">{{ row.creatorName || '-' }}</td>
<td class="whitespace-nowrap px-6 py-3 text-gray-500 dark:text-gray-400">{{ formatDate(row.createdAt) }}</td>
<td class="px-6 py-3">
<div class="flex items-center gap-1">
<button class="rounded p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-blue-600 dark:hover:bg-dk-card" :title="t('questions.edit')" @click="openEdit(row)">
<IconPencil :size="16" />
</button>
<button class="rounded p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-500 dark:hover:bg-dk-card" :title="t('questions.delete')" @click="askDelete(row)">
<IconTrash :size="16" />
</button>
</div>
</td>
</tr>
<tr v-if="!loading && items.length === 0">
<td colspan="8" class="px-6 py-8 text-center text-gray-400">{{ t('questions.empty') }}</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="flex flex-col items-center justify-between gap-3 border-t border-gray-200 px-6 py-3 sm:flex-row dark:border-dk-muted">
<div class="flex items-center gap-1.5 text-sm text-gray-500">
<label>{{ t('quiz.show') }}</label>
<input type="text" class="w-14 rounded border border-gray-300 px-2 py-1 text-center text-sm text-gray-900 dark:border-dk-muted dark:bg-dk-base dark:text-white" :value="pageSize" @change="handlePageSizeInput" />
<label>{{ t('quiz.entries') }}</label>
<span class="ml-1">{{ t('quiz.total_items') }} {{ totalCount }}</span>
</div>
<div class="flex items-center gap-1">
<button class="rounded p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 disabled:opacity-40 dark:hover:bg-dk-card" :disabled="currentPage <= 1" @click="goToPage(1)"><IconChevronsLeft :size="16" /></button>
<button class="rounded p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 disabled:opacity-40 dark:hover:bg-dk-card" :disabled="currentPage <= 1" @click="goToPage(currentPage - 1)"><IconChevronLeftPipe :size="16" /></button>
<template v-for="a in pageRange" :key="a">
<button
class="min-w-[32px] rounded px-2 py-1 text-sm font-medium transition-colors"
:class="a === currentPage ? 'bg-blue-600 text-white' : 'text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-dk-card'"
@click="goToPage(a)"
>{{ a }}</button>
</template>
<button class="rounded p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 disabled:opacity-40 dark:hover:bg-dk-card" :disabled="currentPage >= totalPages" @click="goToPage(currentPage + 1)"><IconChevronRightPipe :size="16" /></button>
<button class="rounded p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 disabled:opacity-40 dark:hover:bg-dk-card" :disabled="currentPage >= totalPages" @click="goToPage(totalPages)"><IconChevronsRight :size="16" /></button>
<input type="text" class="ml-2 w-14 rounded border border-gray-300 px-2 py-1 text-center text-sm text-gray-900 dark:border-dk-muted dark:bg-dk-base dark:text-white" @change="handleJumpPageInput" />
</div>
</div>
</div>
<!-- Add/Edit Modal -->
<Teleport to="body">
<div v-if="showModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" @click.self="showModal = false">
<div class="flex max-h-[90vh] w-full max-w-2xl flex-col rounded-xl bg-white shadow-xl dark:bg-dk-card">
<div class="flex items-center justify-between border-b border-gray-100 px-6 py-4 dark:border-dk-muted">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ editingId > 0 ? t('questions.edit') : t('questions.add') }}
</h3>
<button class="rounded p-1.5 text-gray-500 transition-colors hover:bg-gray-100 dark:hover:bg-dk-base" @click="showModal = false">
<IconX :size="18" />
</button>
</div>
<div class="flex flex-col gap-4 overflow-y-auto px-6 py-5">
<!-- Type -->
<div class="flex items-center gap-3">
<label class="w-20 text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('questions.type') }}</label>
<select
v-model="form.type"
class="rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-sm dark:border-dk-muted dark:bg-dk-base dark:text-white"
>
<option value="single">{{ t('questions.type_single') }}</option>
<option value="multiple">{{ t('questions.type_multiple') }}</option>
<option value="blank">{{ t('questions.type_blank') }}</option>
<option value="judge">{{ t('questions.type_judge') }}</option>
</select>
</div>
<!-- Title -->
<div class="flex items-start gap-3">
<label class="w-20 pt-2 text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('questions.question') }}</label>
<textarea
v-model="form.title"
rows="3"
:placeholder="t('questions.title_placeholder')"
class="flex-1 resize-y rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 outline-none transition-colors focus:border-blue-500 dark:border-dk-muted dark:bg-dk-base dark:text-white dark:placeholder-gray-500"
></textarea>
</div>
<!-- Options -->
<div v-if="isOptionType" class="flex flex-col gap-2">
<div class="flex items-center gap-3">
<label class="w-20 text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('questions.options') }}</label>
<button
class="inline-flex items-center gap-1 rounded-lg border border-gray-300 px-2.5 py-1 text-xs text-gray-600 transition-colors hover:bg-gray-50 dark:border-dk-muted dark:bg-dk-base dark:text-gray-300 dark:hover:bg-dk-muted"
@click="addOption"
>
<IconPlus :size="14" />
{{ t('questions.add_option') }}
</button>
</div>
<div v-for="(opt, i) in form.options" :key="i" class="flex items-center gap-2 pl-20">
<span class="text-sm font-semibold text-gray-500 dark:text-gray-400">{{ String.fromCharCode(65 + i) }}</span>
<input
v-model="form.options[i]"
type="text"
:placeholder="t('questions.option_placeholder')"
class="flex-1 rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-900 placeholder-gray-400 outline-none transition-colors focus:border-blue-500 dark:border-dk-muted dark:bg-dk-base dark:text-white dark:placeholder-gray-500"
/>
<button
class="rounded p-1 text-gray-400 transition-colors hover:text-red-500 disabled:opacity-30"
:disabled="form.options.length <= 2"
@click="removeOption(i)"
>
<IconX :size="15" />
</button>
<!-- 单选选正确答案 -->
<label
v-if="form.type === 'single'"
class="flex shrink-0 cursor-pointer items-center gap-1 text-xs text-gray-600 dark:text-gray-300"
:class="form.singleAnswer === i ? 'text-blue-600 dark:text-blue-400' : ''"
>
<input
type="radio"
name="singleAnswer"
:checked="form.singleAnswer === i"
class="h-3.5 w-3.5"
@change="form.singleAnswer = i"
/>
{{ t('questions.correct') }}
</label>
<!-- 多选选正确答案 -->
<label
v-if="form.type === 'multiple'"
class="flex shrink-0 cursor-pointer items-center gap-1 text-xs text-gray-600 dark:text-gray-300"
:class="form.multipleAnswer.includes(i) ? 'text-purple-600 dark:text-purple-400' : ''"
>
<input
type="checkbox"
:checked="form.multipleAnswer.includes(i)"
class="h-3.5 w-3.5"
@change="toggleMultiple(i)"
/>
{{ t('questions.correct') }}
</label>
</div>
</div>
<!-- Blank answers -->
<div v-else-if="form.type === 'blank'" class="flex flex-col gap-2">
<div class="flex items-center gap-3 pl-20">
<p class="text-xs text-gray-400 dark:text-gray-500">{{ t('questions.blank_hint') }}</p>
<button
class="inline-flex items-center gap-1 rounded-lg border border-gray-300 px-2.5 py-1 text-xs text-gray-600 transition-colors hover:bg-gray-50 dark:border-dk-muted dark:bg-dk-base dark:text-gray-300 dark:hover:bg-dk-muted"
@click="addBlank"
>
<IconPlus :size="14" />
{{ t('questions.add_blank') }}
</button>
</div>
<div v-for="(_, bi) in form.blankAnswers" :key="bi" class="flex items-center gap-2 pl-20">
<span class="text-xs font-semibold text-gray-500 dark:text-gray-400">[{{ bi + 1 }}]</span>
<input
v-model="form.blankAnswers[bi]"
type="text"
:placeholder="t('questions.blank_placeholder')"
class="flex-1 rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-900 placeholder-gray-400 outline-none transition-colors focus:border-blue-500 dark:border-dk-muted dark:bg-dk-base dark:text-white dark:placeholder-gray-500"
/>
<button
class="rounded p-1 text-gray-400 transition-colors hover:text-red-500 disabled:opacity-30"
:disabled="form.blankAnswers.length <= 1"
@click="removeBlank(bi)"
>
<IconX :size="15" />
</button>
</div>
</div>
<!-- Judge answer -->
<div v-else class="flex items-center gap-3">
<label class="w-20 text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('questions.answer') }}</label>
<div class="flex gap-2">
<button
class="rounded-lg border px-4 py-1.5 text-sm transition-colors"
:class="form.judgeAnswer
? 'border-green-500 bg-green-50 text-green-700 dark:border-green-500 dark:bg-green-900/30 dark:text-green-300'
: 'border-gray-300 text-gray-600 hover:bg-gray-50 dark:border-dk-muted dark:bg-dk-base dark:text-gray-300'"
@click="form.judgeAnswer = true"
>
{{ t('quiz.true') }}
</button>
<button
class="rounded-lg border px-4 py-1.5 text-sm transition-colors"
:class="!form.judgeAnswer
? 'border-red-500 bg-red-50 text-red-700 dark:border-red-500 dark:bg-red-900/30 dark:text-red-300'
: 'border-gray-300 text-gray-600 hover:bg-gray-50 dark:border-dk-muted dark:bg-dk-base dark:text-gray-300'"
@click="form.judgeAnswer = false"
>
{{ t('quiz.false') }}
</button>
</div>
</div>
<!-- 答案解析 -->
<div class="flex items-start gap-3">
<label class="w-20 pt-2 text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('questions.explain') }}</label>
<textarea
v-model="form.explain"
rows="2"
:placeholder="t('questions.explain_placeholder')"
class="flex-1 resize-y rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 outline-none transition-colors focus:border-blue-500 dark:border-dk-muted dark:bg-dk-base dark:text-white dark:placeholder-gray-500"
></textarea>
</div>
<!-- Score + Active -->
<div class="flex items-center gap-3">
<label class="w-20 text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('questions.score') }}</label>
<input
v-model.number="form.score"
type="number"
min="1"
max="100"
class="w-24 rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-900 outline-none transition-colors focus:border-blue-500 dark:border-dk-muted dark:bg-dk-base dark:text-white"
/>
<label class="flex items-center gap-1.5 pl-4 text-sm text-gray-700 dark:text-gray-300">
<input v-model="form.active" type="checkbox" class="h-3.5 w-3.5" />
{{ t('questions.active') }}
</label>
</div>
</div>
<div class="flex justify-end gap-2 border-t border-gray-100 px-6 py-4 dark:border-dk-muted">
<button
class="rounded-lg border border-gray-300 px-4 py-1.5 text-sm text-gray-600 transition-colors hover:bg-gray-50 dark:border-dk-muted dark:bg-dk-base dark:text-gray-300 dark:hover:bg-dk-muted"
@click="showModal = false"
>
{{ t('message.cancel') }}
</button>
<button
class="rounded-lg bg-blue-600 px-4 py-1.5 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:opacity-50"
:disabled="saving"
@click="handleSave"
>
{{ t('message.save') }}
</button>
</div>
</div>
</div>
</Teleport>
<ConfirmDialog
v-model="confirmDelete"
:title="t('questions.delete_title')"
:message="t('questions.delete_msg', { title: deleteTarget?.title || '' })"
danger
@confirm="doDelete"
/>
</div>
</template>
@@ -0,0 +1,518 @@
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useToastStore } from '@/stores/toast'
import { usePageTitle } from '@/composables/usePageTitle'
import { quizApi } from '@/api/quiz'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import {
IconPlus,
IconPencil,
IconTrash,
IconX,
IconChevronLeftPipe,
IconChevronRightPipe,
IconChevronsLeft,
IconChevronsRight,
IconArrowLeft,
IconBook2,
IconPlayerPlay,
IconSettings2,
} from '@tabler/icons-vue'
usePageTitle('appname.quiz_questions')
const { t, locale } = useI18n()
const router = useRouter()
const toast = useToastStore()
const banks = ref([])
const totalCount = ref(0)
const pageSize = ref(10)
const currentPage = ref(1)
const keyword = ref('')
const loading = ref(false)
const totalPages = computed(() => Math.ceil(totalCount.value / pageSize.value) || 1)
const pageRange = computed(() => {
const total = totalPages.value
const cur = currentPage.value
let start = Math.max(1, cur - 2)
let end = Math.min(cur + 4, total)
if (end - start < 4) start = Math.max(1, end - 4)
return Array.from({ length: end - start + 1 }, (_, i) => start + i)
})
function durationText(sec) {
if (!sec || sec <= 0) return t('quiz.unlimited')
const m = Math.floor(sec / 60)
const s = sec % 60
return m > 0 ? `${m}${t('quiz.minutes')} ${s}${t('quiz.seconds')}` : `${s}${t('quiz.seconds')}`
}
function drawCountText(bank) {
const single = bank.countSingle ?? 20
const multiple = bank.countMultiple ?? 10
const judge = bank.countJudge ?? 20
const blank = bank.countBlank ?? 10
const parts = [
`${t('banks.single_short')}${single}`,
`${t('banks.multiple_short')}${multiple}`,
`${t('banks.judge_short')}${judge}`,
`${t('banks.blank_short')}${blank}`,
]
return parts.join(' · ')
}
async function fetchBanks() {
loading.value = true
try {
const { errCode, data } = await quizApi.listBanks({
keyword: keyword.value,
page: currentPage.value,
pageSize: pageSize.value,
})
if (errCode === 0) {
banks.value = data.items ?? []
totalCount.value = data.total ?? 0
} else if (errCode === -74) {
toast.error(t('quiz.permission_denied'))
router.replace('/questions')
} else {
toast.error(t('message.server_error'))
}
} catch {
// 拦截器已处理
} finally {
loading.value = false
}
}
function goToPage(page) {
if (page < 1 || page > totalPages.value) return
currentPage.value = page
fetchBanks()
}
function handlePageSizeInput(e) {
let val = parseInt(e.target.value) || 10
if (val > 100) val = 100
if (val < 1) val = 1
pageSize.value = val
currentPage.value = 1
fetchBanks()
}
function handleJumpPageInput(e) {
const val = parseInt(e.target.value)
if (val > 0 && val <= totalPages.value) {
currentPage.value = val
fetchBanks()
}
}
function handleSearch() {
currentPage.value = 1
fetchBanks()
}
function formatDate(dateStr) {
if (!dateStr) return ''
return new Intl.DateTimeFormat(locale.value, {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
}).format(new Date(dateStr))
}
function handleStart(bank, event) {
event.stopPropagation()
if (!bank.questionCnt || bank.questionCnt <= 0) {
toast.warning(t('quiz.bank_empty'))
return
}
router.push({ path: '/quiz/play', query: { bank: bank.id } })
}
function openBankQuestions(row) {
router.push(`/questions/bank/${row.id}`)
}
// ── 新增/编辑弹窗 ──
const showModal = ref(false)
const saving = ref(false)
const editingId = ref(0)
const form = reactive({
name: '',
description: '',
countSingle: 20,
countMultiple: 10,
countJudge: 20,
countBlank: 10,
durationSec: 300,
})
function openAdd() {
editingId.value = 0
form.name = ''
form.description = ''
form.countSingle = 20
form.countMultiple = 10
form.countJudge = 20
form.countBlank = 10
form.durationSec = 300
showModal.value = true
}
function openEdit(row, event) {
event.stopPropagation()
editingId.value = row.id
form.name = row.name
form.description = row.description || ''
form.countSingle = row.countSingle || 20
form.countMultiple = row.countMultiple || 10
form.countJudge = row.countJudge || 20
form.countBlank = row.countBlank || 10
form.durationSec = row.durationSec
showModal.value = true
}
async function handleSave() {
if (saving.value) return
if (!form.name.trim()) {
toast.error(t('banks.err_name_required'))
return
}
saving.value = true
try {
const payload = {
name: form.name.trim(),
description: form.description.trim(),
countSingle: form.countSingle,
countMultiple: form.countMultiple,
countJudge: form.countJudge,
countBlank: form.countBlank,
durationSec: form.durationSec,
}
const { errCode } = editingId.value
? await quizApi.updateBank({ id: editingId.value, ...payload })
: await quizApi.addBank(payload)
if (errCode === 0) {
toast.success(t('message.save_success'))
showModal.value = false
fetchBanks()
} else {
toast.error(t('message.server_error'))
}
} catch {
// 拦截器已处理
} finally {
saving.value = false
}
}
// ── 删除 ──
const deleteTarget = ref(null)
const confirmDelete = ref(false)
function askDelete(row, event) {
event.stopPropagation()
deleteTarget.value = row
confirmDelete.value = true
}
async function doDelete() {
if (!deleteTarget.value) return
try {
const { errCode } = await quizApi.deleteBank(deleteTarget.value.id)
if (errCode === 0) {
toast.success(t('message.delete_success'))
confirmDelete.value = false
deleteTarget.value = null
fetchBanks()
} else {
toast.error(t('message.server_error'))
}
} catch {
// 拦截器已处理
}
}
async function toggleActive(row, event) {
event.stopPropagation()
try {
const { errCode } = await quizApi.updateBank({ id: row.id, active: !row.active })
if (errCode === 0) {
row.active = !row.active
} else {
toast.error(t('message.server_error'))
}
} catch {
// 拦截器已处理
}
}
onMounted(fetchBanks)
</script>
<template>
<div class="mx-auto max-w-6xl px-6 py-6">
<div class="flex flex-col gap-6 rounded-xl border border-gray-200 bg-white shadow-lg dark:border-dk-muted dark:bg-dk-card">
<!-- Header -->
<div class="flex flex-wrap items-center gap-3 border-b border-gray-100 px-6 py-4 dark:border-dk-muted">
<button
class="inline-flex items-center gap-1 rounded-lg border border-gray-300 px-2.5 py-1.5 text-xs text-gray-600 transition-colors hover:bg-gray-50 dark:border-dk-muted dark:bg-dk-base dark:text-gray-300 dark:hover:bg-dk-muted"
@click="router.push('/questions')"
>
<IconArrowLeft :size="14" />
{{ t('banks.back') }}
</button>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('banks.title') }}</h3>
<button
class="ml-auto inline-flex items-center gap-1.5 rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-blue-700"
@click="openAdd"
>
<IconPlus :size="16" />
{{ t('banks.add') }}
</button>
</div>
<!-- 筛选栏 -->
<div class="flex items-center gap-2 px-6 py-3">
<input
v-model="keyword"
type="text"
:placeholder="t('banks.search_placeholder')"
class="w-48 rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-900 placeholder-gray-400 outline-none transition-colors focus:border-blue-500 dark:border-dk-muted dark:bg-dk-base dark:text-white dark:placeholder-gray-500"
@input="handleSearch"
@keydown.enter="handleSearch"
/>
<button
class="inline-flex items-center gap-1 rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-600 transition-colors hover:bg-gray-50 dark:border-dk-muted dark:bg-dk-base dark:text-gray-300 dark:hover:bg-dk-muted"
@click="handleSearch"
>
{{ t('banks.search') }}
</button>
</div>
<!-- Table -->
<div class="overflow-x-auto px-0">
<table class="w-full text-left text-sm text-gray-900">
<thead>
<tr class="border-b border-gray-200 bg-gray-50 text-gray-500 dark:border-dk-muted dark:bg-dk-base">
<th class="w-16 px-6 py-3 font-medium text-gray-500 dark:text-gray-400">No.</th>
<th class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400">{{ t('banks.name') }}</th>
<th class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 whitespace-nowrap">{{ t('quiz.draw_count') }}</th>
<th class="w-24 whitespace-nowrap px-6 py-3 font-medium text-gray-500 dark:text-gray-400">{{ t('quiz.time_limit') }}</th>
<th class="w-20 px-6 py-3 font-medium text-gray-500 dark:text-gray-400">{{ t('banks.question_count') }}</th>
<th class="w-16 px-6 py-3 font-medium text-gray-500 dark:text-gray-400">{{ t('banks.active') }}</th>
<th class="w-40 whitespace-nowrap px-6 py-3 font-medium text-gray-500 dark:text-gray-400">{{ t('banks.created_at') }}</th>
<th class="w-32 px-6 py-3 font-medium text-gray-500 dark:text-gray-400">{{ t('banks.actions') }}</th>
</tr>
</thead>
<tbody>
<tr v-if="loading">
<td colspan="8" class="px-6 py-8 text-center text-gray-400">Loading...</td>
</tr>
<tr
v-for="row in banks"
:key="row.id"
class="cursor-pointer border-b border-gray-100 transition-colors hover:bg-blue-50/50 dark:border-dk-muted/50 dark:hover:bg-dk-base/50"
@click="openBankQuestions(row)"
>
<td class="px-6 py-3 text-gray-400">{{ row.id }}</td>
<td class="px-6 py-3">
<span class="inline-flex items-center gap-1.5 font-medium text-gray-900 dark:text-white">
<IconBook2 :size="16" class="text-blue-500" />
{{ row.name }}
</span>
</td>
<td class="whitespace-nowrap px-6 py-3 text-xs text-gray-600 dark:text-gray-300">{{ drawCountText(row) }}</td>
<td class="whitespace-nowrap px-6 py-3 text-gray-600 dark:text-gray-300">{{ durationText(row.durationSec) }}</td>
<td class="px-6 py-3">
<span class="rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-semibold text-blue-700 dark:bg-blue-900/40 dark:text-blue-400">{{ row.questionCnt }}</span>
</td>
<td class="px-6 py-3">
<button
class="relative inline-flex h-5 w-9 items-center rounded-full transition-colors"
:class="row.active ? 'bg-blue-600' : 'bg-gray-300 dark:bg-gray-600'"
:title="t('banks.active')"
@click="toggleActive(row, $event)"
>
<span
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
:class="row.active ? 'translate-x-4' : 'translate-x-0.5'"
></span>
</button>
</td>
<td class="whitespace-nowrap px-6 py-3 text-gray-500 dark:text-gray-400">{{ formatDate(row.createdAt) }}</td>
<td class="px-6 py-3">
<div class="flex items-center gap-1">
<button class="rounded p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-green-600 dark:hover:bg-dk-card" :title="t('quiz.start_btn')" @click="handleStart(row, $event)">
<IconPlayerPlay :size="16" />
</button>
<button class="rounded p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-blue-600 dark:hover:bg-dk-card" :title="t('banks.manage_questions')" @click="openBankQuestions(row)">
<IconSettings2 :size="16" />
</button>
<button class="rounded p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-blue-600 dark:hover:bg-dk-card" :title="t('banks.edit')" @click="openEdit(row, $event)">
<IconPencil :size="16" />
</button>
<button class="rounded p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-500 dark:hover:bg-dk-card" :title="t('banks.delete')" @click="askDelete(row, $event)">
<IconTrash :size="16" />
</button>
</div>
</td>
</tr>
<tr v-if="!loading && banks.length === 0">
<td colspan="8" class="px-6 py-8 text-center text-gray-400">{{ t('banks.empty') }}</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="flex flex-col items-center justify-between gap-3 border-t border-gray-200 px-6 py-3 sm:flex-row dark:border-dk-muted">
<div class="flex items-center gap-1.5 text-sm text-gray-500">
<label>{{ t('quiz.show') }}</label>
<input type="text" class="w-14 rounded border border-gray-300 px-2 py-1 text-center text-sm text-gray-900 dark:border-dk-muted dark:bg-dk-base dark:text-white" :value="pageSize" @change="handlePageSizeInput" />
<label>{{ t('quiz.entries') }}</label>
<span class="ml-1">{{ t('quiz.total_items') }} {{ totalCount }}</span>
</div>
<div class="flex items-center gap-1">
<button class="rounded p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 disabled:opacity-40 dark:hover:bg-dk-card" :disabled="currentPage <= 1" @click="goToPage(1)"><IconChevronsLeft :size="16" /></button>
<button class="rounded p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 disabled:opacity-40 dark:hover:bg-dk-card" :disabled="currentPage <= 1" @click="goToPage(currentPage - 1)"><IconChevronLeftPipe :size="16" /></button>
<template v-for="a in pageRange" :key="a">
<button
class="min-w-[32px] rounded px-2 py-1 text-sm font-medium transition-colors"
:class="a === currentPage ? 'bg-blue-600 text-white' : 'text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-dk-card'"
@click="goToPage(a)"
>{{ a }}</button>
</template>
<button class="rounded p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 disabled:opacity-40 dark:hover:bg-dk-card" :disabled="currentPage >= totalPages" @click="goToPage(currentPage + 1)"><IconChevronRightPipe :size="16" /></button>
<button class="rounded p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 disabled:opacity-40 dark:hover:bg-dk-card" :disabled="currentPage >= totalPages" @click="goToPage(totalPages)"><IconChevronsRight :size="16" /></button>
<input type="text" class="ml-2 w-14 rounded border border-gray-300 px-2 py-1 text-center text-sm text-gray-900 dark:border-dk-muted dark:bg-dk-base dark:text-white" @change="handleJumpPageInput" />
</div>
</div>
</div>
<!-- Add/Edit Modal -->
<Teleport to="body">
<div v-if="showModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" @click.self="showModal = false">
<div class="flex max-h-[90vh] w-full max-w-lg flex-col rounded-xl bg-white shadow-xl dark:bg-dk-card">
<div class="flex items-center justify-between border-b border-gray-100 px-6 py-4 dark:border-dk-muted">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ editingId > 0 ? t('banks.edit') : t('banks.add') }}
</h3>
<button class="rounded p-1.5 text-gray-500 transition-colors hover:bg-gray-100 dark:hover:bg-dk-base" @click="showModal = false">
<IconX :size="18" />
</button>
</div>
<div class="flex flex-col gap-4 overflow-y-auto px-6 py-5">
<div class="flex items-center gap-3">
<label class="w-24 shrink-0 text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('banks.name') }}</label>
<input
v-model="form.name"
type="text"
:placeholder="t('banks.name_placeholder')"
maxlength="100"
class="flex-1 rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-900 placeholder-gray-400 outline-none transition-colors focus:border-blue-500 dark:border-dk-muted dark:bg-dk-base dark:text-white dark:placeholder-gray-500"
/>
</div>
<div class="flex items-center gap-3">
<label class="w-24 shrink-0 text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('banks.description') }}</label>
<input
v-model="form.description"
type="text"
:placeholder="t('banks.description_placeholder')"
maxlength="200"
class="flex-1 rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-900 placeholder-gray-400 outline-none transition-colors focus:border-blue-500 dark:border-dk-muted dark:bg-dk-base dark:text-white dark:placeholder-gray-500"
/>
</div>
<div class="flex flex-col gap-2">
<label class="w-24 shrink-0 text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('quiz.draw_count') }}</label>
<div class="grid grid-cols-2 gap-3 pl-24">
<div class="flex items-center gap-2">
<label class="text-xs font-medium text-gray-500 dark:text-gray-400 whitespace-nowrap">{{ t('questions.type_single') }}</label>
<input
v-model.number="form.countSingle"
type="number"
min="1"
max="100"
class="w-20 rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-900 outline-none transition-colors focus:border-blue-500 dark:border-dk-muted dark:bg-dk-base dark:text-white"
/>
</div>
<div class="flex items-center gap-2">
<label class="text-xs font-medium text-gray-500 dark:text-gray-400 whitespace-nowrap">{{ t('questions.type_multiple') }}</label>
<input
v-model.number="form.countMultiple"
type="number"
min="1"
max="100"
class="w-20 rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-900 outline-none transition-colors focus:border-blue-500 dark:border-dk-muted dark:bg-dk-base dark:text-white"
/>
</div>
<div class="flex items-center gap-2">
<label class="text-xs font-medium text-gray-500 dark:text-gray-400 whitespace-nowrap">{{ t('questions.type_judge') }}</label>
<input
v-model.number="form.countJudge"
type="number"
min="1"
max="100"
class="w-20 rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-900 outline-none transition-colors focus:border-blue-500 dark:border-dk-muted dark:bg-dk-base dark:text-white"
/>
</div>
<div class="flex items-center gap-2">
<label class="text-xs font-medium text-gray-500 dark:text-gray-400 whitespace-nowrap">{{ t('questions.type_blank') }}</label>
<input
v-model.number="form.countBlank"
type="number"
min="1"
max="100"
class="w-20 rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-900 outline-none transition-colors focus:border-blue-500 dark:border-dk-muted dark:bg-dk-base dark:text-white"
/>
</div>
</div>
<p class="pl-24 text-xs text-gray-400 dark:text-gray-500">{{ t('banks.draw_count_hint') }}</p>
</div>
<div class="flex items-center gap-3">
<label class="w-24 shrink-0 text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('quiz.time_limit') }}</label>
<input
v-model.number="form.durationSec"
type="number"
min="0"
max="7200"
class="w-24 rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-900 outline-none transition-colors focus:border-blue-500 dark:border-dk-muted dark:bg-dk-base dark:text-white"
/>
<span class="text-xs text-gray-400 dark:text-gray-500">{{ t('banks.duration_seconds_hint') }}</span>
</div>
</div>
<div class="flex justify-end gap-2 border-t border-gray-100 px-6 py-4 dark:border-dk-muted">
<button
class="rounded-lg border border-gray-300 px-4 py-1.5 text-sm text-gray-600 transition-colors hover:bg-gray-50 dark:border-dk-muted dark:bg-dk-base dark:text-gray-300 dark:hover:bg-dk-muted"
@click="showModal = false"
>
{{ t('message.cancel') }}
</button>
<button
class="rounded-lg bg-blue-600 px-4 py-1.5 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:opacity-50"
:disabled="saving"
@click="handleSave"
>
{{ t('message.save') }}
</button>
</div>
</div>
</div>
</Teleport>
<ConfirmDialog
v-model="confirmDelete"
:title="t('banks.delete_title')"
:message="t('banks.delete_msg', { name: deleteTarget?.name || '' })"
danger
@confirm="doDelete"
/>
</div>
</template>
@@ -0,0 +1,474 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useToastStore } from '@/stores/toast'
import { useUserStore } from '@/stores/user'
import { usePageTitle } from '@/composables/usePageTitle'
import { quizApi } from '@/api/quiz'
import {
IconPlayerPlay,
IconHistory,
IconTrophy,
IconBook2,
IconClock,
IconListNumbers,
IconMedal,
IconPencil,
IconChevronLeftPipe,
IconChevronRightPipe,
IconChevronsLeft,
IconChevronsRight,
IconReload,
} from '@tabler/icons-vue'
usePageTitle('appname.quiz_bank')
const { t, locale } = useI18n()
const router = useRouter()
const toast = useToastStore()
const userStore = useUserStore()
const isQuizAdmin = computed(() => userStore.isQuizAdmin)
const tab = ref('banks')
// ── 题库卡片 ──
const banks = ref([])
const loadingBanks = ref(false)
// ── 我的成绩 ──
const sessions = ref([])
const historyTotal = ref(0)
const historyPageSize = ref(10)
const historyPage = ref(1)
const loadingHistory = ref(false)
const historyTotalPages = computed(() => Math.ceil(historyTotal.value / historyPageSize.value) || 1)
const historyPageRange = computed(() => {
const total = historyTotalPages.value
const cur = historyPage.value
let start = Math.max(1, cur - 2)
let end = Math.min(cur + 4, total)
if (end - start < 4) start = Math.max(1, end - 4)
return Array.from({ length: end - start + 1 }, (_, i) => start + i)
})
// ── 排行榜 ──
const board = ref([])
const loadingBoard = ref(false)
const boardBankId = ref(0)
const boardOptions = ref([])
function durationText(sec) {
if (!sec || sec <= 0) return t('quiz.unlimited')
const m = Math.floor(sec / 60)
const s = sec % 60
return m > 0 ? `${m}${t('quiz.minutes')} ${s}${t('quiz.seconds')}` : `${s}${t('quiz.seconds')}`
}
function drawCountText(bank) {
const single = bank.countSingle ?? 20
const multiple = bank.countMultiple ?? 10
const judge = bank.countJudge ?? 20
const blank = bank.countBlank ?? 10
const parts = [
`${t('banks.single_short')}${single}`,
`${t('banks.multiple_short')}${multiple}`,
`${t('banks.judge_short')}${judge}`,
`${t('banks.blank_short')}${blank}`,
]
return parts.join(' · ')
}
async function fetchBanks() {
loadingBanks.value = true
try {
const { errCode, data } = await quizApi.availableBanks()
if (errCode === 0) {
banks.value = data.items ?? []
} else {
toast.error(t('message.server_error'))
}
} catch {
// 拦截器已处理
} finally {
loadingBanks.value = false
}
}
async function fetchBoardOptions() {
try {
const { errCode, data } = await quizApi.availableBanks()
if (errCode === 0) {
boardOptions.value = data.items ?? []
if (boardBankId.value === 0 && boardOptions.value.length > 0) {
boardBankId.value = boardOptions.value[0].id
fetchBoard()
}
}
} catch {
// 拦截器已处理
}
}
function handleStart(bank) {
if (!bank.questionCnt || bank.questionCnt <= 0) {
toast.warning(t('quiz.bank_empty'))
return
}
router.push({ path: '/quiz/play', query: { bank: bank.id } })
}
function handleLearn(bank) {
router.push({ path: '/quiz/learn', query: { bank: bank.id } })
}
function goManage() {
router.push('/questions/manage')
}
// ── 我的成绩 ──
async function fetchSessions() {
loadingHistory.value = true
try {
const { errCode, data } = await quizApi.mySessions({ page: historyPage.value, pageSize: historyPageSize.value })
if (errCode === 0) {
sessions.value = data.items ?? []
historyTotal.value = data.total ?? 0
} else {
toast.error(t('message.server_error'))
}
} catch {
// 拦截器已处理
} finally {
loadingHistory.value = false
}
}
function goHistoryPage(page) {
if (page < 1 || page > historyTotalPages.value) return
historyPage.value = page
fetchSessions()
}
function handleHistoryPageSizeInput(e) {
let val = parseInt(e.target.value) || 10
if (val > 100) val = 100
if (val < 1) val = 1
historyPageSize.value = val
historyPage.value = 1
fetchSessions()
}
function handleHistoryJumpInput(e) {
const val = parseInt(e.target.value)
if (val > 0 && val <= historyTotalPages.value) {
historyPage.value = val
fetchSessions()
}
}
function viewSession(id) {
router.push(`/quiz/result/${id}`)
}
function redoWrong(s) {
router.push(`/quiz/redo/${s.id}`)
}
// ── 排行榜 ──
async function fetchBoard() {
loadingBoard.value = true
try {
const { errCode, data } = await quizApi.leaderboard({ bankId: boardBankId.value, limit: 10 })
if (errCode === 0) {
board.value = data.items ?? []
} else {
toast.error(t('message.server_error'))
}
} catch {
// 拦截器已处理
} finally {
loadingBoard.value = false
}
}
function formatDate(dateStr) {
if (!dateStr) return ''
return new Intl.DateTimeFormat(locale.value, {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
}).format(new Date(dateStr))
}
onMounted(() => {
fetchBanks()
fetchBoardOptions()
fetchSessions()
})
</script>
<template>
<div class="mx-auto max-w-6xl px-6 py-6">
<!-- Tabs -->
<div class="mb-6 flex gap-1 rounded-xl border border-gray-200 bg-white p-1 text-sm font-medium shadow-sm dark:border-dk-muted dark:bg-dk-card">
<button
class="flex flex-1 items-center justify-center gap-1.5 rounded-lg px-4 py-2 transition-colors"
:class="tab === 'banks' ? 'bg-blue-600 text-white' : 'text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dk-base'"
@click="tab = 'banks'"
>
<IconBook2 :size="16" />
{{ t('quiz.tab_banks') }}
</button>
<button
class="flex flex-1 items-center justify-center gap-1.5 rounded-lg px-4 py-2 transition-colors"
:class="tab === 'history' ? 'bg-blue-600 text-white' : 'text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dk-base'"
@click="tab = 'history'"
>
<IconHistory :size="16" />
{{ t('quiz.tab_history') }}
</button>
<button
class="flex flex-1 items-center justify-center gap-1.5 rounded-lg px-4 py-2 transition-colors"
:class="tab === 'board' ? 'bg-blue-600 text-white' : 'text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dk-base'"
@click="tab = 'board'"
>
<IconTrophy :size="16" />
{{ t('quiz.tab_leaderboard') }}
</button>
</div>
<!-- 题库列表(卡片) -->
<div v-if="tab === 'banks'" class="rounded-xl border border-gray-200 bg-white shadow-lg dark:border-dk-muted dark:bg-dk-card">
<div class="flex flex-wrap items-center justify-between gap-3 border-b border-gray-100 px-6 py-4 dark:border-dk-muted">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('appname.quiz_bank') }}</h3>
<button
v-if="isQuizAdmin"
class="inline-flex items-center gap-1.5 rounded-lg border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-50 dark:border-dk-muted dark:bg-dk-base dark:text-gray-300 dark:hover:bg-dk-muted"
@click="goManage"
>
<IconPencil :size="16" />
{{ t('banks.manage') }}
</button>
</div>
<div class="grid grid-cols-1 gap-4 p-6 sm:grid-cols-2 lg:grid-cols-3">
<div v-if="loadingBanks" class="col-span-full py-10 text-center text-gray-400">Loading...</div>
<div
v-for="bank in banks"
:key="bank.id"
class="flex flex-col gap-3 rounded-xl border p-5 transition-colors"
:class="bank.questionCnt > 0
? 'border-gray-200 hover:border-blue-400 hover:shadow-md dark:border-dk-muted dark:hover:border-blue-500'
: 'border-gray-200 opacity-60 dark:border-dk-muted'"
>
<div class="flex items-center gap-2">
<IconBook2 :size="18" class="shrink-0 text-blue-500" />
<h4 class="truncate font-semibold text-gray-900 dark:text-white">{{ bank.name }}</h4>
<div class="ml-auto flex shrink-0 items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
<img
:src="bank.creatorAvatar ? '/api/static/avatar/' + bank.creatorAvatar : '/ava.svg'"
class="h-5 w-5 rounded-full object-cover"
:alt="bank.creatorName || ''"
/>
<span class="font-medium">{{ bank.creatorName || '-' }}</span>
</div>
</div>
<p v-if="bank.description" class="line-clamp-2 text-xs text-gray-500 dark:text-gray-400">{{ bank.description }}</p>
<div class="space-y-2 text-xs text-gray-500 dark:text-gray-400">
<div class="flex items-center justify-between gap-2">
<span class="inline-flex shrink-0 items-center gap-1">
<IconListNumbers :size="13" />
{{ t('quiz.draw_count') }}
</span>
<span class="break-words text-right font-medium text-gray-700 dark:text-gray-300">{{ drawCountText(bank) }}</span>
</div>
<div class="flex items-center justify-between gap-2">
<span class="inline-flex shrink-0 items-center gap-1">
<IconClock :size="13" />
{{ t('quiz.time_limit') }}
</span>
<span class="font-medium text-gray-700 dark:text-gray-300">{{ durationText(bank.durationSec) }}</span>
</div>
<div class="flex items-center justify-between gap-2">
<span class="inline-flex shrink-0 items-center gap-1">
<IconBook2 :size="13" />
{{ t('quiz.questions_available') }}
</span>
<span class="font-medium text-gray-700 dark:text-gray-300">{{ bank.questionCnt }}</span>
</div>
<div v-if="bank.myBest > 0" class="flex items-center justify-between gap-2">
<span class="inline-flex shrink-0 items-center gap-1 text-amber-600 dark:text-amber-400">
<IconMedal :size="13" />
{{ t('quiz.my_best') }}
</span>
<span class="font-medium text-amber-600 dark:text-amber-400">{{ bank.myBest }}</span>
</div>
</div>
<div class="mt-auto flex items-center gap-2">
<button
class="inline-flex flex-1 items-center justify-center gap-1.5 rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-50 dark:border-dk-muted dark:bg-dk-base dark:text-gray-300 dark:hover:bg-dk-muted"
@click="handleLearn(bank)"
>
<IconBook2 :size="16" />
{{ t('quiz.learn') }}
</button>
<button
class="inline-flex flex-1 items-center justify-center gap-1.5 rounded-lg px-4 py-2 text-sm font-medium transition-colors disabled:opacity-50"
:class="bank.questionCnt > 0
? 'bg-blue-600 text-white hover:bg-blue-700'
: 'bg-gray-200 text-gray-500 dark:bg-dk-base dark:text-gray-400'"
:disabled="bank.questionCnt === 0"
@click="handleStart(bank)"
>
<IconPlayerPlay :size="16" />
{{ t('quiz.start_btn') }}
</button>
</div>
<p v-if="bank.questionCnt === 0" class="text-center text-xs text-gray-400">{{ t('quiz.bank_empty') }}</p>
</div>
<div v-if="!loadingBanks && banks.length === 0" class="col-span-full py-10 text-center text-gray-400">{{ t('quiz.no_banks') }}</div>
</div>
</div>
<!-- 我的成绩 -->
<div v-else-if="tab === 'history'" class="rounded-xl border border-gray-200 bg-white shadow-lg dark:border-dk-muted dark:bg-dk-card">
<div class="flex items-center justify-between border-b border-gray-100 px-6 py-4 dark:border-dk-muted">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('quiz.tab_history') }}</h3>
</div>
<div class="overflow-x-auto">
<table class="w-full text-left text-sm text-gray-900">
<thead>
<tr class="border-b border-gray-200 bg-gray-50 text-gray-500 dark:border-dk-muted dark:bg-dk-base">
<th class="w-16 px-6 py-3 font-medium text-gray-500 dark:text-gray-400">No.</th>
<th class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400">{{ t('quiz.bank') }}</th>
<th class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400">{{ t('quiz.score') }}</th>
<th class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400">{{ t('quiz.total_score') }}</th>
<th class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400">{{ t('quiz.correct') }}</th>
<th class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400">{{ t('quiz.wrong') }}</th>
<th class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400">{{ t('quiz.duration') }}</th>
<th class="whitespace-nowrap px-6 py-3 font-medium text-gray-500 dark:text-gray-400">{{ t('quiz.date') }}</th>
<th class="w-28 px-6 py-3 font-medium text-gray-500 dark:text-gray-400">{{ t('quiz.actions') }}</th>
</tr>
</thead>
<tbody>
<tr v-if="loadingHistory">
<td colspan="9" class="px-6 py-8 text-center text-gray-400">Loading...</td>
</tr>
<tr v-for="s in sessions" :key="s.id"
class="cursor-pointer border-b border-gray-100 transition-colors hover:bg-blue-50/50 dark:border-dk-muted/50 dark:hover:bg-dk-base/50"
@click="viewSession(s.id)">
<td class="px-6 py-3 text-gray-400">{{ s.id }}</td>
<td class="max-w-[180px] truncate px-6 py-3 font-medium text-gray-900 dark:text-white">{{ s.bankName || '-' }}</td>
<td class="px-6 py-3">
<span class="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-semibold"
:class="s.score >= s.totalScore * 0.6 ? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400' : 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-400'">
{{ s.score }}
</span>
</td>
<td class="px-6 py-3 text-gray-600 dark:text-gray-300">{{ s.totalScore }}</td>
<td class="px-6 py-3 text-green-600 dark:text-green-400">{{ s.correctCount }}</td>
<td class="px-6 py-3 text-red-500 dark:text-red-400">{{ s.wrongCount }}</td>
<td class="px-6 py-3 text-gray-500 dark:text-gray-400">{{ s.durationSec }} {{ t('quiz.seconds') }}</td>
<td class="whitespace-nowrap px-6 py-3 text-gray-500 dark:text-gray-400">{{ formatDate(s.createdAt) }}</td>
<td class="px-6 py-3">
<button
v-if="s.wrongCount > 0"
class="inline-flex items-center gap-1 whitespace-nowrap rounded-lg border border-blue-300 px-2.5 py-1 text-xs font-medium text-blue-600 transition-colors hover:bg-blue-50 dark:border-blue-700 dark:text-blue-400 dark:hover:bg-blue-900/20"
@click.stop="redoWrong(s)"
>
<IconReload :size="13" />
{{ t('quiz.redo') }}
</button>
</td>
</tr>
<tr v-if="!loadingHistory && sessions.length === 0">
<td colspan="9" class="px-6 py-8 text-center text-gray-400">{{ t('quiz.history_empty') }}</td>
</tr>
</tbody>
</table>
</div>
<div class="flex flex-col items-center justify-between gap-3 border-t border-gray-200 px-6 py-3 sm:flex-row dark:border-dk-muted">
<div class="flex items-center gap-1.5 text-sm text-gray-500">
<label>{{ t('quiz.show') }}</label>
<input type="text" class="w-14 rounded border border-gray-300 px-2 py-1 text-center text-sm text-gray-900 dark:border-dk-muted dark:bg-dk-base dark:text-white" :value="historyPageSize" @change="handleHistoryPageSizeInput" />
<label>{{ t('quiz.entries') }}</label>
<span class="ml-1">{{ t('quiz.total_items') }} {{ historyTotal }}</span>
</div>
<div class="flex items-center gap-1">
<button class="rounded p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 disabled:opacity-40 dark:hover:bg-dk-card" :disabled="historyPage <= 1" @click="goHistoryPage(1)"><IconChevronsLeft :size="16" /></button>
<button class="rounded p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 disabled:opacity-40 dark:hover:bg-dk-card" :disabled="historyPage <= 1" @click="goHistoryPage(historyPage - 1)"><IconChevronLeftPipe :size="16" /></button>
<template v-for="a in historyPageRange" :key="a">
<button
class="min-w-[32px] rounded px-2 py-1 text-sm font-medium transition-colors"
:class="a === historyPage ? 'bg-blue-600 text-white' : 'text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-dk-card'"
@click="goHistoryPage(a)"
>{{ a }}</button>
</template>
<button class="rounded p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 disabled:opacity-40 dark:hover:bg-dk-card" :disabled="historyPage >= historyTotalPages" @click="goHistoryPage(historyPage + 1)"><IconChevronRightPipe :size="16" /></button>
<button class="rounded p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 disabled:opacity-40 dark:hover:bg-dk-card" :disabled="historyPage >= historyTotalPages" @click="goHistoryPage(historyTotalPages)"><IconChevronsRight :size="16" /></button>
<input type="text" class="ml-2 w-14 rounded border border-gray-300 px-2 py-1 text-center text-sm text-gray-900 dark:border-dk-muted dark:bg-dk-base dark:text-white" @change="handleHistoryJumpInput" />
</div>
</div>
</div>
<!-- 排行榜 -->
<div v-else class="rounded-xl border border-gray-200 bg-white shadow-lg dark:border-dk-muted dark:bg-dk-card">
<div class="flex flex-wrap items-center gap-3 border-b border-gray-100 px-6 py-4 dark:border-dk-muted">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('quiz.tab_leaderboard') }}</h3>
<select
v-model="boardBankId"
class="ml-auto rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-sm dark:border-dk-muted dark:bg-dk-base dark:text-white"
@change="fetchBoard()"
>
<option v-for="b in boardOptions" :key="b.id" :value="b.id">{{ b.name }}</option>
</select>
</div>
<div class="overflow-x-auto">
<table class="w-full text-left text-sm text-gray-900">
<thead>
<tr class="border-b border-gray-200 bg-gray-50 text-gray-500 dark:border-dk-muted dark:bg-dk-base">
<th class="w-16 px-6 py-3 font-medium text-gray-500 dark:text-gray-400">{{ t('quiz.rank') }}</th>
<th class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400">{{ t('quiz.player') }}</th>
<th class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400">{{ t('quiz.best_score') }}</th>
<th class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400">{{ t('quiz.attempts') }}</th>
<th class="whitespace-nowrap px-6 py-3 font-medium text-gray-500 dark:text-gray-400">{{ t('quiz.last_at') }}</th>
</tr>
</thead>
<tbody>
<tr v-if="loadingBoard">
<td colspan="5" class="px-6 py-8 text-center text-gray-400">Loading...</td>
</tr>
<tr v-for="item in board" :key="item.userId" class="border-b border-gray-100 dark:border-dk-muted/50">
<td class="px-6 py-3">
<span class="inline-flex h-6 w-6 items-center justify-center rounded-full text-xs font-semibold"
:class="item.rank === 1 ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-400'
: item.rank === 2 ? 'bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
: item.rank === 3 ? 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-400'
: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400'">
{{ item.rank }}
</span>
</td>
<td class="px-6 py-3 font-medium text-gray-900 dark:text-white">
<span class="inline-flex items-center gap-2">
<img
:src="item.avatar ? '/api/static/avatar/' + item.avatar : '/ava.svg'"
class="h-6 w-6 rounded-full object-cover"
:alt="item.name || ''"
/>
{{ item.name || '-' }}
</span>
</td>
<td class="px-6 py-3 font-semibold text-green-600 dark:text-green-400">{{ item.best }}</td>
<td class="px-6 py-3 text-gray-600 dark:text-gray-300">{{ item.sessions }}</td>
<td class="whitespace-nowrap px-6 py-3 text-gray-500 dark:text-gray-400">{{ formatDate(item.lastAt) }}</td>
</tr>
<tr v-if="!loadingBoard && board.length === 0">
<td colspan="5" class="px-6 py-8 text-center text-gray-400">{{ t('quiz.board_empty') }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
@@ -0,0 +1,317 @@
<script setup>
import { ref, computed, nextTick, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useToastStore } from '@/stores/toast'
import { usePageTitle } from '@/composables/usePageTitle'
import { quizApi } from '@/api/quiz'
import { IconChevronLeft, IconCircleCheck, IconBook2 } from '@tabler/icons-vue'
usePageTitle('appname.quiz_bank')
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const toast = useToastStore()
const bankId = parseInt(route.query.bank) || 0
const bankName = ref('')
const items = ref([])
const total = ref(0)
const page = ref(1)
const loading = ref(false)
const loadingMore = ref(false)
const cardEls = ref([])
let sentinel = null
let sentinelObserver = null
const posKey = 'quiz_learn_pos_' + bankId
const typeLabels = {
single: 'quiz.type_single',
multiple: 'quiz.type_multiple',
blank: 'quiz.type_blank',
judge: 'quiz.type_judge',
}
const typeColors = {
single: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400',
multiple: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-400',
blank: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-400',
judge: 'bg-teal-100 text-teal-700 dark:bg-teal-900/40 dark:text-teal-400',
}
const hasMore = computed(() => items.value.length < total.value)
function avatarUrl(bankItem) {
return bankItem.creatorAvatar ? '/api/static/avatar/' + bankItem.creatorAvatar : '/ava.svg'
}
// 正确答案的展示文本
function correctText(item) {
if (item.type === 'blank') {
const arr = Array.isArray(item.answer) ? item.answer : []
return arr.map(a => String(a).split('|')[0]).join(' / ') || '-'
}
if (item.type === 'judge') {
return String(item.answer) === 'true' ? t('quiz.true') : t('quiz.false')
}
if (item.type === 'single') {
const idx = typeof item.answer === 'number' ? item.answer : parseInt(item.answer)
if (Number.isFinite(idx) && item.options[idx]) return item.options[idx]
return '-'
}
// multiple
const idxs = Array.isArray(item.answer) ? item.answer : []
const txt = idxs.map(i => item.options[i]).filter(Boolean)
return txt.join(', ') || '-'
}
function isCorrectOption(item, idx) {
if (item.type === 'multiple') {
return Array.isArray(item.answer) && item.answer.map(Number).includes(idx)
}
if (item.type === 'single') {
const a = typeof item.answer === 'number' ? item.answer : parseInt(item.answer)
return a === idx
}
return false
}
async function loadFirst() {
loading.value = true
try {
const { errCode, data } = await quizApi.learnBank(bankId, 1, 10)
if (errCode === 0) {
bankName.value = data.bankName ?? ''
items.value = data.items ?? []
total.value = data.total ?? 0
page.value = 1
} else if (errCode === -77) {
toast.error(t('quiz.bank_unavailable'))
router.replace('/questions')
} else {
toast.error(t('message.server_error'))
}
} catch {
// 拦截器已处理
} finally {
loading.value = false
await nextTick()
sentinel = document.getElementById('learn-sentinel')
setupSentinel()
}
}
async function loadMore() {
if (!hasMore.value || loadingMore.value || loading.value) return
loadingMore.value = true
try {
const next = page.value + 1
const { errCode, data } = await quizApi.learnBank(bankId, next, 10)
if (errCode === 0) {
items.value = (items.value ?? []).concat(data.items ?? [])
total.value = data.total ?? total.value
page.value = next
} else {
toast.error(t('message.server_error'))
}
} catch {
// 拦截器已处理
} finally {
loadingMore.value = false
}
}
function setupSentinel() {
if (sentinelObserver) sentinelObserver.disconnect()
if (!sentinel) return
sentinelObserver = new IntersectionObserver((entries) => {
if (entries.some(e => e.isIntersecting)) {
loadMore()
}
}, { rootMargin: '200px 0px' })
sentinelObserver.observe(sentinel)
}
function setCardRef(el, index) {
if (el) cardEls.value[index] = el
}
// 视口上半个区域内最靠下的题卡序号
function topVisibleIndex() {
let idx = 0
cardEls.value.forEach((el, i) => {
if (!el) return
const r = el.getBoundingClientRect()
if (r.top < window.innerHeight * 0.5) idx = i
})
return idx
}
// 保存阅读位置(题卡序号 + 滚动偏移)并返回
function saveAndBack() {
const pos = { itemIndex: topVisibleIndex(), scrollY: window.scrollY }
try {
localStorage.setItem(posKey, JSON.stringify(pos))
} catch {
// 忽略存储失败,仍返回
}
router.push('/questions')
}
// 恢复阅读位置:按需加载至目标题所在批后定位滚动
async function restorePosition() {
let pos = null
try {
pos = JSON.parse(localStorage.getItem(posKey))
} catch {
pos = null
}
if (!pos || typeof pos.itemIndex !== 'number' || typeof pos.scrollY !== 'number') return
const targetIdx = Math.min(pos.itemIndex, total.value - 1)
if (targetIdx < 0) return
let guard = 0
while (items.value.length <= targetIdx && hasMore.value && guard < 50) {
await loadMore()
guard++
}
await nextTick()
const el = cardEls.value[targetIdx]
if (el) {
window.scrollTo(0, pos.scrollY)
}
}
onMounted(async () => {
if (!bankId) {
router.replace('/questions')
return
}
await loadFirst()
restorePosition()
})
onUnmounted(() => {
if (sentinelObserver) sentinelObserver.disconnect()
})
</script>
<template>
<div class="mx-auto max-w-2xl px-6 py-6 lg:max-w-5xl">
<div class="mb-6 flex items-center gap-2">
<IconBook2 :size="18" class="text-blue-500" />
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ bankName || '...' }}</h3>
<span class="rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-semibold text-blue-700 dark:bg-blue-900/40 dark:text-blue-400">
{{ t('quiz.learn_title') }}
</span>
<span class="ml-auto text-sm text-gray-500 dark:text-gray-400">{{ items.length }} / {{ total }}</span>
<button
class="ml-2 inline-flex items-center gap-1 text-sm text-gray-500 transition-colors hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 lg:hidden"
@click="saveAndBack"
>
<IconChevronLeft :size="16" />
{{ t('quiz.back_home') }}
</button>
</div>
<div v-if="loading" class="py-20 text-center text-gray-400">
<svg class="mx-auto mb-2 h-6 w-6 animate-spin text-gray-400" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Loading...
</div>
<template v-else>
<div class="flex flex-col gap-6 lg:flex-row">
<!-- 左栏题目 -->
<main class="min-w-0 flex-1">
<div v-if="items.length === 0" class="py-20 text-center text-gray-400">{{ t('quiz.learn_empty') }}</div>
<!-- 单列 -->
<div class="columns-1">
<div
v-for="(item, i) in items"
:key="item.id"
:ref="el => setCardRef(el, i)"
class="mb-4 break-inside-avoid rounded-xl border border-gray-200 bg-white p-6 shadow-sm dark:border-dk-muted dark:bg-dk-card"
>
<div class="mb-3 flex items-center gap-3">
<span class="flex h-7 min-w-7 items-center justify-center rounded-full bg-blue-100 px-1 text-sm font-bold text-blue-700 dark:bg-blue-900/40 dark:text-blue-400">{{ i + 1 }}</span>
<span class="rounded-full px-2.5 py-0.5 text-xs font-semibold" :class="typeColors[item.type]">{{ t(typeLabels[item.type]) }}</span>
<span class="ml-auto text-xs font-medium text-gray-400 dark:text-gray-500">{{ item.score }} {{ t('quiz.points') }}</span>
</div>
<p class="mb-4 whitespace-pre-wrap text-sm font-medium text-gray-900 dark:text-white">{{ item.title }}</p>
<!-- 选项正确高亮 -->
<div v-if="item.options.length > 0" class="mb-4 flex flex-col gap-2">
<div
v-for="(opt, oi) in item.options"
:key="oi"
class="flex items-center gap-2.5 rounded-lg border px-3.5 py-2 text-sm"
:class="isCorrectOption(item, oi)
? 'border-green-300 bg-green-50 text-green-700 dark:border-green-700 dark:bg-green-900/30 dark:text-green-300'
: 'border-gray-200 text-gray-600 dark:border-dk-muted dark:text-gray-300'"
>
<span class="shrink-0 font-semibold">{{ String.fromCharCode(65 + oi) }}.</span>
<span class="min-w-0 flex-1 break-words">{{ opt }}</span>
<IconCircleCheck v-if="isCorrectOption(item, oi)" :size="16" class="shrink-0 text-green-500" />
</div>
</div>
<!-- 答案 -->
<div class="mb-2 text-sm">
<span class="text-gray-400 dark:text-gray-500">{{ t('quiz.correct_answer') }}: </span>
<template v-if="item.type === 'judge'">
<span
class="rounded-full px-2.5 py-0.5 text-xs font-semibold"
:class="String(item.answer) === 'true'
? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400'
: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-400'"
>{{ correctText(item) }}</span>
</template>
<span v-else class="font-medium text-green-600 dark:text-green-400">{{ correctText(item) }}</span>
</div>
<!-- 解析 -->
<div
v-if="item.explain"
class="mb-3 rounded-lg border border-blue-100 bg-blue-50/60 px-4 py-2.5 text-sm leading-relaxed text-gray-700 dark:border-blue-900/40 dark:bg-blue-900/10 dark:text-gray-300"
>
<span class="font-medium text-blue-600 dark:text-blue-400">{{ t('quiz.explain') }}: </span>
<span class="whitespace-pre-wrap">{{ item.explain }}</span>
</div>
<!-- 作者(录入人) -->
<div class="flex items-center justify-end gap-1.5 text-xs text-gray-500 dark:text-gray-400">
<img :src="avatarUrl(item)" class="h-5 w-5 rounded-full object-cover" :alt="item.creatorName || ''" />
<span class="font-medium">{{ item.creatorName || '-' }}</span>
</div>
</div>
</div>
<!-- 加载更多 -->
<div v-if="hasMore" class="flex h-10 items-center justify-center text-sm text-gray-400">
<span v-if="loadingMore">{{ t('quiz.loading_more') }}</span>
</div>
<div v-else class="flex justify-center py-6 text-sm text-gray-400">{{ t('quiz.all_loaded') }}</div>
<div id="learn-sentinel" class="h-px"></div>
</main>
<!-- 右栏返回 -->
<aside class="hidden lg:block lg:w-24 lg:shrink-0">
<div class="lg:sticky lg:top-6">
<button
class="inline-flex items-center gap-1 rounded-lg border border-gray-300 px-3 py-1.5 text-sm text-gray-600 transition-colors hover:bg-gray-50 dark:border-dk-muted dark:bg-dk-base dark:text-gray-300 dark:hover:bg-dk-muted"
@click="saveAndBack"
>
<IconChevronLeft :size="16" />
{{ t('quiz.back_home') }}
</button>
</div>
</aside>
</div>
</template>
</div>
</template>
@@ -0,0 +1,408 @@
<script setup>
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useToastStore } from '@/stores/toast'
import { usePageTitle } from '@/composables/usePageTitle'
import { quizApi } from '@/api/quiz'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import { IconCircleCheck, IconClock, IconBook2 } from '@tabler/icons-vue'
usePageTitle('appname.quiz')
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const toast = useToastStore()
const loading = ref(true)
const sessionId = ref(0)
const bankName = ref('')
const durationLimit = ref(0)
const questions = ref([])
const answers = reactive({})
const seconds = ref(0)
const remaining = ref(0)
const submitting = ref(false)
const confirmShow = ref(false)
const timedOut = ref(false)
const activeIndex = ref(0)
const questionEls = ref([])
let timer = null
const typeLabels = {
single: 'quiz.type_single',
multiple: 'quiz.type_multiple',
blank: 'quiz.type_blank',
judge: 'quiz.type_judge',
}
const typeColors = {
single: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400',
multiple: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-400',
blank: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-400',
judge: 'bg-teal-100 text-teal-700 dark:bg-teal-900/40 dark:text-teal-400',
}
function isAnswered(q) {
const ans = answers[q.id]
if (q.type === 'single' || q.type === 'judge') {
return ans !== undefined && ans !== null && ans !== ''
}
if (q.type === 'multiple') {
return Array.isArray(ans) && ans.length > 0
}
if (q.type === 'blank') {
return Array.isArray(ans) && ans.some(v => (v || '').trim() !== '')
}
return false
}
const answeredCount = computed(() => {
let n = 0
for (const q of questions.value) {
if (isAnswered(q)) n++
}
return n
})
const progressPercent = computed(() => {
if (questions.value.length === 0) return 0
return Math.round((answeredCount.value / questions.value.length) * 100)
})
const hasTimeLimit = computed(() => durationLimit.value > 0)
const remainingText = computed(() => {
const r = Math.max(0, remaining.value)
const m = String(Math.floor(r / 60)).padStart(2, '0')
const s = String(r % 60).padStart(2, '0')
return `${m}:${s}`
})
const timeText = computed(() => {
const m = String(Math.floor(seconds.value / 60)).padStart(2, '0')
const s = String(seconds.value % 60).padStart(2, '0')
return `${m}:${s}`
})
function initAnswers() {
for (const q of questions.value) {
if (q.type === 'single' || q.type === 'judge') {
answers[q.id] = ''
} else if (q.type === 'multiple') {
answers[q.id] = []
} else if (q.type === 'blank') {
const n = q.blanks > 0 ? q.blanks : 1
answers[q.id] = Array.from({ length: n }, () => '')
}
}
}
function setQuestionRef(el, index) {
if (el) questionEls.value[index] = el
}
function activate(index) {
activeIndex.value = index
}
function jumpTo(index) {
activeIndex.value = index
const el = questionEls.value[index]
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
}
function toggleOption(q, idx) {
if (q.type === 'single') {
answers[q.id] = idx
} else {
const cur = answers[q.id] || []
const pos = cur.indexOf(idx)
if (pos >= 0) cur.splice(pos, 1)
else cur.push(idx)
}
}
function chooseJudge(q, val) {
answers[q.id] = val
}
async function doSubmit(force = false) {
if (submitting.value) return
submitting.value = true
try {
const answerList = questions.value.map(q => ({
qid: q.id,
answer: answers[q.id] ?? '',
}))
const { errCode, data } = await quizApi.submitQuiz(sessionId.value, seconds.value, answerList)
if (errCode === 0) {
router.replace(`/quiz/result/${sessionId.value}`)
} else if (errCode === -75) {
toast.warning(t('quiz.session_finished'))
router.replace('/quiz')
} else {
toast.error(t('message.server_error'))
if (force) router.replace('/quiz')
}
} catch {
// 拦截器已处理
if (force) router.replace('/quiz')
} finally {
submitting.value = false
}
}
function handleSubmit() {
confirmShow.value = false
doSubmit(false)
}
function onTimeout() {
if (timedOut.value) return
timedOut.value = true
if (timer) {
clearInterval(timer)
timer = null
}
toast.warning(t('quiz.timeout'))
doSubmit(true)
}
onMounted(async () => {
const bankId = parseInt(route.query.bank) || 0
if (!bankId) {
router.replace('/quiz')
return
}
try {
const { errCode, data } = await quizApi.startQuiz(bankId)
if (errCode === 0) {
sessionId.value = data.sessionId ?? 0
bankName.value = data.bankName ?? ''
durationLimit.value = data.durationLimit ?? 0
questions.value = data.questions ?? []
initAnswers()
timer = setInterval(() => {
seconds.value++
if (hasTimeLimit.value) {
remaining.value = durationLimit.value - seconds.value
if (remaining.value <= 0) onTimeout()
}
}, 1000)
} else if (errCode === -71) {
toast.error(t('quiz.no_questions'))
router.replace('/quiz')
} else if (errCode === -77) {
toast.error(t('quiz.bank_unavailable'))
router.replace('/quiz')
} else {
toast.error(t('message.server_error'))
router.replace('/quiz')
}
} catch {
router.replace('/quiz')
} finally {
loading.value = false
}
})
onUnmounted(() => {
if (timer) clearInterval(timer)
})
</script>
<template>
<div class="mx-auto max-w-5xl px-6 py-6">
<!-- Loading -->
<div v-if="loading" class="py-20 text-center text-gray-400">
<svg class="mx-auto mb-2 h-6 w-6 animate-spin text-gray-400" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Loading...
</div>
<template v-else>
<!-- 题库名 -->
<div class="mb-4 inline-flex items-center gap-1.5 text-sm font-medium text-gray-700 dark:text-gray-300">
<IconBook2 :size="16" class="text-blue-500" />
{{ bankName }}
</div>
<div class="flex flex-col gap-6 lg:flex-row">
<!-- 左栏题目 -->
<main class="min-w-0 flex-1">
<div
v-for="(q, i) in questions"
:key="q.id"
:ref="el => setQuestionRef(el, i)"
class="mb-5 cursor-pointer scroll-mt-4 rounded-xl border border-gray-200 bg-white p-6 shadow-sm dark:border-dk-muted dark:bg-dk-card"
:class="activeIndex === i ? 'border-blue-400 ring-1 ring-blue-400/40 dark:border-blue-500' : ''"
@click="activate(i)"
>
<div class="mb-4 flex items-center gap-3">
<span class="flex h-7 min-w-7 items-center justify-center rounded-full bg-blue-100 px-1 text-sm font-bold text-blue-700 dark:bg-blue-900/40 dark:text-blue-400">{{ i + 1 }}</span>
<span
class="rounded-full px-2.5 py-0.5 text-xs font-semibold"
:class="typeColors[q.type]"
>{{ t(typeLabels[q.type]) }}</span>
<span class="ml-auto text-xs font-medium text-gray-400 dark:text-gray-500">{{ q.score }} {{ t('quiz.points') }}</span>
</div>
<p class="mb-4 whitespace-pre-wrap text-sm font-medium text-gray-900 dark:text-white">{{ q.title }}</p>
<!-- 单选 -->
<div v-if="q.type === 'single'" class="flex flex-col gap-2">
<button
v-for="(opt, oi) in q.options"
:key="oi"
class="flex items-center gap-3 rounded-lg border px-4 py-2.5 text-left text-sm transition-colors"
:class="answers[q.id] === oi
? 'border-blue-500 bg-blue-50 text-blue-700 dark:border-blue-500 dark:bg-blue-900/30 dark:text-blue-300'
: 'border-gray-200 text-gray-700 hover:border-gray-300 hover:bg-gray-50 dark:border-dk-muted dark:text-gray-300 dark:hover:bg-dk-base'"
@click="toggleOption(q, oi)"
>
<span class="flex h-5 w-5 shrink-0 items-center justify-center rounded-full border"
:class="answers[q.id] === oi ? 'border-blue-500 bg-blue-500' : 'border-gray-300 dark:border-dk-muted'">
<span v-if="answers[q.id] === oi" class="h-2 w-2 rounded-full bg-white"></span>
</span>
<span>{{ opt }}</span>
</button>
</div>
<!-- 多选 -->
<div v-else-if="q.type === 'multiple'" class="flex flex-col gap-2">
<button
v-for="(opt, oi) in q.options"
:key="oi"
class="flex items-center gap-3 rounded-lg border px-4 py-2.5 text-left text-sm transition-colors"
:class="(answers[q.id] || []).includes(oi)
? 'border-purple-500 bg-purple-50 text-purple-700 dark:border-purple-500 dark:bg-purple-900/30 dark:text-purple-300'
: 'border-gray-200 text-gray-700 hover:border-gray-300 hover:bg-gray-50 dark:border-dk-muted dark:text-gray-300 dark:hover:bg-dk-base'"
@click="toggleOption(q, oi)"
>
<span class="flex h-5 w-5 shrink-0 items-center justify-center rounded border"
:class="(answers[q.id] || []).includes(oi) ? 'border-purple-500 bg-purple-500' : 'border-gray-300 dark:border-dk-muted'">
<span v-if="(answers[q.id] || []).includes(oi)" class="h-2 w-2 rounded-sm bg-white"></span>
</span>
<span>{{ opt }}</span>
</button>
</div>
<!-- 填空 -->
<div v-else-if="q.type === 'blank'" class="flex flex-col gap-3">
<div v-for="(_, bi) in (answers[q.id] || [])" :key="bi" class="flex items-center gap-3">
<span class="text-sm text-gray-400 dark:text-gray-500">{{ t('quiz.blank') }}{{ bi + 1 }}</span>
<input
v-model="answers[q.id][bi]"
type="text"
:placeholder="t('quiz.blank_placeholder')"
class="flex-1 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 outline-none transition-colors focus:border-blue-500 dark:border-dk-muted dark:bg-dk-base dark:text-white dark:placeholder-gray-500"
/>
</div>
</div>
<!-- 判断 -->
<div v-else class="flex gap-3">
<button
class="flex-1 rounded-lg border px-4 py-2.5 text-sm font-medium transition-colors"
:class="answers[q.id] === 'true'
? 'border-green-500 bg-green-50 text-green-700 dark:border-green-500 dark:bg-green-900/30 dark:text-green-300'
: 'border-gray-200 text-gray-700 hover:border-gray-300 hover:bg-gray-50 dark:border-dk-muted dark:text-gray-300 dark:hover:bg-dk-base'"
@click="chooseJudge(q, 'true')"
>
<span class="inline-flex items-center gap-1.5">
<IconCircleCheck :size="16" />
{{ t('quiz.true') }}
</span>
</button>
<button
class="flex-1 rounded-lg border px-4 py-2.5 text-sm font-medium transition-colors"
:class="answers[q.id] === 'false'
? 'border-red-500 bg-red-50 text-red-700 dark:border-red-500 dark:bg-red-900/30 dark:text-red-300'
: 'border-gray-200 text-gray-700 hover:border-gray-300 hover:bg-gray-50 dark:border-dk-muted dark:text-gray-300 dark:hover:bg-dk-base'"
@click="chooseJudge(q, 'false')"
>
{{ t('quiz.false') }}
</button>
</div>
</div>
<div v-if="questions.length === 0" class="py-16 text-center text-gray-400">{{ t('quiz.no_questions') }}</div>
</main>
<!-- 右栏计时/进度/答题卡/提交 -->
<aside class="lg:w-72 lg:shrink-0">
<div class="flex flex-col gap-4 lg:sticky lg:top-4">
<!-- 计时 -->
<div class="rounded-xl border border-gray-200 bg-white p-5 shadow-sm dark:border-dk-muted dark:bg-dk-card">
<div class="flex items-center justify-between">
<span class="inline-flex items-center gap-1.5 text-xs font-medium text-gray-500 dark:text-gray-400">
<IconClock :size="14" />
{{ hasTimeLimit ? t('quiz.remaining') : t('quiz.elapsed') }}
</span>
<span
class="font-mono text-2xl font-bold tabular-nums"
:class="hasTimeLimit && remaining <= 60 ? 'text-red-500 dark:text-red-400' : 'text-gray-900 dark:text-white'"
:title="hasTimeLimit ? t('quiz.elapsed') + ': ' + timeText : ''"
>
{{ hasTimeLimit ? remainingText : timeText }}
</span>
</div>
<div v-if="hasTimeLimit" class="mt-2 text-right text-xs text-gray-400 dark:text-gray-500">
{{ t('quiz.elapsed') }}: {{ timeText }}
</div>
</div>
<!-- 进度 -->
<div class="rounded-xl border border-gray-200 bg-white p-5 shadow-sm dark:border-dk-muted dark:bg-dk-card">
<div class="mb-2 flex items-center justify-between text-sm">
<span class="text-gray-500 dark:text-gray-400">{{ t('quiz.answered_q') }}</span>
<span class="font-semibold text-gray-900 dark:text-white">{{ answeredCount }} / {{ questions.length }}</span>
</div>
<div class="h-2 w-full overflow-hidden rounded-full bg-gray-100 dark:bg-dk-base">
<div class="h-full rounded-full bg-blue-600 transition-all" :style="{ width: progressPercent + '%' }"></div>
</div>
</div>
<!-- 答题卡 -->
<div class="rounded-xl border border-gray-200 bg-white p-5 shadow-sm dark:border-dk-muted dark:bg-dk-card">
<h4 class="mb-3 text-sm font-semibold text-gray-900 dark:text-white">{{ t('quiz.card_title') }}</h4>
<div class="grid grid-cols-8 gap-1.5">
<button
v-for="(q, i) in questions"
:key="q.id"
class="h-8 rounded-md text-xs font-medium transition-colors"
:class="[
isAnswered(q)
? 'bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300'
: 'bg-gray-100 text-gray-500 hover:bg-gray-200 dark:bg-dk-base dark:text-gray-400 dark:hover:bg-dk-muted',
activeIndex === i ? 'ring-2 ring-blue-500 ring-offset-1 dark:ring-offset-dk-card' : '',
]"
@click="jumpTo(i)"
>{{ i + 1 }}</button>
</div>
</div>
<!-- 提交 -->
<button
class="inline-flex items-center justify-center gap-1.5 rounded-lg bg-blue-600 px-6 py-2.5 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:opacity-50"
:disabled="submitting"
@click="confirmShow = true"
>
{{ t('quiz.submit') }}
</button>
</div>
</aside>
</div>
</template>
<ConfirmDialog
v-model="confirmShow"
:title="t('quiz.submit_confirm_title')"
:message="t('quiz.submit_confirm')"
@confirm="handleSubmit"
/>
</div>
</template>
@@ -0,0 +1,429 @@
<script setup>
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useToastStore } from '@/stores/toast'
import { usePageTitle } from '@/composables/usePageTitle'
import { quizApi } from '@/api/quiz'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import { IconChevronLeft, IconCircleCheck, IconCircleX, IconClock, IconTrophy } from '@tabler/icons-vue'
usePageTitle('appname.quiz')
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const toast = useToastStore()
const sessionId = Number(route.params.id) || 0
const loading = ref(true)
const questions = ref([])
const answers = reactive({})
const seconds = ref(0)
const submitting = ref(false)
const confirmShow = ref(false)
const result = ref(null)
let timer = null
const typeLabels = {
single: 'quiz.type_single',
multiple: 'quiz.type_multiple',
blank: 'quiz.type_blank',
judge: 'quiz.type_judge',
}
const typeColors = {
single: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400',
multiple: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-400',
blank: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-400',
judge: 'bg-teal-100 text-teal-700 dark:bg-teal-900/40 dark:text-teal-400',
}
const answeredCount = computed(() => {
let n = 0
for (const q of questions.value) {
const ans = answers[q.qid]
if (q.type === 'single' || q.type === 'judge') {
if (ans !== undefined && ans !== null && ans !== '') n++
} else if (q.type === 'multiple') {
if (Array.isArray(ans) && ans.length > 0) n++
} else if (q.type === 'blank') {
const arr = Array.isArray(ans) ? ans : []
const has = arr.some(v => (v || '').trim() !== '')
if (has) n++
}
}
return n
})
const timeText = computed(() => {
const m = String(Math.floor(seconds.value / 60)).padStart(2, '0')
const s = String(seconds.value % 60).padStart(2, '0')
return `${m}:${s}`
})
function initAnswers() {
for (const q of questions.value) {
if (q.type === 'single' || q.type === 'judge') {
answers[q.qid] = ''
} else if (q.type === 'multiple') {
answers[q.qid] = []
} else if (q.type === 'blank') {
answers[q.qid] = Array.from({ length: q.blanks }, () => '')
}
}
}
function toggleOption(q, idx) {
if (q.type === 'single') {
answers[q.qid] = idx
} else {
const cur = answers[q.qid] || []
const pos = cur.indexOf(idx)
if (pos >= 0) cur.splice(pos, 1)
else cur.push(idx)
}
}
function chooseJudge(q, val) {
answers[q.qid] = val
}
async function doSubmit() {
if (submitting.value) return
submitting.value = true
try {
const answerList = questions.value.map(q => ({
qid: q.qid,
answer: answers[q.qid] ?? '',
}))
const { errCode, data } = await quizApi.redoQuiz(sessionId, seconds.value, answerList)
if (errCode === 0) {
result.value = data
if (timer) clearInterval(timer)
} else {
toast.error(t('message.server_error'))
}
} catch {
// 拦截器已处理
} finally {
submitting.value = false
}
}
function handleSubmit() {
confirmShow.value = false
doSubmit()
}
function restart() {
result.value = null
seconds.value = 0
initAnswers()
timer = setInterval(() => { seconds.value++ }, 1000)
}
function judgeLabel(val) {
if (val === undefined || val === null || val === '') return t('quiz.no_answer')
return String(val) === 'true' ? t('quiz.true') : t('quiz.false')
}
function answerText(vals) {
const arr = vals || []
if (arr.length === 0) return t('quiz.no_answer')
return arr.join(', ')
}
onMounted(async () => {
if (!sessionId) {
router.replace('/questions')
return
}
try {
const { errCode, data } = await quizApi.getSession(sessionId)
if (errCode === 0) {
const wrong = (data.review ?? []).filter(r => !r.isCorrect)
if (wrong.length === 0) {
toast.info(t('quiz.redo_none'))
router.replace('/questions')
return
}
questions.value = wrong.map(r => ({
qid: r.qid,
type: r.type,
title: r.title,
options: r.options || [],
score: r.score,
blanks: r.type === 'blank' ? (r.correctAnswerText?.length || 1) : 0,
}))
initAnswers()
timer = setInterval(() => { seconds.value++ }, 1000)
} else if (errCode === -73) {
toast.error(t('message.server_error'))
router.replace('/questions')
} else {
toast.error(t('message.server_error'))
router.replace('/questions')
}
} catch {
router.replace('/questions')
} finally {
loading.value = false
}
})
onUnmounted(() => {
if (timer) clearInterval(timer)
})
</script>
<template>
<div class="mx-auto max-w-4xl px-6 py-6">
<button
class="mb-4 inline-flex items-center gap-1 text-sm text-gray-500 transition-colors hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
@click="router.push('/questions')"
>
<IconChevronLeft :size="16" />
{{ t('quiz.back_home') }}
</button>
<!-- 成绩(本地) -->
<div v-if="result" class="mb-6 flex flex-col items-center rounded-xl border border-gray-200 bg-white p-8 shadow-sm dark:border-dk-muted dark:bg-dk-card">
<span class="inline-flex items-center gap-1.5 text-sm font-medium text-amber-600 dark:text-amber-400">
{{ t('quiz.redo_local_hint') }}
</span>
<span
class="mt-3 inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm font-semibold"
:class="result.totalScore > 0 && result.score / result.totalScore >= 0.6
? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400'
: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-400'"
>
<IconTrophy :size="16" />
{{ result.totalScore > 0 && result.score / result.totalScore >= 0.6 ? t('quiz.result_pass') : t('quiz.result_fail') }}
</span>
<div class="mt-4 flex items-end gap-2">
<span class="text-5xl font-bold text-gray-900 dark:text-white">{{ result.score }}</span>
<span class="mb-1 text-lg text-gray-400 dark:text-gray-500">/ {{ result.totalScore }}</span>
</div>
<div class="mt-5 grid grid-cols-2 gap-x-12 gap-y-2 text-center sm:grid-cols-4">
<div>
<div class="text-sm text-gray-400 dark:text-gray-500">{{ t('quiz.correct') }}</div>
<div class="text-lg font-semibold text-green-600 dark:text-green-400">{{ result.correct }}</div>
</div>
<div>
<div class="text-sm text-gray-400 dark:text-gray-500">{{ t('quiz.wrong') }}</div>
<div class="text-lg font-semibold text-red-500 dark:text-red-400">{{ result.wrong }}</div>
</div>
<div>
<div class="text-sm text-gray-400 dark:text-gray-500">{{ t('quiz.questions_title') }}</div>
<div class="text-lg font-semibold text-gray-900 dark:text-white">{{ result.correct + result.wrong }}</div>
</div>
<div>
<div class="text-sm text-gray-400 dark:text-gray-500">{{ t('quiz.duration') }}</div>
<div class="text-lg font-semibold text-gray-900 dark:text-white">{{ result.durationSec }}<span class="text-xs font-normal text-gray-400">s</span></div>
</div>
</div>
<div class="mt-6 flex gap-2">
<button
class="rounded-lg border border-gray-300 px-4 py-1.5 text-sm text-gray-600 transition-colors hover:bg-gray-50 dark:border-dk-muted dark:bg-dk-base dark:text-gray-300 dark:hover:bg-dk-muted"
@click="restart"
>
{{ t('quiz.redo_again') }}
</button>
<button
class="rounded-lg bg-blue-600 px-4 py-1.5 text-sm font-medium text-white transition-colors hover:bg-blue-700"
@click="router.push('/questions')"
>
{{ t('quiz.back_home') }}
</button>
</div>
</div>
<!-- Loading -->
<div v-else-if="loading" class="py-20 text-center text-gray-400">
<svg class="mx-auto mb-2 h-6 w-6 animate-spin text-gray-400" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Loading...
</div>
<template v-else>
<!-- 顶部: 标题+计时 -->
<div class="mb-6 flex items-center gap-3 rounded-xl border border-gray-200 bg-white px-6 py-3 shadow-sm dark:border-dk-muted dark:bg-dk-card">
<span class="inline-flex items-center gap-1.5 text-sm font-medium text-gray-700 dark:text-gray-300">
<IconClock :size="16" />
{{ t('quiz.elapsed') }}: {{ timeText }}
</span>
<span class="ml-auto text-sm text-gray-500 dark:text-gray-400">{{ t('quiz.redo_title') }} {{ answeredCount }} / {{ questions.length }}</span>
</div>
<div
v-for="(q, i) in questions"
:key="q.qid"
class="mb-5 rounded-xl border border-gray-200 bg-white p-6 shadow-sm dark:border-dk-muted dark:bg-dk-card"
>
<div class="mb-4 flex items-center gap-3">
<span class="flex h-7 min-w-7 items-center justify-center rounded-full bg-blue-100 px-1 text-sm font-bold text-blue-700 dark:bg-blue-900/40 dark:text-blue-400">{{ i + 1 }}</span>
<span class="rounded-full px-2.5 py-0.5 text-xs font-semibold" :class="typeColors[q.type]">{{ t(typeLabels[q.type]) }}</span>
<span class="ml-auto text-xs font-medium text-gray-400 dark:text-gray-500">{{ q.score }} {{ t('quiz.points') }}</span>
</div>
<p class="mb-4 whitespace-pre-wrap text-sm font-medium text-gray-900 dark:text-white">{{ q.title }}</p>
<!-- 单选 -->
<div v-if="q.type === 'single'" class="flex flex-col gap-2">
<button
v-for="(opt, oi) in q.options"
:key="oi"
class="flex items-center gap-3 rounded-lg border px-4 py-2.5 text-left text-sm transition-colors"
:class="answers[q.qid] === oi
? 'border-blue-500 bg-blue-50 text-blue-700 dark:border-blue-500 dark:bg-blue-900/30 dark:text-blue-300'
: 'border-gray-200 text-gray-700 hover:border-gray-300 hover:bg-gray-50 dark:border-dk-muted dark:text-gray-300 dark:hover:bg-dk-base'"
@click="toggleOption(q, oi)"
>
<span class="flex h-5 w-5 shrink-0 items-center justify-center rounded-full border"
:class="answers[q.qid] === oi ? 'border-blue-500 bg-blue-500' : 'border-gray-300 dark:border-dk-muted'">
<span v-if="answers[q.qid] === oi" class="h-2 w-2 rounded-full bg-white"></span>
</span>
<span>{{ opt }}</span>
</button>
</div>
<!-- 多选 -->
<div v-else-if="q.type === 'multiple'" class="flex flex-col gap-2">
<button
v-for="(opt, oi) in q.options"
:key="oi"
class="flex items-center gap-3 rounded-lg border px-4 py-2.5 text-left text-sm transition-colors"
:class="(answers[q.qid] || []).includes(oi)
? 'border-purple-500 bg-purple-50 text-purple-700 dark:border-purple-500 dark:bg-purple-900/30 dark:text-purple-300'
: 'border-gray-200 text-gray-700 hover:border-gray-300 hover:bg-gray-50 dark:border-dk-muted dark:text-gray-300 dark:hover:bg-dk-base'"
@click="toggleOption(q, oi)"
>
<span class="flex h-5 w-5 shrink-0 items-center justify-center rounded border"
:class="(answers[q.qid] || []).includes(oi) ? 'border-purple-500 bg-purple-500' : 'border-gray-300 dark:border-dk-muted'">
<span v-if="(answers[q.qid] || []).includes(oi)" class="h-2 w-2 rounded-sm bg-white"></span>
</span>
<span>{{ opt }}</span>
</button>
</div>
<!-- 填空 -->
<div v-else-if="q.type === 'blank'" class="flex flex-col gap-3">
<div v-for="(_, bi) in (answers[q.qid] || [])" :key="bi" class="flex items-center gap-3">
<span class="text-sm text-gray-400 dark:text-gray-500">{{ t('quiz.blank') }}{{ bi + 1 }}</span>
<input
v-model="answers[q.qid][bi]"
type="text"
:placeholder="t('quiz.blank_placeholder')"
class="flex-1 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 outline-none transition-colors focus:border-blue-500 dark:border-dk-muted dark:bg-dk-base dark:text-white dark:placeholder-gray-500"
/>
</div>
</div>
<!-- 判断 -->
<div v-else class="flex gap-3">
<button
class="flex-1 rounded-lg border px-4 py-2.5 text-sm font-medium transition-colors"
:class="answers[q.qid] === 'true'
? 'border-green-500 bg-green-50 text-green-700 dark:border-green-500 dark:bg-green-900/30 dark:text-green-300'
: 'border-gray-200 text-gray-700 hover:border-gray-300 hover:bg-gray-50 dark:border-dk-muted dark:text-gray-300 dark:hover:bg-dk-base'"
@click="chooseJudge(q, 'true')"
>
{{ t('quiz.true') }}
</button>
<button
class="flex-1 rounded-lg border px-4 py-2.5 text-sm font-medium transition-colors"
:class="answers[q.qid] === 'false'
? 'border-red-500 bg-red-50 text-red-700 dark:border-red-500 dark:bg-red-900/30 dark:text-red-300'
: 'border-gray-200 text-gray-700 hover:border-gray-300 hover:bg-gray-50 dark:border-dk-muted dark:text-gray-300 dark:hover:bg-dk-base'"
@click="chooseJudge(q, 'false')"
>
{{ t('quiz.false') }}
</button>
</div>
</div>
<!-- 提交 -->
<div class="flex justify-center pb-8 pt-2">
<button
class="inline-flex items-center gap-1.5 rounded-lg bg-blue-600 px-10 py-2.5 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:opacity-50"
:disabled="submitting"
@click="confirmShow = true"
>
{{ t('quiz.submit') }}
</button>
</div>
</template>
<!-- 本地结果: 逐题解析 -->
<div v-if="result" class="rounded-xl border border-gray-200 bg-white shadow-sm dark:border-dk-muted dark:bg-dk-card">
<div class="border-b border-gray-100 px-6 py-4 dark:border-dk-muted">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('quiz.review') }}</h3>
</div>
<div class="flex flex-col gap-5 p-6">
<div
v-for="item in result.review"
:key="item.qid"
class="rounded-lg border p-5"
:class="item.isCorrect
? 'border-green-200 bg-green-50/50 dark:border-green-900/40 dark:bg-green-900/10'
: 'border-red-200 bg-red-50/50 dark:border-red-900/40 dark:bg-red-900/10'"
>
<div class="mb-3 flex items-center gap-3">
<span class="flex h-6 min-w-6 items-center justify-center rounded-full bg-gray-100 px-1 text-xs font-bold text-gray-600 dark:bg-dk-base dark:text-gray-400">{{ item.index }}</span>
<span class="rounded-full px-2.5 py-0.5 text-xs font-semibold" :class="typeColors[item.type]">{{ t(typeLabels[item.type]) }}</span>
<span
class="ml-auto inline-flex items-center gap-1 text-xs font-semibold"
:class="item.isCorrect ? 'text-green-600 dark:text-green-400' : 'text-red-500 dark:text-red-400'"
>
<IconCircleCheck v-if="item.isCorrect" :size="15" />
<IconCircleX v-else :size="15" />
{{ item.isCorrect ? '+' + item.gotScore : '0' }} / {{ item.score }}
</span>
</div>
<p class="mb-3 whitespace-pre-wrap text-sm font-medium text-gray-900 dark:text-white">{{ item.title }}</p>
<div v-if="item.options.length > 0" class="mb-3 flex flex-col gap-1.5">
<div
v-for="(opt, oi) in item.options"
:key="oi"
class="rounded-md px-3 py-1.5 text-sm"
:class="item.correctAnswerText.includes(opt)
? 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300'
: 'bg-gray-50 text-gray-600 dark:bg-dk-base dark:text-gray-400'"
>
{{ String.fromCharCode(65 + oi) }}. {{ opt }}
</div>
</div>
<div class="flex flex-col gap-1.5 sm:flex-row sm:gap-8">
<div class="text-sm">
<span class="text-gray-400 dark:text-gray-500">{{ t('quiz.your_answer') }}: </span>
<span class="font-medium" :class="item.isCorrect ? 'text-green-600 dark:text-green-400' : 'text-red-500 dark:text-red-400'">
{{ item.type === 'judge' ? judgeLabel(item.yourAnswerText?.[0]) : answerText(item.yourAnswerText) }}
</span>
</div>
<div v-if="!item.isCorrect" class="text-sm">
<span class="text-gray-400 dark:text-gray-500">{{ t('quiz.correct_answer') }}: </span>
<span class="font-medium text-green-600 dark:text-green-400">
{{ item.type === 'judge' ? judgeLabel(item.correctAnswerText?.[0]) : answerText(item.correctAnswerText) }}
</span>
</div>
</div>
<div
v-if="item.explain"
class="mt-3 rounded-lg border border-blue-100 bg-blue-50/50 px-4 py-2.5 text-sm leading-relaxed text-gray-700 dark:border-blue-900/40 dark:bg-blue-900/10 dark:text-gray-300"
>
<span class="font-medium text-blue-600 dark:text-blue-400">{{ t('quiz.explain') }}: </span>
<span class="whitespace-pre-wrap">{{ item.explain }}</span>
</div>
</div>
</div>
</div>
<ConfirmDialog
v-model="confirmShow"
:title="t('quiz.submit_confirm_title')"
:message="t('quiz.submit_confirm')"
@confirm="handleSubmit"
/>
</div>
</template>
@@ -0,0 +1,209 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useToastStore } from '@/stores/toast'
import { usePageTitle } from '@/composables/usePageTitle'
import { quizApi } from '@/api/quiz'
import { IconChevronLeft, IconCircleCheck, IconCircleX, IconTrophy, IconBook2 } from '@tabler/icons-vue'
usePageTitle('appname.quiz')
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const toast = useToastStore()
const loading = ref(true)
const session = ref(null)
const review = ref([])
const typeLabels = {
single: 'quiz.type_single',
multiple: 'quiz.type_multiple',
blank: 'quiz.type_blank',
judge: 'quiz.type_judge',
}
const typeColors = {
single: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400',
multiple: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-400',
blank: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-400',
judge: 'bg-teal-100 text-teal-700 dark:bg-teal-900/40 dark:text-teal-400',
}
const scorePercent = computed(() => {
if (!session.value || !session.value.totalScore) return 0
return Math.round((session.value.score / session.value.totalScore) * 100)
})
const passText = computed(() => {
const s = session.value
if (!s || !s.totalScore) return t('quiz.result_pass')
return scorePercent.value >= 60 ? t('quiz.result_pass') : t('quiz.result_fail')
})
function judgeLabel(val) {
if (val === undefined || val === null || val === '') return t('quiz.no_answer')
return String(val) === 'true' ? t('quiz.true') : t('quiz.false')
}
function answerText(item) {
const vals = item.yourAnswerText || []
if (vals.length === 0) return t('quiz.no_answer')
return vals.join(', ')
}
onMounted(async () => {
try {
const sessionId = Number(route.params.id) || 0
if (!sessionId) {
loading.value = false
toast.error(t('message.server_error'))
router.replace('/quiz')
return
}
const { errCode, data } = await quizApi.getSession(sessionId)
if (errCode === 0) {
session.value = data.session ?? null
review.value = data.review ?? []
} else {
toast.error(t('message.server_error'))
router.replace('/quiz')
}
} catch {
//
} finally {
loading.value = false
}
})
</script>
<template>
<div class="mx-auto max-w-4xl px-6 py-6">
<button
class="mb-4 inline-flex items-center gap-1 text-sm text-gray-500 transition-colors hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
@click="router.push('/quiz')"
>
<IconChevronLeft :size="16" />
{{ t('quiz.back_home') }}
</button>
<div v-if="loading" class="py-20 text-center text-gray-400">
<svg class="mx-auto mb-2 h-6 w-6 animate-spin text-gray-400" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Loading...
</div>
<template v-else-if="session">
<!-- Score card -->
<div class="mb-6 flex flex-col items-center rounded-xl border border-gray-200 bg-white p-8 shadow-sm dark:border-dk-muted dark:bg-dk-card">
<span v-if="session.bankName" class="inline-flex items-center gap-1.5 text-sm font-medium text-gray-600 dark:text-gray-300">
<IconBook2 :size="15" class="text-blue-500" />
{{ session.bankName }}
</span>
<span
class="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm font-semibold"
:class="scorePercent >= 60
? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400'
: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-400'"
>
<IconTrophy :size="16" />
{{ passText }}
</span>
<div class="mt-4 flex items-end gap-2">
<span class="text-5xl font-bold text-gray-900 dark:text-white">{{ session.score }}</span>
<span class="mb-1 text-lg text-gray-400 dark:text-gray-500">/ {{ session.totalScore }}</span>
</div>
<div class="mt-5 grid grid-cols-2 gap-x-12 gap-y-2 text-center sm:grid-cols-4">
<div>
<div class="text-sm text-gray-400 dark:text-gray-500">{{ t('quiz.correct') }}</div>
<div class="text-lg font-semibold text-green-600 dark:text-green-400">{{ session.correctCount }}</div>
</div>
<div>
<div class="text-sm text-gray-400 dark:text-gray-500">{{ t('quiz.wrong') }}</div>
<div class="text-lg font-semibold text-red-500 dark:text-red-400">{{ session.wrongCount }}</div>
</div>
<div>
<div class="text-sm text-gray-400 dark:text-gray-500">{{ t('quiz.questions_title') }}</div>
<div class="text-lg font-semibold text-gray-900 dark:text-white">{{ session.count }}</div>
</div>
<div>
<div class="text-sm text-gray-400 dark:text-gray-500">{{ t('quiz.duration') }}</div>
<div class="text-lg font-semibold text-gray-900 dark:text-white">{{ session.durationSec }}<span class="text-xs font-normal text-gray-400">s</span></div>
</div>
</div>
</div>
<!-- Review -->
<div class="rounded-xl border border-gray-200 bg-white shadow-sm dark:border-dk-muted dark:bg-dk-card">
<div class="border-b border-gray-100 px-6 py-4 dark:border-dk-muted">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('quiz.review') }}</h3>
</div>
<div class="flex flex-col gap-5 p-6">
<div
v-for="item in review"
:key="item.qid"
class="rounded-lg border p-5"
:class="item.isCorrect
? 'border-green-200 bg-green-50/50 dark:border-green-900/40 dark:bg-green-900/10'
: 'border-red-200 bg-red-50/50 dark:border-red-900/40 dark:bg-red-900/10'"
>
<div class="mb-3 flex items-center gap-3">
<span class="flex h-6 min-w-6 items-center justify-center rounded-full bg-gray-100 px-1 text-xs font-bold text-gray-600 dark:bg-dk-base dark:text-gray-400">{{ item.index }}</span>
<span class="rounded-full px-2.5 py-0.5 text-xs font-semibold" :class="typeColors[item.type]">{{ t(typeLabels[item.type]) }}</span>
<span
class="ml-auto inline-flex items-center gap-1 text-xs font-semibold"
:class="item.isCorrect ? 'text-green-600 dark:text-green-400' : 'text-red-500 dark:text-red-400'"
>
<IconCircleCheck v-if="item.isCorrect" :size="15" />
<IconCircleX v-else :size="15" />
{{ item.isCorrect ? '+' + item.gotScore : '0' }} / {{ item.score }}
</span>
</div>
<p class="mb-3 whitespace-pre-wrap text-sm font-medium text-gray-900 dark:text-white">{{ item.title }}</p>
<div v-if="item.options.length > 0" class="mb-3 flex flex-col gap-1.5">
<div
v-for="(opt, oi) in item.options"
:key="oi"
class="rounded-md px-3 py-1.5 text-sm"
:class="item.correctAnswerText.includes(opt)
? 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300'
: 'bg-gray-50 text-gray-600 dark:bg-dk-base dark:text-gray-400'"
>
{{ String.fromCharCode(65 + oi) }}. {{ opt }}
</div>
</div>
<div class="flex flex-col gap-1.5 sm:flex-row sm:gap-8">
<div class="text-sm">
<span class="text-gray-400 dark:text-gray-500">{{ t('quiz.your_answer') }}: </span>
<span class="font-medium" :class="item.isCorrect ? 'text-green-600 dark:text-green-400' : 'text-red-500 dark:text-red-400'">
{{ item.type === 'judge' ? judgeLabel(item.yourAnswerText?.[0]) : answerText(item) }}
</span>
</div>
<div v-if="!item.isCorrect" class="text-sm">
<span class="text-gray-400 dark:text-gray-500">{{ t('quiz.correct_answer') }}: </span>
<span class="font-medium text-green-600 dark:text-green-400">
{{ item.type === 'judge' ? judgeLabel(item.correctAnswerText?.[0]) : (item.correctAnswerText || []).join(', ') }}
</span>
</div>
</div>
<div
v-if="item.explain"
class="mt-3 rounded-lg border border-blue-100 bg-blue-50/50 px-4 py-2.5 text-sm leading-relaxed text-gray-700 dark:border-blue-900/40 dark:bg-blue-900/10 dark:text-gray-300"
>
<span class="font-medium text-blue-600 dark:text-blue-400">{{ t('quiz.explain') }}: </span>
<span class="whitespace-pre-wrap">{{ item.explain }}</span>
</div>
</div>
<div v-if="review.length === 0" class="py-8 text-center text-gray-400">{{ t('quiz.review_empty') }}</div>
</div>
</div>
</template>
</div>
</template>