新增答题模块:题库管理、按题型抽题、判分、成绩/排行、错题重做与学习模式
- 后端 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:
17 files changed
+5123
-5
No files matched your search
@@ -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
|
||||
|
||||
|
||||
}
|
||||
@@ -82,6 +82,7 @@ func main() {
|
||||
routers.ApiWarehouseInit()
|
||||
routers.ApiCustomerInit()
|
||||
routers.ApiCalendarInit()
|
||||
routers.ApiQuizInit()
|
||||
routers.ApiAIChatInit()
|
||||
|
||||
routers.BindsInit() //最后初始化绑定数据表
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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": "题库管理"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user