前端时间!激动地搓手手 🎨
🎯 目标
- 搭建Vue3 + TypeScript前端项目结构
- 集成Element Plus组件库和Vite构建工具
- 实现用户认证界面(登录/注册/个人中心)
- 创建题库管理界面(浏览/搜索/筛选)
- 配置macOS特化的前端开发环境
- 实现前后端完整联调
1. 创建Vue3项目(使用Vite)
# 进入项目根目录
$ cd ~/Projects/math-olympiad
# 创建Vue3项目(macOS推荐使用pnpm,更快)
$ npm create vue@latest frontend
# 交互式配置(以下是推荐选择):
# ✔ Project name: … frontend
# ✔ Add TypeScript? … Yes
# ✔ Add JSX Support? … No
# ✔ Add Vue Router for Single Page Application development? … Yes
# ✔ Add Pinia for state management? … Yes
# ✔ Add Vitest for Unit Testing? … No(暂时不需要)
# ✔ Add an End-to-End Testing Solution? … No(暂时不需要)
# ✔ Add ESLint for code quality? … Yes
# ✔ Add Prettier for code formatting? … Yes
# 进入前端目录
$ cd frontend
# 使用pnpm安装(如果没安装pnpm:npm install -g pnpm)
$ pnpm install
# 或者用npm(速度稍慢)
$ npm install
2. 安装核心依赖
# 安装UI组件库和工具
$ pnpm add element-plus @element-plus/icons-vue
$ pnpm add axios
$ pnpm add pinia-plugin-persistedstate # 状态持久化
$ pnpm add vue-i18n # 国际化(可选)
# 开发依赖
$ pnpm add -D sass sass-loader
$ pnpm add -D @types/node # Node.js类型定义
$ pnpm add -D unplugin-auto-import unplugin-vue-components # 自动导入
$ pnpm add -D @iconify-json/ep # Element Plus图标集
3. 配置macOS特化的Vite
# 创建Vite配置文件
$ cat > vite.config.ts << 'EOF'
import { fileURLToPath, URL } from 'node:url'
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import Icons from 'unplugin-icons/vite'
import IconsResolver from 'unplugin-icons/resolver'
// https://vitejs.dev/config/
export default defineConfig(({ mode }) => {
// 加载环境变量
const env = loadEnv(mode, process.cwd(), '')
return {
// macOS特化:开发服务器配置
server: {
port: 5173,
host: true, // 监听所有地址
open: true, // 自动打开浏览器
cors: true,
// macOS开发优化:热更新配置
watch: {
usePolling: false, // macOS文件系统通知更高效
interval: 100, // 轮询间隔(如果需要)
},
// 代理配置,解决跨域
proxy: {
'/api': {
target: env.VITE_API_BASE_URL || 'http://localhost:8000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
secure: false,
},
},
},
// 构建配置
build: {
target: 'esnext',
minify: 'esbuild',
sourcemap: mode === 'development', // 开发环境生成sourcemap
chunkSizeWarningLimit: 1000, // 加大chunk大小警告限制
// macOS特化:构建优化
rollupOptions: {
output: {
manualChunks: {
'vue-vendor': ['vue', 'vue-router', 'pinia'],
'element-plus': ['element-plus', '@element-plus/icons-vue'],
'axios-vendor': ['axios'],
},
},
},
},
// 插件配置
plugins: [
vue(),
vueJsx(),
// 自动导入Element Plus组件
AutoImport({
imports: ['vue', 'vue-router', 'pinia'],
resolvers: [
ElementPlusResolver(),
// 自动导入图标
IconsResolver({
prefix: 'Icon',
}),
],
dts: 'src/auto-imports.d.ts',
}),
Components({
resolvers: [
ElementPlusResolver(),
// 自动注册图标组件
IconsResolver({
enabledCollections: ['ep'],
}),
],
dts: 'src/components.d.ts',
}),
// 图标自动导入
Icons({
autoInstall: true,
}),
],
// 路径别名
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
'@components': fileURLToPath(new URL('./src/components', import.meta.url)),
'@views': fileURLToPath(new URL('./src/views', import.meta.url)),
'@utils': fileURLToPath(new URL('./src/utils', import.meta.url)),
'@stores': fileURLToPath(new URL('./src/stores', import.meta.url)),
'@api': fileURLToPath(new URL('./src/api', import.meta.url)),
},
},
// CSS预处理器配置
css: {
preprocessorOptions: {
scss: {
additionalData: `@use "@/styles/element/index.scss" as *;`,
},
},
},
// 环境变量前缀
envPrefix: 'VITE_',
}
})
EOF
4. 创建macOS特化的环境变量
# 创建环境变量文件
$ cat > .env.development << 'EOF'
# 开发环境配置
VITE_APP_TITLE="奥赛AI平台 - 开发环境"
VITE_APP_VERSION="1.0.0"
VITE_API_BASE_URL="http://localhost:8000"
VITE_APP_MODE="development"
VITE_ENABLE_MOCK="false" # 是否启用Mock数据
# macOS特化配置
VITE_MACOS_DEV=true
VITE_DEBUG=true
# 功能开关
VITE_ENABLE_ANALYTICS=false
VITE_ENABLE_ERROR_REPORTING=true
EOF
$ cat > .env.production << 'EOF'
# 生产环境配置
VITE_APP_TITLE="奥赛AI平台"
VITE_APP_VERSION="1.0.0"
VITE_API_BASE_URL="/api"
VITE_APP_MODE="production"
VITE_ENABLE_MOCK="false"
# 功能开关
VITE_ENABLE_ANALYTICS=true
VITE_ENABLE_ERROR_REPORTING=true
EOF
$ cat > .env.local << 'EOF'
# 本地覆盖配置(不会被提交到Git)
# 在这里覆盖个人开发配置
EOF
5. 配置Element Plus主题和样式
# 创建Element Plus主题配置文件
$ mkdir -p src/styles/element
$ cat > src/styles/element/index.scss << 'EOF'
// Element Plus主题定制
// 覆盖默认变量实现主题定制
// 主题颜色
$--color-primary: #409EFF;
$--color-success: #67C23A;
$--color-warning: #E6A23C;
$--color-danger: #F56C6C;
$--color-info: #909399;
// 背景颜色
$--background-color-base: #f5f7fa;
// 边框颜色和圆角
$--border-color-base: #DCDFE6;
$--border-color-light: #E4E7ED;
$--border-color-lighter: #EBEEF5;
$--border-color-extra-light: #F2F6FC;
$--border-radius-base: 4px;
$--border-radius-small: 2px;
$--border-radius-round: 20px;
$--border-radius-circle: 100%;
// 字体
$--font-path: 'element-plus/theme-chalk/fonts';
$--font-size-base: 14px;
$--font-size-small: 13px;
$--font-size-large: 18px;
// 按钮
$--button-font-weight: 500;
// 输入框
$--input-border-radius: $--border-radius-base;
// Card
$--card-border-radius: 8px;
// 导入Element Plus样式
@use "element-plus/theme-chalk/src/index.scss" as *;
EOF
# 创建全局样式文件
$ cat > src/styles/global.scss << 'EOF'
/* 全局样式 - 奥赛AI平台 */
/* 重置和基础样式 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
font-size: 14px;
height: 100%;
}
body {
font-family: "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB",
"Microsoft YaHei", "微软雅黑", Arial, sans-serif;
font-size: 14px;
line-height: 1.5;
color: #303133;
background-color: #f5f7fa;
height: 100%;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#app {
height: 100%;
}
/* 滚动条样式 - macOS特化 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
/* 工具类 */
.text-ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.flex-center {
display: flex;
align-items: center;
justify-content: center;
}
.flex-between {
display: flex;
align-items: center;
justify-content: space-between;
}
.flex-column {
display: flex;
flex-direction: column;
}
/* 动画 */
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slide-up {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.fade-in {
animation: fade-in 0.3s ease-in-out;
}
.slide-up {
animation: slide-up 0.3s ease-in-out;
}
/* 数学相关样式 */
.math-content {
font-family: "Cambria Math", "Times New Roman", serif;
line-height: 1.8;
.formula {
display: inline-block;
margin: 0 2px;
font-style: italic;
}
.fraction {
display: inline-block;
text-align: center;
vertical-align: middle;
}
.numerator {
display: block;
border-bottom: 1px solid;
}
.denominator {
display: block;
}
}
/* 响应式断点 */
$breakpoint-sm: 640px;
$breakpoint-md: 768px;
$breakpoint-lg: 1024px;
$breakpoint-xl: 1280px;
/* 暗色模式支持 */
@media (prefers-color-scheme: dark) {
body {
color: #e5e7eb;
background-color: #1f2937;
}
.math-content {
color: #e5e7eb;
}
}
EOF
上午 10:30-12:00 | 项目结构和工具配置
6. 创建TypeScript配置
7. 配置ESLint和Prettier
# ESLint配置
$ cat > .eslintrc.cjs << 'EOF'
/* eslint-env node */
require('@rushstack/eslint-patch/modern-module-resolution')
module.exports = {
root: true,
extends: [
'plugin:vue/vue3-essential',
'eslint:recommended',
'@vue/eslint-config-typescript',
'@vue/eslint-config-prettier/skip-formatting'
],
parserOptions: {
ecmaVersion: 'latest'
},
rules: {
// macOS特化:开发环境宽松规则
'vue/multi-word-component-names': 'off',
'vue/no-unused-components': 'warn',
'vue/no-unused-vars': 'warn',
// TypeScript规则
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': 'warn',
// JavaScript规则
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-unused-vars': 'warn',
// 代码风格
'prefer-const': 'warn',
'no-var': 'error',
'eqeqeq': ['error', 'always'],
'curly': ['error', 'all'],
// Vue 3规则
'vue/require-default-prop': 'off',
'vue/attributes-order': 'error',
'vue/order-in-components': 'error',
'vue/component-tags-order': ['error', {
order: ['template', 'script', 'style']
}],
},
overrides: [
{
files: ['*.vue'],
rules: {
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': 'off'
}
}
]
}
EOF
# Prettier配置
$ cat > .prettierrc.json << 'EOF'
{
"semi": false,
"tabWidth": 2,
"printWidth": 100,
"singleQuote": true,
"trailingComma": "es5",
"bracketSpacing": true,
"bracketSameLine": false,
"arrowParens": "always",
"vueIndentScriptAndStyle": true,
"endOfLine": "lf",
"htmlWhitespaceSensitivity": "ignore"
}
EOF
8. 创建macOS特化的开发脚本
$ cat > scripts/dev/mac-dev.sh << 'EOF'
#!/bin/bash
# macOS前端开发环境脚本
set -e
# 颜色定义
GREEN='\033[0;32m'
BLUE='\033[0;34m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m' # No Color
print_info() {
echo -e "${BLUE}[INFO]${NC} $1"
}
print_success() {
echo -e "${GREEN}[SUCCESS]${NC} $1"
}
print_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
print_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# 检查Node.js版本
check_node_version() {
local required="18.0.0"
local current=$(node -v | cut -d'v' -f2)
print_info "检查Node.js版本..."
if [ "$(printf '%s\n' "$required" "$current" | sort -V | head -n1)" != "$required" ]; then
print_error "Node.js版本过低,需要 >= $required,当前: $current"
echo "建议使用nvm管理Node.js版本:"
echo " nvm install 18"
echo " nvm use 18"
exit 1
fi
print_success "Node.js版本: $current"
}
# 检查包管理器
check_package_manager() {
print_info "检查包管理器..."
if command -v pnpm &> /dev/null; then
print_success "使用pnpm (推荐)"
PM="pnpm"
elif command -v yarn &> /dev/null; then
print_warning "使用yarn"
PM="yarn"
elif command -v npm &> /dev/null; then
print_warning "使用npm"
PM="npm"
else
print_error "未找到包管理器"
echo "请安装pnpm: npm install -g pnpm"
exit 1
fi
}
# 检查端口占用
check_port() {
local port=${1:-5173}
print_info "检查端口 $port 占用..."
if lsof -Pi :$port -sTCP:LISTEN -t >/dev/null ; then
print_warning "端口 $port 被占用"
# 显示占用进程
local pid=$(lsof -ti:$port)
local process=$(ps -p $pid -o comm= 2>/dev/null || echo "unknown")
echo "占用进程: $process (PID: $pid)"
read -p "是否终止进程?(y/N): " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
kill -9 $pid 2>/dev/null
print_success "已终止进程"
else
read -p "使用其他端口 (默认: 5174): " new_port
new_port=${new_port:-5174}
PORT=$new_port
fi
else
print_success "端口 $port 可用"
fi
}
# 安装依赖
install_dependencies() {
print_info "安装依赖..."
if [ ! -d "node_modules" ]; then
$PM install
else
print_info "node_modules已存在,跳过安装"
fi
}
# 清理缓存
clean_cache() {
print_info "清理缓存..."
# 清理构建缓存
rm -rf dist 2>/dev/null || true
rm -rf node_modules/.vite 2>/dev/null || true
rm -rf node_modules/.cache 2>/dev/null || true
# 清理日志
find . -name "*.log" -delete 2>/dev/null || true
print_success "缓存清理完成"
}
# 启动开发服务器
start_dev_server() {
local port=${PORT:-5173}
local host=${HOST:-localhost}
print_info "启动开发服务器..."
echo ""
echo "╔══════════════════════════════════════╗"
echo "║ Math Olympiad AI Platform ║"
echo "║ Vue 3 Frontend ║"
echo "╚══════════════════════════════════════╝"
echo ""
echo "📦 包管理器: $PM"
echo "🌐 开发服务器: http://$host:$port"
echo "🔗 API代理: http://localhost:8000"
echo "⚡ 热重载: 已启用"
echo "🐛 调试模式: 已启用"
echo ""
echo "📝 日志输出:"
echo "════════════════════════════════════════"
# 设置环境变量
export PORT=$port
export HOST=$host
# 启动Vite开发服务器
$PM run dev -- --port $port --host $host
}
# 主函数
main() {
echo "🚀 Vue 3 macOS前端开发脚本"
echo "════════════════════════════════════════"
# 检查是否在前端目录
if [[ ! -f "vite.config.ts" ]]; then
print_error "请在前端项目目录(frontend)中运行此脚本"
exit 1
fi
# 执行检查
check_node_version
check_package_manager
check_port "${1:-5173}"
install_dependencies
clean_cache
# 启动开发服务器
start_dev_server
}
# 处理参数
PORT=""
HOST=""
while [[ $# -gt 0 ]]; do
case $1 in
--port)
PORT="$2"
shift 2
;;
--host)
HOST="$2"
shift 2
;;
*)
print_warning "未知参数: $1"
shift
;;
esac
done
# 运行主函数
main "$PORT"
EOF
# 给脚本执行权限
$ chmod +x scripts/dev/mac-dev.sh
下午 13:30-15:00 | 核心工具类和状态管理
9. 创建HTTP请求工具(axios配置)
$ mkdir -p src/utils
$ cat > src/utils/request.ts << 'EOF'
/**
* axios HTTP请求工具
* 封装请求拦截、响应拦截、错误处理
*/
import axios, {
type AxiosInstance,
type AxiosRequestConfig,
type AxiosResponse,
type InternalAxiosRequestConfig
} from 'axios'
import { useUserStore } from '@/stores/user'
import { ElMessage, ElMessageBox } from 'element-plus'
import router from '@/router'
// 环境配置
const isDevelopment = import.meta.env.MODE === 'development'
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || ''
// 创建axios实例
const service: AxiosInstance = axios.create({
baseURL: API_BASE_URL,
timeout: 15000, // 15秒超时
headers: {
'Content-Type': 'application/json;charset=utf-8'
}
})
// 请求拦截器
service.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
// 在发送请求之前做些什么
const userStore = useUserStore()
// 添加token
if (userStore.token) {
config.headers.Authorization = `Bearer ${userStore.token}`
}
// 开发环境日志
if (isDevelopment) {
console.log(`📤 请求: ${config.method?.toUpperCase()} ${config.url}`, config.data || '')
}
return config
},
(error: any) => {
// 对请求错误做些什么
console.error('❌ 请求错误:', error)
return Promise.reject(error)
}
)
// 响应拦截器
service.interceptors.response.use(
(response: AxiosResponse) => {
// 对响应数据做点什么
const res = response.data
// 开发环境日志
if (isDevelopment) {
console.log(`📥 响应: ${response.config.url}`, res)
}
// 业务状态码处理(根据后端API设计调整)
if (response.status === 200) {
return res
} else {
// 业务错误处理
handleBusinessError(res)
return Promise.reject(new Error(res.message || 'Error'))
}
},
(error: any) => {
// 对响应错误做点什么
console.error('❌ 响应错误:', error)
// 网络错误处理
if (!error.response) {
ElMessage.error('网络错误,请检查网络连接')
return Promise.reject(error)
}
// HTTP状态码处理
handleHttpError(error)
return Promise.reject(error)
}
)
// 业务错误处理
function handleBusinessError(response: any) {
const { code, message } = response
// 根据业务状态码处理
switch (code) {
case 400:
ElMessage.warning(message || '请求参数错误')
break
case 401:
handleUnauthorized()
break
case 403:
ElMessage.warning(message || '没有权限')
break
case 404:
ElMessage.warning(message || '资源不存在')
break
case 500:
ElMessage.error(message || '服务器错误')
break
default:
ElMessage.warning(message || '未知错误')
}
}
// HTTP错误处理
function handleHttpError(error: any) {
const { response } = error
const userStore = useUserStore()
if (!response) return
switch (response.status) {
case 400:
ElMessage.error(response.data?.message || '请求错误')
break
case 401:
handleUnauthorized()
break
case 403:
ElMessage.error('没有权限访问')
if (userStore.token) {
// 有token但没权限,可能是token过期
userStore.logout()
router.push('/login')
}
break
case 404:
ElMessage.error('请求的资源不存在')
break
case 408:
ElMessage.error('请求超时')
break
case 500:
ElMessage.error('服务器内部错误')
break
case 502:
ElMessage.error('网关错误')
break
case 503:
ElMessage.error('服务不可用')
break
case 504:
ElMessage.error('网关超时')
break
default:
ElMessage.error(`请求失败: ${response.status}`)
}
}
// 未授权处理
function handleUnauthorized() {
const userStore = useUserStore()
// 清除用户信息
userStore.logout()
// 显示登录提示
ElMessageBox.confirm(
'登录已过期,请重新登录',
'提示',
{
confirmButtonText: '重新登录',
cancelButtonText: '取消',
type: 'warning'
}
).then(() => {
router.push('/login')
}).catch(() => {
// 用户取消
})
}
// 封装GET请求
export function get<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
return service.get(url, config)
}
// 封装POST请求
export function post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
return service.post(url, data, config)
}
// 封装PUT请求
export function put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
return service.put(url, data, config)
}
// 封装DELETE请求
export function del<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
return service.delete(url, config)
}
// 文件上传
export function uploadFile(url: string, file: File, onProgress?: (progress: number) => void) {
const formData = new FormData()
formData.append('file', file)
return service.post(url, formData, {
headers: {
'Content-Type': 'multipart/form-data'
},
onUploadProgress: (progressEvent) => {
if (onProgress && progressEvent.total) {
const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total)
onProgress(progress)
}
}
})
}
export default service
EOF
10. 创建API接口定义
$ mkdir -p src/api
$ cat > src/api/auth.ts << 'EOF'
/**
* 用户认证相关API
*/
import { post, get } from '@/utils/request'
import type {
LoginRequest,
RegisterRequest,
UserInfo,
TokenResponse,
ChangePasswordRequest
} from '@/types/auth'
// 用户登录
export function login(data: LoginRequest): Promise<TokenResponse> {
return post('/api/v1/auth/login/json', data)
}
// 用户注册
export function register(data: RegisterRequest): Promise<UserInfo> {
return post('/api/v1/auth/register', data)
}
// 获取当前用户信息
export function getCurrentUser(): Promise<UserInfo> {
return get('/api/v1/auth/me')
}
// 刷新token
export function refreshToken(refreshToken: string): Promise<TokenResponse> {
return post('/api/v1/auth/refresh', { refresh_token: refreshToken })
}
// 修改密码
export function changePassword(data: ChangePasswordRequest): Promise<void> {
return post('/api/v1/auth/change-password', data)
}
// 登出
export function logout(): Promise<void> {
return post('/api/v1/auth/logout')
}
// 检查用户名是否可用
export function checkUsername(username: string): Promise<{ available: boolean }> {
return get(`/api/v1/auth/check-username/${username}`)
}
// 检查邮箱是否可用
export function checkEmail(email: string): Promise<{ available: boolean }> {
return get(`/api/v1/auth/check-email/${email}`)
}
// 健康检查
export function healthCheck(): Promise<{
status: string
service: string
timestamp: number
}> {
return get('/health')
}
EOF
$ cat > src/api/problem.ts << 'EOF'
/**
* 题目管理相关API
*/
import { get, post, put, del } from '@/utils/request'
import type {
Problem,
ProblemCreateRequest,
ProblemUpdateRequest,
ProblemListResponse,
ProblemFilter,
ProblemStats,
PracticeProblem
} from '@/types/problem'
// 获取题目列表
export function getProblems(params?: ProblemFilter): Promise<ProblemListResponse> {
return get('/api/v1/problems/', { params })
}
// 获取题目详情
export function getProblem(id: number): Promise<Problem> {
return get(`/api/v1/problems/${id}`)
}
// 创建题目
export function createProblem(data: ProblemCreateRequest): Promise<Problem> {
return post('/api/v1/problems/', data)
}
// 更新题目
export function updateProblem(id: number, data: ProblemUpdateRequest): Promise<Problem> {
return put(`/api/v1/problems/${id}`, data)
}
// 删除题目
export function deleteProblem(id: number): Promise<void> {
return del(`/api/v1/problems/${id}`)
}
// 发布/取消发布题目
export function publishProblem(id: number, publish: boolean = true): Promise<void> {
return post(`/api/v1/problems/${id}/publish`, { publish })
}
// 获取题目统计
export function getProblemStats(): Promise<ProblemStats> {
return get('/api/v1/problems/stats/summary')
}
// 获取随机练习题目
export function getRandomProblems(params: {
count?: number
difficulty?: number[]
knowledge_point_ids?: number[]
}): Promise<PracticeProblem[]> {
return get('/api/v1/problems/practice/random', { params })
}
// 记录题目答题
export function recordProblemAttempt(id: number, isCorrect: boolean): Promise<void> {
return post(`/api/v1/problems/${id}/attempt`, { is_correct: isCorrect })
}
// 搜索题目
export function searchProblems(keyword: string, params?: {
skip?: number
limit?: number
}): Promise<Problem[]> {
return get(`/api/v1/problems/search/${keyword}`, { params })
}
EOF
11. 创建类型定义
$ mkdir -p src/types
$ cat > src/types/auth.ts << 'EOF'
/**
* 用户认证相关类型定义
*/
// 登录请求
export interface LoginRequest {
username: string
password: string
}
// 注册请求
export interface RegisterRequest {
username: string
password: string
email?: string
full_name?: string
role?: 'student' | 'teacher' | 'admin' | 'parent'
grade?: string
school?: string
}
// 修改密码请求
export interface ChangePasswordRequest {
current_password: string
new_password: string
}
// Token响应
export interface TokenResponse {
access_token: string
token_type: string
expires_in: number
}
// 用户信息
export interface UserInfo {
id: number
username: string
email?: string
full_name?: string
role: 'student' | 'teacher' | 'admin' | 'parent'
grade?: string
school?: string
is_active: boolean
is_verified: boolean
created_at: string
last_login_at?: string
}
// 用户统计
export interface UserStats {
total_practice_sessions: number
total_questions_attempted: number
overall_accuracy: number
total_practice_time: number
average_session_duration: number
}
EOF
$ cat > src/types/problem.ts << 'EOF'
/**
* 题目相关类型定义
*/
// 题目选项
export interface ProblemOptions {
A: string
B: string
C: string
D: string
}
// 知识点
export interface KnowledgePoint {
id: number
name: string
code: string
parent_id?: number
level: number
description?: string
weight: number
problem_count: number
created_at: string
}
// 题目
export interface Problem {
id: number
title: string
content: string
content_type: 'text' | 'markdown' | 'latex'
options: ProblemOptions
correct_answer: string
solution?: string
solution_type?: string
difficulty: number
source_type?: string
source_year?: number
source_detail?: string
estimated_time?: number
success_rate?: number
is_published: boolean
is_deleted: boolean
review_status: 'pending' | 'approved' | 'rejected'
total_attempts: number
correct_attempts: number
accuracy_rate: number
created_by: number
created_at: string
updated_at?: string
knowledge_points?: KnowledgePoint[]
creator?: {
id: number
username: string
full_name?: string
}
}
// 创建题目请求
export interface ProblemCreateRequest {
title: string
content: string
content_type?: string
options: ProblemOptions
correct_answer: string
solution?: string
solution_type?: string
difficulty?: number
source_type?: string
source_year?: number
source_detail?: string
estimated_time?: number
knowledge_point_ids?: number[]
is_published?: boolean
}
// 更新题目请求
export interface ProblemUpdateRequest {
title?: string
content?: string
content_type?: string
options?: ProblemOptions
correct_answer?: string
solution?: string
solution_type?: string
difficulty?: number
source_type?: string
source_year?: number
source_detail?: string
estimated_time?: number
knowledge_point_ids?: number[]
is_published?: boolean
}
// 题目过滤条件
export interface ProblemFilter {
difficulty?: number[]
source_type?: string
source_year?: number
knowledge_point_id?: number
is_published?: boolean
search?: string
skip?: number
limit?: number
sort_by?: string
sort_order?: 'asc' | 'desc'
}
// 题目列表响应
export interface ProblemListResponse {
problems: Problem[]
total: number
page: number
page_size: number
}
// 题目统计
export interface ProblemStats {
total_problems: number
published_problems: number
by_difficulty: Record<number, number>
by_source: Record<string, number>
avg_accuracy: number
}
// 练习题目(隐藏答案)
export interface PracticeProblem {
id: number
title: string
content: string
content_type: string
options: ProblemOptions
difficulty: number
estimated_time?: number
knowledge_points?: KnowledgePoint[]
}
EOF
12. 创建Pinia状态管理
$ mkdir -p src/stores
$ cat > src/stores/user.ts << 'EOF'
/**
* 用户状态管理
*/
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { UserInfo } from '@/types/auth'
import * as authApi from '@/api/auth'
import { ElMessage } from 'element-plus'
export const useUserStore = defineStore('user', () => {
// 状态
const token = ref<string>('')
const userInfo = ref<UserInfo | null>(null)
const isLoggedIn = computed(() => !!token.value)
const isAdmin = computed(() => userInfo.value?.role === 'admin')
const isTeacher = computed(() => userInfo.value?.role === 'teacher')
const isStudent = computed(() => userInfo.value?.role === 'student')
// 从本地存储加载状态
function loadFromStorage() {
const savedToken = localStorage.getItem('access_token')
const savedUser = localStorage.getItem('user_info')
if (savedToken) {
token.value = savedToken
}
if (savedUser) {
try {
userInfo.value = JSON.parse(savedUser)
} catch (error) {
console.error('解析用户信息失败:', error)
localStorage.removeItem('user_info')
}
}
}
// 保存到本地存储
function saveToStorage() {
if (token.value) {
localStorage.setItem('access_token', token.value)
}
if (userInfo.value) {
localStorage.setItem('user_info', JSON.stringify(userInfo.value))
}
}
// 清除本地存储
function clearStorage() {
localStorage.removeItem('access_token')
localStorage.removeItem('user_info')
}
// 登录
async function login(username: string, password: string) {
try {
const response = await authApi.login({ username, password })
token.value = response.access_token
await fetchUserInfo()
ElMessage.success('登录成功')
return true
} catch (error: any) {
ElMessage.error(error.message || '登录失败')
return false
}
}
// 注册
async function register(data: {
username: string
password: string
email?: string
full_name?: string
role?: string
grade?: string
school?: string
}) {
try {
const user = await authApi.register(data)
userInfo.value = user
saveToStorage()
ElMessage.success('注册成功')
return true
} catch (error: any) {
ElMessage.error(error.message || '注册失败')
return false
}
}
// 获取用户信息
async function fetchUserInfo() {
try {
const user = await authApi.getCurrentUser()
userInfo.value = user
saveToStorage()
} catch (error) {
console.error('获取用户信息失败:', error)
// 获取失败时不清理token,可能是网络问题
}
}
// 登出
function logout() {
token.value = ''
userInfo.value = null
clearStorage()
// 调用后端登出API(可选)
authApi.logout().catch(() => {
// 忽略登出API错误
})
}
// 修改密码
async function changePassword(currentPassword: string, newPassword: string) {
try {
await authApi.changePassword({
current_password: currentPassword,
new_password: newPassword
})
ElMessage.success('密码修改成功')
return true
} catch (error: any) {
ElMessage.error(error.message || '密码修改失败')
return false
}
}
// 检查用户名是否可用
async function checkUsernameAvailable(username: string): Promise<boolean> {
try {
const response = await authApi.checkUsername(username)
return response.available
} catch (error) {
console.error('检查用户名失败:', error)
return false
}
}
// 检查邮箱是否可用
async function checkEmailAvailable(email: string): Promise<boolean> {
try {
const response = await authApi.checkEmail(email)
return response.available
} catch (error) {
console.error('检查邮箱失败:', error)
return false
}
}
// 初始化
loadFromStorage()
// 如果已有token,尝试获取用户信息
if (token.value && !userInfo.value) {
fetchUserInfo().catch(() => {
// 如果获取失败,可能是token过期,清除token
logout()
})
}
return {
// 状态
token,
userInfo,
isLoggedIn,
isAdmin,
isTeacher,
isStudent,
// 操作
login,
register,
logout,
fetchUserInfo,
changePassword,
checkUsernameAvailable,
checkEmailAvailable,
}
}, {
persist: {
key: 'user-store',
paths: ['token', 'userInfo'],
},
})
EOF
下午 15:00-16:30 | 路由和布局组件
13. 配置Vue Router路由
# 先删除默认的路由文件
$ rm src/router/index.ts
$ cat > src/router/index.ts << 'EOF'
import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { ElMessage } from 'element-plus'
// 路由组件(使用懒加载)
const Login = () => import('@/views/auth/Login.vue')
const Register = () => import('@/views/auth/Register.vue')
const Layout = () => import('@/layouts/MainLayout.vue')
const Dashboard = () => import('@/views/dashboard/Dashboard.vue')
const ProblemList = () => import('@/views/problem/ProblemList.vue')
const ProblemDetail = () => import('@/views/problem/ProblemDetail.vue')
const ProblemCreate = () => import('@/views/problem/ProblemCreate.vue')
const ProblemEdit = () => import('@/views/problem/ProblemEdit.vue')
const Profile = () => import('@/views/user/Profile.vue')
const NotFound = () => import('@/views/error/NotFound.vue')
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
redirect: '/dashboard',
},
{
path: '/login',
name: 'Login',
component: Login,
meta: {
title: '登录',
requiresAuth: false,
hideNavbar: true,
},
},
{
path: '/register',
name: 'Register',
component: Register,
meta: {
title: '注册',
requiresAuth: false,
hideNavbar: true,
},
},
{
path: '/',
component: Layout,
meta: {
requiresAuth: true,
},
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: Dashboard,
meta: {
title: '仪表板',
icon: 'Odometer',
requiresAuth: true,
},
},
{
path: 'problems',
name: 'ProblemList',
component: ProblemList,
meta: {
title: '题库管理',
icon: 'Collection',
requiresAuth: true,
},
},
{
path: 'problems/create',
name: 'ProblemCreate',
component: ProblemCreate,
meta: {
title: '创建题目',
requiresAuth: true,
requiresTeacherOrAdmin: true,
},
},
{
path: 'problems/:id',
name: 'ProblemDetail',
component: ProblemDetail,
meta: {
title: '题目详情',
requiresAuth: true,
},
},
{
path: 'problems/:id/edit',
name: 'ProblemEdit',
component: ProblemEdit,
meta: {
title: '编辑题目',
requiresAuth: true,
requiresTeacherOrAdmin: true,
},
},
{
path: 'profile',
name: 'Profile',
component: Profile,
meta: {
title: '个人中心',
icon: 'User',
requiresAuth: true,
},
},
],
},
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: NotFound,
meta: {
title: '页面不存在',
hideNavbar: true,
},
},
],
})
// 全局前置守卫
router.beforeEach(async (to, from, next) => {
const userStore = useUserStore()
// 设置页面标题
const title = to.meta.title as string || '奥赛AI平台'
document.title = `${title} - 奥赛AI平台`
// 检查是否需要认证
if (to.meta.requiresAuth) {
if (!userStore.isLoggedIn) {
ElMessage.warning('请先登录')
next('/login')
return
}
// 检查是否需要老师或管理员权限
if (to.meta.requiresTeacherOrAdmin) {
if (!userStore.isTeacher && !userStore.isAdmin) {
ElMessage.warning('需要老师或管理员权限')
next('/dashboard')
return
}
}
// 检查是否需要管理员权限
if (to.meta.requiresAdmin) {
if (!userStore.isAdmin) {
ElMessage.warning('需要管理员权限')
next('/dashboard')
return
}
}
// 如果用户信息未加载,尝试加载
if (!userStore.userInfo) {
try {
await userStore.fetchUserInfo()
} catch (error) {
console.error('加载用户信息失败:', error)
}
}
}
// 如果已登录,访问登录/注册页则重定向到首页
if ((to.path === '/login' || to.path === '/register') && userStore.isLoggedIn) {
next('/dashboard')
return
}
next()
})
// 全局后置钩子
router.afterEach((to) => {
// 回到页面顶部
window.scrollTo(0, 0)
// 开发环境日志
if (import.meta.env.DEV) {
console.log(`🛣️ 路由跳转: ${from.path} -> ${to.path}`)
}
})
export default router
EOF
14. 创建主布局组件
$ mkdir -p src/layouts
$ cat > src/layouts/MainLayout.vue << 'EOF'
<template>
<div class="main-layout">
<!-- 侧边栏导航 -->
<el-container class="layout-container">
<!-- 侧边栏 -->
<el-aside :width="sidebarWidth" class="sidebar">
<div class="sidebar-header">
<div class="logo" @click="$router.push('/dashboard')">
<span class="logo-icon">🧮</span>
<span class="logo-text" v-show="!isCollapsed">奥赛AI平台</span>
</div>
<el-button
type="text"
class="collapse-btn"
@click="toggleSidebar"
>
<el-icon>
<component :is="isCollapsed ? 'Expand' : 'Fold'" />
</el-icon>
</el-button>
</div>
<!-- 导航菜单 -->
<el-menu
:default-active="activeMenu"
class="sidebar-menu"
:collapse="isCollapsed"
:collapse-transition="false"
router
>
<template v-for="route in sidebarRoutes" :key="route.name">
<el-menu-item :index="route.path">
<el-icon>
<component :is="route.meta.icon" />
</el-icon>
<template #title>{{ route.meta.title }}</template>
</el-menu-item>
</template>
</el-menu>
<!-- 用户信息 -->
<div class="sidebar-footer" v-show="!isCollapsed">
<div class="user-info">
<el-avatar :size="36" :src="userStore.userInfo?.avatar">
{{ userStore.userInfo?.username?.charAt(0).toUpperCase() }}
</el-avatar>
<div class="user-details">
<div class="username">{{ userStore.userInfo?.username }}</div>
<div class="role">{{ roleText }}</div>
</div>
<el-dropdown @command="handleUserCommand">
<el-button type="text" class="user-menu-btn">
<el-icon><More /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="profile">
<el-icon><User /></el-icon>
个人中心
</el-dropdown-item>
<el-dropdown-item command="logout" divided>
<el-icon><SwitchButton /></el-icon>
退出登录
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</el-aside>
<!-- 主内容区 -->
<el-container class="main-container">
<!-- 顶部栏 -->
<el-header class="main-header">
<div class="header-left">
<el-breadcrumb separator="/">
<el-breadcrumb-item :to="{ path: '/dashboard' }">首页</el-breadcrumb-item>
<el-breadcrumb-item v-for="item in breadcrumb" :key="item.path">
{{ item.meta.title }}
</el-breadcrumb-item>
</el-breadcrumb>
</div>
<div class="header-right">
<!-- 全局搜索 -->
<el-input
v-model="searchKeyword"
placeholder="搜索题目、知识点..."
class="search-input"
:style="{ width: isSearchExpanded ? '200px' : '120px' }"
@focus="isSearchExpanded = true"
@blur="isSearchExpanded = false"
@keyup.enter="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<!-- 通知 -->
<el-dropdown class="notification-dropdown">
<el-badge :value="3" class="notification-badge">
<el-button type="text">
<el-icon :size="20"><Bell /></el-icon>
</el-button>
</el-badge>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item>你有新的练习推荐</el-dropdown-item>
<el-dropdown-item>错题需要复习</el-dropdown-item>
<el-dropdown-item>系统更新通知</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<!-- 主题切换 -->
<el-tooltip content="切换主题">
<el-button type="text" @click="toggleTheme">
<el-icon :size="20">
<component :is="isDarkTheme ? 'Sunny' : 'Moon'" />
</el-icon>
</el-button>
</el-tooltip>
</div>
</el-header>
<!-- 页面内容 -->
<el-main class="main-content">
<router-view v-slot="{ Component }">
<transition name="fade-slide" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</el-main>
<!-- 页脚 -->
<el-footer class="main-footer">
<div class="footer-content">
<span>© 2024 奥赛AI平台 v{{ appVersion }}</span>
<div class="footer-links">
<el-link type="info" :underline="false" @click="$router.push('/about')">关于我们</el-link>
<el-link type="info" :underline="false" @click="$router.push('/privacy')">隐私政策</el-link>
<el-link type="info" :underline="false" @click="$router.push('/terms')">使用条款</el-link>
</div>
</div>
</el-footer>
</el-container>
</el-container>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
import {
Odometer,
Collection,
User as UserIcon,
More,
SwitchButton,
Search,
Bell,
Sunny,
Moon,
Expand,
Fold
} from '@element-plus/icons-vue'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
// 侧边栏状态
const isCollapsed = ref(false)
const sidebarWidth = computed(() => isCollapsed.value ? '64px' : '220px')
// 搜索相关
const searchKeyword = ref('')
const isSearchExpanded = ref(false)
// 主题相关
const isDarkTheme = ref(false)
// 应用版本
const appVersion = import.meta.env.VITE_APP_VERSION || '1.0.0'
// 当前激活的菜单
const activeMenu = computed(() => route.path)
// 面包屑导航
const breadcrumb = computed(() => {
const matched = route.matched.filter(item => item.meta && item.meta.title)
return matched.slice(1) // 去掉根路由
})
// 侧边栏路由(过滤掉没有图标的)
const sidebarRoutes = computed(() => {
const routes = router.getRoutes()
return routes.filter(route =>
route.meta?.icon &&
!route.meta?.hideNavbar &&
route.meta?.requiresAuth
)
})
// 用户角色文本
const roleText = computed(() => {
const role = userStore.userInfo?.role
const roleMap: Record<string, string> = {
student: '学生',
teacher: '老师',
admin: '管理员',
parent: '家长'
}
return roleMap[role || 'student']
})
// 切换侧边栏
const toggleSidebar = () => {
isCollapsed.value = !isCollapsed.value
}
// 处理用户菜单命令
const handleUserCommand = (command: string) => {
switch (command) {
case 'profile':
router.push('/profile')
break
case 'logout':
userStore.logout()
router.push('/login')
break
}
}
// 处理搜索
const handleSearch = () => {
if (searchKeyword.value.trim()) {
router.push({
path: '/problems',
query: { search: searchKeyword.value.trim() }
})
searchKeyword.value = ''
}
}
// 切换主题
const toggleTheme = () => {
isDarkTheme.value = !isDarkTheme.value
const html = document.documentElement
if (isDarkTheme.value) {
html.classList.add('dark')
} else {
html.classList.remove('dark')
}
}
// 监听路由变化,收起搜索框
watch(() => route.path, () => {
isSearchExpanded.value = false
})
</script>
<style lang="scss" scoped>
.main-layout {
height: 100vh;
overflow: hidden;
}
.layout-container {
height: 100%;
}
// 侧边栏样式
.sidebar {
background: linear-gradient(180deg, #304156 0%, #263445 100%);
border-right: 1px solid var(--el-border-color);
transition: width 0.3s ease;
display: flex;
flex-direction: column;
overflow: hidden;
.sidebar-header {
height: 60px;
padding: 0 20px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
.logo {
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
user-select: none;
.logo-icon {
font-size: 24px;
}
.logo-text {
color: white;
font-size: 18px;
font-weight: 600;
white-space: nowrap;
}
}
.collapse-btn {
color: rgba(255, 255, 255, 0.7);
padding: 8px;
&:hover {
color: white;
background: rgba(255, 255, 255, 0.1);
}
}
}
.sidebar-menu {
flex: 1;
border-right: none;
background: transparent;
:deep(.el-menu-item) {
color: rgba(255, 255, 255, 0.7);
height: 56px;
margin: 4px 12px;
border-radius: 8px;
&:hover {
background: rgba(255, 255, 255, 0.1);
color: white;
}
&.is-active {
background: var(--el-color-primary);
color: white;
&:hover {
background: var(--el-color-primary-light-3);
}
}
.el-icon {
font-size: 18px;
margin-right: 12px;
}
}
}
.sidebar-footer {
padding: 20px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
.user-info {
display: flex;
align-items: center;
gap: 12px;
.user-details {
flex: 1;
.username {
color: white;
font-weight: 500;
font-size: 14px;
margin-bottom: 2px;
}
.role {
color: rgba(255, 255, 255, 0.6);
font-size: 12px;
}
}
.user-menu-btn {
color: rgba(255, 255, 255, 0.7);
padding: 4px;
&:hover {
color: white;
}
}
}
}
}
// 主内容区样式
.main-container {
display: flex;
flex-direction: column;
overflow: hidden;
}
.main-header {
height: 60px;
padding: 0 24px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--el-border-color);
background: white;
.header-left {
.el-breadcrumb {
font-size: 14px;
}
}
.header-right {
display: flex;
align-items: center;
gap: 16px;
.search-input {
transition: width 0.3s ease;
:deep(.el-input__wrapper) {
border-radius: 16px;
padding-left: 12px;
padding-right: 12px;
}
}
.notification-dropdown {
.notification-badge {
:deep(.el-badge__content) {
transform: translate(50%, -50%);
}
}
}
.el-button {
color: var(--el-text-color-regular);
&:hover {
color: var(--el-color-primary);
}
}
}
}
.main-content {
flex: 1;
padding: 24px;
background: var(--el-bg-color-page);
overflow-y: auto;
}
.main-footer {
height: 48px;
padding: 0 24px;
display: flex;
align-items: center;
justify-content: center;
border-top: 1px solid var(--el-border-color);
background: white;
font-size: 12px;
color: var(--el-text-color-secondary);
.footer-content {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
.footer-links {
display: flex;
gap: 24px;
.el-link {
font-size: 12px;
}
}
}
}
// 过渡动画
.fade-slide-enter-active,
.fade-slide-leave-active {
transition: all 0.3s ease;
}
.fade-slide-enter-from {
opacity: 0;
transform: translateY(10px);
}
.fade-slide-leave-to {
opacity: 0;
transform: translateY(-10px);
}
// 暗色模式适配
:global(.dark) {
.main-header,
.main-footer {
background: var(--el-bg-color);
}
}
</style>
EOF
下午 16:30-17:30 | 创建核心页面组件
15. 创建登录页面
$ mkdir -p src/views/auth
$ cat > src/views/auth/Login.vue << 'EOF'
<template>
<div class="login-container">
<div class="login-wrapper">
<!-- 左侧背景 -->
<div class="login-left">
<div class="login-hero">
<div class="hero-icon">🧮</div>
<h1 class="hero-title">奥赛AI平台</h1>
<p class="hero-subtitle">智能数学奥赛学习系统</p>
<div class="hero-features">
<div class="feature-item">
<el-icon><Reading /></el-icon>
<span>海量奥赛题库</span>
</div>
<div class="feature-item">
<el-icon><TrendCharts /></el-icon>
<span>智能学习分析</span>
</div>
<div class="feature-item">
<el-icon><MagicStick /></el-icon>
<span>个性化推荐</span>
</div>
</div>
</div>
</div>
<!-- 右侧登录表单 -->
<div class="login-right">
<div class="login-form-wrapper">
<div class="login-header">
<h2>欢迎回来</h2>
<p>请输入您的账号密码登录</p>
</div>
<el-form
ref="loginFormRef"
:model="loginForm"
:rules="loginRules"
class="login-form"
@keyup.enter="handleLogin"
>
<el-form-item prop="username">
<el-input
v-model="loginForm.username"
placeholder="用户名"
size="large"
:prefix-icon="User"
/>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="loginForm.password"
type="password"
placeholder="密码"
size="large"
:prefix-icon="Lock"
show-password
/>
</el-form-item>
<div class="form-options">
<el-checkbox v-model="rememberMe">记住我</el-checkbox>
<el-link type="primary" :underline="false" @click="$router.push('/forgot-password')">
忘记密码?
</el-link>
</div>
<el-form-item>
<el-button
type="primary"
size="large"
:loading="loading"
@click="handleLogin"
class="login-btn"
>
登录
</el-button>
</el-form-item>
<div class="login-divider">
<span>其他登录方式</span>
</div>
<div class="social-login">
<el-tooltip content="开发中" placement="top">
<el-button class="social-btn" circle>
<el-icon><ChatDotRound /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip content="开发中" placement="top">
<el-button class="social-btn" circle>
<svg-icon name="wechat" />
</el-button>
</el-tooltip>
<el-tooltip content="开发中" placement="top">
<el-button class="social-btn" circle>
<el-icon><Message /></el-icon>
</el-button>
</el-tooltip>
</div>
<div class="register-link">
还没有账号?
<el-link type="primary" :underline="false" @click="$router.push('/register')">
立即注册
</el-link>
</div>
</el-form>
<!-- 演示账号提示 -->
<el-alert
v-if="showDemoTips"
title="演示账号"
type="info"
:closable="false"
show-icon
class="demo-alert"
>
<p>用户名: <code>demo_student</code> 密码: <code>password</code></p>
<p>用户名: <code>demo_teacher</code> 密码: <code>password</code></p>
<p>用户名: <code>admin</code> 密码: <code>admin123</code></p>
</el-alert>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { User, Lock, Reading, TrendCharts, MagicStick, ChatDotRound, Message } from '@element-plus/icons-vue'
import { useUserStore } from '@/stores/user'
const router = useRouter()
const userStore = useUserStore()
// 表单引用
const loginFormRef = ref<FormInstance>()
// 表单数据
const loginForm = reactive({
username: '',
password: '',
})
// 表单验证规则
const loginRules: FormRules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, max: 50, message: '用户名长度在 3 到 50 个字符', trigger: 'blur' },
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, max: 100, message: '密码长度在 6 到 100 个字符', trigger: 'blur' },
],
}
// 状态
const loading = ref(false)
const rememberMe = ref(false)
const showDemoTips = computed(() => import.meta.env.DEV)
// 处理登录
const handleLogin = async () => {
if (!loginFormRef.value) return
try {
await loginFormRef.value.validate()
loading.value = true
const success = await userStore.login(loginForm.username, loginForm.password)
if (success) {
ElMessage.success('登录成功')
router.push('/dashboard')
}
} catch (error: any) {
if (error?.errors) {
// 表单验证错误,不需要处理
} else {
console.error('登录错误:', error)
}
} finally {
loading.value = false
}
}
// 加载记住的账号
const loadRememberedAccount = () => {
const remembered = localStorage.getItem('remembered_account')
if (remembered) {
try {
const account = JSON.parse(remembered)
loginForm.username = account.username || ''
rememberMe.value = true
} catch (error) {
console.error('加载记住的账号失败:', error)
}
}
}
// 保存记住的账号
const saveRememberedAccount = () => {
if (rememberMe.value && loginForm.username) {
localStorage.setItem('remembered_account', JSON.stringify({
username: loginForm.username,
}))
} else {
localStorage.removeItem('remembered_account')
}
}
// 页面加载时
onMounted(() => {
loadRememberedAccount()
// 如果已登录,跳转到首页
if (userStore.isLoggedIn) {
router.push('/dashboard')
}
})
// 监听记住我变化
watch(rememberMe, saveRememberedAccount)
</script>
<style lang="scss" scoped>
.login-container {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.login-wrapper {
width: 100%;
max-width: 1000px;
background: white;
border-radius: 20px;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.1);
display: flex;
min-height: 600px;
}
.login-left {
flex: 1;
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
padding: 60px 40px;
display: flex;
align-items: center;
justify-content: center;
color: white;
.login-hero {
text-align: center;
max-width: 400px;
.hero-icon {
font-size: 80px;
margin-bottom: 24px;
animation: float 3s ease-in-out infinite;
}
.hero-title {
font-size: 36px;
font-weight: 700;
margin-bottom: 12px;
background: linear-gradient(to right, #fff, #e0e7ff);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.hero-subtitle {
font-size: 18px;
color: rgba(255, 255, 255, 0.8);
margin-bottom: 40px;
}
.hero-features {
.feature-item {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin: 16px 0;
font-size: 16px;
.el-icon {
font-size: 20px;
color: #93c5fd;
}
}
}
}
}
.login-right {
flex: 1;
padding: 60px 40px;
display: flex;
align-items: center;
justify-content: center;
}
.login-form-wrapper {
width: 100%;
max-width: 400px;
.login-header {
text-align: center;
margin-bottom: 40px;
h2 {
font-size: 32px;
font-weight: 700;
color: #1f2937;
margin-bottom: 8px;
}
p {
color: #6b7280;
font-size: 16px;
}
}
.login-form {
.form-options {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
:deep(.el-checkbox__label) {
color: #6b7280;
}
}
.login-btn {
width: 100%;
height: 48px;
font-size: 16px;
font-weight: 500;
}
.login-divider {
position: relative;
text-align: center;
margin: 32px 0;
color: #9ca3af;
&::before,
&::after {
content: '';
position: absolute;
top: 50%;
width: 45%;
height: 1px;
background: #e5e7eb;
}
&::before {
left: 0;
}
&::after {
right: 0;
}
}
.social-login {
display: flex;
justify-content: center;
gap: 16px;
margin-bottom: 32px;
.social-btn {
width: 48px;
height: 48px;
border: 1px solid #e5e7eb;
background: white;
&:hover {
border-color: #3b82f6;
color: #3b82f6;
background: #eff6ff;
}
.el-icon {
font-size: 20px;
}
}
}
.register-link {
text-align: center;
color: #6b7280;
font-size: 14px;
.el-link {
margin-left: 4px;
}
}
}
.demo-alert {
margin-top: 24px;
border-radius: 8px;
p {
margin: 4px 0;
font-size: 13px;
code {
background: #f3f4f6;
padding: 2px 6px;
border-radius: 4px;
font-family: monospace;
}
}
}
}
@keyframes float {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}
// 响应式设计
@media (max-width: 768px) {
.login-wrapper {
flex-direction: column;
max-width: 400px;
}
.login-left {
padding: 40px 20px;
.login-hero {
.hero-icon {
font-size: 60px;
}
.hero-title {
font-size: 28px;
}
.hero-subtitle {
font-size: 16px;
}
}
}
.login-right {
padding: 40px 20px;
}
}
</style>
EOF
16. 创建注册页面
$ cat > src/views/auth/Register.vue << 'EOF'
<template>
<div class="register-container">
<div class="register-wrapper">
<!-- 左侧表单 -->
<div class="register-left">
<div class="register-form-wrapper">
<div class="register-header">
<div class="back-link" @click="$router.push('/login')">
<el-icon><ArrowLeft /></el-icon>
<span>返回登录</span>
</div>
<h2>创建新账号</h2>
<p>加入奥赛AI平台,开启智能学习之旅</p>
</div>
<el-form
ref="registerFormRef"
:model="registerForm"
:rules="registerRules"
class="register-form"
@keyup.enter="handleRegister"
>
<!-- 用户名 -->
<el-form-item prop="username">
<el-input
v-model="registerForm.username"
placeholder="用户名"
size="large"
:prefix-icon="User"
@blur="checkUsername"
>
<template #append>
<el-button
v-if="usernameChecked === false"
type="danger"
text
size="small"
>
已存在
</el-button>
<el-button
v-else-if="usernameChecked === true"
type="success"
text
size="small"
>
可用
</el-button>
</template>
</el-input>
</el-form-item>
<!-- 邮箱 -->
<el-form-item prop="email">
<el-input
v-model="registerForm.email"
placeholder="邮箱(可选)"
size="large"
:prefix-icon="Message"
@blur="checkEmail"
>
<template #append>
<el-button
v-if="emailChecked === false"
type="danger"
text
size="small"
>
已使用
</el-button>
<el-button
v-else-if="emailChecked === true && registerForm.email"
type="success"
text
size="small"
>
可用
</el-button>
</template>
</el-input>
</el-form-item>
<!-- 密码 -->
<el-form-item prop="password">
<el-input
v-model="registerForm.password"
type="password"
placeholder="密码"
size="large"
:prefix-icon="Lock"
show-password
/>
<div class="password-strength">
<div class="strength-bar" :class="passwordStrengthClass"></div>
<div class="strength-text">{{ passwordStrengthText }}</div>
</div>
</el-form-item>
<!-- 确认密码 -->
<el-form-item prop="confirmPassword">
<el-input
v-model="registerForm.confirmPassword"
type="password"
placeholder="确认密码"
size="large"
:prefix-icon="Lock"
show-password
/>
</el-form-item>
<!-- 姓名 -->
<el-form-item prop="fullName">
<el-input
v-model="registerForm.fullName"
placeholder="姓名(可选)"
size="large"
:prefix-icon="UserFilled"
/>
</el-form-item>
<!-- 角色选择 -->
<el-form-item prop="role">
<el-radio-group v-model="registerForm.role">
<el-radio-button value="student">学生</el-radio-button>
<el-radio-button value="teacher">老师</el-radio-button>
<el-radio-button value="parent">家长</el-radio-button>
</el-radio-group>
</el-form-item>
<!-- 学生额外信息 -->
<template v-if="registerForm.role === 'student'">
<el-form-item prop="grade">
<el-select
v-model="registerForm.grade"
placeholder="选择年级"
size="large"
style="width: 100%"
>
<el-option label="小学" value="primary">
<div class="grade-option">
<span class="grade-label">小学</span>
<span class="grade-years">1-6年级</span>
</div>
</el-option>
<el-option label="初中" value="junior">
<div class="grade-option">
<span class="grade-label">初中</span>
<span class="grade-years">7-9年级</span>
</div>
</el-option>
<el-option label="高中" value="senior">
<div class="grade-option">
<span class="grade-label">高中</span>
<span class="grade-years">10-12年级</span>
</div>
</el-option>
</el-select>
</el-form-item>
<el-form-item prop="school">
<el-input
v-model="registerForm.school"
placeholder="学校(可选)"
size="large"
:prefix-icon="School"
/>
</el-form-item>
</template>
<!-- 服务条款 -->
<el-form-item>
<el-checkbox v-model="agreedTerms" :required="true">
我已阅读并同意
<el-link type="primary" :underline="false" @click="showTerms = true">
《服务条款》
</el-link>
和
<el-link type="primary" :underline="false" @click="showPrivacy = true">
《隐私政策》
</el-link>
</el-checkbox>
</el-form-item>
<!-- 注册按钮 -->
<el-form-item>
<el-button
type="primary"
size="large"
:loading="loading"
:disabled="!agreedTerms"
@click="handleRegister"
class="register-btn"
>
立即注册
</el-button>
</el-form-item>
<!-- 登录链接 -->
<div class="login-link">
已有账号?
<el-link type="primary" :underline="false" @click="$router.push('/login')">
立即登录
</el-link>
</div>
</el-form>
</div>
</div>
<!-- 右侧说明 -->
<div class="register-right">
<div class="register-info">
<div class="info-icon">🎓</div>
<h3 class="info-title">为什么加入我们?</h3>
<div class="info-list">
<div class="info-item">
<el-icon><Trophy /></el-icon>
<div>
<h4>专业奥赛题库</h4>
<p>收录AMC8、迎春杯、华杯赛等历年真题</p>
</div>
</div>
<div class="info-item">
<el-icon><TrendCharts /></el-icon>
<div>
<h4>智能学习分析</h4>
<p>基于AI分析学习弱点,个性化推荐题目</p>
</div>
</div>
<div class="info-item">
<el-icon><Timer /></el-icon>
<div>
<h4>实时学习报告</h4>
<p>生成详细的学习报告,随时掌握进度</p>
</div>
</div>
<div class="info-item">
<el-icon><User /></el-icon>
<div>
<h4>个性化学习路径</h4>
<p>根据你的水平和目标制定学习计划</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 服务条款对话框 -->
<el-dialog
v-model="showTerms"
title="服务条款"
width="600px"
>
<div class="terms-content">
<!-- 服务条款内容 -->
<p>这里放置服务条款内容...</p>
</div>
<template #footer>
<el-button type="primary" @click="showTerms = false">已阅读</el-button>
</template>
</el-dialog>
<!-- 隐私政策对话框 -->
<el-dialog
v-model="showPrivacy"
title="隐私政策"
width="600px"
>
<div class="privacy-content">
<!-- 隐私政策内容 -->
<p>这里放置隐私政策内容...</p>
</div>
<template #footer>
<el-button type="primary" @click="showPrivacy = false">已阅读</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import {
User,
Lock,
Message,
UserFilled,
School,
Trophy,
TrendCharts,
Timer,
ArrowLeft
} from '@element-plus/icons-vue'
import { useUserStore } from '@/stores/user'
const router = useRouter()
const userStore = useUserStore()
// 表单引用
const registerFormRef = ref<FormInstance>()
// 表单数据
const registerForm = reactive({
username: '',
email: '',
password: '',
confirmPassword: '',
fullName: '',
role: 'student',
grade: '',
school: '',
})
// 状态
const loading = ref(false)
const agreedTerms = ref(false)
const showTerms = ref(false)
const showPrivacy = ref(false)
const usernameChecked = ref<boolean | null>(null)
const emailChecked = ref<boolean | null>(null)
// 密码强度计算
const passwordStrength = computed(() => {
const password = registerForm.password
if (!password) return 0
let strength = 0
if (password.length >= 8) strength++
if (/[a-z]/.test(password)) strength++
if (/[A-Z]/.test(password)) strength++
if (/[0-9]/.test(password)) strength++
if (/[^a-zA-Z0-9]/.test(password)) strength++
return strength
})
const passwordStrengthClass = computed(() => {
const strength = passwordStrength.value
if (strength <= 2) return 'weak'
if (strength <= 4) return 'medium'
return 'strong'
})
const passwordStrengthText = computed(() => {
const strength = passwordStrength.value
if (strength <= 2) return '弱'
if (strength <= 4) return '中'
return '强'
})
// 表单验证规则
const registerRules: FormRules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, max: 50, message: '用户名长度在 3 到 50 个字符', trigger: 'blur' },
{ pattern: /^[a-zA-Z0-9_]+$/, message: '用户名只能包含字母、数字和下划线', trigger: 'blur' },
],
email: [
{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' },
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, max: 100, message: '密码长度在 6 到 100 个字符', trigger: 'blur' },
{
validator: (_, value, callback) => {
if (value && passwordStrength.value < 3) {
callback(new Error('密码强度不足,请包含大小写字母和数字'))
} else {
callback()
}
},
trigger: 'blur',
},
],
confirmPassword: [
{ required: true, message: '请确认密码', trigger: 'blur' },
{
validator: (_, value, callback) => {
if (value !== registerForm.password) {
callback(new Error('两次输入密码不一致'))
} else {
callback()
}
},
trigger: 'blur',
},
],
role: [
{ required: true, message: '请选择角色', trigger: 'change' },
],
}
// 检查用户名是否可用
const checkUsername = async () => {
if (!registerForm.username || registerForm.username.length < 3) return
try {
const available = await userStore.checkUsernameAvailable(registerForm.username)
usernameChecked.value = available
} catch (error) {
console.error('检查用户名失败:', error)
}
}
// 检查邮箱是否可用
const checkEmail = async () => {
if (!registerForm.email || !registerForm.email.includes('@')) return
try {
const available = await userStore.checkEmailAvailable(registerForm.email)
emailChecked.value = available
} catch (error) {
console.error('检查邮箱失败:', error)
}
}
// 处理注册
const handleRegister = async () => {
if (!registerFormRef.value) return
try {
await registerFormRef.value.validate()
if (!agreedTerms.value) {
ElMessage.warning('请同意服务条款和隐私政策')
return
}
if (usernameChecked.value === false) {
ElMessage.warning('用户名已被使用')
return
}
if (registerForm.email && emailChecked.value === false) {
ElMessage.warning('邮箱已被使用')
return
}
loading.value = true
const success = await userStore.register({
username: registerForm.username,
password: registerForm.password,
email: registerForm.email || undefined,
full_name: registerForm.fullName || undefined,
role: registerForm.role as any,
grade: registerForm.grade || undefined,
school: registerForm.school || undefined,
})
if (success) {
ElMessage.success('注册成功!')
router.push('/dashboard')
}
} catch (error: any) {
if (error?.errors) {
// 表单验证错误,不需要处理
} else {
console.error('注册错误:', error)
}
} finally {
loading.value = false
}
}
// 监听用户名变化,重置检查状态
watch(() => registerForm.username, () => {
usernameChecked.value = null
})
// 监听邮箱变化,重置检查状态
watch(() => registerForm.email, () => {
emailChecked.value = null
})
</script>
<style lang="scss" scoped>
.register-container {
min-height: 100vh;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.register-wrapper {
width: 100%;
max-width: 1200px;
background: white;
border-radius: 20px;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.1);
display: flex;
min-height: 700px;
}
.register-left {
flex: 1.2;
padding: 60px 40px;
display: flex;
align-items: center;
justify-content: center;
}
.register-form-wrapper {
width: 100%;
max-width: 500px;
.register-header {
margin-bottom: 40px;
.back-link {
display: inline-flex;
align-items: center;
gap: 8px;
color: #6b7280;
font-size: 14px;
margin-bottom: 20px;
cursor: pointer;
transition: color 0.2s;
&:hover {
color: #3b82f6;
}
}
h2 {
font-size: 32px;
font-weight: 700;
color: #1f2937;
margin-bottom: 8px;
}
p {
color: #6b7280;
font-size: 16px;
}
}
.register-form {
.password-strength {
margin-top: 8px;
.strength-bar {
height: 4px;
border-radius: 2px;
margin-bottom: 4px;
transition: all 0.3s;
&.weak {
width: 33%;
background: #ef4444;
}
&.medium {
width: 66%;
background: #f59e0b;
}
&.strong {
width: 100%;
background: #10b981;
}
}
.strength-text {
font-size: 12px;
color: #6b7280;
text-align: right;
}
}
.grade-option {
display: flex;
justify-content: space-between;
width: 100%;
.grade-label {
font-weight: 500;
}
.grade-years {
color: #6b7280;
font-size: 12px;
}
}
.register-btn {
width: 100%;
height: 48px;
font-size: 16px;
font-weight: 500;
}
.login-link {
text-align: center;
color: #6b7280;
font-size: 14px;
margin-top: 24px;
.el-link {
margin-left: 4px;
}
}
}
}
.register-right {
flex: 0.8;
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
padding: 60px 40px;
color: white;
display: flex;
align-items: center;
}
.register-info {
max-width: 400px;
.info-icon {
font-size: 80px;
margin-bottom: 24px;
text-align: center;
}
.info-title {
font-size: 28px;
font-weight: 700;
margin-bottom: 40px;
text-align: center;
}
.info-list {
.info-item {
display: flex;
align-items: flex-start;
gap: 16px;
margin-bottom: 32px;
.el-icon {
font-size: 24px;
color: #93c5fd;
margin-top: 4px;
flex-shrink: 0;
}
h4 {
font-size: 18px;
font-weight: 600;
margin-bottom: 4px;
}
p {
color: rgba(255, 255, 255, 0.8);
font-size: 14px;
line-height: 1.5;
}
}
}
}
.terms-content,
.privacy-content {
max-height: 400px;
overflow-y: auto;
padding: 20px;
line-height: 1.6;
}
// 响应式设计
@media (max-width: 992px) {
.register-wrapper {
flex-direction: column;
max-width: 600px;
}
.register-left {
padding: 40px 20px;
}
.register-right {
padding: 40px 20px;
}
}
@media (max-width: 576px) {
.register-form {
:deep(.el-radio-group) {
display: flex;
flex-direction: column;
gap: 8px;
.el-radio-button {
width: 100%;
.el-radio-button__inner {
width: 100%;
}
}
}
}
}
</style>
EOF
下午 17:30-18:00 | 题库管理界面和启动测试
17. 创建题库列表页面
$ mkdir -p src/views/problem
$ cat > src/views/problem/ProblemList.vue << 'EOF'
<template>
<div class="problem-list-container">
<!-- 页面标题和操作 -->
<div class="page-header">
<div class="header-left">
<h1 class="page-title">题库管理</h1>
<p class="page-subtitle">共 {{ pagination.total }} 道题目</p>
</div>
<div class="header-right">
<el-button
v-if="userStore.isTeacher || userStore.isAdmin"
type="primary"
@click="$router.push('/problems/create')"
>
<el-icon><Plus /></el-icon>
创建题目
</el-button>
<el-button type="info" @click="refreshList">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
</div>
</div>
<!-- 筛选条件 -->
<el-card class="filter-card">
<div class="filter-form">
<el-form :model="filterForm" inline>
<!-- 搜索 -->
<el-form-item>
<el-input
v-model="filterForm.search"
placeholder="搜索题目..."
clearable
@keyup.enter="handleFilter"
@clear="handleFilter"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</el-form-item>
<!-- 难度筛选 -->
<el-form-item>
<el-select
v-model="filterForm.difficulty"
placeholder="难度"
multiple
collapse-tags
collapse-tags-tooltip
@change="handleFilter"
>
<el-option label="入门" :value="1" />
<el-option label="基础" :value="2" />
<el-option label="中等" :value="3" />
<el-option label="困难" :value="4" />
<el-option label="挑战" :value="5" />
</el-select>
</el-form-item>
<!-- 来源筛选 -->
<el-form-item>
<el-select
v-model="filterForm.source_type"
placeholder="来源"
clearable
@change="handleFilter"
>
<el-option label="AMC8" value="AMC8" />
<el-option label="迎春杯" value="迎春杯" />
<el-option label="华杯赛" value="华杯赛" />
<el-option label="其他" value="其他" />
</el-select>
</el-form-item>
<!-- 知识点筛选 -->
<el-form-item>
<el-select
v-model="filterForm.knowledge_point_id"
placeholder="知识点"
clearable
filterable
@change="handleFilter"
>
<el-option
v-for="point in knowledgePoints"
:key="point.id"
:label="point.name"
:value="point.id"
/>
</el-select>
</el-form-item>
<!-- 状态筛选 -->
<el-form-item v-if="userStore.isTeacher || userStore.isAdmin">
<el-select
v-model="filterForm.is_published"
placeholder="状态"
clearable
@change="handleFilter"
>
<el-option label="已发布" :value="true" />
<el-option label="未发布" :value="false" />
</el-select>
</el-form-item>
<!-- 筛选按钮 -->
<el-form-item>
<el-button type="primary" @click="handleFilter">
筛选
</el-button>
<el-button @click="resetFilter">
重置
</el-button>
</el-form-item>
</el-form>
</div>
</el-card>
<!-- 题目列表 -->
<el-card class="list-card">
<!-- 表格 -->
<el-table
v-loading="loading"
:data="problems"
style="width: 100%"
@row-click="handleRowClick"
>
<!-- ID列 -->
<el-table-column prop="id" label="ID" width="80" sortable />
<!-- 标题列 -->
<el-table-column prop="title" label="标题" min-width="200">
<template #default="{ row }">
<div class="problem-title">
<span class="title-text">{{ row.title }}</span>
<el-tag
v-if="!row.is_published"
type="info"
size="small"
>
未发布
</el-tag>
</div>
</template>
</el-table-column>
<!-- 难度列 -->
<el-table-column prop="difficulty" label="难度" width="100">
<template #default="{ row }">
<el-rate
v-model="row.difficulty"
disabled
:max="5"
:colors="['#99A9BF', '#F7BA2A', '#FF9900']"
/>
</template>
</el-table-column>
<!-- 来源列 -->
<el-table-column prop="source_type" label="来源" width="120">
<template #default="{ row }">
<span v-if="row.source_type">
{{ row.source_type }}<span v-if="row.source_year">{{ row.source_year }}</span>
</span>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
<!-- 知识点列 -->
<el-table-column prop="knowledge_points" label="知识点" min-width="150">
<template #default="{ row }">
<div class="knowledge-tags">
<el-tag
v-for="point in row.knowledge_points"
:key="point.id"
size="small"
class="knowledge-tag"
>
{{ point.name }}
</el-tag>
<span v-if="!row.knowledge_points?.length" class="text-muted">-</span>
</div>
</template>
</el-table-column>
<!-- 统计列 -->
<el-table-column prop="accuracy_rate" label="正确率" width="100">
<template #default="{ row }">
<div class="accuracy-cell">
<span :class="getAccuracyClass(row.accuracy_rate)">
{{ row.accuracy_rate.toFixed(1) }}%
</span>
<div class="accuracy-bar">
<div
class="accuracy-fill"
:class="getAccuracyClass(row.accuracy_rate)"
:style="{ width: row.accuracy_rate + '%' }"
/>
</div>
</div>
</template>
</el-table-column>
<!-- 创建时间列 -->
<el-table-column prop="created_at" label="创建时间" width="180" sortable>
<template #default="{ row }">
{{ formatDate(row.created_at) }}
</template>
</el-table-column>
<!-- 操作列 -->
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<div class="action-buttons">
<el-button
type="primary"
size="small"
link
@click.stop="$router.push(`/problems/${row.id}`)"
>
查看
</el-button>
<el-button
v-if="userStore.isTeacher || userStore.isAdmin"
type="warning"
size="small"
link
@click.stop="$router.push(`/problems/${row.id}/edit`)"
>
编辑
</el-button>
<el-button
v-if="userStore.isAdmin"
type="danger"
size="small"
link
@click.stop="handleDelete(row)"
>
删除
</el-button>
<el-dropdown
v-if="userStore.isTeacher || userStore.isAdmin"
@command="(command) => handleAction(command, row)"
>
<el-button type="info" size="small" link>
更多
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-if="!row.is_published"
command="publish"
>
<el-icon><Promotion /></el-icon>
发布
</el-dropdown-item>
<el-dropdown-item
v-else
command="unpublish"
>
<el-icon><Remove /></el-icon>
取消发布
</el-dropdown-item>
<el-dropdown-item
command="copy"
divided
>
<el-icon><DocumentCopy /></el-icon>
复制题目
</el-dropdown-item>
<el-dropdown-item
command="stats"
>
<el-icon><DataAnalysis /></el-icon>
查看统计
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-wrapper">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.page_size"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</el-card>
<!-- 统计卡片 -->
<div class="stats-cards" v-if="problemStats">
<el-card class="stat-card">
<div class="stat-content">
<div class="stat-icon total">
<el-icon><Document /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ problemStats.total_problems }}</div>
<div class="stat-label">总题目数</div>
</div>
</div>
</el-card>
<el-card class="stat-card">
<div class="stat-content">
<div class="stat-icon published">
<el-icon><Check /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ problemStats.published_problems }}</div>
<div class="stat-label">已发布</div>
</div>
</div>
</el-card>
<el-card class="stat-card">
<div class="stat-content">
<div class="stat-icon accuracy">
<el-icon><TrendCharts /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ problemStats.avg_accuracy }}%</div>
<div class="stat-label">平均正确率</div>
</div>
</div>
</el-card>
<el-card class="stat-card">
<div class="stat-content">
<div class="stat-icon difficulty">
<el-icon><Star /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">
{{ Object.keys(problemStats.by_difficulty).length }}
</div>
<div class="stat-label">难度等级</div>
</div>
</div>
</el-card>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Plus,
Refresh,
Search,
Promotion,
Remove,
DocumentCopy,
DataAnalysis,
Document,
Check,
TrendCharts,
Star
} from '@element-plus/icons-vue'
import { useUserStore } from '@/stores/user'
import * as problemApi from '@/api/problem'
import type { Problem, ProblemFilter, ProblemStats } from '@/types/problem'
const router = useRouter()
const userStore = useUserStore()
// 状态
const loading = ref(false)
const problems = ref<Problem[]>([])
const knowledgePoints = ref<any[]>([])
const problemStats = ref<ProblemStats | null>(null)
// 分页
const pagination = reactive({
page: 1,
page_size: 20,
total: 0,
})
// 筛选表单
const filterForm = reactive({
search: '',
difficulty: [] as number[],
source_type: '',
knowledge_point_id: undefined as number | undefined,
is_published: userStore.isTeacher || userStore.isAdmin ? undefined : true,
})
// 加载题目列表
const loadProblems = async () => {
loading.value = true
try {
const params: ProblemFilter = {
skip: (pagination.page - 1) * pagination.page_size,
limit: pagination.page_size,
...filterForm,
}
// 移除空值
Object.keys(params).forEach(key => {
const value = (params as any)[key]
if (value === '' || value === undefined || (Array.isArray(value) && value.length === 0)) {
delete (params as any)[key]
}
})
const response = await problemApi.getProblems(params)
problems.value = response.problems || response
pagination.total = response.total || response.length || 0
} catch (error) {
console.error('加载题目列表失败:', error)
ElMessage.error('加载题目列表失败')
} finally {
loading.value = false
}
}
// 加载题目统计
const loadProblemStats = async () => {
try {
const stats = await problemApi.getProblemStats()
problemStats.value = stats
} catch (error) {
console.error('加载题目统计失败:', error)
}
}
// 加载知识点
const loadKnowledgePoints = async () => {
try {
// 这里需要实现知识点API
// knowledgePoints.value = await knowledgeApi.getKnowledgePoints()
} catch (error) {
console.error('加载知识点失败:', error)
}
}
// 处理筛选
const handleFilter = () => {
pagination.page = 1
loadProblems()
}
// 重置筛选
const resetFilter = () => {
filterForm.search = ''
filterForm.difficulty = []
filterForm.source_type = ''
filterForm.knowledge_point_id = undefined
filterForm.is_published = userStore.isTeacher || userStore.isAdmin ? undefined : true
handleFilter()
}
// 处理行点击
const handleRowClick = (row: Problem) => {
router.push(`/problems/${row.id}`)
}
// 处理删除
const handleDelete = async (problem: Problem) => {
try {
await ElMessageBox.confirm(
`确定要删除题目 "${problem.title}" 吗?`,
'确认删除',
{
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
}
)
await problemApi.deleteProblem(problem.id)
ElMessage.success('删除成功')
loadProblems()
} catch (error) {
// 用户取消或删除失败
}
}
// 处理更多操作
const handleAction = async (command: string, problem: Problem) => {
switch (command) {
case 'publish':
await problemApi.publishProblem(problem.id, true)
ElMessage.success('发布成功')
loadProblems()
break
case 'unpublish':
await problemApi.publishProblem(problem.id, false)
ElMessage.success('取消发布成功')
loadProblems()
break
case 'copy':
// 复制题目逻辑
ElMessage.info('复制功能开发中')
break
case 'stats':
// 查看统计逻辑
ElMessage.info('统计功能开发中')
break
}
}
// 处理分页大小变化
const handleSizeChange = (size: number) => {
pagination.page_size = size
loadProblems()
}
// 处理页码变化
const handleCurrentChange = (page: number) => {
pagination.page = page
loadProblems()
}
// 刷新列表
const refreshList = () => {
loadProblems()
loadProblemStats()
}
// 格式化日期
const formatDate = (dateString: string) => {
const date = new Date(dateString)
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
// 获取正确率颜色类
const getAccuracyClass = (accuracy: number) => {
if (accuracy >= 80) return 'accuracy-high'
if (accuracy >= 60) return 'accuracy-medium'
return 'accuracy-low'
}
// 页面加载时
onMounted(() => {
loadProblems()
loadProblemStats()
loadKnowledgePoints()
})
</script>
<style lang="scss" scoped>
.problem-list-container {
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
.header-left {
.page-title {
font-size: 24px;
font-weight: 600;
color: var(--el-text-color-primary);
margin-bottom: 8px;
}
.page-subtitle {
color: var(--el-text-color-secondary);
font-size: 14px;
}
}
}
.filter-card {
margin-bottom: 20px;
.filter-form {
:deep(.el-form-item) {
margin-bottom: 0;
margin-right: 16px;
}
}
}
.list-card {
.problem-title {
display: flex;
align-items: center;
gap: 8px;
.title-text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.knowledge-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
.knowledge-tag {
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.accuracy-cell {
display: flex;
flex-direction: column;
gap: 4px;
.accuracy-high {
color: var(--el-color-success);
}
.accuracy-medium {
color: var(--el-color-warning);
}
.accuracy-low {
color: var(--el-color-error);
}
.accuracy-bar {
width: 100%;
height: 4px;
background: var(--el-border-color-lighter);
border-radius: 2px;
overflow: hidden;
.accuracy-fill {
height: 100%;
transition: width 0.3s;
&.accuracy-high {
background: var(--el-color-success);
}
&.accuracy-medium {
background: var(--el-color-warning);
}
&.accuracy-low {
background: var(--el-color-error);
}
}
}
}
.action-buttons {
display: flex;
align-items: center;
gap: 8px;
:deep(.el-button) {
padding: 0 4px;
}
}
.pagination-wrapper {
margin-top: 24px;
display: flex;
justify-content: center;
}
}
.stats-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-top: 24px;
.stat-card {
.stat-content {
display: flex;
align-items: center;
gap: 16px;
.stat-icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
&.total {
background: #e3f2fd;
color: #1976d2;
}
&.published {
background: #e8f5e9;
color: #388e3c;
}
&.accuracy {
background: #fff3e0;
color: #f57c00;
}
&.difficulty {
background: #f3e5f5;
color: #7b1fa2;
}
}
.stat-info {
.stat-value {
font-size: 24px;
font-weight: 600;
color: var(--el-text-color-primary);
line-height: 1;
margin-bottom: 4px;
}
.stat-label {
font-size: 14px;
color: var(--el-text-color-secondary);
}
}
}
}
}
}
.text-muted {
color: var(--el-text-color-placeholder);
font-style: italic;
}
// 响应式设计
@media (max-width: 768px) {
.page-header {
flex-direction: column;
align-items: flex-start !important;
gap: 16px;
.header-right {
width: 100%;
.el-button {
width: 100%;
}
}
}
.filter-form {
:deep(.el-form-item) {
width: 100%;
margin-right: 0 !important;
margin-bottom: 12px !important;
&:last-child {
margin-bottom: 0 !important;
}
}
}
.stats-cards {
grid-template-columns: 1fr;
}
}
</style>
EOF
18. 更新主入口文件
$ cat > src/main.ts << 'EOF'
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import 'element-plus/dist/index.css'
import '@/styles/global.scss'
import App from './App.vue'
import router from './router'
// 创建应用
const app = createApp(App)
// 配置Pinia
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
// 注册Element Plus
app.use(ElementPlus, {
locale: zhCn,
})
// 注册所有图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
// 使用插件
app.use(pinia)
app.use(router)
// 挂载应用
app.mount('#app')
// macOS开发环境日志
if (import.meta.env.DEV) {
console.log('🚀 Vue 3前端应用已启动')
console.log('📦 环境:', import.meta.env.MODE)
console.log('🌐 API基础URL:', import.meta.env.VITE_API_BASE_URL)
console.log('🔗 路由模式:', router.history.base)
}
EOF
# 更新App.vue
$ cat > src/App.vue << 'EOF'
<template>
<router-view />
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
onMounted(() => {
// 在macOS上优化滚动性能
if (navigator.userAgent.includes('Mac')) {
document.body.style.webkitOverflowScrolling = 'touch'
}
})
</script>
<style>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
height: 100%;
}
</style>
EOF
19. 测试启动前端应用
# 确保在后端服务运行的情况下,启动前端
$ cd frontend
# 使用macOS特化启动脚本
$ ./scripts/dev/mac-dev.sh --port 5173
# 或者直接使用pnpm
$ pnpm run dev
# 预期输出:
# 🚀 Vue 3 macOS前端开发脚本
# ════════════════════════════════════════
# [INFO] 检查Node.js版本...
# [SUCCESS] Node.js版本: v18.17.0
# [INFO] 检查包管理器...
# [SUCCESS] 使用pnpm (推荐)
# [INFO] 检查端口 5173 占用...
# [SUCCESS] 端口 5173 可用
# [INFO] 安装依赖...
# [INFO] node_modules已存在,跳过安装
# [INFO] 清理缓存...
# [SUCCESS] 缓存清理完成
# ╔══════════════════════════════════════╗
# ║ Math Olympiad AI Platform ║
# ║ Vue 3 Frontend ║
# ╚══════════════════════════════════════╝
# 📦 包管理器: pnpm
# 🌐 开发服务器: http://localhost:5173
# 🔗 API代理: http://localhost:8000
# ⚡ 热重载: 已启用
# 🐛 调试模式: 已启用
# 📝 日志输出:
# ════════════════════════════════════════
# VITE v4.5.0 ready in 320 ms
# ➜ Local: http://localhost:5173/
# ➜ Network: http://192.168.x.x:5173/
# ➜ press h to show help
✅ 今日完成清单
- 搭建完整的Vue3 + TypeScript项目结构
- 集成Element Plus组件库和Vite构建工具
- 配置macOS特化的开发环境(Vite、ESLint、Prettier)
- 创建HTTP请求工具和API接口定义
- 实现Pinia状态管理(用户状态持久化)
- 配置Vue Router路由(权限控制、懒加载)
- 创建主布局组件(侧边栏导航、顶部栏、页脚)
- 实现用户登录/注册界面(完整表单验证)
- 创建题库管理界面(列表、筛选、分页、操作)
- 测试前后端完整联调
🐛 macOS特有问题与解决方案
问题1:端口冲突
# 查看端口占用
$ lsof -i :5173
# 解决方案:使用其他端口
$ ./scripts/dev/mac-dev.sh --port 5174
问题2:文件监视限制(Vite热更新失效)
# macOS文件监视限制
$ sysctl kern.maxfiles
$ sysctl kern.maxfilesperproc
# 解决方案:增加限制
$ sudo sysctl -w kern.maxfiles=524288
$ sudo sysctl -w kern.maxfilesperproc=524288
# 或永久修改:sudo nano /etc/sysctl.conf
问题3:Node.js内存限制
# 检查内存限制
$ node -e 'console.log(v8.getHeapStatistics().heap_size_limit / 1024 / 1024, "MB")'
# 解决方案:增加内存限制
export NODE_OPTIONS="--max-old-space-size=4096"
💡 技术亮点
1. macOS特化优化
- 专门的macOS开发脚本,自动检查环境
- 优化文件监视和热重载配置
- 彩色终端输出和详细日志
2. 完整的前端架构
- Vue3组合式API + TypeScript
- Pinia状态管理(支持持久化)
- 完整的路由权限控制
- 自动导入Element Plus组件
3. 企业级代码质量
- ESLint + Prettier代码规范
- TypeScript严格类型检查
- 组件化架构,高可维护性
- 响应式设计,支持移动端
4. 优秀的用户体验
- Element Plus美观的UI组件
- 完整的表单验证和错误处理
- 加载状态和用户反馈
- 平滑的过渡动画
🧠 今日学习收获
-
Vue3组合式API:
- 响应式数据管理
- 组合式函数复用
- 类型安全的TypeScript集成
-
前端工程化:
- Vite构建工具配置
- 环境变量管理
- 代码规范和格式化
-
Element Plus高级用法:
- 表单验证和自定义规则
- 表格和分页组件
- 下拉菜单和对话框
-
macOS前端开发:
- 解决端口冲突和文件监视问题
- 优化开发服务器配置
- 终端彩色输出和日志管理