Просмотр исходного кода

Initialize project dev branch

Add project files including backend, web, docker configs, and documentation.
Hugvvn 3 дней назад
Родитель
Сommit
120edfb8b3
75 измененных файлов с 4171 добавлено и 1 удалено
  1. 23 0
      .gitignore
  2. 35 1
      README.md
  3. 30 0
      backend/.gitignore
  4. 14 0
      backend/Dockerfile
  5. 61 0
      backend/docker/docker-compose.yml
  6. 10 0
      backend/requirements/base.txt
  7. 16 0
      backend/requirements/requirements.txt
  8. 64 0
      backend/run.sh
  9. 83 0
      backend/run_server.py
  10. 0 0
      backend/src/api/__init__.py
  11. 0 0
      backend/src/api/v1/__init__.py
  12. 0 0
      backend/src/api/v1/retrieval/__init__.py
  13. 0 0
      backend/src/app/__init__.py
  14. 0 0
      backend/src/app/auth/__init__.py
  15. 0 0
      backend/src/app/auth/models/__init__.py
  16. 18 0
      backend/src/app/auth/models/user.py
  17. 0 0
      backend/src/app/auth/schemas/__init__.py
  18. 23 0
      backend/src/app/auth/schemas/auth_schema.py
  19. 20 0
      backend/src/app/base/__init__.py
  20. 56 0
      backend/src/app/base/async_mysql_connection.py
  21. 37 0
      backend/src/app/base/async_redis_connection.py
  22. 0 0
      backend/src/app/config/__init__.py
  23. 32 0
      backend/src/app/config/config.ini
  24. 0 0
      backend/src/app/core/__init__.py
  25. 45 0
      backend/src/app/core/config.py
  26. 68 0
      backend/src/app/core/exceptions.py
  27. 0 0
      backend/src/app/middleware/__init__.py
  28. 44 0
      backend/src/app/middleware/token_refresh_middleware.py
  29. 0 0
      backend/src/app/models/__init__.py
  30. 27 0
      backend/src/app/models/base.py
  31. 0 0
      backend/src/app/retrieval/__init__.py
  32. 0 0
      backend/src/app/retrieval/models/__init__.py
  33. 0 0
      backend/src/app/retrieval/schemas/__init__.py
  34. 0 0
      backend/src/app/schemas/__init__.py
  35. 51 0
      backend/src/app/schemas/base.py
  36. 0 0
      backend/src/app/server/__init__.py
  37. 251 0
      backend/src/app/server/app.py
  38. 0 0
      backend/src/app/services/__init__.py
  39. 47 0
      backend/src/app/services/auth_service.py
  40. 57 0
      backend/src/app/services/jwt_token.py
  41. 33 0
      backend/src/app/services/retrieval_service.py
  42. 0 0
      backend/src/app/utils/__init__.py
  43. 40 0
      backend/src/app/utils/auth_dependency.py
  44. 87 0
      backend/src/app/utils/security.py
  45. 9 0
      backend/src/path_config.py
  46. 0 0
      backend/src/views/__init__.py
  47. 32 0
      backend/src/views/auth_view.py
  48. 36 0
      backend/src/views/retrieval_view.py
  49. 61 0
      docker/docker-compose.yml
  50. 4 0
      web/.gitignore
  51. 7 0
      web/env.d.ts
  52. 13 0
      web/index.html
  53. 1609 0
      web/package-lock.json
  54. 23 0
      web/package.json
  55. 3 0
      web/src/App.vue
  56. 9 0
      web/src/api/auth.ts
  57. 5 0
      web/src/api/retrieval.ts
  58. 91 0
      web/src/components/FilterPanel.vue
  59. 39 0
      web/src/components/Pagination.vue
  60. 75 0
      web/src/components/ResultCard.vue
  61. 34 0
      web/src/components/SearchBar.vue
  62. 30 0
      web/src/components/SortDropdown.vue
  63. 20 0
      web/src/components/StatsOverview.vue
  64. 44 0
      web/src/layouts/DefaultLayout.vue
  65. 20 0
      web/src/main.ts
  66. 41 0
      web/src/router/index.ts
  67. 25 0
      web/src/styles/index.css
  68. 36 0
      web/src/utils/request.ts
  69. 140 0
      web/src/views/SearchPage.vue
  70. 74 0
      web/src/views/auth/Login.vue
  71. 37 0
      web/src/views/auth/OAuthCallback.vue
  72. 25 0
      web/tsconfig.json
  73. 10 0
      web/tsconfig.node.json
  74. 21 0
      web/vite.config.ts
  75. 326 0
      项目/项目结构.md

+ 23 - 0
.gitignore

@@ -58,3 +58,26 @@ docs/_build/
 # PyBuilder
 target/
 
+# Node.js
+node_modules/
+web/dist/
+
+# IDE
+.idea/
+.vscode/
+*.swp
+*.swo
+
+# Env
+.env
+.env.local
+.env.*.local
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Logs
+logs/
+*.pid
+

+ 35 - 1
README.md

@@ -1,3 +1,37 @@
 # LQMDRetrieval
 
-多维度检索应用端
+多维度文档检索应用端
+
+## 项目结构
+
+```
+LQMDRetrieval/
+── backend/          # 后端 (FastAPI + SQLAlchemy + MySQL + Redis + Elasticsearch + MinIO)
+├── web/              # 前端 (Vue 3 + Vite + Element Plus)
+├── docker/           # 顶层 Docker Compose 编排
+└── 项目结构.md        # 详细项目结构说明
+```
+
+## 快速开始
+
+### 后端
+
+```bash
+cd backend
+pip install -r requirements/requirements.txt
+python run_server.py
+```
+
+### 前端
+
+```bash
+cd web
+npm install
+npm run dev
+```
+
+### Docker
+
+```bash
+docker compose -f docker/docker-compose.yml up -d
+```

+ 30 - 0
backend/.gitignore

@@ -0,0 +1,30 @@
+__pycache__/
+*.py[cod]
+*.pyo
+*.egg-info/
+dist/
+build/
+.eggs/
+*.egg
+
+logs/
+*.log
+*.pid
+
+.env
+.env.local
+config.ini.local
+
+.idea/
+.vscode/
+*.swp
+*.swo
+
+.venv/
+venv/
+env/
+
+.DS_Store
+Thumbs.db
+
+docker/mysql/init.sql

+ 14 - 0
backend/Dockerfile

@@ -0,0 +1,14 @@
+FROM python:3.12-slim
+
+WORKDIR /app
+
+COPY requirements/requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+COPY . .
+
+RUN mkdir -p logs
+
+EXPOSE 8000
+
+CMD ["python", "run_server.py"]

+ 61 - 0
backend/docker/docker-compose.yml

@@ -0,0 +1,61 @@
+version: '3.8'
+
+services:
+  mysql:
+    image: mysql:8.0
+    container_name: lqmd_mysql
+    environment:
+      MYSQL_ROOT_PASSWORD: root
+      MYSQL_DATABASE: lqmd_retrieval
+      TZ: Asia/Shanghai
+    ports:
+      - "3306:3306"
+    volumes:
+      - mysql_data:/var/lib/mysql
+    command: --default-authentication-plugin=mysql_native_password --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
+    networks:
+      - lqmd_network
+
+  redis:
+    image: redis:7-alpine
+    container_name: lqmd_redis
+    ports:
+      - "6379:6379"
+    volumes:
+      - redis_data:/data
+    networks:
+      - lqmd_network
+
+  backend:
+    build:
+      context: .
+      dockerfile: Dockerfile
+    container_name: lqmd_backend
+    ports:
+      - "8000:8000"
+    depends_on:
+      - mysql
+      - redis
+    networks:
+      - lqmd_network
+
+  nginx:
+    image: nginx:alpine
+    container_name: lqmd_nginx
+    ports:
+      - "80:80"
+    volumes:
+      - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf
+      - ./dist:/usr/share/nginx/html
+    depends_on:
+      - backend
+    networks:
+      - lqmd_network
+
+networks:
+  lqmd_network:
+    driver: bridge
+
+volumes:
+  mysql_data:
+  redis_data:

+ 10 - 0
backend/requirements/base.txt

@@ -0,0 +1,10 @@
+fastapi==0.104.0
+uvicorn==0.24.0
+sqlalchemy==2.0.23
+aiomysql==0.2.0
+redis==5.0.1
+python-jose[cryptography]==3.3.0
+passlib[bcrypt]==1.7.4
+pydantic==2.5.2
+pydantic-settings==2.1.0
+python-multipart==0.0.6

+ 16 - 0
backend/requirements/requirements.txt

@@ -0,0 +1,16 @@
+fastapi==0.104.0
+uvicorn==0.24.0
+sqlalchemy==2.0.23
+aiomysql==0.2.0
+redis==5.0.1
+python-jose[cryptography]==3.3.0
+passlib[bcrypt]==1.7.4
+pydantic==2.5.2
+pydantic-settings==2.1.0
+python-multipart==0.0.6
+pytest==7.4.3
+pytest-asyncio==0.21.1
+black==23.11.0
+flake8==6.1.0
+mypy==1.7.1
+httpx==0.25.2

+ 64 - 0
backend/run.sh

@@ -0,0 +1,64 @@
+#!/bin/bash
+# LQMDRetrieval 服务管理脚本
+
+APP_DIR="$(cd "$(dirname "$0")" && pwd)"
+PID_FILE="$APP_DIR/logs/server.pid"
+LOG_FILE="$APP_DIR/logs/server.log"
+
+start() {
+    if [ -f "$PID_FILE" ]; then
+        PID=$(cat "$PID_FILE")
+        if kill -0 "$PID" 2>/dev/null; then
+            echo "服务已在运行 (PID: $PID)"
+            return 1
+        fi
+    fi
+
+    mkdir -p "$APP_DIR/logs"
+    cd "$APP_DIR"
+    nohup python run_server.py > "$LOG_FILE" 2>&1 &
+    echo $! > "$PID_FILE"
+    echo "服务已启动 (PID: $(cat "$PID_FILE"))"
+}
+
+stop() {
+    if [ -f "$PID_FILE" ]; then
+        PID=$(cat "$PID_FILE")
+        if kill -0 "$PID" 2>/dev/null; then
+            kill "$PID"
+            echo "服务已停止 (PID: $PID)"
+        else
+            echo "服务未运行"
+        fi
+        rm -f "$PID_FILE"
+    else
+        echo "服务未运行"
+    fi
+}
+
+restart() {
+    stop
+    sleep 2
+    start
+}
+
+status() {
+    if [ -f "$PID_FILE" ]; then
+        PID=$(cat "$PID_FILE")
+        if kill -0 "$PID" 2>/dev/null; then
+            echo "服务正在运行 (PID: $PID)"
+        else
+            echo "服务未运行 (PID 文件存在但进程不存在)"
+        fi
+    else
+        echo "服务未运行"
+    fi
+}
+
+case "$1" in
+    start)   start ;;
+    stop)    stop ;;
+    restart) restart ;;
+    status)  status ;;
+    *)       echo "用法: $0 {start|stop|restart|status}" ;;
+esac

+ 83 - 0
backend/run_server.py

@@ -0,0 +1,83 @@
+#!/usr/bin/env python3
+"""
+LQMDRetrieval 服务启动脚本
+"""
+import sys
+import os
+import socket
+
+project_root = os.path.dirname(os.path.abspath(__file__))
+sys.path.insert(0, project_root)
+sys.path.insert(0, os.path.join(project_root, 'src'))
+
+from src.app.core.config import config_handler
+
+
+def check_port(host: str, port: int) -> bool:
+    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+        try:
+            s.bind((host, port))
+            return True
+        except OSError:
+            return False
+
+
+def find_available_port(host: str, start_port: int = 8000, max_port: int = 8010) -> int:
+    for port in range(start_port, max_port + 1):
+        if check_port(host, port):
+            return port
+    return None
+
+
+def main():
+    import uvicorn
+
+    host = config_handler.get("retrieval_app", "HOST", "0.0.0.0")
+    port = config_handler.get_int("retrieval_app", "PORT", 8000)
+    reload = config_handler.get_bool("retrieval_app", "RELOAD", True)
+    debug = config_handler.get_bool("retrieval_app", "DEBUG", True)
+    print(f"host={host}, port={port}")
+
+    if not check_port(host, port):
+        print(f"⚠️  端口 {port} 已被占用,正在查找可用端口...")
+        available_port = find_available_port(host, port, port + 10)
+        if available_port:
+            port = available_port
+            print(f"✅ 找到可用端口: {port}")
+        else:
+            print(f"❌ 未找到可用端口 (尝试范围: {port}-{port+10})")
+            sys.exit(1)
+
+    print("=" * 70)
+    print(f"🚀 正在启动 {config_handler.get('retrieval_app', 'APP_NAME', '多维度检索')} v{config_handler.get('retrieval_app', 'APP_VERSION', '1.0.0')}")
+    print("=" * 70)
+    print(f"📍 服务地址: http://{host}:{port}")
+    print(f" API文档: http://{host}:{port}/docs")
+    print(f"🔧 调试模式: {'开启' if debug else '关闭'}")
+    print(f"🔄 热重载: {'开启' if reload else '关闭'}")
+    print("=" * 70)
+
+    try:
+        reload_dirs = [os.path.join(project_root, "src")] if reload else None
+
+        uvicorn.run(
+            "src.app.server.app:app",
+            host=host,
+            port=port,
+            reload=reload,
+            reload_dirs=reload_dirs,
+            log_level=config_handler.get("retrieval_app", "LOG_LEVEL", "INFO").lower(),
+            access_log=True,
+            use_colors=True
+        )
+    except KeyboardInterrupt:
+        print("\n👋 服务已停止")
+    except Exception as e:
+        print(f"❌ 服务启动失败: {e}")
+        import traceback
+        traceback.print_exc()
+        sys.exit(1)
+
+
+if __name__ == "__main__":
+    main()

+ 0 - 0
backend/src/api/__init__.py


+ 0 - 0
backend/src/api/v1/__init__.py


+ 0 - 0
backend/src/api/v1/retrieval/__init__.py


+ 0 - 0
backend/src/app/__init__.py


+ 0 - 0
backend/src/app/auth/__init__.py


+ 0 - 0
backend/src/app/auth/models/__init__.py


+ 18 - 0
backend/src/app/auth/models/user.py

