chore: 前端首次基线提交
运行必须源码:src/、public/、index.html、package.json 及构建配置。 已忽略运行非必须:node_modules/、dist/、.env*。
This commit is contained in:
156
src/stores/auth.js
Normal file
156
src/stores/auth.js
Normal file
@@ -0,0 +1,156 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import * as authApi from '@/api/auth'
|
||||
|
||||
// ==================== 认证状态管理 ====================
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
// ==================== 状态 ====================
|
||||
|
||||
// 当前用户信息
|
||||
const currentUser = ref(
|
||||
JSON.parse(localStorage.getItem('user') || 'null')
|
||||
)
|
||||
|
||||
// 认证令牌
|
||||
const token = ref(localStorage.getItem('token') || '')
|
||||
|
||||
// ==================== 计算属性(Getters) ====================
|
||||
|
||||
// 是否已登录
|
||||
const isLoggedIn = computed(() => !!token.value)
|
||||
|
||||
// 是否为管理员
|
||||
const isAdmin = computed(() => {
|
||||
if (!currentUser.value) return false
|
||||
return (
|
||||
currentUser.value.role === 'admin' ||
|
||||
currentUser.value.isAdmin === true
|
||||
)
|
||||
})
|
||||
|
||||
// 用户名称(优先显示姓名,其次用户名)
|
||||
const userName = computed(() => {
|
||||
if (!currentUser.value) return ''
|
||||
return currentUser.value.name || currentUser.value.username || ''
|
||||
})
|
||||
|
||||
// 用户所属组别
|
||||
const userGroup = computed(() => {
|
||||
return currentUser.value?.group || ''
|
||||
})
|
||||
|
||||
// ==================== 私有辅助方法 ====================
|
||||
|
||||
/**
|
||||
* 持久化存储 token 和用户信息到 localStorage
|
||||
*/
|
||||
function persistAuth(tokenValue, userData) {
|
||||
token.value = tokenValue
|
||||
currentUser.value = userData
|
||||
localStorage.setItem('token', tokenValue)
|
||||
localStorage.setItem('user', JSON.stringify(userData))
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除本地存储的认证信息
|
||||
*/
|
||||
function clearAuth() {
|
||||
token.value = ''
|
||||
currentUser.value = null
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
}
|
||||
|
||||
// ==================== 操作方法(Actions) ====================
|
||||
|
||||
/**
|
||||
* 参赛者登录(身份证号 + 密码)
|
||||
* @param {string} idNumber - 身份证号
|
||||
* @param {string} password - 密码
|
||||
* @returns {Promise<object>} 用户信息
|
||||
*/
|
||||
async function login(phone, password) {
|
||||
const result = await authApi.login(phone, password)
|
||||
// 后端应返回 { token, user }
|
||||
persistAuth(result.token, result.user)
|
||||
return result.user
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员登录
|
||||
* @param {string} username - 管理员用户名
|
||||
* @param {string} password - 密码
|
||||
* @returns {Promise<object>} 管理员信息
|
||||
*/
|
||||
async function adminLogin(username, password) {
|
||||
const result = await authApi.adminLogin(username, password)
|
||||
// 后端应返回 { token, user }
|
||||
persistAuth(result.token, result.user)
|
||||
return result.user
|
||||
}
|
||||
|
||||
/**
|
||||
* 激活账号(设置密码)
|
||||
* @param {object} data - { phone, password, smsCode }
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async function activate(data) {
|
||||
const result = await authApi.activate(data)
|
||||
// 激活成功后自动登录
|
||||
if (result.token && result.user) {
|
||||
persistAuth(result.token, result.user)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 退出登录
|
||||
*/
|
||||
async function logout() {
|
||||
try {
|
||||
// 尝试通知后端登出
|
||||
await authApi.logout()
|
||||
} catch (e) {
|
||||
// 即使后端请求失败,也清除本地状态
|
||||
console.warn('登出请求失败,仍将清除本地状态', e)
|
||||
}
|
||||
clearAuth()
|
||||
// 跳转到登录页
|
||||
window.location.href = '/login'
|
||||
}
|
||||
|
||||
/**
|
||||
* 从服务端获取最新用户信息
|
||||
* @returns {Promise<object>} 用户信息
|
||||
*/
|
||||
async function fetchCurrentUser() {
|
||||
const user = await authApi.getCurrentUser()
|
||||
currentUser.value = user
|
||||
localStorage.setItem('user', JSON.stringify(user))
|
||||
return user
|
||||
}
|
||||
|
||||
// ==================== 导出 ====================
|
||||
|
||||
return {
|
||||
// 状态
|
||||
currentUser,
|
||||
token,
|
||||
// 计算属性
|
||||
isLoggedIn,
|
||||
isAdmin,
|
||||
userName,
|
||||
userGroup,
|
||||
// 操作
|
||||
login,
|
||||
adminLogin,
|
||||
activate,
|
||||
logout,
|
||||
fetchCurrentUser,
|
||||
// 工具方法
|
||||
clearAuth,
|
||||
persistAuth,
|
||||
}
|
||||
})
|
||||
295
src/stores/exam.js
Normal file
295
src/stores/exam.js
Normal file
@@ -0,0 +1,295 @@
|
||||
import { ref, computed, reactive } from 'vue'
|
||||
import * as examApi from '@/api/exam'
|
||||
import * as uploadApi from '@/api/upload'
|
||||
|
||||
// ==================== 考试状态管理 ====================
|
||||
|
||||
const currentGroup = ref('')
|
||||
const questions = ref([])
|
||||
const answers = ref({})
|
||||
const countdown = ref(0)
|
||||
const examStarted = ref(false)
|
||||
const wordTemplate = ref(null)
|
||||
const wordUploaded = ref(false)
|
||||
const totalQuestions = ref(0)
|
||||
|
||||
// 考试 session 标识(防止返回重新进入)
|
||||
let examSessionId = ''
|
||||
let timerInterval = null
|
||||
let resyncInterval = null
|
||||
let finalCheckInFlight = false
|
||||
|
||||
// ==================== 计算属性 ====================
|
||||
|
||||
const countdownDisplay = computed(() => {
|
||||
const total = countdown.value
|
||||
if (total <= 0) return '00:00'
|
||||
const h = Math.floor(total / 3600)
|
||||
const m = Math.floor((total % 3600) / 60)
|
||||
const s = total % 60
|
||||
if (h > 0) return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
|
||||
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
|
||||
})
|
||||
|
||||
const isTimeUp = computed(() => countdown.value <= 0 && examStarted.value)
|
||||
|
||||
const progress = computed(() => {
|
||||
if (questions.value.length === 0) return 0
|
||||
return Math.round((Object.keys(answers.value).length / questions.value.length) * 100)
|
||||
})
|
||||
|
||||
const choiceQuestions = computed(() => questions.value.filter(q => q.question_type === 'choice'))
|
||||
const judgeQuestions = computed(() => questions.value.filter(q => q.question_type === 'judge'))
|
||||
|
||||
// ==================== 内部函数 ====================
|
||||
|
||||
function parseOptions(q) {
|
||||
if (!q) return q
|
||||
// options 可能是 JSON 字符串(TEXT 列),需要解析
|
||||
if (typeof q.options === 'string') {
|
||||
try { q.options = JSON.parse(q.options) } catch (e) { q.options = [] }
|
||||
}
|
||||
if (!Array.isArray(q.options)) q.options = []
|
||||
// 后端已返回全局唯一的 question_uid(题型_自增id,如 choice_12 / judge_3)。
|
||||
// 这里仅作兜底:若后端未给,则按「题型_id」重建,避免 choice#1 与 judge#1 因纯数字 id 相同而键碰撞。
|
||||
if (q.id !== undefined && (q.question_uid === undefined || q.question_uid === null)) {
|
||||
q.question_uid = (q.question_type ? q.question_type + '_' : '') + q.id
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
/** 与服务器对时:返回考试/提交状态(网络异常返回 null,继续本地倒计时兜底) */
|
||||
async function fetchServerStatus() {
|
||||
if (!currentGroup.value) return null
|
||||
try {
|
||||
// 提交组(本科/教师)的剩余时间来自上传截止时间(GET /api/upload/:group/status 的 deadline),
|
||||
// 与考试组(中职/高职)的 GET /api/exam/:group/status 不同口径,需按组别分支。
|
||||
if (['benke', 'jiaoshi'].includes(currentGroup.value)) {
|
||||
const up = await uploadApi.getUploadStatus(currentGroup.value)
|
||||
if (up && up.deadline) {
|
||||
const deadline = new Date(up.deadline).getTime()
|
||||
const cd = Math.max(0, Math.floor((deadline - Date.now()) / 1000))
|
||||
return { status: cd > 0 ? 'in_progress' : 'ended', countdown: cd }
|
||||
}
|
||||
return null
|
||||
}
|
||||
const st = await examApi.getExamStatus(currentGroup.value)
|
||||
if (st && st.countdown !== undefined) return st
|
||||
} catch (e) { /* 网络异常时忽略,本地倒计时继续 */ }
|
||||
return null
|
||||
}
|
||||
|
||||
function startTimer() {
|
||||
if (timerInterval) clearInterval(timerInterval)
|
||||
if (resyncInterval) clearInterval(resyncInterval)
|
||||
|
||||
timerInterval = setInterval(async () => {
|
||||
if (countdown.value > 1) {
|
||||
countdown.value--
|
||||
} else if (!finalCheckInFlight) {
|
||||
// 即将归零:以服务器为最终权威再确认一次,
|
||||
// 防止管理员中途延长 end_time 后客户端仍按旧倒计时提前自动交卷
|
||||
finalCheckInFlight = true
|
||||
const st = await fetchServerStatus()
|
||||
finalCheckInFlight = false
|
||||
if (st && st.status === 'in_progress' && st.countdown > 0) {
|
||||
countdown.value = st.countdown // 考试时间被延长,继续作答
|
||||
} else {
|
||||
countdown.value = 0 // 服务器确认已结束(或网络不可达按本地时间兜底)
|
||||
clearInterval(timerInterval)
|
||||
timerInterval = null
|
||||
if (resyncInterval) { clearInterval(resyncInterval); resyncInterval = null }
|
||||
}
|
||||
}
|
||||
}, 1000)
|
||||
|
||||
// 周期对时(30s):吸收本地计时漂移,并及时感知管理员延长/缩短考试时间
|
||||
resyncInterval = setInterval(async () => {
|
||||
const st = await fetchServerStatus()
|
||||
if (st && st.status === 'in_progress' && typeof st.countdown === 'number') {
|
||||
countdown.value = st.countdown
|
||||
} else if (st && st.status === 'ended') {
|
||||
countdown.value = 0
|
||||
}
|
||||
}, 30000)
|
||||
}
|
||||
|
||||
function stopTimer() {
|
||||
if (timerInterval) {
|
||||
clearInterval(timerInterval)
|
||||
timerInterval = null
|
||||
}
|
||||
if (resyncInterval) {
|
||||
clearInterval(resyncInterval)
|
||||
resyncInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 公开方法 ====================
|
||||
|
||||
async function startExam(group) {
|
||||
stopExam()
|
||||
|
||||
// 生成唯一的考试 session ID
|
||||
examSessionId = group + '_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8)
|
||||
sessionStorage.setItem('exam_session', examSessionId)
|
||||
|
||||
currentGroup.value = group
|
||||
|
||||
const [status, questionList] = await Promise.allSettled([
|
||||
examApi.getExamStatus(group),
|
||||
examApi.getQuestions(group),
|
||||
])
|
||||
|
||||
if (status.status === 'fulfilled' && status.value) {
|
||||
const stateData = status.value
|
||||
if (stateData.countdown !== undefined) {
|
||||
countdown.value = stateData.countdown
|
||||
}
|
||||
}
|
||||
|
||||
if (questionList.status === 'fulfilled' && questionList.value) {
|
||||
// 解析 options(可能是 JSON 字符串)
|
||||
// allSettled 包装后,value 是整个 response.data,再嵌套一层 data
|
||||
const responseData = questionList.value
|
||||
const rawQuestions = responseData.questions || []
|
||||
questions.value = rawQuestions.map(q => parseOptions(q))
|
||||
// 记录本次出题数量,提交时回传后端作为"应答题基准",避免与抽题数不一致
|
||||
totalQuestions.value = responseData.count || rawQuestions.length
|
||||
}
|
||||
|
||||
wordTemplate.value = { group }
|
||||
|
||||
examStarted.value = true
|
||||
startTimer()
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动提交组(本科/教师)倒计时:从上传截止时间推算剩余秒,复用同一套带 30s 对时、
|
||||
* 归零最终校验的定时器。提交组无客观题,故不拉取 questions。
|
||||
*/
|
||||
async function startUploadCountdown(group) {
|
||||
stopExam()
|
||||
|
||||
examSessionId = group + '_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8)
|
||||
sessionStorage.setItem('exam_session', examSessionId)
|
||||
|
||||
currentGroup.value = group
|
||||
|
||||
try {
|
||||
const up = await uploadApi.getUploadStatus(group)
|
||||
if (up && up.deadline) {
|
||||
const deadline = new Date(up.deadline).getTime()
|
||||
countdown.value = Math.max(0, Math.floor((deadline - Date.now()) / 1000))
|
||||
}
|
||||
} catch (e) { /* 获取截止时间失败则保持 0,由对时兜底 */ }
|
||||
|
||||
examStarted.value = true
|
||||
startTimer()
|
||||
}
|
||||
|
||||
function stopExam() {
|
||||
stopTimer()
|
||||
examStarted.value = false
|
||||
sessionStorage.removeItem('exam_session')
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 question_uid 推导题型(后端校验强制要求合法 question_type)。
|
||||
* uid 全局唯一化后形如 'choice_683' / 'judge_3',前缀即题型;
|
||||
* 兜底再查 questions 列表(恢复会话等场景 questions 可能为空,前缀推导仍有效)。
|
||||
*/
|
||||
function typeOfUid(questionUid) {
|
||||
const uid = String(questionUid)
|
||||
if (uid.startsWith('choice')) return 'choice'
|
||||
if (uid.startsWith('judge')) return 'judge'
|
||||
const q = questions.value.find(x => String(x.question_uid) === uid)
|
||||
return q ? q.question_type : undefined
|
||||
}
|
||||
|
||||
async function submitAnswer(questionId, answer) {
|
||||
// 本地先保存答案
|
||||
answers.value = { ...answers.value, [questionId]: answer }
|
||||
|
||||
// 异步提交到服务器(必须带 question_type,否则后端校验直接拒绝:
|
||||
// 「答案格式错误,需要合法的 question_type(choice/judge)」)
|
||||
try {
|
||||
await examApi.submitAnswers(currentGroup.value, [
|
||||
{ question_uid: questionId, question_type: typeOfUid(questionId), answer_data: answer },
|
||||
])
|
||||
} catch (e) {
|
||||
// 静默失败,不弹窗打扰用户答题
|
||||
console.warn('提交答案失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function submitAll(additionalData = {}) {
|
||||
// 每条答案都必须带合法 question_type:优先用 uid 前缀推导(questions 为空
|
||||
// 的恢复会话场景依然可靠),否则后端校验拒绝整卷提交。
|
||||
const answersArray = Object.entries(answers.value).map(([questionUid, answer]) => ({
|
||||
question_uid: questionUid,
|
||||
question_type: typeOfUid(questionUid),
|
||||
answer_data: answer,
|
||||
}))
|
||||
const data = {
|
||||
answers: answersArray,
|
||||
totalQuestions: totalQuestions.value,
|
||||
...additionalData,
|
||||
}
|
||||
const result = await examApi.submitAll(currentGroup.value, data)
|
||||
stopExam()
|
||||
return result
|
||||
}
|
||||
|
||||
function resetExam() {
|
||||
stopTimer()
|
||||
questions.value = []
|
||||
answers.value = {}
|
||||
countdown.value = 0
|
||||
examStarted.value = false
|
||||
wordTemplate.value = null
|
||||
wordUploaded.value = false
|
||||
currentGroup.value = ''
|
||||
examSessionId = ''
|
||||
sessionStorage.removeItem('exam_session')
|
||||
}
|
||||
|
||||
/** 检查当前 session 是否有效 */
|
||||
function isSessionValid() {
|
||||
const saved = sessionStorage.getItem('exam_session')
|
||||
return saved === examSessionId && examSessionId !== ''
|
||||
}
|
||||
|
||||
/** 恢复已有答案(考试重新进入时使用) */
|
||||
function restoreAnswers(restoredAnswers) {
|
||||
answers.value = { ...restoredAnswers }
|
||||
}
|
||||
|
||||
export function useExamStore() {
|
||||
return reactive({
|
||||
// 状态
|
||||
currentGroup,
|
||||
questions,
|
||||
answers,
|
||||
countdown,
|
||||
examStarted,
|
||||
wordTemplate,
|
||||
wordUploaded,
|
||||
totalQuestions,
|
||||
// 计算属性
|
||||
countdownDisplay,
|
||||
isTimeUp,
|
||||
progress,
|
||||
choiceQuestions,
|
||||
judgeQuestions,
|
||||
// 方法
|
||||
startExam,
|
||||
startUploadCountdown,
|
||||
stopExam,
|
||||
submitAnswer,
|
||||
submitAll,
|
||||
resetExam,
|
||||
isSessionValid,
|
||||
restoreAnswers,
|
||||
})
|
||||
}
|
||||
77
src/stores/news.js
Normal file
77
src/stores/news.js
Normal file
@@ -0,0 +1,77 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import * as newsApi from '@/api/news'
|
||||
|
||||
// ==================== 新闻资讯状态管理 ====================
|
||||
|
||||
export const useNewsStore = defineStore('news', () => {
|
||||
// ==================== 状态 ====================
|
||||
|
||||
// 新闻列表数据
|
||||
const newsList = ref([])
|
||||
|
||||
// 当前新闻分组/分类
|
||||
const currentGroup = ref('all')
|
||||
|
||||
// 分页信息
|
||||
const pagination = ref({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
})
|
||||
|
||||
// 加载状态
|
||||
const loading = ref(false)
|
||||
|
||||
// ==================== 操作方法(Actions) ====================
|
||||
|
||||
/**
|
||||
* 获取新闻列表
|
||||
* @param {object} params - 查询参数 { page, pageSize, keyword, category }
|
||||
*/
|
||||
async function fetchNews(params = {}) {
|
||||
loading.value = true
|
||||
try {
|
||||
const queryParams = {
|
||||
page: pagination.value.page,
|
||||
pageSize: pagination.value.pageSize,
|
||||
...params,
|
||||
}
|
||||
|
||||
const result = await newsApi.getNewsList(queryParams)
|
||||
|
||||
// 根据返回结构更新数据
|
||||
if (result && result.list) {
|
||||
newsList.value = result.list
|
||||
pagination.value.total = result.total || 0
|
||||
pagination.value.page = result.page || pagination.value.page
|
||||
pagination.value.pageSize = result.pageSize || pagination.value.pageSize
|
||||
} else if (Array.isArray(result)) {
|
||||
// 如果直接返回数组
|
||||
newsList.value = result
|
||||
pagination.value.total = result.length
|
||||
}
|
||||
|
||||
if (params?.category) {
|
||||
currentGroup.value = params.category
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('获取新闻列表失败', e)
|
||||
newsList.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 导出 ====================
|
||||
|
||||
return {
|
||||
// 状态
|
||||
newsList,
|
||||
currentGroup,
|
||||
pagination,
|
||||
loading,
|
||||
// 操作
|
||||
fetchNews,
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user