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

676
src/views/Activate.vue Normal file
View File

@@ -0,0 +1,676 @@
<template>
<div class="activate-page">
<div class="activate-container">
<!-- 头部 -->
<div class="activate-header">
<div class="header-icon">&#9788;</div>
<h1 class="header-title">账号激活</h1>
<p class="header-subtitle">激活您的参赛账号,开始比赛之旅</p>
</div>
<!-- 步骤条 -->
<el-steps :active="currentStep" align-center finish-status="success" class="activate-steps">
<el-step title="身份验证" description="输入身份证号" />
<el-step title="短信验证" description="输入短信验证码" />
<el-step title="设置密码" description="设置登录密码" />
</el-steps>
<!-- ========== 步骤1:获取图形验证码 ========== -->
<el-card v-if="currentStep === 0" class="step-card" shadow="never">
<template #header>
<span class="step-card-title">第一步:身份验证</span>
</template>
<el-form
ref="step1FormRef"
:model="step1Form"
:rules="step1Rules"
label-position="top"
@keyup.enter="handleStep1"
>
<el-form-item label="身份证号" prop="idNumber">
<el-input
v-model="step1Form.idNumber"
placeholder="请输入您的身份证号"
size="large"
clearable
maxlength="18"
/>
</el-form-item>
<el-form-item label="点击验证码" prop="captchaCode">
<div class="click-captcha-area">
<div class="captcha-hint" v-if="captchaHint">{{ captchaHint }}</div>
<div class="captcha-chars">
<span
v-for="(char, idx) in captchaChars"
:key="idx"
class="captcha-char"
:class="{ clicked: captchaClicked.has(idx) }"
@click="handleCaptchaClick(idx, char)"
>{{ char }}</span>
</div>
<div class="captcha-sequence" v-if="captchaSequence">
已点击:<span class="seq-chars">{{ captchaSequence }}</span>
</div>
<el-link type="primary" v-guard="() => refreshCaptcha()" :underline="false" class="captcha-refresh">
<el-icon><Refresh /></el-icon><span>换一组</span>
</el-link>
</div>
</el-form-item>
<el-form-item>
<el-button
type="primary"
size="large"
class="step-btn"
:loading="step1Loading"
v-guard="() => handleStep1()"
>
下一步
</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- ========== 步骤2:短信验证码 ========== -->
<el-card v-if="currentStep === 1" class="step-card" shadow="never">
<template #header>
<span class="step-card-title">第二步:短信验证</span>
</template>
<el-form
ref="step2FormRef"
:model="step2Form"
:rules="step2Rules"
label-position="top"
>
<div class="phone-display">
<span class="phone-label">短信已发送至:</span>
<span class="phone-value">{{ userInfo.phone || step1Form.phone || '--' }}</span>
</div>
<el-form-item label="短信验证码" prop="smsCode">
<div class="sms-row">
<el-input
v-model="step2Form.smsCode"
placeholder="请输入 6 位短信验证码"
size="large"
maxlength="6"
class="sms-input"
/>
<el-button
size="large"
class="sms-btn"
:disabled="smsCountdown > 0"
v-guard="() => resendSms()"
>
{{ smsCountdown > 0 ? `${smsCountdown}s 后重新发送` : '重新发送' }}
</el-button>
</div>
</el-form-item>
<el-form-item>
<div class="step-btn-group">
<el-button size="large" @click="currentStep = 0">上一步</el-button>
<el-button
type="primary"
size="large"
class="step-btn"
:loading="step2Loading"
@click="handleStep2"
>
下一步
</el-button>
</div>
</el-form-item>
</el-form>
</el-card>
<!-- ========== 步骤3:设置密码 ========== -->
<el-card v-if="currentStep === 2" class="step-card" shadow="never">
<template #header>
<span class="step-card-title">第三步:设置密码</span>
</template>
<el-form
ref="step3FormRef"
:model="step3Form"
:rules="step3Rules"
label-position="top"
>
<!-- 基本信息确认 -->
<div class="info-confirm">
<el-descriptions :column="1" border size="small" title="基本信息确认">
<el-descriptions-item label="姓名">{{ userInfo.name || '--' }}</el-descriptions-item>
<el-descriptions-item label="手机号">{{ userInfo.phone || '--' }}</el-descriptions-item>
<el-descriptions-item label="参赛组别">{{ groupLabel }}</el-descriptions-item>
</el-descriptions>
</div>
<el-form-item label="设置密码" prop="password">
<el-input
v-model="step3Form.password"
type="password"
placeholder="请设置登录密码(至少6位)"
size="large"
show-password
/>
</el-form-item>
<el-form-item label="确认密码" prop="confirmPassword">
<el-input
v-model="step3Form.confirmPassword"
type="password"
placeholder="请再次输入密码"
size="large"
show-password
/>
</el-form-item>
<el-form-item>
<div class="step-btn-group">
<el-button size="large" @click="currentStep = 1">上一步</el-button>
<el-button
type="primary"
size="large"
class="step-btn"
:loading="step3Loading"
v-guard="() => handleActivate()"
>
完成激活
</el-button>
</div>
</el-form-item>
</el-form>
</el-card>
<!-- 返回链接 -->
<div class="back-link">
<el-link type="info" @click="$router.push('/')">
<el-icon><HomeFilled /></el-icon><span>返回首页</span>
</el-link>
<span class="link-sep">|</span>
<el-link type="info" @click="$router.push('/login')">
<el-icon><ArrowLeft /></el-icon><span>返回登录</span>
</el-link>
</div>
</div>
</div>
</template>
<script setup>
/**
* Activate.vue - 账号激活页面(公开访问)
*
* 流程:
* 步骤1:输入身份证号 -> 获取图形验证码 -> 验证
* 步骤2:发送短信验证码 -> 输入 6 位验证码 -> 验证
* 步骤3:确认基本信息 -> 设置密码 + 确认密码 -> 完成激活
*/
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ArrowLeft, HomeFilled, Refresh } from '@element-plus/icons-vue'
import * as authApi from '@/api/auth'
import { showTestSmsCode } from '@/utils/testSms'
const router = useRouter()
// ==================== 状态 ====================
const currentStep = ref(0)
// 点击式验证码状态
const captchaChars = ref([])
const captchaHint = ref('')
const captchaClicked = ref(new Set())
const captchaSequence = ref('')
const smsCountdown = ref(0)
let smsTimer = null
// 步骤1表单
const step1FormRef = ref(null)
const step1Form = reactive({
idNumber: '',
captchaCode: '',
phone: '',
})
const step1Loading = ref(false)
// 步骤2表单
const step2FormRef = ref(null)
const step2Form = reactive({
smsCode: '',
})
const step2Loading = ref(false)
// 步骤3表单
const step3FormRef = ref(null)
const step3Form = reactive({
password: '',
confirmPassword: '',
})
const step3Loading = ref(false)
// 用户基本信息(从服务端返回)
const userInfo = reactive({
name: '',
phone: '',
group: '',
})
// ==================== 表单验证规则 ====================
const step1Rules = {
idNumber: [
{ required: true, message: '请输入身份证号', trigger: 'blur' },
{
pattern: /^[1-9]\d{5}(19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/,
message: '请输入正确的身份证号',
trigger: 'blur',
},
],
captchaCode: [
{ required: true, message: '请按顺序点击验证码', trigger: 'change' },
],
}
const step2Rules = {
smsCode: [
{ required: true, message: '请输入短信验证码', trigger: 'blur' },
{ len: 6, message: '请输入 6 位短信验证码', trigger: 'blur' },
],
}
const step3Rules = {
password: [
{ required: true, message: '请设置密码', trigger: 'blur' },
{ min: 6, message: '密码至少 6 个字符', trigger: 'blur' },
{ max: 20, message: '密码不超过 20 个字符', trigger: 'blur' },
],
confirmPassword: [
{ required: true, message: '请确认密码', trigger: 'blur' },
{
validator: (rule, value, callback) => {
if (value !== step3Form.password) {
callback(new Error('两次输入的密码不一致'))
} else {
callback()
}
},
trigger: 'blur',
},
],
}
// ==================== 计算属性 ====================
const groupLabel = computed(() => {
const map = {
zhongzhi: '中职组',
gaozhi: '高职组',
benke: '本科组',
jiaoshi: '教师组',
}
return map[userInfo.group] || userInfo.group || '--'
})
// ==================== 方法 ====================
/** 获取点击式验证码 */
async function refreshCaptcha() {
try {
captchaChars.value = []
captchaHint.value = ''
captchaClicked.value = new Set()
captchaSequence.value = ''
step1Form.captchaCode = ''
const data = await authApi.getCaptcha()
if (data && data.chars) {
captchaChars.value = data.chars
captchaHint.value = data.hint || ''
}
} catch (e) {
console.warn('获取验证码失败', e)
}
}
/** 点击验证码字符 */
function handleCaptchaClick(idx, char) {
if (captchaClicked.value.has(idx)) return // 已点击过
const newClicked = new Set(captchaClicked.value)
newClicked.add(idx)
captchaClicked.value = newClicked
captchaSequence.value += char
step1Form.captchaCode = captchaSequence.value
}
/** 步骤1:验证身份并发送短信 */
async function handleStep1() {
if (!step1FormRef.value) return
try {
await step1FormRef.value.validate()
step1Loading.value = true
// 发送短信验证码
const result = await authApi.sendSms(step1Form.idNumber, step1Form.captchaCode)
// 测试环境:后端随响应回传验证码,弹窗展示(生产环境不回传、不弹窗,走真实短信)
if (result?.smsCode) {
showTestSmsCode(result.smsCode, '账号激活')
}
// 后端可能返回用户基本信息
if (result) {
step1Form.phone = result.phone || ''
if (result.name) userInfo.name = result.name
if (result.phone) userInfo.phone = result.phone
if (result.group) userInfo.group = result.group
}
ElMessage.success('短信验证码已发送')
// 进入下一步
currentStep.value = 1
// 启动倒计时
startSmsCountdown()
} catch (err) {
if (err?.message) ElMessage.error(err.message)
// 刷新验证码
refreshCaptcha()
} finally {
step1Loading.value = false
}
}
/** 步骤2:验证短信验证码 */
async function handleStep2() {
if (!step2FormRef.value) return
try {
await step2FormRef.value.validate()
// 验证短信码(此处通过获取用户信息间接验证)
step2Loading.value = true
// 短信码的验证由后端在激活时完成,此处仅进入下一步
currentStep.value = 2
ElMessage.success('短信验证通过')
} catch (err) {
if (err?.message) ElMessage.error(err.message)
} finally {
step2Loading.value = false
}
}
/** 步骤3:完成激活 */
async function handleActivate() {
if (!step3FormRef.value) return
try {
await step3FormRef.value.validate()
step3Loading.value = true
await authApi.activate({
id_number: step1Form.idNumber,
phone: step1Form.phone || step1Form.idNumber,
password: step3Form.password,
smsCode: step2Form.smsCode,
})
ElMessage.success('账号激活成功!请登录参赛')
// 跳转到登录页
setTimeout(() => {
router.push('/login')
}, 1500)
} catch (err) {
if (err?.message) ElMessage.error(err.message)
} finally {
step3Loading.value = false
}
}
/** 重新发送短信验证码 */
async function resendSms() {
if (smsCountdown.value > 0) return
try {
await refreshCaptcha()
const result = await authApi.sendSms(step1Form.idNumber, step1Form.captchaCode)
if (result?.smsCode) {
showTestSmsCode(result.smsCode, '账号激活(重新发送)')
}
ElMessage.success('短信验证码已重新发送')
startSmsCountdown()
} catch (err) {
if (err?.message) ElMessage.error(err.message)
refreshCaptcha()
}
}
/** 启动短信倒计时 */
function startSmsCountdown() {
smsCountdown.value = 60
clearInterval(smsTimer)
smsTimer = setInterval(() => {
smsCountdown.value--
if (smsCountdown.value <= 0) {
clearInterval(smsTimer)
smsTimer = null
}
}, 1000)
}
// ==================== 生命周期 ====================
onMounted(() => {
refreshCaptcha()
})
onUnmounted(() => {
if (smsTimer) clearInterval(smsTimer)
})
</script>
<style scoped>
.activate-page {
min-height: 100vh;
background: linear-gradient(135deg, #f5f6f8 0%, #eef0f5 100%);
padding: 40px 20px;
display: flex;
justify-content: center;
}
.activate-container {
width: 100%;
max-width: 540px;
}
/* ==================== 头部 ==================== */
.activate-header {
text-align: center;
margin-bottom: 32px;
}
.header-icon {
font-size: 48px;
color: #c9a84c;
margin-bottom: 12px;
}
.header-title {
font-size: 28px;
font-weight: 700;
color: #1a3a5c;
margin-bottom: 8px;
}
.header-subtitle {
font-size: 14px;
color: #909399;
}
/* ==================== 步骤条 ==================== */
.activate-steps {
margin-bottom: 32px;
}
.activate-steps :deep(.el-step__title) {
font-size: 14px;
}
.activate-steps :deep(.el-step__description) {
font-size: 12px;
}
/* ==================== 步骤卡片 ==================== */
.step-card {
border-radius: 12px;
border: 1px solid #e5e7eb;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
}
.step-card-title {
font-size: 16px;
font-weight: 600;
color: #1a3a5c;
}
/* ==================== 点击验证码 ==================== */
.click-captcha-area {
width: 100%;
}
.captcha-hint {
font-size: 14px;
color: #303133;
margin-bottom: 10px;
font-weight: 500;
}
.captcha-chars {
display: flex;
gap: 12px;
margin-bottom: 8px;
}
.captcha-char {
width: 52px;
height: 52px;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid #dcdfe6;
border-radius: 8px;
font-size: 22px;
font-weight: bold;
color: #303133;
cursor: pointer;
user-select: none;
background: #fff;
transition: all 0.2s;
}
.captcha-char:hover {
border-color: #409eff;
background: #ecf5ff;
transform: scale(1.05);
}
.captcha-char.clicked {
border-color: #67c23a;
background: #f0f9eb;
color: #67c23a;
cursor: default;
transform: scale(1.05);
}
.captcha-sequence {
font-size: 13px;
color: #606266;
margin-bottom: 6px;
}
.seq-chars {
font-weight: bold;
color: #67c23a;
letter-spacing: 4px;
}
.captcha-refresh {
font-size: 13px;
}
/* ==================== 短信行 ==================== */
.sms-row {
display: flex;
gap: 12px;
}
.sms-input {
flex: 1;
}
.sms-btn {
flex-shrink: 0;
min-width: 130px;
}
.phone-display {
background: #f5f7fa;
border: 1px solid #e4e7ed;
border-radius: 6px;
padding: 12px 16px;
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 8px;
}
.phone-label {
font-size: 14px;
color: #909399;
}
.phone-value {
font-size: 14px;
color: #303133;
font-weight: 600;
}
/* ==================== 信息确认 ==================== */
.info-confirm {
margin-bottom: 24px;
}
/* ==================== 按钮 ==================== */
.step-btn {
width: 100%;
height: 46px;
font-size: 16px;
}
.step-btn-group {
display: flex;
gap: 12px;
width: 100%;
}
.step-btn-group .el-button {
flex: 1;
}
/* ==================== 返回链接 ==================== */
.back-link {
text-align: center;
margin-top: 24px;
}
/* ==================== Responsive ==================== */
@media (max-width: 480px) {
.activate-page {
padding: 20px 12px;
}
.header-title {
font-size: 22px;
}
.captcha-img {
width: 100px;
}
.sms-btn {
min-width: 100px;
font-size: 12px;
}
}
</style>

2900
src/views/Admin.vue Normal file

File diff suppressed because it is too large Load Diff

806
src/views/AtSimulator.vue Normal file
View File

@@ -0,0 +1,806 @@
<template>
<div class="at-page">
<div class="at-header">
<h1>卫星通信 AT 命令模拟器</h1>
<p class="subtitle">高职组 · 卫星通信设备操作实操训练</p>
</div>
<div class="at-body">
<!-- 左侧:知识面板 -->
<div class="at-knowledge">
<el-card shadow="hover">
<template #header>
<span class="card-title">AT 命令基础知识</span>
</template>
<el-collapse v-model="activeKnowledge">
<el-collapse-item title="什么是 AT 命令" name="1">
<p>AT 命令(Attention Commands)是用于控制调制解调器(Modem)和通信模块的指令集。在卫星通信中,通过串口向卫星通信模组发送 AT 命令来控制设备行为。</p>
<p><strong>基本格式:</strong>每条命令以 <code>AT</code> 开头,以 <code>\r\n</code> 结尾。</p>
<ul>
<li><code>AT</code> — 测试命令,返回 OK 表示模组正常</li>
<li><code>AT+XXXX=?</code> — 查询命令参数范围</li>
<li><code>AT+XXXX?</code> — 查询当前参数值</li>
<li><code>AT+XXXX=value</code> — 设置参数值</li>
</ul>
</el-collapse-item>
<el-collapse-item title="常用 AT 命令速查" name="2">
<el-table :data="atCommands" border stripe size="small">
<el-table-column prop="cmd" label="命令" width="220" />
<el-table-column prop="desc" label="功能说明" />
<el-table-column prop="resp" label="成功响应" width="140" />
</el-table>
</el-collapse-item>
<el-collapse-item title="卫星通信发送流程" name="3">
<div class="flow-steps">
<div class="flow-step">
<span class="step-num">1</span>
<div>
<strong>设备初始化</strong>
<p>发送 <code>AT</code> 确认模组正常,发送 <code>AT+CSQ</code> 查询信号强度</p>
</div>
</div>
<div class="flow-step">
<span class="step-num">2</span>
<div>
<strong>网络注册</strong>
<p>发送 <code>AT+CREG?</code> 检查网络注册状态,确保返回 +CREG: 0,1(已注册)</p>
</div>
</div>
<div class="flow-step">
<span class="step-num">3</span>
<div>
<strong>查询卫星状态</strong>
<p>发送 <code>AT+SSTATUS?</code> 获取当前卫星连接状态(是否可见、是否锁定)</p>
</div>
</div>
<div class="flow-step">
<span class="step-num">4</span>
<div>
<strong>发送消息</strong>
<p>发送 <code>AT+SEND=数据</code> 将数据通过卫星链路发送出去</p>
</div>
</div>
<div class="flow-step">
<span class="step-num">5</span>
<div>
<strong>确认发送结果</strong>
<p>等待 <code>+SEND: OK</code> 响应,确认消息已成功发送</p>
</div>
</div>
</div>
</el-collapse-item>
<el-collapse-item title="消息长度限制" name="4">
<p>卫星通信消息发送有不同的长度限制,取决于所用模组和协议:</p>
<el-table :data="lengthLimits" border stripe size="small">
<el-table-column prop="type" label="通信方式" width="150" />
<el-table-column prop="maxLen" label="最大长度" width="120" />
<el-table-column prop="note" label="说明" />
</el-table>
</el-collapse-item>
<el-collapse-item title="串口连接配置" name="5">
<p><strong>串口配置参数:</strong></p>
<el-table :data="serialConfigs" border stripe size="small">
<el-table-column prop="param" label="参数" />
<el-table-column prop="value" label="典型值" />
<el-table-column prop="desc" label="说明" />
</el-table>
</el-collapse-item>
</el-collapse>
</el-card>
</div>
<!-- 右侧:模拟器 -->
<div class="at-tool">
<!-- 设备状态面板 -->
<el-card shadow="hover">
<template #header>
<div class="device-header">
<span class="card-title">设备状态</span>
<el-tag :type="deviceState.connected ? 'success' : 'info'" size="small">
{{ deviceState.connected ? '已连接' : '未连接' }}
</el-tag>
</div>
</template>
<div class="device-info">
<el-descriptions :column="4" border size="small">
<el-descriptions-item label="模组型号">
{{ deviceState.module }}
</el-descriptions-item>
<el-descriptions-item label="信号强度">
<el-tag :type="signalType" size="small">{{ deviceState.signal }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="网络状态">
<el-tag :type="deviceState.registered ? 'success' : 'warning'" size="small">
{{ deviceState.registered ? '已注册' : '未注册' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="卫星状态">
<el-tag :type="deviceState.satelliteLocked ? 'success' : 'danger'" size="small">
{{ deviceState.satelliteLocked ? '已锁定' : '未锁定' }}
</el-tag>
</el-descriptions-item>
</el-descriptions>
</div>
<div class="connect-row">
<el-button type="primary" @click="connectDevice" :disabled="deviceState.connected">
{{ deviceState.connected ? '已连接' : '连接设备' }}
</el-button>
<el-button type="danger" @click="disconnectDevice" :disabled="!deviceState.connected">
断开连接
</el-button>
<el-button @click="resetDevice">重置设备</el-button>
</div>
</el-card>
<!-- 串口终端 -->
<el-card shadow="hover" class="terminal-card">
<template #header>
<div class="terminal-header">
<span class="card-title">串口终端</span>
<span class="serial-info">COM3 · 115200-8N1</span>
</div>
</template>
<!-- 终端输出 -->
<div class="terminal-output" ref="terminalRef">
<div v-for="(line, idx) in terminalLines" :key="idx" class="terminal-line" :class="line.type">
<span class="line-time">{{ line.time }}</span>
<span class="line-content">{{ line.text }}</span>
</div>
<div v-if="terminalLines.length === 0" class="terminal-empty">
串口终端就绪,等待设备连接...
</div>
</div>
<!-- 命令输入 -->
<div class="terminal-input-area">
<div class="command-prompt">
<span class="prompt">AT&gt;</span>
<el-input
v-model="commandInput"
@keyup.enter="sendCommand"
placeholder="输入 AT 命令,如 AT+CSQ"
:disabled="!deviceState.connected"
ref="inputRef"
class="command-input"
>
<template #append>
<el-button @click="sendCommand" :disabled="!deviceState.connected">
发送
</el-button>
</template>
</el-input>
</div>
<div class="quick-commands">
<span class="quick-label">快捷命令:</span>
<el-button
v-for="qc in quickCommands"
:key="qc.cmd"
size="small"
@click="sendQuickCommand(qc.cmd)"
:disabled="!deviceState.connected"
>
{{ qc.cmd }}
</el-button>
</div>
</div>
</el-card>
<!-- 消息发送区 -->
<el-card shadow="hover" class="send-card">
<template #header>
<span class="card-title">消息发送</span>
</template>
<div class="send-area">
<el-input
v-model="messageToSend"
type="textarea"
:rows="3"
placeholder="输入要发送的消息(注意长度限制)"
:disabled="!deviceState.connected"
/>
<div class="send-row">
<el-button
type="success"
@click="sendMessage"
:disabled="!deviceState.connected || !messageToSend.trim()"
>
发送消息
</el-button>
<span class="char-count" :class="charCountClass">
{{ messageToSend.length }} / {{ maxMessageLength }} 字符
</span>
</div>
</div>
</el-card>
<!-- 练习区 -->
<el-card shadow="hover" class="practice-card">
<template #header>
<span class="card-title">练习题目</span>
</template>
<div class="practice-item">
<p><strong>1.</strong> 低轨卫星通信设备发送消息前,需要先查询什么状态?请写出对应的 AT 命令:</p>
<el-input v-model="practice1" placeholder="AT+CSQ" class="practice-input" />
</div>
<div class="practice-item">
<p><strong>2.</strong> 查询当前卫星设备与卫星连接状态的 AT 命令是什么?</p>
<el-radio-group v-model="practice2">
<el-radio label="AT+CSQ">AT+CSQ</el-radio>
<el-radio label="AT+SSTATUS?">AT+SSTATUS?</el-radio>
<el-radio label="AT+CREG?">AT+CREG?</el-radio>
<el-radio label="AT+CGATT?">AT+CGATT?</el-radio>
</el-radio-group>
</div>
<div class="practice-item">
<p><strong>3.</strong> 天启卫星通信模组单次发送消息的最大长度限制是多少?</p>
<el-input v-model="practice3" placeholder="请输入字节数" class="practice-input" />
</div>
</el-card>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, nextTick } from 'vue'
// ========== 知识面板数据 ==========
const activeKnowledge = ref(['1', '3'])
const atCommands = [
{ cmd: 'AT', desc: '测试模组是否正常,返回 OK', resp: 'OK' },
{ cmd: 'AT+CSQ', desc: '查询信号强度(0-31,越大越好)', resp: '+CSQ: 25,99' },
{ cmd: 'AT+CREG?', desc: '查询网络注册状态', resp: '+CREG: 0,1' },
{ cmd: 'AT+SSTATUS?', desc: '查询卫星连接状态', resp: '+SSTATUS: LOCKED' },
{ cmd: 'AT+CGATT?', desc: '查询 PS 附着状态', resp: '+CGATT: 1' },
{ cmd: 'AT+CGMI', desc: '查询制造商信息', resp: 'SATELLITE_MODULE_V2' },
{ cmd: 'AT+CGMM', desc: '查询模组型号', resp: 'SM-2300' },
{ cmd: 'AT+CGSN', desc: '查询序列号(IMEI)', resp: '860123456789012' },
{ cmd: 'AT+CIMI', desc: '查询 SIM 卡 IMSI', resp: '460012345678901' },
{ cmd: 'AT+CPIN?', desc: '查询 SIM 卡状态', resp: '+CPIN: READY' },
{ cmd: 'AT+SEND=数据', desc: '发送卫星消息(数据需编码)', resp: '+SEND: OK' },
{ cmd: 'AT+SRECV?', desc: '查询是否有待接收消息', resp: '+SRECV: 0' },
{ cmd: 'AT+IPR?', desc: '查询/设置波特率', resp: '+IPR: 115200' },
{ cmd: 'AT&W', desc: '保存当前配置到模组', resp: 'OK' },
{ cmd: 'AT+RESET', desc: '重启模组', resp: 'OK' },
]
const lengthLimits = [
{ type: '天启卫星(短报文)', maxLen: '140 字节', note: '类似短信,适合传输传感器数据、状态信息' },
{ type: '天启卫星(扩展)', maxLen: '512 字节', note: '支持更长消息,但会增加传输延迟' },
{ type: '铱星(Iridium)SBD', maxLen: '340 字节', note: 'Short Burst Data,上行 340 字节,下行 270 字节' },
{ type: '北斗短报文', maxLen: '78 字节', note: '北斗 RDSS 短报文,区域短报文每次 1000 汉字' },
{ type: 'LoRa 卫星', maxLen: '255 字节', note: 'LoRa 调制通过卫星中继,最大 255 字节' },
]
const serialConfigs = [
{ param: '波特率 (Baud Rate)', value: '115200', desc: '卫星通信模组常用高速波特率,部分设备支持 9600-921600' },
{ param: '数据位 (Data Bits)', value: '8', desc: '固定 8 位数据位' },
{ param: '校验位 (Parity)', value: 'N(无校验)', desc: '通常不使用校验位' },
{ param: '停止位 (Stop Bits)', value: '1', desc: '1 位停止位' },
{ param: '流控制 (Flow Control)', value: 'N(无流控)', desc: '通常不使用硬件流控制' },
{ param: '端口号', value: 'COM3/COM4', desc: '通过设备管理器查看实际端口号' },
]
// ========== 设备状态 ==========
const deviceState = reactive({
connected: false,
signal: '--',
registered: false,
satelliteLocked: false,
module: 'SM-2300',
imei: '860123456789012',
})
const signalType = computed(() => {
if (!deviceState.connected) return 'info'
const s = parseInt(deviceState.signal)
if (s >= 20) return 'success'
if (s >= 10) return 'warning'
return 'danger'
})
// ========== 终端 ==========
const terminalLines = ref([])
const terminalRef = ref(null)
const inputRef = ref(null)
const commandInput = ref('')
const messageToSend = ref('')
const maxMessageLength = 140
const charCountClass = computed(() => {
if (messageToSend.value.length > maxMessageLength) return 'char-over'
if (messageToSend.value.length > maxMessageLength * 0.8) return 'char-warn'
return 'char-ok'
})
const quickCommands = [
{ cmd: 'AT' },
{ cmd: 'AT+CSQ' },
{ cmd: 'AT+CREG?' },
{ cmd: 'AT+SSTATUS?' },
{ cmd: 'AT+CGATT?' },
{ cmd: 'AT+CGMI' },
{ cmd: 'AT+CGSN' },
{ cmd: 'AT+CPIN?' },
]
// 练习
const practice1 = ref('')
const practice2 = ref('')
const practice3 = ref('')
function addTerminalLine(text, type = 'response') {
const now = new Date()
const time = now.toTimeString().substring(0, 8)
terminalLines.value.push({ text, type, time })
nextTick(() => {
if (terminalRef.value) {
terminalRef.value.scrollTop = terminalRef.value.scrollHeight
}
})
}
function connectDevice() {
deviceState.connected = true
deviceState.signal = '25'
deviceState.registered = true
deviceState.satelliteLocked = true
addTerminalLine('=== 串口已连接:COM3,115200-8N1 ===', 'system')
addTerminalLine('AT', 'command')
addTerminalLine('OK', 'response')
addTerminalLine('', 'blank')
addTerminalLine('AT+CSQ', 'command')
addTerminalLine('+CSQ: 25,99', 'response')
addTerminalLine('', 'blank')
addTerminalLine('AT+CREG?', 'command')
addTerminalLine('+CREG: 0,1', 'response')
addTerminalLine('', 'blank')
addTerminalLine('AT+SSTATUS?', 'command')
addTerminalLine('+SSTATUS: LOCKED', 'response')
addTerminalLine('', 'blank')
addTerminalLine('设备连接成功,信号良好,卫星已锁定。', 'success')
}
function disconnectDevice() {
addTerminalLine('=== 串口已断开 ===', 'system')
deviceState.connected = false
deviceState.signal = '--'
deviceState.registered = false
deviceState.satelliteLocked = false
}
function resetDevice() {
terminalLines.value = []
commandInput.value = ''
messageToSend.value = ''
deviceState.connected = false
deviceState.signal = '--'
deviceState.registered = false
deviceState.satelliteLocked = false
addTerminalLine('设备已重置,串口终端已清空。', 'system')
}
function processCommand(cmd) {
const upperCmd = cmd.toUpperCase().trim()
addTerminalLine(cmd, 'command')
// AT 测试
if (upperCmd === 'AT') {
addTerminalLine('OK', 'response')
return
}
// AT+CSQ 信号强度
if (upperCmd === 'AT+CSQ' || upperCmd === 'AT+CSQ?') {
const sig = deviceState.connected ? deviceState.signal : '0'
addTerminalLine(`+CSQ: ${sig},99`, 'response')
return
}
// AT+CREG? 网络注册
if (upperCmd === 'AT+CREG?') {
addTerminalLine(`+CREG: 0,${deviceState.registered ? '1' : '0'}`, 'response')
return
}
// AT+SSTATUS? 卫星状态
if (upperCmd === 'AT+SSTATUS?') {
const status = deviceState.satelliteLocked ? 'LOCKED' : 'SEARCHING'
addTerminalLine(`+SSTATUS: ${status}`, 'response')
return
}
// AT+CGATT? PS附着
if (upperCmd === 'AT+CGATT?') {
addTerminalLine('+CGATT: 1', 'response')
return
}
// AT+CGMI 制造商
if (upperCmd === 'AT+CGMI') {
addTerminalLine('SATELLITE_MODULE_V2', 'response')
return
}
// AT+CGMM 型号
if (upperCmd === 'AT+CGMM') {
addTerminalLine('SM-2300', 'response')
return
}
// AT+CGSN 序列号
if (upperCmd === 'AT+CGSN') {
addTerminalLine(deviceState.imei, 'response')
return
}
// AT+CIMI IMSI
if (upperCmd === 'AT+CIMI') {
addTerminalLine('460012345678901', 'response')
return
}
// AT+CPIN? SIM卡
if (upperCmd === 'AT+CPIN?') {
addTerminalLine('+CPIN: READY', 'response')
return
}
// AT+IPR? 波特率
if (upperCmd === 'AT+IPR?') {
addTerminalLine('+IPR: 115200', 'response')
return
}
// AT&W 保存
if (upperCmd === 'AT&W') {
addTerminalLine('OK', 'response')
return
}
// AT+RESET 重启
if (upperCmd === 'AT+RESET') {
addTerminalLine('OK', 'response')
addTerminalLine('模组重启中...', 'system')
setTimeout(() => {
addTerminalLine('AT', 'command')
addTerminalLine('OK', 'response')
addTerminalLine('模组重启完成。', 'success')
}, 1500)
return
}
// AT+SEND= 发送消息
if (upperCmd.startsWith('AT+SEND=')) {
const data = cmd.substring(8).trim()
if (!data) {
addTerminalLine('ERROR: 消息内容为空', 'error')
return
}
if (data.length > maxMessageLength) {
addTerminalLine(`ERROR: 消息长度超出限制(${data.length} > ${maxMessageLength})`, 'error')
return
}
addTerminalLine('+SEND: OK', 'response')
addTerminalLine(`消息已发送至卫星(${data.length} 字节)`, 'success')
return
}
// AT+SRECV? 接收消息
if (upperCmd === 'AT+SRECV?') {
addTerminalLine('+SRECV: 0', 'response')
return
}
// 未知命令
addTerminalLine('ERROR', 'error')
}
function sendCommand() {
if (!deviceState.connected) return
const cmd = commandInput.value.trim()
if (!cmd) return
processCommand(cmd)
commandInput.value = ''
nextTick(() => {
if (inputRef.value) inputRef.value.focus()
})
}
function sendQuickCommand(cmd) {
if (!deviceState.connected) return
processCommand(cmd)
}
function sendMessage() {
if (!deviceState.connected || !messageToSend.value.trim()) return
const msg = messageToSend.value.trim()
if (msg.length > maxMessageLength) {
addTerminalLine(`ERROR: 消息长度 ${msg.length} 超出限制 ${maxMessageLength}`, 'error')
return
}
processCommand(`AT+SEND=${msg}`)
messageToSend.value = ''
}
</script>
<style scoped>
.at-page {
min-height: 100vh;
background: #f0f2f5;
padding: 20px;
}
.at-header {
text-align: center;
padding: 30px 0 20px;
}
.at-header h1 {
font-size: 28px;
color: #1a1a2e;
margin: 0 0 8px;
}
.subtitle {
font-size: 14px;
color: #909399;
}
.at-body {
display: flex;
gap: 20px;
max-width: 1400px;
margin: 0 auto;
}
.at-knowledge {
flex: 0 0 400px;
max-height: calc(100vh - 160px);
overflow-y: auto;
}
.at-tool {
flex: 1;
min-width: 0;
}
.card-title {
font-weight: 600;
font-size: 16px;
color: #303133;
}
/* 设备状态 */
.device-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.device-info {
margin-bottom: 12px;
}
.connect-row {
display: flex;
gap: 10px;
}
/* 流程图 */
.flow-steps {
padding: 0;
}
.flow-step {
display: flex;
gap: 12px;
padding: 8px 0;
border-bottom: 1px dashed #e4e7ed;
}
.flow-step:last-child {
border-bottom: none;
}
.step-num {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
background: #409eff;
color: white;
font-weight: bold;
font-size: 14px;
flex-shrink: 0;
}
.flow-step p {
margin: 4px 0 0;
font-size: 13px;
color: #606266;
}
/* 终端 */
.terminal-card {
margin-top: 16px;
}
.terminal-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.serial-info {
font-size: 12px;
color: #909399;
font-family: 'Courier New', monospace;
}
.terminal-output {
background: #1a1a2e;
color: #00ff88;
font-family: 'Courier New', monospace;
font-size: 13px;
padding: 12px;
height: 300px;
overflow-y: auto;
border-radius: 4px;
line-height: 1.6;
}
.terminal-empty {
color: #666;
font-style: italic;
}
.terminal-line {
display: flex;
gap: 8px;
padding: 1px 0;
}
.terminal-line .line-time {
color: #555;
font-size: 11px;
flex-shrink: 0;
}
.terminal-line.command .line-content {
color: #ffd700;
}
.terminal-line.response .line-content {
color: #00ff88;
}
.terminal-line.error .line-content {
color: #ff4444;
}
.terminal-line.system .line-content {
color: #8888ff;
}
.terminal-line.success .line-content {
color: #44ff44;
}
.terminal-line.blank {
height: 4px;
}
.terminal-input-area {
margin-top: 12px;
}
.command-prompt {
display: flex;
align-items: center;
gap: 8px;
}
.prompt {
font-family: 'Courier New', monospace;
font-size: 14px;
color: #409eff;
font-weight: bold;
flex-shrink: 0;
}
.command-input {
flex: 1;
}
.quick-commands {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 10px;
align-items: center;
}
.quick-label {
font-size: 12px;
color: #909399;
margin-right: 4px;
}
/* 消息发送 */
.send-card {
margin-top: 16px;
}
.send-area {
display: flex;
flex-direction: column;
gap: 10px;
}
.send-row {
display: flex;
align-items: center;
gap: 12px;
}
.char-count {
font-size: 13px;
}
.char-ok { color: #67c23a; }
.char-warn { color: #e6a23c; }
.char-over { color: #f56c6c; }
/* 练习 */
.practice-card {
margin-top: 16px;
}
.practice-item {
padding: 10px 0;
border-bottom: 1px dashed #ebeef5;
}
.practice-item:last-child {
border-bottom: none;
}
.practice-item p {
font-size: 14px;
color: #303133;
margin: 0 0 8px;
}
.practice-input {
max-width: 500px;
}
/* 响应式 */
@media (max-width: 900px) {
.at-body {
flex-direction: column;
}
.at-knowledge {
flex: none;
max-height: none;
}
}
</style>

1458
src/views/Competition.vue Normal file

File diff suppressed because it is too large Load Diff

490
src/views/Dashboard.vue Normal file
View File

@@ -0,0 +1,490 @@
<template>
<div class="dashboard-page">
<div class="dashboard-header">
<el-button text @click="$router.push('/')" class="back-btn">
<el-icon><HomeFilled /></el-icon><span>返回首页</span>
</el-button>
<h1 class="welcome-title">欢迎回来,{{ authStore.userName }}</h1>
</div>
<div class="dashboard-grid">
<!-- ==================== 进入比赛 ==================== -->
<el-card class="dash-card dash-card--contest" shadow="hover">
<div class="card-header">
<h3 class="card-title">{{ isUploadGroup ? '在线提交' : '在线答题' }}</h3>
<el-tag :type="examStatusTag.type" size="small">
{{ examStatusTag.text }}
</el-tag>
</div>
<div class="card-body">
<p class="exam-desc" v-if="contestStatus === 'ended'">考试已结束,无法再次进入</p>
<p class="exam-desc" v-else-if="(!contestTime || contestStatus === 'not_started') && !hasSubmitted">
{{ isUploadGroup ? '点击下方按钮进入比赛页面,请确保网络稳定,准备好后开始提交作品。' : '点击下方按钮进入比赛页面,请确保网络稳定,准备好后开始答题。' }}
</p>
<p class="exam-desc" v-else-if="!hasSubmitted">请关注比赛时间安排</p>
<el-button
type="primary"
size="large"
class="card-action-btn"
v-guard="() => enterContest()"
:disabled="contestStatus === 'ended'"
>
{{ enterBtnText }}
</el-button>
</div>
</el-card>
<!-- ==================== 模拟考试(仅中职/高职) ==================== -->
<el-card v-if="showMockExam" class="dash-card dash-card--mock" shadow="hover">
<div class="card-header">
<h3 class="card-title">模拟考试</h3>
</div>
<div class="card-body">
<p class="exam-desc">
模拟考试系统,模拟选拔赛的完整流程,仅作考试流程演示,提供习题并非考试真题。
</p>
<el-button type="primary" size="large" class="card-action-btn" @click="$router.push('/mock-exam')">
进入模拟考试
</el-button>
</div>
</el-card>
<!-- ==================== 我的成绩 ==================== -->
<el-card class="dash-card dash-card--score" shadow="hover">
<div class="card-header">
<h3 class="card-title">我的成绩</h3>
</div>
<div class="card-body">
<p class="exam-desc" v-if="noScore">
{{ scoreMessage }}
</p>
<div v-else class="score-display">
<div class="score-number">{{ scoreData.score || 0 }}</div>
<span class="score-unit">分</span>
</div>
<el-button type="primary" size="large" class="card-action-btn" @click="viewScore">
查看详情
</el-button>
</div>
</el-card>
<!-- ==================== 资料下载 ==================== -->
<el-card class="dash-card dash-card--materials" shadow="hover">
<div class="card-header">
<h3 class="card-title">资料下载</h3>
</div>
<div class="card-body">
<p class="exam-desc">
关于中职组/高职组/本科组/教师组比赛相关的说明资料,点击按钮进入下载界面
</p>
<!-- 百度网盘提示词暂时从前端隐藏(需求:测试反馈第4项)
<div class="materials-links">
<p class="materials-link-item">
<el-link type="primary" href="https://pan.baidu.com" target="_blank">百度网盘:https://pan.baidu.com/s/xxxxxx</el-link>
<span class="materials-code">(提取码:2026)</span>
</p>
</div>
-->
<el-button type="primary" size="large" class="card-action-btn" @click="$router.push('/materials')">
查看备赛资料
</el-button>
</div>
</el-card>
</div>
<!-- ==================== 公告区 ==================== -->
<div class="dashboard-news" v-if="newsList.length > 0">
<h3 class="news-title">{{ groupName }}通知公告</h3>
<el-card class="news-card" shadow="never">
<div
v-for="item in newsList"
:key="item.id"
class="news-item"
@click="viewNewsDetail(item.id)"
>
<span class="news-dot"></span>
<span class="news-item-title">{{ item.title }}</span>
<span class="news-item-time">{{ formatDate(item.publish_time) }}</span>
</div>
</el-card>
</div>
</div>
</template>
<script setup>
/**
* Dashboard.vue - 选手工作台
*
* 功能:
* 1. 在线答题
* 2. 模拟考试(仅中职/高职组)
* 3. 我的成绩
* 4. 资料下载
* 5. 公告通知
*/
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { HomeFilled } from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
import * as examApi from '@/api/exam'
import * as newsApi from '@/api/news'
const router = useRouter()
const authStore = useAuthStore()
// ==================== 状态 ====================
const contestStatus = ref('not_started')
const contestTime = ref(null)
const hasSubmitted = ref(false)
const scoreData = ref(null)
const newsList = ref([])
// ==================== 计算属性 ====================
const showMockExam = computed(() => {
const group = authStore.userGroup
return group === 'zhongzhi' || group === 'gaozhi'
})
const groupName = computed(() => {
const map = { zhongzhi: '中职组', gaozhi: '高职组', benke: '本科组', jiaoshi: '教师组' }
return map[authStore.userGroup] || '全部'
})
// 本科组/教师组为「作品提交」模式,在线答题提示词应指引「开始提交作品」;
// 中职组/高职组为在线考试,提示「开始答题」。
const isUploadGroup = computed(() => {
const g = authStore.userGroup
return g === 'benke' || g === 'jiaoshi'
})
const noScore = computed(() => !scoreData.value || scoreData.value.score === undefined || scoreData.value.score === null)
const scoreMessage = computed(() => {
if (contestStatus.value === 'ended') return '成绩暂未发布,请关注公告'
return '暂无成绩,请耐心等待成绩发布后查看'
})
// 进入比赛按钮文案:截止后→「已结束」;已提交→ 提交组「重新提交」/考试组「继续考试」;
// 其余→「进入比赛」。考试组(中职/高职)已提交也可在截止前重进(应对意外退出)。
const enterBtnText = computed(() => {
if (contestStatus.value === 'ended') return '已结束'
if (hasSubmitted.value) {
return isUploadGroup.value ? '重新提交' : '继续考试'
}
return '进入比赛'
})
// 在线答题卡片右上角状态标签:随「考试窗口状态 + 本人是否提交」联动
// 后端 getExamStatus 返回 status ∈ {not_set, not_started, in_progress, ended},
// 此前前端误判 === 'open' 导致活动期恒定显示「未开始」;现统一在此映射并纳入已提交态。
const examStatusTag = computed(() => {
if (hasSubmitted.value) {
return { text: '已提交', type: 'primary' }
}
switch (contestStatus.value) {
case 'in_progress':
return { text: '进行中', type: 'success' }
case 'not_started':
return { text: '未开始', type: 'warning' }
case 'ended':
return { text: '已结束', type: 'danger' }
case 'not_set':
default:
return { text: '未配置', type: 'info' }
}
})
// ==================== 方法 ====================
function formatDate(dateStr) {
if (!dateStr) return ''
const d = new Date(dateStr)
if (isNaN(d.getTime())) return dateStr
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
async function enterContest() {
// 所有组别统一:仅考试窗口内(isActive)允许进入;截止后(ended)统一禁止。
// 考试组/提交组即便已提交,也可在截止前重新进入(应对断网/断电/误操作退出)。
try {
const group = authStore.userGroup || 'zhongzhi'
const data = await examApi.getExamStatus(group)
if (!data.isActive) {
ElMessage.warning(data.message || (data.status === 'ended' ? '考试已结束,无法进入' : '考试未在进行中,请等待考试开始'))
return
}
} catch (e) {
ElMessage.warning('获取考试状态失败,请稍后重试')
return
}
router.push('/competition')
}
function viewScore() {
router.push('/score')
}
function viewNewsDetail(id) {
router.push(`/news/${id}`)
}
async function fetchContestStatus() {
try {
const group = authStore.userGroup || 'zhongzhi'
const data = await examApi.getExamStatus(group)
if (data) {
contestStatus.value = data.status || 'not_started'
contestTime.value = data.time || null
hasSubmitted.value = data.submitted || false
}
} catch (err) {
contestStatus.value = 'not_started'
}
}
async function fetchScore() {
try {
const group = authStore.userGroup || 'zhongzhi'
const data = await examApi.getScore(group)
if (data) {
scoreData.value = data
}
} catch (err) {
// 接口可能不存在
}
}
async function fetchNews() {
try {
const group = authStore.userGroup || 'zhongzhi'
const data = await newsApi.getNewsList({ group, page: 1, pageSize: 5 })
if (data && data.list) {
newsList.value = data.list
} else if (Array.isArray(data)) {
newsList.value = data.slice(0, 5)
}
} catch (err) {
// 接口可能不存在
}
}
onMounted(() => {
fetchContestStatus()
fetchScore()
fetchNews()
})
</script>
<style scoped>
.dashboard-page {
min-height: 100vh;
background: #f5f6f8;
padding: 24px 20px 60px;
}
.dashboard-header {
max-width: 1200px;
margin: 0 auto 28px;
}
.back-btn {
font-size: 15px;
font-weight: 600;
color: #1a3a5c;
margin-bottom: 12px;
padding: 6px 14px;
border: 1px solid #1a3a5c;
border-radius: 8px;
transition: all 0.2s;
}
/* 本按钮的设计意图是 hover「深蓝实底 + 白字」。
必须提高特异性才能压过 Element Plus 的
.el-button.is-text:not(.is-disabled):hover(4 个类级、近白不透明底),
否则深蓝底被覆盖、只剩白字 → 白底白字不可读(已复现的 UI 缺陷)。
此处 .dashboard-header + .el-button + .back-btn + :hover + scoped 属性 = 5 个类级,稳定胜出。 */
.dashboard-header .el-button.back-btn:hover,
.dashboard-header .el-button.back-btn:focus {
color: #fff;
background-color: #1a3a5c;
}
.welcome-title {
font-size: 28px;
font-weight: 700;
color: #1f2937;
margin: 0;
}
/* ==================== Grid ==================== */
.dashboard-grid {
max-width: 1200px;
margin: 0 auto;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 24px;
}
/* ==================== Card ==================== */
.dash-card {
border-radius: 12px;
border: 1px solid #e5e7eb;
transition: transform 0.3s, box-shadow 0.3s;
}
.dash-card :deep(.el-card__body) {
display: flex;
flex-direction: column;
height: 100%;
}
.dash-card:hover {
transform: translateY(-4px);
}
.dash-card--contest {
border-left: 4px solid #3b82f6;
}
.dash-card--training {
border-left: 4px solid #10b981;
}
.dash-card--mock {
border-left: 4px solid #f59e0b;
}
.dash-card--materials {
border-left: 4px solid #8b5cf6;
}
.dash-card--score {
border-left: 4px solid #ef4444;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.card-title {
font-size: 18px;
font-weight: 700;
color: #1f2937;
margin: 0;
}
.card-body {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.exam-desc {
font-size: 14px;
color: #6b7280;
line-height: 1.7;
margin-bottom: 16px;
min-height: 48px;
}
.card-action-btn {
width: 100%;
max-width: 200px;
font-weight: 600;
letter-spacing: 1px;
margin-top: auto;
}
/* ==================== Materials ==================== */
.materials-links {
margin-bottom: 12px;
text-align: center;
}
.materials-link-item {
font-size: 13px;
color: #6b7280;
margin-bottom: 4px;
}
.materials-code {
color: #909399;
font-size: 12px;
margin-left: 4px;
}
/* ==================== Score ==================== */
.score-display {
display: flex;
align-items: baseline;
justify-content: center;
margin-bottom: 16px;
}
.score-number {
font-size: 48px;
font-weight: 800;
color: #1a3a5c;
line-height: 1;
}
.score-unit {
font-size: 18px;
color: #6b7280;
margin-left: 2px;
}
/* ==================== News ==================== */
.dashboard-news {
max-width: 1200px;
margin: 32px auto 0;
}
.news-title {
font-size: 18px;
font-weight: 700;
color: #1f2937;
margin-bottom: 16px;
}
.news-card {
border-radius: 12px;
border: 1px solid #e5e7eb;
}
.news-item {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 8px;
cursor: pointer;
border-radius: 8px;
transition: background 0.2s;
border-bottom: 1px solid #e5e7eb;
}
.news-item:last-child {
border-bottom: none;
}
.news-item:hover {
background: #f5f6f8;
}
.news-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #1a3a5c;
flex-shrink: 0;
}
.news-item-title {
font-size: 15px;
font-weight: 500;
color: #1f2937;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.news-item-time {
font-size: 13px;
color: #6b7280;
flex-shrink: 0;
white-space: nowrap;
}
/* ==================== Responsive ==================== */
@media (max-width: 768px) {
.dashboard-grid {
grid-template-columns: 1fr;
}
.welcome-title {
font-size: 22px;
}
}
</style>

View File

@@ -0,0 +1,617 @@
<template>
<div class="forgot-page">
<div class="forgot-container">
<!-- 头部 -->
<div class="forgot-header">
<div class="header-icon">&#128274;</div>
<h1 class="header-title">忘记密码</h1>
<p class="header-subtitle">通过身份验证重置您的登录密码</p>
</div>
<!-- 步骤条 -->
<el-steps :active="currentStep" align-center finish-status="success" class="forgot-steps">
<el-step title="身份验证" description="输入身份证号" />
<el-step title="短信验证" description="输入验证码" />
<el-step title="重置密码" description="设置新密码" />
</el-steps>
<!-- ========== 步骤1:身份验证 ========== -->
<el-card v-if="currentStep === 0" class="step-card" shadow="never">
<template #header>
<span class="step-card-title">第一步:身份验证</span>
</template>
<el-form
ref="step1FormRef"
:model="step1Form"
:rules="step1Rules"
label-position="top"
>
<el-form-item label="身份证号" prop="idNumber">
<el-input
v-model="step1Form.idNumber"
placeholder="请输入您的身份证号"
size="large"
clearable
maxlength="18"
/>
</el-form-item>
<el-form-item label="点击验证码" prop="captchaCode">
<div class="click-captcha-area">
<div class="captcha-hint" v-if="captchaHint">{{ captchaHint }}</div>
<div class="captcha-chars">
<span
v-for="(char, idx) in captchaChars"
:key="idx"
class="captcha-char"
:class="{ clicked: captchaClicked.has(idx) }"
@click="handleCaptchaClick(idx, char)"
>{{ char }}</span>
</div>
<div class="captcha-sequence" v-if="captchaSequence">
已点击:<span class="seq-chars">{{ captchaSequence }}</span>
</div>
<el-link type="primary" v-guard="() => refreshCaptcha()" :underline="false" class="captcha-refresh">
<el-icon><Refresh /></el-icon><span>换一组</span>
</el-link>
</div>
</el-form-item>
<el-form-item>
<el-button
type="primary"
size="large"
class="step-btn"
:loading="step1Loading"
v-guard="() => handleStep1()"
>
下一步
</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- ========== 步骤2:短信验证 ========== -->
<el-card v-if="currentStep === 1" class="step-card" shadow="never">
<template #header>
<span class="step-card-title">第二步:短信验证</span>
</template>
<el-form
ref="step2FormRef"
:model="step2Form"
:rules="step2Rules"
label-position="top"
>
<div class="phone-display">
<span class="phone-label">短信已发送至手机号后四位:</span>
<span class="phone-value">{{ phoneMask }}</span>
</div>
<el-form-item label="短信验证码" prop="smsCode">
<div class="sms-row">
<el-input
v-model="step2Form.smsCode"
placeholder="请输入 6 位短信验证码"
size="large"
maxlength="6"
class="sms-input"
/>
<el-button
size="large"
class="sms-btn"
:disabled="smsCountdown > 0"
v-guard="() => resendSms()"
>
{{ smsCountdown > 0 ? `${smsCountdown}s` : '重新发送' }}
</el-button>
</div>
</el-form-item>
<el-form-item>
<div class="step-btn-group">
<el-button size="large" @click="currentStep = 0">上一步</el-button>
<el-button
type="primary"
size="large"
class="step-btn"
:loading="step2Loading"
@click="handleStep2"
>
下一步
</el-button>
</div>
</el-form-item>
</el-form>
</el-card>
<!-- ========== 步骤3:重置密码 ========== -->
<el-card v-if="currentStep === 2" class="step-card" shadow="never">
<template #header>
<span class="step-card-title">第三步:重置密码</span>
</template>
<el-form
ref="step3FormRef"
:model="step3Form"
:rules="step3Rules"
label-position="top"
>
<el-form-item label="新密码" prop="password">
<el-input
v-model="step3Form.password"
type="password"
placeholder="请设置新密码(至少6位)"
size="large"
show-password
/>
</el-form-item>
<el-form-item label="确认新密码" prop="confirmPassword">
<el-input
v-model="step3Form.confirmPassword"
type="password"
placeholder="请再次输入新密码"
size="large"
show-password
/>
</el-form-item>
<el-form-item>
<div class="step-btn-group">
<el-button size="large" @click="currentStep = 1">上一步</el-button>
<el-button
type="primary"
size="large"
class="step-btn"
:loading="step3Loading"
v-guard="() => handleReset()"
>
重置密码
</el-button>
</div>
</el-form-item>
</el-form>
</el-card>
<!-- 返回链接 -->
<div class="back-link">
<el-link type="info" @click="$router.push('/')">
<el-icon><HomeFilled /></el-icon><span>返回首页</span>
</el-link>
<span class="link-sep">|</span>
<el-link type="info" @click="$router.push('/login')">
<el-icon><ArrowLeft /></el-icon><span>返回登录</span>
</el-link>
</div>
</div>
</div>
</template>
<script setup>
/**
* ForgotPassword.vue - 忘记密码页面(公开访问)
*
* 流程:
* 步骤1:输入身份证号 + 图形验证码
* 步骤2:短信验证码验证
* 步骤3:设置新密码
*/
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ArrowLeft, HomeFilled, Refresh } from '@element-plus/icons-vue'
import * as authApi from '@/api/auth'
import { showTestSmsCode } from '@/utils/testSms'
const router = useRouter()
// ==================== 状态 ====================
const currentStep = ref(0)
// 点击式验证码状态
const captchaChars = ref([])
const captchaHint = ref('')
const captchaClicked = ref(new Set())
const captchaSequence = ref('')
const smsCountdown = ref(0)
const userPhone = ref('')
let smsTimer = null
// 步骤1表单
const step1FormRef = ref(null)
const step1Form = reactive({
idNumber: '',
captchaCode: '',
})
const step1Loading = ref(false)
// 步骤2表单
const step2FormRef = ref(null)
const step2Form = reactive({
smsCode: '',
})
const step2Loading = ref(false)
// 步骤3表单
const step3FormRef = ref(null)
const step3Form = reactive({
password: '',
confirmPassword: '',
})
const step3Loading = ref(false)
// ==================== 计算属性 ====================
const phoneMask = computed(() => {
const phone = userPhone.value
if (phone.length >= 4) {
return '****' + phone.slice(-4)
}
return phone || '****'
})
// ==================== 表单验证规则 ====================
const step1Rules = {
idNumber: [
{ required: true, message: '请输入身份证号', trigger: 'blur' },
{
pattern: /^[1-9]\d{5}(19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/,
message: '请输入正确的身份证号',
trigger: 'blur',
},
],
captchaCode: [
{ required: true, message: '请按顺序点击验证码', trigger: 'change' },
],
}
const step2Rules = {
smsCode: [
{ required: true, message: '请输入短信验证码', trigger: 'blur' },
{ len: 6, message: '请输入 6 位短信验证码', trigger: 'blur' },
],
}
const step3Rules = {
password: [
{ required: true, message: '请设置新密码', trigger: 'blur' },
{ min: 6, message: '密码至少 6 个字符', trigger: 'blur' },
{ max: 20, message: '密码不超过 20 个字符', trigger: 'blur' },
],
confirmPassword: [
{ required: true, message: '请确认新密码', trigger: 'blur' },
{
validator: (rule, value, callback) => {
if (value !== step3Form.password) {
callback(new Error('两次输入的密码不一致'))
} else {
callback()
}
},
trigger: 'blur',
},
],
}
// ==================== 方法 ====================
/** 获取点击式验证码 */
async function refreshCaptcha() {
try {
captchaChars.value = []
captchaHint.value = ''
captchaClicked.value = new Set()
captchaSequence.value = ''
step1Form.captchaCode = ''
const data = await authApi.getCaptcha()
if (data && data.chars) {
captchaChars.value = data.chars
captchaHint.value = data.hint || ''
}
} catch (e) {
console.warn('获取验证码失败', e)
}
}
/** 点击验证码字符 */
function handleCaptchaClick(idx, char) {
if (captchaClicked.value.has(idx)) return
const newClicked = new Set(captchaClicked.value)
newClicked.add(idx)
captchaClicked.value = newClicked
captchaSequence.value += char
step1Form.captchaCode = captchaSequence.value
}
/** 步骤1:验证身份并发送短信 */
async function handleStep1() {
if (!step1FormRef.value) return
try {
await step1FormRef.value.validate()
step1Loading.value = true
const result = await authApi.forgotPassword(step1Form.idNumber, step1Form.captchaCode)
if (result?.phone) {
userPhone.value = result.phone
}
// 测试环境:后端随响应回传验证码,弹窗展示(生产环境不回传、不弹窗,走真实短信)
if (result?.smsCode) {
showTestSmsCode(result.smsCode, '忘记密码')
}
ElMessage.success('短信验证码已发送')
currentStep.value = 1
startSmsCountdown()
} catch (err) {
if (err?.message) ElMessage.error(err.message)
refreshCaptcha()
} finally {
step1Loading.value = false
}
}
/** 步骤2:验证短信验证码 */
async function handleStep2() {
if (!step2FormRef.value) return
try {
await step2FormRef.value.validate()
step2Loading.value = true
// 短信码由后端在重置密码时验证
currentStep.value = 2
} catch (err) {
if (err?.message) ElMessage.error(err.message)
} finally {
step2Loading.value = false
}
}
/** 步骤3:重置密码 */
async function handleReset() {
if (!step3FormRef.value) return
try {
await step3FormRef.value.validate()
step3Loading.value = true
await authApi.resetPassword({
id_number: step1Form.idNumber,
password: step3Form.password,
smsCode: step2Form.smsCode,
})
ElMessage.success('密码重置成功!请使用新密码登录')
setTimeout(() => {
router.push('/login')
}, 1500)
} catch (err) {
if (err?.message) ElMessage.error(err.message)
} finally {
step3Loading.value = false
}
}
/** 重新发送短信 */
async function resendSms() {
if (smsCountdown.value > 0) return
try {
await refreshCaptcha()
const result = await authApi.forgotPassword(step1Form.idNumber, step1Form.captchaCode)
if (result?.smsCode) {
showTestSmsCode(result.smsCode, '忘记密码(重新发送)')
}
ElMessage.success('短信验证码已重新发送')
startSmsCountdown()
} catch (err) {
if (err?.message) ElMessage.error(err.message)
refreshCaptcha()
}
}
/** 启动倒计时 */
function startSmsCountdown() {
smsCountdown.value = 60
clearInterval(smsTimer)
smsTimer = setInterval(() => {
smsCountdown.value--
if (smsCountdown.value <= 0) {
clearInterval(smsTimer)
smsTimer = null
}
}, 1000)
}
// ==================== 生命周期 ====================
onMounted(() => {
refreshCaptcha()
})
onUnmounted(() => {
if (smsTimer) clearInterval(smsTimer)
})
</script>
<style scoped>
.forgot-page {
min-height: 100vh;
background: linear-gradient(135deg, #f5f6f8 0%, #eef0f5 100%);
padding: 40px 20px;
display: flex;
justify-content: center;
}
.forgot-container {
width: 100%;
max-width: 540px;
}
.forgot-header {
text-align: center;
margin-bottom: 32px;
}
.header-icon {
font-size: 48px;
margin-bottom: 12px;
}
.header-title {
font-size: 28px;
font-weight: 700;
color: #1a3a5c;
margin-bottom: 8px;
}
.header-subtitle {
font-size: 14px;
color: #909399;
}
.forgot-steps {
margin-bottom: 32px;
}
.step-card {
border-radius: 12px;
border: 1px solid #e5e7eb;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
}
.step-card-title {
font-size: 16px;
font-weight: 600;
color: #1a3a5c;
}
/* ==================== 点击验证码 ==================== */
.click-captcha-area {
width: 100%;
}
.captcha-hint {
font-size: 14px;
color: #303133;
margin-bottom: 10px;
font-weight: 500;
}
.captcha-chars {
display: flex;
gap: 12px;
margin-bottom: 8px;
}
.captcha-char {
width: 52px;
height: 52px;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid #dcdfe6;
border-radius: 8px;
font-size: 22px;
font-weight: bold;
color: #303133;
cursor: pointer;
user-select: none;
background: #fff;
transition: all 0.2s;
}
.captcha-char:hover {
border-color: #409eff;
background: #ecf5ff;
transform: scale(1.05);
}
.captcha-char.clicked {
border-color: #67c23a;
background: #f0f9eb;
color: #67c23a;
cursor: default;
transform: scale(1.05);
}
.captcha-sequence {
font-size: 13px;
color: #606266;
margin-bottom: 6px;
}
.seq-chars {
font-weight: bold;
color: #67c23a;
letter-spacing: 4px;
}
.captcha-refresh {
font-size: 13px;
}
.sms-row {
display: flex;
gap: 12px;
}
.sms-input {
flex: 1;
}
.sms-btn {
flex-shrink: 0;
min-width: 110px;
}
.phone-display {
background: #f5f7fa;
border: 1px solid #e4e7ed;
border-radius: 6px;
padding: 12px 16px;
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 8px;
}
.phone-label {
font-size: 14px;
color: #909399;
}
.phone-value {
font-size: 14px;
color: #303133;
font-weight: 600;
}
.step-btn {
width: 100%;
height: 46px;
font-size: 16px;
}
.step-btn-group {
display: flex;
gap: 12px;
width: 100%;
}
.step-btn-group .el-button {
flex: 1;
}
.back-link {
text-align: center;
margin-top: 24px;
}
@media (max-width: 480px) {
.forgot-page {
padding: 20px 12px;
}
.header-title {
font-size: 22px;
}
.captcha-img {
width: 100px;
}
}
</style>

965
src/views/Home.vue Normal file
View File

@@ -0,0 +1,965 @@
<template>
<div class="home-page">
<!-- ==================== 顶部导航栏 ==================== -->
<header class="home-header">
<div class="header-inner">
<div class="header-logo">
<span class="logo-icon">&#9788;</span>
<span class="logo-text">卫星通信与导航应用技术创新赛</span>
</div>
<nav class="header-nav">
<a href="#intro" class="nav-link">赛事介绍</a>
<a href="#groups" class="nav-link">竞赛组别</a>
<a href="#news" class="nav-link">新闻公告</a>
<a href="#process" class="nav-link">参赛流程</a>
</nav>
<div class="header-actions">
<template v-if="authStore.isLoggedIn">
<el-button v-if="authStore.isAdmin" type="primary" size="default" @click="$router.push('/admin')">
管理后台
</el-button>
<el-button v-else type="primary" size="default" @click="$router.push('/dashboard')">
选手工作台
</el-button>
<el-button type="default" size="default" v-guard="() => handleLogout()">
退出登录
</el-button>
</template>
<template v-else>
<el-button type="primary" size="default" @click="$router.push('/login')">
选手登录
</el-button>
<el-button type="warning" size="default" @click="$router.push('/activate')">
激活账号
</el-button>
</template>
</div>
</div>
</header>
<!-- ==================== 轮播图区域(全部由后台数据库驱动) ==================== -->
<section class="carousel-section" v-if="carouselList.length">
<el-carousel
:interval="5000"
:height="carouselHeight"
indicator-position="outside"
arrow="always"
trigger="click"
>
<el-carousel-item v-for="item in carouselList" :key="'slide-' + item.id">
<div
class="carousel-slide"
:class="{ 'carousel-slide--gradient': !item.imageUrl }"
:style="item.imageUrl ? { backgroundImage: `url(${item.imageUrl})` } : {}"
>
<div class="carousel-overlay"></div>
<div class="carousel-content">
<!-- 标题:用 v-html 以支持 <br> 等基础格式 -->
<h1 class="carousel-title" v-if="item.title" v-html="item.title"></h1>
<!-- 富文本正文(wangEditor 生成,v-html 渲染) -->
<div class="carousel-richtext" v-if="item.content" v-html="item.content"></div>
<!-- 可配置按钮:外链用 a[target=_blank],站内路由用 router-link -->
<template v-if="item.buttonText">
<a
v-if="isExternalLink(item.buttonLink)"
:href="item.buttonLink"
target="_blank"
rel="noopener noreferrer"
class="carousel-cta"
>{{ item.buttonText }}</a>
<router-link
v-else
:to="item.buttonLink || '/activate'"
class="carousel-cta"
>{{ item.buttonText }}</router-link>
</template>
</div>
</div>
</el-carousel-item>
</el-carousel>
</section>
<!-- ==================== 赛事介绍 ==================== -->
<section class="intro-section" id="intro">
<el-row justify="center">
<el-col :xs="24" :md="18" :lg="16">
<div class="intro-card">
<h2 class="section-title">
<span class="title-line"></span>
卫星通信与导航应用技术创新赛
</h2>
<el-divider />
<p class="intro-text">
卫星通信与导航应用技术竞赛为2026"一带一路"暨金砖国家技能发展与技术创新大赛全新增设核心赛项。
赛事聚焦卫星通信、导航定位领域前沿核心技术,紧扣产业发展实际需求与现代职业教育改革导向,
依托多层级、立体化竞赛考核体系,着力展示行业尖端技术成果,引领职业教育教学革新,
全面提升院校实训教学水平,锤炼参赛选手综合实操与创新能力,选拔培育卫星信息领域高素质技术技能人才。
</p>
</div>
</el-col>
</el-row>
</section>
<!-- ==================== 竞赛组别 ==================== -->
<section class="groups-section" id="groups">
<div class="section-header">
<h2 class="section-title"><span class="title-line"></span>竞赛组别</h2>
<p class="section-subtitle">选择适合您的赛道,展现您的技术实力</p>
</div>
<el-row justify="center">
<el-col :xs="24" :md="22" :lg="20">
<el-row :gutter="24" justify="center">
<el-col :xs="24" :sm="12" :md="6" v-for="grp in groups" :key="grp.key">
<el-card class="group-card" :class="`group-card--${grp.key}`" :body-style="{ padding: '0' }" shadow="hover">
<div class="group-card-inner">
<div class="group-card-header" :style="{ background: grp.color }">
<div class="group-icon"><span class="icon-placeholder">{{ grp.icon }}</span></div>
<h3 class="group-name">{{ grp.name }}</h3>
</div>
<div class="group-card-body">
<el-tag :color="grp.tagColor" class="group-tag" effect="dark">{{ grp.type }}</el-tag>
<p class="group-desc">{{ grp.desc }}</p>
<el-divider class="group-divider" />
<el-button class="group-link-btn" :style="{ color: grp.color, borderColor: grp.color }" plain round size="small" @click="$router.push('/login')">
立即报名
</el-button>
</div>
</div>
</el-card>
</el-col>
</el-row>
</el-col>
</el-row>
</section>
<!-- ==================== 新闻公告(带分组 Tab 筛选) ==================== -->
<section class="news-section" id="news">
<div class="section-header">
<h2 class="section-title"><span class="title-line"></span>新闻公告</h2>
<p class="section-subtitle">了解赛事最新动态与通知</p>
</div>
<el-row :gutter="20" justify="center">
<el-col :xs="24" :md="18" :lg="16">
<!-- 分组 Tab -->
<el-tabs v-model="activeNewsTab" @tab-change="onNewsTabChange" class="news-tabs">
<el-tab-pane
v-for="tab in newsTabs"
:key="tab.key"
:label="`${tab.label} (${tab.count})`"
:name="tab.key"
/>
</el-tabs>
<el-card shadow="never" class="news-card" v-loading="newsLoading">
<!-- 置顶新闻 -->
<div v-if="pinnedNews.length > 0" class="pinned-section">
<div class="pinned-header">
<el-icon color="#c9a84c"><Top /></el-icon>
<span class="pinned-title">置顶公告</span>
</div>
<div
v-for="item in pinnedNews"
:key="'pinned-' + item.id"
class="news-item pinned-item"
@click="viewNewsDetail(item.id)"
>
<el-tag type="warning" size="small" effect="dark">置顶</el-tag>
<span class="news-item-title text-ellipsis">{{ item.title }}</span>
<template v-if="item.tags">
<el-tag v-for="tag in (item.tags || '').split(',').filter(Boolean)" :key="tag" size="small" class="news-tag" round>{{ tag.trim() }}</el-tag>
</template>
<span class="news-item-time">{{ formatDate(item.publish_time) }}</span>
</div>
</div>
<!-- 普通新闻列表 -->
<div v-if="normalNews.length > 0" class="normal-section">
<div
v-for="item in normalNews"
:key="item.id"
class="news-item"
@click="viewNewsDetail(item.id)"
>
<span class="news-dot"></span>
<span class="news-item-title text-ellipsis">{{ item.title }}</span>
<template v-if="item.tags">
<el-tag v-for="tag in (item.tags || '').split(',').filter(Boolean)" :key="tag" size="small" class="news-tag" round>{{ tag.trim() }}</el-tag>
</template>
<span class="news-item-time">{{ formatDate(item.publish_time) }}</span>
</div>
</div>
<!-- 空状态 -->
<el-empty v-if="!newsLoading && pinnedNews.length === 0 && normalNews.length === 0" description="暂无新闻公告" />
<!-- 分页 -->
<div class="news-pagination" v-if="newsTotal > newsPagination.pageSize">
<el-pagination
v-model:current-page="newsPagination.page"
:page-size="newsPagination.pageSize"
:total="newsTotal"
layout="prev, pager, next"
size="small"
@current-change="onNewsPageChange"
/>
</div>
</el-card>
</el-col>
</el-row>
</section>
<!-- ==================== 参赛流程 ==================== -->
<section class="timeline-section" id="process">
<div class="section-header">
<h2 class="section-title"><span class="title-line"></span>参赛流程</h2>
<p class="section-subtitle">简单四步,开启您的竞赛之旅</p>
</div>
<el-row justify="center">
<el-col :xs="24" :md="18" :lg="16">
<div class="process-timeline">
<template v-for="(step, index) in processSteps" :key="index">
<div class="process-step">
<div class="process-step-badge">
<span class="step-number">{{ index + 1 }}</span>
</div>
<div class="process-step-content">
<h4 class="step-title">{{ step }}</h4>
</div>
</div>
<div class="process-step-arrow" v-if="index < processSteps.length - 1">
<span class="arrow-icon">&#10132;</span>
</div>
</template>
</div>
</el-col>
</el-row>
</section>
<!-- ==================== 资料下载(已移至工作台) ==================== -->
<!--
<section class="materials-section" id="materials">
<div class="section-header">
<h2 class="section-title"><span class="title-line"></span>资料下载</h2>
<p class="section-subtitle">关于中职组/高职组/本科组/教师组比赛相关的说明资料,点击按钮进入下载界面</p>
</div>
<el-row justify="center">
<el-col :xs="24" :md="18" :lg="16">
<div class="materials-card">
<el-alert
title="赛事资料请通过百度网盘下载"
type="info"
:closable="false"
show-icon
class="materials-alert"
>
<template #default>
<p class="materials-link">百度网盘链接:<el-link type="primary" href="https://pan.baidu.com" target="_blank">https://pan.baidu.com/s/xxxxxx</el-link>(提取码:2026)</p>
<p class="materials-hint">请使用微信扫描下方二维码获取资料,或登录后在工作台查看备赛资料</p>
</template>
</el-alert>
<div class="materials-qr">
<div class="qr-placeholder">
<span class="qr-icon">&#9633;</span>
<span class="qr-text">二维码</span>
</div>
<p class="qr-hint">微信扫码获取资料</p>
</div>
</div>
</el-col>
</el-row>
</section>
-->
<!-- ==================== 页脚 ==================== -->
<footer class="footer-section">
<div class="footer-content">
<h3 class="footer-contact-title">联系我们</h3>
<p class="footer-contact">赛事咨询:吴老师 13810099338</p>
<p class="footer-contact">大赛QQ群:1103585733</p>
<p class="footer-text">&copy; {{ currentYear }} 卫星通信与导航应用技术创新赛组委会 版权所有</p>
<p class="footer-subtext">技术支持 | 竞赛咨询请联系组委会官方渠道</p>
</div>
</footer>
</div>
</template>
<script setup>
/**
* Home.vue - 竞赛网站首页(公开访问)
*
* 功能:
* 1. 顶部导航栏(登录/激活按钮)
* 2. 固定两页轮播 + 管理员自定义轮播
* 3. 赛事介绍
* 4. 竞赛组别卡片(4 个组别)
* 5. 新闻公告(Tab 分组筛选)
* 6. 参赛流程时间线
* 7. 资料下载(百度网盘 + 二维码)
* 8. 页脚
*/
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Top } from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
import * as carouselApi from '@/api/carousel'
import * as newsApi from '@/api/news'
import { normalizeImageUrl } from '@/utils/upload'
const router = useRouter()
const authStore = useAuthStore()
// ==================== 状态 ====================
// 轮播图(管理员自定义)
const carouselList = ref([])
const carouselHeight = ref('500px')
// 新闻
const newsList = ref([])
const newsLoading = ref(false)
const activeNewsTab = ref('all')
const newsPagination = ref({ page: 1, pageSize: 10 })
const newsTotal = ref(0)
const newsTabs = ref([
{ key: 'all', label: '全部', count: 0 },
{ key: 'zhongzhi', label: '中职组', count: 0 },
{ key: 'gaozhi', label: '高职组', count: 0 },
{ key: 'benke', label: '本科组', count: 0 },
{ key: 'jiaoshi', label: '教师组', count: 0 },
])
// 竞赛组别
const groups = [
{ key: 'zhongzhi', name: '中职组', icon: '中职', color: '#3b82f6', tagColor: '#3b82f6', type: '在线答题', desc: '重点考察卫星通信与导航领域的基础核心知识和高精度接收机的实操能力。' },
{ key: 'gaozhi', name: '高职组', icon: '高职', color: '#10b981', tagColor: '#10b981', type: '在线答题', desc: '重点考察卫星通信与导航领域的基础核心知识和卫星通信设备的实操能力。' },
{ key: 'benke', name: '本科组', icon: '本科', color: '#f59e0b', tagColor: '#f59e0b', type: '提交作品', desc: '重点考察卫星通信与导航领域的基础核心知识和卫星通信与导航的应用创新能力。' },
{ key: 'jiaoshi', name: '教师组', icon: '教师', color: '#ef4444', tagColor: '#ef4444', type: '提交作品', desc: '重点考察卫星通信与导航技术相关课程的教学方案设计能力和现场表达展现能力。' },
]
const processSteps = ['账号激活', '确认考试信息', '参加考试', '成绩获取']
const currentYear = new Date().getFullYear()
// ==================== 计算属性 ====================
const pinnedNews = computed(() => newsList.value.filter(n => n.pinned))
const normalNews = computed(() => newsList.value.filter(n => !n.pinned))
// ==================== 方法 ====================
function formatDate(dateStr) {
if (!dateStr) return ''
const d = new Date(dateStr)
if (isNaN(d.getTime())) return dateStr
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
function viewNewsDetail(id) {
router.push(`/news/${id}`)
}
function onNewsTabChange() {
newsPagination.value.page = 1
fetchNews()
}
function onNewsPageChange() {
fetchNews()
}
/**
* 判断链接是否为外部链接(http/https/协议相对 //)
* @param {string} link
* @returns {boolean}
*/
function isExternalLink(link) {
if (!link) return false
return /^(https?:)?\/\//i.test(link)
}
async function fetchCarousel() {
try {
const data = await carouselApi.getCarouselList()
if (Array.isArray(data)) {
carouselList.value = data
.filter(item => item.enabled !== false)
.map(item => ({
id: item.id,
imageUrl: item.image_url ? normalizeImageUrl(item.image_url) : '',
title: item.title || '',
content: item.content || '',
buttonText: item.button_text || '',
buttonLink: item.button_link || '',
sortOrder: item.sort_order || 0,
}))
.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0))
}
updateCarouselHeight()
window.addEventListener('resize', updateCarouselHeight)
} catch (err) {
console.warn('获取轮播图失败', err)
}
}
function updateCarouselHeight() {
carouselHeight.value = window.innerWidth < 768 ? '280px' : '500px'
}
async function fetchNews() {
newsLoading.value = true
try {
const params = {
page: newsPagination.value.page,
pageSize: newsPagination.value.pageSize,
}
if (activeNewsTab.value !== 'all') {
params.group = activeNewsTab.value
}
const data = await newsApi.getNewsList(params)
if (data && data.list) {
newsList.value = data.list
newsTotal.value = data.total || 0
} else if (Array.isArray(data)) {
newsList.value = data
newsTotal.value = data.length
} else {
newsList.value = []
newsTotal.value = 0
}
const counts = await newsApi.getNewsCounts()
if (counts) {
newsTabs.value.forEach(tab => {
tab.count = counts[tab.key] || 0
})
}
} catch (err) {
console.warn('获取新闻列表失败', err)
newsList.value = []
newsTotal.value = 0
} finally {
newsLoading.value = false
}
}
async function handleLogout() {
await authStore.logout()
}
onMounted(() => {
fetchCarousel()
fetchNews()
})
</script>
<style scoped>
/* ==================== CSS 变量 ==================== */
.home-page {
--primary: #1a3a5c;
--primary-light: #2a5a8c;
--primary-dark: #0f2640;
--gold: #c9a84c;
--gold-light: #e0c97a;
--bg-light: #f5f6f8;
--bg-white: #ffffff;
--text-primary: #1f2937;
--text-secondary: #6b7280;
--border-light: #e5e7eb;
min-height: 100vh;
background: var(--bg-light);
}
/* ==================== 顶部导航栏 ==================== */
.home-header {
position: sticky;
top: 0;
z-index: 1000;
background: rgba(26, 58, 92, 0.95);
backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.header-inner {
max-width: 1200px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
height: 60px;
}
.header-logo {
display: flex;
align-items: center;
gap: 10px;
}
.logo-icon {
font-size: 24px;
color: var(--gold);
}
.logo-text {
font-size: 16px;
font-weight: 700;
color: #fff;
letter-spacing: 1px;
}
.header-nav {
display: flex;
gap: 28px;
}
.nav-link {
color: rgba(255, 255, 255, 0.8);
font-size: 14px;
transition: color 0.3s;
cursor: pointer;
}
.nav-link:hover {
color: var(--gold);
}
.header-actions {
display: flex;
gap: 10px;
}
@media (max-width: 768px) {
.header-nav { display: none; }
.logo-text { font-size: 13px; }
.header-inner { padding: 0 12px; }
}
/* ==================== 轮播图 ==================== */
.carousel-section {
position: relative;
background: var(--primary-dark);
}
.carousel-section .el-carousel__arrow {
width: 48px;
height: 48px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.18);
color: #fff;
font-size: 20px;
transition: all 0.3s ease;
border: none;
}
.carousel-section .el-carousel__arrow:hover {
/* 箭头图标为白色字体,hover 不可用浅/白色背景,否则图标隐形 */
background: rgba(26, 58, 92, 0.6);
}
.carousel-section .el-carousel__arrow--left {
left: 24px;
}
.carousel-section .el-carousel__arrow--right {
right: 24px;
}
.carousel-slide {
position: relative;
width: 100%;
height: 100%;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
}
/* 无底图时的默认渐变背景(还原原固定页深蓝渐变外观) */
.carousel-slide--gradient {
background: linear-gradient(135deg, #0f2640 0%, #1a3a5c 30%, #2a5a8c 60%, #0f2640 100%);
}
.carousel-overlay {
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
background: radial-gradient(ellipse at 30% 50%, rgba(201, 168, 76, 0.12) 0%, transparent 60%),
radial-gradient(ellipse at 70% 50%, rgba(201, 168, 76, 0.06) 0%, transparent 60%);
pointer-events: none;
}
.carousel-content {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 2;
text-align: center;
color: #fff;
max-width: 800px;
width: 100%;
padding: 0 40px;
}
.carousel-title {
font-size: 40px;
font-weight: 800;
margin-bottom: 18px;
letter-spacing: 2px;
line-height: 1.3;
text-shadow: 0 2px 16px rgba(0, 0, 0, 0.4);
animation: carouselFadeInUp 0.7s ease;
}
.carousel-desc {
font-size: 18px;
color: rgba(255, 255, 255, 0.88);
line-height: 1.8;
margin-bottom: 32px;
text-shadow: 0 1px 8px rgba(0, 0, 0, 0.3);
animation: carouselFadeInUp 0.7s ease 0.15s both;
}
/* 富文本正文容器(wangEditor 生成的 HTML 经 v-html 渲染)
scoped CSS 不作用于 v-html 子节点,故用 :deep() 兜底常见标签的可读性 */
.carousel-richtext {
font-size: 18px;
color: rgba(255, 255, 255, 0.9);
line-height: 1.8;
margin-bottom: 32px;
text-shadow: 0 1px 8px rgba(0, 0, 0, 0.3);
animation: carouselFadeInUp 0.7s ease 0.15s both;
}
.carousel-richtext :deep(p),
.carousel-richtext :deep(span),
.carousel-richtext :deep(li),
.carousel-richtext :deep(div) {
color: inherit;
line-height: 1.8;
}
.carousel-richtext :deep(a) {
color: #e0c97a;
text-decoration: underline;
}
.carousel-richtext :deep(img) {
max-width: 100%;
border-radius: 8px;
}
.carousel-cta {
display: inline-block;
padding: 14px 44px;
font-size: 17px;
font-weight: 600;
letter-spacing: 2px;
color: #1a3a5c;
background: linear-gradient(135deg, #c9a84c, #e0c97a);
border: none;
border-radius: 50px;
text-decoration: none;
cursor: pointer;
box-shadow: 0 4px 20px rgba(201, 168, 76, 0.4);
transition: all 0.3s ease;
animation: carouselFadeInUp 0.7s ease 0.3s both;
}
.carousel-cta:hover {
transform: translateY(-2px);
box-shadow: 0 8px 30px rgba(201, 168, 76, 0.5);
color: #1a3a5c;
}
/* 轮播页二:赛项内容卡片 */
.carousel-items {
display: flex;
justify-content: center;
gap: 24px;
flex-wrap: wrap;
margin-bottom: 36px;
animation: carouselFadeInUp 0.7s ease 0.15s both;
}
.carousel-item-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 20px 24px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 12px;
min-width: 140px;
transition: all 0.3s ease;
}
.carousel-item-card:hover {
background: rgba(255, 255, 255, 0.18);
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(201, 168, 76, 0.2);
}
.carousel-item-card .item-icon {
font-size: 32px;
line-height: 1;
}
.carousel-item-card span {
font-size: 15px;
font-weight: 600;
letter-spacing: 1px;
}
@keyframes carouselFadeInUp {
from {
opacity: 0;
transform: translateY(24px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 768px) {
.carousel-title {
font-size: 26px;
letter-spacing: 1px;
}
.carousel-desc {
font-size: 15px;
margin-bottom: 24px;
}
.carousel-cta {
font-size: 15px;
padding: 12px 32px;
}
.carousel-section .el-carousel__arrow {
width: 38px;
height: 38px;
}
.carousel-section .el-carousel__arrow--left {
left: 12px;
}
.carousel-section .el-carousel__arrow--right {
right: 12px;
}
.carousel-items {
gap: 12px;
}
.carousel-item-card {
min-width: 120px;
padding: 14px 16px;
}
.carousel-item-card .item-icon {
font-size: 26px;
}
.carousel-item-card span {
font-size: 13px;
}
}
/* ==================== Shared Sections ==================== */
.section-header {
text-align: center;
margin-bottom: 44px;
}
.section-title {
font-size: 30px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 12px;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
}
.title-line {
display: inline-block;
width: 4px;
height: 28px;
background: var(--gold);
border-radius: 2px;
}
.section-subtitle {
font-size: 15px;
color: var(--text-secondary);
}
/* ==================== Intro ==================== */
.intro-section {
padding: 72px 20px 56px;
background: var(--bg-white);
}
.intro-card { text-align: center; padding: 20px; }
.intro-text { font-size: 15px; color: var(--text-secondary); line-height: 1.9; margin-bottom: 16px; text-align: left; text-indent: 2em; }
/* ==================== Groups ==================== */
.groups-section {
padding: 72px 20px;
background: var(--bg-light);
}
.group-card { border-radius: 12px; overflow: hidden; border: 1px solid var(--border-light); margin-bottom: 24px; transition: transform 0.3s; }
.group-card:hover { transform: translateY(-6px); }
.group-card-inner { display: flex; flex-direction: column; height: 100%; }
.group-card-header { padding: 28px 20px 20px; text-align: center; color: #fff; }
.group-icon { width: 64px; height: 64px; border-radius: 50%; background: rgba(255, 255, 255, 0.2); display: flex; align-items: center; justify-content: center; margin: 0 auto 12px; }
.icon-placeholder { font-size: 15px; font-weight: 700; letter-spacing: 2px; white-space: nowrap; }
.group-name { font-size: 20px; font-weight: 700; margin: 0; letter-spacing: 2px; }
.group-card-body { padding: 20px; flex: 1; display: flex; flex-direction: column; align-items: center; text-align: center; background: var(--bg-white); }
.group-tag { margin-bottom: 12px; border: none; }
.group-desc { font-size: 13px; color: var(--text-secondary); line-height: 1.8; margin: 0; flex: 1; }
.group-divider { margin: 16px 0; }
.group-link-btn { border-width: 1.5px; }
/* ==================== News ==================== */
.news-section {
padding: 72px 20px;
background: var(--bg-white);
}
.news-tabs .el-tabs__nav-wrap::after { height: 1px; }
.news-tabs .el-tabs__item { font-size: 15px; }
.news-card { border-radius: 12px; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04); }
.pinned-section { margin-bottom: 20px; padding-bottom: 16px; border-bottom: 1px dashed var(--border-light); }
.pinned-header { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
.pinned-title { font-size: 14px; font-weight: 600; color: var(--gold); }
.news-item { display: flex; align-items: center; gap: 12px; padding: 14px 0; border-bottom: 1px solid var(--border-light); cursor: pointer; transition: all 0.2s; }
.news-item:hover { background: #fafbfc; margin: 0 -12px; padding-left: 12px; padding-right: 12px; border-radius: 8px; }
.news-item:last-child { border-bottom: none; }
.news-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--primary-light); flex-shrink: 0; }
.news-item-title { font-size: 15px; color: var(--text-primary); flex: 1; min-width: 0; }
.text-ellipsis { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.news-tag { margin-left: 6px; font-size: 11px; }
.news-item-time { font-size: 13px; color: var(--text-secondary); white-space: nowrap; flex-shrink: 0; }
.news-pagination { margin-top: 20px; display: flex; justify-content: center; }
/* ==================== Timeline ==================== */
.timeline-section {
padding: 72px 20px;
background: var(--bg-light);
}
.process-timeline {
display: flex;
justify-content: center;
align-items: flex-start;
gap: 0;
flex-wrap: wrap;
}
.process-step {
display: flex;
flex-direction: column;
align-items: center;
min-width: 140px;
max-width: 180px;
position: relative;
}
.process-step-badge {
width: 64px;
height: 64px;
border-radius: 50%;
background: linear-gradient(135deg, var(--primary), var(--primary-light));
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16px;
box-shadow: 0 4px 16px rgba(26, 58, 92, 0.25);
}
.step-number {
font-size: 24px;
font-weight: 800;
color: #fff;
}
.process-step-content {
text-align: center;
}
.step-title {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.process-step-arrow {
display: flex;
align-items: flex-start;
justify-content: center;
flex: 0 0 auto;
/* 垂直对齐流程点徽章中心:徽章高 64,中心在 32;箭头图标约 20 高,半高 10 → 32-10=22 */
margin: 22px 14px 0;
z-index: 1;
}
.arrow-icon {
font-size: 20px;
color: var(--primary-light);
}
/* ==================== Materials ==================== */
.materials-section {
padding: 72px 20px;
background: var(--bg-white);
}
.materials-card {
background: var(--bg-light);
border-radius: 12px;
padding: 32px;
text-align: center;
}
.materials-alert {
margin-bottom: 24px;
text-align: left;
}
.materials-link {
margin: 0 0 8px;
font-size: 14px;
}
.materials-hint {
margin: 0;
font-size: 13px;
color: var(--text-secondary);
}
.materials-qr {
margin-top: 8px;
}
.qr-placeholder {
width: 140px;
height: 140px;
border: 2px dashed var(--border-light);
border-radius: 8px;
margin: 0 auto 10px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #fff;
}
.qr-icon {
font-size: 36px;
color: var(--text-secondary);
}
.qr-text {
font-size: 13px;
color: var(--text-secondary);
margin-top: 4px;
}
.qr-hint {
font-size: 13px;
color: var(--text-secondary);
}
/* ==================== Footer ==================== */
.footer-section {
background: var(--primary-dark);
color: rgba(255, 255, 255, 0.7);
padding: 48px 20px 32px;
text-align: center;
}
.footer-content {
max-width: 800px;
margin: 0 auto;
}
.footer-contact-title {
font-size: 18px;
font-weight: 600;
color: #fff;
margin-bottom: 12px;
}
.footer-contact {
font-size: 14px;
margin: 6px 0;
}
.footer-text {
font-size: 13px;
margin-top: 24px;
color: rgba(255, 255, 255, 0.5);
}
.footer-subtext {
font-size: 12px;
color: rgba(255, 255, 255, 0.35);
margin-top: 4px;
}
/* ==================== Responsive ==================== */
@media (max-width: 768px) {
.section-title { font-size: 24px; }
.intro-section, .groups-section, .news-section, .timeline-section { padding: 48px 16px; }
.process-step-arrow { display: none; }
.process-timeline { flex-direction: column; gap: 32px; }
.process-step { flex-direction: row; gap: 16px; min-width: auto; width: 100%; max-width: 300px; }
.process-step-badge { margin-bottom: 0; flex-shrink: 0; }
.process-step-content { text-align: left; }
}
</style>

440
src/views/Login.vue Normal file
View File

@@ -0,0 +1,440 @@
<template>
<div class="login-page">
<div class="login-container">
<!-- 左侧品牌区域 -->
<div class="login-brand">
<div class="brand-content">
<div class="brand-icon">&#9788;</div>
<h1 class="brand-title">卫星通信与导航应用技术创新赛</h1>
<p class="brand-subtitle">汇聚创新力量,推动卫星通信与导航技术融合发展</p>
</div>
</div>
<!-- 右侧登录表单 -->
<div class="login-form-wrapper">
<div class="login-form-card">
<!-- 登录过期提示 -->
<el-alert
v-if="expiredNotice"
type="warning"
:closable="false"
show-icon
class="expired-banner"
title="登录状态已过期"
description="您的登录状态已失效,请重新登录以继续访问备赛资料等功能。"
/>
<!-- Tab 切换 -->
<el-tabs v-model="loginType" class="login-tabs" :before-leave="onTabChange">
<el-tab-pane label="选手登录" name="contestant" />
<el-tab-pane label="管理员登录" name="admin" />
</el-tabs>
<!-- ========== 选手登录表单 ========== -->
<el-form
v-if="loginType === 'contestant'"
ref="contestantFormRef"
:model="contestantForm"
:rules="contestantRules"
label-position="top"
@keyup.enter="handleContestantLogin"
>
<el-form-item label="手机号" prop="phone">
<el-input
v-model="contestantForm.phone"
placeholder="请输入手机号"
:prefix-icon="Phone"
size="large"
clearable
maxlength="11"
/>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input
v-model="contestantForm.password"
type="password"
placeholder="请输入密码"
:prefix-icon="Lock"
size="large"
show-password
@keyup.enter="handleContestantLogin"
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
size="large"
class="login-btn"
:loading="contestantLoading"
v-guard="() => handleContestantLogin()"
>
登 录
</el-button>
</el-form-item>
<div class="login-links">
<el-link type="primary" @click="$router.push('/activate')">激活账号</el-link>
<el-link type="primary" @click="$router.push('/forgot-password')">忘记密码</el-link>
</div>
</el-form>
<!-- ========== 管理员登录表单 ========== -->
<el-form
v-if="loginType === 'admin'"
ref="adminFormRef"
:model="adminForm"
:rules="adminRules"
label-position="top"
@keyup.enter="handleAdminLogin"
>
<el-form-item label="管理员账号" prop="username">
<el-input
v-model="adminForm.username"
placeholder="请输入管理员账号"
:prefix-icon="UserFilled"
size="large"
clearable
/>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input
v-model="adminForm.password"
type="password"
placeholder="请输入密码"
:prefix-icon="Lock"
size="large"
show-password
@keyup.enter="handleAdminLogin"
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
size="large"
class="login-btn"
:loading="adminLoading"
v-guard="() => handleAdminLogin()"
>
管理员登录
</el-button>
</el-form-item>
</el-form>
<!-- 返回首页 -->
<div class="back-home">
<el-link type="info" @click="$router.push('/')">
<el-icon><ArrowLeft /></el-icon><span>返回首页</span>
</el-link>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
/**
* Login.vue - 登录页面(公开访问)
*
* 功能:
* 1. Tab 切换:选手登录 / 管理员登录
* 2. 选手登录:身份证号 + 密码
* 3. 管理员登录:用户名 + 密码
* 4. 链接:激活账号、忘记密码
* 5. 登录成功后跳转:选手 -> /dashboard,管理员 -> /admin
*/
import { ref, reactive, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { UserFilled, Lock, ArrowLeft, Phone } from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
// ==================== 状态 ====================
const loginType = ref('contestant')
const contestantLoading = ref(false)
const adminLoading = ref(false)
// 登录过期提示(由路由 reason=expired 触发)
const expiredNotice = ref(false)
const contestantFormRef = ref(null)
const adminFormRef = ref(null)
// 选手表单
const contestantForm = reactive({
phone: '',
password: '',
})
// 管理员表单
const adminForm = reactive({
username: '',
password: '',
})
// ==================== 表单验证规则 ====================
const contestantRules = {
phone: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{
pattern: /^1[3-9]\d{9}$/,
message: '请输入正确的手机号',
trigger: 'blur',
},
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码至少 6 个字符', trigger: 'blur' },
],
}
const adminRules = {
username: [
{ required: true, message: '请输入管理员账号', trigger: 'blur' },
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
],
}
// ==================== 方法 ====================
/** Tab 切换时清空表单 */
function onTabChange() {
// 清空两个表单
contestantForm.phone = ''
contestantForm.password = ''
adminForm.username = ''
adminForm.password = ''
contestantFormRef.value?.resetFields()
adminFormRef.value?.resetFields()
}
/** 选手登录 */
async function handleContestantLogin() {
if (!contestantFormRef.value) return
try {
await contestantFormRef.value.validate()
contestantLoading.value = true
await authStore.login(contestantForm.phone, contestantForm.password)
ElMessage.success('登录成功')
// 跳转到目标页面
const redirect = route.query.redirect || '/dashboard'
router.push(redirect)
} catch (err) {
if (err?.message) {
ElMessage.error(err.message)
} else if (err !== false) {
// 表单验证失败不提示
}
} finally {
contestantLoading.value = false
}
}
/** 管理员登录 */
async function handleAdminLogin() {
if (!adminFormRef.value) return
try {
await adminFormRef.value.validate()
adminLoading.value = true
await authStore.adminLogin(adminForm.username, adminForm.password)
ElMessage.success('管理员登录成功')
const redirect = route.query.redirect || '/admin'
router.push(redirect)
} catch (err) {
if (err?.message) {
ElMessage.error(err.message)
}
} finally {
adminLoading.value = false
}
}
// ==================== 生命周期 ====================
onMounted(() => {
// 兜底同步:若 localStorage 已无 token(被路由守卫/401 拦截器清理),
// 但以 Pinia store 仍认为已登录,则以 localStorage 为准重新清理,避免登录页自动跳回首页造成死循环。
if (!localStorage.getItem('token') && authStore.isLoggedIn) {
authStore.clearAuth()
}
// 如已登录则自动跳转
if (authStore.isLoggedIn) {
const target = authStore.isAdmin ? '/admin' : '/dashboard'
router.push(target)
}
// 登录过期的友好提示
expiredNotice.value = route.query.reason === 'expired'
})
</script>
<style scoped>
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #0f2640 0%, #1a3a5c 40%, #2a5a8c 100%);
padding: 20px;
}
.login-container {
display: flex;
width: 100%;
max-width: 960px;
min-height: 540px;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
/* ==================== 左侧品牌区域 ==================== */
.login-brand {
flex: 1;
background: linear-gradient(135deg, #1a3a5c 0%, #0f2640 100%);
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
position: relative;
overflow: hidden;
}
.login-brand::before {
content: '';
position: absolute;
top: -50%;
right: -50%;
width: 200%;
height: 200%;
background: radial-gradient(circle at 30% 50%, rgba(201, 168, 76, 0.1) 0%, transparent 50%);
}
.brand-content {
position: relative;
z-index: 1;
text-align: center;
}
.brand-icon {
font-size: 56px;
color: #c9a84c;
margin-bottom: 20px;
}
.brand-title {
font-size: 24px;
font-weight: 700;
color: #fff;
margin-bottom: 16px;
letter-spacing: 2px;
line-height: 1.4;
}
.brand-subtitle {
font-size: 14px;
color: rgba(255, 255, 255, 0.7);
line-height: 1.6;
}
/* ==================== 右侧表单区域 ==================== */
.login-form-wrapper {
flex: 1;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
}
.login-form-card {
width: 100%;
max-width: 360px;
}
.expired-banner {
margin-bottom: 16px;
}
.login-tabs {
margin-bottom: 10px;
}
.login-tabs :deep(.el-tabs__header) {
margin-bottom: 24px;
}
.login-tabs :deep(.el-tabs__item) {
font-size: 16px;
font-weight: 600;
}
.login-btn {
width: 100%;
height: 46px;
font-size: 16px;
letter-spacing: 4px;
font-weight: 600;
}
.login-links {
display: flex;
justify-content: space-between;
margin-top: -8px;
}
.back-home {
text-align: center;
margin-top: 20px;
}
/* ==================== Responsive ==================== */
@media (max-width: 768px) {
.login-container {
flex-direction: column;
max-width: 420px;
min-height: auto;
}
.login-brand {
padding: 32px 20px;
}
.brand-icon {
font-size: 40px;
}
.brand-title {
font-size: 20px;
}
.login-form-wrapper {
padding: 32px 20px;
}
}
@media (max-width: 480px) {
.login-page {
padding: 12px;
}
.login-container {
border-radius: 12px;
}
.brand-title {
font-size: 18px;
}
}
</style>

361
src/views/Materials.vue Normal file
View File

@@ -0,0 +1,361 @@
<template>
<div class="materials-page">
<!-- 顶部导航 -->
<div class="mat-header">
<div class="mat-header-left">
<el-button text @click="$router.push('/')" class="back-btn">
<el-icon><HomeFilled /></el-icon><span>返回首页</span>
</el-button>
<el-button text @click="$router.push('/dashboard')" class="back-btn">
<el-icon><ArrowLeft /></el-icon><span>返回工作台</span>
</el-button>
</div>
<h2 class="mat-title">备赛资料</h2>
<div class="mat-header-right">
<span class="group-tag">{{ groupLabel }}</span>
</div>
</div>
<!-- 内容区域 -->
<div class="mat-content">
<!-- 资料列表 -->
<el-card shadow="never" class="mat-card" v-loading="loading">
<template #header>
<div class="mat-card-header">
<span class="mat-card-title">可用资料列表</span>
<span class="mat-card-count">共 {{ materials.length }} 个文件</span>
</div>
</template>
<div v-if="materials.length > 0">
<div
v-for="item in materials"
:key="item.id"
class="mat-item"
>
<div class="mat-item-info">
<div class="mat-item-icon">
<el-icon :size="32"><Document /></el-icon>
</div>
<div class="mat-item-detail">
<span class="mat-item-name">{{ item.name || item.fileName || '未命名文件' }}</span>
<span class="mat-item-meta">
<template v-if="item.size">{{ formatSize(item.size) }}</template>
<template v-if="item.type"> | {{ item.type.toUpperCase() }}</template>
<template v-if="item.uploadTime"> | {{ formatDate(item.uploadTime) }}</template>
</span>
</div>
</div>
<el-button
type="primary"
:icon="Download"
:loading="downloadingId === item.id"
v-guard="() => downloadMaterial(item)"
>
下载
</el-button>
</div>
</div>
<el-empty v-else description="暂无可用资料" :image-size="120" />
</el-card>
</div>
</div>
</template>
<script setup>
/**
* Materials.vue - 备赛资料页面(需要登录)
*
* 功能:
* 1. 按组别显示可下载的备赛资料列表
* 2. 下载按钮(带 loading 状态)
* 3. 资料说明静态区域
*/
import { ref, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { ArrowLeft, HomeFilled, Document, Download } from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
import * as materialsApi from '@/api/materials'
import { safeDownload } from '@/utils/download'
const authStore = useAuthStore()
// ==================== 状态 ====================
const loading = ref(false)
const materials = ref([])
const downloadingId = ref(null)
// ==================== 计算属性 ====================
const groupLabel = computed(() => {
const map = { zhongzhi: '中职组', gaozhi: '高职组', benke: '本科组', jiaoshi: '教师组' }
return map[authStore.userGroup] || authStore.userGroup || '未知组别'
})
// ==================== 方法 ====================
function formatDate(dateStr) {
if (!dateStr) return ''
const d = new Date(dateStr)
if (isNaN(d.getTime())) return dateStr
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
function formatSize(bytes) {
if (!bytes) return ''
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(2) + ' MB'
}
/** 下载资料 */
async function downloadMaterial(item) {
try {
downloadingId.value = item.id
const fileName = item.fileName || item.name
const url = `/api/materials/${authStore.userGroup}/${encodeURIComponent(fileName)}/download`
// safeDownload 自带「HTTP 状态 + content-type」双重校验,绝不会把后端错误 JSON 存成文件
const ok = await safeDownload(url, { fallbackName: fileName || 'material' })
if (ok) ElMessage.success('下载成功')
} catch (err) {
ElMessage.error('下载失败:' + (err?.message || '未知错误'))
} finally {
downloadingId.value = null
}
}
/** 获取资料列表 */
async function fetchMaterials() {
loading.value = true
try {
const data = await materialsApi.getMaterialsList(authStore.userGroup)
if (Array.isArray(data)) {
materials.value = data
} else if (data?.list) {
materials.value = data.list
}
} catch (err) {
console.warn('获取资料列表失败', err)
materials.value = []
} finally {
loading.value = false
}
}
// ==================== 生命周期 ====================
onMounted(() => {
fetchMaterials()
})
</script>
<style scoped>
.materials-page {
min-height: 100vh;
background: #f5f6f8;
padding-bottom: 40px;
}
/* ==================== 顶部导航 ==================== */
.mat-header {
background: #1a3a5c;
color: #fff;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
height: 60px;
position: sticky;
top: 0;
z-index: 100;
}
.back-btn {
color: rgba(255,255,255,0.85);
white-space: nowrap;
}
.back-btn:hover { color: #c9a84c; }
.mat-title {
font-size: 18px;
font-weight: 600;
position: absolute;
left: 50%;
transform: translateX(-50%);
}
.group-tag {
background: rgba(201, 168, 76, 0.2);
color: #c9a84c;
padding: 4px 12px;
border-radius: 4px;
font-size: 13px;
font-weight: 600;
}
.mat-header-left {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.mat-header-right {
flex-shrink: 0;
text-align: right;
}
/* ==================== 内容 ==================== */
.mat-content {
max-width: 860px;
margin: 24px auto 0;
padding: 0 20px;
}
.mat-card {
border-radius: 12px;
border: 1px solid #e5e7eb;
margin-bottom: 24px;
}
.mat-card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.mat-card-title {
font-size: 16px;
font-weight: 600;
color: #1f2937;
}
.mat-card-count {
font-size: 13px;
color: #909399;
}
/* ==================== 资料列表项 ==================== */
.mat-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 8px;
border-bottom: 1px solid #f0f0f0;
gap: 16px;
}
.mat-item:last-child { border-bottom: none; }
.mat-item-info {
display: flex;
align-items: center;
gap: 16px;
flex: 1;
min-width: 0;
}
.mat-item-icon {
width: 48px;
height: 48px;
border-radius: 8px;
background: #eef2ff;
display: flex;
align-items: center;
justify-content: center;
color: #1a3a5c;
flex-shrink: 0;
}
.mat-item-detail {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.mat-item-name {
font-size: 15px;
font-weight: 500;
color: #303133;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mat-item-meta {
font-size: 12px;
color: #909399;
}
/* ==================== 说明区域 ==================== */
.mat-description {
font-size: 14px;
color: #6b7280;
line-height: 1.8;
}
.syllabus-section {
font-size: 16px;
font-weight: 600;
color: #1a3a5c;
margin: 20px 0 12px;
padding-bottom: 6px;
border-bottom: 2px solid #c9a84c;
}
.syllabus-section:first-child {
margin-top: 0;
}
.syllabus-table {
margin-bottom: 8px;
}
.syllabus-collapse {
margin-bottom: 8px;
}
.syllabus-collapse :deep(.el-collapse-item__header) {
font-weight: 600;
color: #1a3a5c;
}
.syllabus-collapse p {
font-size: 13px;
color: #606266;
margin: 4px 0;
padding-left: 8px;
border-left: 2px solid #e4e7ed;
}
.syllabus-list {
padding-left: 20px;
margin-bottom: 12px;
}
.syllabus-list li {
margin-bottom: 6px;
font-size: 14px;
color: #606266;
}
.syllabus-note {
font-size: 13px;
color: #c9a84c;
font-weight: 500;
margin-top: 12px;
padding: 8px 12px;
background: #fdf6e3;
border-radius: 6px;
border-left: 3px solid #c9a84c;
}
/* ==================== Responsive ==================== */
@media (max-width: 480px) {
.mat-header { padding: 0 12px; }
.mat-item { flex-direction: column; align-items: flex-start; gap: 12px; }
.mat-item .el-button { align-self: flex-end; }
}
</style>

826
src/views/MockExam.vue Normal file
View File

@@ -0,0 +1,826 @@
<template>
<div class="mock-exam-page">
<!-- ==================== 模拟考试提示横幅 ==================== -->
<div class="mock-banner">
<el-icon :size="18"><WarningFilled /></el-icon>
<span>模拟考试系统,模拟选拔赛的完整流程,仅作考试流程演示,提供习题并非考试真题。</span>
</div>
<!-- ==================== 顶部倒计时栏 ==================== -->
<div class="exam-top-bar" :class="{ 'time-critical': remainingSeconds < 300 && remainingSeconds > 0 }">
<div class="exam-top-inner">
<div class="exam-top-left">
<div class="exam-timer">
<el-icon :size="20"><Timer /></el-icon>
<span class="timer-display" :class="{ 'timer-danger': remainingSeconds < 300 }">
{{ countdownDisplay }}
</span>
</div>
<div class="exam-progress">
<span>已答 {{ answeredCount }} / {{ totalCount }} 题</span>
<el-progress :percentage="progressPercent" :stroke-width="8" :color="progressColor" />
</div>
</div>
<span class="exam-title">模拟考试</span>
<div class="exam-top-right">
<span class="group-tag">{{ groupLabel }}</span>
</div>
</div>
</div>
<!-- ==================== 主布局 ==================== -->
<div class="exam-body">
<!-- 左侧题目导航 -->
<div class="exam-sidebar">
<h4 class="sidebar-title">题目导航</h4>
<!-- 选择题 -->
<div class="nav-section">
<div class="nav-section-title">选择题 ({{ mockChoiceQuestions.length }}题)</div>
<div class="question-nav">
<div
v-for="(q, idx) in mockChoiceQuestions"
:key="'choice-' + idx"
class="nav-item"
:class="{
'nav-answered': answers[q.id] !== undefined,
'nav-current': currentIndex === findIndex(q.id),
}"
@click="goToQuestion(findIndex(q.id))"
>
{{ findIndex(q.id) + 1 }}
</div>
</div>
</div>
<!-- 判断题 -->
<div class="nav-section">
<div class="nav-section-title">判断题 ({{ mockJudgeQuestions.length }}题)</div>
<div class="question-nav">
<div
v-for="(q, idx) in mockJudgeQuestions"
:key="'judge-' + idx"
class="nav-item"
:class="{
'nav-answered': answers[q.id] !== undefined,
'nav-current': currentIndex === findIndex(q.id),
}"
@click="goToQuestion(findIndex(q.id))"
>
{{ findIndex(q.id) + 1 }}
</div>
</div>
</div>
<!-- Word 答题 -->
<div class="nav-section">
<div class="nav-section-title">Word 答题</div>
<div class="question-nav">
<div
class="nav-item"
:class="{
'nav-answered': wordCompleted,
'nav-current': currentIndex === wordIndex,
}"
@click="currentIndex = wordIndex"
>
{{ wordIndex + 1 }}
</div>
</div>
</div>
<div class="sidebar-legend">
<span class="legend-item">
<span class="legend-dot answered"></span> 已答
</span>
<span class="legend-item">
<span class="legend-dot current"></span> 当前
</span>
<span class="legend-item">
<span class="legend-dot unanswered"></span> 未答
</span>
</div>
</div>
<!-- 右侧题目区域 -->
<div class="exam-main">
<!-- 选择题 -->
<div v-if="isCurrentQuestion && currentQuestion && currentQuestion.type === 'choice'" class="question-card">
<div class="question-header">
<span class="question-number">第 {{ currentIndex + 1 }} 题</span>
<el-tag type="primary" size="small">选择题</el-tag>
<span class="question-score">({{ currentQuestion.score || 0 }} 分)</span>
</div>
<div class="question-body">
<p class="question-text">{{ currentQuestion.question }}</p>
<el-radio-group
v-model="answers[currentQuestion.id]"
class="options-group"
>
<div
v-for="(opt, oi) in currentQuestion.options"
:key="oi"
class="option-item"
>
<el-radio :value="opt.value">
<span class="option-label">{{ optionLabels[oi] }}.</span>
{{ opt.text }}
</el-radio>
</div>
</el-radio-group>
</div>
<div class="question-footer">
<el-button :disabled="currentIndex === 0" @click="prevQuestion">上一题</el-button>
<el-button type="primary" @click="nextQuestion">
下一题
</el-button>
</div>
</div>
<!-- 判断题 -->
<div v-if="isCurrentQuestion && currentQuestion && currentQuestion.type === 'judge'" class="question-card">
<div class="question-header">
<span class="question-number">第 {{ currentIndex + 1 }} 题</span>
<el-tag type="success" size="small">判断题</el-tag>
<span class="question-score">({{ currentQuestion.score || 0 }} 分)</span>
</div>
<div class="question-body">
<p class="question-text">{{ currentQuestion.question }}</p>
<el-radio-group
v-model="answers[currentQuestion.id]"
class="options-group"
>
<div class="option-item">
<el-radio value="true">正确</el-radio>
</div>
<div class="option-item">
<el-radio value="false">错误</el-radio>
</div>
</el-radio-group>
</div>
<div class="question-footer">
<el-button :disabled="currentIndex === 0" @click="prevQuestion">上一题</el-button>
<el-button type="primary" @click="nextQuestion">
下一题
</el-button>
</div>
</div>
<!-- Word 答题 -->
<div v-if="currentIndex === wordIndex" class="question-card word-card">
<div class="question-header">
<span class="question-number">第 {{ wordIndex + 1 }} 题</span>
<el-tag type="danger" size="small">综合题</el-tag>
</div>
<div class="question-body">
<p class="question-text">请下载 Word 答题模板,在 Word 中完成答题后上传(模拟考试,文件不会真实上传)。</p>
<div class="word-sections">
<div class="word-section">
<h4>第 1 步:下载模板</h4>
<el-button type="primary" :icon="Download" v-guard="() => downloadMockTemplate()">
下载 Word 答题模板
</el-button>
</div>
<el-divider />
<div class="word-section">
<h4>第 2 步:上传答卷</h4>
<el-upload
class="word-upload"
drag
:auto-upload="false"
:limit="1"
:on-change="onMockWordChange"
:file-list="wordFileList"
accept=".doc,.docx"
>
<el-icon class="el-icon--upload" :size="36"><UploadFilled /></el-icon>
<div class="el-upload__text">
拖拽或 <em>点击上传</em> Word 文件
</div>
<template #tip>
<div class="el-upload__tip">模拟考试,文件不会真实上传</div>
</template>
</el-upload>
<div v-if="wordCompleted" class="uploaded-info">
<el-tag type="success" size="small">模拟上传成功</el-tag>
<span class="uploaded-name">{{ wordFileName }}</span>
</div>
</div>
</div>
</div>
<div class="question-footer">
<el-button :disabled="currentIndex === 0" @click="prevQuestion">上一题</el-button>
<el-button type="primary" @click="nextQuestion">
下一题
</el-button>
</div>
</div>
</div>
</div>
<!-- ==================== 底部提交栏 ==================== -->
<div class="exam-bottom-bar">
<div class="exam-bottom-inner">
<span class="submit-hint">
请确保已检查所有题目和 Word 文件后提交
</span>
<el-button
type="danger"
size="large"
v-guard="() => handleSubmitAll()"
:loading="submitting"
>
提交全部
</el-button>
</div>
</div>
</div>
</template>
<script setup>
/**
* MockExam.vue - 模拟考试页面
* 仅中职/高职组可见,模拟选拔赛完整流程,不保存数据
*/
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter, onBeforeRouteLeave } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Timer, Download, UploadFilled, WarningFilled } from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
import * as examApi from '@/api/exam'
const router = useRouter()
const authStore = useAuthStore()
// ==================== 组别标签 ====================
const groupLabel = computed(() => {
const map = { zhongzhi: '中职组', gaozhi: '高职组', benke: '本科组', jiaoshi: '教师组' }
return map[authStore.userGroup] || '未知组别'
})
// ==================== 选项标签 ====================
const optionLabels = ['A', 'B', 'C', 'D']
// ==================== 模拟题目数据 ====================
// 设计意图(与用户明确要求一致):
// 1) 模拟考试的题量必须与在线正式考试一致:选择题 50 道 + 判断题 20 道(+ 1 道 Word 综合题)。
// 2) 为了让学生「明显感觉这只是体验考试形式」,选择题全部使用同一道样题(第一题),
// 判断题也全部使用同一道样题(第一题),后面每一题内容都与第一题相同。
// 3) 关键:即便内容相同,每一题也必须拥有全局唯一的 id(c1..c50 / j1..j20)。
// 否则 answers 以 id 为键会相互覆盖,重演在线考试「序号重复 / 答完仍提示未作答」的碰撞 Bug。
const MOCK_CHOICE_COUNT = 50
const MOCK_JUDGE_COUNT = 20
// 选择题样题(第一题)——所有选择题都复用它
const MOCK_CHOICE_SAMPLE = {
type: 'choice',
question: '卫星通信中,GEO卫星的轨道高度约为多少公里?',
options: [
{ value: 'A', text: '780km' },
{ value: 'B', text: '20200km' },
{ value: 'C', text: '35786km' },
{ value: 'D', text: '1000km' },
],
score: 1,
}
// 判断题样题(第一题)——所有判断题都复用它
const MOCK_JUDGE_SAMPLE = {
type: 'judge',
question: 'GEO卫星相对于地球是静止的,轨道高度约为35786km。',
score: 1,
}
// 生成 50 道内容相同、id 唯一的选择题
const mockChoiceQuestions = Array.from({ length: MOCK_CHOICE_COUNT }, (_, i) => ({
id: 'c' + (i + 1),
type: MOCK_CHOICE_SAMPLE.type,
question: MOCK_CHOICE_SAMPLE.question,
// options 深拷贝,避免多题共享同一引用
options: MOCK_CHOICE_SAMPLE.options.map(o => ({ ...o })),
score: MOCK_CHOICE_SAMPLE.score,
}))
// 生成 20 道内容相同、id 唯一的判断题
const mockJudgeQuestions = Array.from({ length: MOCK_JUDGE_COUNT }, (_, i) => ({
id: 'j' + (i + 1),
type: MOCK_JUDGE_SAMPLE.type,
question: MOCK_JUDGE_SAMPLE.question,
score: MOCK_JUDGE_SAMPLE.score,
}))
// 合并所有题目(选择题在前、判断题在后,与在线考试一致)
const allQuestions = [...mockChoiceQuestions, ...mockJudgeQuestions]
// ==================== 状态 ====================
const currentIndex = ref(0)
const answers = ref({})
const submitting = ref(false)
const remainingSeconds = ref(3600) // 60分钟模拟
const wordCompleted = ref(false)
const wordFileName = ref('')
const wordFileList = ref([])
let timerInterval = null
// ==================== 计算属性 ====================
const totalCount = computed(() => allQuestions.length + 1) // +1 for Word
const wordIndex = computed(() => allQuestions.length)
const isCurrentQuestion = computed(() => currentIndex.value < allQuestions.length)
const currentQuestion = computed(() => {
if (currentIndex.value < allQuestions.length) {
return allQuestions[currentIndex.value]
}
return null
})
const answeredCount = computed(() => {
let count = Object.keys(answers.value).filter(k => answers.value[k] !== undefined && answers.value[k] !== '').length
if (wordCompleted.value) count++
return count
})
const progressPercent = computed(() => {
return totalCount.value > 0 ? Math.round((answeredCount.value / totalCount.value) * 100) : 0
})
const progressColor = computed(() => {
if (progressPercent.value >= 80) return '#67c23a'
if (progressPercent.value >= 50) return '#409eff'
return '#e6a23c'
})
const countdownDisplay = computed(() => {
const h = Math.floor(remainingSeconds.value / 3600)
const m = Math.floor((remainingSeconds.value % 3600) / 60)
const s = remainingSeconds.value % 60
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
})
// ==================== 方法 ====================
function findIndex(id) {
return allQuestions.findIndex(q => q.id === id)
}
function goToQuestion(idx) {
if (idx >= 0 && idx < totalCount.value) {
currentIndex.value = idx
}
}
function prevQuestion() {
if (currentIndex.value > 0) {
currentIndex.value--
}
}
function nextQuestion() {
if (currentIndex.value < totalCount.value - 1) {
currentIndex.value++
}
}
function onMockWordChange(file) {
wordCompleted.value = true
wordFileName.value = file.name
ElMessage.success('模拟上传成功')
}
async function downloadMockTemplate() {
// 模拟考试使用「固定样例模板」,与在线正式考试的真实 Word 模板完全隔离:
// - 不再请求 /api/exam/:group/word-template(那是各组别的真实考试模板);
// - 直接下载随前端一起发布的静态样例文件 public/mock-word-template.docx。
// 这样即便正式模板未上传 / 变更,也不会影响模拟考试的体验,且不会泄露真实模板。
try {
const url = `${import.meta.env.BASE_URL || '/'}mock-word-template.docx`
const response = await fetch(url)
if (!response.ok) {
ElMessage.error('样例模板下载失败')
return
}
const blob = await response.blob()
// 🔴 同源守卫(缺口10 修复):样例模板缺失时 dev/部署可能走 SPA 回退吐 index.html,
// 直接存成 .docx 会损坏。先嗅探是否为 HTML,是则报错不下载。
const tplHead = await blob.slice(0, 256).text()
if (/^\s*(<!doctype\s+html|<html[\s>])/i.test(tplHead)) {
ElMessage.error('样例模板文件缺失或不可用,请联系管理员')
return
}
const downloadUrl = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = downloadUrl
link.download = '模拟考试Word答题样例.docx'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(downloadUrl)
ElMessage.success('样例模板已下载')
} catch (err) {
ElMessage.error('样例模板下载失败')
}
}
async function handleSubmitAll() {
const unanswered = totalCount.value - answeredCount.value
const confirmMsg = unanswered > 0
? `还有 ${unanswered} 题未作答,确定要提交吗?`
: '确定要提交全部答案吗?'
try {
await ElMessageBox.confirm(confirmMsg, '模拟提交确认', {
confirmButtonText: '确认提交',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
submitting.value = true
setTimeout(() => {
submitting.value = false
stopTimer()
ElMessage.success('模拟考试提交成功!感谢您的参与。')
ElMessageBox.alert('您的模拟考试已成功提交,感谢您的参与!', '提交成功', {
confirmButtonText: '返回工作台',
type: 'success',
}).then(() => {
router.push('/dashboard')
})
}, 1500)
}
function startTimer() {
stopTimer()
timerInterval = setInterval(() => {
if (remainingSeconds.value > 0) {
remainingSeconds.value--
} else {
stopTimer()
ElMessage.warning('模拟考试时间已到,系统将自动提交')
handleSubmitAll()
}
}, 1000)
}
function stopTimer() {
if (timerInterval) {
clearInterval(timerInterval)
timerInterval = null
}
}
// 路由离开确认
onBeforeRouteLeave((to, from, next) => {
ElMessageBox.confirm(
'确定要离开模拟考试页面吗?当前作答不会保存。',
'提示',
{ confirmButtonText: '确定离开', cancelButtonText: '继续作答', type: 'warning' }
).then(() => {
stopTimer()
next()
}).catch(() => next(false))
})
onMounted(async () => {
// 读取真实考试时长,与在线答题保持一致(取不到时回退默认 60 分钟)
try {
const group = authStore.userGroup
if (group) {
const status = await examApi.getExamStatus(group)
if (status && typeof status.countdown === 'number' && status.countdown > 0) {
remainingSeconds.value = status.countdown
}
}
} catch (e) {
// 忽略异常,保留默认 60 分钟
}
startTimer()
})
onUnmounted(() => {
stopTimer()
})
</script>
<style scoped>
.mock-exam-page {
min-height: 100vh;
background: #f5f6f8;
display: flex;
flex-direction: column;
}
/* ==================== 模拟考试横幅 ==================== */
.mock-banner {
background: linear-gradient(135deg, #fef7e0, #fdf0d5);
border-bottom: 2px solid #e6a23c;
padding: 10px 24px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
color: #8b6914;
font-size: 14px;
font-weight: 500;
}
/* ==================== 顶部倒计时栏 ==================== */
.exam-top-bar {
background: #1a3a5c;
color: #fff;
padding: 12px 24px;
position: sticky;
top: 0;
z-index: 100;
border-bottom: 2px solid transparent;
}
.exam-top-bar.time-critical {
background: #fef0f0;
border-bottom: 2px solid #f56c6c;
color: #f56c6c;
}
.exam-top-bar.time-critical .exam-progress span { color: #f56c6c; }
.exam-top-bar.time-critical .exam-title { color: #f56c6c; }
.exam-top-bar.time-critical .group-tag {
background: rgba(245, 108, 108, 0.15);
color: #f56c6c;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.85; }
}
.exam-top-inner {
max-width: 1200px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
position: relative;
}
.exam-top-left {
display: flex;
align-items: center;
gap: 24px;
flex-shrink: 0;
}
.exam-top-right {
flex-shrink: 0;
}
.exam-title {
font-size: 18px;
font-weight: 700;
letter-spacing: 2px;
position: absolute;
left: 50%;
transform: translateX(-50%);
}
.exam-timer {
display: flex;
align-items: center;
gap: 8px;
}
.timer-display {
font-size: 28px;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.timer-danger {
color: #f56c6c;
animation: blink 1s infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.exam-progress {
width: 180px;
font-size: 13px;
color: rgba(255,255,255,0.8);
}
.group-tag {
background: rgba(201, 168, 76, 0.2);
color: #c9a84c;
padding: 4px 12px;
border-radius: 4px;
font-size: 13px;
font-weight: 600;
white-space: nowrap;
flex-shrink: 0;
}
/* ==================== 主布局 ==================== */
.exam-body {
display: flex;
flex: 1;
max-width: 1200px;
margin: 20px auto;
width: 100%;
padding: 0 20px;
gap: 20px;
}
/* ==================== 左侧导航 ==================== */
.exam-sidebar {
width: 240px;
flex-shrink: 0;
background: #fff;
border-radius: 12px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
height: fit-content;
position: sticky;
top: 80px;
}
.sidebar-title {
font-size: 16px;
font-weight: 700;
color: #1a3a5c;
margin-bottom: 16px;
}
.nav-section {
margin-bottom: 16px;
}
.nav-section-title {
font-size: 13px;
color: #6b7280;
margin-bottom: 8px;
}
.question-nav {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.nav-item {
width: 36px;
height: 36px;
border-radius: 8px;
background: #f5f7fa;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
color: #909399;
border: 1px solid #e4e7eb;
}
.nav-item:hover { border-color: #409eff; color: #409eff; }
.nav-answered { background: #ecf5ff; color: #409eff; border-color: #b3d8ff; }
.nav-current { background: #67c23a; color: #fff; border-color: #67c23a; }
.sidebar-legend {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 16px;
font-size: 12px;
color: #909399;
}
.legend-item {
display: flex;
align-items: center;
gap: 4px;
}
.legend-dot {
width: 10px;
height: 10px;
border-radius: 2px;
}
.legend-dot.answered { background: #409eff; }
.legend-dot.current { background: #67c23a; }
.legend-dot.unanswered { background: #e4e7eb; border: 1px solid #d0d0d0; }
/* ==================== 右侧题目区 ==================== */
.exam-main {
flex: 1;
min-width: 0;
}
.question-card {
background: #fff;
border-radius: 12px;
padding: 32px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
}
.question-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid #f0f0f0;
}
.question-number {
font-size: 18px;
font-weight: 700;
color: #1a3a5c;
}
.question-score {
font-size: 14px;
color: #909399;
}
.question-body {
min-height: 200px;
}
.question-text {
font-size: 16px;
color: #303133;
line-height: 1.8;
margin-bottom: 24px;
}
.options-group {
display: flex;
flex-direction: column;
gap: 12px;
}
.option-item {
padding: 12px 16px;
border: 1px solid #e5e7eb;
border-radius: 8px;
transition: background 0.2s;
}
.option-item:hover { border-color: #409eff; }
.option-label {
font-weight: 600;
color: #409eff;
margin-right: 4px;
}
.question-footer {
display: flex;
justify-content: space-between;
margin-top: 32px;
padding-top: 20px;
border-top: 1px solid #f0f0f0;
}
/* Word 答题 */
.word-sections {
display: flex;
flex-direction: column;
gap: 16px;
}
.word-section h4 {
font-size: 15px;
color: #1a3a5c;
margin-bottom: 12px;
}
.word-upload {
margin-bottom: 12px;
}
.uploaded-info {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
background: #f0f9eb;
border-radius: 6px;
margin-top: 8px;
}
.uploaded-name {
font-size: 13px;
color: #303133;
}
/* ==================== 底部提交栏 ==================== */
.exam-bottom-bar {
background: #fff;
border-top: 2px solid #e5e7eb;
padding: 16px 24px;
position: sticky;
bottom: 0;
box-shadow: 0 -2px 8px rgba(0,0,0,0.06);
}
.exam-bottom-inner {
max-width: 1200px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 24px;
}
.submit-hint {
font-size: 13px;
color: #909399;
}
/* ==================== Responsive ==================== */
@media (max-width: 768px) {
.exam-body { flex-direction: column; }
.exam-sidebar {
width: 100%;
position: static;
flex-direction: row;
flex-wrap: wrap;
}
.exam-top-inner { flex-wrap: wrap; gap: 12px; }
.timer-display { font-size: 22px; }
}
</style>

366
src/views/NewsDetail.vue Normal file
View File

@@ -0,0 +1,366 @@
<template>
<div class="news-detail-page">
<!-- 顶部导航栏 -->
<header class="page-header">
<div class="header-inner">
<div class="header-logo">
<span class="logo-icon">&#9788;</span>
<span class="logo-text">卫星通信与导航应用技术创新赛</span>
</div>
<div class="header-actions">
<el-button type="default" size="default" @click="$router.push('/')">
<span>返回首页</span>
</el-button>
</div>
</div>
</header>
<!-- 主体内容 -->
<main class="detail-main" v-loading="loading">
<template v-if="news">
<!-- 面包屑 -->
<div class="breadcrumb">
<el-breadcrumb separator="/">
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
<el-breadcrumb-item>
<a href="/#news" class="breadcrumb-link">新闻公告</a>
</el-breadcrumb-item>
<el-breadcrumb-item>{{ news.title }}</el-breadcrumb-item>
</el-breadcrumb>
</div>
<!-- 文章卡片 -->
<el-card shadow="never" class="article-card">
<!-- 标题区域 -->
<div class="article-header">
<h1 class="article-title">{{ news.title }}</h1>
<div class="article-meta">
<span class="meta-item">
<el-icon><Clock /></el-icon>
{{ formatDate(news.publish_time) }}
</span>
<span class="meta-item" v-if="news.group && news.group !== 'all'">
<el-icon><User /></el-icon>
{{ groupLabel(news.group) }}
</span>
<el-tag v-if="news.pinned" type="warning" size="small" effect="dark">置顶</el-tag>
</div>
</div>
<el-divider />
<!-- 微信链接型新闻 -->
<div v-if="news.news_type === 'wx_link' && news.wx_link" class="wx-link-section">
<el-alert type="info" :closable="false" show-icon>
<template #title>
<span>此新闻来自微信公众号</span>
</template>
<template #default>
<a :href="news.wx_link" target="_blank" rel="noopener" class="wx-link-btn">
<el-button type="success">
<el-icon><Link /></el-icon>
<span>在微信中查看文章</span>
</el-button>
</a>
</template>
</el-alert>
</div>
<!-- 正文内容 -->
<div v-if="news.news_type !== 'wx_link' || !news.wx_link" class="article-content" v-html="news.content"></div>
<!-- 底部信息 -->
<el-divider />
<div class="article-footer">
<span class="footer-item">发布者:赛事组委会</span>
<span class="footer-item">更新时间:{{ formatDate(news.updated_at) }}</span>
</div>
</el-card>
</template>
<!-- 加载失败 -->
<el-empty v-else-if="!loading" description="新闻不存在或已被删除">
<el-button type="primary" @click="$router.push('/')">返回首页</el-button>
</el-empty>
</main>
<!-- 底部 -->
<footer class="page-footer">
<p>卫星通信与导航应用技术创新赛组委会 &copy; {{ new Date().getFullYear() }}</p>
</footer>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { getNewsDetail } from '@/api/news'
const route = useRoute()
const news = ref(null)
const loading = ref(true)
/** 格式化日期 */
function formatDate(dateStr) {
if (!dateStr) return ''
const d = new Date(dateStr)
if (isNaN(d.getTime())) return dateStr
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
/** 组别标签 */
function groupLabel(group) {
const map = {
zhongzhi: '中职组',
gaozhi: '高职组',
benke: '本科组',
jiaoshi: '教师组',
}
return map[group] || group
}
/** 获取新闻详情 */
async function fetchDetail() {
loading.value = true
try {
const id = route.params.id
const data = await getNewsDetail(id)
// 后端返回 { code, data, message },data 是新闻对象
news.value = data
} catch (err) {
console.error('获取新闻详情失败:', err)
news.value = null
} finally {
loading.value = false
}
}
onMounted(() => {
fetchDetail()
})
</script>
<style scoped>
.news-detail-page {
min-height: 100vh;
background: #f5f6f8;
display: flex;
flex-direction: column;
}
/* ==================== 顶部导航 ==================== */
.page-header {
background: rgba(26, 58, 92, 0.95);
backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.header-inner {
max-width: 1200px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
height: 60px;
}
.header-logo {
display: flex;
align-items: center;
gap: 10px;
color: #fff;
}
.logo-icon {
font-size: 24px;
color: #c9a84c;
}
.logo-text {
font-size: 18px;
font-weight: 600;
}
/* ==================== 主体 ==================== */
.detail-main {
flex: 1;
max-width: 900px;
width: 100%;
margin: 0 auto;
padding: 24px 20px 60px;
}
/* 面包屑 */
.breadcrumb {
margin-bottom: 20px;
}
.breadcrumb-link {
color: #1a3a5c;
text-decoration: none;
}
.breadcrumb-link:hover {
color: #c9a84c;
text-decoration: underline;
}
/* 文章卡片 */
.article-card {
border-radius: 8px;
overflow: hidden;
}
.article-header {
padding: 8px 0;
}
.article-title {
font-size: 26px;
font-weight: 700;
color: #1f2937;
line-height: 1.4;
margin: 0 0 16px 0;
}
.article-meta {
display: flex;
align-items: center;
gap: 20px;
flex-wrap: wrap;
color: #6b7280;
font-size: 14px;
}
.meta-item {
display: flex;
align-items: center;
gap: 4px;
}
/* 正文内容 */
.article-content {
padding: 16px 0;
font-size: 16px;
line-height: 1.8;
color: #374151;
word-break: break-word;
}
/* 微信链接区 */
.wx-link-section {
padding: 20px 0;
}
.wx-link-btn {
text-decoration: none;
}
/* 富文本样式 */
.article-content :deep(p) {
margin: 0 0 16px 0;
}
.article-content :deep(h1),
.article-content :deep(h2),
.article-content :deep(h3),
.article-content :deep(h4) {
margin: 24px 0 12px 0;
color: #1f2937;
}
.article-content :deep(h2) {
font-size: 20px;
border-left: 4px solid #1a3a5c;
padding-left: 12px;
}
.article-content :deep(h3) {
font-size: 18px;
}
.article-content :deep(img) {
max-width: 100%;
height: auto;
border-radius: 4px;
margin: 12px 0;
}
.article-content :deep(ul),
.article-content :deep(ol) {
padding-left: 24px;
margin: 0 0 16px 0;
}
.article-content :deep(li) {
margin-bottom: 8px;
}
.article-content :deep(blockquote) {
border-left: 4px solid #c9a84c;
padding: 8px 16px;
margin: 16px 0;
background: #fefce8;
border-radius: 0 4px 4px 0;
color: #92400e;
}
.article-content :deep(table) {
width: 100%;
border-collapse: collapse;
margin: 16px 0;
}
.article-content :deep(th),
.article-content :deep(td) {
border: 1px solid #e5e7eb;
padding: 8px 12px;
text-align: left;
}
.article-content :deep(th) {
background: #f3f4f6;
font-weight: 600;
}
.article-content :deep(a) {
color: #1a3a5c;
text-decoration: underline;
}
.article-content :deep(strong) {
font-weight: 600;
color: #1f2937;
}
/* 底部信息 */
.article-footer {
display: flex;
justify-content: space-between;
color: #9ca3af;
font-size: 13px;
padding: 4px 0;
}
/* ==================== 页脚 ==================== */
.page-footer {
text-align: center;
padding: 24px 20px;
color: #9ca3af;
font-size: 13px;
background: #fff;
border-top: 1px solid #e5e7eb;
}
/* ==================== 响应式 ==================== */
@media (max-width: 768px) {
.article-title {
font-size: 22px;
}
.article-content {
font-size: 15px;
}
.article-meta {
gap: 12px;
}
}
</style>

661
src/views/NmeaParser.vue Normal file
View File

@@ -0,0 +1,661 @@
<template>
<div class="nmea-page">
<!-- 顶部导航 -->
<div class="nmea-header">
<div class="nmea-header-left">
<el-button text @click="$router.push('/')" class="back-btn">
<el-icon><HomeFilled /></el-icon><span>返回首页</span>
</el-button>
<el-button text @click="$router.push('/dashboard')" class="back-btn">
<el-icon><ArrowLeft /></el-icon><span>返回工作台</span>
</el-button>
</div>
<h2 class="nmea-title">NMEA-0183 协议解析</h2>
<div class="nmea-header-right">
<span class="group-tag">中职组</span>
</div>
</div>
<div class="nmea-body">
<!-- 左侧:协议知识 -->
<div class="nmea-knowledge">
<el-card shadow="hover">
<template #header>
<span class="card-title">NMEA-0183 协议基础知识</span>
</template>
<el-collapse v-model="activeKnowledge">
<el-collapse-item title="协议概述" name="1">
<p>NMEA-0183 是美国国家海洋电子协会(National Marine Electronics Association)制定的标准通信协议,用于 GPS/GNSS 接收机等海洋电子设备之间的数据交换。</p>
<p><strong>核心特征:</strong>基于 ASCII 文本的串行通信协议,默认波特率 4800bps,每条语句以 <code>$</code> 开头,以 <code>&lt;CR&gt;&lt;LF&gt;</code> 结尾。</p>
</el-collapse-item>
<el-collapse-item title="标准报文结构" name="2">
<p><strong>通用格式:</strong><code>$--SSS,DD1,DD2,...,DDn*hh&lt;CR&gt;&lt;LF&gt;</code></p>
<ul>
<li><code>$</code> — 语句起始符</li>
<li><code>--</code> — 发送者标识(Talker ID),如 <code>GP</code>=GPS, <code>BD</code>=北斗, <code>GN</code>=多系统</li>
<li><code>SSS</code> — 语句标识符(Sentence ID),如 GGA, RMC, GSA, GSV</li>
<li><code>DD1,..</code> — 数据字段,以逗号分隔</li>
<li><code>*</code> — 校验和分隔符</li>
<li><code>hh</code> — 异或校验和(<code>$</code>与<code>*</code>之间所有字符的 XOR 值,十六进制大写)</li>
<li><code>&lt;CR&gt;&lt;LF&gt;</code> — 回车换行结束符</li>
</ul>
</el-collapse-item>
<el-collapse-item title="八大核心语句" name="3">
<el-table :data="coreStatements" border stripe size="small">
<el-table-column prop="id" label="语句" width="70" />
<el-table-column prop="name" label="名称" width="180" />
<el-table-column prop="desc" label="功能说明" />
</el-table>
</el-collapse-item>
<el-collapse-item title="卫星系统前缀" name="4">
<el-table :data="systemPrefixes" border stripe size="small">
<el-table-column prop="prefix" label="前缀" width="80" />
<el-table-column prop="system" label="卫星系统" />
</el-table>
</el-collapse-item>
<el-collapse-item title="GGA 语句详解(重点)" name="5">
<p><strong>$GPGGA</strong> 是 GPS 固定数据输出语句,包含定位信息。</p>
<p><strong>格式:</strong><code>$GPGGA,HHMMSS.SS,DDMM.MMMM,N,DDDMM.MMMM,E,Q,NN,H.H,M,GG.G,M,,*CC</code></p>
<el-table :data="ggaFields" border stripe size="small">
<el-table-column prop="field" label="字段" width="120" />
<el-table-column prop="name" label="名称" />
<el-table-column prop="desc" label="说明" />
</el-table>
</el-collapse-item>
</el-collapse>
</el-card>
</div>
<!-- 右侧:解析工具 -->
<div class="nmea-tool">
<!-- 输入区域 -->
<el-card shadow="hover">
<template #header>
<span class="card-title">NMEA 语句解析器</span>
</template>
<div class="input-section">
<div class="input-header">
<span>请输入 NMEA-0183 语句(支持多条,每行一条)</span>
<el-button type="primary" size="small" @click="loadSample">加载示例</el-button>
</div>
<el-input
v-model="nmeaInput"
type="textarea"
:rows="6"
placeholder='$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47'
class="nmea-textarea"
/>
<div class="action-row">
<el-button type="primary" :icon="Search" @click="parseNmea">解析语句</el-button>
<el-button :icon="Delete" @click="clearAll">清空</el-button>
<span class="validation-info" v-if="validationMsg" :class="validationClass">
{{ validationMsg }}
</span>
</div>
</div>
</el-card>
<!-- 解析结果 -->
<el-card shadow="hover" v-if="parseResults.length > 0" class="result-card">
<template #header>
<span class="card-title">解析结果({{ parseResults.length }} 条)</span>
</template>
<div v-for="(result, idx) in parseResults" :key="idx" class="result-item">
<div class="result-header">
<el-tag :type="result.valid ? 'success' : 'danger'" size="small">
{{ result.valid ? '校验通过' : '校验失败' }}
</el-tag>
<span class="result-statement">{{ result.raw }}</span>
</div>
<div v-if="result.valid && result.parsed" class="result-detail">
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="发送者">{{ result.parsed.talkerName }}</el-descriptions-item>
<el-descriptions-item label="语句类型">{{ result.parsed.statementName }}</el-descriptions-item>
<el-descriptions-item label="字段数">{{ result.parsed.fields.length }}</el-descriptions-item>
<el-descriptions-item label="校验和">
<span :class="result.checksumMatch ? 'checksum-ok' : 'checksum-err'">
{{ result.parsed.checksum }}
</span>
</el-descriptions-item>
</el-descriptions>
<!-- GGA 特殊解析 -->
<div v-if="result.parsed.statementType === 'GGA'" class="gga-display">
<h4>GGA 定位信息解析</h4>
<el-descriptions :column="3" border size="small">
<el-descriptions-item label="UTC 时间">
{{ result.parsed.gga.utcTime }}
</el-descriptions-item>
<el-descriptions-item label="纬度">
{{ result.parsed.gga.latitude }}
</el-descriptions-item>
<el-descriptions-item label="经度">
{{ result.parsed.gga.longitude }}
</el-descriptions-item>
<el-descriptions-item label="定位质量">
<el-tag :type="result.parsed.gga.quality === 1 ? 'success' : 'warning'" size="small">
{{ result.parsed.gga.qualityLabel }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="卫星数">{{ result.parsed.gga.satellites }}</el-descriptions-item>
<el-descriptions-item label="HDOP">{{ result.parsed.gga.hdop }}</el-descriptions-item>
<el-descriptions-item label="天线高度">{{ result.parsed.gga.altitude }}</el-descriptions-item>
<el-descriptions-item label="大地水准面差距">{{ result.parsed.gga.geoidSeparation }}</el-descriptions-item>
<el-descriptions-item label="差分站ID">{{ result.parsed.gga.dgpsStationId }}</el-descriptions-item>
</el-descriptions>
</div>
<!-- RMC 特殊解析 -->
<div v-if="result.parsed.statementType === 'RMC'" class="rmc-display">
<h4>RMC 推荐最小定位信息解析</h4>
<el-descriptions :column="3" border size="small">
<el-descriptions-item label="UTC 时间">{{ result.parsed.rmc.utcTime }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="result.parsed.rmc.status === 'A' ? 'success' : 'danger'" size="small">
{{ result.parsed.rmc.status === 'A' ? '有效' : '无效' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="纬度">{{ result.parsed.rmc.latitude }}</el-descriptions-item>
<el-descriptions-item label="经度">{{ result.parsed.rmc.longitude }}</el-descriptions-item>
<el-descriptions-item label="航速">{{ result.parsed.rmc.speed }} 节</el-descriptions-item>
<el-descriptions-item label="航向">{{ result.parsed.rmc.track }}</el-descriptions-item>
<el-descriptions-item label="日期">{{ result.parsed.rmc.date }}</el-descriptions-item>
<el-descriptions-item label="磁偏角">{{ result.parsed.rmc.magneticVariation }}</el-descriptions-item>
</el-descriptions>
</div>
<!-- 通用字段展示 -->
<div v-if="!['GGA', 'RMC'].includes(result.parsed.statementType)" class="fields-display">
<h4>数据字段</h4>
<el-table :data="result.parsed.fieldTable" border stripe size="small">
<el-table-column prop="index" label="#" width="50" />
<el-table-column prop="value" label="值" />
<el-table-column prop="hint" label="说明" />
</el-table>
</div>
</div>
<div v-if="!result.valid" class="result-error">
<el-alert :title="result.error" type="error" :closable="false" show-icon />
</div>
</div>
</el-card>
<!-- 练习区 -->
<el-card shadow="hover" class="practice-card">
<template #header>
<span class="card-title">练习题目</span>
</template>
<div class="practice-item">
<p><strong>1.</strong> 请写出 GPS 系统的 GGA 语句中,定位质量字段 <code>Q</code> 的取值含义:</p>
<el-input v-model="practiceQ1" placeholder="0=无效, 1=单点定位, 2=差分定位..." class="practice-input" />
</div>
<div class="practice-item">
<p><strong>2.</strong> 在 NMEA-0183 协议中,北斗系统的 Talker ID 前缀是什么?</p>
<el-radio-group v-model="practiceQ2">
<el-radio label="GP">GP</el-radio>
<el-radio label="BD">BD</el-radio>
<el-radio label="GN">GN</el-radio>
<el-radio label="GA">GA</el-radio>
</el-radio-group>
</div>
<div class="practice-item">
<p><strong>3.</strong> NMEA-0183 语句的校验和是如何计算的?</p>
<el-input v-model="practiceQ3" placeholder="异或校验,$和*之间所有字符..." class="practice-input" />
</div>
</el-card>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed } from 'vue'
import { Search, Delete, HomeFilled, ArrowLeft } from '@element-plus/icons-vue'
// ============ 知识面板 ============
const activeKnowledge = ref(['1', '5'])
const coreStatements = [
{ id: 'GGA', name: '全球定位系统固定数据', desc: '包含时间、经纬度、定位质量、卫星数、HDOP、海拔高度等完整定位信息。' },
{ id: 'RMC', name: '推荐最小定位信息', desc: '包含时间、日期、经纬度、航速、航向等,是最常用的导航语句。' },
{ id: 'GSA', name: '当前卫星信息', desc: '包含定位模式(2D/3D)、PRN 号、PDOP、HDOP、VDOP 值。' },
{ id: 'GSV', name: '可见卫星信息', desc: '包含可见卫星总数、PRN 号、仰角、方位角、信噪比(SNR)。' },
{ id: 'GLL', name: '地理定位信息', desc: '包含经纬度和 UTC 时间,不含高度信息。' },
{ id: 'VTG', name: '航迹向和航速', desc: '包含航向(真北/磁北)、航速(节/公里/小时)。' },
{ id: 'ZDA', name: '时间和日期', desc: '包含 UTC 时间、日、月、年及本地时区信息。' },
{ id: 'TXT', name: '文本传输', desc: '用于传输文本信息,如错误提示或状态报告。' },
]
const systemPrefixes = [
{ prefix: 'GP', system: 'GPS(美国全球定位系统)' },
{ prefix: 'BD', system: 'BDS(中国北斗卫星导航系统)' },
{ prefix: 'GL', system: 'GLONASS(俄罗斯格洛纳斯)' },
{ prefix: 'GA', system: 'Galileo(欧洲伽利略)' },
{ prefix: 'GN', system: 'GNSS(多系统组合定位)' },
{ prefix: 'GQ', system: 'QZSS(日本准天顶卫星系统)' },
{ prefix: 'GI', system: 'IRNSS/NavIC(印度区域导航系统)' },
]
const ggaFields = [
{ field: 'HHMMSS.SS', name: 'UTC 时间', desc: '协调世界时(小时分钟秒.毫秒)' },
{ field: 'DDMM.MMMM', name: '纬度', desc: '度度分分.分分分分格式' },
{ field: 'N/S', name: '北纬/南纬', desc: 'N=北纬,S=南纬' },
{ field: 'DDDMM.MMMM', name: '经度', desc: '度度度分分.分分分分格式' },
{ field: 'E/W', name: '东经/西经', desc: 'E=东经,W=西经' },
{ field: 'Q', name: '定位质量', desc: '0=无效, 1=单点定位(SPS), 2=差分定位(DGPS), 4=固定解(RTK), 5=浮点解' },
{ field: 'NN', name: '卫星数', desc: '用于定位的卫星数量' },
{ field: 'H.H', name: 'HDOP', desc: '水平精度因子(越小越好,<1 为理想)' },
{ field: 'M', name: '天线高度', desc: '海拔高度(米)' },
{ field: 'GG.G', name: '大地水准面差距', desc: '大地水准面与 WGS-84 椭球面的差距(米)' },
{ field: 'M', name: '大地水准面差距单位', desc: 'M=米' },
{ field: '', name: '差分数据龄期', desc: '距离上次接收差分信号的秒数(RTK 场景为 0)' },
{ field: 'CC', name: '差分站ID', desc: '差分基准站编号(0000-1023)' },
]
// ============ 解析器 ============
const nmeaInput = ref('')
const parseResults = ref([])
const validationMsg = ref('')
const validationClass = ref('')
// 练习
const practiceQ1 = ref('')
const practiceQ2 = ref('')
const practiceQ3 = ref('')
// 语句类型说明
const statementNames = {
GGA: '全球定位系统固定数据',
RMC: '推荐最小定位信息',
GSA: '当前卫星信息',
GSV: '可见卫星信息',
GLL: '地理定位信息',
VTG: '航迹向和航速',
ZDA: '时间和日期',
TXT: '文本传输',
}
const talkerNames = {
GP: 'GPS(美国)',
BD: '北斗(中国)',
GL: 'GLONASS(俄罗斯)',
GA: 'Galileo(欧洲)',
GN: 'GNSS(多系统)',
GQ: 'QZSS(日本)',
GI: 'IRNSS(印度)',
}
function computeChecksum(sentence) {
let checksum = 0
for (let i = 1; i < sentence.length; i++) {
const ch = sentence[i]
if (ch === '*') break
checksum ^= ch.charCodeAt(0)
}
return checksum.toString(16).toUpperCase().padStart(2, '0')
}
function formatLatitude(latStr, ns) {
if (!latStr || latStr.length < 4) return latStr
const deg = parseInt(latStr.substring(0, latStr.indexOf('.') - 2))
const min = parseFloat(latStr.substring(latStr.indexOf('.') - 2))
const decimal = deg + min / 60
return `${decimal.toFixed(6)}°${ns || ''}`
}
function formatLongitude(lonStr, ew) {
if (!lonStr || lonStr.length < 5) return lonStr
const deg = parseInt(lonStr.substring(0, lonStr.indexOf('.') - 2))
const min = parseFloat(lonStr.substring(lonStr.indexOf('.') - 2))
const decimal = deg + min / 60
return `${decimal.toFixed(6)}°${ew || ''}`
}
function formatUtcTime(timeStr) {
if (!timeStr || timeStr.length < 6) return timeStr
const h = timeStr.substring(0, 2)
const m = timeStr.substring(2, 4)
const s = timeStr.substring(4, 6)
const ms = timeStr.length > 6 ? timeStr.substring(7) : ''
return `${h}:${m}:${s}${ms ? '.' + ms : ''} UTC`
}
function parseNmea() {
parseResults.value = []
validationMsg.value = ''
if (!nmeaInput.value.trim()) {
validationMsg.value = '请输入 NMEA 语句'
validationClass.value = 'validation-warn'
return
}
const lines = nmeaInput.value.trim().split('\n').filter(l => l.trim())
lines.forEach((line, lineIdx) => {
const raw = line.trim()
const result = { raw, valid: false, parsed: null, error: '', checksumMatch: false }
if (!raw.startsWith('$')) {
result.error = `第 ${lineIdx + 1} 行:【格式错误】NMEA 语句必须以 "$" 开头`
parseResults.value.push(result)
return
}
const starIdx = raw.indexOf('*')
let sentenceBody, checksumStr = null
if (starIdx >= 0) {
sentenceBody = raw.substring(0, starIdx)
checksumStr = raw.substring(starIdx + 1).trim()
} else {
sentenceBody = raw
}
const talkerMatch = sentenceBody.match(/^\$(\w{2})(\w{3})/)
if (!talkerMatch) {
result.error = `第 ${lineIdx + 1} 行:【格式错误】无法识别 Talker ID 和语句类型,期望格式 $--SSS`
parseResults.value.push(result)
return
}
const talkerId = talkerMatch[1]
const statementId = talkerMatch[2]
if (checksumStr) {
const computed = computeChecksum(raw)
result.checksumMatch = computed.toUpperCase() === checksumStr.toUpperCase()
if (!result.checksumMatch) {
result.error = `第 ${lineIdx + 1} 行:【校验和错误】期望 ${computed},实际 ${checksumStr}`
parseResults.value.push(result)
return
}
}
const fieldsPart = sentenceBody.substring(talkerMatch[0].length + 1)
const fields = fieldsPart.split(',')
result.valid = true
result.parsed = {
talkerId,
talkerName: talkerNames[talkerId] || `未知 (${talkerId})`,
statementType: statementId,
statementName: statementNames[statementId] || `未知 (${statementId})`,
fields,
checksum: checksumStr || '未提供',
fieldTable: [],
}
if (statementId === 'GGA' && fields.length >= 14) {
const qualityLabels = ['无效', '单点定位(SPS)', '差分定位(DGPS)', 'PPS', '固定解(RTK)', '浮点解', '推算', '手动输入', '仿真模式']
result.parsed.gga = {
utcTime: formatUtcTime(fields[0]),
latitude: formatLatitude(fields[1], fields[2]),
longitude: formatLongitude(fields[3], fields[4]),
quality: parseInt(fields[5]) || 0,
qualityLabel: qualityLabels[parseInt(fields[5])] || `未知(${fields[5]})`,
satellites: fields[6],
hdop: fields[7],
altitude: fields[8] + ' ' + (fields[9] || 'M'),
geoidSeparation: fields[10] + ' ' + (fields[11] || 'M'),
dgpsAge: fields[12] || '无',
dgpsStationId: fields[13] || '无',
}
}
if (statementId === 'RMC' && fields.length >= 10) {
result.parsed.rmc = {
utcTime: formatUtcTime(fields[0]),
status: fields[1],
latitude: formatLatitude(fields[2], fields[3]),
longitude: formatLongitude(fields[4], fields[5]),
speed: fields[6],
track: fields[7],
date: fields[8] ? `${fields[8].substring(0, 2)}/${fields[8].substring(2, 4)}/20${fields[8].substring(4)}` : '无',
magneticVariation: fields[9] ? fields[9] + '°' + (fields[10] || '') : '无',
}
}
result.parsed.fieldTable = fields.map((val, i) => {
let hint = ''
if (statementId === 'GGA') {
const ggaHints = [
'UTC时间', '纬度', 'N/S', '经度', 'E/W', '定位质量', '卫星数', 'HDOP', '天线高度',
'高度单位', '大地水准面差距', '差距单位', '差分龄期', '差分站ID'
]
hint = ggaHints[i] || ''
} else if (statementId === 'RMC') {
const rmcHints = [
'UTC时间', '状态(A=有效/V=无效)', '纬度', 'N/S', '经度', 'E/W', '航速(节)', '航向', '日期(DDMMYY)', '磁偏角', '磁偏角方向'
]
hint = rmcHints[i] || ''
}
return { index: i + 1, value: val, hint }
})
parseResults.value.push(result)
})
const validCount = parseResults.value.filter(r => r.valid).length
const totalCount = parseResults.value.length
validationMsg.value = `解析完成:${validCount}/${totalCount} 条语句校验通过`
validationClass.value = validCount === totalCount ? 'validation-ok' : 'validation-warn'
}
function loadSample() {
nmeaInput.value = [
'$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47',
'$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A',
'$GPGSA,A,3,04,05,,09,12,,,24,,,,,2.5,1.3,2.1*39',
'$GPGSV,2,1,08,01,40,083,46,02,17,308,41,12,07,344,39,14,22,228,45*75',
'$BDGGA,063015,3957.263,N,11618.432,E,1,05,1.8,50.2,M,-10.0,M,,*6F',
].join('\n')
}
function clearAll() {
nmeaInput.value = ''
parseResults.value = []
validationMsg.value = ''
}
</script>
<style scoped>
.nmea-page {
min-height: 100vh;
background: #f5f6f8;
}
/* ==================== 顶部导航栏(与 Materials 统一) ==================== */
.nmea-header {
background: #1a3a5c;
color: #fff;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
height: 60px;
position: sticky;
top: 0;
z-index: 100;
}
.back-btn {
color: rgba(255, 255, 255, 0.85);
white-space: nowrap;
}
.back-btn:hover { color: #c9a84c; }
.nmea-title {
font-size: 18px;
font-weight: 600;
position: absolute;
left: 50%;
transform: translateX(-50%);
}
.group-tag {
background: rgba(201, 168, 76, 0.2);
color: #c9a84c;
padding: 4px 12px;
border-radius: 4px;
font-size: 13px;
font-weight: 600;
}
.nmea-header-left {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.nmea-header-right {
flex-shrink: 0;
text-align: right;
}
/* ==================== 主内容区 ==================== */
.nmea-body {
display: flex;
gap: 20px;
max-width: 1400px;
margin: 20px auto;
padding: 0 20px;
}
.nmea-knowledge {
flex: 0 0 400px;
max-height: calc(100vh - 100px);
overflow-y: auto;
}
.nmea-tool {
flex: 1;
min-width: 0;
}
.card-title {
font-weight: 600;
font-size: 16px;
color: #303133;
}
.input-section {
margin-bottom: 0;
}
.input-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
font-size: 14px;
color: #606266;
}
.nmea-textarea :deep(.el-textarea__inner) {
font-family: 'Courier New', monospace;
font-size: 13px;
line-height: 1.6;
}
.action-row {
display: flex;
align-items: center;
gap: 10px;
margin-top: 12px;
}
.validation-info {
font-size: 13px;
margin-left: 10px;
}
.validation-ok { color: #67c23a; }
.validation-warn { color: #e6a23c; }
.validation-err { color: #f56c6c; }
.result-card {
margin-top: 16px;
}
.result-item {
padding: 12px 0;
border-bottom: 1px solid #ebeef5;
}
.result-item:last-child {
border-bottom: none;
}
.result-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.result-statement {
font-family: 'Courier New', monospace;
font-size: 13px;
color: #303133;
word-break: break-all;
}
.result-detail {
margin-top: 8px;
}
.result-detail h4 {
font-size: 14px;
color: #409eff;
margin: 12px 0 8px;
}
.checksum-ok { color: #67c23a; font-weight: bold; }
.checksum-err { color: #f56c6c; font-weight: bold; }
.practice-card {
margin-top: 16px;
}
.practice-item {
padding: 10px 0;
border-bottom: 1px dashed #ebeef5;
}
.practice-item:last-child {
border-bottom: none;
}
.practice-item p {
font-size: 14px;
color: #303133;
margin: 0 0 8px;
}
.practice-input {
max-width: 500px;
}
/* 响应式 */
@media (max-width: 900px) {
.nmea-body {
flex-direction: column;
}
.nmea-knowledge {
flex: none;
max-height: none;
}
}
</style>

50
src/views/NotFound.vue Normal file
View File

@@ -0,0 +1,50 @@
<!-- ============================================================
NotFound.vue — 404 页面未找到
当用户访问未定义的路由时显示此页面
============================================================ -->
<template>
<div class="page-container">
<el-card shadow="hover">
<div class="text-center" style="padding: 60px 20px;">
<h1 style="font-size: 72px; color: #c9a84c; margin: 0;">404</h1>
<p style="font-size: 18px; margin: 20px 0; color: #606266;">
抱歉,您访问的页面不存在
</p>
<el-button type="primary" size="large" @click="goHome">
返回首页
</el-button>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
/**
* NotFound.vue — 404 页面未找到视图
* 当用户访问未定义的路由时显示此页面
*/
import { useRouter } from 'vue-router'
const router = useRouter()
/**
* 返回首页
*/
function goHome() {
router.push('/')
}
</script>
<style scoped>
.page-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 60vh;
padding: 40px 20px;
}
.text-center {
text-align: center;
}
</style>

234
src/views/Score.vue Normal file
View File

@@ -0,0 +1,234 @@
<template>
<div class="score-page">
<!-- 顶部导航栏(与 Materials 统一) -->
<div class="score-header">
<div class="score-header-left">
<el-button text @click="$router.push('/')" class="back-btn">
<el-icon><HomeFilled /></el-icon><span>返回首页</span>
</el-button>
<el-button text @click="$router.push('/dashboard')" class="back-btn">
<el-icon><ArrowLeft /></el-icon><span>返回工作台</span>
</el-button>
</div>
<h2 class="score-title">我的成绩</h2>
<div class="score-header-right">
<span class="group-tag">{{ groupLabel }}</span>
</div>
</div>
<div class="score-container">
<!-- 内容 -->
<div class="score-content" v-loading="loading">
<!-- 已发布 -->
<template v-if="scoreData && scoreData.published">
<div class="score-header-card">
<div class="score-greeting">
<h1 class="greeting-title">{{ authStore.userName }},您的成绩</h1>
</div>
<div class="score-big-display">
<div class="score-number">{{ scoreData.score || '--' }}</div>
<div class="score-unit">分</div>
</div>
</div>
<!-- 分项成绩 -->
<el-card shadow="never" class="detail-card" v-if="breakdown.length > 0 || scoreData.rank || scoreData.formula">
<template #header>
<span class="card-title">成绩详情</span>
</template>
<el-descriptions :column="2" border size="small">
<el-descriptions-item v-for="item in breakdown" :key="item.label" :label="item.label">
<span class="detail-score">{{ item.value }} 分</span>
</el-descriptions-item>
<el-descriptions-item label="总分">
<span class="detail-score total-score">{{ scoreData.score }} 分</span>
</el-descriptions-item>
<el-descriptions-item v-if="scoreData.rank" label="排名">
<span class="detail-score">第 {{ scoreData.rank }} 名</span>
</el-descriptions-item>
</el-descriptions>
<div class="formula-tip" v-if="scoreData.formula">
计分方式:{{ scoreData.formula }}
</div>
</el-card>
</template>
<!-- 未发布 -->
<el-card shadow="never" class="detail-card" v-else>
<el-empty :description="scoreData?.message || '成绩暂未发布,请关注公告'" :image-size="120" />
</el-card>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ArrowLeft, HomeFilled } from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
import { getMyScore } from '@/api/score'
const authStore = useAuthStore()
const loading = ref(false)
const scoreData = ref(null)
const groupLabel = computed(() => {
const map = { zhongzhi: '中职组', gaozhi: '高职组', benke: '本科组', jiaoshi: '教师组' }
return map[authStore.userGroup] || '未知组别'
})
// 所有组别均仅向考生展示「总分」,不展示分数构成(客观题/Word/PPT 分项)。
// 分数分布只在管理后台与数据库可见,避免考生对分项成绩产生异议。
// 需求:本科/教师组"只显示总分"(实施反馈第3项)→ 中职/高职同样适用(分数异议)。
const breakdown = computed(() => {
return []
})
async function fetchScore() {
loading.value = true
try {
const data = await getMyScore()
if (data) {
scoreData.value = data
}
} catch (err) {
scoreData.value = null
} finally {
loading.value = false
}
}
onMounted(() => {
fetchScore()
})
</script>
<style scoped>
.score-page {
min-height: 100vh;
background: #f5f6f8;
}
/* ==================== 顶部导航栏(与 Materials 统一) ==================== */
.score-header {
background: #1a3a5c;
color: #fff;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
height: 60px;
position: sticky;
top: 0;
z-index: 100;
}
.back-btn {
color: rgba(255, 255, 255, 0.85);
white-space: nowrap;
}
.back-btn:hover { color: #c9a84c; }
.score-title {
font-size: 18px;
font-weight: 600;
position: absolute;
left: 50%;
transform: translateX(-50%);
}
.group-tag {
background: rgba(201, 168, 76, 0.2);
color: #c9a84c;
padding: 4px 12px;
border-radius: 4px;
font-size: 13px;
font-weight: 600;
}
.score-header-left {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.score-header-right {
flex-shrink: 0;
}
/* ==================== 内容区 ==================== */
.score-container {
max-width: 720px;
margin: 24px auto 0;
padding: 0 20px;
}
/* ==================== 头部成绩卡片 ==================== */
.score-header-card {
background: linear-gradient(135deg, #1a3a5c, #2a5a8c);
border-radius: 16px;
padding: 36px;
text-align: center;
color: #fff;
margin-bottom: 24px;
box-shadow: 0 4px 20px rgba(26, 58, 92, 0.3);
}
.greeting-title {
font-size: 20px;
font-weight: 600;
margin-bottom: 12px;
}
.score-big-display {
display: flex;
align-items: baseline;
justify-content: center;
margin-top: 16px;
}
.score-number {
font-size: 72px;
font-weight: 800;
line-height: 1;
}
.score-unit {
font-size: 24px;
color: rgba(255, 255, 255, 0.7);
margin-left: 4px;
}
/* ==================== 详情卡片 ==================== */
.detail-card {
border-radius: 12px;
border: 1px solid #e5e7eb;
}
.card-title {
font-size: 16px;
font-weight: 600;
color: #1f2937;
}
.detail-score {
font-size: 15px;
font-weight: 600;
color: #1a3a5c;
}
.total-score {
font-size: 18px;
color: #c9a84c;
}
.formula-tip {
margin-top: 14px;
padding: 10px 14px;
background: #f5f7fa;
border-left: 3px solid #c9a84c;
border-radius: 4px;
font-size: 13px;
color: #606266;
}
</style>

735
src/views/Upload.vue Normal file
View File

@@ -0,0 +1,735 @@
<template>
<div class="upload-page">
<!-- 顶部导航 -->
<div class="upload-header">
<div class="upload-header-left">
<el-button text @click="$router.push('/')" class="back-btn">
<el-icon><HomeFilled /></el-icon><span>返回首页</span>
</el-button>
<el-button text @click="$router.push('/dashboard')" class="back-btn">
<el-icon><ArrowLeft /></el-icon><span>返回工作台</span>
</el-button>
</div>
<h2 class="upload-title">作品提交</h2>
<span class="group-tag">{{ groupLabel }}</span>
</div>
<div class="upload-content">
<!-- 截止时间 -->
<div class="deadline-card" v-if="deadline">
<div class="deadline-info">
<el-icon :size="20"><Clock /></el-icon>
<span>提交截止时间:</span>
<strong :class="{ 'deadline-near': isDeadlineNear }">{{ formatDeadline(deadline) }}</strong>
</div>
<div class="deadline-countdown" v-if="deadlineCountdown > 0">
<span>剩余时间:</span>
<strong>{{ formatCountdown(deadlineCountdown) }}</strong>
</div>
</div>
<!-- 教师组双上传区 -->
<template v-if="isTeacher">
<el-row :gutter="24">
<el-col :xs="24" :md="12">
<div class="upload-card">
<h3 class="upload-card-title">教学方案</h3>
<p class="upload-card-desc">请上传教学方案文件(PDF、Word 格式)</p>
<file-upload-area
group="jiaoshi"
file-type="word"
:max-size="100 * 1024 * 1024"
accept=".pdf,.doc,.docx"
@upload-success="onUploadSuccess('word', $event)"
/>
</div>
</el-col>
<el-col :xs="24" :md="12">
<div class="upload-card">
<h3 class="upload-card-title">说课 PPT</h3>
<p class="upload-card-desc">请上传说课 PPT 文件</p>
<file-upload-area
group="jiaoshi"
file-type="ppt"
:max-size="100 * 1024 * 1024"
accept=".ppt,.pptx"
@upload-success="onUploadSuccess('ppt', $event)"
/>
</div>
</el-col>
</el-row>
</template>
<!-- 本科组双上传区(Word + PPT) -->
<template v-else>
<el-row :gutter="24">
<el-col :xs="24" :md="12">
<div class="upload-card">
<h3 class="upload-card-title">参赛作品(Word)</h3>
<p class="upload-card-desc">请上传参赛作品文档(PDF、Word 格式)</p>
<file-upload-area
:group="userGroup"
file-type="word"
:max-size="50 * 1024 * 1024"
accept=".pdf,.doc,.docx"
@upload-success="onUploadSuccess('word', $event)"
/>
</div>
</el-col>
<el-col :xs="24" :md="12">
<div class="upload-card">
<h3 class="upload-card-title">参赛作品(PPT)</h3>
<p class="upload-card-desc">请上传参赛作品演示文稿(PPT 格式)</p>
<file-upload-area
:group="userGroup"
file-type="ppt"
:max-size="50 * 1024 * 1024"
accept=".ppt,.pptx"
@upload-success="onUploadSuccess('ppt', $event)"
/>
</div>
</el-col>
</el-row>
</template>
<!-- 上传历史 -->
<div class="history-card">
<h3 class="history-title">提交记录</h3>
<div v-loading="historyLoading">
<el-table :data="submissionHistory" stripe empty-text="暂无提交记录">
<el-table-column prop="fileName" label="文件名" min-width="180" show-overflow-tooltip />
<el-table-column prop="fileSize" label="大小" width="100">
<template #default="{ row }">{{ formatSize(row.fileSize) }}</template>
</el-table-column>
<el-table-column prop="uploadTime" label="上传时间" width="170">
<template #default="{ row }">{{ formatDate(row.uploadTime) }}</template>
</el-table-column>
<el-table-column label="操作" width="160" align="center">
<template #default="{ row }">
<el-button
text
type="danger"
size="small"
:loading="deletingId === row.id"
v-guard="() => deleteSubmission(row)"
>删除</el-button>
<el-button text type="primary" size="small" @click="reupload">重新上传</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
</div>
</div>
</template>
<script setup>
/**
* Upload.vue - 作品上传页面(需要登录,benke/jiaoshi 组)
*
* 功能:
* 1. 截止时间倒计时
* 2. 拖拽上传区域
* 3. 文件类型/大小校验
* 4. 分片上传 + 进度条
* 5. MD5 哈希显示
* 6. 上传历史列表
* 7. 教师组双上传区(教学方案 + 说课PPT)
*/
import { ref, computed, onMounted, onUnmounted, h, resolveComponent } from 'vue'
import { ElMessage, ElMessageBox, ElUpload, ElProgress, ElButton, ElTag, ElIcon } from 'element-plus'
import { ArrowLeft, HomeFilled, Clock, UploadFilled, Delete } from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
import * as uploadApi from '@/api/upload'
import { chunkFile, calculateMd5, uploadWithRetry, formatFileSize } from '@/utils/upload'
const authStore = useAuthStore()
// ==================== 计算属性 ====================
const userGroup = computed(() => authStore.userGroup)
const groupLabel = computed(() => {
const map = { benke: '本科组', jiaoshi: '教师组' }
return map[userGroup.value] || userGroup.value
})
const isTeacher = computed(() => userGroup.value === 'jiaoshi')
const isDeadlineNear = computed(() => {
if (!deadline.value) return false
const diff = new Date(deadline.value) - new Date()
return diff > 0 && diff < 3 * 24 * 60 * 60 * 1000
})
// ==================== 状态 ====================
const deadline = ref('')
const deadlineCountdown = ref(0)
const submissionHistory = ref([])
const historyLoading = ref(false)
const deletingId = ref(null)
let deadlineTimer = null
// ==================== 文件上传子组件(内联实现) ====================
// 使用简单的内联上传逻辑替代子组件
const uploadStates = reactive({})
// ==================== 方法 ====================
function formatDate(dateStr) {
if (!dateStr) return '--'
const d = new Date(dateStr)
if (isNaN(d.getTime())) return dateStr
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
function formatDeadline(dateStr) {
if (!dateStr) return '--'
const d = new Date(dateStr)
if (isNaN(d.getTime())) return dateStr
return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日 ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
function formatSize(bytes) {
return formatFileSize(bytes || 0)
}
function formatCountdown(seconds) {
const d = Math.floor(seconds / 86400)
const h = Math.floor((seconds % 86400) / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = seconds % 60
if (d > 0) return `${d}天 ${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
}
/** 启动截止倒计时 */
function startDeadlineCountdown() {
clearInterval(deadlineTimer)
if (!deadline.value) return
deadlineTimer = setInterval(() => {
const diff = Math.floor((new Date(deadline.value) - new Date()) / 1000)
deadlineCountdown.value = Math.max(0, diff)
}, 1000)
}
/** 获取提交历史 */
async function fetchHistory() {
historyLoading.value = true
try {
const data = await uploadApi.getSubmissions(userGroup.value)
if (Array.isArray(data)) {
submissionHistory.value = data
} else if (data?.list) {
submissionHistory.value = data.list
}
} catch (err) {
console.warn('获取提交历史失败', err)
} finally {
historyLoading.value = false
}
}
/** 获取上传状态 */
async function fetchUploadStatus() {
try {
const data = await uploadApi.getUploadStatus(userGroup.value)
if (data?.deadline) {
deadline.value = data.deadline
startDeadlineCountdown()
}
} catch (err) {
console.warn('获取上传状态失败', err)
}
}
function onUploadSuccess(type, result) {
ElMessage.success('文件上传成功')
fetchHistory()
}
/** 删除已上传的作品文件(本科/教师组) */
async function deleteSubmission(row) {
try {
await ElMessageBox.confirm(
`确定要删除「${row.fileName}」吗?删除后可重新上传进行替换。`,
'删除确认',
{ confirmButtonText: '确认删除', cancelButtonText: '取消', type: 'warning' }
)
} catch {
return
}
deletingId.value = row.id
try {
await uploadApi.deleteWork(userGroup.value, row.id)
ElMessage.success('文件已删除')
fetchHistory()
} catch (err) {
ElMessage.error('删除失败,请重试')
} finally {
deletingId.value = null
}
}
/** 滚动到上传区以便重新上传 */
function reupload() {
const el = document.querySelector('.upload-card')
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
// ==================== 生命周期 ====================
onMounted(() => {
// 组别检查
if (!['benke', 'jiaoshi'].includes(authStore.userGroup)) {
ElMessage.warning('您所在的组别不需要提交作品')
// 不强制跳转,避免闪烁
}
fetchUploadStatus()
fetchHistory()
})
onUnmounted(() => {
if (deadlineTimer) clearInterval(deadlineTimer)
})
</script>
<!-- ==================== 文件上传子区域组件模板 ==================== -->
<script>
/**
* file-upload-area 是一个内联组件
* 为了保持 Vue SFC 的简洁性,我们用 defineComponent
*/
import { defineComponent, ref, reactive, h, resolveComponent, computed } from 'vue'
import { ElMessage, ElUpload, ElProgress, ElButton, ElTag, ElIcon } from 'element-plus'
import { UploadFilled, Delete } from '@element-plus/icons-vue'
import * as uploadApi from '@/api/upload'
import { chunkFile, calculateMd5, uploadWithRetry, formatFileSize } from '@/utils/upload'
const FileUploadArea = defineComponent({
name: 'FileUploadArea',
props: {
group: { type: String, required: true },
fileType: { type: String, default: 'work' },
maxSize: { type: Number, default: 50 * 1024 * 1024 },
accept: { type: String, default: '.pdf,.doc,.docx,.ppt,.pptx,.zip' },
},
emits: ['upload-success'],
setup(props, { emit }) {
const fileList = ref([])
const uploading = ref(false)
const uploadPercent = ref(0)
const uploadedInfo = ref(null)
function validateFile(file) {
const raw = file.raw || file
if (raw.size > props.maxSize) {
const maxMB = props.maxSize / (1024 * 1024)
ElMessage.error(`文件大小不能超过 ${maxMB}MB`)
return false
}
return true
}
function handleFileChange(file) {
if (!validateFile(file)) {
fileList.value = []
return
}
fileList.value = [file]
uploadedInfo.value = null
// 选择文件后自动开始上传,提供即时反馈
startUpload()
}
function removeFile() {
if (uploading.value) {
ElMessage.warning('文件正在上传中,请等待完成后再操作')
return
}
fileList.value = []
uploadedInfo.value = null
uploadPercent.value = 0
}
async function startUpload() {
// 防重入:选择文件会自动触发一次上传,用户若再点「开始上传」会造成同一文件
// 双份分片流并发,既浪费连接池也可能让后端合并出错。
if (uploading.value) return
if (fileList.value.length === 0) {
ElMessage.warning('请先选择文件')
return
}
const rawFile = fileList.value[0]?.raw || fileList.value[0]
if (!rawFile) return
uploading.value = true
uploadPercent.value = 0
try {
// 计算 MD5 作为 fileId
let fileId = ''
try {
fileId = await calculateMd5(rawFile)
} catch (e) {
console.warn('MD5 计算失败', e)
fileId = rawFile.name + '_' + Date.now()
}
// 分片上传
const chunks = chunkFile(rawFile)
for (let i = 0; i < chunks.length; i++) {
const formData = new FormData()
// ⚠️ Egg stream 模式下 stream.fields 仅解析「出现在文件字段之前」的普通字段。
// 必须把文本字段放在 chunk(文件)之前,否则后端收不到 chunkIndex/fileId/fileName -> 400 缺参。
formData.append('chunkIndex', chunks[i].index)
formData.append('totalChunks', chunks[i].total)
formData.append('fileId', fileId)
formData.append('fileName', rawFile.name)
formData.append('chunk', chunks[i].chunk, rawFile.name)
await uploadWithRetry(
() => uploadApi.uploadChunk(props.group, formData),
3, 1000
)
uploadPercent.value = Math.round(((i + 1) / chunks.length) * 100)
}
// 所有分片上传完成,调用合并接口(透传 file_type:word/ppt)
await uploadApi.mergeChunks(props.group, {
fileId,
fileName: rawFile.name,
totalChunks: chunks.length,
work_name: rawFile.name,
file_type: props.fileType,
})
uploadedInfo.value = {
fileName: rawFile.name,
fileSize: rawFile.size,
fileId,
}
ElMessage.success('上传成功')
emit('upload-success', { type: props.fileType, info: uploadedInfo.value })
} catch (err) {
ElMessage.error('上传失败,请重试')
} finally {
uploading.value = false
}
}
return () => {
return h('div', { class: 'file-upload-wrapper' }, [
// 拖拽上传区
!uploadedInfo.value ? [
h('div', {
class: 'drag-area',
onClick: () => {
// 触发文件选择
},
}, [
h('input', {
type: 'file',
accept: props.accept,
style: { display: 'none' },
ref: 'fileInput',
onChange: (e) => {
const file = e.target.files?.[0]
if (file) {
handleFileChange({ raw: file, name: file.name, size: file.size })
}
e.target.value = ''
},
}),
h('div', {
class: 'drag-content',
onClick: (e) => {
// 点击时触发隐藏的 file input
e.currentTarget.previousElementSibling?.click()
},
}, [
h(resolveComponent('el-icon'), { size: 48 }, () => h(UploadFilled)),
h('div', { class: 'drag-text' }, '拖拽文件到此处或 '),
h('em', null, '点击上传'),
h('div', { class: 'drag-hint' },
`支持 ${props.accept.replace(/\./g, '')} 格式,最大 ${props.maxSize / (1024 * 1024)}MB`
),
]),
]),
// 已选文件
fileList.value.length > 0 ? h('div', { class: 'selected-file' }, [
h('span', { class: 'selected-name' }, fileList.value[0].name || fileList.value[0].fileName),
h('el-button', {
type: 'danger',
size: 'small',
icon: h(Delete),
circle: true,
onClick: removeFile,
}),
]) : null,
// 上传按钮
fileList.value.length > 0 ? h('div', { class: 'upload-actions' }, [
h('el-button', {
type: 'primary',
loading: uploading.value,
onClick: startUpload,
}, { default: () => '开始上传' }),
]) : null,
] : null,
// 上传进度
uploading.value ? h('div', { class: 'upload-progress' }, [
h(resolveComponent('el-progress'), {
percentage: uploadPercent.value,
status: uploadPercent.value >= 100 ? 'success' : undefined,
}),
]) : null,
// 已上传文件信息
uploadedInfo.value ? h('div', { class: 'uploaded-card' }, [
h(resolveComponent('el-tag'), { type: 'success', size: 'small' }, () => '上传成功'),
h('div', { class: 'uploaded-name' }, uploadedInfo.value.fileName),
h('div', { class: 'uploaded-meta' }, [
h('span', null, formatFileSize(uploadedInfo.value.fileSize)),
uploadedInfo.value.md5 ? h('span', null, `MD5: ${uploadedInfo.value.md5.slice(0, 16)}...`) : null,
].filter(Boolean)),
h('el-button', {
text: true,
type: 'danger',
size: 'small',
onClick: removeFile,
}, { default: () => '重新上传' }),
]) : null,
])
}
},
})
export default {
components: { FileUploadArea },
}
</script>
<style scoped>
.upload-page {
min-height: 100vh;
background: #f5f6f8;
padding-bottom: 40px;
}
/* ==================== 顶部导航 ==================== */
.upload-header {
background: #1a3a5c;
color: #fff;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
height: 60px;
position: sticky;
top: 0;
z-index: 100;
}
.back-btn { color: rgba(255,255,255,0.85); white-space: nowrap; }
.back-btn:hover { color: #c9a84c; }
.upload-title {
font-size: 18px;
font-weight: 600;
position: absolute;
left: 50%;
transform: translateX(-50%);
}
.upload-header-left {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.group-tag {
background: rgba(201,168,76,0.2);
color: #c9a84c;
padding: 4px 12px;
border-radius: 4px;
font-size: 13px;
font-weight: 600;
}
/* ==================== 内容 ==================== */
.upload-content {
max-width: 1000px;
margin: 24px auto 0;
padding: 0 20px;
}
/* ==================== 截止时间 ==================== */
.deadline-card {
background: #fff;
border-radius: 12px;
padding: 20px 24px;
margin-bottom: 24px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
.deadline-info {
display: flex;
align-items: center;
gap: 8px;
font-size: 15px;
color: #6b7280;
}
.deadline-near { color: #f56c6c; }
.deadline-countdown {
font-size: 18px;
color: #1a3a5c;
}
/* ==================== 上传卡片 ==================== */
.upload-card {
background: #fff;
border-radius: 12px;
padding: 28px;
margin-bottom: 24px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
}
.upload-card-title {
font-size: 18px;
font-weight: 600;
color: #1a3a5c;
margin-bottom: 8px;
}
.upload-card-desc {
font-size: 14px;
color: #6b7280;
margin-bottom: 24px;
}
/* ==================== 拖拽上传区 ==================== */
.drag-area {
border: 2px dashed #d1d5db;
border-radius: 12px;
padding: 40px 20px;
text-align: center;
cursor: pointer;
transition: all 0.3s;
background: #fafafa;
}
.drag-area:hover {
border-color: #1a3a5c;
background: #f0f4f8;
}
.drag-content {
color: #6b7280;
}
.drag-text {
font-size: 14px;
margin-top: 12px;
}
.drag-hint {
font-size: 12px;
color: #909399;
margin-top: 8px;
}
/* ==================== 已选文件 ==================== */
.selected-file {
display: flex;
align-items: center;
justify-content: space-between;
background: #f0f4f8;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 12px 16px;
margin-top: 12px;
}
.selected-name {
font-size: 14px;
color: #1f2937;
font-weight: 500;
}
.upload-actions {
margin-top: 12px;
text-align: center;
}
.upload-progress {
margin-top: 16px;
}
/* ==================== 上传成功信息 ==================== */
.uploaded-card {
border: 1px solid #bbf7d0;
background: #f0fdf4;
border-radius: 10px;
padding: 20px;
margin-top: 16px;
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
}
.uploaded-name {
font-size: 14px;
font-weight: 600;
color: #1f2937;
flex: 1;
min-width: 200px;
word-break: break-all;
}
.uploaded-meta {
display: flex;
gap: 16px;
font-size: 12px;
color: #6b7280;
width: 100%;
}
/* ==================== 历史记录 ==================== */
.history-card {
background: #fff;
border-radius: 12px;
padding: 28px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
}
.history-title {
font-size: 18px;
font-weight: 600;
color: #1a3a5c;
margin-bottom: 16px;
}
/* ==================== Responsive ==================== */
@media (max-width: 480px) {
.upload-header { padding: 0 12px; }
.deadline-card { flex-direction: column; }
}
</style>