@@ -0,0 +1,18 @@
+"""
+用户模型
+"""
+from sqlalchemy import Column, String, Boolean, Integer
+from sqlalchemy.dialects.mysql import CHAR
+from app.models.base import BaseModel
+
+
+class User(BaseModel):
+    """用户模型"""
+    __tablename__ = "t_auth_users"
+
+    username = Column(String(64), unique=True, nullable=False, comment="用户名")
+    password_hash = Column(String(255), nullable=False, comment="密码哈希")
+    email = Column(String(128), nullable=True, comment="邮箱")
+    phone = Column(String(20), nullable=True, comment="手机号")
+    is_active = Column(Boolean, default=True, comment="是否启用")
+    role = Column(String(32), default="user", comment="角色")

+ 0 - 0
backend/src/app/auth/schemas/__init__.py


+ 23 - 0
backend/src/app/auth/schemas/auth_schema.py

@@ -0,0 +1,23 @@
+"""
+认证相关 Schema
+"""
+from pydantic import BaseModel, Field
+
+
+class LoginRequest(BaseModel):
+    username: str = Field(..., description="用户名")
+    password: str = Field(..., description="密码")
+
+
+class LoginResponse(BaseModel):
+    access_token: str
+    refresh_token: str
+    user: dict
+
+
+class RefreshRequest(BaseModel):
+    refresh_token: str = Field(..., description="刷新令牌")
+
+
+class RefreshResponse(BaseModel):
+    access_token: str

+ 20 - 0
backend/src/app/base/__init__.py

@@ -0,0 +1,20 @@
+"""
+Base 模块导出
+"""
+from app.base.async_mysql_connection import (
+    init_db,
+    close_db,
+    get_db_session,
+    Base,
+)
+from app.base.async_redis_connection import init_redis, close_redis, get_redis
+
+__all__ = [
+    "init_db",
+    "close_db",
+    "get_db_session",
+    "Base",
+    "init_redis",
+    "close_redis",
+    "get_redis",
+]

+ 56 - 0
backend/src/app/base/async_mysql_connection.py

@@ -0,0 +1,56 @@
+"""
+异步 MySQL 连接
+"""
+from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
+from sqlalchemy.orm import sessionmaker, declarative_base
+from sqlalchemy.pool import NullPool
+import logging
+
+from app.core.config import config_handler
+
+logger = logging.getLogger(__name__)
+
+Base = declarative_base()
+
+DATABASE_URL = (
+    f"mysql+aiomysql://{config_handler.get('database', 'USER', 'root')}:"
+    f"{config_handler.get('database', 'PASSWORD', 'root')}@"
+    f"{config_handler.get('database', 'HOST', 'localhost')}:"
+    f"{config_handler.get('database', 'PORT', '3306')}/"
+    f"{config_handler.get('database', 'DATABASE', 'lqmd_retrieval')}?charset=utf8mb4"
+)
+
+_engine = None
+_session_factory = None
+
+
+async def init_db():
+    global _engine, _session_factory
+    _engine = create_async_engine(
+        DATABASE_URL,
+        echo=config_handler.get_bool("database", "ECHO", False),
+        pool_size=config_handler.get_int("database", "POOL_SIZE", 10),
+        max_overflow=config_handler.get_int("database", "MAX_OVERFLOW", 20),
+        pool_recycle=3600,
+    )
+    _session_factory = sessionmaker(
+        bind=_engine,
+        class_=AsyncSession,
+        expire_on_commit=False,
+    )
+    async with _engine.begin() as conn:
+        await conn.run_sync(Base.metadata.create_all)
+    logger.info(f"MySQL 连接初始化成功: {DATABASE_URL.split('@')[1]}")
+
+
+async def close_db():
+    global _engine
+    if _engine:
+        await _engine.dispose()
+        _engine = None
+
+
+def get_db_session() -> sessionmaker:
+    if _session_factory is None:
+        raise RuntimeError("数据库未初始化,请先调用 init_db()")
+    return _session_factory

+ 37 - 0
backend/src/app/base/async_redis_connection.py

@@ -0,0 +1,37 @@
+"""
+异步 Redis 连接
+"""
+import redis.asyncio as aioredis
+import logging
+
+from app.core.config import config_handler
+
+logger = logging.getLogger(__name__)
+
+_redis_client = None
+
+
+async def init_redis():
+    global _redis_client
+    _redis_client = aioredis.Redis(
+        host=config_handler.get('redis', 'HOST', 'localhost'),
+        port=config_handler.get_int('redis', 'PORT', 6379),
+        db=config_handler.get_int('redis', 'DB', 0),
+        password=config_handler.get('redis', 'PASSWORD', None) or None,
+        decode_responses=True,
+    )
+    await _redis_client.ping()
+    logger.info("Redis 连接初始化成功")
+
+
+async def close_redis():
+    global _redis_client
+    if _redis_client:
+        await _redis_client.close()
+        _redis_client = None
+
+
+def get_redis() -> aioredis.Redis:
+    if _redis_client is None:
+        raise RuntimeError("Redis 未初始化,请先调用 init_redis()")
+    return _redis_client

+ 0 - 0
backend/src/app/config/__init__.py


+ 32 - 0
backend/src/app/config/config.ini

@@ -0,0 +1,32 @@
+[retrieval_app]
+APP_NAME = 多维度检索
+APP_VERSION = 1.0.0
+HOST = 0.0.0.0
+PORT = 8007
+DEBUG = True
+RELOAD = True
+LOG_LEVEL = INFO
+JWT_SECRET_KEY = lqmd-retrieval-jwt-secret-key-change-in-production
+ALGORITHM = HS256
+ACCESS_TOKEN_EXPIRE_MINUTES = 30
+REFRESH_TOKEN_EXPIRE_DAYS = 30
+
+[database]
+HOST = 192.168.92.61
+PORT = 13306
+USER = root
+PASSWORD = Lq123456!
+DATABASE = lq_retrieval_dev
+ECHO = False
+POOL_SIZE = 10
+MAX_OVERFLOW = 20
+
+[redis]
+HOST = localhost
+PORT = 6379
+DB = 0
+PASSWORD = 123456
+
+[retrieval_api]
+URL = http://localhost:9001/api/search
+TIMEOUT = 30

+ 0 - 0
backend/src/app/core/__init__.py


+ 45 - 0
backend/src/app/core/config.py

@@ -0,0 +1,45 @@
+"""
+配置处理类 - 读取 INI 配置文件
+"""
+import configparser
+import os
+from typing import Any, Optional
+
+
+class ConfigHandler:
+    """INI 配置文件解析器"""
+
+    def __init__(self, config_path: Optional[str] = None):
+        self.config = configparser.ConfigParser()
+        if config_path is None:
+            config_path = os.path.join(os.path.dirname(__file__), '..', 'config', 'config.ini')
+            config_path = os.path.abspath(config_path)
+        if os.path.exists(config_path):
+            self.config.read(config_path, encoding='utf-8')
+
+    def get(self, section: str, key: str, default: Any = None) -> str:
+        try:
+            return self.config.get(section, key)
+        except (configparser.NoSectionError, configparser.NoOptionError):
+            return default
+
+    def get_int(self, section: str, key: str, default: int = 0) -> int:
+        try:
+            return self.config.getint(section, key)
+        except (configparser.NoSectionError, configparser.NoOptionError, ValueError):
+            return default
+
+    def get_bool(self, section: str, key: str, default: bool = False) -> bool:
+        try:
+            return self.config.getboolean(section, key)
+        except (configparser.NoSectionError, configparser.NoOptionError, ValueError):
+            return default
+
+    def get_float(self, section: str, key: str, default: float = 0.0) -> float:
+        try:
+            return self.config.getfloat(section, key)
+        except (configparser.NoSectionError, configparser.NoOptionError, ValueError):
+            return default
+
+
+config_handler = ConfigHandler()

+ 68 - 0
backend/src/app/core/exceptions.py

@@ -0,0 +1,68 @@
+"""
+自定义异常模块
+"""
+from typing import Any, Dict, Optional
+
+
+class BaseAPIException(Exception):
+    """API 异常基类"""
+
+    def __init__(
+        self,
+        message: str = "服务器内部错误",
+        code: int = 500001,
+        status_code: int = 500,
+        details: Optional[Dict[str, Any]] = None,
+    ):
+        self.message = message
+        self.code = code
+        self.status_code = status_code
+        self.details = details or {}
+        super().__init__(self.message)
+
+
+class ValidationError(BaseAPIException):
+    def __init__(self, message: str = "参数验证失败", details: Optional[Dict[str, Any]] = None):
+        super().__init__(message=message, code=100001, status_code=400, details=details)
+
+
+class AuthenticationError(BaseAPIException):
+    def __init__(self, message: str = "认证失败", details: Optional[Dict[str, Any]] = None):
+        super().__init__(message=message, code=200001, status_code=401, details=details)
+
+
+class AuthorizationError(BaseAPIException):
+    def __init__(self, message: str = "权限不足", details: Optional[Dict[str, Any]] = None):
+        super().__init__(message=message, code=200003, status_code=403, details=details)
+
+
+class NotFoundError(BaseAPIException):
+    def __init__(self, message: str = "资源不存在", details: Optional[Dict[str, Any]] = None):
+        super().__init__(message=message, code=100004, status_code=404, details=details)
+
+
+class TokenExpiredError(AuthenticationError):
+    def __init__(self, message: str = "令牌已过期", details: Optional[Dict[str, Any]] = None):
+        super().__init__(message=message, details=details)
+        self.code = 200003
+
+
+class TokenInvalidError(AuthenticationError):
+    def __init__(self, message: str = "令牌无效", details: Optional[Dict[str, Any]] = None):
+        super().__init__(message=message, details=details)
+        self.code = 200002
+
+
+class UserNotFoundError(NotFoundError):
+    def __init__(self, message: str = "用户不存在", details: Optional[Dict[str, Any]] = None):
+        super().__init__(message=message, details=details)
+
+
+class DatabaseError(BaseAPIException):
+    def __init__(self, message: str = "数据库操作失败", details: Optional[Dict[str, Any]] = None):
+        super().__init__(message=message, code=500003, status_code=500, details=details)
+
+
+class ExternalServiceError(BaseAPIException):
+    def __init__(self, message: str = "外部服务调用失败", details: Optional[Dict[str, Any]] = None):
+        super().__init__(message=message, code=500004, status_code=502, details=details)

+ 0 - 0
backend/src/app/middleware/__init__.py


+ 44 - 0
backend/src/app/middleware/token_refresh_middleware.py

@@ -0,0 +1,44 @@
+"""
+Token 自动刷新中间件 - 滑动过期机制
+"""
+from fastapi import Request
+from fastapi.responses import JSONResponse
+from starlette.middleware.base import BaseHTTPMiddleware
+from app.services.jwt_token import verify_and_refresh_token
+
+
+class TokenRefreshMiddleware(BaseHTTPMiddleware):
+    def __init__(self, app, exclude_paths: list = None):
+        super().__init__(app)
+        self.exclude_paths = exclude_paths or [
+            "/auth/login", "/auth/refresh", "/docs", "/openapi.json",
+            "/redoc", "/favicon.ico", "/health", "/",
+        ]
+
+    async def dispatch(self, request: Request, call_next):
+        if any(request.url.path.startswith(path) for path in self.exclude_paths):
+            return await call_next(request)
+
+        auth_header = request.headers.get("Authorization")
+        if not auth_header or not auth_header.startswith("Bearer "):
+            return await call_next(request)
+
+        token = auth_header[7:]
+
+        try:
+            payload, new_token = verify_and_refresh_token(token)
+            if not payload:
+                return JSONResponse(
+                    status_code=401,
+                    content={"code": 401, "message": "无效的访问令牌", "data": None},
+                )
+
+            response = await call_next(request)
+
+            if new_token:
+                response.headers["X-New-Token"] = new_token
+                response.headers["X-Token-Refreshed"] = "true"
+
+            return response
+        except Exception:
+            return await call_next(request)

+ 0 - 0
backend/src/app/models/__init__.py


+ 27 - 0
backend/src/app/models/base.py

@@ -0,0 +1,27 @@
+"""
+数据库模型基类
+"""
+from sqlalchemy import Column, String, DateTime, Boolean, func
+from sqlalchemy.dialects.mysql import CHAR
+from app.base import Base
+from datetime import datetime
+import uuid
+
+
+class BaseModel(Base):
+    """数据库模型基类"""
+    __abstract__ = True
+
+    id = Column(CHAR(36), primary_key=True, default=lambda: str(uuid.uuid4()), comment="主键ID")
+    created_by = Column(CHAR(36), nullable=True, comment="创建人")
+    created_time = Column(DateTime, default=func.now(), comment="创建时间")
+    updated_by = Column(CHAR(36), nullable=True, comment="修改人")
+    updated_time = Column(DateTime, default=func.now(), onupdate=func.now(), comment="修改时间")
+
+    def to_dict(self) -> dict:
+        return {column.name: getattr(self, column.name) for column in self.__table__.columns}
+
+    def update_from_dict(self, data: dict):
+        for key, value in data.items():
+            if hasattr(self, key):
+                setattr(self, key, value)

+ 0 - 0
backend/src/app/retrieval/__init__.py


+ 0 - 0
backend/src/app/retrieval/models/__init__.py


+ 0 - 0
backend/src/app/retrieval/schemas/__init__.py


+ 0 - 0
backend/src/app/schemas/__init__.py


+ 51 - 0
backend/src/app/schemas/base.py

@@ -0,0 +1,51 @@
+"""
+基础 Schema 模型
+"""
+from pydantic import BaseModel, Field
+from typing import Optional, Any
+from datetime import datetime
+
+
+class BaseSchema(BaseModel):
+    class Config:
+        from_attributes = True
+        json_encoders = {
+            datetime: lambda v: v.isoformat() if v else None
+        }
+
+
+class ResponseSchema(BaseSchema):
+    code: str = Field(default="000000", description="响应码")
+    message: str = Field(default="success", description="响应消息")
+    data: Optional[Any] = Field(default=None, description="响应数据")
+    timestamp: datetime = Field(default_factory=datetime.utcnow, description="响应时间")
+
+
+class PaginationSchema(BaseSchema):
+    page: int = Field(default=1, ge=1, description="页码")
+    page_size: int = Field(default=20, ge=1, le=1000, description="每页数量")
+    total: int = Field(default=0, description="总数量")
+    total_pages: int = Field(default=0, description="总页数")
+
+
+class PaginatedResponseSchema(ResponseSchema):
+    data: Optional[Any] = Field(default=None, description="响应数据")
+    meta: Optional[PaginationSchema] = Field(default=None, description="分页信息")
+
+
+class IDSchema(BaseSchema):
+    id: str = Field(..., description="ID")
+
+
+class TimestampSchema(BaseSchema):
+    created_at: Optional[datetime] = Field(default=None, description="创建时间")
+    updated_at: Optional[datetime] = Field(default=None, description="更新时间")
+
+
+class BaseModelSchema(IDSchema, TimestampSchema):
+    is_deleted: bool = Field(default=False, description="是否删除")
+
+
+class PageQuery(BaseModel):
+    page: int = Field(1, ge=1)
+    page_size: int = Field(20, ge=1, le=100)

