chore: 前端首次基线提交

运行必须源码:src/、public/、index.html、package.json 及构建配置。
已忽略运行非必须:node_modules/、dist/、.env*。
This commit is contained in:
WorkBuddy
2026-08-17 12:25:42 +08:00
commit e8f0195b61
52 changed files with 16866 additions and 0 deletions

156
src/stores/auth.js Normal file
View 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
View 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
View 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,
}
})