【奥赛AI平台】(4):Vue3前端项目搭建与用户界面

0 阅读22分钟

前端时间!激动地搓手手 🎨

🎯 目标

  1. 搭建Vue3 + TypeScript前端项目结构
  2. 集成Element Plus组件库和Vite构建工具
  3. 实现用户认证界面(登录/注册/个人中心)
  4. 创建题库管理界面(浏览/搜索/筛选)
  5. 配置macOS特化的前端开发环境
  6. 实现前后端完整联调

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: '用户名长度在 350 个字符', 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: '密码长度在 6100 个字符', 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组件
  • 完整的表单验证和错误处理
  • 加载状态和用户反馈
  • 平滑的过渡动画

🧠 今日学习收获

  1. Vue3组合式API

    • 响应式数据管理
    • 组合式函数复用
    • 类型安全的TypeScript集成
  2. 前端工程化

    • Vite构建工具配置
    • 环境变量管理
    • 代码规范和格式化
  3. Element Plus高级用法

    • 表单验证和自定义规则
    • 表格和分页组件
    • 下拉菜单和对话框
  4. macOS前端开发

    • 解决端口冲突和文件监视问题
    • 优化开发服务器配置
    • 终端彩色输出和日志管理