+ 0 - 0
backend/src/app/server/__init__.py


+ 251 - 0
backend/src/app/server/app.py

@@ -0,0 +1,251 @@
+"""
+LQMDRetrieval - FastAPI 应用实例
+"""
+import sys
+import os
+from contextlib import asynccontextmanager
+from fastapi import FastAPI, Request, status
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import JSONResponse
+from fastapi.exceptions import RequestValidationError
+from datetime import datetime, timezone
+
+try:
+    import path_config
+except ImportError:
+    src_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '../..'))
+    if src_path not in sys.path:
+        sys.path.insert(0, src_path)
+    root_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '../../..'))
+    if root_path not in sys.path:
+        sys.path.insert(0, root_path)
+    try:
+        import path_config
+    except ImportError:
+        pass
+
+from app.core.config import config_handler
+from app.base import init_db, close_db, init_redis, close_redis
+from app.core.exceptions import BaseAPIException
+from app.schemas.base import ResponseSchema
+
+from views.auth_view import router as auth_router
+from views.retrieval_view import router as retrieval_router
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+    """应用生命周期管理"""
+    print("=" * 60)
+    print("正在启动 LQMDRetrieval 服务...")
+    print("=" * 60)
+
+    try:
+        await init_db()
+        print("✅ 数据库初始化成功")
+    except Exception as e:
+        print(f"❌ 数据库初始化失败: {e}")
+
+    try:
+        await init_redis()
+        print("✅ Redis初始化成功")
+    except Exception as e:
+        print(f"⚠️  Redis初始化失败: {e}")
+
+    print("=" * 60)
+    print(f"🚀 服务启动成功!")
+    print(f"📍 服务地址: http://{config_handler.get('retrieval_app', 'HOST', '0.0.0.0')}:{config_handler.get_int('retrieval_app', 'PORT', 8000)}")
+    print(f"📚 API文档: http://{config_handler.get('retrieval_app', 'HOST', '0.0.0.0')}:{config_handler.get_int('retrieval_app', 'PORT', 8000)}/docs")
+    print("=" * 60)
+
+    yield
+
+    print("=" * 60)
+    print("正在关闭 LQMDRetrieval 服务...")
+    print("=" * 60)
+
+    try:
+        await close_db()
+        print("✅ 数据库连接已关闭")
+    except Exception as e:
+        print(f"❌ 关闭数据库连接失败: {e}")
+
+    try:
+        await close_redis()
+        print("✅ Redis连接已关闭")
+    except Exception as e:
+        print(f"⚠️  关闭Redis连接失败: {e}")
+
+    print("=" * 60)
+    print(" 服务已关闭")
+    print("=" * 60)
+
+
+app = FastAPI(
+    title=config_handler.get("retrieval_app", "APP_NAME", "多维度检索"),
+    version=config_handler.get("retrieval_app", "APP_VERSION", "1.0.0"),
+    description="多维度文档检索应用端",
+    docs_url="/docs" if config_handler.get_bool("retrieval_app", "DEBUG", False) else None,
+    redoc_url="/redoc" if config_handler.get_bool("retrieval_app", "DEBUG", False) else None,
+    lifespan=lifespan
+)
+
+app.add_middleware(
+    CORSMiddleware,
+    allow_origins=[
+        "http://localhost:5173",
+        "http://localhost:3000",
+        "http://127.0.0.1:5173",
+        "http://127.0.0.1:3000",
+        "*"
+    ],
+    allow_credentials=True,
+    allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"],
+    allow_headers=["*"],
+    expose_headers=["X-New-Token", "X-Token-Refreshed"],
+)
+
+from app.middleware.token_refresh_middleware import TokenRefreshMiddleware
+
+app.add_middleware(
+    TokenRefreshMiddleware,
+    exclude_paths=[
+        "/auth/login",
+        "/auth/refresh",
+        "/docs",
+        "/openapi.json",
+        "/redoc",
+        "/favicon.ico",
+        "/health",
+        "/"
+    ]
+)
+
+@app.middleware("http")
+async def add_new_token_to_response(request: Request, call_next):
+    """响应中间件:将新 token 写入响应头"""
+    response = await call_next(request)
+    if hasattr(request.state, "new_token"):
+        response.headers["X-New-Token"] = request.state.new_token
+        response.headers["X-Token-Refreshed"] = "true"
+    return response
+
+
+@app.exception_handler(BaseAPIException)
+async def api_exception_handler(request: Request, exc: BaseAPIException):
+    return JSONResponse(
+        status_code=exc.status_code,
+        content={
+            "code": str(exc.code),
+            "message": exc.message,
+            "data": exc.details,
+            "timestamp": datetime.now(timezone.utc).isoformat()
+        }
+    )
+
+
+@app.exception_handler(RequestValidationError)
+async def validation_exception_handler(request: Request, exc: RequestValidationError):
+    errors = []
+    for error in exc.errors():
+        errors.append({
+            "field": ".".join(str(loc) for loc in error["loc"]),
+            "message": error["msg"],
+            "type": error["type"]
+        })
+    return JSONResponse(
+        status_code=status.HTTP_400_BAD_REQUEST,
+        content={
+            "code": "100001",
+            "message": "参数验证失败",
+            "data": {"errors": errors},
+            "timestamp": datetime.now(timezone.utc).isoformat()
+        }
+    )
+
+
+@app.exception_handler(Exception)
+async def general_exception_handler(request: Request, exc: Exception):
+    print(f"未处理的异常: {exc}")
+    import traceback
+    traceback.print_exc()
+    return JSONResponse(
+        status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+        content={
+            "code": "500001",
+            "message": "服务器内部错误" if not config_handler.get_bool("retrieval_app", "DEBUG", False) else str(exc),
+            "data": None,
+            "timestamp": datetime.now(timezone.utc).isoformat()
+        }
+    )
+
+
+@app.get("/health", tags=["系统"])
+async def health_check():
+    return ResponseSchema(
+        code="000000",
+        message="服务正常运行",
+        data={
+            "status": "healthy",
+            "version": config_handler.get("retrieval_app", "APP_VERSION", "1.0.0"),
+            "timestamp": datetime.now(timezone.utc)
+        }
+    )
+
+
+@app.get("/", tags=["系统"])
+async def root():
+    return ResponseSchema(
+        code="000000",
+        message="欢迎使用 LQMDRetrieval",
+        data={
+            "name": config_handler.get("retrieval_app", "APP_NAME", "多维度检索"),
+            "version": config_handler.get("retrieval_app", "APP_VERSION", "1.0.0"),
+            "docs": "/docs" if config_handler.get_bool("retrieval_app", "DEBUG", False) else None,
+        }
+    )
+
+
+app.include_router(auth_router, prefix="/api/v1")
+app.include_router(retrieval_router, prefix="/api/v1")
+
+
+def create_app() -> FastAPI:
+    return app
+
+
+if __name__ == "__main__":
+    import uvicorn
+    import socket
+
+    def check_port(port):
+        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+            try:
+                s.bind(('localhost', port))
+                return True
+            except OSError:
+                return False
+
+    def find_available_port(start_port=8000, max_port=8010):
+        for port in range(start_port, max_port + 1):
+            if check_port(port):
+                return port
+        return None
+
+    port = config_handler.get_int("retrieval_app", "PORT", 8000)
+    if not check_port(port):
+        print(f"端口 {port} 已被占用,正在查找可用端口...")
+        port = find_available_port(port, port + 10)
+        if port:
+            print(f"找到可用端口: {port}")
+        else:
+            print("未找到可用端口")
+            sys.exit(1)
+
+    uvicorn.run(
+        "app.server.app:app",
+        host=config_handler.get("retrieval_app", "HOST", "0.0.0.0"),
+        port=port,
+        reload=config_handler.get_bool("retrieval_app", "RELOAD", False),
+        log_level=config_handler.get("retrieval_app", "LOG_LEVEL", "INFO").lower()
+    )

+ 0 - 0
backend/src/app/services/__init__.py


+ 47 - 0
backend/src/app/services/auth_service.py

@@ -0,0 +1,47 @@
+"""
+用户登录认证服务
+"""
+from typing import Dict, Any
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select
+
+from app.auth.models.user import User
+from app.utils.security import verify_password, create_access_token, create_refresh_token
+from app.core.exceptions import AuthenticationError
+
+
+class AuthService:
+    async def login(self, db: AsyncSession, username: str, password: str) -> Dict[str, Any]:
+        """用户登录"""
+        result = await db.execute(select(User).where(User.username == username))
+        user = result.scalar_one_or_none()
+
+        if not user or not verify_password(password, user.password_hash):
+            raise AuthenticationError(message="用户名或密码错误")
+
+        if not user.is_active:
+            raise AuthenticationError(message="用户已禁用")
+
+        token_data = {"sub": str(user.id), "username": user.username}
+        access_token = create_access_token(token_data)
+        refresh_token = create_refresh_token(token_data)
+
+        return {
+            "access_token": access_token,
+            "refresh_token": refresh_token,
+            "user": {"id": str(user.id), "username": user.username},
+        }
+
+    async def refresh(self, refresh_token: str) -> Dict[str, Any]:
+        """刷新访问令牌"""
+        from app.services.jwt_token import verify_token
+        payload = verify_token(refresh_token)
+
+        if not payload or payload.get("type") != "refresh":
+            raise AuthenticationError(message="无效的刷新令牌")
+
+        token_data = {"sub": payload["sub"], "username": payload["username"]}
+        return {"access_token": create_access_token(token_data)}
+
+
+auth_service = AuthService()

+ 57 - 0
backend/src/app/services/jwt_token.py

@@ -0,0 +1,57 @@
+"""
+JWT Token 管理
+"""
+from typing import Optional
+from datetime import datetime, timedelta, timezone
+from app.core.config import config_handler
+
+try:
+    import jwt as pyjwt
+    jwt = pyjwt
+    JWT_ERROR = (pyjwt.PyJWTError, pyjwt.DecodeError, pyjwt.ExpiredSignatureError)
+except (ImportError, AttributeError):
+    from jose import jwt, JWTError
+    JWT_ERROR = (JWTError,)
+
+JWT_SECRET_KEY = config_handler.get("retrieval_app", "JWT_SECRET_KEY", "dev-jwt-secret-key-change-in-production")
+JWT_ALGORITHM = config_handler.get("retrieval_app", "ALGORITHM", "HS256")
+ACCESS_TOKEN_EXPIRE_MINUTES = config_handler.get_int("retrieval_app", "ACCESS_TOKEN_EXPIRE_MINUTES", 30)
+
+
+def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
+    to_encode = data.copy()
+    if expires_delta:
+        expire = datetime.now(timezone.utc) + expires_delta
+    else:
+        expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
+    to_encode.update({"exp": expire, "iat": datetime.now(timezone.utc)})
+    encoded_jwt = jwt.encode(to_encode, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM)
+    if isinstance(encoded_jwt, bytes):
+        encoded_jwt = encoded_jwt.decode('utf-8')
+    return encoded_jwt
+
+
+def verify_token(token: str) -> Optional[dict]:
+    try:
+        return jwt.decode(token, JWT_SECRET_KEY, algorithms=[JWT_ALGORITHM])
+    except JWT_ERROR:
+        return None
+
+
+def verify_and_refresh_token(token: str) -> tuple:
+    """验证令牌并实现滑动过期机制。Returns: (payload, new_token)"""
+    try:
+        payload = jwt.decode(token, JWT_SECRET_KEY, algorithms=[JWT_ALGORITHM])
+        exp = payload.get('exp')
+        iat = payload.get('iat')
+        if exp and iat:
+            current_time = datetime.now(timezone.utc)
+            iat_time = datetime.fromtimestamp(iat, tz=timezone.utc)
+            total_expire_seconds = ACCESS_TOKEN_EXPIRE_MINUTES * 60
+            token_age_seconds = (current_time - iat_time).total_seconds()
+            if token_age_seconds > total_expire_seconds / 2:
+                new_token_data = {k: v for k, v in payload.items() if k not in ['exp', 'iat']}
+                return payload, create_access_token(new_token_data)
+        return payload, None
+    except JWT_ERROR:
+        return None, None

+ 33 - 0
backend/src/app/services/retrieval_service.py

@@ -0,0 +1,33 @@
+"""
+多维检索服务 - 调用外部检索接口
+"""
+import httpx
+import logging
+from typing import Dict, Any, Optional
+from app.core.config import config_handler
+
+# 外部检索服务地址(从 config.ini 读取,必填项)
+RETRIEVAL_API_URL = config_handler.get("retrieval_api", "URL")
+RETRIEVAL_API_TIMEOUT = config_handler.get_int("retrieval_api", "TIMEOUT", 30)
+
+
+class RetrievalService:
+    """多维检索服务 — 转发请求到外部检索接口"""
+
+    async def search(self, payload: dict) -> dict:
+        """调用外部检索接口
+
+        Args:
+            payload: 检索参数(keyword, doc_type, department, publish_date_from/to,
+                     page, page_size, sort_by)
+
+        Returns:
+            外部接口返回的检索结果
+        """
+        async with httpx.AsyncClient(timeout=RETRIEVAL_API_TIMEOUT) as client:
+            resp = await client.post(RETRIEVAL_API_URL, json=payload)
+            resp.raise_for_status()
+            return resp.json()
+
+
+retrieval_service = RetrievalService()

+ 0 - 0
backend/src/app/utils/__init__.py


+ 40 - 0
backend/src/app/utils/auth_dependency.py

@@ -0,0 +1,40 @@
+"""
+认证依赖函数
+"""
+from fastapi import Depends, HTTPException, Request
+from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
+from app.services.jwt_token import verify_and_refresh_token
+import logging
+
+logger = logging.getLogger(__name__)
+
+security = HTTPBearer()
+
+
+async def get_current_user_with_refresh(
+    request: Request,
+    credentials: HTTPAuthorizationCredentials = Depends(security),
+) -> dict:
+    token = credentials.credentials
+    payload, new_token = verify_and_refresh_token(token)
+
+    if not payload:
+        raise HTTPException(status_code=401, detail="无效的访问令牌")
+
+    if new_token:
+        request.state.new_token = new_token
+
+    return payload
+
+
+async def get_current_user(
+    credentials: HTTPAuthorizationCredentials = Depends(security),
+) -> dict:
+    from app.services.jwt_token import verify_token
+    token = credentials.credentials
+    payload = verify_token(token)
+
+    if not payload:
+        raise HTTPException(status_code=401, detail="无效的访问令牌")
+
+    return payload

+ 87 - 0
backend/src/app/utils/security.py

@@ -0,0 +1,87 @@
+"""
+安全工具模块
+"""
+from passlib.context import CryptContext
+from jose import JWTError, jwt
+from datetime import datetime, timedelta
+from typing import Optional, Dict, Any
+from app.core.config import config_handler
+import secrets
+import string
+import hashlib
+
+pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
+
+
+def hash_password(password: str) -> str:
+    if len(password.encode('utf-8')) > 72:
+        password = password[:72]
+    return pwd_context.hash(password)
+
+
+def verify_password(plain_password: str, hashed_password: str) -> bool:
+    # 1. sha256 legacy format: sha256$salt$hash
+    if hashed_password.startswith('sha256$'):
+        try:
+            parts = hashed_password.split('$')
+            if len(parts) == 3:
+                _, salt, stored_hash = parts
+                computed_hash = hashlib.sha256((plain_password + salt).encode()).hexdigest()
+                return computed_hash == stored_hash
+        except Exception:
+            return False
+    # 2. bcrypt format (starts with $2b$, $2a$, etc.)
+    if hashed_password.startswith(('$2b$', '$2a$', '$2y$')):
+        try:
+            return pwd_context.verify(plain_password, hashed_password)
+        except Exception:
+            return False
+    # 3. plaintext fallback (development only)
+    return plain_password == hashed_password
+
+
+def create_access_token(data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str:
+    to_encode = data.copy()
+    if expires_delta:
+        expire = datetime.utcnow() + expires_delta
+    else:
+        expire = datetime.utcnow() + timedelta(minutes=config_handler.get_int("retrieval_app", "ACCESS_TOKEN_EXPIRE_MINUTES", 30))
+    to_encode.update({"exp": expire, "iat": datetime.utcnow()})
+    encoded_jwt = jwt.encode(
+        to_encode,
+        config_handler.get("retrieval_app", "JWT_SECRET_KEY", "dev-jwt-secret-key"),
+        algorithm=config_handler.get("retrieval_app", "ALGORITHM", "HS256"),
+    )
+    return encoded_jwt
+
+
+def create_refresh_token(data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str:
+    to_encode = data.copy()
+    if expires_delta:
+        expire = datetime.utcnow() + expires_delta
+    else:
+        expire = datetime.utcnow() + timedelta(days=config_handler.get_int("retrieval_app", "REFRESH_TOKEN_EXPIRE_DAYS", 30))
+    to_encode.update({"exp": expire, "iat": datetime.utcnow(), "type": "refresh"})
+    encoded_jwt = jwt.encode(
+        to_encode,
+        config_handler.get("retrieval_app", "JWT_SECRET_KEY", "dev-jwt-secret-key"),
+        algorithm=config_handler.get("retrieval_app", "ALGORITHM", "HS256"),
+    )
+    return encoded_jwt
+
+
+def verify_token(token: str) -> Optional[Dict[str, Any]]:
+    try:
+        payload = jwt.decode(
+            token,
+            config_handler.get("retrieval_app", "JWT_SECRET_KEY", "dev-jwt-secret-key"),
+            algorithms=[config_handler.get("retrieval_app", "ALGORITHM", "HS256")],
+        )
+        return payload
+    except JWTError:
+        return None
+
+
+def generate_random_string(length: int = 32) -> str:
+    alphabet = string.ascii_letters + string.digits
+    return ''.join(secrets.choice(alphabet) for _ in range(length))

+ 9 - 0
backend/src/path_config.py

@@ -0,0 +1,9 @@
+"""
+path_config - Ensure 'src' is in sys.path so 'app.*' imports work
+"""
+import os
+import sys
+
+SRC_DIR = os.path.abspath(os.path.dirname(__file__))
+if SRC_DIR not in sys.path:
+    sys.path.insert(0, SRC_DIR)

+ 0 - 0
backend/src/views/__init__.py


+ 32 - 0
backend/src/views/auth_view.py

@@ -0,0 +1,32 @@
+"""
+认证路由
+"""
+from fastapi import APIRouter, Depends
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.base import get_db_session
+from app.schemas.base import ResponseSchema
+from app.auth.schemas.auth_schema import LoginRequest, RefreshRequest
+from app.services.auth_service import auth_service
+
+router = APIRouter(prefix="/auth", tags=["认证"])
+
+
+async def get_db():
+    session_factory = get_db_session()
+    async with session_factory() as session:
+        yield session
+
+
+@router.post("/login", response_model=ResponseSchema)
+async def login(login_data: LoginRequest, db: AsyncSession = Depends(get_db)):
+    """用户登录"""
+    result = await auth_service.login(db, login_data.username, login_data.password)
+    return ResponseSchema(code="000000", message="登录成功", data=result)
+
+
+@router.post("/refresh", response_model=ResponseSchema)
+async def refresh_token(data: RefreshRequest):
+    """刷新访问令牌"""
+    result = await auth_service.refresh(data.refresh_token)
+    return ResponseSchema(code="000000", message="刷新成功", data=result)

+ 36 - 0
backend/src/views/retrieval_view.py

@@ -0,0 +1,36 @@
+"""
+多维检索路由
+"""
+from fastapi import APIRouter
+from pydantic import BaseModel, Field
+from typing import Optional
+import logging
+
+from app.schemas.base import ResponseSchema
+from app.services.retrieval_service import retrieval_service
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/retrieval", tags=["多维检索"])
+
+
+class SearchRequest(BaseModel):
+    keyword: Optional[str] = Field(None, description="关键词")
+    doc_type: Optional[str] = Field(None, description="文档类型")
+    department: Optional[str] = Field(None, description="发布部门")
+    publish_date_from: Optional[str] = Field(None, description="发布日期起始 YYYY-MM-DD")
+    publish_date_to: Optional[str] = Field(None, description="发布日期截止 YYYY-MM-DD")
+    page: int = Field(default=1, ge=1, description="页码")
+    page_size: int = Field(default=20, ge=1, le=100, description="每页数量")
+    sort_by: str = Field(default="relevance", description="排序方式")
+
+
+@router.post("/search", response_model=ResponseSchema)
+async def search(payload: SearchRequest):
+    """执行多维检索(调用外部接口)"""
+    result = await retrieval_service.search(payload.model_dump(exclude_none=True))
+    return ResponseSchema(
+        code=result.get("code", "000000"),
+        message=result.get("message", "检索成功"),
+        data=result.get("data"),
+    )

+ 61 - 0
docker/docker-compose.yml

@@ -0,0 +1,61 @@
+version: '3.8'
+
+services:
+  mysql:
+    image: mysql:8.0
+    container_name: lqmd_mysql
+    environment:
+      MYSQL_ROOT_PASSWORD: root
+      MYSQL_DATABASE: lqmd_retrieval
+      TZ: Asia/Shanghai
+    ports:
+      - "3306:3306"
+    volumes:
+      - mysql_data:/var/lib/mysql
+    command: --default-authentication-plugin=mysql_native_password --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
+    networks:
+      - lqmd_network
+
+  redis:
+    image: redis:7-alpine
+    container_name: lqmd_redis
+    ports:
+      - "6379:6379"
+    volumes:
+      - redis_data:/data
+    networks:
+      - lqmd_network
+
+  backend:
+    build:
+      context: ./backend
+      dockerfile: Dockerfile
+    container_name: lqmd_backend
+    ports:
+      - "8000:8000"
+    depends_on:
+      - mysql
+      - redis
+    networks:
+      - lqmd_network
+
+  nginx:
+    image: nginx:alpine
+    container_name: lqmd_nginx
+    ports:
+      - "80:80"
+    volumes:
+      - ./backend/docker/nginx/default.conf:/etc/nginx/conf.d/default.conf
+      - ./web/dist:/usr/share/nginx/html
+    depends_on:
+      - backend
+    networks:
+      - lqmd_network
+
+networks:
+  lqmd_network:
+    driver: bridge
+
+volumes:
+  mysql_data:
+  redis_data:

+ 4 - 0
web/.gitignore

@@ -0,0 +1,4 @@
+node_modules/
+dist/
+.env.local
+.env.*.local

+ 7 - 0
web/env.d.ts

@@ -0,0 +1,7 @@
+/// <reference types="vite/client" />
+
+declare module '*.vue' {
+  import type { DefineComponent } from 'vue'
+  const component: DefineComponent<object, object, any>
+  export default component
+}

+ 13 - 0
web/index.html

@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+  <meta charset="UTF-8" />
+  <link rel="icon" type="image/svg+xml" href="/vite.svg" />
+  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+  <title>多维度检索</title>
+</head>
+<body>
+  <div id="app"></div>
+  <script type="module" src="/src/main.ts"></script>
+</body>
+</html>

+ 1609 - 0
web/package-lock.json

@@ -0,0 +1,1609 @@
+{
+  "name": "lqmd-retrieval-web",
+  "version": "1.0.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "lqmd-retrieval-web",
+      "version": "1.0.0",
+      "dependencies": {
+        "@element-plus/icons-vue": "^2.3.1",
+        "element-plus": "^2.4.4",
+        "vue": "^3.4.0",
+        "vue-router": "^4.2.5"
+      },
+      "devDependencies": {
+        "@vitejs/plugin-vue": "^4.5.2",
+        "typescript": "^5.3.3",
+        "vite": "^5.0.8",
+        "vue-tsc": "^1.8.27"
+      }
+    },
+    "node_modules/@babel/helper-string-parser": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+      "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-validator-identifier": {
+      "version": "7.28.5",
+      "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+      "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/parser": {
+      "version": "7.29.3",
+      "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.3.tgz",
+      "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/types": "^7.29.0"
+      },
+      "bin": {
+        "parser": "bin/babel-parser.js"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@babel/types": {
+      "version": "7.29.0",
+      "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz",
+      "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-string-parser": "^7.27.1",
+        "@babel/helper-validator-identifier": "^7.28.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@ctrl/tinycolor": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz",
+      "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=14"
+      }
+    },
+    "node_modules/@element-plus/icons-vue": {
+      "version": "2.3.2",
+      "resolved": "https://registry.npmmirror.com/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz",
+      "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==",
+      "license": "MIT",
+      "peerDependencies": {
+        "vue": "^3.2.0"
+      }
+    },
+    "node_modules/@esbuild/aix-ppc64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
+      "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "aix"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/android-arm": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
+      "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/android-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
+      "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/android-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
+      "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/darwin-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
+      "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/darwin-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
+      "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/freebsd-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
+      "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/freebsd-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
+      "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-arm": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
+      "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
+      "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-ia32": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
+      "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-loong64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
+      "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-mips64el": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
+      "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+      "cpu": [
+        "mips64el"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-ppc64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
+      "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-riscv64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
+      "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-s390x": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
+      "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
+      "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/netbsd-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
+      "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/openbsd-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
+      "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/sunos-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
+      "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/win32-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
+      "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/win32-ia32": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
+      "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/win32-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
+      "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@floating-ui/core": {
+      "version": "1.7.5",
+      "resolved": "https://registry.npmmirror.com/@floating-ui/core/-/core-1.7.5.tgz",
+      "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@floating-ui/utils": "^0.2.11"
+      }
+    },
+    "node_modules/@floating-ui/dom": {
+      "version": "1.7.6",
+      "resolved": "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.7.6.tgz",
+      "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@floating-ui/core": "^1.7.5",
+        "@floating-ui/utils": "^0.2.11"
+      }
+    },
+    "node_modules/@floating-ui/utils": {
+      "version": "0.2.11",
+      "resolved": "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.11.tgz",
+      "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
+      "license": "MIT"
+    },
+    "node_modules/@jridgewell/sourcemap-codec": {
+      "version": "1.5.5",
+      "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+      "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+      "license": "MIT"
+    },
+    "node_modules/@popperjs/core": {
+      "name": "@sxzz/popperjs-es",
+      "version": "2.11.8",
+      "resolved": "https://registry.npmmirror.com/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz",
+      "integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==",
+      "license": "MIT",
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/popperjs"
+      }
+    },
+    "node_modules/@rollup/rollup-android-arm-eabi": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz",
+      "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-android-arm64": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz",
+      "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-arm64": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz",
+      "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-x64": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz",
+      "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-freebsd-arm64": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz",
+      "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-freebsd-x64": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz",
+      "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz",
+      "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz",
+      "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-gnu": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz",
+      "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-musl": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz",
+      "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-loong64-gnu": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz",
+      "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-loong64-musl": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz",
+      "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz",
+      "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-ppc64-musl": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz",
+      "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz",
+      "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-riscv64-musl": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz",
+      "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-s390x-gnu": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz",
+      "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-gnu": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz",
+      "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-musl": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz",
+      "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-openbsd-x64": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz",
+      "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-openharmony-arm64": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz",
+      "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-arm64-msvc": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz",
+      "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-ia32-msvc": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz",
+      "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-x64-gnu": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz",
+      "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-x64-msvc": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz",
+      "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@types/estree": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz",
+      "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/lodash": {
+      "version": "4.17.24",
+      "resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.24.tgz",
+      "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==",
+      "license": "MIT"
+    },
+    "node_modules/@types/lodash-es": {
+      "version": "4.17.12",
+      "resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz",
+      "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/lodash": "*"
+      }
+    },
+    "node_modules/@types/web-bluetooth": {
+      "version": "0.0.21",
+      "resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
+      "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
+      "license": "MIT"
+    },
+    "node_modules/@vitejs/plugin-vue": {
+      "version": "4.6.2",
+      "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-4.6.2.tgz",
+      "integrity": "sha512-kqf7SGFoG+80aZG6Pf+gsZIVvGSCKE98JbiWqcCV9cThtg91Jav0yvYFC9Zb+jKetNGF6ZKeoaxgZfND21fWKw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "^14.18.0 || >=16.0.0"
+      },
+      "peerDependencies": {
+        "vite": "^4.0.0 || ^5.0.0",
+        "vue": "^3.2.25"
+      }
+    },
+    "node_modules/@volar/language-core": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmmirror.com/@volar/language-core/-/language-core-1.11.1.tgz",
+      "integrity": "sha512-dOcNn3i9GgZAcJt43wuaEykSluAuOkQgzni1cuxLxTV0nJKanQztp7FxyswdRILaKH+P2XZMPRp2S4MV/pElCw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@volar/source-map": "1.11.1"
+      }
+    },
+    "node_modules/@volar/source-map": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmmirror.com/@volar/source-map/-/source-map-1.11.1.tgz",
+      "integrity": "sha512-hJnOnwZ4+WT5iupLRnuzbULZ42L7BWWPMmruzwtLhJfpDVoZLjNBxHDi2sY2bgZXCKlpU5XcsMFoYrsQmPhfZg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "muggle-string": "^0.3.1"
+      }
+    },
+    "node_modules/@volar/typescript": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmmirror.com/@volar/typescript/-/typescript-1.11.1.tgz",
+      "integrity": "sha512-iU+t2mas/4lYierSnoFOeRFQUhAEMgsFuQxoxvwn5EdQopw43j+J27a4lt9LMInx1gLJBC6qL14WYGlgymaSMQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@volar/language-core": "1.11.1",
+        "path-browserify": "^1.0.1"
+      }
+    },
+    "node_modules/@vue/compiler-core": {
+      "version": "3.5.34",
+      "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.34.tgz",
+      "integrity": "sha512-s9cLyK5mLcvZ4Agva5QgRsQyLKvts9WbU9DB6NqiZkkGEdwmcEiylj5Jbwkp680drF/NNCV8OlAJSe+yMLxaJw==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/parser": "^7.29.3",
+        "@vue/shared": "3.5.34",
+        "entities": "^7.0.1",
+        "estree-walker": "^2.0.2",
+        "source-map-js": "^1.2.1"
+      }
+    },
+    "node_modules/@vue/compiler-dom": {
+      "version": "3.5.34",
+      "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.34.tgz",
+      "integrity": "sha512-EbF/T++k0e2MMZlJsBhzK8Sgwt0HcIPOhzn1CTB/lv6sQcyk+OWf8YeiLxZp3ro7MbbLcAfAJ6sEvjFWuNgUCw==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/compiler-core": "3.5.34",
+        "@vue/shared": "3.5.34"
+      }
+    },
+    "node_modules/@vue/compiler-sfc": {
+      "version": "3.5.34",
+      "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.34.tgz",
+      "integrity": "sha512-D/ihr6uZeIt6r+pVZf46RWT1fAsLFMbUP7k8G1VkiiWexriED9GrX3echHd4Abbt17zjlfiFJ8z7a3BxZOPNjg==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/parser": "^7.29.3",
+        "@vue/compiler-core": "3.5.34",
+        "@vue/compiler-dom": "3.5.34",
+        "@vue/compiler-ssr": "3.5.34",
+        "@vue/shared": "3.5.34",
+        "estree-walker": "^2.0.2",
+        "magic-string": "^0.30.21",
+        "postcss": "^8.5.14",
+        "source-map-js": "^1.2.1"
+      }
+    },
+    "node_modules/@vue/compiler-ssr": {
+      "version": "3.5.34",
+      "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.34.tgz",
+      "integrity": "sha512-cDtTHKibkThKGHH1SP+WdccquNRYQDFH6rRjQCqT9G2ltFAfoR5pUftpab/z+aM5mW9HLLVQW7hfKKQe/1GBeQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/compiler-dom": "3.5.34",
+        "@vue/shared": "3.5.34"
+      }
+    },
+    "node_modules/@vue/devtools-api": {
+      "version": "6.6.4",
+      "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
+      "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
+      "license": "MIT"
+    },
+    "node_modules/@vue/language-core": {
+      "version": "1.8.27",
+      "resolved": "https://registry.npmmirror.com/@vue/language-core/-/language-core-1.8.27.tgz",
+      "integrity": "sha512-L8Kc27VdQserNaCUNiSFdDl9LWT24ly8Hpwf1ECy3aFb9m6bDhBGQYOujDm21N7EW3moKIOKEanQwe1q5BK+mA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@volar/language-core": "~1.11.1",
+        "@volar/source-map": "~1.11.1",
+        "@vue/compiler-dom": "^3.3.0",
+        "@vue/shared": "^3.3.0",
+        "computeds": "^0.0.1",
+        "minimatch": "^9.0.3",
+        "muggle-string": "^0.3.1",
+        "path-browserify": "^1.0.1",
+        "vue-template-compiler": "^2.7.14"
+      },
+      "peerDependencies": {
+        "typescript": "*"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@vue/reactivity": {
+      "version": "3.5.34",
+      "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.34.tgz",
+      "integrity": "sha512-y9XDjCEuBp+98k+UL5dbYkh57AHU4o6cxZedOPXw3bmrZZYLQsVHguGurq7hVrPCSrQtrnz1f9dssyFr+dMXfQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/shared": "3.5.34"
+      }
+    },
+    "node_modules/@vue/runtime-core": {
+      "version": "3.5.34",
+      "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.34.tgz",
+      "integrity": "sha512-mKeBYvu8tcMSLhypAHBmriUFfWXKTCF/23Z4jiCoYK3UtWepkliViNLuR90V9XOyD62mUxs9p1jsrpK3CCGIzw==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/reactivity": "3.5.34",
+        "@vue/shared": "3.5.34"
+      }
+    },
+    "node_modules/@vue/runtime-dom": {
+      "version": "3.5.34",
+      "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.34.tgz",
+      "integrity": "sha512-e8kZzERmCwUnBRVsgSQlAfrfU2rGoy0FFKPBXSlfEjc/O3KfA7QP0t1/2ZylrbchjmIKB4dPTd07A6WPr0eOrg==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/reactivity": "3.5.34",
+        "@vue/runtime-core": "3.5.34",
+        "@vue/shared": "3.5.34",
+        "csstype": "^3.2.3"
+      }
+    },
+    "node_modules/@vue/server-renderer": {
+      "version": "3.5.34",
+      "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.34.tgz",
+      "integrity": "sha512-nHxmJoTrKsmrkbILRhkC9gY1G3moZbJTqCzDd7DOOzG5KH9oeJ0Unqrff5f9v0pW//jES05ZkJcNtfE8JjOIew==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/compiler-ssr": "3.5.34",
+        "@vue/shared": "3.5.34"
+      },
+      "peerDependencies": {
+        "vue": "3.5.34"
+      }
+    },
+    "node_modules/@vue/shared": {
+      "version": "3.5.34",
+      "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.34.tgz",
+      "integrity": "sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==",
+      "license": "MIT"
+    },
+    "node_modules/@vueuse/core": {
+      "version": "14.3.0",
+      "resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-14.3.0.tgz",
+      "integrity": "sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/web-bluetooth": "^0.0.21",
+        "@vueuse/metadata": "14.3.0",
+        "@vueuse/shared": "14.3.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      },
+      "peerDependencies": {
+        "vue": "^3.5.0"
+      }
+    },
+    "node_modules/@vueuse/metadata": {
+      "version": "14.3.0",
+      "resolved": "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-14.3.0.tgz",
+      "integrity": "sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      }
+    },
+    "node_modules/@vueuse/shared": {
+      "version": "14.3.0",
+      "resolved": "https://registry.npmmirror.com/@vueuse/shared/-/shared-14.3.0.tgz",
+      "integrity": "sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      },
+      "peerDependencies": {
+        "vue": "^3.5.0"
+      }
+    },
+    "node_modules/async-validator": {
+      "version": "4.2.5",
+      "resolved": "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz",
+      "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==",
+      "license": "MIT"
+    },
+    "node_modules/balanced-match": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz",
+      "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/brace-expansion": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.0.tgz",
+      "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "balanced-match": "^1.0.0"
+      }
+    },
+    "node_modules/computeds": {
+      "version": "0.0.1",
+      "resolved": "https://registry.npmmirror.com/computeds/-/computeds-0.0.1.tgz",
+      "integrity": "sha512-7CEBgcMjVmitjYo5q8JTJVra6X5mQ20uTThdK+0kR7UEaDrAWEQcRiBtWJzga4eRpP6afNwwLsX2SET2JhVB1Q==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/csstype": {
+      "version": "3.2.3",
+      "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
+      "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+      "license": "MIT"
+    },
+    "node_modules/dayjs": {
+      "version": "1.11.20",
+      "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.20.tgz",
+      "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==",
+      "license": "MIT"
+    },
+    "node_modules/de-indent": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmmirror.com/de-indent/-/de-indent-1.0.2.tgz",
+      "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/element-plus": {
+      "version": "2.14.0",
+      "resolved": "https://registry.npmmirror.com/element-plus/-/element-plus-2.14.0.tgz",
+      "integrity": "sha512-POgH+TtoreaEKWqYYAVQyE6i8rQMEFqAEublyF29dBA5yASWPLKY6EzfeqBTr2Uv26mPss4vSrMrNPyaK7LX5w==",
+      "license": "MIT",
+      "dependencies": {
+        "@ctrl/tinycolor": "^4.2.0",
+        "@element-plus/icons-vue": "^2.3.2",
+        "@floating-ui/dom": "^1.0.1",
+        "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.8",
+        "@types/lodash": "^4.17.24",
+        "@types/lodash-es": "^4.17.12",
+        "@vueuse/core": "14.3.0",
+        "async-validator": "^4.2.5",
+        "dayjs": "^1.11.20",
+        "lodash": "^4.18.1",
+        "lodash-es": "^4.18.1",
+        "lodash-unified": "^1.0.3",
+        "memoize-one": "^6.0.0",
+        "normalize-wheel-es": "^1.2.0",
+        "vue-component-type-helpers": "^3.2.8"
+      },
+      "peerDependencies": {
+        "vue": "^3.3.7"
+      }
+    },
+    "node_modules/entities": {
+      "version": "7.0.1",
+      "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz",
+      "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=0.12"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/entities?sponsor=1"
+      }
+    },
+    "node_modules/esbuild": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz",
+      "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "bin": {
+        "esbuild": "bin/esbuild"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "optionalDependencies": {
+        "@esbuild/aix-ppc64": "0.21.5",
+        "@esbuild/android-arm": "0.21.5",
+        "@esbuild/android-arm64": "0.21.5",
+        "@esbuild/android-x64": "0.21.5",
+        "@esbuild/darwin-arm64": "0.21.5",
+        "@esbuild/darwin-x64": "0.21.5",
+        "@esbuild/freebsd-arm64": "0.21.5",
+        "@esbuild/freebsd-x64": "0.21.5",
+        "@esbuild/linux-arm": "0.21.5",
+        "@esbuild/linux-arm64": "0.21.5",
+        "@esbuild/linux-ia32": "0.21.5",
+        "@esbuild/linux-loong64": "0.21.5",
+        "@esbuild/linux-mips64el": "0.21.5",
+        "@esbuild/linux-ppc64": "0.21.5",
+        "@esbuild/linux-riscv64": "0.21.5",
+        "@esbuild/linux-s390x": "0.21.5",
+        "@esbuild/linux-x64": "0.21.5",
+        "@esbuild/netbsd-x64": "0.21.5",
+        "@esbuild/openbsd-x64": "0.21.5",
+        "@esbuild/sunos-x64": "0.21.5",
+        "@esbuild/win32-arm64": "0.21.5",
+        "@esbuild/win32-ia32": "0.21.5",
+        "@esbuild/win32-x64": "0.21.5"
+      }
+    },
+    "node_modules/estree-walker": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz",
+      "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
+      "license": "MIT"
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
+      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/he": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmmirror.com/he/-/he-1.2.0.tgz",
+      "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "he": "bin/he"
+      }
+    },
+    "node_modules/lodash": {
+      "version": "4.18.1",
+      "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz",
+      "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
+      "license": "MIT"
+    },
+    "node_modules/lodash-es": {
+      "version": "4.18.1",
+      "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.18.1.tgz",
+      "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
+      "license": "MIT"
+    },
+    "node_modules/lodash-unified": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmmirror.com/lodash-unified/-/lodash-unified-1.0.3.tgz",
+      "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@types/lodash-es": "*",
+        "lodash": "*",
+        "lodash-es": "*"
+      }
+    },
+    "node_modules/magic-string": {
+      "version": "0.30.21",
+      "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz",
+      "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/sourcemap-codec": "^1.5.5"
+      }
+    },
+    "node_modules/memoize-one": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmmirror.com/memoize-one/-/memoize-one-6.0.0.tgz",
+      "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
+      "license": "MIT"
+    },
+    "node_modules/minimatch": {
+      "version": "9.0.9",
+      "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz",
+      "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "brace-expansion": "^2.0.2"
+      },
+      "engines": {
+        "node": ">=16 || 14 >=14.17"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/muggle-string": {
+      "version": "0.3.1",
+      "resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.3.1.tgz",
+      "integrity": "sha512-ckmWDJjphvd/FvZawgygcUeQCxzvohjFO5RxTjj4eq8kw359gFF3E1brjfI+viLMxss5JrHTDRHZvu2/tuy0Qg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/nanoid": {
+      "version": "3.3.12",
+      "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.12.tgz",
+      "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "bin": {
+        "nanoid": "bin/nanoid.cjs"
+      },
+      "engines": {
+        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+      }
+    },
+    "node_modules/normalize-wheel-es": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmmirror.com/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz",
+      "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==",
+      "license": "BSD-3-Clause"
+    },
+    "node_modules/path-browserify": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz",
+      "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/picocolors": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
+      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+      "license": "ISC"
+    },
+    "node_modules/postcss": {
+      "version": "8.5.15",
+      "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.15.tgz",
+      "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/postcss"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "nanoid": "^3.3.12",
+        "picocolors": "^1.1.1",
+        "source-map-js": "^1.2.1"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      }
+    },
+    "node_modules/rollup": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.60.4.tgz",
+      "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "1.0.8"
+      },
+      "bin": {
+        "rollup": "dist/bin/rollup"
+      },
+      "engines": {
+        "node": ">=18.0.0",
+        "npm": ">=8.0.0"
+      },
+      "optionalDependencies": {
+        "@rollup/rollup-android-arm-eabi": "4.60.4",
+        "@rollup/rollup-android-arm64": "4.60.4",
+        "@rollup/rollup-darwin-arm64": "4.60.4",
+        "@rollup/rollup-darwin-x64": "4.60.4",
+        "@rollup/rollup-freebsd-arm64": "4.60.4",
+        "@rollup/rollup-freebsd-x64": "4.60.4",
+        "@rollup/rollup-linux-arm-gnueabihf": "4.60.4",
+        "@rollup/rollup-linux-arm-musleabihf": "4.60.4",
+        "@rollup/rollup-linux-arm64-gnu": "4.60.4",
+        "@rollup/rollup-linux-arm64-musl": "4.60.4",
+        "@rollup/rollup-linux-loong64-gnu": "4.60.4",
+        "@rollup/rollup-linux-loong64-musl": "4.60.4",
+        "@rollup/rollup-linux-ppc64-gnu": "4.60.4",
+        "@rollup/rollup-linux-ppc64-musl": "4.60.4",
+        "@rollup/rollup-linux-riscv64-gnu": "4.60.4",
+        "@rollup/rollup-linux-riscv64-musl": "4.60.4",
+        "@rollup/rollup-linux-s390x-gnu": "4.60.4",
+        "@rollup/rollup-linux-x64-gnu": "4.60.4",
+        "@rollup/rollup-linux-x64-musl": "4.60.4",
+        "@rollup/rollup-openbsd-x64": "4.60.4",
+        "@rollup/rollup-openharmony-arm64": "4.60.4",
+        "@rollup/rollup-win32-arm64-msvc": "4.60.4",
+        "@rollup/rollup-win32-ia32-msvc": "4.60.4",
+        "@rollup/rollup-win32-x64-gnu": "4.60.4",
+        "@rollup/rollup-win32-x64-msvc": "4.60.4",
+        "fsevents": "~2.3.2"
+      }
+    },
+    "node_modules/semver": {
+      "version": "7.8.1",
+      "resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.1.tgz",
+      "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
+      "dev": true,
+      "license": "ISC",
+      "bin": {
+        "semver": "bin/semver.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/source-map-js": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
+      "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/typescript": {
+      "version": "5.9.3",
+      "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz",
+      "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+      "devOptional": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "tsc": "bin/tsc",
+        "tsserver": "bin/tsserver"
+      },
+      "engines": {
+        "node": ">=14.17"
+      }
+    },
+    "node_modules/vite": {
+      "version": "5.4.21",
+      "resolved": "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz",
+      "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "esbuild": "^0.21.3",
+        "postcss": "^8.4.43",
+        "rollup": "^4.20.0"
+      },
+      "bin": {
+        "vite": "bin/vite.js"
+      },
+      "engines": {
+        "node": "^18.0.0 || >=20.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/vitejs/vite?sponsor=1"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.3"
+      },
+      "peerDependencies": {
+        "@types/node": "^18.0.0 || >=20.0.0",
+        "less": "*",
+        "lightningcss": "^1.21.0",
+        "sass": "*",
+        "sass-embedded": "*",
+        "stylus": "*",
+        "sugarss": "*",
+        "terser": "^5.4.0"
+      },
+      "peerDependenciesMeta": {
+        "@types/node": {
+          "optional": true
+        },
+        "less": {
+          "optional": true
+        },
+        "lightningcss": {
+          "optional": true
+        },
+        "sass": {
+          "optional": true
+        },
+        "sass-embedded": {
+          "optional": true
+        },
+        "stylus": {
+          "optional": true
+        },
+        "sugarss": {
+          "optional": true
+        },
+        "terser": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vue": {
+      "version": "3.5.34",
+      "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.34.tgz",
+      "integrity": "sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/compiler-dom": "3.5.34",
+        "@vue/compiler-sfc": "3.5.34",
+        "@vue/runtime-dom": "3.5.34",
+        "@vue/server-renderer": "3.5.34",
+        "@vue/shared": "3.5.34"
+      },
+      "peerDependencies": {
+        "typescript": "*"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vue-component-type-helpers": {
+      "version": "3.3.1",
+      "resolved": "https://registry.npmmirror.com/vue-component-type-helpers/-/vue-component-type-helpers-3.3.1.tgz",
+      "integrity": "sha512-pu58kqxmVyEH6VfNYW1UyEfR3XAnJ27ZXT3yzXxxpjLxVzAbyC35Zk/nm/RMs7ijWnJNSd9fWkeex2OhUsx3MA==",
+      "license": "MIT"
+    },
+    "node_modules/vue-router": {
+      "version": "4.6.4",
+      "resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz",
+      "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/devtools-api": "^6.6.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/posva"
+      },
+      "peerDependencies": {
+        "vue": "^3.5.0"
+      }
+    },
+    "node_modules/vue-template-compiler": {
+      "version": "2.7.16",
+      "resolved": "https://registry.npmmirror.com/vue-template-compiler/-/vue-template-compiler-2.7.16.tgz",
+      "integrity": "sha512-AYbUWAJHLGGQM7+cNTELw+KsOG9nl2CnSv467WobS5Cv9uk3wFcnr1Etsz2sEIHEZvw1U+o9mRlEO6QbZvUPGQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "de-indent": "^1.0.2",
+        "he": "^1.2.0"
+      }
+    },
+    "node_modules/vue-tsc": {
+      "version": "1.8.27",
+      "resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-1.8.27.tgz",
+      "integrity": "sha512-WesKCAZCRAbmmhuGl3+VrdWItEvfoFIPXOvUJkjULi+x+6G/Dy69yO3TBRJDr9eUlmsNAwVmxsNZxvHKzbkKdg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@volar/typescript": "~1.11.1",
+        "@vue/language-core": "1.8.27",
+        "semver": "^7.5.4"
+      },
+      "bin": {
+        "vue-tsc": "bin/vue-tsc.js"
+      },
+      "peerDependencies": {
+        "typescript": "*"
+      }
+    }
+  }
+}

+ 23 - 0
web/package.json

@@ -0,0 +1,23 @@
+{
+  "name": "lqmd-retrieval-web",
+  "private": true,
+  "version": "1.0.0",
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "build": "vue-tsc && vite build",
+    "preview": "vite preview"
+  },
+  "dependencies": {
+    "vue": "^3.4.0",
+    "vue-router": "^4.2.5",
+    "element-plus": "^2.4.4",
+    "@element-plus/icons-vue": "^2.3.1"
+  },
+  "devDependencies": {
+    "@vitejs/plugin-vue": "^4.5.2",
+    "typescript": "^5.3.3",
+    "vite": "^5.0.8",
+    "vue-tsc": "^1.8.27"
+  }
+}

+ 3 - 0
web/src/App.vue

@@ -0,0 +1,3 @@
+<template>
+  <router-view />
+</template>

+ 9 - 0
web/src/api/auth.ts

@@ -0,0 +1,9 @@
+import { api } from '@/utils/request'
+
+export function login(data: { username: string; password: string }) {
+  return api.post('/auth/login', data)
+}
+
+export function refreshToken(token: string) {
+  return api.post('/auth/refresh', { refresh_token: token })
+}

+ 5 - 0
web/src/api/retrieval.ts

@@ -0,0 +1,5 @@
+import { api } from '@/utils/request'
+
+export function searchDocuments(data: any) {
+  return api.post('/retrieval/search', data)
+}

+ 91 - 0
web/src/components/FilterPanel.vue

@@ -0,0 +1,91 @@
+<template>
+  <div class="filter-panel">
+    <el-row :gutter="16">
+      <el-col :span="24">
+        <div class="filter-row">
+          <span class="filter-label">文档类型</span>
+          <el-radio-group v-model="docType" size="small" @change="emitChange">
+            <el-radio-button v-for="t in docTypes" :key="t" :label="t">{{ t }}</el-radio-button>
+          </el-radio-group>
+        </div>
+      </el-col>
+      <el-col :span="24">
+        <div class="filter-row">
+          <span class="filter-label">发布日期</span>
+          <el-date-picker
+            v-model="dateRange"
+            type="daterange"
+            range-separator="至"
+            start-placeholder="年 /月 /日"
+            end-placeholder="年 /月 /日"
+            size="small"
+            value-format="YYYY-MM-DD"
+            @change="emitChange"
+          />
+        </div>
+      </el-col>
+      <el-col :span="24">
+        <div class="filter-row">
+          <span class="filter-label">发布部门</span>
+          <el-radio-group v-model="department" size="small" @change="emitChange">
+            <el-radio-button v-for="d in departments" :key="d" :label="d">{{ d }}</el-radio-button>
+          </el-radio-group>
+        </div>
+      </el-col>
+      <el-col :span="24">
+        <div class="filter-row">
+          <span class="filter-label">关键词</span>
+          <el-input v-model="keyword" placeholder="输入精准关键词进行检索" size="small" clearable style="width: 300px" @input="emitChange" />
+        </div>
+      </el-col>
+    </el-row>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue'
+
+const docTypes = ['全部', '法规制度', '技术标准', '操作指南', '通知公告', '工作报告']
+const departments = ['全部', '工程管理部', '安全管理部', '财务部', '采购部', '人力资源部']
+
+const docType = ref('全部')
+const department = ref('全部')
+const dateRange = ref<[string, string] | null>(null)
+const keyword = ref('')
+
+const emit = defineEmits<{ change: [params: { docType: string; department: string; publishDateFrom: string; publishDateTo: string; keyword: string }] }>()
+
+function emitChange() {
+  emit('change', {
+    docType: docType.value,
+    department: department.value,
+    publishDateFrom: dateRange.value?.[0] || '',
+    publishDateTo: dateRange.value?.[1] || '',
+    keyword: keyword.value,
+  })
+}
+</script>
+
+<style scoped>
+.filter-panel {
+  background: #fff;
+  border-radius: 8px;
+  padding: 16px 20px;
+  margin-top: 24px;
+  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
+}
+.filter-row {
+  display: flex;
+  align-items: center;
+  margin-bottom: 12px;
+}
+.filter-row:last-child {
+  margin-bottom: 0;
+}
+.filter-label {
+  width: 70px;
+  flex-shrink: 0;
+  font-size: 13px;
+  color: #606266;
+}
+</style>

+ 39 - 0
web/src/components/Pagination.vue

@@ -0,0 +1,39 @@
+<template>
+  <div class="pagination-wrapper">
+    <el-pagination
+      v-model:current-page="page"
+      v-model:page-size="pageSize"
+      :page-sizes="[10, 20, 50, 100]"
+      :total="total"
+      layout="total, sizes, prev, pager, next"
+      @current-change="emit('change', page, pageSize)"
+      @size-change="emit('change', 1, pageSize)"
+    />
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, watch } from 'vue'
+
+const props = defineProps<{
+  total: number
+  currentPage: number
+  pageSize: number
+}>()
+
+const emit = defineEmits<{ change: [page: number, pageSize: number] }>()
+
+const page = ref(props.currentPage)
+const pageSize = ref(props.pageSize)
+
+watch(() => props.currentPage, (v) => { page.value = v })
+watch(() => props.pageSize, (v) => { pageSize.value = v })
+</script>
+
+<style scoped>
+.pagination-wrapper {
+  display: flex;
+  justify-content: center;
+  margin-top: 20px;
+}
+</style>

+ 75 - 0
web/src/components/ResultCard.vue

@@ -0,0 +1,75 @@
+<template>
+  <div class="result-card" @click="$emit('preview', item)">
+    <h3 class="result-title">{{ item.title }}</h3>
+    <div class="result-meta">
+      <el-tag size="small" type="info">{{ item.doc_type }}</el-tag>
+      <el-tag size="small">{{ item.department }}</el-tag>
+      <span class="meta-date">{{ item.publish_date || '-' }}</span>
+      <span class="meta-views">{{ item.view_count || 0 }} 次浏览</span>
+    </div>
+    <p class="result-abstract">{{ item.abstract }}</p>
+    <div class="result-tags" v-if="item.tags && item.tags.length">
+      <el-tag v-for="tag in item.tags" :key="tag" size="small" effect="plain">{{ tag }}</el-tag>
+    </div>
+    <div class="result-actions">
+      <el-button size="small" @click.stop="$emit('preview', item)">
+        <el-icon><View /></el-icon> 文档预览
+      </el-button>
+      <el-button size="small" type="primary" @click.stop="$emit('download', item)">
+        <el-icon><Download /></el-icon> 下载
+      </el-button>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { View, Download } from '@element-plus/icons-vue'
+
+defineProps<{ item: any; keyword: string }>()
+defineEmits<{ preview: [item: any]; download: [item: any] }>()
+</script>
+
+<style scoped>
+.result-card {
+  background: #fff;
+  border-radius: 8px;
+  padding: 16px 20px;
+  margin-bottom: 12px;
+  cursor: pointer;
+  transition: box-shadow 0.2s;
+}
+.result-card:hover {
+  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
+}
+.result-title {
+  font-size: 15px;
+  color: #409eff;
+  margin: 0 0 8px;
+}
+.result-meta {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin-bottom: 8px;
+  flex-wrap: wrap;
+}
+.meta-date, .meta-views {
+  font-size: 12px;
+  color: #909399;
+}
+.result-abstract {
+  font-size: 13px;
+  color: #606266;
+  line-height: 1.6;
+  margin: 0 0 8px;
+}
+.result-tags {
+  display: flex;
+  gap: 4px;
+  margin-bottom: 8px;
+}
+.result-actions {
+  display: flex;
+  gap: 8px;
+}
+</style>

+ 34 - 0
web/src/components/SearchBar.vue

@@ -0,0 +1,34 @@
+<template>
+  <div class="search-bar">
+    <el-input
+      v-model="keyword"
+      placeholder="请输入关键词进行搜索"
+      size="large"
+      clearable
+      @keyup.enter="handleSearch"
+    >
+      <template #append>
+        <el-button :icon="Search" @click="handleSearch">搜索</el-button>
+      </template>
+    </el-input>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue'
+import { Search } from '@element-plus/icons-vue'
+
+const keyword = ref('')
+const emit = defineEmits<{ search: [keyword: string] }>()
+
+function handleSearch() {
+  emit('search', keyword.value)
+}
+</script>
+
+<style scoped>
+.search-bar {
+  max-width: 700px;
+  margin: 0 auto;
+}
+</style>

+ 30 - 0
web/src/components/SortDropdown.vue

@@ -0,0 +1,30 @@
+<template>
+  <div class="sort-dropdown">
+    <span class="sort-label">排序方式</span>
+    <el-select v-model="sortBy" size="small" @change="emit('change', sortBy)" style="width: 120px">
+      <el-option label="相关性排序" value="relevance" />
+      <el-option label="最新发布" value="date_desc" />
+      <el-option label="最早发布" value="date_asc" />
+      <el-option label="浏览量" value="views" />
+    </el-select>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue'
+
+const emit = defineEmits<{ change: [value: string] }>()
+const sortBy = ref('relevance')
+</script>
+
+<style scoped>
+.sort-dropdown {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+.sort-label {
+  font-size: 13px;
+  color: #909399;
+}
+</style>

+ 20 - 0
web/src/components/StatsOverview.vue

@@ -0,0 +1,20 @@
+<template>
+  <div class="stats-overview">
+    <span class="stats-text">找到 <strong>{{ total }}</strong> 个相关文档</span>
+  </div>
+</template>
+
+<script setup lang="ts">
+defineProps<{ total: number }>()
+</script>
+
+<style scoped>
+.stats-overview {
+  font-size: 13px;
+  color: #909399;
+  margin-bottom: 8px;
+}
+.stats-text strong {
+  color: #e6a23c;
+}
+</style>

+ 44 - 0
web/src/layouts/DefaultLayout.vue

@@ -0,0 +1,44 @@
+<template>
+  <header class="app-header">
+    <div class="header-inner">
+      <div class="logo">
+        <span class="logo-icon">🔍</span>
+        <span class="logo-text">多维度检索</span>
+      </div>
+    </div>
+  </header>
+  <main class="app-main">
+    <router-view />
+  </main>
+</template>
+
+<style scoped>
+.app-header {
+  background: #fff;
+  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
+  padding: 0 24px;
+  height: 56px;
+  display: flex;
+  align-items: center;
+}
+.header-inner {
+  max-width: 1200px;
+  width: 100%;
+  margin: 0 auto;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+.logo {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  font-size: 18px;
+  font-weight: 600;
+  color: #e6a23c;
+}
+.app-main {
+  min-height: calc(100vh - 56px);
+  background: #f5f7fa;
+}
+</style>

+ 20 - 0
web/src/main.ts

@@ -0,0 +1,20 @@
+import { createApp } from 'vue'
+import ElementPlus from 'element-plus'
+import 'element-plus/dist/index.css'
+import * as ElementPlusIconsVue from '@element-plus/icons-vue'
+import zhCn from 'element-plus/es/locale/lang/zh-cn'
+
+import App from './App.vue'
+import router from './router'
+import './styles/index.css'
+
+const app = createApp(App)
+
+app.use(router)
+app.use(ElementPlus, { locale: zhCn })
+
+for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
+  app.component(key, component)
+}
+
+app.mount('#app')

+ 41 - 0
web/src/router/index.ts

@@ -0,0 +1,41 @@
+import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'
+
+const routes: RouteRecordRaw[] = [
+  {
+    path: '/login',
+    name: 'Login',
+    component: () => import('@/views/auth/Login.vue'),
+  },
+  {
+    path: '/oauth/callback',
+    name: 'OAuthCallback',
+    component: () => import('@/views/auth/OAuthCallback.vue'),
+  },
+  {
+    path: '/',
+    component: () => import('@/layouts/DefaultLayout.vue'),
+    children: [
+      {
+        path: '',
+        name: 'Search',
+        component: () => import('@/views/SearchPage.vue'),
+      },
+    ],
+  },
+]
+
+const router = createRouter({
+  history: createWebHistory(),
+  routes,
+})
+
+router.beforeEach((to, from, next) => {
+  const token = sessionStorage.getItem('access_token')
+  if (to.path !== '/login' && to.path !== '/oauth/callback' && !token) {
+    next('/login')
+  } else {
+    next()
+  }
+})
+
+export default router

+ 25 - 0
web/src/styles/index.css

@@ -0,0 +1,25 @@
+* {
+  margin: 0;
+  padding: 0;
+  box-sizing: border-box;
+}
+
+body {
+  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
+  background: #f5f7fa;
+  color: #303133;
+  -webkit-font-smoothing: antialiased;
+}
+
+#app {
+  min-height: 100vh;
+}
+
+a {
+  color: #409eff;
+  text-decoration: none;
+}
+
+a:hover {
+  color: #66b1ff;
+}

+ 36 - 0
web/src/utils/request.ts

@@ -0,0 +1,36 @@
+const BASE_URL = '/api/v1'
+
+async function request<T = any>(url: string, options: RequestInit = {}): Promise<T> {
+  const token = sessionStorage.getItem('access_token')
+  const headers: Record<string, string> = { 'Content-Type': 'application/json' }
+  if (options.headers) {
+    Object.entries(options.headers as Record<string, string>).forEach(([k, v]) => {
+      headers[k] = v
+    })
+  }
+  if (token) headers.Authorization = `Bearer ${token}`
+
+  const response = await fetch(BASE_URL + url, { ...options, headers })
+
+  const newToken = response.headers.get('X-New-Token')
+  if (newToken) sessionStorage.setItem('access_token', newToken)
+
+  if (response.status === 401) {
+    sessionStorage.removeItem('access_token')
+    window.location.href = '/login'
+  }
+
+  if (!response.ok) {
+    const err = await response.json().catch(() => ({}))
+    throw new Error(err.message || `请求失败 (${response.status})`)
+  }
+
+  return response.json()
+}
+
+export const api = {
+  get: <T = any>(url: string) => request<T>(url, { method: 'GET' }),
+  post: <T = any>(url: string, body?: any) => request<T>(url, { method: 'POST', body: JSON.stringify(body) }),
+}
+
+export default api

+ 140 - 0
web/src/views/SearchPage.vue

@@ -0,0 +1,140 @@
+<template>
+  <div class="search-page">
+    <SearchBar @search="handleSearch" />
+    <FilterPanel @change="handleFilterChange" />
+    <div class="result-area">
+      <div class="result-header">
+        <StatsOverview :total="total" />
+        <SortDropdown @change="handleSort" />
+      </div>
+      <div class="result-list" v-loading="loading">
+        <ResultCard
+          v-for="item in results"
+          :key="item.id"
+          :item="item"
+          :keyword="searchParams.keyword"
+          @preview="handlePreview"
+          @download="handleDownload"
+        />
+        <el-empty v-if="!loading && results.length === 0 && searched" description="暂无搜索结果" />
+      </div>
+      <Pagination
+        v-if="total > 0"
+        :total="total"
+        :current-page="page"
+        :page-size="pageSize"
+        @change="handlePageChange"
+      />
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive } from 'vue'
+import { useRouter } from 'vue-router'
+import { searchDocuments } from '@/api/retrieval'
+import { ElMessage } from 'element-plus'
+import SearchBar from '@/components/SearchBar.vue'
+import FilterPanel from '@/components/FilterPanel.vue'
+import ResultCard from '@/components/ResultCard.vue'
+import Pagination from '@/components/Pagination.vue'
+import StatsOverview from '@/components/StatsOverview.vue'
+import SortDropdown from '@/components/SortDropdown.vue'
+
+const router = useRouter()
+
+const searchParams = reactive({
+  keyword: '',
+  docType: '全部',
+  department: '全部',
+  publishDateFrom: '',
+  publishDateTo: '',
+  sortBy: 'relevance',
+})
+
+const loading = ref(false)
+const results = ref<any[]>([])
+const total = ref(0)
+const page = ref(1)
+const pageSize = ref(20)
+const searched = ref(false)
+
+function handleSearch(kw: string) {
+  searchParams.keyword = kw
+  page.value = 1
+  doSearch()
+}
+
+function handleFilterChange(params: any) {
+  Object.assign(searchParams, params)
+  page.value = 1
+  doSearch()
+}
+
+function handleSort(sortBy: string) {
+  searchParams.sortBy = sortBy
+  page.value = 1
+  doSearch()
+}
+
+function handlePageChange(p: number, ps: number) {
+  page.value = p
+  pageSize.value = ps
+  doSearch()
+}
+
+function handlePreview(item: any) {
+  // 预览功能由外部接口提供
+  ElMessage.info('预览功能开发中')
+}
+
+function handleDownload(item: any) {
+  ElMessage.info('下载功能开发中')
+}
+
+async function doSearch() {
+  loading.value = true
+  searched.value = true
+  try {
+    const body: Record<string, any> = {
+      page: page.value,
+      page_size: pageSize.value,
+      sort_by: searchParams.sortBy,
+    }
+    if (searchParams.keyword) body.keyword = searchParams.keyword
+    if (searchParams.docType !== '全部') body.doc_type = searchParams.docType
+    if (searchParams.department !== '全部') body.department = searchParams.department
+    if (searchParams.publishDateFrom) body.publish_date_from = searchParams.publishDateFrom
+    if (searchParams.publishDateTo) body.publish_date_to = searchParams.publishDateTo
+
+    const res = await searchDocuments(body)
+    const data = (res as any).data
+    results.value = data?.items || []
+    total.value = data?.total || 0
+  } catch (e: any) {
+    ElMessage.error(e.message || '检索失败')
+  } finally {
+    loading.value = false
+  }
+}
+</script>
+
+<style scoped>
+.search-page {
+  max-width: 900px;
+  margin: 0 auto;
+  padding: 32px 16px;
+}
+.result-area {
+  margin-top: 20px;
+}
+.result-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 12px;
+}
+.result-list {
+  min-height: 200px;
+}
+</style>

+ 74 - 0
web/src/views/auth/Login.vue

@@ -0,0 +1,74 @@
+<template>
+  <div class="login-page">
+    <div class="login-card">
+      <h2>多维度检索</h2>
+      <el-form :model="form" @submit.prevent="handleLogin">
+        <el-form-item>
+          <el-input v-model="form.username" placeholder="用户名" size="large" prefix-icon="User" />
+        </el-form-item>
+        <el-form-item>
+          <el-input v-model="form.password" type="password" placeholder="密码" size="large" prefix-icon="Lock" show-password />
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" size="large" style="width: 100%" :loading="loading" @click="handleLogin">
+            登录
+          </el-button>
+        </el-form-item>
+      </el-form>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue'
+import { useRouter } from 'vue-router'
+import { login } from '@/api/auth'
+import { ElMessage } from 'element-plus'
+
+const router = useRouter()
+const loading = ref(false)
+
+const form = ref({ username: '', password: '' })
+
+async function handleLogin() {
+  if (!form.value.username || !form.value.password) {
+    ElMessage.warning('请输入用户名和密码')
+    return
+  }
+  loading.value = true
+  try {
+    const res = await login(form.value)
+    const data = (res as any).data
+    sessionStorage.setItem('access_token', data.access_token)
+    sessionStorage.setItem('refresh_token', data.refresh_token)
+    ElMessage.success('登录成功')
+    router.push('/')
+  } catch (e: any) {
+    ElMessage.error(e.response?.data?.message || e.message || '登录失败')
+  } finally {
+    loading.value = false
+  }
+}
+</script>
+
+<style scoped>
+.login-page {
+  height: 100vh;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: #f5f7fa;
+}
+.login-card {
+  width: 380px;
+  background: #fff;
+  border-radius: 12px;
+  padding: 40px 32px;
+  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
+}
+.login-card h2 {
+  text-align: center;
+  color: #e6a23c;
+  margin-bottom: 32px;
+}
+</style>

+ 37 - 0
web/src/views/auth/OAuthCallback.vue

@@ -0,0 +1,37 @@
+<template>
+  <div class="callback-page">
+    <p>正在处理登录...</p>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { onMounted } from 'vue'
+import { useRouter, useRoute } from 'vue-router'
+import { ElMessage } from 'element-plus'
+
+const router = useRouter()
+const route = useRoute()
+
+onMounted(() => {
+  const token = (route.query.token || '') as string
+  if (token) {
+    sessionStorage.setItem('access_token', token)
+    ElMessage.success('登录成功')
+    router.push('/')
+  } else {
+    ElMessage.error('登录失败')
+    router.push('/login')
+  }
+})
+</script>
+
+<style scoped>
+.callback-page {
+  height: 100vh;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 16px;
+  color: #909399;
+}
+</style>

+ 25 - 0
web/tsconfig.json

@@ -0,0 +1,25 @@
+{
+  "compilerOptions": {
+    "target": "ES2020",
+    "useDefineForClassFields": true,
+    "module": "ESNext",
+    "lib": ["ES2020", "DOM", "DOM.Iterable"],
+    "skipLibCheck": true,
+    "moduleResolution": "bundler",
+    "allowImportingTsExtensions": true,
+    "resolveJsonModule": true,
+    "isolatedModules": true,
+    "noEmit": true,
+    "jsx": "preserve",
+    "strict": true,
+    "noUnusedLocals": false,
+    "noUnusedParameters": false,
+    "noFallthroughCasesInSwitch": true,
+    "baseUrl": ".",
+    "paths": {
+      "@/*": ["src/*"]
+    }
+  },
+  "include": ["src/**/*.ts", "src/**/*.vue", "env.d.ts"],
+  "references": [{ "path": "./tsconfig.node.json" }]
+}

+ 10 - 0
web/tsconfig.node.json

@@ -0,0 +1,10 @@
+{
+  "compilerOptions": {
+    "composite": true,
+    "skipLibCheck": true,
+    "module": "ESNext",
+    "moduleResolution": "bundler",
+    "allowSyntheticDefaultImports": true
+  },
+  "include": ["vite.config.ts"]
+}

+ 21 - 0
web/vite.config.ts

@@ -0,0 +1,21 @@
+import { defineConfig } from 'vite'
+import vue from '@vitejs/plugin-vue'
+import { resolve } from 'path'
+
+export default defineConfig({
+  plugins: [vue()],
+  resolve: {
+    alias: {
+      '@': resolve(__dirname, 'src'),
+    },
+  },
+  server: {
+    port: 9007,
+    proxy: {
+      '/api': {
+        target: 'http://localhost:8007',
+        changeOrigin: true,
+      },
+    },
+  },
+})

+ 326 - 0
项目/项目结构.md

@@ -0,0 +1,326 @@
+# LQMDRetrieval 项目结构说明
+
+## 1. 项目概述
+
+LQMDRetrieval 是一个**多维度文档检索应用端**,提供基于文档类型、发布日期、发布部门、关键词的多维度检索能力。检索引擎直接调用外部检索接口,实现在精准查询的同时支持文档在线预览。
+
+| 功能域 | 说明 |
+|---|---|
+| 多维检索 | 按文档类型、发布日期、部门、关键词四维组合筛选与检索(调用外部接口) |
+| 文档预览 | 通过文件 UUID 读取 OSS 源文件,实现在线预览展示(调用外部接口) |
+| 分页与排序 | 支持检索结果分页展示与多维度排序管理(调用外部接口) |
+| 数据统计 | 统计检索结果文档总量,展示数据概览(调用外部接口) |
+
+## 2. 技术栈
+
+| 类别 | 技术 |
+|---|---|
+| 语言 | Python 3.12 |
+| Web 框架 | FastAPI 0.104 + Uvicorn 0.24 |
+| ORM | SQLAlchemy 2.0(异步) |
+| 主数据库 | MySQL(aiomysql) |
+| 缓存 | Redis(aioredis) |
+| 搜索引擎 | 调用外部检索接口 |
+| 认证 | python-jose (JWT) + passlib + bcrypt |不确定
+| 数据校验 | Pydantic 2.x |
+| 容器化 | Docker + Docker Compose |
+| 前端框架 | Vue 3 + Vite + Element Plus |
+| 路由 | Vue Router 4 |
+| PDF 预览 | PDF.js |
+
+## 3. 顶层目录结构
+
+```
+LQMDRetrieval/
+├── backend/                    # 后端项目(FastAPI)
+│   ├── requirements/           # Python 依赖管理
+│   │   ├── base.txt            # 生产依赖
+│   │   └── requirements.txt    # 完整依赖
+│   ├── docker/                 # Docker Compose 编排
+│   │   ├── docker-compose.yml
+│   │   ── nginx/
+│   ├── run.sh                  # 服务管理脚本
+│   ├── run_server.py           # Python 入口
+│   ├── Dockerfile
+│   ├── src/                    # 后端源代码
+│   └── .gitignore
+│
+├── web/                        # 前端项目(Vue 3)
+│   ├── src/
+│   ├── index.html
+│   ├── package.json
+│   ├── vite.config.ts
+│   ├── tsconfig.json
+│   └── .gitignore
+│
+├── docker/                     # 顶层 Docker 编排(前后端统一启动)
+│   └── docker-compose.yml
+│
+├── 项目/                        # 设计文档、API 接口定义、需求文档
+│
+├── README.md
+└── .gitignore
+```
+
+## 4. 后端源代码目录结构(backend/src/)
+
+```
+backend/src/
+├── path_config.py                      # 自动配置 Python 导入路径
+│
+── app/
+    ├── server/                         # FastAPI 应用工厂 & 入口
+    │   ── app.py                      # 主 FastAPI 实例:中间件、CORS、异常处理、路由注册
+    │
+    ├── base/                           # 基础设施 / 连接管理
+    │   ├── async_mysql_connection.py   # SQLAlchemy 异步 MySQL 连接
+    │   └── async_redis_connection.py   # Redis 连接
+    │
+    ├── core/                           # 核心框架代码
+    │   ├── config.py                   # ConfigHandler — 全局配置解析
+    │   └── exceptions.py               # 自定义异常 BaseAPIException
+    │
+    ├── middleware/                     # HTTP 中间件
+    │   └── token_refresh_middleware.py # JWT Token 自动刷新中间件
+    │
+    ├── models/                         # 通用 SQLAlchemy 模型
+    │   └── base.py                     # 基础模型类
+    │
+    ├── schemas/                        # 基础 Pydantic 响应结构
+    │   └── base.py                     # ResponseSchema / PaginationSchema
+    │
+    ├── utils/                          # 工具函数
+    │   ├── auth_dependency.py          # 认证依赖注入
+    │   └── security.py                 # 安全工具(密码哈希、Token 生成)
+    │
+    ├── services/                       # 业务逻辑层
+    │   ├── auth_service.py             # 用户登录认证
+    │   ├── jwt_token.py                # JWT Token 管理
+    │   └── retrieval_service.py        # 多维检索服务(调用外部接口)
+    │
+    ├── retrieval/                      # 【多维检索模块】
+    │   ├── models/                     # 检索相关 SQLAlchemy 模型
+    │   │   └── search_history.py       # 搜索历史模型(暂保留)
+    │   └── schemas/
+    │
+    ── auth/                           # 【授权管理模块】
+        ├── models/                     # 认证 SQLAlchemy 模型
+        │   └── user.py                 # 用户模型
+        └── schemas/                    # 认证 Pydantic 结构
+            └── auth_schema.py
+
+├── views/                              # HTTP 路由定义(API 端点)
+│   ├── auth_view.py                    # 认证路由(登录、Token 刷新)
+│   └── retrieval_view.py               # 多维检索路由(核心接口)
+│
+└── api/                                # 对外 API 定义(v1 版本)
+    └── v1/
+        └── retrieval/                  # 检索对外 API
+```
+
+## 5. 前端项目结构(web/)
+
+```
+web/
+├── src/
+│   ├── main.ts                         # 应用入口
+│   ├── App.vue                         # 根组件
+│   ├── router/                         # 路由配置
+│   │   └── index.ts
+│   ├── api/                            # API 请求(按模块划分)
+│   │   ├── auth.ts                     # 认证 API
+│   │   └── retrieval.ts                # 检索 API
+│   ├── components/                     # 可复用组件
+│   │   ├── SearchBar.vue               # 搜索栏组件
+│   │   ├── FilterPanel.vue             # 筛选面板组件
+│   │   ├── ResultCard.vue              # 结果卡片组件
+│   │   ├── Pagination.vue              # 分页组件
+│   │   ├── SortDropdown.vue            # 排序下拉组件
+│   │   └── StatsOverview.vue           # 统计概览组件
+│   ├── layouts/                        # 布局组件
+│   │   └── DefaultLayout.vue
+│   ├── views/                          # 页面视图
+│   │   ├── SearchPage.vue              # 检索主页
+│   │   └── Auth/
+│   │       ├── Login.vue               # 登录页
+│   │       └── OAuthCallback.vue       # SSO 回调页
+│   ├── utils/                          # 工具函数
+│   │   ├── request.ts                  # Fetch 封装
+│   │   └── helpers.ts
+│   ── styles/                         # 全局样式
+│       └── index.css
+│
+├── index.html
+├── package.json
+├── vite.config.ts
+├── tsconfig.json
+└── .gitignore
+```
+
+## 6. 功能模块详细说明
+
+### 6.1 多维检索(retrieval/)— 调用外部接口
+
+| 子功能 | 说明 |
+|---|---|
+| 按文档类型检索 | 根据文档类型进行筛选(法规制度、技术标准、操作指南、通知公告、工作报告等) |
+| 按发布日期检索 | 根据发布时间进行范围筛选 |
+| 按部门检索 | 根据发布部门进行筛选(工程管理部、安全管理部、财务部、采购部、人力资源部) |
+| 按关键词检索 | 调用外部检索接口,实现精准查询 |
+| 多维组合检索 | 支持上述维度任意组合,实现交叉筛选 |
+
+### 6.2 文档预览(preview/)— 调用外部接口
+
+| 子功能 | 说明 |
+|---|---|
+| 在线预览 | 通过文件 UUID 从 OSS 读取源文件,实现在线预览展示 |
+| PDF 预览 | 支持 PDF 文档在线渲染,包含放大、缩小、旋转、打印功能 |
+| 分页导航 | 预览面板支持文档页码跳转与缩略图导航 |
+
+### 6.3 分页与排序 — 调用外部接口
+
+| 子功能 | 说明 |
+|---|---|
+| 分页展示 | 支持检索结果分页(默认每页 20 条) |
+| 多维度排序 | 支持按相关性、发布时间、浏览量等多维度排序 |
+
+### 6.4 数据统计 — 调用外部接口
+
+| 子功能 | 说明 |
+|---|---|
+| 结果总量统计 | 统计当前检索条件下匹配的文档总量 |
+| 数据概览 | 按文档类型、发布部门、时间维度展示统计概览 |
+
+## 7. 关键文件说明
+
+| 文件 | 作用 |
+|---|---|
+| `backend/src/app/server/app.py` | FastAPI 应用实例,负责中间件、CORS、异常处理、路由注册 |
+| `backend/src/app/core/config.py` | 全局配置读取(数据库连接、外部接口地址等) |
+| `backend/src/app/base/async_mysql_connection.py` | 异步 MySQL 连接池管理 |
+| `backend/src/app/base/async_redis_connection.py` | Redis 连接池管理 |
+| `backend/src/app/services/retrieval_service.py` | 多维检索服务:调用外部检索接口 |
+| `backend/src/app/services/auth_service.py` | 用户登录认证服务 |
+| `backend/src/app/services/jwt_token.py` | JWT Token 管理 |
+| `backend/src/app/middleware/token_refresh_middleware.py` | Token 自动刷新中间件 |
+| `backend/run_server.py` | 应用启动入口,uvicorn 启动器 |
+| `backend/run.sh` | Shell 服务管理脚本 |
+
+## 8. 数据流向
+
+```
+客户端请求
+    ↓
+Nginx 反向代理
+    ↓
+FastAPI (uvicorn :8000)
+    ↓
+middleware/token_refresh_middleware.py(Token 刷新)
+    ↓
+views/retrieval_view.py(检索路由匹配)
+    ↓
+services/retrieval_service.py(调用外部检索接口)
+    ↓
+MySQL(本地用户数据存储)
+Redis(缓存 / Session)
+```
+
+## 9. 核心检索流程
+
+```
+用户输入关键词 + 筛选条件
+    ↓
+retrieval_service.py 转发请求到外部检索接口
+    ↓
+外部接口返回检索结果
+    ↓
+返回分页结果给前端
+```
+
+## 10. 项目架构分层
+
+```
+┌─────────────────────────────────────────────
+│              views/ (路由层)                  │  ← HTTP 端点定义,按功能划分
+├─────────────────────────────────────────────┤
+│           services/ (业务逻辑层)              │  ← 检索转发、认证业务规则
+├─────────────────────────────────────────────┤
+│  retrieval/ (检索模块) │ auth/ (认证模块)     │  ← 按功能域划分的 models + schemas
+├─────────────────────────────────────────────┤
+│           utils/ (工具层)                    │  ← 认证依赖、安全工具
+├─────────────────────────────────────────────┤
+│           base/ (基础设施层)                 │  ← MySQL / Redis 连接
+├─────────────────────────────────────────────┤
+│    core/ (核心框架)  │ middleware/ (中间件)   │  ← 配置、异常、Token 刷新
+└─────────────────────────────────────────────
+```
+
+## 11. 前端页面结构
+
+```
+┌─────────────────────────────────────────────────┐
+│                    Header                        │  ← Logo + 多维度检索标题
+─────────────────────────────────────────────────┤
+│  ┌───────────────────────────────────────────┐  │
+│  │              SearchBar                      │  │  ← 搜索输入框 + 搜索按钮
+│  └───────────────────────────────────────────┘  │
+─────────────────────────────────────────────────┤
+│  ┌───────────────────────────────────────────┐  │
+│  │             FilterPanel                     │  │  ← 文档类型/日期/部门/关键词筛选
+│  │  - 文档类型标签(全部/法规/标准/指南...)   │  │
+│  │  - 发布日期范围选择器                       │  │
+│  │  - 发布部门标签(全部/工程/安全/财务...)  │  │
+│  │  - 精准关键词输入                          │  │
+│  └───────────────────────────────────────────┘  │
+├─────────────────────────────────────────────────┤
+│  ──────────────┐  ┌──────────────────────────┐ │
+│  │ StatsOverview│  │  Sort & Pagination       │ │  ← 统计概览 + 排序/分页
+│  │ (找到 N 个文档)│  │  相关性排序 / 时间排序    │ │
+│  └──────────────┘  └──────────────────────────┘ │
+─────────────────────────────────────────────────┤
+│  ───────────────────────────────────────────┐  │
+│  │           ResultCard (列表)                 │  │  ← 检索结果卡片列表
+│  │  - 文档标题                                 │  │
+│  │  - 类型/部门/日期/浏览量                    │  │
+│  │  - 摘要(关键词高亮)                       │  │
+│  │  - 标签                                     │  │
+│  │  - 操作:文档预览 / 下载                    │  │
+│  └───────────────────────────────────────────┘  │
+─────────────────────────────────────────────────┘
+
+文档预览弹窗(侧边抽屉):
+┌─────────────────────────────────────────────────┐
+│  PDF 标题 + 操作栏(放大/缩小/旋转/打印)         │
+├─────────────────────────────────────────────────┤
+│  ──────────────────────────────────────────┐  │
+│  │          PDF.js 渲染区域                    │  │  ← PDF 文档在线预览
+│  │          (支持翻页、缩放)                   │  │
+│  └───────────────────────────────────────────┘  │
+├─────────────────────────────────────────────────┤
+│  下载按钮                                        │
+└─────────────────────────────────────────────────┘
+```
+
+## 12. 数据库设计概要
+
+### 12.1 搜索历史表(t_doc_search_history)
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| id | BIGINT | 主键 |
+| user_id | BIGINT | 用户 ID |
+| keywords | VARCHAR(255) | 搜索关键词 |
+| filters | JSON | 筛选条件快照 |
+| result_count | INT | 结果数量 |
+| created_at | DATETIME | 搜索时间 |
+
+## 13. 与 LQAdminPlatform 的关系
+
+| 维度 | LQAdminPlatform | LQMDRetrieval |
+|---|---|---|
+| 定位 | 综合管理平台(系统管理+SSO+样本中心) | 专用检索应用端 |
+| 检索能力 | 样本中心内嵌语义检索 | 调用外部检索接口 |
+| 文档预览 | 样本中心文档管理 | 调用外部预览接口 |
+| 前端 | Vue 3 + Element Plus 管理后台 | Vue 3 + Element Plus 检索应用 |
+| 后端架构 | 多模块(system/oauth/sample) | 双模块(retrieval/auth) |
+| 技术复用 | - | 复用 LQAdminPlatform 的基础设施层(MySQL/Redis 连接、认证体系) |