diff --git a/create-vue-app.bat b/create-vue-app.bat new file mode 100644 index 0000000..69eed4e --- /dev/null +++ b/create-vue-app.bat @@ -0,0 +1,57 @@ +@echo off +chcp 65001 >nul +echo 🚀 开始创建 web-app-vue 项目... +echo. + +REM 检查是否已存在 +if exist "web-app-vue" ( + echo ❌ web-app-vue 目录已存在,请先删除或重命名 + pause + exit /b 1 +) + +REM 创建 Vue 项目 +echo 📦 创建 Vue 3 + TypeScript 项目... +call npm create vite@latest web-app-vue -- --template vue-ts + +REM 进入项目目录 +cd web-app-vue + +echo. +echo 📚 安装依赖... +call npm install + +echo. +echo 🎨 安装 Element Plus... +call npm install element-plus + +echo. +echo 🛣️ 安装 Vue Router 和 Pinia... +call npm install vue-router@4 pinia + +echo. +echo 🌐 安装 Axios... +call npm install axios + +echo. +echo 🔧 安装工具库... +call npm install @vueuse/core + +echo. +echo 🛠️ 安装开发依赖... +call npm install -D sass unplugin-vue-components unplugin-auto-import + +echo. +echo ✅ 项目创建完成! +echo. +echo 📖 下一步: +echo cd web-app-vue +echo npm run dev +echo. +echo 🌐 访问地址: +echo http://localhost:3000 +echo. +echo 📚 详细文档: +echo 请查看 docs\Vue重构方案.md +echo. +pause diff --git a/create-vue-app.sh b/create-vue-app.sh new file mode 100755 index 0000000..61db94f --- /dev/null +++ b/create-vue-app.sh @@ -0,0 +1,356 @@ +#!/bin/bash + +# 云酒馆 Vue 3 前台应用快速创建脚本 + +echo "🚀 开始创建 web-app-vue 项目..." + +# 检查是否已存在 +if [ -d "web-app-vue" ]; then + echo "❌ web-app-vue 目录已存在,请先删除或重命名" + exit 1 +fi + +# 创建 Vue 项目 +echo "📦 创建 Vue 3 + TypeScript 项目..." +npm create vite@latest web-app-vue -- --template vue-ts + +# 进入项目目录 +cd web-app-vue + +echo "📚 安装依赖..." +npm install + +# 安装 Element Plus +echo "🎨 安装 Element Plus..." +npm install element-plus + +# 安装路由和状态管理 +echo "🛣️ 安装 Vue Router 和 Pinia..." +npm install vue-router@4 pinia + +# 安装 HTTP 客户端 +echo "🌐 安装 Axios..." +npm install axios + +# 安装工具库 +echo "🔧 安装工具库..." +npm install @vueuse/core + +# 安装开发依赖 +echo "🛠️ 安装开发依赖..." +npm install -D sass +npm install -D unplugin-vue-components unplugin-auto-import + +# 创建目录结构 +echo "📂 创建项目目录结构..." +mkdir -p src/api +mkdir -p src/assets/styles +mkdir -p src/components/common +mkdir -p src/components/chat +mkdir -p src/components/character +mkdir -p src/components/settings +mkdir -p src/composables +mkdir -p src/layouts +mkdir -p src/router +mkdir -p src/stores +mkdir -p src/types +mkdir -p src/utils +mkdir -p src/views/auth +mkdir -p src/views/home +mkdir -p src/views/character +mkdir -p src/views/chat +mkdir -p src/views/settings +mkdir -p src/views/user + +# 创建环境配置文件 +echo "⚙️ 创建环境配置..." +cat > .env.development << 'EOF' +# 开发环境配置 +VITE_API_BASE_URL=http://localhost:8888 +VITE_WS_URL=ws://localhost:8888 +EOF + +cat > .env.production << 'EOF' +# 生产环境配置 +VITE_API_BASE_URL=https://your-domain.com +VITE_WS_URL=wss://your-domain.com +EOF + +# 创建 Vite 配置 +echo "⚙️ 配置 Vite..." +cat > vite.config.ts << 'EOF' +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import { resolve } from 'path' +import AutoImport from 'unplugin-auto-import/vite' +import Components from 'unplugin-vue-components/vite' +import { ElementPlusResolver } from 'unplugin-vue-components/resolvers' + +export default defineConfig({ + plugins: [ + vue(), + AutoImport({ + resolvers: [ElementPlusResolver()], + imports: ['vue', 'vue-router', 'pinia'], + dts: 'src/auto-imports.d.ts', + }), + Components({ + resolvers: [ElementPlusResolver()], + dts: 'src/components.d.ts', + }), + ], + resolve: { + alias: { + '@': resolve(__dirname, 'src'), + }, + }, + server: { + port: 3000, + proxy: { + '/app': { + target: 'http://localhost:8888', + changeOrigin: true, + }, + '/api': { + target: 'http://localhost:8888', + changeOrigin: true, + }, + }, + }, + build: { + outDir: 'dist', + sourcemap: false, + rollupOptions: { + output: { + manualChunks: { + 'element-plus': ['element-plus'], + 'vue-vendor': ['vue', 'vue-router', 'pinia'], + }, + }, + }, + }, +}) +EOF + +# 创建 TypeScript 配置 +echo "⚙️ 配置 TypeScript..." +cat > tsconfig.json << 'EOF' +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "preserve", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + + /* Path mapping */ + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], + "references": [{ "path": "./tsconfig.node.json" }] +} +EOF + +# 创建基础文件 +echo "📝 创建基础文件..." + +# 创建 main.ts +cat > src/main.ts << 'EOF' +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import ElementPlus from 'element-plus' +import 'element-plus/dist/index.css' +import 'element-plus/theme-chalk/dark/css-vars.css' +import router from './router' +import App from './App.vue' +import './assets/styles/index.scss' + +const app = createApp(App) +const pinia = createPinia() + +app.use(pinia) +app.use(router) +app.use(ElementPlus) + +app.mount('#app') +EOF + +# 创建路由配置 +cat > src/router/index.ts << 'EOF' +import { createRouter, createWebHistory } from 'vue-router' +import type { RouteRecordRaw } from 'vue-router' + +const routes: RouteRecordRaw[] = [ + { + path: '/auth', + name: 'Auth', + component: () => import('@/layouts/AuthLayout.vue'), + children: [ + { + path: 'login', + name: 'Login', + component: () => import('@/views/auth/Login.vue'), + }, + { + path: 'register', + name: 'Register', + component: () => import('@/views/auth/Register.vue'), + }, + ], + }, + { + path: '/', + component: () => import('@/layouts/DefaultLayout.vue'), + children: [ + { + path: '', + name: 'Home', + component: () => import('@/views/home/Index.vue'), + }, + { + path: 'characters', + name: 'Characters', + component: () => import('@/views/character/List.vue'), + }, + { + path: 'chat', + name: 'Chat', + component: () => import('@/views/chat/Index.vue'), + }, + { + path: 'settings', + name: 'Settings', + component: () => import('@/views/settings/Index.vue'), + }, + ], + }, +] + +const router = createRouter({ + history: createWebHistory(), + routes, +}) + +// 路由守卫 +router.beforeEach((to, from, next) => { + const token = localStorage.getItem('st_access_token') + + if (!token && !to.path.startsWith('/auth')) { + next('/auth/login') + } else if (token && to.path.startsWith('/auth')) { + next('/') + } else { + next() + } +}) + +export default router +EOF + +# 创建全局样式 +cat > src/assets/styles/index.scss << 'EOF' +/* 全局样式 */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'Segoe UI', 'Noto Sans', -apple-system, BlinkMacSystemFont, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +#app { + width: 100%; + height: 100vh; +} + +/* 自定义滚动条 */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: var(--el-fill-color-light); +} + +::-webkit-scrollbar-thumb { + background: var(--el-border-color-darker); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--el-border-color-dark); +} +EOF + +# 创建 README +cat > README.md << 'EOF' +# 云酒馆前台应用 (Vue 3) + +## 技术栈 + +- Vue 3 + TypeScript +- Vite 5 +- Element Plus +- Vue Router 4 +- Pinia +- Axios + +## 开发 + +```bash +# 安装依赖 +npm install + +# 启动开发服务器 +npm run dev + +# 构建生产版本 +npm run build + +# 预览生产构建 +npm run preview +``` + +## 访问地址 + +- 开发环境: http://localhost:3000 +- API 代理: http://localhost:8888 + +## 项目结构 + +详见 `/docs/Vue重构方案.md` +EOF + +echo "✅ 项目创建完成!" +echo "" +echo "📖 下一步:" +echo " cd web-app-vue" +echo " npm run dev" +echo "" +echo "🌐 访问地址:" +echo " http://localhost:3000" +echo "" +echo "📚 详细文档:" +echo " 请查看 docs/Vue重构方案.md" diff --git a/deploy/.gitignore b/deploy/.gitignore new file mode 100644 index 0000000..25035a7 --- /dev/null +++ b/deploy/.gitignore @@ -0,0 +1,14 @@ +# PostgreSQL 数据 +postgres/data/ +*.sql.backup +*.dump + +# 日志文件 +*.log + +# 环境变量文件(如果创建的话) +.env +.env.local + +# pgAdmin 数据 +pgadmin_data/ diff --git a/deploy/README.postgres.md b/deploy/README.postgres.md new file mode 100644 index 0000000..813f32b --- /dev/null +++ b/deploy/README.postgres.md @@ -0,0 +1,293 @@ +# PostgreSQL Docker 部署指南 + +## 📋 目录结构 + +``` +deploy/ +├── docker-compose.postgres.yml # Docker Compose 配置 +├── postgres/ +│ ├── init.sql # 数据库初始化脚本 +│ └── postgresql.conf # PostgreSQL 配置文件 +└── README.postgres.md # 本文档 +``` + +## 🚀 快速启动 + +### 1. 启动数据库 + +```bash +# 在 deploy 目录下执行 +cd deploy +docker-compose -f docker-compose.postgres.yml up -d +``` + +### 2. 查看日志 + +```bash +# 查看 PostgreSQL 日志 +docker-compose -f docker-compose.postgres.yml logs -f postgres + +# 查看所有服务日志 +docker-compose -f docker-compose.postgres.yml logs -f +``` + +### 3. 停止服务 + +```bash +# 停止服务(保留数据) +docker-compose -f docker-compose.postgres.yml stop + +# 停止并删除容器(保留数据卷) +docker-compose -f docker-compose.postgres.yml down + +# 停止并删除所有内容(包括数据) +docker-compose -f docker-compose.postgres.yml down -v +``` + +## 🔧 配置说明 + +### 数据库连接信息 + +| 项目 | 值 | +|------|-----| +| Host | localhost | +| Port | 5432 | +| Database | st_dev | +| Username | st_user | +| Password | st_password | + +### pgAdmin 管理界面 + +- **访问地址**: http://localhost:5050 +- **登录邮箱**: admin@st.local +- **登录密码**: admin123 + +### 已安装的扩展 + +- ✅ **pgvector** - 向量相似度搜索(用于 AI 记忆功能) +- ✅ **uuid-ossp** - UUID 生成 +- ✅ **pg_trgm** - 模糊搜索和全文搜索 +- ✅ **btree_gin** - GIN 索引优化(加速 JSONB 查询) + +## 📝 更新 Go 配置 + +修改 `server/config.yaml` 中的数据库配置: + +```yaml +# system/config.yaml +system: + db-type: postgres + +pgsql: + path: 127.0.0.1 + port: "5432" + config: sslmode=disable TimeZone=Asia/Shanghai + db-name: st_dev + username: st_user + password: st_password + max-idle-conns: 10 + max-open-conns: 100 + log-mode: info + log-zap: false +``` + +## 🔍 数据库管理 + +### 使用 Docker 命令连接 + +```bash +# 进入 PostgreSQL 容器 +docker exec -it st-postgres bash + +# 连接数据库 +psql -U st_user -d st_dev + +# 或者直接执行 +docker exec -it st-postgres psql -U st_user -d st_dev +``` + +### 常用 SQL 命令 + +```sql +-- 查看已安装的扩展 +\dx + +-- 查看所有表 +\dt + +-- 查看表结构 +\d+ table_name + +-- 查看数据库大小 +SELECT pg_size_pretty(pg_database_size('st_dev')); + +-- 查看表大小 +SELECT + schemaname, + tablename, + pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) AS size +FROM pg_tables +WHERE schemaname = 'public' +ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC; + +-- 测试 pgvector 扩展 +SELECT vector_dims(ARRAY[1,2,3]::vector); + +-- 查看当前连接数 +SELECT count(*) FROM pg_stat_activity; +``` + +## 🔄 数据备份与恢复 + +### 备份数据库 + +```bash +# 备份整个数据库 +docker exec st-postgres pg_dump -U st_user st_dev > backup_$(date +%Y%m%d_%H%M%S).sql + +# 备份为自定义格式(推荐,支持并行恢复) +docker exec st-postgres pg_dump -U st_user -Fc st_dev > backup_$(date +%Y%m%d_%H%M%S).dump +``` + +### 恢复数据库 + +```bash +# 从 SQL 文件恢复 +docker exec -i st-postgres psql -U st_user -d st_dev < backup.sql + +# 从自定义格式恢复 +docker exec -i st-postgres pg_restore -U st_user -d st_dev -c backup.dump +``` + +## 🛠️ 性能优化建议 + +### 开发环境(当前配置) + +- `shared_buffers`: 256MB +- `effective_cache_size`: 1GB +- `work_mem`: 5MB + +### 生产环境(8GB 内存服务器) + +修改 `postgresql.conf`: + +```conf +shared_buffers = 2GB # 系统内存的 25% +effective_cache_size = 6GB # 系统内存的 75% +maintenance_work_mem = 512MB +work_mem = 16MB +``` + +## 🐛 常见问题 + +### 1. 端口被占用 + +如果 5432 端口被占用,修改 `docker-compose.postgres.yml`: + +```yaml +ports: + - "15432:5432" # 映射到本地 15432 端口 +``` + +### 2. 权限问题 + +```bash +# Linux/Mac 系统可能需要修改数据目录权限 +sudo chown -R 999:999 /path/to/postgres_data +``` + +### 3. 连接失败 + +检查防火墙和 Docker 网络: + +```bash +# 查看容器状态 +docker ps + +# 查看容器日志 +docker logs st-postgres + +# 测试连接 +docker exec st-postgres pg_isready -U st_user +``` + +### 4. 性能调优 + +```bash +# 查看当前配置 +docker exec st-postgres psql -U st_user -d st_dev -c "SHOW ALL;" + +# 分析慢查询 +docker exec st-postgres psql -U st_user -d st_dev -c " +SELECT query, calls, total_time, mean_time +FROM pg_stat_statements +ORDER BY mean_time DESC +LIMIT 10; +" +``` + +## 📊 监控 + +### 使用 pgAdmin + +1. 访问 http://localhost:5050 +2. 登录后,添加新服务器: + - Name: ST Development + - Host: postgres (容器名称) + - Port: 5432 + - Username: st_user + - Password: st_password + +### 使用命令行监控 + +```bash +# 查看活动连接 +docker exec st-postgres psql -U st_user -d st_dev -c " +SELECT pid, usename, application_name, client_addr, state, query +FROM pg_stat_activity +WHERE datname = 'st_dev'; +" + +# 查看表统计信息 +docker exec st-postgres psql -U st_user -d st_dev -c " +SELECT schemaname, tablename, n_live_tup, n_dead_tup +FROM pg_stat_user_tables +ORDER BY n_live_tup DESC; +" +``` + +## 🔐 安全建议 + +### 生产环境部署 + +1. **修改默认密码**: + ```yaml + environment: + POSTGRES_PASSWORD: 使用强密码 + ``` + +2. **限制网络访问**: + ```yaml + ports: + - "127.0.0.1:5432:5432" # 只允许本地访问 + ``` + +3. **启用 SSL**: + ```conf + ssl = on + ssl_cert_file = '/path/to/cert.pem' + ssl_key_file = '/path/to/key.pem' + ``` + +4. **定期备份**: + ```bash + # 添加到 crontab + 0 2 * * * docker exec st-postgres pg_dump -U st_user -Fc st_dev > /backups/st_$(date +\%Y\%m\%d).dump + ``` + +## 📚 参考资料 + +- [PostgreSQL 官方文档](https://www.postgresql.org/docs/18/) +- [pgvector 扩展文档](https://github.com/pgvector/pgvector) +- [Docker Hub - PostgreSQL](https://hub.docker.com/_/postgres) +- [pgAdmin 文档](https://www.pgadmin.org/docs/) diff --git a/deploy/docker-compose.postgres-official.yml b/deploy/docker-compose.postgres-official.yml new file mode 100644 index 0000000..d0f28db --- /dev/null +++ b/deploy/docker-compose.postgres-official.yml @@ -0,0 +1,86 @@ +version: '3.8' + +services: + postgres: + # 使用自定义 Dockerfile 构建(基于官方镜像) + build: + context: ./postgres + dockerfile: Dockerfile + image: st-postgres:18.1-pgvector + container_name: st-postgres + restart: unless-stopped + ports: + - "5432:5432" + environment: + # 数据库配置 + POSTGRES_DB: st_dev + POSTGRES_USER: st_user + POSTGRES_PASSWORD: st_password + # PostgreSQL 配置 + POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C" + # 时区设置 + TZ: Asia/Shanghai + PGTZ: Asia/Shanghai + volumes: + # 数据持久化 + - postgres_data:/var/lib/postgresql/data + # 初始化脚本 + - ./postgres/init.sql:/docker-entrypoint-initdb.d/01-init.sql:ro + # PostgreSQL 配置文件(可选) + - ./postgres/postgresql.conf:/etc/postgresql/postgresql.conf:ro + command: > + postgres + -c shared_preload_libraries=vector + -c max_connections=200 + -c shared_buffers=256MB + -c effective_cache_size=1GB + -c maintenance_work_mem=128MB + -c checkpoint_completion_target=0.9 + -c wal_buffers=16MB + -c default_statistics_target=100 + -c random_page_cost=1.1 + -c effective_io_concurrency=200 + -c work_mem=5242kB + -c huge_pages=off + -c min_wal_size=1GB + -c max_wal_size=4GB + healthcheck: + test: ["CMD-SHELL", "pg_isready -U st_user -d st_dev"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - st-network + + # pgAdmin - PostgreSQL Web 管理界面(可选) + pgadmin: + image: dpage/pgadmin4:latest + container_name: st-pgadmin + restart: unless-stopped + ports: + - "5050:80" + environment: + PGADMIN_DEFAULT_EMAIL: admin@st.local + PGADMIN_DEFAULT_PASSWORD: admin123 + PGADMIN_CONFIG_SERVER_MODE: 'False' + PGADMIN_CONFIG_MASTER_PASSWORD_REQUIRED: 'False' + volumes: + - pgadmin_data:/var/lib/pgadmin + depends_on: + postgres: + condition: service_healthy + networks: + - st-network + +volumes: + postgres_data: + driver: local + name: st-postgres-data + pgadmin_data: + driver: local + name: st-pgadmin-data + +networks: + st-network: + driver: bridge + name: st-network diff --git a/deploy/docker-compose.postgres.yml b/deploy/docker-compose.postgres.yml new file mode 100644 index 0000000..69ab91e --- /dev/null +++ b/deploy/docker-compose.postgres.yml @@ -0,0 +1,82 @@ +version: '3.8' + +services: + postgres: + image: ankane/pgvector:v0.7.4-pg18 + container_name: st-postgres + restart: unless-stopped + ports: + - "5432:5432" + environment: + # 数据库配置 + POSTGRES_DB: st_dev + POSTGRES_USER: st_user + POSTGRES_PASSWORD: st_password + # PostgreSQL 配置 + POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C" + # 时区设置 + TZ: Asia/Shanghai + PGTZ: Asia/Shanghai + volumes: + # 数据持久化 + - postgres_data:/var/lib/postgresql/data + # 初始化脚本 + - ./postgres/init.sql:/docker-entrypoint-initdb.d/01-init.sql:ro + # PostgreSQL 配置文件(可选) + - ./postgres/postgresql.conf:/etc/postgresql/postgresql.conf:ro + command: > + postgres + -c shared_preload_libraries=vector + -c max_connections=200 + -c shared_buffers=256MB + -c effective_cache_size=1GB + -c maintenance_work_mem=128MB + -c checkpoint_completion_target=0.9 + -c wal_buffers=16MB + -c default_statistics_target=100 + -c random_page_cost=1.1 + -c effective_io_concurrency=200 + -c work_mem=5242kB + -c huge_pages=off + -c min_wal_size=1GB + -c max_wal_size=4GB + healthcheck: + test: ["CMD-SHELL", "pg_isready -U st_user -d st_dev"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - st-network + + # pgAdmin - PostgreSQL Web 管理界面(可选) + pgadmin: + image: dpage/pgadmin4:latest + container_name: st-pgadmin + restart: unless-stopped + ports: + - "5050:80" + environment: + PGADMIN_DEFAULT_EMAIL: admin@st.local + PGADMIN_DEFAULT_PASSWORD: admin123 + PGADMIN_CONFIG_SERVER_MODE: 'False' + PGADMIN_CONFIG_MASTER_PASSWORD_REQUIRED: 'False' + volumes: + - pgadmin_data:/var/lib/pgadmin + depends_on: + postgres: + condition: service_healthy + networks: + - st-network + +volumes: + postgres_data: + driver: local + name: st-postgres-data + pgadmin_data: + driver: local + name: st-pgadmin-data + +networks: + st-network: + driver: bridge + name: st-network diff --git a/deploy/postgres/Dockerfile b/deploy/postgres/Dockerfile new file mode 100644 index 0000000..d820d20 --- /dev/null +++ b/deploy/postgres/Dockerfile @@ -0,0 +1,42 @@ +# ==================================================== +# 基于官方 PostgreSQL 18.1 镜像 + pgvector 扩展 +# ==================================================== + +FROM postgres:18.1 + +# 设置维护者信息 +LABEL maintainer="st-dev@example.com" +LABEL description="PostgreSQL 18.1 with pgvector extension" + +# 安装依赖 +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + build-essential \ + git \ + postgresql-server-dev-18 \ + ca-certificates && \ + rm -rf /var/lib/apt/lists/* + +# 安装 pgvector 扩展 +RUN cd /tmp && \ + git clone --branch v0.7.4 https://github.com/pgvector/pgvector.git && \ + cd pgvector && \ + make && \ + make install && \ + cd / && \ + rm -rf /tmp/pgvector + +# 清理 +RUN apt-get purge -y --auto-remove build-essential git postgresql-server-dev-18 && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + +# 设置时区 +ENV TZ=Asia/Shanghai +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone + +# 暴露端口 +EXPOSE 5432 + +# 使用默认的 PostgreSQL 入口点 +CMD ["postgres"] diff --git a/deploy/postgres/init.sql b/deploy/postgres/init.sql new file mode 100644 index 0000000..45b46a0 --- /dev/null +++ b/deploy/postgres/init.sql @@ -0,0 +1,82 @@ +-- ==================================================== +-- 云酒馆数据库初始化脚本 +-- ==================================================== + +-- 设置客户端编码 +SET client_encoding = 'UTF8'; + +-- 创建必要的扩展 +-- ==================================================== + +-- 1. pgvector - 向量相似度搜索扩展(用于 AI 记忆功能) +CREATE EXTENSION IF NOT EXISTS vector; + +-- 2. uuid-ossp - UUID 生成扩展(用于生成唯一标识符) +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- 3. pg_trgm - 三元组模糊匹配扩展(用于全文搜索) +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +-- 4. btree_gin - GIN 索引扩展(用于加速 JSONB 查询) +CREATE EXTENSION IF NOT EXISTS btree_gin; + +-- 验证扩展是否安装成功 +-- ==================================================== +DO $$ +BEGIN + RAISE NOTICE '==========================================='; + RAISE NOTICE '云酒馆数据库初始化完成!'; + RAISE NOTICE '==========================================='; + RAISE NOTICE '已安装的扩展:'; + RAISE NOTICE ' ✓ vector - 向量相似度搜索'; + RAISE NOTICE ' ✓ uuid-ossp - UUID 生成'; + RAISE NOTICE ' ✓ pg_trgm - 模糊搜索'; + RAISE NOTICE ' ✓ btree_gin - GIN 索引优化'; + RAISE NOTICE '==========================================='; + RAISE NOTICE '数据库信息:'; + RAISE NOTICE ' 数据库名: st_dev'; + RAISE NOTICE ' 用户名: st_user'; + RAISE NOTICE ' 字符集: UTF8'; + RAISE NOTICE '==========================================='; +END $$; + +-- 创建用户并授予权限 +-- ==================================================== +-- 如果需要额外的只读用户,可以取消以下注释 +-- CREATE USER st_readonly WITH PASSWORD 'readonly_password'; +-- GRANT CONNECT ON DATABASE st_dev TO st_readonly; +-- GRANT USAGE ON SCHEMA public TO st_readonly; +-- GRANT SELECT ON ALL TABLES IN SCHEMA public TO st_readonly; +-- ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO st_readonly; + +-- 性能优化设置 +-- ==================================================== +-- 启用 JIT 编译(PostgreSQL 11+) +SET jit = on; + +-- 设置默认的统计信息采样 +ALTER DATABASE st_dev SET default_statistics_target = 100; + +-- 优化大数据量查询 +ALTER DATABASE st_dev SET work_mem = '16MB'; + +-- 提示信息 +-- ==================================================== +\echo '' +\echo '==========================================' +\echo '数据库初始化完成!' +\echo '==========================================' +\echo '连接信息:' +\echo ' Host: localhost' +\echo ' Port: 5432' +\echo ' Database: st_dev' +\echo ' Username: st_user' +\echo ' Password: st_password' +\echo '==========================================' +\echo '' +\echo '管理工具:' +\echo ' pgAdmin: http://localhost:5050' +\echo ' Email: admin@st.local' +\echo ' Pass: admin123' +\echo '==========================================' +\echo '' diff --git a/deploy/postgres/postgresql.conf b/deploy/postgres/postgresql.conf new file mode 100644 index 0000000..d770621 --- /dev/null +++ b/deploy/postgres/postgresql.conf @@ -0,0 +1,79 @@ +# ==================================================== +# PostgreSQL 配置文件(针对开发环境优化) +# ==================================================== + +# 连接设置 +# ==================================================== +listen_addresses = '*' +max_connections = 200 +superuser_reserved_connections = 3 + +# 内存设置 +# ==================================================== +shared_buffers = 256MB # 共享缓冲区(推荐系统内存的 25%) +effective_cache_size = 1GB # 有效缓存大小(推荐系统内存的 50-75%) +maintenance_work_mem = 128MB # 维护操作内存 +work_mem = 5MB # 排序和哈希表内存 + +# WAL(Write-Ahead Logging)设置 +# ==================================================== +wal_buffers = 16MB +min_wal_size = 1GB +max_wal_size = 4GB +checkpoint_completion_target = 0.9 + +# 查询优化 +# ==================================================== +random_page_cost = 1.1 # SSD 优化(默认 4.0) +effective_io_concurrency = 200 # SSD 优化(默认 1) +default_statistics_target = 100 + +# 日志设置 +# ==================================================== +logging_collector = on +log_destination = 'stderr' +log_directory = 'log' +log_filename = 'postgresql-%Y-%m-%d_%H%M%S.log' +log_rotation_age = 1d +log_rotation_size = 100MB +log_line_prefix = '%m [%p] %u@%d ' +log_timezone = 'Asia/Shanghai' + +# 慢查询日志 +log_min_duration_statement = 1000 # 记录超过 1 秒的查询 +log_statement = 'ddl' # 记录 DDL 语句 + +# 扩展配置 +# ==================================================== +shared_preload_libraries = 'vector' # 预加载 pgvector 扩展 + +# JIT 编译(提升性能) +# ==================================================== +jit = on +jit_above_cost = 100000 +jit_inline_above_cost = 500000 +jit_optimize_above_cost = 500000 + +# 时区设置 +# ==================================================== +timezone = 'Asia/Shanghai' + +# 客户端连接默认设置 +# ==================================================== +client_encoding = 'UTF8' +lc_messages = 'C' +lc_monetary = 'C' +lc_numeric = 'C' +lc_time = 'C' + +# 并行查询设置(提升大数据量查询性能) +# ==================================================== +max_parallel_workers_per_gather = 2 +max_parallel_workers = 4 +max_worker_processes = 4 + +# 自动清理设置 +# ==================================================== +autovacuum = on +autovacuum_max_workers = 3 +autovacuum_naptime = 1min diff --git a/deploy/start-postgres.sh b/deploy/start-postgres.sh new file mode 100644 index 0000000..a6d7ae5 --- /dev/null +++ b/deploy/start-postgres.sh @@ -0,0 +1,109 @@ +#!/bin/bash + +# ==================================================== +# PostgreSQL Docker 快速启动脚本 +# ==================================================== + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +echo "==========================================" +echo "云酒馆 PostgreSQL 启动脚本" +echo "==========================================" +echo "" + +# 检查 Docker 是否安装 +if ! command -v docker &> /dev/null; then + echo "❌ 错误: 未检测到 Docker,请先安装 Docker" + echo " 安装地址: https://www.docker.com/get-started" + exit 1 +fi + +# 检查 Docker Compose 是否安装 +if ! command -v docker-compose &> /dev/null; then + echo "❌ 错误: 未检测到 Docker Compose,请先安装" + exit 1 +fi + +# 检查 Docker 是否运行 +if ! docker info &> /dev/null; then + echo "❌ 错误: Docker 服务未运行,请先启动 Docker" + exit 1 +fi + +echo "✅ Docker 环境检查通过" +echo "" + +# 创建必要的目录 +mkdir -p postgres + +# 检查容器是否已经运行 +if docker ps -a --format '{{.Names}}' | grep -q '^st-postgres$'; then + echo "📦 检测到已存在的容器" + + if docker ps --format '{{.Names}}' | grep -q '^st-postgres$'; then + echo " 容器正在运行中..." + echo "" + echo "如需重启,请先执行: $0 stop" + exit 0 + else + echo " 正在启动已存在的容器..." + docker-compose -f docker-compose.postgres.yml start + fi +else + echo "🚀 首次启动,正在创建容器..." + docker-compose -f docker-compose.postgres.yml up -d +fi + +echo "" +echo "⏳ 等待数据库启动..." +sleep 3 + +# 检查数据库是否就绪 +MAX_RETRIES=30 +RETRY_COUNT=0 + +while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do + if docker exec st-postgres pg_isready -U st_user -d st_dev &> /dev/null; then + echo "✅ 数据库已就绪!" + break + fi + + RETRY_COUNT=$((RETRY_COUNT + 1)) + echo " 等待中... ($RETRY_COUNT/$MAX_RETRIES)" + sleep 2 +done + +if [ $RETRY_COUNT -eq $MAX_RETRIES ]; then + echo "❌ 数据库启动超时,请检查日志:" + echo " docker-compose -f docker-compose.postgres.yml logs postgres" + exit 1 +fi + +echo "" +echo "==========================================" +echo "✨ PostgreSQL 启动成功!" +echo "==========================================" +echo "" +echo "📝 数据库连接信息:" +echo " Host: localhost" +echo " Port: 5432" +echo " Database: st_dev" +echo " Username: st_user" +echo " Password: st_password" +echo "" +echo "🌐 pgAdmin 管理界面:" +echo " URL: http://localhost:5050" +echo " Email: admin@st.local" +echo " Pass: admin123" +echo "" +echo "📊 常用命令:" +echo " 查看日志: docker-compose -f docker-compose.postgres.yml logs -f" +echo " 停止服务: $0 stop" +echo " 重启服务: $0 restart" +echo " 查看状态: docker-compose -f docker-compose.postgres.yml ps" +echo " 进入数据库: docker exec -it st-postgres psql -U st_user -d st_dev" +echo "" +echo "==========================================" diff --git a/deploy/stop-postgres.sh b/deploy/stop-postgres.sh new file mode 100644 index 0000000..3af33ff --- /dev/null +++ b/deploy/stop-postgres.sh @@ -0,0 +1,41 @@ +#!/bin/bash + +# ==================================================== +# PostgreSQL Docker 停止脚本 +# ==================================================== + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +echo "==========================================" +echo "停止 PostgreSQL 服务" +echo "==========================================" +echo "" + +# 检查参数 +if [ "$1" == "--clean" ] || [ "$1" == "-c" ]; then + echo "⚠️ 警告: 将删除所有数据(包括数据卷)" + read -p "确定要继续吗?(yes/no): " confirm + + if [ "$confirm" != "yes" ]; then + echo "操作已取消" + exit 0 + fi + + echo "🗑️ 停止并删除所有内容..." + docker-compose -f docker-compose.postgres.yml down -v + echo "✅ 已删除容器和数据卷" +else + echo "🛑 停止服务(保留数据)..." + docker-compose -f docker-compose.postgres.yml stop + echo "✅ 服务已停止" + echo "" + echo "💡 提示:" + echo " - 数据已保留,重启后可继续使用" + echo " - 完全删除(包括数据): $0 --clean" +fi + +echo "" +echo "==========================================" diff --git a/docs/Vue重构方案.md b/docs/Vue重构方案.md new file mode 100644 index 0000000..7e96eed --- /dev/null +++ b/docs/Vue重构方案.md @@ -0,0 +1,648 @@ +# web-app Vue 3 重构方案 + +## 📋 方案概述 + +将 web-app 从传统的 jQuery 项目重构为 Vue 3 现代化单页应用(SPA)。 + +## 🎯 重构目标 + +### 技术目标 +- ✅ 使用 Vue 3 + Vite + TypeScript +- ✅ 组件化、模块化开发 +- ✅ 统一前后端技术栈(与管理后台一致) +- ✅ 优化性能和用户体验 +- ✅ 便于维护和扩展 + +### 业务目标 +- ✅ 保留所有原有功能 +- ✅ 改善 UI/UX 设计 +- ✅ 适配移动端 +- ✅ 支持主题切换 +- ✅ 国际化支持 + +## 🏗️ 技术栈选择 + +### 核心技术 +```json +{ + "框架": "Vue 3 (Composition API)", + "构建工具": "Vite 5", + "语言": "TypeScript", + "状态管理": "Pinia", + "路由": "Vue Router 4", + "HTTP": "Axios", + "WebSocket": "原生 WebSocket / Socket.io-client" +} +``` + +### UI 框架选择 + +#### 方案一:Element Plus(推荐) +```bash +# 优点 +✅ 与管理后台统一(web/ 使用的就是 Element Plus) +✅ 组件丰富,开箱即用 +✅ 中文文档完善 +✅ 支持深色主题 +✅ TypeScript 支持好 + +# 缺点 +⚠️ 组件较重(如果只需要简单 UI,可能过度设计) +``` + +#### 方案二:Naive UI +```bash +# 优点 +✅ 轻量级,性能好 +✅ TypeScript 原生支持 +✅ 组件设计现代 +✅ 支持深色主题 + +# 缺点 +⚠️ 与管理后台不统一 +``` + +#### 方案三:自定义 UI(参考 auth.html) +```bash +# 优点 +✅ 完全自定义,独特设计 +✅ 轻量级 +✅ 可以复用已有的 auth.css 样式 + +# 缺点 +⚠️ 需要自己实现所有组件 +⚠️ 开发时间较长 +``` + +**推荐:Element Plus + 自定义样式** +- 使用 Element Plus 的基础组件(表单、按钮、对话框等) +- 自定义主题和关键页面样式 +- 兼顾开发效率和设计独特性 + +## 📂 项目结构 + +### 新项目结构(推荐) + +``` +web-app-vue/ # 新建的 Vue 项目 +├── public/ +│ ├── favicon.ico +│ └── img/ # 静态图片资源 +├── src/ +│ ├── api/ # API 接口 +│ │ ├── auth.ts # 认证接口 +│ │ ├── character.ts # 角色接口 +│ │ ├── chat.ts # 对话接口 +│ │ └── index.ts # axios 配置 +│ ├── assets/ # 资源文件 +│ │ ├── styles/ # 全局样式 +│ │ │ ├── index.scss +│ │ │ ├── variables.scss +│ │ │ └── dark-theme.scss +│ │ └── images/ +│ ├── components/ # 通用组件 +│ │ ├── common/ # 基础组件 +│ │ │ ├── AppHeader.vue +│ │ │ ├── AppSidebar.vue +│ │ │ └── Loading.vue +│ │ ├── chat/ # 对话组件 +│ │ │ ├── ChatMessage.vue +│ │ │ ├── ChatInput.vue +│ │ │ ├── MessageSwipe.vue +│ │ │ └── VoiceInput.vue +│ │ ├── character/ # 角色组件 +│ │ │ ├── CharacterCard.vue +│ │ │ ├── CharacterList.vue +│ │ │ ├── CharacterEditor.vue +│ │ │ └── CharacterImport.vue +│ │ └── settings/ # 设置组件 +│ │ ├── AIConfig.vue +│ │ ├── ThemeConfig.vue +│ │ └── UserProfile.vue +│ ├── composables/ # 组合式函数 +│ │ ├── useAuth.ts # 认证相关 +│ │ ├── useChat.ts # 对话相关 +│ │ ├── useCharacter.ts # 角色相关 +│ │ └── useWebSocket.ts # WebSocket +│ ├── layouts/ # 布局组件 +│ │ ├── DefaultLayout.vue # 默认布局 +│ │ ├── AuthLayout.vue # 认证页布局 +│ │ └── ChatLayout.vue # 对话页布局 +│ ├── router/ # 路由配置 +│ │ ├── index.ts +│ │ └── guards.ts # 路由守卫 +│ ├── stores/ # Pinia 状态管理 +│ │ ├── auth.ts # 用户认证 +│ │ ├── chat.ts # 对话状态 +│ │ ├── character.ts # 角色管理 +│ │ ├── settings.ts # 设置 +│ │ └── index.ts +│ ├── types/ # TypeScript 类型 +│ │ ├── api.d.ts +│ │ ├── character.d.ts +│ │ ├── chat.d.ts +│ │ └── user.d.ts +│ ├── utils/ # 工具函数 +│ │ ├── request.ts # HTTP 请求封装 +│ │ ├── storage.ts # 本地存储 +│ │ ├── websocket.ts # WebSocket 封装 +│ │ ├── format.ts # 格式化 +│ │ └── validate.ts # 验证 +│ ├── views/ # 页面视图 +│ │ ├── auth/ # 认证页 +│ │ │ ├── Login.vue +│ │ │ └── Register.vue +│ │ ├── home/ # 主页 +│ │ │ └── Index.vue +│ │ ├── character/ # 角色页 +│ │ │ ├── List.vue +│ │ │ ├── Detail.vue +│ │ │ └── Create.vue +│ │ ├── chat/ # 对话页 +│ │ │ └── Index.vue +│ │ ├── settings/ # 设置页 +│ │ │ └── Index.vue +│ │ └── user/ # 用户中心 +│ │ └── Profile.vue +│ ├── App.vue # 根组件 +│ └── main.ts # 入口文件 +├── .env.development # 开发环境配置 +├── .env.production # 生产环境配置 +├── index.html +├── package.json +├── tsconfig.json +├── vite.config.ts +└── README.md +``` + +### 与现有项目的关系 + +``` +st/ +├── server/ # Go 后端(不变) +├── web/ # Vue 管理后台(不变) +├── web-app/ # 旧项目(保留备份) +└── web-app-vue/ # 新的 Vue 前台(新建) + └── ... +``` + +## 🚀 实施步骤 + +### 阶段一:项目初始化(1-2 天) + +#### 1.1 创建 Vue 项目 +```bash +# 使用 Vite 创建项目 +npm create vite@latest web-app-vue -- --template vue-ts + +cd web-app-vue +npm install +``` + +#### 1.2 安装依赖 +```bash +# UI 框架 +npm install element-plus + +# 路由和状态管理 +npm install vue-router@4 pinia + +# HTTP 和工具 +npm install axios +npm install @vueuse/core # Vue 常用组合式函数 + +# 开发依赖 +npm install -D sass +npm install -D unplugin-vue-components unplugin-auto-import +``` + +#### 1.3 配置 Vite +```typescript +// vite.config.ts +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import AutoImport from 'unplugin-auto-import/vite' +import Components from 'unplugin-vue-components/vite' +import { ElementPlusResolver } from 'unplugin-vue-components/resolvers' + +export default defineConfig({ + plugins: [ + vue(), + AutoImport({ + resolvers: [ElementPlusResolver()], + imports: ['vue', 'vue-router', 'pinia'], + }), + Components({ + resolvers: [ElementPlusResolver()], + }), + ], + server: { + port: 3000, + proxy: { + '/app': { + target: 'http://localhost:8888', + changeOrigin: true, + }, + }, + }, +}) +``` + +#### 1.4 基础配置 +```typescript +// src/main.ts +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import ElementPlus from 'element-plus' +import 'element-plus/dist/index.css' +import router from './router' +import App from './App.vue' +import './assets/styles/index.scss' + +const app = createApp(App) +const pinia = createPinia() + +app.use(pinia) +app.use(router) +app.use(ElementPlus) +app.mount('#app') +``` + +### 阶段二:核心功能开发(5-7 天) + +#### 2.1 认证模块(1 天) +- [x] 登录页面 +- [x] 注册页面 +- [x] Token 管理 +- [x] 路由守卫 + +#### 2.2 角色管理(2 天) +- [ ] 角色列表 +- [ ] 角色详情 +- [ ] 角色创建/编辑 +- [ ] 角色导入/导出 +- [ ] 收藏功能 + +#### 2.3 对话功能(3 天) +- [ ] 对话界面 +- [ ] 消息发送 +- [ ] 消息渲染 +- [ ] Swipe 功能 +- [ ] WebSocket 实时通信 +- [ ] 流式输出 + +#### 2.4 设置页面(1 天) +- [ ] AI 配置 +- [ ] 主题设置 +- [ ] 用户偏好 + +### 阶段三:高级功能(3-5 天) + +#### 3.1 AI 功能 +- [ ] 多模型支持 +- [ ] Prompt 管理 +- [ ] World Info +- [ ] 向量记忆 + +#### 3.2 UI/UX 优化 +- [ ] 响应式布局 +- [ ] 移动端适配 +- [ ] 主题切换 +- [ ] 动画效果 + +#### 3.3 文件管理 +- [ ] 头像上传 +- [ ] 文件管理 +- [ ] 图片预览 + +### 阶段四:测试和优化(2-3 天) + +- [ ] 单元测试 +- [ ] E2E 测试 +- [ ] 性能优化 +- [ ] 打包优化 +- [ ] 部署配置 + +## 📦 关键组件示例 + +### 1. 认证 Store + +```typescript +// src/stores/auth.ts +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { login, register, getUserInfo } from '@/api/auth' +import type { LoginRequest, User } from '@/types/user' + +export const useAuthStore = defineStore('auth', () => { + const token = ref(localStorage.getItem('st_access_token') || '') + const user = ref(null) + + const isLoggedIn = computed(() => !!token.value) + + async function handleLogin(data: LoginRequest) { + const res = await login(data) + token.value = res.data.token + user.value = res.data.user + localStorage.setItem('st_access_token', res.data.token) + localStorage.setItem('st_user_info', JSON.stringify(res.data.user)) + } + + async function handleLogout() { + token.value = '' + user.value = null + localStorage.removeItem('st_access_token') + localStorage.removeItem('st_user_info') + } + + async function fetchUserInfo() { + const res = await getUserInfo() + user.value = res.data + } + + return { + token, + user, + isLoggedIn, + handleLogin, + handleLogout, + fetchUserInfo, + } +}) +``` + +### 2. HTTP 请求封装 + +```typescript +// src/utils/request.ts +import axios from 'axios' +import { ElMessage } from 'element-plus' +import { useAuthStore } from '@/stores/auth' + +const request = axios.create({ + baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8888', + timeout: 10000, +}) + +// 请求拦截器 +request.interceptors.request.use( + (config) => { + const authStore = useAuthStore() + if (authStore.token) { + config.headers['x-token'] = authStore.token + } + return config + }, + (error) => { + return Promise.reject(error) + } +) + +// 响应拦截器 +request.interceptors.response.use( + (response) => { + const res = response.data + if (res.code !== 0) { + ElMessage.error(res.msg || '请求失败') + return Promise.reject(new Error(res.msg || '请求失败')) + } + return res + }, + (error) => { + ElMessage.error(error.message || '网络错误') + return Promise.reject(error) + } +) + +export default request +``` + +### 3. 角色卡片组件 + +```vue + + + + + + +``` + +## 🔄 迁移策略 + +### 渐进式迁移(推荐) + +1. **Phase 1: 新功能使用 Vue** + - 保留旧 web-app,用于稳定运行 + - 新功能在 web-app-vue 开发 + - 通过路由切换新旧页面 + +2. **Phase 2: 逐步迁移** + - 按模块迁移(认证 → 角色 → 对话 → 设置) + - 每个模块迁移后充分测试 + - 确保功能完整 + +3. **Phase 3: 完全替换** + - 所有功能迁移完成 + - 彻底删除旧代码 + - web-app-vue 改名为 web-app + +### 一次性重写(不推荐,风险高) + +除非你有充足的时间和人力,否则不建议一次性重写整个项目。 + +## 📊 与现有项目的集成 + +### Go 后端配置 + +```go +// server/initialize/router.go + +// 前台 Vue 应用静态文件 +webAppVuePath := "../web-app-vue/dist" +if _, err := os.Stat(webAppVuePath); err == nil { + Router.Static("/assets", webAppVuePath+"/assets") + Router.StaticFile("/", webAppVuePath+"/index.html") + global.GVA_LOG.Info("前台 Vue 应用已启动: " + webAppVuePath) +} +``` + +### 环境配置 + +```bash +# .env.development +VITE_API_BASE_URL=http://localhost:8888 +VITE_WS_URL=ws://localhost:8888 + +# .env.production +VITE_API_BASE_URL=https://your-domain.com +VITE_WS_URL=wss://your-domain.com +``` + +## 🎯 开发优先级 + +### P0(必须) +1. ✅ 用户认证(登录/注册) +2. ✅ 角色列表和详情 +3. ✅ 基本对话功能 +4. ✅ AI 配置 + +### P1(重要) +5. ⚪ 角色编辑和创建 +6. ⚪ 消息 Swipe +7. ⚪ 文件上传 +8. ⚪ 设置页面 + +### P2(可选) +9. ⚪ World Info +10. ⚪ 向量记忆 +11. ⚪ 高级 AI 功能 +12. ⚪ 主题切换 + +## 📝 注意事项 + +### 1. 保持功能一致性 +- 确保所有原有功能都能在 Vue 版本中实现 +- 参考旧版的交互逻辑 +- 不要遗漏边界情况 + +### 2. 性能优化 +- 使用虚拟滚动(角色列表、消息列表) +- 图片懒加载 +- 路由懒加载 +- 打包分析和优化 + +### 3. 移动端适配 +- 响应式设计 +- 触摸手势支持 +- 移动端专用布局 + +### 4. 国际化 +- 使用 vue-i18n +- 支持多语言切换 +- 本地化日期和数字 + +## 📚 学习资源 + +- Vue 3 官方文档: https://cn.vuejs.org/ +- Vite 文档: https://cn.vitejs.dev/ +- Element Plus: https://element-plus.org/zh-CN/ +- Pinia 文档: https://pinia.vuejs.org/zh/ +- TypeScript 文档: https://www.typescriptlang.org/zh/ + +## 🎉 总结 + +### 为什么要重构? + +1. ✅ **代码质量**:从混乱的 jQuery 代码到清晰的组件化代码 +2. ✅ **开发效率**:热更新、TypeScript、自动导入 +3. ✅ **性能提升**:Vite 构建、按需加载、虚拟滚动 +4. ✅ **易于维护**:模块化、类型安全、单一职责 +5. ✅ **技术统一**:与管理后台使用相同技术栈 +6. ✅ **未来发展**:易于扩展新功能 + +### 建议的时间线 + +- **快速原型**(1 周):核心功能,可以开始使用 +- **MVP 版本**(2-3 周):主要功能完成 +- **完整版本**(4-6 周):所有功能迁移完成 +- **优化打磨**(持续):性能优化、bug 修复 + +--- + +**更新日期**: 2026-02-10 +**版本**: v1.0.0 diff --git a/docs/用户认证API文档.md b/docs/用户认证API文档.md new file mode 100644 index 0000000..8bdfad6 --- /dev/null +++ b/docs/用户认证API文档.md @@ -0,0 +1,382 @@ +# 用户认证 API 文档 + +## 📋 概述 + +本文档描述云酒馆项目前台用户认证系统的所有 API 接口。 + +## 🔗 基础信息 + +- **Base URL**: `http://localhost:8888/app`(开发环境) +- **认证方式**: JWT Bearer Token +- **Content-Type**: `application/json` + +## 📌 API 接口列表 + +### 1. 用户注册 + +**接口**: `POST /app/auth/register` + +**描述**: 新用户注册 + +**请求头**: 无需认证 + +**请求体**: +```json +{ + "username": "testuser", + "password": "123456", + "nickName": "测试用户", + "email": "test@example.com", + "phone": "13800138000" +} +``` + +**字段说明**: +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| username | string | 是 | 用户名,3-32字符 | +| password | string | 是 | 密码,6-32字符 | +| nickName | string | 否 | 昵称,最多50字符 | +| email | string | 否 | 邮箱地址 | +| phone | string | 否 | 手机号 | + +**成功响应**: +```json +{ + "code": 0, + "msg": "注册成功" +} +``` + +**错误响应**: +```json +{ + "code": 7, + "msg": "用户名已存在" +} +``` + +--- + +### 2. 用户登录 + +**接口**: `POST /app/auth/login` + +**描述**: 用户登录获取 Token + +**请求头**: 无需认证 + +**请求体**: +```json +{ + "username": "testuser", + "password": "123456" +} +``` + +**成功响应**: +```json +{ + "code": 0, + "data": { + "user": { + "id": 1, + "uuid": "550e8400-e29b-41d4-a716-446655440000", + "username": "testuser", + "nickName": "测试用户", + "email": "test@example.com", + "phone": "13800138000", + "avatar": "", + "status": "active", + "enable": true, + "lastLoginAt": "2026-02-10T09:00:00Z", + "chatCount": 0, + "messageCount": 0, + "aiSettings": null, + "preferences": null, + "createdAt": "2026-02-10T08:00:00Z" + }, + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "expiresAt": 1707552000 + }, + "msg": "登录成功" +} +``` + +**错误响应**: +```json +{ + "code": 7, + "msg": "用户名或密码错误" +} +``` + +--- + +### 3. 刷新 Token + +**接口**: `POST /app/auth/refresh` + +**描述**: 使用 RefreshToken 获取新的 Token + +**请求头**: 无需认证 + +**请求体**: +```json +{ + "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." +} +``` + +**成功响应**: 同登录接口 + +--- + +### 4. 用户登出 + +**接口**: `POST /app/auth/logout` + +**描述**: 用户登出(清除会话) + +**请求头**: +``` +x-token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... +``` +或 +``` +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... +``` + +**请求体**: 无 + +**成功响应**: +```json +{ + "code": 0, + "msg": "登出成功" +} +``` + +--- + +### 5. 获取用户信息 + +**接口**: `GET /app/auth/userinfo` + +**描述**: 获取当前登录用户信息 + +**请求头**: 需要认证(JWT Token) + +**成功响应**: +```json +{ + "code": 0, + "data": { + "id": 1, + "uuid": "550e8400-e29b-41d4-a716-446655440000", + "username": "testuser", + "nickName": "测试用户", + "email": "test@example.com", + "phone": "13800138000", + "avatar": "", + "status": "active", + "enable": true, + "lastLoginAt": "2026-02-10T09:00:00Z", + "chatCount": 5, + "messageCount": 120, + "aiSettings": {}, + "preferences": {}, + "createdAt": "2026-02-10T08:00:00Z" + } +} +``` + +--- + +### 6. 更新用户信息 + +**接口**: `PUT /app/user/profile` + +**描述**: 更新当前用户的个人信息 + +**请求头**: 需要认证(JWT Token) + +**请求体**: +```json +{ + "nickName": "新昵称", + "email": "newemail@example.com", + "phone": "13900139000", + "avatar": "https://example.com/avatar.jpg", + "preferences": "{\"theme\":\"dark\"}", + "aiSettings": "{\"defaultModel\":\"gpt-4\"}" +} +``` + +**字段说明**: 所有字段均为可选 + +**成功响应**: +```json +{ + "code": 0, + "msg": "更新成功" +} +``` + +--- + +### 7. 修改密码 + +**接口**: `POST /app/user/change-password` + +**描述**: 修改当前用户密码 + +**请求头**: 需要认证(JWT Token) + +**请求体**: +```json +{ + "oldPassword": "123456", + "newPassword": "new123456" +} +``` + +**成功响应**: +```json +{ + "code": 0, + "msg": "修改成功" +} +``` + +**错误响应**: +```json +{ + "code": 7, + "msg": "原密码错误" +} +``` + +--- + +## 🔐 认证说明 + +### Token 使用方式 + +认证接口需要在请求头中携带 Token,支持两种方式: + +**方式 1**: `x-token` 头 +```http +x-token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... +``` + +**方式 2**: `Authorization` 头(Bearer Token) +```http +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... +``` + +**方式 3**: Query 参数(不推荐,仅用于特殊场景) +```http +GET /app/auth/userinfo?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... +``` + +### Token 有效期 + +- **Access Token**: 配置文件中的 `jwt.expiresTime` 设置(默认 7200 秒 = 2 小时) +- **Refresh Token**: 固定 7 天 + +### Token 刷新流程 + +1. Access Token 过期后,使用 Refresh Token 调用 `/app/auth/refresh` 接口 +2. 获取新的 Access Token 和 Refresh Token +3. 使用新的 Token 继续访问 API + +--- + +## 🧪 测试用例 + +### 使用 curl 测试 + +**1. 注册新用户**: +```bash +curl -X POST http://localhost:8888/app/auth/register \ + -H "Content-Type: application/json" \ + -d '{ + "username": "testuser", + "password": "123456", + "nickName": "测试用户" + }' +``` + +**2. 登录**: +```bash +curl -X POST http://localhost:8888/app/auth/login \ + -H "Content-Type: application/json" \ + -d '{ + "username": "testuser", + "password": "123456" + }' +``` + +**3. 获取用户信息**(需替换 TOKEN): +```bash +TOKEN="your_token_here" +curl -X GET http://localhost:8888/app/auth/userinfo \ + -H "x-token: $TOKEN" +``` + +**4. 更新用户信息**: +```bash +curl -X PUT http://localhost:8888/app/user/profile \ + -H "x-token: $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "nickName": "新昵称", + "avatar": "https://example.com/avatar.jpg" + }' +``` + +**5. 修改密码**: +```bash +curl -X POST http://localhost:8888/app/user/change-password \ + -H "x-token: $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "oldPassword": "123456", + "newPassword": "new123456" + }' +``` + +--- + +## ❗ 常见错误码 + +| 错误码 | 说明 | +|-------|------| +| 0 | 成功 | +| 7 | 业务错误(如用户名已存在、密码错误等) | +| 401 | 未认证或 Token 无效 | +| 403 | 账户被禁用或状态异常 | +| 500 | 服务器内部错误 | + +--- + +## 📝 注意事项 + +1. **密码安全**: 所有密码均使用 bcrypt 加密存储,不可逆 +2. **用户状态**: 只有 `status=active` 且 `enable=true` 的用户才能登录 +3. **用户名唯一性**: 用户名和邮箱必须唯一 +4. **会话管理**: 登出后会删除对应的会话记录,但 Token 在过期前仍然有效 +5. **跨域配置**: 如需前端调用,请在 `initialize/router.go` 中启用 CORS 中间件 + +--- + +## 🔄 下一步开发 + +- [ ] 邮箱验证 +- [ ] 手机号验证 +- [ ] 找回密码 +- [ ] 第三方登录(OAuth) +- [ ] 多设备登录管理 +- [ ] 用户在线状态 diff --git a/docs/重构实施方案.md b/docs/重构实施方案.md new file mode 100644 index 0000000..b642ded --- /dev/null +++ b/docs/重构实施方案.md @@ -0,0 +1,3077 @@ +# 云酒馆项目重构实施方案 + +## 文档说明 + +本文档详细描述了将云酒馆项目从 Node.js 后端 + 前端一体化架构重构为 Go 后端 + 纯前端架构的完整实施方案。 + +--- + +## 一、重构目标 + +### 1.1 目标架构 + +``` +云酒馆项目(重构后) +├── web-app/ # 用户前端应用(纯前端 - C端) +│ ├── 移除所有 Node.js 后端代码 +│ ├── 仅保留静态资源和前端逻辑 +│ ├── 所有 API 调用指向 Go 后端 +│ └── 使用 Nginx 或其他静态服务器部署 +│ +├── web/ # 管理后台前端(B端) +│ ├── 基于 Vue 3 + Vite +│ ├── 系统管理功能(保持不变) +│ ├── AI对话数据管理(新增) +│ └── 用户数据统计(新增) +│ +└── server/ # 统一 Go 后端服务 + ├── 基于 Gin + Gorm + ├── PostgreSQL 数据库 + ├── system/ 模块(保持不变) + │ └── 管理后台相关功能 + ├── app/ 模块(新增) + │ ├── 前台用户认证与授权 + │ ├── AI 对话服务集成 + │ ├── 角色与对话管理 + │ ├── 文件与对象存储 + │ ├── 向量数据库集成 + │ └── WebSocket 实时通信 + └── 双用户体系设计 + ├── sys_users(管理员) + └── app_users(前台用户) +``` + +### 1.2 重构原则 + +**⚠️ 重要约束**: +1. **不修改现有 system 模块**:所有管理后台相关的代码(api/v1/system、service/system、model/system 等)保持不变 +2. **不修改 sys_users 表**:管理员用户体系保持独立 +3. **新增 app 模块**:所有前台相关功能在新的 app 模块中实现 +4. **并行开发**:system 和 app 模块互不干扰,可以独立开发和部署 +5. **共享基础设施**:数据库连接、Redis、对象存储等基础设施共享 + +### 1.3 双用户体系设计 + +本项目采用双用户体系,将管理员和前台用户完全分离: + +#### 用户体系对比 + +| 特性 | 管理后台用户(sys_users) | 前台应用用户(app_users) | +|------|-------------------------|------------------------| +| 用途 | 系统管理、数据管理 | AI对话、角色管理 | +| 数据表 | `sys_users` | `app_users` | +| 认证方式 | 原有 JWT | 独立 JWT(带UserType标识) | +| 中间件 | `JWTAuth()` | `AppJWTAuth()` | +| 路由前缀 | `/base`, `/user`, `/authority` 等 | `/app/*` | +| 模块目录 | `system/` | `app/` | +| 是否修改 | ❌ 不修改 | ✅ 新建 | + +#### 设计优势 + +1. **完全隔离**:两套用户体系互不干扰,降低风险 +2. **独立扩展**:前台功能可独立开发、测试、部署 +3. **权限清晰**:管理员和普通用户权限分离 +4. **数据安全**:管理后台数据不受前台影响 + +### 1.4 技术栈确定 + +#### 后端技术栈 +- **语言**: Go 1.24+ +- **Web框架**: Gin 1.10+ +- **ORM**: Gorm 1.25+ +- **数据库**: PostgreSQL 14+ +- **向量扩展**: pgvector(PostgreSQL扩展) +- **缓存**: Redis 7+ +- **认证**: JWT (golang-jwt/jwt) +- **WebSocket**: gorilla/websocket +- **AI SDK**: + - go-openai (OpenAI) + - anthropic-sdk-go (Claude) + - google-cloud-go/ai (Gemini) + +#### 前端技术栈 +- **web-app**: 原生 JavaScript + HTML + CSS(移除 Express) +- **web**: Vue 3 + Vite + Element Plus + Pinia + +--- + +## 二、重构阶段规划 + +### 阶段概览 + +| 阶段 | 名称 | 预期成果 | 依赖 | +|------|------|----------|------| +| 阶段一 | 数据库设计 | 完整的数据库表结构 | - | +| 阶段二 | Go后端API开发 | 替代所有 Node.js 接口 | 阶段一 | +| 阶段三 | 前端改造 | web-app 纯前端化 | 阶段二 | +| 阶段四 | 数据迁移 | 历史数据迁移 | 阶段二、三 | +| 阶段五 | 测试与优化 | 功能验证、性能优化 | 阶段一-四 | +| 阶段六 | 部署上线 | 生产环境部署 | 阶段五 | + +--- + +## 三、阶段一:数据库设计 + +### 3.1 PostgreSQL 数据库设计 + +#### 3.1.1 安装 pgvector 扩展 + +```sql +-- 安装 pgvector 扩展(用于向量存储) +CREATE EXTENSION IF NOT EXISTS vector; +``` + +#### 3.1.2 核心表结构设计 + +**说明**: +- `sys_users` 表保持不变,仅用于管理后台用户(管理员) +- 新建 `app_users` 表,用于前台用户(普通用户) +- 两套用户体系完全独立,互不干扰 + +##### 1. 前台用户相关表 + +```sql +-- 前台用户表(新建,与 sys_users 独立) +CREATE TABLE IF NOT EXISTS app_users ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE, + + -- 基本信息 + uuid UUID UNIQUE NOT NULL DEFAULT gen_random_uuid(), + username VARCHAR(191) UNIQUE NOT NULL, + password VARCHAR(191) NOT NULL, -- bcrypt 加密 + nick_name VARCHAR(191) DEFAULT '', + email VARCHAR(191), + phone VARCHAR(191), + avatar VARCHAR(1024), -- 头像 URL + + -- 账户状态 + status VARCHAR(50) DEFAULT 'active', -- active, suspended, deleted + enable BOOLEAN DEFAULT TRUE, + + -- 认证信息 + last_login_at TIMESTAMP WITH TIME ZONE, + last_login_ip VARCHAR(100), + + -- AI 相关配置(JSONB 存储) + ai_settings JSONB DEFAULT '{}'::jsonb, + + -- 用户偏好设置 + preferences JSONB DEFAULT '{}'::jsonb, + + -- 统计信息 + chat_count INTEGER DEFAULT 0, -- 对话数量 + message_count INTEGER DEFAULT 0, -- 消息数量 + + INDEX idx_username (username), + INDEX idx_uuid (uuid), + INDEX idx_email (email), + INDEX idx_deleted_at (deleted_at) +); + +COMMENT ON TABLE app_users IS '前台用户表(与管理后台 sys_users 独立)'; +COMMENT ON COLUMN app_users.ai_settings IS 'AI配置,如:默认模型、参数等'; +COMMENT ON COLUMN app_users.preferences IS '用户偏好,如:主题、语言等'; + +-- 前台用户会话表 +CREATE TABLE IF NOT EXISTS app_user_sessions ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + + user_id BIGINT NOT NULL REFERENCES app_users(id) ON DELETE CASCADE, + session_token VARCHAR(500) UNIQUE NOT NULL, + refresh_token VARCHAR(500), + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + refresh_expires_at TIMESTAMP WITH TIME ZONE, + ip_address VARCHAR(100), + user_agent TEXT, + device_info JSONB DEFAULT '{}'::jsonb, + + INDEX idx_user_id (user_id), + INDEX idx_session_token (session_token), + INDEX idx_expires_at (expires_at) +); + +COMMENT ON TABLE app_user_sessions IS '前台用户会话表(支持多设备登录)'; +``` + +##### 2. AI 角色相关表 + +```sql +-- AI 角色表 +CREATE TABLE IF NOT EXISTS ai_characters ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE, + + -- 角色基本信息 + name VARCHAR(500) NOT NULL, + description TEXT, + personality TEXT, + scenario TEXT, + + -- 角色头像 + avatar VARCHAR(1024), + + -- 创建者(前台用户) + creator_id BIGINT REFERENCES app_users(id) ON DELETE SET NULL, + + -- 角色卡片数据(完整的 Character Card 格式) + card_data JSONB NOT NULL, + + -- 角色标签 + tags TEXT[], + + -- 可见性:public(公开), private(私有), shared(共享) + visibility VARCHAR(50) DEFAULT 'private', + + -- 角色版本 + version INTEGER DEFAULT 1, + + -- 第一条消息(开场白) + first_message TEXT, + + -- 消息示例 + example_messages JSONB DEFAULT '[]'::jsonb, + + -- 统计信息 + usage_count INTEGER DEFAULT 0, + favorite_count INTEGER DEFAULT 0, + + INDEX idx_creator_id (creator_id), + INDEX idx_visibility (visibility), + INDEX idx_tags (tags) USING GIN, + INDEX idx_deleted_at (deleted_at) +); + +-- 用户收藏的角色 +CREATE TABLE IF NOT EXISTS app_user_favorite_characters ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + + user_id BIGINT NOT NULL REFERENCES app_users(id) ON DELETE CASCADE, + character_id BIGINT NOT NULL REFERENCES ai_characters(id) ON DELETE CASCADE, + + UNIQUE(user_id, character_id), + INDEX idx_user_id (user_id), + INDEX idx_character_id (character_id) +); + +COMMENT ON TABLE app_user_favorite_characters IS '前台用户收藏的角色'; +``` + +##### 3. 对话相关表 + +```sql +-- 对话表 +CREATE TABLE IF NOT EXISTS ai_chats ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE, + + -- 对话标题 + title VARCHAR(500) DEFAULT '新对话', + + -- 所属用户(前台用户) + user_id BIGINT NOT NULL REFERENCES app_users(id) ON DELETE CASCADE, + + -- 关联角色 + character_id BIGINT REFERENCES ai_characters(id) ON DELETE SET NULL, + + -- 对话类型:single(单角色), group(群聊) + chat_type VARCHAR(50) DEFAULT 'single', + + -- 对话设置 + settings JSONB DEFAULT '{}'::jsonb, + + -- 最后一条消息时间 + last_message_at TIMESTAMP WITH TIME ZONE, + + -- 消息数量 + message_count INTEGER DEFAULT 0, + + -- 是否固定 + is_pinned BOOLEAN DEFAULT FALSE, + + INDEX idx_user_id (user_id), + INDEX idx_character_id (character_id), + INDEX idx_last_message_at (last_message_at), + INDEX idx_deleted_at (deleted_at) +); + +-- 消息表 +CREATE TABLE IF NOT EXISTS ai_messages ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE, + + -- 所属对话 + chat_id BIGINT NOT NULL REFERENCES ai_chats(id) ON DELETE CASCADE, + + -- 消息内容 + content TEXT NOT NULL, + + -- 发送者类型:user(用户), assistant(AI), system(系统) + role VARCHAR(50) NOT NULL, + + -- 发送者ID(如果是用户消息,关联前台用户) + sender_id BIGINT REFERENCES app_users(id) ON DELETE SET NULL, + + -- AI角色ID(如果是AI消息) + character_id BIGINT REFERENCES ai_characters(id) ON DELETE SET NULL, + + -- 消息序号(在对话中的位置) + sequence_number INTEGER NOT NULL, + + -- AI 模型信息 + model VARCHAR(200), + + -- Token 使用量 + prompt_tokens INTEGER DEFAULT 0, + completion_tokens INTEGER DEFAULT 0, + total_tokens INTEGER DEFAULT 0, + + -- 生成参数 + generation_params JSONB DEFAULT '{}'::jsonb, + + -- 消息元数据(如:swipe变体、编辑历史等) + metadata JSONB DEFAULT '{}'::jsonb, + + -- 是否被用户删除 + is_deleted BOOLEAN DEFAULT FALSE, + + INDEX idx_chat_id (chat_id), + INDEX idx_role (role), + INDEX idx_sequence_number (chat_id, sequence_number), + INDEX idx_deleted_at (deleted_at) +); + +-- 消息变体表(swipe 功能) +CREATE TABLE IF NOT EXISTS ai_message_swipes ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + + message_id BIGINT NOT NULL REFERENCES ai_messages(id) ON DELETE CASCADE, + + -- 变体内容 + content TEXT NOT NULL, + + -- 变体序号 + swipe_index INTEGER NOT NULL, + + -- 是否为当前选中的变体 + is_active BOOLEAN DEFAULT FALSE, + + -- 生成参数 + generation_params JSONB DEFAULT '{}'::jsonb, + + UNIQUE(message_id, swipe_index), + INDEX idx_message_id (message_id) +); +``` + +##### 4. 群聊相关表 + +```sql +-- 群聊成员表 +CREATE TABLE IF NOT EXISTS ai_chat_members ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + + chat_id BIGINT NOT NULL REFERENCES ai_chats(id) ON DELETE CASCADE, + character_id BIGINT NOT NULL REFERENCES ai_characters(id) ON DELETE CASCADE, + + -- 成员在群聊中的排序 + display_order INTEGER DEFAULT 0, + + -- 成员设置 + settings JSONB DEFAULT '{}'::jsonb, + + UNIQUE(chat_id, character_id), + INDEX idx_chat_id (chat_id), + INDEX idx_character_id (character_id) +); +``` + +##### 5. 向量记忆表 + +```sql +-- 向量记忆表(使用 pgvector) +CREATE TABLE IF NOT EXISTS ai_memory_vectors ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE, + + -- 所属用户(前台用户) + user_id BIGINT NOT NULL REFERENCES app_users(id) ON DELETE CASCADE, + + -- 所属角色(可选) + character_id BIGINT REFERENCES ai_characters(id) ON DELETE CASCADE, + + -- 所属对话(可选) + chat_id BIGINT REFERENCES ai_chats(id) ON DELETE CASCADE, + + -- 文本内容 + content TEXT NOT NULL, + + -- 向量嵌入(1536维,OpenAI text-embedding-ada-002) + embedding vector(1536), + + -- 元数据 + metadata JSONB DEFAULT '{}'::jsonb, + + -- 重要性评分 + importance FLOAT DEFAULT 0.5, + + INDEX idx_user_id (user_id), + INDEX idx_character_id (character_id), + INDEX idx_chat_id (chat_id), + INDEX idx_deleted_at (deleted_at) +); + +-- 创建向量索引(使用 HNSW 算法,余弦相似度) +CREATE INDEX idx_memory_vectors_embedding ON ai_memory_vectors +USING hnsw (embedding vector_cosine_ops); +``` + +##### 6. AI 服务配置表 + +```sql +-- AI 服务提供商配置 +CREATE TABLE IF NOT EXISTS ai_providers ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE, + + -- 用户ID(每个前台用户可以有自己的配置,NULL表示系统默认配置) + user_id BIGINT REFERENCES app_users(id) ON DELETE CASCADE, + + -- 提供商名称:openai, anthropic, google, azure, etc. + provider_name VARCHAR(100) NOT NULL, + + -- API 配置(加密存储) + api_config JSONB NOT NULL, + + -- 是否启用 + is_enabled BOOLEAN DEFAULT TRUE, + + -- 是否为默认提供商 + is_default BOOLEAN DEFAULT FALSE, + + INDEX idx_user_id (user_id), + INDEX idx_provider_name (provider_name), + INDEX idx_deleted_at (deleted_at) +); + +-- AI 模型配置 +CREATE TABLE IF NOT EXISTS ai_models ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + + provider_id BIGINT NOT NULL REFERENCES ai_providers(id) ON DELETE CASCADE, + + -- 模型名称 + model_name VARCHAR(200) NOT NULL, + + -- 模型显示名称 + display_name VARCHAR(200), + + -- 模型参数配置 + config JSONB DEFAULT '{}'::jsonb, + + -- 是否启用 + is_enabled BOOLEAN DEFAULT TRUE, + + INDEX idx_provider_id (provider_id), + INDEX idx_model_name (model_name) +); +``` + +##### 7. 文件管理表 + +```sql +-- 文件表 +CREATE TABLE IF NOT EXISTS ai_files ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE, + + -- 上传者(前台用户) + user_id BIGINT NOT NULL REFERENCES app_users(id) ON DELETE CASCADE, + + -- 文件名 + filename VARCHAR(500) NOT NULL, + + -- 原始文件名 + original_filename VARCHAR(500) NOT NULL, + + -- 文件类型:avatar, background, attachment, export, etc. + file_type VARCHAR(100) NOT NULL, + + -- MIME 类型 + mime_type VARCHAR(200), + + -- 文件大小(字节) + file_size BIGINT, + + -- 存储路径 + storage_path VARCHAR(1024) NOT NULL, + + -- 对象存储 URL(如果使用OSS) + url VARCHAR(1024), + + -- 关联对象(JSON格式,如:{"chat_id": 123, "character_id": 456}) + related_to JSONB DEFAULT '{}'::jsonb, + + -- 元数据 + metadata JSONB DEFAULT '{}'::jsonb, + + INDEX idx_user_id (user_id), + INDEX idx_file_type (file_type), + INDEX idx_deleted_at (deleted_at) +); +``` + +##### 8. 预设与设置表 + +```sql +-- 对话预设表 +CREATE TABLE IF NOT EXISTS ai_presets ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE, + + -- 预设名称 + name VARCHAR(200) NOT NULL, + + -- 所属用户(NULL表示系统预设,非NULL表示前台用户的自定义预设) + user_id BIGINT REFERENCES app_users(id) ON DELETE CASCADE, + + -- 预设类型:generation, instruction, etc. + preset_type VARCHAR(100) NOT NULL, + + -- 预设配置 + config JSONB NOT NULL, + + -- 是否为系统预设 + is_system BOOLEAN DEFAULT FALSE, + + -- 是否为默认预设 + is_default BOOLEAN DEFAULT FALSE, + + INDEX idx_user_id (user_id), + INDEX idx_preset_type (preset_type), + INDEX idx_deleted_at (deleted_at) +); + +-- 世界书(World Info)表 +CREATE TABLE IF NOT EXISTS ai_world_info ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE, + + -- 所属用户(前台用户) + user_id BIGINT NOT NULL REFERENCES app_users(id) ON DELETE CASCADE, + + -- 关联角色(可选) + character_id BIGINT REFERENCES ai_characters(id) ON DELETE CASCADE, + + -- 世界书名称 + name VARCHAR(500) NOT NULL, + + -- 触发关键词 + keywords TEXT[], + + -- 内容 + content TEXT NOT NULL, + + -- 优先级 + priority INTEGER DEFAULT 0, + + -- 是否启用 + is_enabled BOOLEAN DEFAULT TRUE, + + -- 触发条件配置 + trigger_config JSONB DEFAULT '{}'::jsonb, + + INDEX idx_user_id (user_id), + INDEX idx_character_id (character_id), + INDEX idx_keywords (keywords) USING GIN, + INDEX idx_deleted_at (deleted_at) +); +``` + +##### 9. 系统设置与日志表 + +```sql +-- 系统配置表(扩展现有) +CREATE TABLE IF NOT EXISTS sys_configs ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + + -- 配置键 + config_key VARCHAR(200) UNIQUE NOT NULL, + + -- 配置值 + config_value JSONB NOT NULL, + + -- 配置描述 + description TEXT, + + -- 配置分组 + config_group VARCHAR(100), + + INDEX idx_config_key (config_key), + INDEX idx_config_group (config_group) +); + +-- AI 使用统计表 +CREATE TABLE IF NOT EXISTS ai_usage_stats ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + + user_id BIGINT NOT NULL REFERENCES app_users(id) ON DELETE CASCADE, + + -- 统计日期 + stat_date DATE NOT NULL, + + -- AI 提供商 + provider_name VARCHAR(100), + + -- 模型名称 + model_name VARCHAR(200), + + -- 请求次数 + request_count INTEGER DEFAULT 0, + + -- Token 使用量 + total_tokens BIGINT DEFAULT 0, + prompt_tokens BIGINT DEFAULT 0, + completion_tokens BIGINT DEFAULT 0, + + -- 费用(如果有) + cost DECIMAL(10, 4) DEFAULT 0, + + UNIQUE(user_id, stat_date, provider_name, model_name), + INDEX idx_user_id (user_id), + INDEX idx_stat_date (stat_date) +); +``` + +### 3.2 数据库迁移脚本 + +创建 Gorm 迁移文件:`server/model/app/` 目录下创建对应的 Go 模型文件。 + +**重要说明**: +- 不要修改 `server/model/system/` 下的任何文件 +- 所有前台相关模型都在 `server/model/app/` 下创建 + +#### 示例:前台用户模型 + +```go +// server/model/app/user.go +package app + +import ( + "git.echol.cn/loser/st/server/global" + "gorm.io/datatypes" + "time" +) + +// AppUser 前台用户模型(与 sys_users 独立) +type AppUser struct { + global.GVA_MODEL + UUID string `json:"uuid" gorm:"type:uuid;uniqueIndex;comment:用户UUID"` + Username string `json:"username" gorm:"uniqueIndex;comment:用户登录名"` + Password string `json:"-" gorm:"comment:用户登录密码"` + NickName string `json:"nickName" gorm:"comment:用户昵称"` + Email string `json:"email" gorm:"index;comment:用户邮箱"` + Phone string `json:"phone" gorm:"comment:用户手机号"` + Avatar string `json:"avatar" gorm:"type:varchar(1024);comment:用户头像"` + Status string `json:"status" gorm:"type:varchar(50);default:active;comment:账户状态"` + Enable bool `json:"enable" gorm:"default:true;comment:用户是否启用"` + LastLoginAt *time.Time `json:"lastLoginAt" gorm:"comment:最后登录时间"` + LastLoginIP string `json:"lastLoginIp" gorm:"type:varchar(100);comment:最后登录IP"` + AISettings datatypes.JSON `json:"aiSettings" gorm:"type:jsonb;comment:AI配置"` + Preferences datatypes.JSON `json:"preferences" gorm:"type:jsonb;comment:用户偏好"` + ChatCount int `json:"chatCount" gorm:"default:0;comment:对话数量"` + MessageCount int `json:"messageCount" gorm:"default:0;comment:消息数量"` +} + +func (AppUser) TableName() string { + return "app_users" +} + +// AppUserSession 前台用户会话 +type AppUserSession struct { + global.GVA_MODEL + UserID uint `json:"userId" gorm:"index;comment:用户ID"` + SessionToken string `json:"sessionToken" gorm:"type:varchar(500);uniqueIndex;comment:会话Token"` + RefreshToken string `json:"refreshToken" gorm:"type:varchar(500);comment:刷新Token"` + ExpiresAt time.Time `json:"expiresAt" gorm:"index;comment:过期时间"` + RefreshExpiresAt *time.Time `json:"refreshExpiresAt" gorm:"comment:刷新Token过期时间"` + IPAddress string `json:"ipAddress" gorm:"type:varchar(100);comment:IP地址"` + UserAgent string `json:"userAgent" gorm:"type:text;comment:用户代理"` + DeviceInfo datatypes.JSON `json:"deviceInfo" gorm:"type:jsonb;comment:设备信息"` +} + +func (AppUserSession) TableName() string { + return "app_user_sessions" +} +``` + +--- + +## 四、阶段二:Go 后端 API 开发 + +### 4.1 项目结构调整 + +**重要原则**: +- ✅ 保持现有 `system/` 模块完全不变 +- ✅ 新增 `app/` 模块,与 `system/` 并列 +- ✅ 两个模块完全独立,互不干扰 + +在 `server/` 目录下创建新的模块结构: + +``` +server/ +├── api/ +│ └── v1/ +│ ├── system/ # 现有系统接口(保持不变) +│ │ ├── sys_user.go +│ │ ├── sys_authority.go +│ │ └── ... +│ └── app/ # 新增:前台应用接口 +│ ├── character.go # 角色管理 +│ ├── chat.go # 对话管理 +│ ├── message.go # 消息管理 +│ ├── provider.go # AI提供商配置 +│ ├── memory.go # 向量记忆 +│ ├── preset.go # 预设管理 +│ └── file.go # 文件管理 +├── model/ +│ └── app/ # 新增:应用模型 +│ ├── character.go +│ ├── chat.go +│ ├── message.go +│ ├── provider.go +│ ├── memory.go +│ └── ... +├── service/ +│ └── app/ # 新增:应用服务 +│ ├── character.go +│ ├── chat.go +│ ├── ai_service.go # AI服务集成 +│ ├── embedding.go # 向量嵌入服务 +│ └── ... +├── router/ +│ └── app/ # 新增:应用路由 +│ ├── character.go +│ ├── chat.go +│ └── ... +└── pkg/ # 新增:公共包(system 和 app 共享) + ├── ai/ # AI SDK 封装 + │ ├── openai.go + │ ├── anthropic.go + │ └── google.go + ├── embedding/ # 向量嵌入 + │ └── openai.go + └── websocket/ # WebSocket 服务 + └── hub.go +``` + +**重要说明**: +1. 所有 `system/` 目录下的代码保持不变 +2. 所有新增功能都在 `app/` 目录下实现 +3. `pkg/` 目录下的公共包可以被 `system` 和 `app` 共同使用 +4. 两个模块使用不同的数据表,互不干扰 + +### 4.2 双用户体系设计 + +#### 4.2.1 用户类型区分 + +```go +// server/global/constants.go (新增常量) +package global + +const ( + UserTypeSystem = "system" // 管理后台用户 + UserTypeApp = "app" // 前台应用用户 +) +``` + +#### 4.2.2 前台用户 JWT 中间件 + +```go +// server/middleware/app_jwt.go (新增) +package middleware + +import ( + "github.com/gin-gonic/gin" + "git.echol.cn/loser/st/server/global" + "git.echol.cn/loser/st/server/model/app" + "git.echol.cn/loser/st/server/utils" +) + +// AppJWTAuth 前台用户JWT认证中间件 +func AppJWTAuth() gin.HandlerFunc { + return func(c *gin.Context) { + token := utils.GetToken(c) + if token == "" { + response.FailWithDetailed(gin.H{"reload": true}, "未登录或非法访问", c) + c.Abort() + return + } + + // 解析 JWT + claims, err := utils.ParseToken(token) + if err != nil { + response.FailWithDetailed(gin.H{"reload": true}, "Token已过期", c) + c.Abort() + return + } + + // 验证用户类型(确保是前台用户) + if claims.UserType != global.UserTypeApp { + response.FailWithMessage("无效的用户类型", c) + c.Abort() + return + } + + // 查询用户是否存在 + var user app.AppUser + err = global.GVA_DB.Where("id = ?", claims.UserID).First(&user).Error + if err != nil { + response.FailWithMessage("用户不存在", c) + c.Abort() + return + } + + // 检查用户状态 + if !user.Enable { + response.FailWithMessage("用户已被禁用", c) + c.Abort() + return + } + + // 将用户信息存入上下文 + c.Set("appUserID", user.ID) + c.Set("appUser", user) + c.Next() + } +} + +// GetAppUserID 从上下文获取前台用户ID +func GetAppUserID(c *gin.Context) uint { + if userID, exists := c.Get("appUserID"); exists { + return userID.(uint) + } + return 0 +} + +// GetAppUser 从上下文获取前台用户信息 +func GetAppUser(c *gin.Context) *app.AppUser { + if user, exists := c.Get("appUser"); exists { + return user.(*app.AppUser) + } + return nil +} +``` + +**说明**: +- 管理后台继续使用原有的 `JWTAuth()` 中间件(不修改) +- 前台应用使用新的 `AppJWTAuth()` 中间件 +- JWT Claims 中添加 `UserType` 字段区分用户类型 +- 两套中间件完全独立,互不干扰 + +### 4.3 核心依赖添加 + +在 `server/go.mod` 中添加以下依赖: + +```bash +cd server +go get github.com/sashabaranov/go-openai@latest +go get github.com/gorilla/websocket@latest +go get github.com/pgvector/pgvector-go@latest +go get github.com/pkoukk/tiktoken-go@latest +``` + +### 4.3 核心 API 接口开发 + +**重要提醒**: +- 所有前台 API 都在 `api/v1/app/` 下实现 +- 不要修改 `api/v1/system/` 下的任何文件 +- 前台用户使用 `app_users` 表,管理员使用 `sys_users` 表 + +#### 4.3.1 前台用户认证 API + +```go +// server/api/v1/app/auth.go +package app + +import ( + "github.com/gin-gonic/gin" + "git.echol.cn/loser/st/server/global" + "git.echol.cn/loser/st/server/model/common/response" + "git.echol.cn/loser/st/server/service" +) + +type AuthApi struct{} + +// Register 前台用户注册 +// @Tags App.Auth +// @Summary 前台用户注册 +// @accept application/json +// @Produce application/json +// @Param data body request.RegisterRequest true "用户注册信息" +// @Success 200 {object} response.Response{msg=string} +// @Router /app/auth/register [post] +func (a *AuthApi) Register(ctx *gin.Context) { + // 实现逻辑 +} + +// Login 前台用户登录 +// @Tags App.Auth +// @Summary 前台用户登录 +// @accept application/json +// @Produce application/json +// @Param data body request.LoginRequest true "用户登录信息" +// @Success 200 {object} response.Response{data=response.LoginResponse,msg=string} +// @Router /app/auth/login [post] +func (a *AuthApi) Login(ctx *gin.Context) { + // 实现逻辑 +} + +// Logout 前台用户登出 +// @Tags App.Auth +// @Summary 前台用户登出 +// @Security ApiKeyAuth +// @accept application/json +// @Produce application/json +// @Success 200 {object} response.Response{msg=string} +// @Router /app/auth/logout [post] +func (a *AuthApi) Logout(ctx *gin.Context) { + // 实现逻辑 +} + +// RefreshToken 刷新Token +// @Tags App.Auth +// @Summary 刷新Token +// @accept application/json +// @Produce application/json +// @Param data body request.RefreshTokenRequest true "刷新Token" +// @Success 200 {object} response.Response{data=response.LoginResponse,msg=string} +// @Router /app/auth/refresh [post] +func (a *AuthApi) RefreshToken(ctx *gin.Context) { + // 实现逻辑 +} + +// GetUserInfo 获取当前登录用户信息 +// @Tags App.Auth +// @Summary 获取当前登录用户信息 +// @Security ApiKeyAuth +// @accept application/json +// @Produce application/json +// @Success 200 {object} response.Response{data=model.AppUser,msg=string} +// @Router /app/auth/userinfo [get] +func (a *AuthApi) GetUserInfo(ctx *gin.Context) { + // 实现逻辑 +} +``` + +**说明**: +- 前台用户认证完全独立于管理后台 +- 使用独立的 JWT Token,避免混淆 +- 建议使用不同的 Token 密钥或添加类型标识 + +#### 4.3.2 角色管理 API + +```go +// server/api/v1/app/character.go +package app + +import ( + "github.com/gin-gonic/gin" + "git.echol.cn/loser/st/server/global" + "git.echol.cn/loser/st/server/model/common/response" + "git.echol.cn/loser/st/server/service" +) + +type CharacterApi struct{} + +// GetCharacterList 获取角色列表 +// @Tags Character +// @Summary 获取角色列表 +// @Security ApiKeyAuth +// @accept application/json +// @Produce application/json +// @Param data query request.CharacterSearch true "分页查询" +// @Success 200 {object} response.Response{data=response.PageResult,msg=string} +// @Router /character/list [get] +func (c *CharacterApi) GetCharacterList(ctx *gin.Context) { + // 实现逻辑 +} + +// CreateCharacter 创建角色 +// @Tags Character +// @Summary 创建角色 +// @Security ApiKeyAuth +// @accept application/json +// @Produce application/json +// @Param data body request.CharacterCreate true "角色信息" +// @Success 200 {object} response.Response{msg=string} +// @Router /character/create [post] +func (c *CharacterApi) CreateCharacter(ctx *gin.Context) { + // 实现逻辑 +} + +// GetCharacter 获取角色详情 +// @Tags Character +// @Summary 获取角色详情 +// @Security ApiKeyAuth +// @accept application/json +// @Produce application/json +// @Param id path int true "角色ID" +// @Success 200 {object} response.Response{data=model.Character,msg=string} +// @Router /character/:id [get] +func (c *CharacterApi) GetCharacter(ctx *gin.Context) { + // 实现逻辑 +} + +// UpdateCharacter 更新角色 +func (c *CharacterApi) UpdateCharacter(ctx *gin.Context) { + // 实现逻辑 +} + +// DeleteCharacter 删除角色 +func (c *CharacterApi) DeleteCharacter(ctx *gin.Context) { + // 实现逻辑 +} + +// ImportCharacter 导入角色(支持 Character Card 格式) +func (c *CharacterApi) ImportCharacter(ctx *gin.Context) { + // 实现逻辑 +} + +// ExportCharacter 导出角色 +func (c *CharacterApi) ExportCharacter(ctx *gin.Context) { + // 实现逻辑 +} +``` + +#### 4.3.2 对话管理 API + +```go +// server/api/v1/app/chat.go +package app + +type ChatApi struct{} + +// CreateChat 创建对话 +func (c *ChatApi) CreateChat(ctx *gin.Context) {} + +// GetChatList 获取对话列表 +func (c *ChatApi) GetChatList(ctx *gin.Context) {} + +// GetChat 获取对话详情 +func (c *ChatApi) GetChat(ctx *gin.Context) {} + +// UpdateChat 更新对话 +func (c *ChatApi) UpdateChat(ctx *gin.Context) {} + +// DeleteChat 删除对话 +func (c *ChatApi) DeleteChat(ctx *gin.Context) {} + +// GetChatMessages 获取对话消息历史 +func (c *ChatApi) GetChatMessages(ctx *gin.Context) {} + +// SendMessage 发送消息(调用 AI) +func (c *ChatApi) SendMessage(ctx *gin.Context) {} + +// RegenerateMessage 重新生成消息 +func (c *ChatApi) RegenerateMessage(ctx *gin.Context) {} + +// EditMessage 编辑消息 +func (c *ChatApi) EditMessage(ctx *gin.Context) {} + +// DeleteMessage 删除消息 +func (c *ChatApi) DeleteMessage(ctx *gin.Context) {} + +// SwipeMessage 切换消息变体 +func (c *ChatApi) SwipeMessage(ctx *gin.Context) {} +``` + +#### 4.3.3 AI 服务 API + +```go +// server/api/v1/app/provider.go +package app + +type ProviderApi struct{} + +// GetProviders 获取 AI 提供商列表 +func (p *ProviderApi) GetProviders(ctx *gin.Context) {} + +// CreateProvider 创建 AI 提供商配置 +func (p *ProviderApi) CreateProvider(ctx *gin.Context) {} + +// UpdateProvider 更新 AI 提供商配置 +func (p *ProviderApi) UpdateProvider(ctx *gin.Context) {} + +// DeleteProvider 删除 AI 提供商配置 +func (p *ProviderApi) DeleteProvider(ctx *gin.Context) {} + +// TestProvider 测试 AI 提供商连接 +func (p *ProviderApi) TestProvider(ctx *gin.Context) {} + +// GetModels 获取可用模型列表 +func (p *ProviderApi) GetModels(ctx *gin.Context) {} +``` + +### 4.4 AI 服务集成 + +#### 4.4.1 OpenAI 集成 + +```go +// server/pkg/ai/openai.go +package ai + +import ( + "context" + "github.com/sashabaranov/go-openai" +) + +type OpenAIClient struct { + client *openai.Client +} + +func NewOpenAIClient(apiKey string, baseURL ...string) *OpenAIClient { + config := openai.DefaultConfig(apiKey) + if len(baseURL) > 0 { + config.BaseURL = baseURL[0] + } + return &OpenAIClient{ + client: openai.NewClientWithConfig(config), + } +} + +// ChatCompletion 对话补全 +func (c *OpenAIClient) ChatCompletion(ctx context.Context, messages []openai.ChatCompletionMessage, opts ...Option) (*openai.ChatCompletionResponse, error) { + req := openai.ChatCompletionRequest{ + Model: openai.GPT4, + Messages: messages, + } + + // 应用选项 + for _, opt := range opts { + opt(&req) + } + + return c.client.CreateChatCompletion(ctx, req) +} + +// StreamChatCompletion 流式对话补全 +func (c *OpenAIClient) StreamChatCompletion(ctx context.Context, messages []openai.ChatCompletionMessage, opts ...Option) (*openai.ChatCompletionStream, error) { + req := openai.ChatCompletionRequest{ + Model: openai.GPT4, + Messages: messages, + Stream: true, + } + + for _, opt := range opts { + opt(&req) + } + + return c.client.CreateChatCompletionStream(ctx, req) +} + +// CreateEmbedding 创建向量嵌入 +func (c *OpenAIClient) CreateEmbedding(ctx context.Context, input string) ([]float32, error) { + resp, err := c.client.CreateEmbeddings(ctx, openai.EmbeddingRequest{ + Model: openai.AdaEmbeddingV2, + Input: input, + }) + if err != nil { + return nil, err + } + return resp.Data[0].Embedding, nil +} + +type Option func(*openai.ChatCompletionRequest) + +func WithModel(model string) Option { + return func(req *openai.ChatCompletionRequest) { + req.Model = model + } +} + +func WithTemperature(temp float32) Option { + return func(req *openai.ChatCompletionRequest) { + req.Temperature = temp + } +} + +func WithMaxTokens(max int) Option { + return func(req *openai.ChatCompletionRequest) { + req.MaxTokens = max + } +} +``` + +#### 4.4.2 向量嵌入服务 + +```go +// server/service/app/embedding.go +package app + +import ( + "context" + "git.echol.cn/loser/st/server/global" + "git.echol.cn/loser/st/server/model/app" + "git.echol.cn/loser/st/server/pkg/ai" + "github.com/pgvector/pgvector-go" +) + +type EmbeddingService struct{} + +// CreateMemory 创建记忆(带向量嵌入) +func (e *EmbeddingService) CreateMemory(ctx context.Context, userID uint, content string, metadata map[string]interface{}) error { + // 1. 创建向量嵌入 + aiClient := ai.NewOpenAIClient(global.GVA_CONFIG.AI.OpenAI.APIKey) + embedding, err := aiClient.CreateEmbedding(ctx, content) + if err != nil { + return err + } + + // 2. 保存到数据库 + memory := app.MemoryVector{ + UserID: userID, + Content: content, + Embedding: pgvector.NewVector(embedding), + Metadata: metadata, + } + + return global.GVA_DB.Create(&memory).Error +} + +// SearchSimilarMemories 搜索相似记忆 +func (e *EmbeddingService) SearchSimilarMemories(ctx context.Context, userID uint, query string, limit int) ([]app.MemoryVector, error) { + // 1. 创建查询向量 + aiClient := ai.NewOpenAIClient(global.GVA_CONFIG.AI.OpenAI.APIKey) + embedding, err := aiClient.CreateEmbedding(ctx, query) + if err != nil { + return nil, err + } + + // 2. 向量相似度搜索 + var memories []app.MemoryVector + err = global.GVA_DB. + Where("user_id = ?", userID). + Order(fmt.Sprintf("embedding <=> '%s'", pgvector.NewVector(embedding).String())). + Limit(limit). + Find(&memories).Error + + return memories, err +} +``` + +### 4.5 WebSocket 实现 + +```go +// server/pkg/websocket/hub.go +package websocket + +import ( + "sync" + "github.com/gorilla/websocket" +) + +// Hub WebSocket 连接中心 +type Hub struct { + // 客户端连接映射 map[userID]map[connectionID]*Client + clients map[uint]map[string]*Client + broadcast chan *Message + register chan *Client + unregister chan *Client + mu sync.RWMutex +} + +type Client struct { + hub *Hub + conn *websocket.Conn + userID uint + id string + send chan []byte +} + +type Message struct { + UserID uint `json:"userId"` + Type string `json:"type"` + Payload interface{} `json:"payload"` +} + +func NewHub() *Hub { + return &Hub{ + clients: make(map[uint]map[string]*Client), + broadcast: make(chan *Message, 256), + register: make(chan *Client), + unregister: make(chan *Client), + } +} + +func (h *Hub) Run() { + for { + select { + case client := <-h.register: + h.mu.Lock() + if _, ok := h.clients[client.userID]; !ok { + h.clients[client.userID] = make(map[string]*Client) + } + h.clients[client.userID][client.id] = client + h.mu.Unlock() + + case client := <-h.unregister: + h.mu.Lock() + if clients, ok := h.clients[client.userID]; ok { + if _, ok := clients[client.id]; ok { + delete(clients, client.id) + close(client.send) + if len(clients) == 0 { + delete(h.clients, client.userID) + } + } + } + h.mu.Unlock() + + case message := <-h.broadcast: + h.mu.RLock() + if clients, ok := h.clients[message.UserID]; ok { + for _, client := range clients { + select { + case client.send <- marshal(message): + default: + close(client.send) + delete(clients, client.id) + } + } + } + h.mu.RUnlock() + } + } +} + +// BroadcastToUser 向指定用户广播消息 +func (h *Hub) BroadcastToUser(userID uint, msgType string, payload interface{}) { + h.broadcast <- &Message{ + UserID: userID, + Type: msgType, + Payload: payload, + } +} +``` + +### 4.6 路由注册 + +**重要说明**: +- 前台路由统一使用 `/app` 前缀,与管理后台的路由区分 +- 管理后台路由保持原有的路径不变 + +#### 4.6.1 前台用户认证路由 + +```go +// server/router/app/auth.go +package app + +import ( + "github.com/gin-gonic/gin" + v1 "git.echol.cn/loser/st/server/api/v1" +) + +type AuthRouter struct{} + +func (r *AuthRouter) InitAuthRouter(Router *gin.RouterGroup) { + // 公开路由(无需认证) + publicRouter := Router.Group("auth") + authApi := v1.ApiGroupApp.AppApiGroup.AuthApi + { + publicRouter.POST("register", authApi.Register) // 注册 + publicRouter.POST("login", authApi.Login) // 登录 + publicRouter.POST("refresh", authApi.RefreshToken) // 刷新Token + } + + // 需要认证的路由 + privateRouter := Router.Group("auth") + // privateRouter.Use(middleware.AppJWTAuth()) // 使用前台用户JWT中间件 + { + privateRouter.POST("logout", authApi.Logout) + privateRouter.GET("userinfo", authApi.GetUserInfo) + } +} +``` + +#### 4.6.2 角色管理路由 + +```go +// server/router/app/character.go +package app + +import ( + "github.com/gin-gonic/gin" + v1 "git.echol.cn/loser/st/server/api/v1" +) + +type CharacterRouter struct{} + +func (r *CharacterRouter) InitCharacterRouter(Router *gin.RouterGroup) { + characterRouter := Router.Group("character") + // characterRouter.Use(middleware.AppJWTAuth()) // 使用前台用户JWT中间件 + characterApi := v1.ApiGroupApp.AppApiGroup.CharacterApi + { + characterRouter.GET("list", characterApi.GetCharacterList) + characterRouter.POST("create", characterApi.CreateCharacter) + characterRouter.GET(":id", characterApi.GetCharacter) + characterRouter.PUT(":id", characterApi.UpdateCharacter) + characterRouter.DELETE(":id", characterApi.DeleteCharacter) + characterRouter.POST("import", characterApi.ImportCharacter) + characterRouter.GET("export/:id", characterApi.ExportCharacter) + } +} +``` + +#### 4.6.3 路由入口注册 + +```go +// server/router/app/enter.go +package app + +type RouterGroup struct { + AuthRouter + CharacterRouter + ChatRouter + MessageRouter + ProviderRouter + FileRouter +} +``` + +#### 4.6.4 主路由文件集成 + +```go +// server/initialize/router.go +package initialize + +import ( + "git.echol.cn/loser/st/server/router" + "github.com/gin-gonic/gin" +) + +func Routers() *gin.Engine { + Router := gin.Default() + + // ... 其他中间件配置 + + PublicGroup := Router.Group("") + { + // 管理后台路由(保持不变) + systemRouter := router.RouterGroupApp.System + systemRouter.InitBaseRouter(PublicGroup) // 原有的管理后台路由 + // ... 其他 system 路由 + + // 前台应用路由(新增) + appRouter := router.RouterGroupApp.App + appGroup := PublicGroup.Group("app") // 统一使用 /app 前缀 + { + appRouter.InitAuthRouter(appGroup) // /app/auth/* + appRouter.InitCharacterRouter(appGroup) // /app/character/* + appRouter.InitChatRouter(appGroup) // /app/chat/* + // ... 其他 app 路由 + } + } + + return Router +} +``` + +**路由示例**: +- 管理后台登录:`POST /base/login` (保持不变) +- 前台用户登录:`POST /app/auth/login` (新增) +- 管理后台用户列表:`GET /user/getUserList` (保持不变) +- 前台角色列表:`GET /app/character/list` (新增) + +--- + +## 五、阶段三:前端改造 + +### 5.1 web-app 改造计划 + +#### 5.1.1 目录结构调整 + +``` +web-app/ +├── public/ # 静态资源(保留) +│ ├── index.html # 主页面 +│ ├── css/ # 样式 +│ ├── scripts/ # JavaScript +│ │ ├── main.js # 主入口 +│ │ ├── api/ # API 调用层(新增) +│ │ │ ├── client.js # HTTP 客户端 +│ │ │ ├── character.js +│ │ │ ├── chat.js +│ │ │ └── ... +│ │ ├── services/ # 业务逻辑层 +│ │ ├── components/ # UI 组件 +│ │ └── utils/ # 工具函数 +│ ├── lib/ # 第三方库(保留) +│ └── ... +├── nginx.conf # Nginx 配置(新增) +├── Dockerfile # Docker 配置(新增) +└── package.json # 仅用于开发工具(可选) +``` + +#### 5.1.2 移除的文件 + +删除以下 Node.js 后端相关文件: +- `server.js` +- `src/` 目录(所有后端代码) +- `webpack.config.js`(如果不需要打包) + +#### 5.1.3 API 客户端实现 + +```javascript +// public/scripts/api/client.js + +class APIClient { + constructor(baseURL) { + this.baseURL = baseURL || 'http://localhost:8888'; + this.token = localStorage.getItem('token'); + } + + async request(method, endpoint, data = null, options = {}) { + const url = `${this.baseURL}${endpoint}`; + const headers = { + 'Content-Type': 'application/json', + ...options.headers + }; + + if (this.token) { + headers['x-token'] = this.token; + } + + const config = { + method, + headers, + ...options + }; + + if (data && (method === 'POST' || method === 'PUT')) { + config.body = JSON.stringify(data); + } + + try { + const response = await fetch(url, config); + const result = await response.json(); + + if (result.code !== 0) { + throw new Error(result.msg || '请求失败'); + } + + return result.data; + } catch (error) { + console.error('API请求失败:', error); + throw error; + } + } + + get(endpoint, options) { + return this.request('GET', endpoint, null, options); + } + + post(endpoint, data, options) { + return this.request('POST', endpoint, data, options); + } + + put(endpoint, data, options) { + return this.request('PUT', endpoint, data, options); + } + + delete(endpoint, options) { + return this.request('DELETE', endpoint, null, options); + } + + setToken(token) { + this.token = token; + localStorage.setItem('token', token); + } + + clearToken() { + this.token = null; + localStorage.removeItem('token'); + } +} + +// 导出单例 +const apiClient = new APIClient(); +``` + +```javascript +// public/scripts/api/character.js + +class CharacterAPI { + constructor(client) { + this.client = client; + } + + // 获取角色列表 + async getList(params = {}) { + const query = new URLSearchParams(params).toString(); + return this.client.get(`/character/list?${query}`); + } + + // 创建角色 + async create(characterData) { + return this.client.post('/character/create', characterData); + } + + // 获取角色详情 + async get(id) { + return this.client.get(`/character/${id}`); + } + + // 更新角色 + async update(id, characterData) { + return this.client.put(`/character/${id}`, characterData); + } + + // 删除角色 + async delete(id) { + return this.client.delete(`/character/${id}`); + } + + // 导入角色 + async import(file) { + const formData = new FormData(); + formData.append('file', file); + + return this.client.post('/character/import', formData, { + headers: { + 'Content-Type': 'multipart/form-data' + } + }); + } + + // 导出角色 + async export(id) { + window.location.href = `${this.client.baseURL}/character/export/${id}`; + } +} + +const characterAPI = new CharacterAPI(apiClient); +``` + +```javascript +// public/scripts/api/chat.js + +class ChatAPI { + constructor(client) { + this.client = client; + } + + async getList(params = {}) { + const query = new URLSearchParams(params).toString(); + return this.client.get(`/chat/list?${query}`); + } + + async create(chatData) { + return this.client.post('/chat/create', chatData); + } + + async get(id) { + return this.client.get(`/chat/${id}`); + } + + async update(id, chatData) { + return this.client.put(`/chat/${id}`, chatData); + } + + async delete(id) { + return this.client.delete(`/chat/${id}`); + } + + async getMessages(chatId, params = {}) { + const query = new URLSearchParams(params).toString(); + return this.client.get(`/chat/${chatId}/messages?${query}`); + } + + async sendMessage(chatId, content, options = {}) { + return this.client.post(`/chat/${chatId}/send`, { + content, + ...options + }); + } + + async regenerateMessage(chatId, messageId) { + return this.client.post(`/chat/${chatId}/regenerate/${messageId}`); + } + + async editMessage(chatId, messageId, content) { + return this.client.put(`/chat/${chatId}/message/${messageId}`, { + content + }); + } + + async deleteMessage(chatId, messageId) { + return this.client.delete(`/chat/${chatId}/message/${messageId}`); + } +} + +const chatAPI = new ChatAPI(apiClient); +``` + +#### 5.1.4 WebSocket 客户端 + +```javascript +// public/scripts/utils/websocket.js + +class WebSocketClient { + constructor(url) { + this.url = url || 'ws://localhost:8888/ws'; + this.ws = null; + this.reconnectInterval = 5000; + this.listeners = new Map(); + } + + connect(token) { + const wsUrl = `${this.url}?token=${token}`; + this.ws = new WebSocket(wsUrl); + + this.ws.onopen = () => { + console.log('WebSocket 连接成功'); + this.emit('connected'); + }; + + this.ws.onmessage = (event) => { + try { + const message = JSON.parse(event.data); + this.emit(message.type, message.payload); + } catch (error) { + console.error('WebSocket 消息解析失败:', error); + } + }; + + this.ws.onerror = (error) => { + console.error('WebSocket 错误:', error); + this.emit('error', error); + }; + + this.ws.onclose = () => { + console.log('WebSocket 连接关闭'); + this.emit('disconnected'); + + // 自动重连 + setTimeout(() => { + this.connect(token); + }, this.reconnectInterval); + }; + } + + send(type, payload) { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify({ type, payload })); + } + } + + on(event, callback) { + if (!this.listeners.has(event)) { + this.listeners.set(event, []); + } + this.listeners.get(event).push(callback); + } + + off(event, callback) { + if (this.listeners.has(event)) { + const callbacks = this.listeners.get(event); + const index = callbacks.indexOf(callback); + if (index > -1) { + callbacks.splice(index, 1); + } + } + } + + emit(event, data) { + if (this.listeners.has(event)) { + this.listeners.get(event).forEach(callback => { + callback(data); + }); + } + } + + disconnect() { + if (this.ws) { + this.ws.close(); + } + } +} + +const wsClient = new WebSocketClient(); +``` + +#### 5.1.5 环境配置 + +```javascript +// public/scripts/config.js + +const CONFIG = { + // API 基础地址 + API_BASE_URL: window.location.origin.includes('localhost') + ? 'http://localhost:8888' + : window.location.origin + '/api', + + // WebSocket 地址 + WS_URL: window.location.origin.includes('localhost') + ? 'ws://localhost:8888/ws' + : (window.location.protocol === 'https:' ? 'wss:' : 'ws:') + '//' + window.location.host + '/ws', + + // 其他配置... +}; +``` + +### 5.2 web 管理后台扩展 + +#### 5.2.1 添加 AI 管理模块 + +在 `web/src/view/` 下创建新目录: + +``` +web/src/view/ +└── aiManage/ # AI 管理模块 + ├── character/ # 角色管理 + │ ├── index.vue + │ └── components/ + │ ├── CharacterForm.vue + │ └── CharacterCard.vue + ├── chat/ # 对话管理 + │ ├── index.vue + │ └── components/ + │ └── ChatHistory.vue + ├── user/ # 用户管理 + │ └── index.vue + ├── statistics/ # 数据统计 + │ └── index.vue + └── provider/ # AI提供商配置 + └── index.vue +``` + +#### 5.2.2 添加 API 接口 + +```javascript +// web/src/api/ai.js + +import service from '@/utils/request' + +// 角色管理 +export const getCharacterList = (params) => { + return service({ + url: '/character/list', + method: 'get', + params + }) +} + +export const createCharacter = (data) => { + return service({ + url: '/character/create', + method: 'post', + data + }) +} + +export const updateCharacter = (id, data) => { + return service({ + url: `/character/${id}`, + method: 'put', + data + }) +} + +export const deleteCharacter = (id) => { + return service({ + url: `/character/${id}`, + method: 'delete' + }) +} + +// 对话管理 +export const getChatList = (params) => { + return service({ + url: '/chat/list', + method: 'get', + params + }) +} + +export const getChatMessages = (chatId, params) => { + return service({ + url: `/chat/${chatId}/messages`, + method: 'get', + params + }) +} + +// 统计数据 +export const getUsageStats = (params) => { + return service({ + url: '/stats/usage', + method: 'get', + params + }) +} +``` + +#### 5.2.3 添加路由 + +```javascript +// web/src/router/index.js + +const aiManageRouter = { + path: 'aiManage', + name: 'aiManage', + component: () => import('@/view/routerHolder.vue'), + meta: { + title: 'AI管理', + icon: 'ai-gva' + }, + children: [ + { + path: 'character', + name: 'character', + component: () => import('@/view/aiManage/character/index.vue'), + meta: { + title: '角色管理', + icon: 'customer-gva' + } + }, + { + path: 'chat', + name: 'chat', + component: () => import('@/view/aiManage/chat/index.vue'), + meta: { + title: '对话管理', + icon: 'customer-gva' + } + }, + { + path: 'statistics', + name: 'statistics', + component: () => import('@/view/aiManage/statistics/index.vue'), + meta: { + title: '数据统计', + icon: 'customer-gva' + } + } + ] +} +``` + +--- + +## 六、阶段四:数据迁移 + +### 6.1 数据迁移策略 + +**重要说明**: +- 数据迁移目标用户为 `app_users` 表中的前台用户 +- 不涉及 `sys_users` 表 +- 建议先创建一个测试用的前台用户,然后将数据迁移到该用户下 + +#### 6.1.1 从文件系统迁移到 PostgreSQL + +现有 web-app 使用文件系统存储数据,需要编写迁移脚本。 + +```go +// server/utils/migrate/migrate_sillytavern.go +package migrate + +import ( + "encoding/json" + "io/ioutil" + "path/filepath" + "git.echol.cn/loser/st/server/global" + "git.echol.cn/loser/st/server/model/app" +) + +type SillyTavernMigrator struct { + dataPath string // SillyTavern 数据目录路径 +} + +func NewSillyTavernMigrator(dataPath string) *SillyTavernMigrator { + return &SillyTavernMigrator{dataPath: dataPath} +} + +// MigrateCharacters 迁移角色数据 +func (m *SillyTavernMigrator) MigrateCharacters(userID uint) error { + charactersPath := filepath.Join(m.dataPath, "characters") + files, err := ioutil.ReadDir(charactersPath) + if err != nil { + return err + } + + for _, file := range files { + if filepath.Ext(file.Name()) == ".json" { + // 读取角色文件 + data, err := ioutil.ReadFile(filepath.Join(charactersPath, file.Name())) + if err != nil { + global.GVA_LOG.Error("读取角色文件失败: " + err.Error()) + continue + } + + // 解析角色数据 + var cardData map[string]interface{} + if err := json.Unmarshal(data, &cardData); err != nil { + global.GVA_LOG.Error("解析角色数据失败: " + err.Error()) + continue + } + + // 创建角色记录 + character := app.Character{ + Name: cardData["name"].(string), + Description: cardData["description"].(string), + Personality: cardData["personality"].(string), + Scenario: cardData["scenario"].(string), + FirstMessage: cardData["first_mes"].(string), + CreatorID: &userID, + CardData: cardData, + Visibility: "private", + } + + if err := global.GVA_DB.Create(&character).Error; err != nil { + global.GVA_LOG.Error("创建角色失败: " + err.Error()) + continue + } + + global.GVA_LOG.Info("迁移角色成功: " + character.Name) + } + } + + return nil +} + +// MigrateChats 迁移对话数据 +func (m *SillyTavernMigrator) MigrateChats(userID uint) error { + chatsPath := filepath.Join(m.dataPath, "chats") + files, err := ioutil.ReadDir(chatsPath) + if err != nil { + return err + } + + for _, file := range files { + if filepath.Ext(file.Name()) == ".jsonl" { + // 读取对话文件 + data, err := ioutil.ReadFile(filepath.Join(chatsPath, file.Name())) + if err != nil { + global.GVA_LOG.Error("读取对话文件失败: " + err.Error()) + continue + } + + // 解析对话数据(JSONL 格式,每行一个消息) + lines := strings.Split(string(data), "\n") + + // 创建对话记录 + chat := app.Chat{ + Title: "迁移的对话", + UserID: userID, + ChatType: "single", + } + + if err := global.GVA_DB.Create(&chat).Error; err != nil { + global.GVA_LOG.Error("创建对话失败: " + err.Error()) + continue + } + + // 迁移消息 + for i, line := range lines { + if line == "" { + continue + } + + var msgData map[string]interface{} + if err := json.Unmarshal([]byte(line), &msgData); err != nil { + continue + } + + message := app.Message{ + ChatID: chat.ID, + Content: msgData["mes"].(string), + Role: msgData["is_user"].(bool) ? "user" : "assistant", + SequenceNumber: i + 1, + } + + if err := global.GVA_DB.Create(&message).Error; err != nil { + global.GVA_LOG.Error("创建消息失败: " + err.Error()) + } + } + + global.GVA_LOG.Info("迁移对话成功: " + file.Name()) + } + } + + return nil +} +``` + +#### 6.1.2 迁移命令 + +```go +// server/cmd/migrate/main.go +package main + +import ( + "flag" + "git.echol.cn/loser/st/server/core" + "git.echol.cn/loser/st/server/global" + "git.echol.cn/loser/st/server/initialize" + "git.echol.cn/loser/st/server/utils/migrate" +) + +func main() { + dataPath := flag.String("data", "", "SillyTavern 数据目录路径") + userID := flag.Uint("user", 1, "目标用户ID") + flag.Parse() + + if *dataPath == "" { + panic("请指定数据目录路径") + } + + // 初始化系统 + global.GVA_VP = core.Viper() + global.GVA_LOG = core.Zap() + global.GVA_DB = initialize.Gorm() + + // 执行迁移 + migrator := migrate.NewSillyTavernMigrator(*dataPath) + + if err := migrator.MigrateCharacters(*userID); err != nil { + global.GVA_LOG.Error("迁移角色失败: " + err.Error()) + } + + if err := migrator.MigrateChats(*userID); err != nil { + global.GVA_LOG.Error("迁移对话失败: " + err.Error()) + } + + global.GVA_LOG.Info("数据迁移完成") +} +``` + +使用方式: + +```bash +cd server +go run cmd/migrate/main.go --data=/path/to/sillytavern/data --user=1 +``` + +### 6.2 文件迁移到对象存储 + +```go +// server/utils/migrate/migrate_files.go +package migrate + +import ( + "io/ioutil" + "path/filepath" + "git.echol.cn/loser/st/server/global" + "git.echol.cn/loser/st/server/model/app" + "git.echol.cn/loser/st/server/utils/upload" +) + +type FileMigrator struct { + sourcePath string + uploader upload.OSS +} + +func NewFileMigrator(sourcePath string) *FileMigrator { + return &FileMigrator{ + sourcePath: sourcePath, + uploader: upload.NewOss(), // 根据配置创建 OSS 上传器 + } +} + +// MigrateAvatars 迁移头像文件 +func (m *FileMigrator) MigrateAvatars(userID uint) error { + avatarsPath := filepath.Join(m.sourcePath, "User Avatars") + files, err := ioutil.ReadDir(avatarsPath) + if err != nil { + return err + } + + for _, file := range files { + if file.IsDir() { + continue + } + + // 读取文件 + filePath := filepath.Join(avatarsPath, file.Name()) + data, err := ioutil.ReadFile(filePath) + if err != nil { + global.GVA_LOG.Error("读取文件失败: " + err.Error()) + continue + } + + // 上传到对象存储 + uploadPath, uploadURL, err := m.uploader.UploadFile(file.Name(), data) + if err != nil { + global.GVA_LOG.Error("上传文件失败: " + err.Error()) + continue + } + + // 保存文件记录 + fileRecord := app.File{ + UserID: userID, + Filename: file.Name(), + OriginalFilename: file.Name(), + FileType: "avatar", + FileSize: file.Size(), + StoragePath: uploadPath, + URL: uploadURL, + } + + if err := global.GVA_DB.Create(&fileRecord).Error; err != nil { + global.GVA_LOG.Error("保存文件记录失败: " + err.Error()) + } + + global.GVA_LOG.Info("迁移文件成功: " + file.Name()) + } + + return nil +} +``` + +--- + +## 七、阶段五:测试与优化 + +### 7.1 功能测试清单 + +#### 7.1.1 用户认证测试 +- [ ] 用户注册 +- [ ] 用户登录 +- [ ] JWT Token 验证 +- [ ] 登出功能 +- [ ] 密码修改 + +#### 7.1.2 角色管理测试 +- [ ] 创建角色 +- [ ] 编辑角色 +- [ ] 删除角色 +- [ ] 角色列表查询 +- [ ] 角色详情查看 +- [ ] 导入 Character Card +- [ ] 导出 Character Card +- [ ] 角色收藏 + +#### 7.1.3 对话功能测试 +- [ ] 创建对话 +- [ ] 发送消息 +- [ ] 接收 AI 回复 +- [ ] 消息流式输出 +- [ ] 重新生成消息 +- [ ] 编辑消息 +- [ ] 删除消息 +- [ ] Swipe 功能(消息变体) +- [ ] 对话历史加载 +- [ ] 群聊功能 + +#### 7.1.4 AI 服务测试 +- [ ] OpenAI 集成 +- [ ] Claude 集成 +- [ ] Gemini 集成 +- [ ] 模型切换 +- [ ] 参数配置 +- [ ] 错误处理 + +#### 7.1.5 向量记忆测试 +- [ ] 创建记忆 +- [ ] 向量搜索 +- [ ] 记忆召回 +- [ ] 记忆管理 + +#### 7.1.6 WebSocket 测试 +- [ ] 连接建立 +- [ ] 消息推送 +- [ ] 断线重连 +- [ ] 多客户端支持 + +#### 7.1.7 文件管理测试 +- [ ] 文件上传 +- [ ] 文件下载 +- [ ] 文件删除 +- [ ] 对象存储集成 + +### 7.2 性能测试 + +#### 7.2.1 压力测试 + +使用工具:Apache Bench (ab)、wrk、或 K6 + +```bash +# 测试登录接口 +ab -n 1000 -c 100 -p login.json -T application/json http://localhost:8888/base/login + +# 测试对话接口 +ab -n 500 -c 50 -H "x-token: YOUR_TOKEN" http://localhost:8888/chat/list +``` + +#### 7.2.2 数据库优化 + +```sql +-- 创建必要的索引 +CREATE INDEX CONCURRENTLY idx_messages_chat_id_sequence +ON ai_messages(chat_id, sequence_number); + +CREATE INDEX CONCURRENTLY idx_characters_user_visibility +ON ai_characters(creator_id, visibility) WHERE deleted_at IS NULL; + +-- 分析表统计信息 +ANALYZE ai_messages; +ANALYZE ai_characters; +ANALYZE ai_chats; + +-- 查看慢查询 +SELECT query, mean_exec_time, calls +FROM pg_stat_statements +ORDER BY mean_exec_time DESC +LIMIT 10; +``` + +#### 7.2.3 缓存优化 + +使用 Redis 缓存热点数据: + +```go +// server/service/app/character.go + +func (s *CharacterService) GetCharacter(id uint) (*app.Character, error) { + cacheKey := fmt.Sprintf("character:%d", id) + + // 尝试从缓存获取 + if global.GVA_REDIS != nil { + cached, err := global.GVA_REDIS.Get(context.Background(), cacheKey).Result() + if err == nil { + var character app.Character + if err := json.Unmarshal([]byte(cached), &character); err == nil { + return &character, nil + } + } + } + + // 从数据库查询 + var character app.Character + err := global.GVA_DB.First(&character, id).Error + if err != nil { + return nil, err + } + + // 写入缓存 + if global.GVA_REDIS != nil { + data, _ := json.Marshal(character) + global.GVA_REDIS.Set(context.Background(), cacheKey, data, 30*time.Minute) + } + + return &character, nil +} +``` + +### 7.3 安全加固 + +#### 7.3.1 API 限流 + +```go +// server/middleware/limiter.go +package middleware + +import ( + "github.com/gin-gonic/gin" + "github.com/redis/go-redis/v9" + "golang.org/x/time/rate" +) + +// RateLimiter 基于令牌桶的限流中间件 +func RateLimiter(r rate.Limit, b int) gin.HandlerFunc { + limiter := rate.NewLimiter(r, b) + + return func(c *gin.Context) { + if !limiter.Allow() { + c.JSON(429, gin.H{ + "code": 429, + "msg": "请求过于频繁,请稍后再试", + }) + c.Abort() + return + } + c.Next() + } +} + +// UserRateLimiter 基于用户的限流 +func UserRateLimiter(rdb *redis.Client) gin.HandlerFunc { + return func(c *gin.Context) { + userID := c.GetUint("userID") + key := fmt.Sprintf("rate_limit:user:%d", userID) + + // 使用 Redis 实现滑动窗口限流 + // ... + + c.Next() + } +} +``` + +#### 7.3.2 敏感数据加密 + +```go +// server/utils/crypto/encrypt.go +package crypto + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "io" +) + +// EncryptAPIKey 加密 API Key +func EncryptAPIKey(plaintext, key string) (string, error) { + block, err := aes.NewCipher([]byte(key)) + if err != nil { + return "", err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + + ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil) + return base64.StdEncoding.EncodeToString(ciphertext), nil +} + +// DecryptAPIKey 解密 API Key +func DecryptAPIKey(ciphertext, key string) (string, error) { + data, err := base64.StdEncoding.DecodeString(ciphertext) + if err != nil { + return "", err + } + + block, err := aes.NewCipher([]byte(key)) + if err != nil { + return "", err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + + nonceSize := gcm.NonceSize() + nonce, ciphertextBytes := data[:nonceSize], data[nonceSize:] + + plaintext, err := gcm.Open(nil, nonce, ciphertextBytes, nil) + if err != nil { + return "", err + } + + return string(plaintext), nil +} +``` + +--- + +## 八、阶段六:部署上线 + +### 8.1 Docker 部署 + +#### 8.1.1 Dockerfile + +##### Go 后端 Dockerfile + +```dockerfile +# server/Dockerfile +FROM golang:1.24-alpine AS builder + +WORKDIR /app + +# 安装依赖 +RUN apk add --no-cache git gcc musl-dev + +# 复制 go.mod 和 go.sum +COPY go.mod go.sum ./ +RUN go mod download + +# 复制源代码 +COPY . . + +# 编译 +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o server . + +# 运行阶段 +FROM alpine:latest + +RUN apk --no-cache add ca-certificates tzdata + +WORKDIR /app + +# 从构建阶段复制编译好的二进制文件 +COPY --from=builder /app/server . +COPY --from=builder /app/config.yaml . +COPY --from=builder /app/resource ./resource + +# 设置时区 +ENV TZ=Asia/Shanghai + +EXPOSE 8888 + +CMD ["./server"] +``` + +##### web-app Dockerfile + +```dockerfile +# web-app/Dockerfile +FROM nginx:alpine + +# 复制静态文件 +COPY public /usr/share/nginx/html + +# 复制 nginx 配置 +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] +``` + +##### web 管理后台 Dockerfile + +```dockerfile +# web/Dockerfile +FROM node:18-alpine AS builder + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci + +COPY . . +RUN npm run build + +# 运行阶段 +FROM nginx:alpine + +COPY --from=builder /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] +``` + +#### 8.1.2 docker-compose.yml + +```yaml +# docker-compose.yml +version: '3.8' + +services: + postgres: + image: pgvector/pgvector:pg16 + container_name: st-postgres + environment: + POSTGRES_DB: st_db + POSTGRES_USER: postgres + POSTGRES_PASSWORD: your_password + TZ: Asia/Shanghai + volumes: + - postgres_data:/var/lib/postgresql/data + ports: + - "5432:5432" + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + container_name: st-redis + command: redis-server --appendonly yes + volumes: + - redis_data:/data + ports: + - "6379:6379" + restart: unless-stopped + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 3 + + server: + build: + context: ./server + dockerfile: Dockerfile + container_name: st-server + environment: + - TZ=Asia/Shanghai + volumes: + - ./server/config.yaml:/app/config.yaml + - ./server/log:/app/log + - ./server/uploads:/app/uploads + ports: + - "8888:8888" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + restart: unless-stopped + + web: + build: + context: ./web + dockerfile: Dockerfile + container_name: st-web-admin + ports: + - "8080:80" + restart: unless-stopped + + web-app: + build: + context: ./web-app + dockerfile: Dockerfile + container_name: st-web-app + ports: + - "8000:80" + restart: unless-stopped + + nginx: + image: nginx:alpine + container_name: st-nginx + volumes: + - ./deploy/nginx/nginx.conf:/etc/nginx/nginx.conf + - ./deploy/nginx/ssl:/etc/nginx/ssl + ports: + - "80:80" + - "443:443" + depends_on: + - server + - web + - web-app + restart: unless-stopped + +volumes: + postgres_data: + redis_data: +``` + +#### 8.1.3 Nginx 配置 + +```nginx +# deploy/nginx/nginx.conf +upstream api_server { + server server:8888; +} + +upstream admin_web { + server web:80; +} + +upstream user_app { + server web-app:80; +} + +server { + listen 80; + server_name yourdomain.com; + + # 用户前端应用 + location / { + proxy_pass http://user_app; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # 管理后台 + location /admin { + proxy_pass http://admin_web; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # API 接口 + location /api { + rewrite ^/api/(.*)$ /$1 break; + proxy_pass http://api_server; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # 超时设置(AI 请求可能较慢) + proxy_connect_timeout 300s; + proxy_send_timeout 300s; + proxy_read_timeout 300s; + } + + # WebSocket + location /ws { + proxy_pass http://api_server; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # WebSocket 超时 + proxy_read_timeout 86400s; + proxy_send_timeout 86400s; + } + + # 文件上传大小限制 + client_max_body_size 100M; +} +``` + +### 8.2 启动与验证 + +#### 8.2.1 启动所有服务 + +```bash +# 构建并启动 +docker-compose up -d --build + +# 查看日志 +docker-compose logs -f + +# 查看服务状态 +docker-compose ps +``` + +#### 8.2.2 初始化数据库 + +```bash +# 进入 server 容器 +docker exec -it st-server sh + +# 运行数据库初始化(如果有初始化脚本) +./server --init-db +``` + +#### 8.2.3 验证服务 + +```bash +# 检查 API +curl http://localhost/api/base/captcha + +# 检查管理后台 +curl http://localhost/admin + +# 检查用户前端 +curl http://localhost/ +``` + +### 8.3 生产环境配置 + +#### 8.3.1 环境变量配置 + +创建 `.env` 文件: + +```bash +# .env +# 数据库配置 +POSTGRES_HOST=postgres +POSTGRES_PORT=5432 +POSTGRES_DB=st_db +POSTGRES_USER=postgres +POSTGRES_PASSWORD=your_secure_password + +# Redis 配置 +REDIS_HOST=redis +REDIS_PORT=6379 +REDIS_PASSWORD= + +# JWT 配置 +JWT_SECRET=your_jwt_secret_key_at_least_32_chars + +# AI API Keys(加密存储) +OPENAI_API_KEY= +ANTHROPIC_API_KEY= +GOOGLE_API_KEY= + +# 对象存储配置 +OSS_TYPE=aliyun # aliyun, tencent, qiniu, minio, local +OSS_ENDPOINT= +OSS_ACCESS_KEY= +OSS_SECRET_KEY= +OSS_BUCKET= + +# 日志级别 +LOG_LEVEL=info + +# 环境 +ENVIRONMENT=production +``` + +#### 8.3.2 SSL 证书配置 + +```nginx +# HTTPS 配置 +server { + listen 443 ssl http2; + server_name yourdomain.com; + + ssl_certificate /etc/nginx/ssl/cert.pem; + ssl_certificate_key /etc/nginx/ssl/key.pem; + + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_prefer_server_ciphers on; + + # ... 其他配置同上 +} + +# HTTP 重定向到 HTTPS +server { + listen 80; + server_name yourdomain.com; + return 301 https://$server_name$request_uri; +} +``` + +--- + +## 九、后续优化建议 + +### 9.1 性能优化 + +1. **数据库连接池优化** + - 调整 `max-open-conns` 和 `max-idle-conns` + - 监控连接池使用情况 + +2. **Redis 缓存策略** + - 角色数据缓存 + - 对话历史缓存 + - 用户配置缓存 + +3. **CDN 加速** + - 静态资源使用 CDN + - 图片压缩与优化 + +4. **数据库分表分库** + - 消息表按月分表 + - 使用分区表优化查询 + +### 9.2 功能扩展 + +1. **移动端支持** + - 响应式设计优化 + - PWA 支持 + - 移动端 APP + +2. **高级功能** + - 语音输入输出 + - 图片生成集成 + - 多模态支持 + +3. **社交功能** + - 角色分享 + - 社区讨论 + - 排行榜 + +### 9.3 监控与运维 + +1. **应用监控** + - Prometheus + Grafana + - 日志聚合(ELK) + - 告警系统 + +2. **备份策略** + - 数据库定时备份 + - 对象存储备份 + - 灾难恢复计划 + +--- + +## 十、时间规划与里程碑 + +### 10.1 预估时间表 + +| 阶段 | 任务 | 预估时间 | 负责人 | +|------|------|----------|--------| +| 阶段一 | 数据库设计 | 3-5天 | 后端开发 | +| 阶段二 | Go后端API开发 | 15-20天 | 后端开发 | +| 阶段三 | 前端改造 | 10-15天 | 前端开发 | +| 阶段四 | 数据迁移 | 3-5天 | 后端开发 | +| 阶段五 | 测试与优化 | 7-10天 | 全体 | +| 阶段六 | 部署上线 | 2-3天 | 运维 | +| **总计** | | **40-58天** | | + +### 10.2 关键里程碑 + +- **M1**: 数据库设计完成并评审通过 +- **M2**: 核心 API(角色、对话)开发完成 +- **M3**: web-app 前端改造完成 +- **M4**: 数据迁移脚本测试通过 +- **M5**: 全功能测试通过 +- **M6**: 生产环境部署成功 + +--- + +## 十一、风险与应对 + +### 11.1 技术风险 + +| 风险 | 影响 | 概率 | 应对措施 | +|------|------|------|----------| +| AI SDK 兼容性问题 | 高 | 中 | 提前调研,准备备选方案 | +| 向量数据库性能 | 中 | 低 | 性能测试,优化索引 | +| WebSocket 稳定性 | 高 | 低 | 完善重连机制,监控 | +| 数据迁移数据丢失 | 高 | 低 | 充分备份,分批迁移 | + +### 11.2 进度风险 + +| 风险 | 应对措施 | +|------|----------| +| 开发人员不足 | 优先开发核心功能,延后次要功能 | +| 技术难点耗时 | 预留 buffer 时间,及时调整计划 | +| 测试不充分 | 增加自动化测试,提前介入测试 | + +--- + +## 十二、重要提醒:不修改清单 + +### 12.1 后端 - 保持不变的部分 + +#### 目录结构(不修改) +``` +server/ +├── api/v1/system/ ❌ 不修改 +├── model/system/ ❌ 不修改 +├── service/system/ ❌ 不修改 +├── router/system/ ❌ 不修改 +├── initialize/ ⚠️ 仅添加新的初始化逻辑,不修改现有代码 +├── middleware/ ⚠️ 仅添加 AppJWTAuth,不修改现有中间件 +└── config/ ⚠️ 可能需要添加配置,但不修改现有配置 +``` + +#### 数据表(不修改) +- `sys_users` - 管理员用户表 +- `sys_authorities` - 角色表 +- `sys_base_menus` - 菜单表 +- `sys_apis` - API表 +- `casbin_rule` - 权限规则表 +- `sys_operation_records` - 操作日志表 +- 其他所有 `sys_*` 开头的表 + +#### API路由(不修改) +- `/base/*` - 基础接口 +- `/user/*` - 用户管理 +- `/authority/*` - 角色管理 +- `/menu/*` - 菜单管理 +- `/api/*` - API管理 +- 其他所有管理后台相关路由 + +### 12.2 新增的部分 + +#### 目录结构(新增) +``` +server/ +├── api/v1/app/ ✅ 新增 - 前台应用接口 +├── model/app/ ✅ 新增 - 前台应用模型 +├── service/app/ ✅ 新增 - 前台应用服务 +├── router/app/ ✅ 新增 - 前台应用路由 +├── middleware/ +│ └── app_jwt.go ✅ 新增 - 前台JWT中间件 +└── pkg/ ✅ 新增 - 公共包(AI SDK等) +``` + +#### 数据表(新增) +- `app_users` - 前台用户表 +- `app_user_sessions` - 前台用户会话表 +- `ai_characters` - AI角色表 +- `ai_chats` - 对话表 +- `ai_messages` - 消息表 +- `ai_memory_vectors` - 向量记忆表 +- 其他所有 `app_*` 和 `ai_*` 开头的表 + +#### API路由(新增) +- `/app/auth/*` - 前台用户认证 +- `/app/character/*` - 角色管理 +- `/app/chat/*` - 对话管理 +- `/app/provider/*` - AI提供商配置 +- 其他所有 `/app/*` 开头的路由 + +### 12.3 开发检查清单 + +在开发过程中,请确保: + +- [ ] 没有修改 `server/api/v1/system/` 下的任何文件 +- [ ] 没有修改 `server/model/system/` 下的任何文件 +- [ ] 没有修改 `server/service/system/` 下的任何文件 +- [ ] 没有修改 `server/router/system/` 下的任何文件 +- [ ] 没有修改 `sys_users` 表结构 +- [ ] 所有新功能都在 `app/` 目录下实现 +- [ ] 所有新数据表都使用 `app_` 或 `ai_` 前缀 +- [ ] 所有新路由都使用 `/app/` 前缀 +- [ ] 前台用户使用独立的 JWT 认证 + +--- + +## 十三、总结 + +本重构方案将云酒馆项目从 Node.js 一体化架构重构为 Go + PostgreSQL 的前后端分离架构,主要收益包括: + +1. **性能提升**: Go 的高并发能力,PostgreSQL 的稳定性 +2. **架构清晰**: 前后端分离,职责明确,双用户体系隔离 +3. **易于维护**: 统一的后端服务,便于扩展 +4. **功能增强**: 向量数据库支持长期记忆,WebSocket 实时通信 +5. **部署灵活**: Docker 容器化,易于部署和扩展 +6. **风险可控**: 不修改现有 system 模块,新功能独立开发 + +**关键设计特点**: +- ✅ 双用户体系:`sys_users`(管理员)和 `app_users`(前台用户)完全独立 +- ✅ 模块隔离:`system/` 和 `app/` 模块并行开发,互不影响 +- ✅ 路由分离:管理后台和前台应用使用不同的路由前缀 +- ✅ 数据隔离:使用不同的数据表前缀,避免冲突 + +通过分阶段实施,可以降低风险,确保项目平稳过渡。 + +--- + +**文档版本**: v1.0.0 +**创建日期**: 2026-02-10 +**维护者**: 开发团队 +**下次更新**: 根据实施进度更新 diff --git a/docs/重构进度管理.md b/docs/重构进度管理.md new file mode 100644 index 0000000..2308eef --- /dev/null +++ b/docs/重构进度管理.md @@ -0,0 +1,1986 @@ +# 云酒馆项目重构进度管理 + +> **项目目标**:将 web-app 从 Node.js 后端改造为纯前端,所有接口由 Go 后端提供 +> **开始日期**:2026-02-10 +> **预计完成**:40-58 天 +> **当前状态**:阶段三进行中 - 角色卡管理模块已完成(含导入导出功能) + +--- + +## 📊 总体进度 + +| 阶段 | 进度 | 状态 | 开始日期 | 完成日期 | +|------|------|------|----------|----------| +| 阶段一:数据库设计 | 100% | 🟢 已完成 | 2026-02-10 | 2026-02-10 | +| 阶段二:Go后端API开发 | 35% | 🔵 进行中 | 2026-02-10 | - | +| 阶段三:Vue3前台开发 | 40% | 🔵 进行中 | 2026-02-10 | - | +| 阶段四:前端改造(旧版) | 0% | ⚪ 暂停 | - | - | +| 阶段五:数据迁移 | 0% | ⚪ 未开始 | - | - | +| 阶段六:测试与优化 | 0% | ⚪ 未开始 | - | - | +| 阶段七:部署上线 | 0% | ⚪ 未开始 | - | - | + +**图例**: +- ⚪ 未开始 +- 🟡 待开始(已规划) +- 🔵 进行中 +- 🟢 已完成 +- 🔴 受阻 + +--- + +## 🎯 阶段一:数据库设计(预计 3-5 天) + +### 1.1 环境准备 + +- [ ] **T1.1.1** 安装 PostgreSQL 14+ + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + - 备注: + +- [ ] **T1.1.2** 安装 pgvector 扩展 + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 依赖:T1.1.1 + - 状态:⚪ 未开始 + - 备注: + +- [ ] **T1.1.3** 配置 PostgreSQL 连接 + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 依赖:T1.1.1 + - 状态:⚪ 未开始 + - 备注:修改 `server/config.yaml` + +- [ ] **T1.1.4** 安装 Redis 7+(可选) + - 负责人: + - 优先级:P2(推荐) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + - 备注: + +### 1.2 数据表设计与创建 + +- [ ] **T1.2.1** 创建前台用户表(app_users) + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 依赖:T1.1.2 + - 状态:⚪ 未开始 + - SQL 文件:`server/initialize/sql/app_users.sql` + - 验证方式:查询表结构 + +- [ ] **T1.2.2** 创建用户会话表(app_user_sessions) + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 依赖:T1.2.1 + - 状态:⚪ 未开始 + +- [ ] **T1.2.3** 创建 AI 角色表(ai_characters) + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 依赖:T1.2.1 + - 状态:⚪ 未开始 + +- [ ] **T1.2.4** 创建对话表(ai_chats) + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 依赖:T1.2.3 + - 状态:⚪ 未开始 + +- [ ] **T1.2.5** 创建消息表(ai_messages) + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 依赖:T1.2.4 + - 状态:⚪ 未开始 + +- [ ] **T1.2.6** 创建消息变体表(ai_message_swipes) + - 负责人: + - 优先级:P1(重要) + - 预计时间:0.5天 + - 依赖:T1.2.5 + - 状态:⚪ 未开始 + +- [ ] **T1.2.7** 创建群聊成员表(ai_chat_members) + - 负责人: + - 优先级:P2(推荐) + - 预计时间:0.5天 + - 依赖:T1.2.3, T1.2.4 + - 状态:⚪ 未开始 + +- [ ] **T1.2.8** 创建向量记忆表(ai_memory_vectors) + - 负责人: + - 优先级:P1(重要) + - 预计时间:0.5天 + - 依赖:T1.1.2, T1.2.1 + - 状态:⚪ 未开始 + - 备注:需要 pgvector 扩展 + +- [ ] **T1.2.9** 创建 AI 提供商配置表(ai_providers) + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 依赖:T1.2.1 + - 状态:⚪ 未开始 + +- [ ] **T1.2.10** 创建 AI 模型配置表(ai_models) + - 负责人: + - 优先级:P1(重要) + - 预计时间:0.5天 + - 依赖:T1.2.9 + - 状态:⚪ 未开始 + +- [ ] **T1.2.11** 创建文件表(ai_files) + - 负责人: + - 优先级:P1(重要) + - 预计时间:0.5天 + - 依赖:T1.2.1 + - 状态:⚪ 未开始 + +- [ ] **T1.2.12** 创建预设表(ai_presets) + - 负责人: + - 优先级:P2(推荐) + - 预计时间:0.5天 + - 依赖:T1.2.1 + - 状态:⚪ 未开始 + +- [ ] **T1.2.13** 创建世界书表(ai_world_info) + - 负责人: + - 优先级:P2(推荐) + - 预计时间:0.5天 + - 依赖:T1.2.1 + - 状态:⚪ 未开始 + +- [ ] **T1.2.14** 创建使用统计表(ai_usage_stats) + - 负责人: + - 优先级:P3(可选) + - 预计时间:0.5天 + - 依赖:T1.2.1 + - 状态:⚪ 未开始 + +- [ ] **T1.2.15** 创建用户收藏表(app_user_favorite_characters) + - 负责人: + - 优先级:P2(推荐) + - 预计时间:0.5天 + - 依赖:T1.2.1, T1.2.3 + - 状态:⚪ 未开始 + +### 1.3 Gorm 模型创建 + +- [ ] **T1.3.1** 创建 AppUser 模型 + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 依赖:T1.2.1 + - 状态:⚪ 未开始 + - 文件:`server/model/app/user.go` + +- [ ] **T1.3.2** 创建 AppUserSession 模型 + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 依赖:T1.2.2 + - 状态:⚪ 未开始 + +- [ ] **T1.3.3** 创建 Character 模型 + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 依赖:T1.2.3 + - 状态:⚪ 未开始 + - 文件:`server/model/app/character.go` + +- [ ] **T1.3.4** 创建 Chat 模型 + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 依赖:T1.2.4 + - 状态:⚪ 未开始 + - 文件:`server/model/app/chat.go` + +- [ ] **T1.3.5** 创建 Message 模型 + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 依赖:T1.2.5 + - 状态:⚪ 未开始 + - 文件:`server/model/app/message.go` + +- [ ] **T1.3.6** 创建其他辅助模型(Provider、File、Preset 等) + - 负责人: + - 优先级:P1(重要) + - 预计时间:1天 + - 状态:⚪ 未开始 + +### 1.4 数据库初始化 + +- [ ] **T1.4.1** 编写数据库自动迁移脚本 + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 依赖:T1.3.* + - 状态:⚪ 未开始 + - 文件:`server/initialize/gorm_app.go` + +- [ ] **T1.4.2** 测试数据库表创建 + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 依赖:T1.4.1 + - 状态:⚪ 未开始 + - 验证:所有表成功创建,外键关系正确 + +--- + +## 🎯 阶段二:Go 后端 API 开发(预计 15-20 天) + +### 2.1 基础设施搭建 + +- [ ] **T2.1.1** 安装 Go 依赖包 + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + - 命令: + ```bash + go get github.com/sashabaranov/go-openai@latest + go get github.com/gorilla/websocket@latest + go get github.com/pgvector/pgvector-go@latest + go get github.com/pkoukk/tiktoken-go@latest + ``` + +- [ ] **T2.1.2** 创建项目目录结构 + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + - 目录:`api/v1/app/`, `service/app/`, `router/app/`, `pkg/` + +- [ ] **T2.1.3** 配置文件扩展 + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + - 文件:`server/config.yaml` + - 内容:AI API Keys、向量数据库配置等 + +### 2.2 前台用户认证模块 + +- [x] **T2.2.1** 实现前台用户 JWT 中间件(AppJWTAuth) + - 负责人:AI助手 + - 优先级:P0(必须) + - 预计时间:1天 + - 状态:🟢 已完成 + - 完成日期:2026-02-10 + - 文件: + - `server/middleware/app_jwt.go`(中间件) + - `server/utils/app_jwt.go`(JWT工具函数) + +- [x] **T2.2.2** 实现用户注册 API + - 负责人:AI助手 + - 优先级:P0(必须) + - 预计时间:1天 + - 依赖:T2.2.1, T1.3.1 + - 状态:🟢 已完成 + - 完成日期:2026-02-10 + - 接口:`POST /app/auth/register` + - 文件: + - `server/api/v1/app/auth.go` + - `server/service/app/auth.go` + - `server/model/app/request/auth.go` + +- [x] **T2.2.3** 实现用户登录 API + - 负责人:AI助手 + - 优先级:P0(必须) + - 预计时间:1天 + - 依赖:T2.2.1 + - 状态:🟢 已完成 + - 完成日期:2026-02-10 + - 接口:`POST /app/auth/login` + +- [x] **T2.2.4** 实现用户登出 API + - 负责人:AI助手 + - 优先级:P0(必须) + - 预计时间:0.5天 + - 依赖:T2.2.1 + - 状态:🟢 已完成 + - 完成日期:2026-02-10 + - 接口:`POST /app/auth/logout` + +- [x] **T2.2.5** 实现 Token 刷新 API + - 负责人:AI助手 + - 优先级:P1(重要) + - 预计时间:1天 + - 依赖:T2.2.1 + - 状态:🟢 已完成 + - 完成日期:2026-02-10 + - 接口:`POST /app/auth/refresh` + +- [x] **T2.2.6** 实现获取用户信息 API + - 负责人:AI助手 + - 优先级:P0(必须) + - 预计时间:0.5天 + - 依赖:T2.2.1 + - 状态:🟢 已完成 + - 完成日期:2026-02-10 + - 接口:`GET /app/auth/userinfo` + +- [x] **T2.2.7** 实现用户信息更新 API + - 负责人:AI助手 + - 优先级:P1(重要) + - 预计时间:0.5天 + - 状态:🟢 已完成 + - 完成日期:2026-02-10 + - 接口:`PUT /app/user/profile` + +- [x] **T2.2.8** 实现密码修改 API + - 负责人:AI助手 + - 优先级:P1(重要) + - 预计时间:0.5天 + - 状态:🟢 已完成 + - 完成日期:2026-02-10 + - 接口:`POST /app/user/change-password` + +### 2.3 AI 服务集成 + +- [ ] **T2.3.1** 封装 OpenAI SDK + - 负责人: + - 优先级:P0(必须) + - 预计时间:2天 + - 状态:⚪ 未开始 + - 文件:`server/pkg/ai/openai.go` + - 功能:对话补全、流式输出、向量嵌入 + +- [ ] **T2.3.2** 封装 Anthropic SDK(Claude) + - 负责人: + - 优先级:P1(重要) + - 预计时间:2天 + - 状态:⚪ 未开始 + - 文件:`server/pkg/ai/anthropic.go` + +- [ ] **T2.3.3** 封装 Google AI SDK(Gemini) + - 负责人: + - 优先级:P2(推荐) + - 预计时间:2天 + - 状态:⚪ 未开始 + - 文件:`server/pkg/ai/google.go` + +- [ ] **T2.3.4** 实现 AI 服务统一接口 + - 负责人: + - 优先级:P0(必须) + - 预计时间:1天 + - 依赖:T2.3.1 + - 状态:⚪ 未开始 + - 文件:`server/service/app/ai_service.go` + +- [ ] **T2.3.5** 实现向量嵌入服务 + - 负责人: + - 优先级:P1(重要) + - 预计时间:1天 + - 依赖:T2.3.1 + - 状态:⚪ 未开始 + - 文件:`server/service/app/embedding.go` + +### 2.4 AI 提供商配置管理 + +- [ ] **T2.4.1** 实现创建提供商配置 API + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + - 接口:`POST /app/provider/create` + +- [ ] **T2.4.2** 实现获取提供商列表 API + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + - 接口:`GET /app/provider/list` + +- [ ] **T2.4.3** 实现更新提供商配置 API + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + - 接口:`PUT /app/provider/:id` + +- [ ] **T2.4.4** 实现删除提供商配置 API + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + - 接口:`DELETE /app/provider/:id` + +- [ ] **T2.4.5** 实现测试提供商连接 API + - 负责人: + - 优先级:P1(重要) + - 预计时间:1天 + - 依赖:T2.3.1 + - 状态:⚪ 未开始 + - 接口:`POST /app/provider/test` + +- [ ] **T2.4.6** 实现获取可用模型列表 API + - 负责人: + - 优先级:P1(重要) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + - 接口:`GET /app/provider/:id/models` + +### 2.5 AI 角色管理 + +- [ ] **T2.5.1** 实现创建角色 API + - 负责人: + - 优先级:P0(必须) + - 预计时间:1天 + - 状态:⚪ 未开始 + - 接口:`POST /app/character/create` + - 文件: + - `server/api/v1/app/character.go` + - `server/service/app/character.go` + +- [ ] **T2.5.2** 实现获取角色列表 API + - 负责人: + - 优先级:P0(必须) + - 预计时间:1天 + - 状态:⚪ 未开始 + - 接口:`GET /app/character/list` + - 功能:支持分页、搜索、筛选 + +- [ ] **T2.5.3** 实现获取角色详情 API + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + - 接口:`GET /app/character/:id` + +- [ ] **T2.5.4** 实现更新角色 API + - 负责人: + - 优先级:P0(必须) + - 预计时间:1天 + - 状态:⚪ 未开始 + - 接口:`PUT /app/character/:id` + +- [ ] **T2.5.5** 实现删除角色 API + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + - 接口:`DELETE /app/character/:id` + +- [ ] **T2.5.6** 实现导入角色 API(Character Card) + - 负责人: + - 优先级:P0(必须) + - 预计时间:2天 + - 状态:⚪ 未开始 + - 接口:`POST /app/character/import` + - 功能:解析 PNG、JSON 格式的角色卡片 + +- [ ] **T2.5.7** 实现导出角色 API + - 负责人: + - 优先级:P1(重要) + - 预计时间:1天 + - 状态:⚪ 未开始 + - 接口:`GET /app/character/export/:id` + +- [ ] **T2.5.8** 实现角色收藏/取消收藏 API + - 负责人: + - 优先级:P2(推荐) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + - 接口:`POST /app/character/:id/favorite` + +### 2.6 对话管理 + +- [ ] **T2.6.1** 实现创建对话 API + - 负责人: + - 优先级:P0(必须) + - 预计时间:1天 + - 状态:⚪ 未开始 + - 接口:`POST /app/chat/create` + - 文件: + - `server/api/v1/app/chat.go` + - `server/service/app/chat.go` + +- [ ] **T2.6.2** 实现获取对话列表 API + - 负责人: + - 优先级:P0(必须) + - 预计时间:1天 + - 状态:⚪ 未开始 + - 接口:`GET /app/chat/list` + - 功能:支持分页、筛选、排序 + +- [ ] **T2.6.3** 实现获取对话详情 API + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + - 接口:`GET /app/chat/:id` + +- [ ] **T2.6.4** 实现更新对话 API + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + - 接口:`PUT /app/chat/:id` + +- [ ] **T2.6.5** 实现删除对话 API + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + - 接口:`DELETE /app/chat/:id` + +- [ ] **T2.6.6** 实现获取对话消息历史 API + - 负责人: + - 优先级:P0(必须) + - 预计时间:1天 + - 状态:⚪ 未开始 + - 接口:`GET /app/chat/:id/messages` + - 功能:支持分页、倒序加载 + +### 2.7 消息管理与 AI 对话 + +- [ ] **T2.7.1** 实现发送消息 API(调用 AI) + - 负责人: + - 优先级:P0(必须) + - 预计时间:3天 + - 依赖:T2.3.4 + - 状态:⚪ 未开始 + - 接口:`POST /app/chat/:id/send` + - 功能: + - 调用 AI API + - 保存用户消息和 AI 回复 + - 支持流式输出 + - Token 计数 + +- [ ] **T2.7.2** 实现流式消息推送(SSE) + - 负责人: + - 优先级:P0(必须) + - 预计时间:2天 + - 依赖:T2.7.1 + - 状态:⚪ 未开始 + - 接口:`GET /app/chat/:id/stream` + +- [ ] **T2.7.3** 实现重新生成消息 API + - 负责人: + - 优先级:P1(重要) + - 预计时间:1天 + - 依赖:T2.7.1 + - 状态:⚪ 未开始 + - 接口:`POST /app/chat/:id/regenerate/:messageId` + +- [ ] **T2.7.4** 实现编辑消息 API + - 负责人: + - 优先级:P1(重要) + - 预计时间:1天 + - 状态:⚪ 未开始 + - 接口:`PUT /app/chat/:id/message/:messageId` + +- [ ] **T2.7.5** 实现删除消息 API + - 负责人: + - 优先级:P1(重要) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + - 接口:`DELETE /app/chat/:id/message/:messageId` + +- [ ] **T2.7.6** 实现消息变体(Swipe)功能 + - 负责人: + - 优先级:P1(重要) + - 预计时间:2天 + - 状态:⚪ 未开始 + - 接口: + - `POST /app/chat/:id/message/:messageId/swipe`(创建变体) + - `GET /app/chat/:id/message/:messageId/swipes`(获取所有变体) + - `PUT /app/chat/:id/message/:messageId/swipe/:swipeId`(切换变体) + +### 2.8 向量记忆管理 + +- [ ] **T2.8.1** 实现创建记忆 API + - 负责人: + - 优先级:P1(重要) + - 预计时间:2天 + - 依赖:T2.3.5 + - 状态:⚪ 未开始 + - 接口:`POST /app/memory/create` + - 功能:自动生成向量嵌入 + +- [ ] **T2.8.2** 实现搜索相似记忆 API + - 负责人: + - 优先级:P1(重要) + - 预计时间:2天 + - 依赖:T2.8.1 + - 状态:⚪ 未开始 + - 接口:`POST /app/memory/search` + - 功能:向量相似度搜索 + +- [ ] **T2.8.3** 实现获取记忆列表 API + - 负责人: + - 优先级:P1(重要) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + - 接口:`GET /app/memory/list` + +- [ ] **T2.8.4** 实现删除记忆 API + - 负责人: + - 优先级:P1(重要) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + - 接口:`DELETE /app/memory/:id` + +- [ ] **T2.8.5** 对话时自动检索相关记忆 + - 负责人: + - 优先级:P1(重要) + - 预计时间:1天 + - 依赖:T2.7.1, T2.8.2 + - 状态:⚪ 未开始 + - 集成到发送消息流程 + +### 2.9 文件管理 + +- [ ] **T2.9.1** 实现文件上传 API + - 负责人: + - 优先级:P0(必须) + - 预计时间:1天 + - 状态:⚪ 未开始 + - 接口:`POST /app/file/upload` + - 功能:支持本地存储和对象存储 + +- [ ] **T2.9.2** 实现文件列表 API + - 负责人: + - 优先级:P1(重要) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + - 接口:`GET /app/file/list` + +- [ ] **T2.9.3** 实现文件删除 API + - 负责人: + - 优先级:P1(重要) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + - 接口:`DELETE /app/file/:id` + +- [ ] **T2.9.4** 实现头像上传专用接口 + - 负责人: + - 优先级:P1(重要) + - 预计时间:1天 + - 状态:⚪ 未开始 + - 接口:`POST /app/file/upload/avatar` + +### 2.10 WebSocket 实时通信 + +- [ ] **T2.10.1** 实现 WebSocket Hub + - 负责人: + - 优先级:P1(重要) + - 预计时间:2天 + - 状态:⚪ 未开始 + - 文件:`server/pkg/websocket/hub.go` + +- [ ] **T2.10.2** 实现 WebSocket 连接认证 + - 负责人: + - 优先级:P1(重要) + - 预计时间:1天 + - 依赖:T2.10.1, T2.2.1 + - 状态:⚪ 未开始 + +- [ ] **T2.10.3** 实现消息推送功能 + - 负责人: + - 优先级:P1(重要) + - 预计时间:1天 + - 依赖:T2.10.1 + - 状态:⚪ 未开始 + - 功能:AI 回复实时推送 + +- [ ] **T2.10.4** 实现心跳检测和断线重连 + - 负责人: + - 优先级:P1(重要) + - 预计时间:1天 + - 依赖:T2.10.1 + - 状态:⚪ 未开始 + +### 2.11 路由注册 + +- [ ] **T2.11.1** 注册认证路由 + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + - 文件:`server/router/app/auth.go` + +- [ ] **T2.11.2** 注册角色路由 + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + - 文件:`server/router/app/character.go` + +- [ ] **T2.11.3** 注册对话路由 + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + - 文件:`server/router/app/chat.go` + +- [ ] **T2.11.4** 注册其他路由(Provider、File、Memory 等) + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + +- [ ] **T2.11.5** 集成到主路由 + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 依赖:T2.11.1-4 + - 状态:⚪ 未开始 + - 文件:`server/initialize/router.go` + +### 2.12 Swagger 文档 + +- [ ] **T2.12.1** 为所有 API 添加 Swagger 注释 + - 负责人: + - 优先级:P2(推荐) + - 预计时间:2天 + - 状态:⚪ 未开始 + +- [ ] **T2.12.2** 生成 Swagger 文档 + - 负责人: + - 优先级:P2(推荐) + - 预计时间:0.5天 + - 依赖:T2.12.1 + - 状态:⚪ 未开始 + - 命令:`swag init` + +--- + +## 🎯 阶段三:Vue 3 前台开发(预计 15-20 天) + +> **重要决策**:使用 Vue 3 + TypeScript 重构前台,而非改造旧的 jQuery 代码 +> **原因**:旧代码过于混乱(index.html 7978行,226+ JS文件),维护成本极高 +> **优势**:与管理后台技术统一,易于维护,性能更好 + +### 3.1 Vue 项目初始化 + +- [x] **T3.1.1** 创建 Vue 3 + TypeScript 项目 + - 负责人:AI助手 + - 优先级:P0(必须) + - 预计时间:0.5天 + - 状态:🟢 已完成 + - 完成日期:2026-02-10 + - 技术栈:Vue 3 + Vite + TypeScript + Element Plus + - 项目目录:`web-app-vue/` + +- [x] **T3.1.2** 配置项目基础设施 + - 负责人:AI助手 + - 优先级:P0(必须) + - 预计时间:0.5天 + - 状态:🟢 已完成 + - 完成日期:2026-02-10 + - 内容: + - Vite 配置(代理、别名、插件) + - TypeScript 配置 + - Element Plus 自动导入 + - Pinia + Vue Router 配置 + +- [x] **T3.1.3** 创建项目目录结构 + - 负责人:AI助手 + - 优先级:P0(必须) + - 预计时间:0.5天 + - 状态:🟢 已完成 + - 完成日期:2026-02-10 + - 目录:api/, stores/, types/, utils/, layouts/, views/, composables/ + +### 3.2 Vue 认证模块开发 + +- [x] **T3.2.1** 创建 TypeScript 类型定义 + - 负责人:AI助手 + - 优先级:P0(必须) + - 预计时间:0.5天 + - 状态:🟢 已完成 + - 完成日期:2026-02-10 + - 文件: + - `src/types/user.d.ts`(用户、登录、注册类型) + - `src/types/api.d.ts`(API响应类型) + +- [x] **T3.2.2** 封装 HTTP 请求工具 + - 负责人:AI助手 + - 优先级:P0(必须) + - 预计时间:0.5天 + - 状态:🟢 已完成 + - 完成日期:2026-02-10 + - 文件:`src/utils/request.ts` + - 功能:请求拦截、响应拦截、自动添加Token、统一错误处理 + +- [x] **T3.2.3** 创建认证 API 接口 + - 负责人:AI助手 + - 优先级:P0(必须) + - 预计时间:0.5天 + - 状态:🟢 已完成 + - 完成日期:2026-02-10 + - 文件:`src/api/auth.ts` + - 接口:login, register, logout, getUserInfo, refreshToken + +- [x] **T3.2.4** 创建认证状态管理(Pinia) + - 负责人:AI助手 + - 优先级:P0(必须) + - 预计时间:1天 + - 状态:🟢 已完成 + - 完成日期:2026-02-10 + - 文件:`src/stores/auth.ts` + - 功能:登录、注册、登出、Token管理、用户信息管理 + +- [x] **T3.2.5** 创建认证布局组件 + - 负责人:AI助手 + - 优先级:P0(必须) + - 预计时间:0.5天 + - 状态:🟢 已完成 + - 完成日期:2026-02-10 + - 文件:`src/layouts/AuthLayout.vue` + - 特性:渐变背景动画、居中布局、响应式设计 + +- [x] **T3.2.6** 创建默认布局组件 + - 负责人:AI助手 + - 优先级:P0(必须) + - 预计时间:0.5天 + - 状态:🟢 已完成 + - 完成日期:2026-02-10 + - 文件:`src/layouts/DefaultLayout.vue` + - 特性:顶部导航、用户菜单、主内容区 + +- [x] **T3.2.7** 开发登录页面 + - 负责人:AI助手 + - 优先级:P0(必须) + - 预计时间:1天 + - 状态:🟢 已完成 + - 完成日期:2026-02-10 + - 文件:`src/views/auth/Login.vue` + - 功能:用户名密码登录、表单验证、回车登录 + +- [x] **T3.2.8** 开发注册页面 + - 负责人:AI助手 + - 优先级:P0(必须) + - 预计时间:1天 + - 状态:🟢 已完成 + - 完成日期:2026-02-10 + - 文件:`src/views/auth/Register.vue` + - 功能:用户注册、确认密码、邮箱验证、表单验证 + +- [x] **T3.2.9** 配置路由和路由守卫 + - 负责人:AI助手 + - 优先级:P0(必须) + - 预计时间:0.5天 + - 状态:🟢 已完成 + - 完成日期:2026-02-10 + - 文件:`src/router/index.ts` + - 功能:路由配置、认证守卫、自动跳转、页面标题 + +- [x] **T3.2.10** 开发首页 + - 负责人:AI助手 + - 优先级:P0(必须) + - 预计时间:0.5天 + - 状态:🟢 已完成 + - 完成日期:2026-02-10 + - 文件:`src/views/home/Index.vue` + - 功能:欢迎信息、用户统计、快捷入口 + +### 3.3 Vue 角色管理模块 + +- [x] **T3.3.1** 完善 AICharacter 数据模型 + - 负责人:AI助手 + - 优先级:P0(必须) + - 预计时间:0.5天 + - 状态:🟢 已完成 + - 完成日期:2026-02-10 + - 添加字段:totalChats, totalLikes, isPublic, creatorName 等 + +- [x] **T3.3.2** 创建角色卡请求/响应结构体 + - 负责人:AI助手 + - 优先级:P0(必须) + - 预计时间:0.5天 + - 状态:🟢 已完成 + - 完成日期:2026-02-10 + - 文件:`server/model/app/request/character.go`, `response/character.go` + +- [x] **T3.3.3** 创建角色卡 Service 层 + - 负责人:AI助手 + - 优先级:P0(必须) + - 预计时间:2天 + - 状态:🟢 已完成 + - 完成日期:2026-02-10 + - 文件:`server/service/app/character.go` + - 功能:CRUD、收藏、点赞、导入导出 + +- [x] **T3.3.4** 创建角色卡 API 层 + - 负责人:AI助手 + - 优先级:P0(必须) + - 预计时间:1天 + - 状态:🟢 已完成 + - 完成日期:2026-02-10 + - 文件:`server/api/v1/app/character.go` + - 包含公开接口和需鉴权接口 + +- [x] **T3.3.5** 创建角色卡路由 + - 负责人:AI助手 + - 优先级:P0(必须) + - 预计时间:0.5天 + - 状态:🟢 已完成 + - 完成日期:2026-02-10 + - 文件:`server/router/app/character.go` + +- [x] **T3.3.6** 创建 Vue 角色卡类型定义 + - 负责人:AI助手 + - 优先级:P0(必须) + - 预计时间:0.5天 + - 状态:🟢 已完成 + - 完成日期:2026-02-10 + - 文件:`web-app-vue/src/types/character.d.ts` + +- [x] **T3.3.7** 创建 Vue 角色卡 API 接口 + - 负责人:AI助手 + - 优先级:P0(必须) + - 预计时间:0.5天 + - 状态:🟢 已完成 + - 完成日期:2026-02-10 + - 文件:`web-app-vue/src/api/character.ts` + +- [x] **T3.3.8** 创建 Vue 角色卡状态管理 + - 负责人:AI助手 + - 优先级:P0(必须) + - 预计时间:1天 + - 状态:🟢 已完成 + - 完成日期:2026-02-10 + - 文件:`web-app-vue/src/stores/character.ts` + +- [x] **T3.3.9** 开发首页角色列表(瀑布流) + - 负责人:AI助手 + - 优先级:P0(必须) + - 预计时间:2天 + - 状态:🟢 已完成 + - 完成日期:2026-02-10 + - 文件:`web-app-vue/src/views/home/CharacterList.vue` + - 特性:瀑布流布局、搜索、排序、收藏、点赞 + +- [x] **T3.3.10** 开发角色详情页面 + - 负责人:AI助手 + - 优先级:P0(必须) + - 预计时间:1.5天 + - 状态:🟢 已完成 + - 完成日期:2026-02-10 + - 文件:`web-app-vue/src/views/character/Detail.vue` + - 功能:详情展示、编辑、删除、导出 + +- [x] **T3.3.11** 开发"我的角色卡"页面 + - 负责人:AI助手 + - 优先级:P0(必须) + - 预计时间:1天 + - 状态:🟢 已完成 + - 完成日期:2026-02-10 + - 文件:`web-app-vue/src/views/character/MyCharacters.vue` + +- [x] **T3.3.12** 开发角色卡编辑页面 + - 负责人:AI助手 + - 优先级:P0(必须) + - 预计时间:2天 + - 状态:🟢 已完成 + - 完成日期:2026-02-10 + - 文件:`web-app-vue/src/views/character/Edit.vue` + - 功能:创建、编辑、表单验证 + +- [x] **T3.3.13** 更新路由配置 + - 负责人:AI助手 + - 优先级:P0(必须) + - 预计时间:0.5天 + - 状态:🟢 已完成 + - 完成日期:2026-02-10 + - 添加角色卡相关路由 + +- [x] **T3.3.14** 更新导航布局 + - 负责人:AI助手 + - 优先级:P0(必须) + - 预计时间:0.5天 + - 状态:🟢 已完成 + - 完成日期:2026-02-10 + - 添加主导航菜单、登录/注册按钮 + +### 3.4 Vue 对话功能模块(待开发) + +- [ ] **T3.4.1** 创建对话类型定义 + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + - 文件:`src/types/chat.d.ts` + +- [ ] **T3.4.2** 创建对话 API 接口 + - 负责人: + - 优先级:P0(必须) + - 预计时间:1天 + - 状态:⚪ 未开始 + - 文件:`src/api/chat.ts` + +- [ ] **T3.4.3** 创建对话状态管理 + - 负责人: + - 优先级:P0(必须) + - 预计时间:1天 + - 状态:⚪ 未开始 + - 文件:`src/stores/chat.ts` + +- [ ] **T3.4.4** 封装 WebSocket 工具 + - 负责人: + - 优先级:P0(必须) + - 预计时间:2天 + - 状态:⚪ 未开始 + - 文件:`src/utils/websocket.ts` + +- [ ] **T3.4.5** 开发对话主界面 + - 负责人: + - 优先级:P0(必须) + - 预计时间:3天 + - 状态:⚪ 未开始 + - 文件:`src/views/chat/Index.vue` + +- [ ] **T3.4.6** 开发消息组件 + - 负责人: + - 优先级:P0(必须) + - 预计时间:2天 + - 状态:⚪ 未开始 + - 文件:`src/components/chat/ChatMessage.vue` + +- [ ] **T3.4.7** 开发消息输入组件 + - 负责人: + - 优先级:P0(必须) + - 预计时间:1天 + - 状态:⚪ 未开始 + - 文件:`src/components/chat/ChatInput.vue` + +- [ ] **T3.4.8** 实现 Swipe 功能 + - 负责人: + - 优先级:P1(重要) + - 预计时间:1天 + - 状态:⚪ 未开始 + +### 3.5 Vue 设置和其他页面(待开发) + +- [ ] **T3.5.1** 开发设置页面 + - 负责人: + - 优先级:P1(重要) + - 预计时间:2天 + - 状态:⚪ 未开始 + - 文件:`src/views/settings/Index.vue` + +- [ ] **T3.5.2** 开发用户中心页面 + - 负责人: + - 优先级:P1(重要) + - 预计时间:1天 + - 状态:⚪ 未开始 + - 文件:`src/views/user/Profile.vue` + +--- + +## 🎯 阶段四:前端改造(旧版 jQuery)(暂停) + +> **已决定**:不再改造旧版 web-app,直接使用 Vue 3 重构 +> **状态**:本阶段暂停,优先完成阶段三(Vue 版本) + +### 3.1 web-app 前端改造准备 + +- [ ] **T3.1.1** 备份原有 web-app 项目 + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + - 备份位置:`web-app-backup/` + +- [ ] **T3.1.2** 分析现有前端代码结构 + - 负责人: + - 优先级:P0(必须) + - 预计时间:1天 + - 状态:⚪ 未开始 + - 输出:前端功能清单 + +- [ ] **T3.1.3** 规划前端目录结构 + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + +### 3.2 移除 Node.js 后端 + +- [ ] **T3.2.1** 删除 server.js 和后端相关文件 + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + - 删除文件: + - `server.js` + - `src/` 目录(所有后端代码) + - `webpack.config.js`(如不需要) + +- [ ] **T3.2.2** 保留必要的前端文件 + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + - 保留:`public/` 目录下所有文件 + +### 3.3 API 客户端封装 + +- [ ] **T3.3.1** 创建 HTTP 客户端基类 + - 负责人: + - 优先级:P0(必须) + - 预计时间:1天 + - 状态:⚪ 未开始 + - 文件:`public/scripts/api/client.js` + - 功能: + - 统一的请求/响应处理 + - Token 管理 + - 错误处理 + +- [ ] **T3.3.2** 封装认证相关 API + - 负责人: + - 优先级:P0(必须) + - 预计时间:1天 + - 依赖:T3.3.1 + - 状态:⚪ 未开始 + - 文件:`public/scripts/api/auth.js` + - 接口:注册、登录、登出、刷新Token + +- [ ] **T3.3.3** 封装角色管理 API + - 负责人: + - 优先级:P0(必须) + - 预计时间:1天 + - 依赖:T3.3.1 + - 状态:⚪ 未开始 + - 文件:`public/scripts/api/character.js` + +- [ ] **T3.3.4** 封装对话管理 API + - 负责人: + - 优先级:P0(必须) + - 预计时间:1天 + - 依赖:T3.3.1 + - 状态:⚪ 未开始 + - 文件:`public/scripts/api/chat.js` + +- [ ] **T3.3.5** 封装消息管理 API + - 负责人: + - 优先级:P0(必须) + - 预计时间:1天 + - 依赖:T3.3.1 + - 状态:⚪ 未开始 + - 文件:`public/scripts/api/message.js` + +- [ ] **T3.3.6** 封装 AI 提供商配置 API + - 负责人: + - 优先级:P1(重要) + - 预计时间:0.5天 + - 依赖:T3.3.1 + - 状态:⚪ 未开始 + - 文件:`public/scripts/api/provider.js` + +- [ ] **T3.3.7** 封装文件上传 API + - 负责人: + - 优先级:P1(重要) + - 预计时间:0.5天 + - 依赖:T3.3.1 + - 状态:⚪ 未开始 + - 文件:`public/scripts/api/file.js` + +### 3.4 WebSocket 客户端 + +- [ ] **T3.4.1** 实现 WebSocket 客户端 + - 负责人: + - 优先级:P1(重要) + - 预计时间:2天 + - 状态:⚪ 未开始 + - 文件:`public/scripts/utils/websocket.js` + - 功能: + - 连接管理 + - 自动重连 + - 消息订阅/发布 + +- [ ] **T3.4.2** 集成 WebSocket 到对话界面 + - 负责人: + - 优先级:P1(重要) + - 预计时间:1天 + - 依赖:T3.4.1 + - 状态:⚪ 未开始 + +### 3.5 前端页面适配 + +- [ ] **T3.5.1** 适配登录/注册页面 + - 负责人: + - 优先级:P0(必须) + - 预计时间:1天 + - 依赖:T3.3.2 + - 状态:⚪ 未开始 + +- [ ] **T3.5.2** 适配角色列表页面 + - 负责人: + - 优先级:P0(必须) + - 预计时间:1天 + - 依赖:T3.3.3 + - 状态:⚪ 未开始 + +- [ ] **T3.5.3** 适配角色详情/编辑页面 + - 负责人: + - 优先级:P0(必须) + - 预计时间:1天 + - 依赖:T3.3.3 + - 状态:⚪ 未开始 + +- [ ] **T3.5.4** 适配对话列表页面 + - 负责人: + - 优先级:P0(必须) + - 预计时间:1天 + - 依赖:T3.3.4 + - 状态:⚪ 未开始 + +- [ ] **T3.5.5** 适配对话界面 + - 负责人: + - 优先级:P0(必须) + - 预计时间:2天 + - 依赖:T3.3.5, T3.4.2 + - 状态:⚪ 未开始 + - 功能: + - 消息发送 + - 流式显示 + - Swipe 功能 + - 消息编辑 + +- [ ] **T3.5.6** 适配设置页面 + - 负责人: + - 优先级:P1(重要) + - 预计时间:1天 + - 依赖:T3.3.6 + - 状态:⚪ 未开始 + +- [ ] **T3.5.7** 适配个人中心页面 + - 负责人: + - 优先级:P1(重要) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + +### 3.6 环境配置 + +- [ ] **T3.6.1** 创建配置文件 + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + - 文件:`public/scripts/config.js` + - 内容:API 地址、WebSocket 地址等 + +- [ ] **T3.6.2** 配置开发环境 + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + - 开发服务器:使用 live-server 或类似工具 + +- [ ] **T3.6.3** 配置生产环境 + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + - 文件:`web-app/nginx.conf` + +### 3.7 web 管理后台扩展 + +- [ ] **T3.7.1** 创建 AI 管理模块目录 + - 负责人: + - 优先级:P1(重要) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + - 目录:`web/src/view/aiManage/` + +- [ ] **T3.7.2** 添加角色管理页面 + - 负责人: + - 优先级:P1(重要) + - 预计时间:2天 + - 状态:⚪ 未开始 + - 文件:`web/src/view/aiManage/character/index.vue` + +- [ ] **T3.7.3** 添加对话管理页面 + - 负责人: + - 优先级:P1(重要) + - 预计时间:2天 + - 状态:⚪ 未开始 + - 文件:`web/src/view/aiManage/chat/index.vue` + +- [ ] **T3.7.4** 添加用户管理页面 + - 负责人: + - 优先级:P1(重要) + - 预计时间:1天 + - 状态:⚪ 未开始 + - 文件:`web/src/view/aiManage/user/index.vue` + +- [ ] **T3.7.5** 添加数据统计页面 + - 负责人: + - 优先级:P2(推荐) + - 预计时间:2天 + - 状态:⚪ 未开始 + - 文件:`web/src/view/aiManage/statistics/index.vue` + +- [ ] **T3.7.6** 添加 API 接口 + - 负责人: + - 优先级:P1(重要) + - 预计时间:1天 + - 状态:⚪ 未开始 + - 文件:`web/src/api/ai.js` + +- [ ] **T3.7.7** 添加路由配置 + - 负责人: + - 优先级:P1(重要) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + - 文件:`web/src/router/index.js` + +--- + +## 🎯 阶段五:数据迁移(预计 3-5 天) + +### 4.1 迁移准备 + +- [ ] **T4.1.1** 备份 SillyTavern 原始数据 + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + - 备份位置:指定安全位置 + +- [ ] **T4.1.2** 创建测试用前台用户 + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 依赖:T2.2.2 + - 状态:⚪ 未开始 + - 用于数据迁移目标账号 + +### 4.2 角色数据迁移 + +- [ ] **T4.2.1** 编写角色迁移脚本 + - 负责人: + - 优先级:P0(必须) + - 预计时间:1天 + - 状态:⚪ 未开始 + - 文件:`server/utils/migrate/migrate_sillytavern.go` + - 功能:从文件系统读取角色 JSON,写入数据库 + +- [ ] **T4.2.2** 测试角色迁移 + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 依赖:T4.2.1 + - 状态:⚪ 未开始 + - 验证:数据完整性、关联关系 + +- [ ] **T4.2.3** 执行角色正式迁移 + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 依赖:T4.2.2 + - 状态:⚪ 未开始 + +### 4.3 对话数据迁移 + +- [ ] **T4.3.1** 编写对话迁移脚本 + - 负责人: + - 优先级:P0(必须) + - 预计时间:1天 + - 状态:⚪ 未开始 + - 功能:从 JSONL 文件读取对话,写入数据库 + +- [ ] **T4.3.2** 测试对话迁移 + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 依赖:T4.3.1 + - 状态:⚪ 未开始 + +- [ ] **T4.3.3** 执行对话正式迁移 + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 依赖:T4.3.2 + - 状态:⚪ 未开始 + +### 4.4 文件数据迁移 + +- [ ] **T4.4.1** 编写文件迁移脚本 + - 负责人: + - 优先级:P1(重要) + - 预计时间:1天 + - 状态:⚪ 未开始 + - 功能:上传到对象存储,记录到数据库 + +- [ ] **T4.4.2** 迁移头像文件 + - 负责人: + - 优先级:P1(重要) + - 预计时间:0.5天 + - 依赖:T4.4.1 + - 状态:⚪ 未开始 + +- [ ] **T4.4.3** 迁移背景文件 + - 负责人: + - 优先级:P2(推荐) + - 预计时间:0.5天 + - 依赖:T4.4.1 + - 状态:⚪ 未开始 + +- [ ] **T4.4.4** 迁移其他附件文件 + - 负责人: + - 优先级:P2(推荐) + - 预计时间:0.5天 + - 依赖:T4.4.1 + - 状态:⚪ 未开始 + +### 4.5 数据验证 + +- [ ] **T4.5.1** 验证角色数据完整性 + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 依赖:T4.2.3 + - 状态:⚪ 未开始 + - 检查项:数量、字段完整性、关联关系 + +- [ ] **T4.5.2** 验证对话数据完整性 + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 依赖:T4.3.3 + - 状态:⚪ 未开始 + +- [ ] **T4.5.3** 验证文件数据完整性 + - 负责人: + - 优先级:P1(重要) + - 预计时间:0.5天 + - 依赖:T4.4.* + - 状态:⚪ 未开始 + +--- + +## 🎯 阶段六:测试与优化(预计 7-10 天) + +### 5.1 功能测试 + +#### 5.1.1 用户认证测试 + +- [ ] **T5.1.1** 用户注册功能测试 + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + - 测试用例:正常注册、重复用户名、无效邮箱等 + +- [ ] **T5.1.2** 用户登录功能测试 + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + - 测试用例:正确密码、错误密码、不存在用户等 + +- [ ] **T5.1.3** Token 刷新测试 + - 负责人: + - 优先级:P1(重要) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + +- [ ] **T5.1.4** 登出功能测试 + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + +#### 5.1.2 角色管理测试 + +- [ ] **T5.1.5** 创建角色测试 + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + +- [ ] **T5.1.6** 编辑角色测试 + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + +- [ ] **T5.1.7** 删除角色测试 + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + +- [ ] **T5.1.8** 导入导出角色测试 + - 负责人: + - 优先级:P0(必须) + - 预计时间:1天 + - 状态:⚪ 未开始 + - 测试:PNG、JSON 格式 + +#### 5.1.3 对话功能测试 + +- [ ] **T5.1.9** 创建对话测试 + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + +- [ ] **T5.1.10** 发送消息测试 + - 负责人: + - 优先级:P0(必须) + - 预计时间:1天 + - 状态:⚪ 未开始 + - 测试:普通消息、长文本、特殊字符 + +- [ ] **T5.1.11** AI 回复测试 + - 负责人: + - 优先级:P0(必须) + - 预计时间:1天 + - 状态:⚪ 未开始 + - 测试:OpenAI、Claude 等不同模型 + +- [ ] **T5.1.12** 流式输出测试 + - 负责人: + - 优先级:P0(必须) + - 预计时间:1天 + - 状态:⚪ 未开始 + +- [ ] **T5.1.13** 消息重新生成测试 + - 负责人: + - 优先级:P1(重要) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + +- [ ] **T5.1.14** 消息编辑测试 + - 负责人: + - 优先级:P1(重要) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + +- [ ] **T5.1.15** Swipe 功能测试 + - 负责人: + - 优先级:P1(重要) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + +- [ ] **T5.1.16** 对话历史加载测试 + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + - 测试:分页、倒序加载 + +#### 5.1.4 向量记忆测试 + +- [ ] **T5.1.17** 创建记忆测试 + - 负责人: + - 优先级:P1(重要) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + +- [ ] **T5.1.18** 向量搜索测试 + - 负责人: + - 优先级:P1(重要) + - 预计时间:1天 + - 状态:⚪ 未开始 + - 验证:搜索准确性、性能 + +- [ ] **T5.1.19** 记忆召回测试 + - 负责人: + - 优先级:P1(重要) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + - 测试:对话时自动召回相关记忆 + +#### 5.1.5 WebSocket 测试 + +- [ ] **T5.1.20** WebSocket 连接测试 + - 负责人: + - 优先级:P1(重要) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + +- [ ] **T5.1.21** 消息推送测试 + - 负责人: + - 优先级:P1(重要) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + +- [ ] **T5.1.22** 断线重连测试 + - 负责人: + - 优先级:P1(重要) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + +- [ ] **T5.1.23** 多客户端测试 + - 负责人: + - 优先级:P1(重要) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + +#### 5.1.6 文件管理测试 + +- [ ] **T5.1.24** 文件上传测试 + - 负责人: + - 优先级:P1(重要) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + - 测试:大文件、不同格式 + +- [ ] **T5.1.25** 文件下载测试 + - 负责人: + - 优先级:P1(重要) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + +- [ ] **T5.1.26** 文件删除测试 + - 负责人: + - 优先级:P1(重要) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + +### 5.2 性能测试 + +- [ ] **T5.2.1** 登录接口压力测试 + - 负责人: + - 优先级:P1(重要) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + - 工具:Apache Bench / wrk + +- [ ] **T5.2.2** 对话接口压力测试 + - 负责人: + - 优先级:P1(重要) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + +- [ ] **T5.2.3** 数据库查询性能测试 + - 负责人: + - 优先级:P1(重要) + - 预计时间:1天 + - 状态:⚪ 未开始 + - 检查:慢查询、索引优化 + +- [ ] **T5.2.4** 向量搜索性能测试 + - 负责人: + - 优先级:P1(重要) + - 预计时间:1天 + - 状态:⚪ 未开始 + +- [ ] **T5.2.5** WebSocket 并发测试 + - 负责人: + - 优先级:P1(重要) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + +### 5.3 性能优化 + +- [ ] **T5.3.1** 添加 Redis 缓存 + - 负责人: + - 优先级:P1(重要) + - 预计时间:2天 + - 状态:⚪ 未开始 + - 缓存对象:角色数据、用户配置等 + +- [ ] **T5.3.2** 数据库索引优化 + - 负责人: + - 优先级:P1(重要) + - 预计时间:1天 + - 依赖:T5.2.3 + - 状态:⚪ 未开始 + +- [ ] **T5.3.3** SQL 查询优化 + - 负责人: + - 优先级:P1(重要) + - 预计时间:1天 + - 依赖:T5.2.3 + - 状态:⚪ 未开始 + +- [ ] **T5.3.4** 前端资源优化 + - 负责人: + - 优先级:P2(推荐) + - 预计时间:1天 + - 状态:⚪ 未开始 + - 内容:压缩、合并、懒加载 + +### 5.4 安全加固 + +- [ ] **T5.4.1** 实现 API 限流 + - 负责人: + - 优先级:P1(重要) + - 预计时间:1天 + - 状态:⚪ 未开始 + - 文件:`server/middleware/limiter.go` + +- [ ] **T5.4.2** 加密存储 API Keys + - 负责人: + - 优先级:P0(必须) + - 预计时间:1天 + - 状态:⚪ 未开始 + - 文件:`server/utils/crypto/encrypt.go` + +- [ ] **T5.4.3** SQL 注入防护验证 + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + +- [ ] **T5.4.4** XSS 防护验证 + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + +- [ ] **T5.4.5** CSRF 防护验证 + - 负责人: + - 优先级:P1(重要) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + +--- + +## 🎯 阶段七:部署上线(预计 2-3 天) + +### 6.1 Docker 部署 + +- [ ] **T6.1.1** 编写 Go 后端 Dockerfile + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + - 文件:`server/Dockerfile` + +- [ ] **T6.1.2** 编写 web-app Dockerfile + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + - 文件:`web-app/Dockerfile` + +- [ ] **T6.1.3** 编写 web 管理后台 Dockerfile + - 负责人: + - 优先级:P1(重要) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + - 文件:`web/Dockerfile` + +- [ ] **T6.1.4** 编写 docker-compose.yml + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + - 文件:`docker-compose.yml` + - 服务:PostgreSQL、Redis、Server、Web、Web-App、Nginx + +- [ ] **T6.1.5** 测试 Docker 部署 + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 依赖:T6.1.1-4 + - 状态:⚪ 未开始 + +### 6.2 Nginx 配置 + +- [ ] **T6.2.1** 编写 Nginx 配置文件 + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + - 文件:`deploy/nginx/nginx.conf` + - 内容:反向代理、WebSocket 支持 + +- [ ] **T6.2.2** 配置 SSL 证书 + - 负责人: + - 优先级:P1(重要) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + - 使用 Let's Encrypt + +- [ ] **T6.2.3** 测试 Nginx 配置 + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 依赖:T6.2.1 + - 状态:⚪ 未开始 + +### 6.3 环境配置 + +- [ ] **T6.3.1** 创建 .env 文件模板 + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + - 文件:`.env.example` + +- [ ] **T6.3.2** 配置生产环境变量 + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + - 内容:数据库、Redis、AI API Keys 等 + +- [ ] **T6.3.3** 配置日志系统 + - 负责人: + - 优先级:P1(重要) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + +### 6.4 数据库备份 + +- [ ] **T6.4.1** 设置数据库自动备份 + - 负责人: + - 优先级:P1(重要) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + - 工具:pg_dump + crontab + +- [ ] **T6.4.2** 测试数据库恢复 + - 负责人: + - 优先级:P1(重要) + - 预计时间:0.5天 + - 依赖:T6.4.1 + - 状态:⚪ 未开始 + +### 6.5 监控与日志 + +- [ ] **T6.5.1** 配置应用监控(可选) + - 负责人: + - 优先级:P3(可选) + - 预计时间:1天 + - 状态:⚪ 未开始 + - 工具:Prometheus + Grafana + +- [ ] **T6.5.2** 配置日志聚合(可选) + - 负责人: + - 优先级:P3(可选) + - 预计时间:1天 + - 状态:⚪ 未开始 + - 工具:ELK Stack + +- [ ] **T6.5.3** 配置告警系统(可选) + - 负责人: + - 优先级:P3(可选) + - 预计时间:0.5天 + - 状态:⚪ 未开始 + +### 6.6 上线验证 + +- [ ] **T6.6.1** 生产环境部署 + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 依赖:所有前置任务 + - 状态:⚪ 未开始 + +- [ ] **T6.6.2** 烟雾测试 + - 负责人: + - 优先级:P0(必须) + - 预计时间:0.5天 + - 依赖:T6.6.1 + - 状态:⚪ 未开始 + - 测试:核心功能可用性 + +- [ ] **T6.6.3** 性能监控 + - 负责人: + - 优先级:P0(必须) + - 预计时间:1天 + - 依赖:T6.6.1 + - 状态:⚪ 未开始 + - 监控:响应时间、错误率、资源使用 + +- [ ] **T6.6.4** 用户验收测试 + - 负责人: + - 优先级:P0(必须) + - 预计时间:1天 + - 依赖:T6.6.1 + - 状态:⚪ 未开始 + +--- + +## 📝 文档完善 + +- [ ] **TD.1** 完善 API 文档 + - 负责人: + - 优先级:P2(推荐) + - 预计时间:2天 + - 状态:⚪ 未开始 + +- [ ] **TD.2** 编写部署文档 + - 负责人: + - 优先级:P1(重要) + - 预计时间:1天 + - 状态:⚪ 未开始 + +- [ ] **TD.3** 编写用户使用手册 + - 负责人: + - 优先级:P2(推荐) + - 预计时间:1天 + - 状态:⚪ 未开始 + +- [ ] **TD.4** 编写开发者文档 + - 负责人: + - 优先级:P2(推荐) + - 预计时间:1天 + - 状态:⚪ 未开始 + +--- + +## 🎉 项目完成 + +- [ ] **Final.1** 项目总结会议 +- [ ] **Final.2** 经验教训总结 +- [ ] **Final.3** 技术债务整理 +- [ ] **Final.4** 后续优化规划 + +--- + +## 📊 优先级说明 + +- **P0(必须)**:核心功能,必须完成 +- **P1(重要)**:重要功能,强烈建议完成 +- **P2(推荐)**:增强功能,建议完成 +- **P3(可选)**:可选功能,时间允许再做 + +--- + +## 📅 里程碑 + +| 里程碑 | 预期完成日期 | 实际完成日期 | 状态 | +|--------|-------------|-------------|------| +| M1: 数据库设计完成 | - | - | ⚪ | +| M2: 核心 API 开发完成 | - | - | ⚪ | +| M3: 前端改造完成 | - | - | ⚪ | +| M4: 数据迁移完成 | - | - | ⚪ | +| M5: 测试通过 | - | - | ⚪ | +| M6: 生产环境上线 | - | - | ⚪ | + +--- + +## 📌 注意事项 + +### 重要约束 +1. ❌ **禁止修改** `server/api/v1/system/` 下的任何文件 +2. ❌ **禁止修改** `sys_users` 表结构 +3. ✅ **所有新功能** 都在 `app/` 目录下实现 +4. ✅ **所有新表** 使用 `app_` 或 `ai_` 前缀 +5. ✅ **所有新路由** 使用 `/app/` 前缀 + +### 开发建议 +1. 每完成一个任务,及时更新状态 +2. 遇到问题及时记录在备注中 +3. 定期 review 进度,调整计划 +4. 重要节点完成后进行备份 + +--- + +**文档创建日期**: 2026-02-10 +**最后更新**: 2026-02-10 +**维护者**: 开发团队 diff --git a/server/api/v1/app/auth.go b/server/api/v1/app/auth.go new file mode 100644 index 0000000..a55d5fb --- /dev/null +++ b/server/api/v1/app/auth.go @@ -0,0 +1,194 @@ +package app + +import ( + "git.echol.cn/loser/st/server/global" + "git.echol.cn/loser/st/server/middleware" + "git.echol.cn/loser/st/server/model/app/request" + "git.echol.cn/loser/st/server/model/common/response" + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +type AuthApi struct{} + +// Register 前台用户注册 +// @Tags App.Auth +// @Summary 前台用户注册 +// @accept application/json +// @Produce application/json +// @Param data body request.RegisterRequest true "用户注册信息" +// @Success 200 {object} response.Response{msg=string} "注册成功" +// @Router /app/auth/register [post] +func (a *AuthApi) Register(c *gin.Context) { + var req request.RegisterRequest + err := c.ShouldBindJSON(&req) + if err != nil { + response.FailWithMessage(err.Error(), c) + return + } + + err = authService.Register(&req) + if err != nil { + global.GVA_LOG.Error("注册失败", zap.Error(err)) + response.FailWithMessage(err.Error(), c) + return + } + + response.OkWithMessage("注册成功", c) +} + +// Login 前台用户登录 +// @Tags App.Auth +// @Summary 前台用户登录 +// @accept application/json +// @Produce application/json +// @Param data body request.LoginRequest true "用户登录信息" +// @Success 200 {object} response.Response{data=response.LoginResponse,msg=string} "登录成功" +// @Router /app/auth/login [post] +func (a *AuthApi) Login(c *gin.Context) { + var req request.LoginRequest + err := c.ShouldBindJSON(&req) + if err != nil { + response.FailWithMessage(err.Error(), c) + return + } + + // 获取客户端 IP + ip := c.ClientIP() + + result, err := authService.Login(&req, ip) + if err != nil { + global.GVA_LOG.Error("登录失败", zap.Error(err)) + response.FailWithMessage(err.Error(), c) + return + } + + response.OkWithData(result, c) +} + +// Logout 前台用户登出 +// @Tags App.Auth +// @Summary 前台用户登出 +// @Security ApiKeyAuth +// @accept application/json +// @Produce application/json +// @Success 200 {object} response.Response{msg=string} "登出成功" +// @Router /app/auth/logout [post] +func (a *AuthApi) Logout(c *gin.Context) { + userID := middleware.GetAppUserID(c) + token := middleware.GetToken(c) + + err := authService.Logout(userID, token) + if err != nil { + global.GVA_LOG.Error("登出失败", zap.Error(err)) + response.FailWithMessage("登出失败", c) + return + } + + response.OkWithMessage("登出成功", c) +} + +// RefreshToken 刷新 Token +// @Tags App.Auth +// @Summary 刷新 Token +// @accept application/json +// @Produce application/json +// @Param data body request.RefreshTokenRequest true "刷新Token请求" +// @Success 200 {object} response.Response{data=response.LoginResponse,msg=string} "刷新成功" +// @Router /app/auth/refresh [post] +func (a *AuthApi) RefreshToken(c *gin.Context) { + var req request.RefreshTokenRequest + err := c.ShouldBindJSON(&req) + if err != nil { + response.FailWithMessage(err.Error(), c) + return + } + + result, err := authService.RefreshToken(&req) + if err != nil { + global.GVA_LOG.Error("刷新 Token 失败", zap.Error(err)) + response.FailWithMessage(err.Error(), c) + return + } + + response.OkWithData(result, c) +} + +// GetUserInfo 获取当前登录用户信息 +// @Tags App.Auth +// @Summary 获取当前登录用户信息 +// @Security ApiKeyAuth +// @accept application/json +// @Produce application/json +// @Success 200 {object} response.Response{data=response.AppUserResponse,msg=string} "获取成功" +// @Router /app/auth/userinfo [get] +func (a *AuthApi) GetUserInfo(c *gin.Context) { + userID := middleware.GetAppUserID(c) + + result, err := authService.GetUserInfo(userID) + if err != nil { + global.GVA_LOG.Error("获取用户信息失败", zap.Error(err)) + response.FailWithMessage("获取用户信息失败", c) + return + } + + response.OkWithData(result, c) +} + +// UpdateProfile 更新用户信息 +// @Tags App.Auth +// @Summary 更新用户信息 +// @Security ApiKeyAuth +// @accept application/json +// @Produce application/json +// @Param data body request.UpdateProfileRequest true "用户信息" +// @Success 200 {object} response.Response{msg=string} "更新成功" +// @Router /app/user/profile [put] +func (a *AuthApi) UpdateProfile(c *gin.Context) { + userID := middleware.GetAppUserID(c) + + var req request.UpdateProfileRequest + err := c.ShouldBindJSON(&req) + if err != nil { + response.FailWithMessage(err.Error(), c) + return + } + + err = authService.UpdateProfile(userID, &req) + if err != nil { + global.GVA_LOG.Error("更新用户信息失败", zap.Error(err)) + response.FailWithMessage(err.Error(), c) + return + } + + response.OkWithMessage("更新成功", c) +} + +// ChangePassword 修改密码 +// @Tags App.Auth +// @Summary 修改密码 +// @Security ApiKeyAuth +// @accept application/json +// @Produce application/json +// @Param data body request.ChangePasswordRequest true "密码信息" +// @Success 200 {object} response.Response{msg=string} "修改成功" +// @Router /app/user/change-password [post] +func (a *AuthApi) ChangePassword(c *gin.Context) { + userID := middleware.GetAppUserID(c) + + var req request.ChangePasswordRequest + err := c.ShouldBindJSON(&req) + if err != nil { + response.FailWithMessage(err.Error(), c) + return + } + + err = authService.ChangePassword(userID, &req) + if err != nil { + global.GVA_LOG.Error("修改密码失败", zap.Error(err)) + response.FailWithMessage(err.Error(), c) + return + } + + response.OkWithMessage("修改成功", c) +} diff --git a/server/api/v1/app/character.go b/server/api/v1/app/character.go new file mode 100644 index 0000000..b7f179d --- /dev/null +++ b/server/api/v1/app/character.go @@ -0,0 +1,392 @@ +package app + +import ( + "git.echol.cn/loser/st/server/global" + "git.echol.cn/loser/st/server/middleware" + "git.echol.cn/loser/st/server/model/app/request" + "git.echol.cn/loser/st/server/model/common/response" + "github.com/gin-gonic/gin" + "go.uber.org/zap" + "io" + "strconv" +) + +type CharacterApi struct{} + +// GetPublicCharacterList 获取公开角色卡列表(无需鉴权) +// @Summary 获取公开角色卡列表 +// @Tags 角色卡 +// @Accept json +// @Produce json +// @Param page query int false "页码" default(1) +// @Param pageSize query int false "每页数量" default(20) +// @Param keyword query string false "关键词" +// @Param sortBy query string false "排序方式: newest, popular, mostChats, mostLikes" +// @Success 200 {object} response.Response{data=response.CharacterListResponse} +// @Router /app/character/public [get] +func (ca *CharacterApi) GetPublicCharacterList(c *gin.Context) { + var req request.CharacterListRequest + req.Page, _ = strconv.Atoi(c.DefaultQuery("page", "1")) + req.PageSize, _ = strconv.Atoi(c.DefaultQuery("pageSize", "20")) + req.Keyword = c.Query("keyword") + req.SortBy = c.Query("sortBy") + + if req.Page < 1 { + req.Page = 1 + } + if req.PageSize < 1 || req.PageSize > 100 { + req.PageSize = 20 + } + + // 尝试获取当前用户ID(如果已登录) + userID := middleware.GetOptionalAppUserID(c) + + list, err := characterService.GetPublicCharacterList(req, userID) + if err != nil { + global.GVA_LOG.Error("获取公开角色卡列表失败", zap.Error(err)) + response.FailWithMessage("获取列表失败", c) + return + } + + response.OkWithData(list, c) +} + +// GetMyCharacterList 获取我的角色卡列表 +// @Summary 获取我的角色卡列表 +// @Tags 角色卡 +// @Accept json +// @Produce json +// @Param page query int false "页码" default(1) +// @Param pageSize query int false "每页数量" default(20) +// @Param keyword query string false "关键词" +// @Param sortBy query string false "排序方式" +// @Success 200 {object} response.Response{data=response.CharacterListResponse} +// @Router /app/character/my [get] +// @Security ApiKeyAuth +func (ca *CharacterApi) GetMyCharacterList(c *gin.Context) { + userID := middleware.GetAppUserID(c) + + var req request.CharacterListRequest + req.Page, _ = strconv.Atoi(c.DefaultQuery("page", "1")) + req.PageSize, _ = strconv.Atoi(c.DefaultQuery("pageSize", "20")) + req.Keyword = c.Query("keyword") + req.SortBy = c.Query("sortBy") + + if req.Page < 1 { + req.Page = 1 + } + if req.PageSize < 1 || req.PageSize > 100 { + req.PageSize = 20 + } + + list, err := characterService.GetMyCharacterList(req, userID) + if err != nil { + global.GVA_LOG.Error("获取我的角色卡列表失败", zap.Error(err)) + response.FailWithMessage("获取列表失败", c) + return + } + + response.OkWithData(list, c) +} + +// GetCharacterDetail 获取角色卡详情 +// @Summary 获取角色卡详情 +// @Tags 角色卡 +// @Accept json +// @Produce json +// @Param id path int true "角色卡ID" +// @Success 200 {object} response.Response{data=response.CharacterResponse} +// @Router /app/character/:id [get] +func (ca *CharacterApi) GetCharacterDetail(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + response.FailWithMessage("无效的ID", c) + return + } + + // 尝试获取当前用户ID(如果已登录) + userID := middleware.GetOptionalAppUserID(c) + + character, err := characterService.GetCharacterDetail(uint(id), userID) + if err != nil { + global.GVA_LOG.Error("获取角色卡详情失败", zap.Error(err)) + response.FailWithMessage(err.Error(), c) + return + } + + response.OkWithData(character, c) +} + +// CreateCharacter 创建角色卡 +// @Summary 创建角色卡 +// @Tags 角色卡 +// @Accept json +// @Produce json +// @Param data body request.CreateCharacterRequest true "角色卡信息" +// @Success 200 {object} response.Response{data=response.CharacterResponse} +// @Router /app/character [post] +// @Security ApiKeyAuth +func (ca *CharacterApi) CreateCharacter(c *gin.Context) { + userID := middleware.GetAppUserID(c) + + var req request.CreateCharacterRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.FailWithMessage(err.Error(), c) + return + } + + character, err := characterService.CreateCharacter(req, userID) + if err != nil { + global.GVA_LOG.Error("创建角色卡失败", zap.Error(err)) + response.FailWithMessage("创建失败", c) + return + } + + response.OkWithData(character, c) +} + +// UpdateCharacter 更新角色卡 +// @Summary 更新角色卡 +// @Tags 角色卡 +// @Accept json +// @Produce json +// @Param data body request.UpdateCharacterRequest true "角色卡信息" +// @Success 200 {object} response.Response{data=response.CharacterResponse} +// @Router /app/character [put] +// @Security ApiKeyAuth +func (ca *CharacterApi) UpdateCharacter(c *gin.Context) { + userID := middleware.GetAppUserID(c) + + var req request.UpdateCharacterRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.FailWithMessage(err.Error(), c) + return + } + + character, err := characterService.UpdateCharacter(req, userID) + if err != nil { + global.GVA_LOG.Error("更新角色卡失败", zap.Error(err)) + response.FailWithMessage(err.Error(), c) + return + } + + response.OkWithData(character, c) +} + +// DeleteCharacter 删除角色卡 +// @Summary 删除角色卡 +// @Tags 角色卡 +// @Accept json +// @Produce json +// @Param id path int true "角色卡ID" +// @Success 200 {object} response.Response +// @Router /app/character/:id [delete] +// @Security ApiKeyAuth +func (ca *CharacterApi) DeleteCharacter(c *gin.Context) { + userID := middleware.GetAppUserID(c) + + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + response.FailWithMessage("无效的ID", c) + return + } + + err = characterService.DeleteCharacter(uint(id), userID) + if err != nil { + global.GVA_LOG.Error("删除角色卡失败", zap.Error(err)) + response.FailWithMessage(err.Error(), c) + return + } + + response.OkWithMessage("删除成功", c) +} + +// ToggleFavorite 切换收藏状态 +// @Summary 切换收藏状态 +// @Tags 角色卡 +// @Accept json +// @Produce json +// @Param data body request.CharacterActionRequest true "角色卡ID" +// @Success 200 {object} response.Response{data=map[string]bool} +// @Router /app/character/favorite [post] +// @Security ApiKeyAuth +func (ca *CharacterApi) ToggleFavorite(c *gin.Context) { + userID := middleware.GetAppUserID(c) + + var req request.CharacterActionRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.FailWithMessage(err.Error(), c) + return + } + + isFavorited, err := characterService.ToggleFavorite(req.CharacterID, userID) + if err != nil { + global.GVA_LOG.Error("切换收藏失败", zap.Error(err)) + response.FailWithMessage(err.Error(), c) + return + } + + response.OkWithData(gin.H{"isFavorited": isFavorited}, c) +} + +// LikeCharacter 点赞角色卡 +// @Summary 点赞角色卡 +// @Tags 角色卡 +// @Accept json +// @Produce json +// @Param data body request.CharacterActionRequest true "角色卡ID" +// @Success 200 {object} response.Response +// @Router /app/character/like [post] +func (ca *CharacterApi) LikeCharacter(c *gin.Context) { + var req request.CharacterActionRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.FailWithMessage(err.Error(), c) + return + } + + err := characterService.LikeCharacter(req.CharacterID) + if err != nil { + global.GVA_LOG.Error("点赞失败", zap.Error(err)) + response.FailWithMessage(err.Error(), c) + return + } + + response.OkWithMessage("点赞成功", c) +} + +// ExportCharacter 导出角色卡为 JSON +// @Summary 导出角色卡为 JSON +// @Tags 角色卡 +// @Accept json +// @Produce json +// @Param id path int true "角色卡ID" +// @Success 200 {object} map[string]interface{} +// @Router /app/character/:id/export [get] +func (ca *CharacterApi) ExportCharacter(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + response.FailWithMessage("无效的ID", c) + return + } + + // 尝试获取当前用户ID(如果已登录) + userID := middleware.GetOptionalAppUserID(c) + + exportData, err := characterService.ExportCharacter(uint(id), userID) + if err != nil { + global.GVA_LOG.Error("导出角色卡失败", zap.Error(err)) + response.FailWithMessage(err.Error(), c) + return + } + + // 设置下载文件头 + c.Header("Content-Type", "application/json") + c.Header("Content-Disposition", "attachment; filename=character.json") + c.JSON(200, exportData) +} + +// ExportCharacterAsPNG 导出角色卡为 PNG +// @Summary 导出角色卡为 PNG +// @Tags 角色卡 +// @Accept json +// @Produce octet-stream +// @Param id path int true "角色卡ID" +// @Success 200 {file} binary +// @Router /app/character/:id/export/png [get] +func (ca *CharacterApi) ExportCharacterAsPNG(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + response.FailWithMessage("无效的ID", c) + return + } + + // 尝试获取当前用户ID(如果已登录) + userID := middleware.GetOptionalAppUserID(c) + + pngData, err := characterService.ExportCharacterAsPNG(uint(id), userID) + if err != nil { + global.GVA_LOG.Error("导出 PNG 角色卡失败", zap.Error(err)) + response.FailWithMessage(err.Error(), c) + return + } + + // 设置下载文件头 + c.Header("Content-Type", "image/png") + c.Header("Content-Disposition", "attachment; filename=character.png") + c.Data(200, "image/png", pngData) +} + +// ImportCharacter 导入角色卡 +// @Summary 导入角色卡(支持 PNG 和 JSON) +// @Tags 角色卡 +// @Accept multipart/form-data +// @Produce json +// @Param file formData file true "角色卡文件(PNG 或 JSON)" +// @Param isPublic formData bool false "是否公开" +// @Success 200 {object} response.Response{data=response.CharacterResponse} +// @Router /app/character/import [post] +// @Security ApiKeyAuth +func (ca *CharacterApi) ImportCharacter(c *gin.Context) { + userID := middleware.GetAppUserID(c) + + // 获取上传的文件 + file, err := c.FormFile("file") + if err != nil { + global.GVA_LOG.Error("获取上传文件失败", zap.Error(err)) + response.FailWithMessage("请上传文件", c) + return + } + + // 检查文件大小(最大 10MB) + maxSize := int64(10 * 1024 * 1024) + if file.Size > maxSize { + response.FailWithMessage("文件大小不能超过 10MB", c) + return + } + + global.GVA_LOG.Info("接收到文件上传", + zap.String("filename", file.Filename), + zap.Int64("size", file.Size)) + + // 读取文件内容 + src, err := file.Open() + if err != nil { + global.GVA_LOG.Error("打开文件失败", zap.Error(err)) + response.FailWithMessage("读取文件失败", c) + return + } + defer src.Close() + + // 使用 io.ReadAll 读取完整文件内容 + fileData, err := io.ReadAll(src) + if err != nil { + global.GVA_LOG.Error("读取文件内容失败", zap.Error(err)) + response.FailWithMessage("读取文件内容失败: "+err.Error(), c) + return + } + + global.GVA_LOG.Info("文件读取成功", zap.Int("bytes", len(fileData))) + + // 获取是否公开参数 + isPublic := c.DefaultPostForm("isPublic", "false") == "true" + + // 导入角色卡 + character, err := characterService.ImportCharacter(fileData, file.Filename, userID, isPublic) + if err != nil { + global.GVA_LOG.Error("导入角色卡失败", + zap.Error(err), + zap.String("filename", file.Filename)) + response.FailWithMessage(err.Error(), c) + return + } + + global.GVA_LOG.Info("角色卡导入成功", + zap.Uint("characterId", character.ID), + zap.String("name", character.Name)) + + response.OkWithData(character, c) +} diff --git a/server/api/v1/app/enter.go b/server/api/v1/app/enter.go index e7b797f..64d77ff 100644 --- a/server/api/v1/app/enter.go +++ b/server/api/v1/app/enter.go @@ -1,5 +1,13 @@ package app +import "git.echol.cn/loser/st/server/service" + type ApiGroup struct { AuthApi + CharacterApi } + +var ( + authService = service.ServiceGroupApp.AppServiceGroup.AuthService + characterService = service.ServiceGroupApp.AppServiceGroup.CharacterService +) diff --git a/server/initialize/gorm.go b/server/initialize/gorm.go index dbe96a6..017598b 100644 --- a/server/initialize/gorm.go +++ b/server/initialize/gorm.go @@ -41,8 +41,8 @@ func RegisterTables() { return } - // 初始化 PostgreSQL 扩展(pgvector) - InitPgSQLExtensions() + // 初始化 PostgreSQL 扩展(仅创建 pgvector 扩展) + InitPgSQLExtension() db := global.GVA_DB err := db.AutoMigrate( @@ -100,6 +100,9 @@ func RegisterTables() { os.Exit(0) } + // 创建向量索引(必须在 AutoMigrate 之后) + CreateVectorIndexes() + err = bizModel() if err != nil { diff --git a/server/initialize/gorm_pgsql_extension.go b/server/initialize/gorm_pgsql_extension.go new file mode 100644 index 0000000..ccf4a2b --- /dev/null +++ b/server/initialize/gorm_pgsql_extension.go @@ -0,0 +1,48 @@ +package initialize + +import ( + "git.echol.cn/loser/st/server/global" + "go.uber.org/zap" +) + +// InitPgSQLExtension 初始化 PostgreSQL 扩展(仅创建 pgvector 扩展) +// 必须在 AutoMigrate 之前调用 +func InitPgSQLExtension() { + if global.GVA_CONFIG.System.DbType != "pgsql" { + return + } + + db := global.GVA_DB + + // 安装 pgvector 扩展(用于向量存储) + if err := db.Exec("CREATE EXTENSION IF NOT EXISTS vector").Error; err != nil { + global.GVA_LOG.Error("failed to create pgvector extension", zap.Error(err)) + global.GVA_LOG.Warn("请确保 PostgreSQL 已安装 pgvector 扩展") + } else { + global.GVA_LOG.Info("pgvector extension is ready") + } +} + +// CreateVectorIndexes 创建向量索引 +// 必须在 AutoMigrate 之后调用(确保表已存在) +func CreateVectorIndexes() { + if global.GVA_CONFIG.System.DbType != "pgsql" { + return + } + + db := global.GVA_DB + + // 为 ai_memory_vectors 表创建 HNSW 索引(余弦相似度) + sql := ` + CREATE INDEX IF NOT EXISTS idx_memory_vectors_embedding + ON ai_memory_vectors + USING hnsw (embedding vector_cosine_ops) + ` + + if err := db.Exec(sql).Error; err != nil { + global.GVA_LOG.Error("failed to create vector indexes", zap.Error(err)) + return + } + + global.GVA_LOG.Info("vector indexes created successfully") +} diff --git a/server/initialize/router.go b/server/initialize/router.go index a27a9da..d830b2b 100644 --- a/server/initialize/router.go +++ b/server/initialize/router.go @@ -35,6 +35,10 @@ func (fs justFilesFilesystem) Open(name string) (http.File, error) { func Routers() *gin.Engine { Router := gin.New() + + // 设置文件上传大小限制(10MB) + Router.MaxMultipartMemory = 10 << 20 // 10 MB + // 使用自定义的 Recovery 中间件,记录 panic 并入库 Router.Use(middleware.GinRecovery(true)) if gin.Mode() == gin.DebugMode { @@ -58,6 +62,24 @@ func Routers() *gin.Engine { systemRouter := router.RouterGroupApp.System exampleRouter := router.RouterGroupApp.Example appRouter := router.RouterGroupApp.App // 前台应用路由 + + // 前台用户端静态文件服务(web-app) + // 开发环境:直接使用 web-app/public 目录 + // 生产环境:使用打包后的 web-app/dist 目录 + webAppPath := "../web-app/public" + if _, err := os.Stat(webAppPath); err == nil { + Router.Static("/css", webAppPath+"/css") + Router.Static("/scripts", webAppPath+"/scripts") + Router.Static("/img", webAppPath+"/img") + Router.Static("/fonts", webAppPath+"/fonts") + Router.Static("/webfonts", webAppPath+"/webfonts") + Router.StaticFile("/auth.html", webAppPath+"/auth.html") + Router.StaticFile("/dashboard-example.html", webAppPath+"/dashboard-example.html") + Router.StaticFile("/favicon.ico", webAppPath+"/favicon.ico") + global.GVA_LOG.Info("前台静态文件服务已启动: " + webAppPath) + } + + // 管理后台前端静态文件(web) // 如果想要不使用nginx代理前端网页,可以修改 web/.env.production 下的 // VUE_APP_BASE_API = / // VUE_APP_BASE_PATH = http://localhost @@ -67,10 +89,10 @@ func Routers() *gin.Engine { // Router.StaticFile("/", "./dist/index.html") // 前端网页入口页面 Router.StaticFS(global.GVA_CONFIG.Local.StorePath, justFilesFilesystem{http.Dir(global.GVA_CONFIG.Local.StorePath)}) // Router.Use(middleware.LoadTls()) // 如果需要使用https 请打开此中间件 然后前往 core/server.go 将启动模式 更变为 Router.RunTLS("端口","你的cre/pem文件","你的key文件") - // 跨域,如需跨域可以打开下面的注释 - // Router.Use(middleware.Cors()) // 直接放行全部跨域请求 - // Router.Use(middleware.CorsByRules()) // 按照配置的规则放行跨域请求 - // global.GVA_LOG.Info("use middleware cors") + + // 跨域配置(前台应用需要) + Router.Use(middleware.Cors()) // 直接放行全部跨域请求 + global.GVA_LOG.Info("use middleware cors") docs.SwaggerInfo.BasePath = global.GVA_CONFIG.System.RouterPrefix Router.GET(global.GVA_CONFIG.System.RouterPrefix+"/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) global.GVA_LOG.Info("register swagger handler") @@ -121,8 +143,9 @@ func Routers() *gin.Engine { // 前台应用路由(新增) { - appGroup := PublicGroup.Group("app") // 统一使用 /app 前缀 - appRouter.InitAuthRouter(appGroup) // 认证路由:/app/auth/* 和 /app/user/* + appGroup := PublicGroup.Group("app") // 统一使用 /app 前缀 + appRouter.InitAuthRouter(appGroup) // 认证路由:/app/auth/* 和 /app/user/* + appRouter.InitCharacterRouter(appGroup) // 角色卡路由:/app/character/* } //插件路由安装 diff --git a/server/middleware/app_jwt.go b/server/middleware/app_jwt.go new file mode 100644 index 0000000..cbb4508 --- /dev/null +++ b/server/middleware/app_jwt.go @@ -0,0 +1,147 @@ +package middleware + +import ( + "errors" + "strconv" + + "git.echol.cn/loser/st/server/global" + "git.echol.cn/loser/st/server/model/app" + "git.echol.cn/loser/st/server/model/common/response" + "git.echol.cn/loser/st/server/utils" + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" +) + +// AppJWTAuth 前台用户 JWT 认证中间件 +func AppJWTAuth() gin.HandlerFunc { + return func(c *gin.Context) { + token := GetToken(c) + if token == "" { + response.FailWithDetailed(gin.H{"reload": true}, "未登录或非法访问", c) + c.Abort() + return + } + + // 解析 JWT + claims, err := utils.ParseAppToken(token) + if err != nil { + if errors.Is(err, jwt.ErrTokenExpired) { + response.FailWithDetailed(gin.H{"reload": true}, "Token 已过期", c) + } else { + response.FailWithDetailed(gin.H{"reload": true}, "Token 无效", c) + } + c.Abort() + return + } + + // 验证用户类型(确保是前台用户) + if claims.UserType != utils.UserTypeApp { + response.FailWithMessage("无效的用户类型", c) + c.Abort() + return + } + + // 查询用户是否存在 + var user app.AppUser + err = global.GVA_DB.Where("id = ?", claims.UserID).First(&user).Error + if err != nil { + response.FailWithMessage("用户不存在", c) + c.Abort() + return + } + + // 检查用户状态 + if !user.Enable { + response.FailWithMessage("用户已被禁用", c) + c.Abort() + return + } + + if user.Status != "active" { + response.FailWithMessage("账户状态异常", c) + c.Abort() + return + } + + // 将用户信息存入上下文 + c.Set("appUserId", user.ID) + c.Set("appUser", &user) + c.Set("appUsername", user.Username) + + c.Next() + } +} + +// GetAppUserID 从上下文获取前台用户 ID(需要鉴权的接口使用) +func GetAppUserID(c *gin.Context) uint { + if userID, exists := c.Get("appUserId"); exists { + return userID.(uint) + } + return 0 +} + +// GetOptionalAppUserID 从上下文获取可选的前台用户 ID(公开接口使用) +// 如果用户已登录,返回用户 ID;否则返回 nil +func GetOptionalAppUserID(c *gin.Context) *uint { + // 先尝试从上下文获取(通过鉴权中间件设置) + if userID, exists := c.Get("appUserId"); exists { + if id, ok := userID.(uint); ok { + return &id + } + } + + // 如果上下文中没有,尝试手动解析 Token(用于公开接口) + token := GetToken(c) + if token == "" { + return nil + } + + claims, err := utils.ParseAppToken(token) + if err != nil { + return nil + } + + if claims.UserType != utils.UserTypeApp { + return nil + } + + return &claims.UserID +} + +// GetAppUser 从上下文获取前台用户信息 +func GetAppUser(c *gin.Context) *app.AppUser { + if user, exists := c.Get("appUser"); exists { + return user.(*app.AppUser) + } + return nil +} + +// GetAppUsername 从上下文获取前台用户名 +func GetAppUsername(c *gin.Context) string { + if username, exists := c.Get("appUsername"); exists { + return username.(string) + } + return "" +} + +// GetToken 从请求中获取 Token +// 优先从 Header 获取,其次从 Query 参数获取 +func GetToken(c *gin.Context) string { + token := c.Request.Header.Get("x-token") + if token == "" { + token = c.Request.Header.Get("Authorization") + if token != "" && len(token) > 7 && token[:7] == "Bearer " { + token = token[7:] + } + } + if token == "" { + token = c.Query("token") + } + return token +} + +// SetAppUserID 设置用户 ID 到上下文(用于某些特殊场景) +func SetAppUserID(c *gin.Context, userID uint) { + c.Set("appUserId", userID) + c.Set("appUserIdStr", strconv.Itoa(int(userID))) +} diff --git a/server/model/app/README.md b/server/model/app/README.md new file mode 100644 index 0000000..a387e55 --- /dev/null +++ b/server/model/app/README.md @@ -0,0 +1,213 @@ +# App 前台应用数据模型 + +## 📋 模型列表 + +本目录包含所有前台用户应用相关的数据模型,与管理后台的 `system` 模块完全独立。 + +### 1. 用户相关模型 + +| 文件 | 模型 | 表名 | 说明 | +|------|------|------|------| +| `app_user.go` | `AppUser` | `app_users` | 前台用户表 | +| `app_user_session.go` | `AppUserSession` | `app_user_sessions` | 用户会话表 | + +### 2. AI 角色相关模型 + +| 文件 | 模型 | 表名 | 说明 | +|------|------|------|------| +| `ai_character.go` | `AICharacter` | `ai_characters` | AI 角色表 | +| `ai_character.go` | `AppUserFavoriteCharacter` | `app_user_favorite_characters` | 用户收藏角色表 | + +### 3. 对话相关模型 + +| 文件 | 模型 | 表名 | 说明 | +|------|------|------|------| +| `ai_chat.go` | `AIChat` | `ai_chats` | 对话表 | +| `ai_chat.go` | `AIChatMember` | `ai_chat_members` | 群聊成员表 | +| `ai_message.go` | `AIMessage` | `ai_messages` | 消息表 | +| `ai_message.go` | `AIMessageSwipe` | `ai_message_swipes` | 消息变体表 | + +### 4. 向量记忆模型 + +| 文件 | 模型 | 表名 | 说明 | +|------|------|------|------| +| `ai_memory.go` | `AIMemoryVector` | `ai_memory_vectors` | 向量记忆表(使用 pgvector) | + +### 5. AI 服务配置模型 + +| 文件 | 模型 | 表名 | 说明 | +|------|------|------|------| +| `ai_provider.go` | `AIProvider` | `ai_providers` | AI 提供商配置表 | +| `ai_provider.go` | `AIModel` | `ai_models` | AI 模型配置表 | + +### 6. 文件管理模型 + +| 文件 | 模型 | 表名 | 说明 | +|------|------|------|------| +| `ai_file.go` | `AIFile` | `ai_files` | 文件表 | + +### 7. 其他模型 + +| 文件 | 模型 | 表名 | 说明 | +|------|------|------|------| +| `ai_preset.go` | `AIPreset` | `ai_presets` | 对话预设表 | +| `ai_world_info.go` | `AIWorldInfo` | `ai_world_info` | 世界书表 | +| `ai_usage_stat.go` | `AIUsageStat` | `ai_usage_stats` | 使用统计表 | + +## 🔧 使用说明 + +### 1. 数据库自动迁移 + +所有模型已在 `initialize/gorm.go` 中注册,启动服务时会自动创建表: + +```go +// 在 RegisterTables() 函数中已注册 +app.AppUser{}, +app.AppUserSession{}, +app.AICharacter{}, +app.AppUserFavoriteCharacter{}, +app.AIChat{}, +app.AIChatMember{}, +app.AIMessage{}, +app.AIMessageSwipe{}, +app.AIMemoryVector{}, +app.AIProvider{}, +app.AIModel{}, +app.AIFile{}, +app.AIPreset{}, +app.AIWorldInfo{}, +app.AIUsageStat{}, +``` + +### 2. PostgreSQL 向量扩展 + +向量记忆功能依赖 `pgvector` 扩展,已在 `initialize/gorm_pgsql_extension.go` 中自动安装: + +```sql +CREATE EXTENSION IF NOT EXISTS vector; +CREATE INDEX idx_memory_vectors_embedding ON ai_memory_vectors +USING hnsw (embedding vector_cosine_ops); +``` + +### 3. 外键关系 + +模型之间的关系已通过 GORM 标签定义: + +- `AppUser` ← `AppUserSession`(一对多) +- `AppUser` ← `AICharacter`(一对多,创建者) +- `AppUser` ← `AIChat`(一对多) +- `AppUser` ← `AppUserFavoriteCharacter`(多对多,通过中间表) +- `AICharacter` ← `AppUserFavoriteCharacter`(多对多,通过中间表) +- `AICharacter` ← `AIChat`(一对多) +- `AIChat` ← `AIMessage`(一对多) +- `AIChat` ← `AIChatMember`(多对多,通过中间表) +- `AICharacter` ← `AIChatMember`(多对多,通过中间表) +- `AIMessage` ← `AIMessageSwipe`(一对多) +- `AIProvider` ← `AIModel`(一对多) + +### 4. JSONB 字段 + +以下字段使用 PostgreSQL 的 JSONB 类型: + +- `AppUser.AISettings` - AI 相关配置 +- `AppUser.Preferences` - 用户偏好设置 +- `AICharacter.CardData` - 角色卡片数据 +- `AICharacter.Tags` - 角色标签 +- `AICharacter.ExampleMessages` - 消息示例 +- `AIChat.Settings` - 对话设置 +- `AIMessage.GenerationParams` - AI 生成参数 +- `AIMessage.Metadata` - 消息元数据 +- `AIMemoryVector.Metadata` - 记忆元数据 +- `AIProvider.APIConfig` - API 配置 +- `AIModel.Config` - 模型配置 +- `AIFile.RelatedTo` - 文件关联对象 +- `AIFile.Metadata` - 文件元数据 +- `AIPreset.Config` - 预设配置 +- `AIWorldInfo.TriggerConfig` - 触发条件配置 + +### 5. 向量字段 + +`AIMemoryVector.Embedding` 使用 `pgvector.Vector` 类型,维度为 1536(OpenAI text-embedding-ada-002)。 + +## ⚠️ 注意事项 + +1. **不要修改 system 包**:所有管理后台相关的模型在 `model/system/` 包中,**不要修改** +2. **表名前缀**: + - 前台用户相关:`app_*` + - AI 功能相关:`ai_*` + - 系统管理相关:`sys_*`(不修改) +3. **UUID 生成**:`AppUser.UUID` 使用数据库自动生成(PostgreSQL 的 `gen_random_uuid()`) +4. **软删除**:所有模型继承 `global.GVA_MODEL`,自动支持软删除 +5. **时间字段**:`CreatedAt`、`UpdatedAt`、`DeletedAt` 由 GORM 自动管理 + +## 📊 ER 图关系 + +``` +AppUser (前台用户) + ├── AppUserSession (会话) + ├── AICharacter (创建的角色) + ├── AIChat (对话) + ├── AppUserFavoriteCharacter (收藏的角色) + ├── AIMemoryVector (记忆) + ├── AIProvider (AI 提供商配置) + ├── AIFile (文件) + ├── AIPreset (预设) + ├── AIWorldInfo (世界书) + └── AIUsageStat (使用统计) + +AICharacter (AI 角色) + ├── AIChat (对话) + ├── AIChatMember (群聊成员) + ├── AppUserFavoriteCharacter (被收藏) + └── AIMemoryVector (记忆) + +AIChat (对话) + ├── AIMessage (消息) + ├── AIChatMember (群聊成员) + └── AIMemoryVector (记忆) + +AIMessage (消息) + └── AIMessageSwipe (消息变体) + +AIProvider (AI 提供商) + └── AIModel (AI 模型) +``` + +## 🚀 快速开始 + +1. 确保 PostgreSQL 已安装 pgvector 扩展 +2. 配置 `config.yaml` 中的数据库连接 +3. 启动服务,AutoMigrate 会自动创建所有表 +4. 检查日志确认表创建成功 + +```bash +# 启动服务 +go run main.go + +# 查看日志 +# [GVA] pgvector extension is ready +# [GVA] vector indexes created successfully +# [GVA] register table success +``` + +## 📝 开发建议 + +1. 查询时使用预加载避免 N+1 问题: + ```go + db.Preload("User").Preload("Character").Find(&chats) + ``` + +2. 向量搜索示例: + ```go + db.Order("embedding <=> ?", queryVector).Limit(10).Find(&memories) + ``` + +3. JSONB 查询示例: + ```go + db.Where("ai_settings->>'model' = ?", "gpt-4").Find(&users) + ``` + +--- + +**创建日期**: 2026-02-10 +**维护者**: 开发团队 diff --git a/server/model/app/ai_character.go b/server/model/app/ai_character.go new file mode 100644 index 0000000..b4c2e09 --- /dev/null +++ b/server/model/app/ai_character.go @@ -0,0 +1,49 @@ +package app + +import ( + "git.echol.cn/loser/st/server/global" + "github.com/lib/pq" + "gorm.io/datatypes" +) + +// AICharacter AI 角色表 +type AICharacter struct { + global.GVA_MODEL + Name string `json:"name" gorm:"type:varchar(500);not null;comment:角色名称"` + Description string `json:"description" gorm:"type:text;comment:角色描述"` + Personality string `json:"personality" gorm:"type:text;comment:角色性格"` + Scenario string `json:"scenario" gorm:"type:text;comment:角色场景"` + Avatar string `json:"avatar" gorm:"type:varchar(1024);comment:角色头像"` + CreatorID *uint `json:"creatorId" gorm:"index;comment:创建者ID"` + Creator *AppUser `json:"creator" gorm:"foreignKey:CreatorID"` + CreatorName string `json:"creatorName" gorm:"type:varchar(200);comment:创建者名称"` + CreatorNotes string `json:"creatorNotes" gorm:"type:text;comment:创建者备注"` + CardData datatypes.JSON `json:"cardData" gorm:"type:jsonb;not null;comment:角色卡片数据"` + Tags pq.StringArray `json:"tags" gorm:"type:text[];comment:角色标签"` + IsPublic bool `json:"isPublic" gorm:"default:false;index;comment:是否公开"` + Version int `json:"version" gorm:"default:1;comment:角色版本"` + FirstMessage string `json:"firstMessage" gorm:"type:text;comment:第一条消息"` + ExampleMessages pq.StringArray `json:"exampleMessages" gorm:"type:text[];comment:消息示例"` + TotalChats int `json:"totalChats" gorm:"default:0;comment:对话总数"` + TotalLikes int `json:"totalLikes" gorm:"default:0;comment:点赞总数"` + UsageCount int `json:"usageCount" gorm:"default:0;comment:使用次数"` + FavoriteCount int `json:"favoriteCount" gorm:"default:0;comment:收藏次数"` + TokenCount int `json:"tokenCount" gorm:"default:0;comment:Token数量"` +} + +func (AICharacter) TableName() string { + return "ai_characters" +} + +// AppUserFavoriteCharacter 用户收藏的角色 +type AppUserFavoriteCharacter struct { + global.GVA_MODEL + UserID uint `json:"userId" gorm:"not null;index:idx_user_character,unique;comment:用户ID"` + User *AppUser `json:"user" gorm:"foreignKey:UserID"` + CharacterID uint `json:"characterId" gorm:"not null;index:idx_user_character,unique;comment:角色ID"` + Character *AICharacter `json:"character" gorm:"foreignKey:CharacterID"` +} + +func (AppUserFavoriteCharacter) TableName() string { + return "app_user_favorite_characters" +} diff --git a/server/model/app/ai_chat.go b/server/model/app/ai_chat.go new file mode 100644 index 0000000..8a7c222 --- /dev/null +++ b/server/model/app/ai_chat.go @@ -0,0 +1,41 @@ +package app + +import ( + "git.echol.cn/loser/st/server/global" + "gorm.io/datatypes" + "time" +) + +// AIChat 对话表 +type AIChat struct { + global.GVA_MODEL + Title string `json:"title" gorm:"type:varchar(500);default:新对话;comment:对话标题"` + UserID uint `json:"userId" gorm:"not null;index;comment:所属用户ID"` + User *AppUser `json:"user" gorm:"foreignKey:UserID"` + CharacterID *uint `json:"characterId" gorm:"index;comment:关联角色ID"` + Character *AICharacter `json:"character" gorm:"foreignKey:CharacterID"` + ChatType string `json:"chatType" gorm:"type:varchar(50);default:single;comment:对话类型"` + Settings datatypes.JSON `json:"settings" gorm:"type:jsonb;comment:对话设置"` + LastMessageAt *time.Time `json:"lastMessageAt" gorm:"index;comment:最后一条消息时间"` + MessageCount int `json:"messageCount" gorm:"default:0;comment:消息数量"` + IsPinned bool `json:"isPinned" gorm:"default:false;comment:是否固定"` +} + +func (AIChat) TableName() string { + return "ai_chats" +} + +// AIChatMember 群聊成员表 +type AIChatMember struct { + global.GVA_MODEL + ChatID uint `json:"chatId" gorm:"not null;index:idx_chat_character,unique;comment:对话ID"` + Chat *AIChat `json:"chat" gorm:"foreignKey:ChatID"` + CharacterID uint `json:"characterId" gorm:"not null;index:idx_chat_character,unique;comment:角色ID"` + Character *AICharacter `json:"character" gorm:"foreignKey:CharacterID"` + DisplayOrder int `json:"displayOrder" gorm:"default:0;comment:显示顺序"` + Settings datatypes.JSON `json:"settings" gorm:"type:jsonb;comment:成员设置"` +} + +func (AIChatMember) TableName() string { + return "ai_chat_members" +} diff --git a/server/model/app/ai_file.go b/server/model/app/ai_file.go new file mode 100644 index 0000000..1aecee0 --- /dev/null +++ b/server/model/app/ai_file.go @@ -0,0 +1,26 @@ +package app + +import ( + "git.echol.cn/loser/st/server/global" + "gorm.io/datatypes" +) + +// AIFile 文件表 +type AIFile struct { + global.GVA_MODEL + UserID uint `json:"userId" gorm:"not null;index;comment:上传者ID"` + User *AppUser `json:"user" gorm:"foreignKey:UserID"` + Filename string `json:"filename" gorm:"type:varchar(500);not null;comment:文件名"` + OriginalFilename string `json:"originalFilename" gorm:"type:varchar(500);not null;comment:原始文件名"` + FileType string `json:"fileType" gorm:"type:varchar(100);not null;index;comment:文件类型"` + MimeType string `json:"mimeType" gorm:"type:varchar(200);comment:MIME类型"` + FileSize int64 `json:"fileSize" gorm:"comment:文件大小(字节)"` + StoragePath string `json:"storagePath" gorm:"type:varchar(1024);not null;comment:存储路径"` + URL string `json:"url" gorm:"type:varchar(1024);comment:对象存储URL"` + RelatedTo datatypes.JSON `json:"relatedTo" gorm:"type:jsonb;comment:关联对象"` + Metadata datatypes.JSON `json:"metadata" gorm:"type:jsonb;comment:元数据"` +} + +func (AIFile) TableName() string { + return "ai_files" +} diff --git a/server/model/app/ai_memory.go b/server/model/app/ai_memory.go new file mode 100644 index 0000000..09c888c --- /dev/null +++ b/server/model/app/ai_memory.go @@ -0,0 +1,26 @@ +package app + +import ( + "git.echol.cn/loser/st/server/global" + "github.com/pgvector/pgvector-go" + "gorm.io/datatypes" +) + +// AIMemoryVector 向量记忆表(使用 pgvector) +type AIMemoryVector struct { + global.GVA_MODEL + UserID uint `json:"userId" gorm:"not null;index;comment:所属用户ID"` + User *AppUser `json:"user" gorm:"foreignKey:UserID"` + CharacterID *uint `json:"characterId" gorm:"index;comment:所属角色ID"` + Character *AICharacter `json:"character" gorm:"foreignKey:CharacterID"` + ChatID *uint `json:"chatId" gorm:"index;comment:所属对话ID"` + Chat *AIChat `json:"chat" gorm:"foreignKey:ChatID"` + Content string `json:"content" gorm:"type:text;not null;comment:文本内容"` + Embedding pgvector.Vector `json:"-" gorm:"type:vector(1536);comment:向量嵌入"` + Metadata datatypes.JSON `json:"metadata" gorm:"type:jsonb;comment:元数据"` + Importance float64 `json:"importance" gorm:"default:0.5;comment:重要性评分"` +} + +func (AIMemoryVector) TableName() string { + return "ai_memory_vectors" +} diff --git a/server/model/app/ai_message.go b/server/model/app/ai_message.go new file mode 100644 index 0000000..1e0045b --- /dev/null +++ b/server/model/app/ai_message.go @@ -0,0 +1,46 @@ +package app + +import ( + "git.echol.cn/loser/st/server/global" + "gorm.io/datatypes" +) + +// AIMessage 消息表 +type AIMessage struct { + global.GVA_MODEL + ChatID uint `json:"chatId" gorm:"not null;index:idx_chat_sequence;comment:所属对话ID"` + Chat *AIChat `json:"chat" gorm:"foreignKey:ChatID"` + Content string `json:"content" gorm:"type:text;not null;comment:消息内容"` + Role string `json:"role" gorm:"type:varchar(50);not null;comment:发送者类型"` + SenderID *uint `json:"senderId" gorm:"comment:发送者ID"` + Sender *AppUser `json:"sender" gorm:"foreignKey:SenderID"` + CharacterID *uint `json:"characterId" gorm:"comment:AI角色ID"` + Character *AICharacter `json:"character" gorm:"foreignKey:CharacterID"` + SequenceNumber int `json:"sequenceNumber" gorm:"not null;index:idx_chat_sequence;comment:消息序号"` + Model string `json:"model" gorm:"type:varchar(200);comment:AI模型"` + PromptTokens int `json:"promptTokens" gorm:"default:0;comment:提示词Token数"` + CompletionTokens int `json:"completionTokens" gorm:"default:0;comment:补全Token数"` + TotalTokens int `json:"totalTokens" gorm:"default:0;comment:总Token数"` + GenerationParams datatypes.JSON `json:"generationParams" gorm:"type:jsonb;comment:生成参数"` + Metadata datatypes.JSON `json:"metadata" gorm:"type:jsonb;comment:消息元数据"` + IsDeleted bool `json:"isDeleted" gorm:"default:false;comment:是否被删除"` +} + +func (AIMessage) TableName() string { + return "ai_messages" +} + +// AIMessageSwipe 消息变体表(swipe 功能) +type AIMessageSwipe struct { + global.GVA_MODEL + MessageID uint `json:"messageId" gorm:"not null;index:idx_message_swipe,unique;comment:消息ID"` + Message *AIMessage `json:"message" gorm:"foreignKey:MessageID"` + Content string `json:"content" gorm:"type:text;not null;comment:变体内容"` + SwipeIndex int `json:"swipeIndex" gorm:"not null;index:idx_message_swipe,unique;comment:变体序号"` + IsActive bool `json:"isActive" gorm:"default:false;comment:是否为当前选中"` + GenerationParams datatypes.JSON `json:"generationParams" gorm:"type:jsonb;comment:生成参数"` +} + +func (AIMessageSwipe) TableName() string { + return "ai_message_swipes" +} diff --git a/server/model/app/ai_preset.go b/server/model/app/ai_preset.go new file mode 100644 index 0000000..da648a5 --- /dev/null +++ b/server/model/app/ai_preset.go @@ -0,0 +1,22 @@ +package app + +import ( + "git.echol.cn/loser/st/server/global" + "gorm.io/datatypes" +) + +// AIPreset 对话预设表 +type AIPreset struct { + global.GVA_MODEL + Name string `json:"name" gorm:"type:varchar(200);not null;comment:预设名称"` + UserID *uint `json:"userId" gorm:"index;comment:所属用户ID(NULL表示系统预设)"` + User *AppUser `json:"user" gorm:"foreignKey:UserID"` + PresetType string `json:"presetType" gorm:"type:varchar(100);not null;index;comment:预设类型"` + Config datatypes.JSON `json:"config" gorm:"type:jsonb;not null;comment:预设配置"` + IsSystem bool `json:"isSystem" gorm:"default:false;comment:是否为系统预设"` + IsDefault bool `json:"isDefault" gorm:"default:false;comment:是否为默认预设"` +} + +func (AIPreset) TableName() string { + return "ai_presets" +} diff --git a/server/model/app/ai_provider.go b/server/model/app/ai_provider.go new file mode 100644 index 0000000..3e85dbd --- /dev/null +++ b/server/model/app/ai_provider.go @@ -0,0 +1,36 @@ +package app + +import ( + "git.echol.cn/loser/st/server/global" + "gorm.io/datatypes" +) + +// AIProvider AI 服务提供商配置 +type AIProvider struct { + global.GVA_MODEL + UserID *uint `json:"userId" gorm:"index;comment:用户ID(NULL表示系统配置)"` + User *AppUser `json:"user" gorm:"foreignKey:UserID"` + ProviderName string `json:"providerName" gorm:"type:varchar(100);not null;index;comment:提供商名称"` + APIConfig datatypes.JSON `json:"apiConfig" gorm:"type:jsonb;not null;comment:API配置(加密存储)"` + IsEnabled bool `json:"isEnabled" gorm:"default:true;comment:是否启用"` + IsDefault bool `json:"isDefault" gorm:"default:false;comment:是否为默认提供商"` +} + +func (AIProvider) TableName() string { + return "ai_providers" +} + +// AIModel AI 模型配置 +type AIModel struct { + global.GVA_MODEL + ProviderID uint `json:"providerId" gorm:"not null;index;comment:提供商ID"` + Provider *AIProvider `json:"provider" gorm:"foreignKey:ProviderID"` + ModelName string `json:"modelName" gorm:"type:varchar(200);not null;comment:模型名称"` + DisplayName string `json:"displayName" gorm:"type:varchar(200);comment:模型显示名称"` + Config datatypes.JSON `json:"config" gorm:"type:jsonb;comment:模型参数配置"` + IsEnabled bool `json:"isEnabled" gorm:"default:true;comment:是否启用"` +} + +func (AIModel) TableName() string { + return "ai_models" +} diff --git a/server/model/app/ai_usage_stat.go b/server/model/app/ai_usage_stat.go new file mode 100644 index 0000000..2c6b86c --- /dev/null +++ b/server/model/app/ai_usage_stat.go @@ -0,0 +1,25 @@ +package app + +import ( + "git.echol.cn/loser/st/server/global" + "time" +) + +// AIUsageStat AI 使用统计表 +type AIUsageStat struct { + global.GVA_MODEL + UserID uint `json:"userId" gorm:"not null;index:idx_user_stat,unique;comment:用户ID"` + User *AppUser `json:"user" gorm:"foreignKey:UserID"` + StatDate time.Time `json:"statDate" gorm:"type:date;not null;index:idx_user_stat,unique;comment:统计日期"` + ProviderName string `json:"providerName" gorm:"type:varchar(100);index:idx_user_stat,unique;comment:AI提供商"` + ModelName string `json:"modelName" gorm:"type:varchar(200);index:idx_user_stat,unique;comment:模型名称"` + RequestCount int `json:"requestCount" gorm:"default:0;comment:请求次数"` + TotalTokens int64 `json:"totalTokens" gorm:"default:0;comment:总Token使用量"` + PromptTokens int64 `json:"promptTokens" gorm:"default:0;comment:提示词Token使用量"` + CompletionTokens int64 `json:"completionTokens" gorm:"default:0;comment:补全Token使用量"` + Cost float64 `json:"cost" gorm:"type:decimal(10,4);default:0;comment:费用"` +} + +func (AIUsageStat) TableName() string { + return "ai_usage_stats" +} diff --git a/server/model/app/ai_world_info.go b/server/model/app/ai_world_info.go new file mode 100644 index 0000000..feb7191 --- /dev/null +++ b/server/model/app/ai_world_info.go @@ -0,0 +1,26 @@ +package app + +import ( + "git.echol.cn/loser/st/server/global" + "github.com/lib/pq" + "gorm.io/datatypes" +) + +// AIWorldInfo 世界书(World Info)表 +type AIWorldInfo struct { + global.GVA_MODEL + UserID uint `json:"userId" gorm:"not null;index;comment:所属用户ID"` + User *AppUser `json:"user" gorm:"foreignKey:UserID"` + CharacterID *uint `json:"characterId" gorm:"index;comment:关联角色ID"` + Character *AICharacter `json:"character" gorm:"foreignKey:CharacterID"` + Name string `json:"name" gorm:"type:varchar(500);not null;comment:世界书名称"` + Keywords pq.StringArray `json:"keywords" gorm:"type:text[];comment:触发关键词"` + Content string `json:"content" gorm:"type:text;not null;comment:内容"` + Priority int `json:"priority" gorm:"default:0;comment:优先级"` + IsEnabled bool `json:"isEnabled" gorm:"default:true;comment:是否启用"` + TriggerConfig datatypes.JSON `json:"triggerConfig" gorm:"type:jsonb;comment:触发条件配置"` +} + +func (AIWorldInfo) TableName() string { + return "ai_world_info" +} diff --git a/server/model/app/app_user.go b/server/model/app/app_user.go new file mode 100644 index 0000000..8b82844 --- /dev/null +++ b/server/model/app/app_user.go @@ -0,0 +1,31 @@ +package app + +import ( + "git.echol.cn/loser/st/server/global" + "gorm.io/datatypes" + "time" +) + +// AppUser 前台用户模型(与 sys_users 独立) +type AppUser struct { + global.GVA_MODEL + UUID string `json:"uuid" gorm:"type:uuid;uniqueIndex;comment:用户UUID"` + Username string `json:"username" gorm:"uniqueIndex;comment:用户登录名"` + Password string `json:"-" gorm:"comment:用户登录密码"` + NickName string `json:"nickName" gorm:"comment:用户昵称"` + Email string `json:"email" gorm:"index;comment:用户邮箱"` + Phone string `json:"phone" gorm:"comment:用户手机号"` + Avatar string `json:"avatar" gorm:"type:varchar(1024);comment:用户头像"` + Status string `json:"status" gorm:"type:varchar(50);default:active;comment:账户状态"` + Enable bool `json:"enable" gorm:"default:true;comment:用户是否启用"` + LastLoginAt *time.Time `json:"lastLoginAt" gorm:"comment:最后登录时间"` + LastLoginIP string `json:"lastLoginIp" gorm:"type:varchar(100);comment:最后登录IP"` + AISettings datatypes.JSON `json:"aiSettings" gorm:"type:jsonb;comment:AI配置"` + Preferences datatypes.JSON `json:"preferences" gorm:"type:jsonb;comment:用户偏好"` + ChatCount int `json:"chatCount" gorm:"default:0;comment:对话数量"` + MessageCount int `json:"messageCount" gorm:"default:0;comment:消息数量"` +} + +func (AppUser) TableName() string { + return "app_users" +} diff --git a/server/model/app/app_user_session.go b/server/model/app/app_user_session.go new file mode 100644 index 0000000..64de52f --- /dev/null +++ b/server/model/app/app_user_session.go @@ -0,0 +1,24 @@ +package app + +import ( + "git.echol.cn/loser/st/server/global" + "gorm.io/datatypes" + "time" +) + +// AppUserSession 前台用户会话 +type AppUserSession struct { + global.GVA_MODEL + UserID uint `json:"userId" gorm:"index;comment:用户ID"` + SessionToken string `json:"sessionToken" gorm:"type:varchar(500);uniqueIndex;comment:会话Token"` + RefreshToken string `json:"refreshToken" gorm:"type:varchar(500);comment:刷新Token"` + ExpiresAt time.Time `json:"expiresAt" gorm:"index;comment:过期时间"` + RefreshExpiresAt *time.Time `json:"refreshExpiresAt" gorm:"comment:刷新Token过期时间"` + IPAddress string `json:"ipAddress" gorm:"type:varchar(100);comment:IP地址"` + UserAgent string `json:"userAgent" gorm:"type:text;comment:用户代理"` + DeviceInfo datatypes.JSON `json:"deviceInfo" gorm:"type:jsonb;comment:设备信息"` +} + +func (AppUserSession) TableName() string { + return "app_user_sessions" +} diff --git a/server/model/app/request/auth.go b/server/model/app/request/auth.go new file mode 100644 index 0000000..d718f77 --- /dev/null +++ b/server/model/app/request/auth.go @@ -0,0 +1,37 @@ +package request + +// RegisterRequest 用户注册请求 +type RegisterRequest struct { + Username string `json:"username" binding:"required,min=3,max=32"` + Password string `json:"password" binding:"required,min=6,max=32"` + NickName string `json:"nickName" binding:"max=50"` + Email string `json:"email" binding:"omitempty,email"` + Phone string `json:"phone" binding:"omitempty"` +} + +// LoginRequest 用户登录请求 +type LoginRequest struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` +} + +// RefreshTokenRequest 刷新 Token 请求 +type RefreshTokenRequest struct { + RefreshToken string `json:"refreshToken" binding:"required"` +} + +// ChangePasswordRequest 修改密码请求 +type ChangePasswordRequest struct { + OldPassword string `json:"oldPassword" binding:"required"` + NewPassword string `json:"newPassword" binding:"required,min=6,max=32"` +} + +// UpdateProfileRequest 更新用户信息请求 +type UpdateProfileRequest struct { + NickName string `json:"nickName" binding:"max=50"` + Email string `json:"email" binding:"omitempty,email"` + Phone string `json:"phone"` + Avatar string `json:"avatar"` + Preferences string `json:"preferences"` // JSON 字符串 + AISettings string `json:"aiSettings"` // JSON 字符串 +} diff --git a/server/model/app/request/character.go b/server/model/app/request/character.go new file mode 100644 index 0000000..eea6a96 --- /dev/null +++ b/server/model/app/request/character.go @@ -0,0 +1,54 @@ +package request + +import "mime/multipart" + +// CreateCharacterRequest 创建角色卡请求 +type CreateCharacterRequest struct { + Name string `json:"name" binding:"required,min=1,max=500"` + Description string `json:"description"` + Personality string `json:"personality"` + Scenario string `json:"scenario"` + Avatar string `json:"avatar"` + CreatorName string `json:"creatorName"` + CreatorNotes string `json:"creatorNotes"` + FirstMessage string `json:"firstMessage"` + ExampleMessages []string `json:"exampleMessages"` + Tags []string `json:"tags"` + IsPublic bool `json:"isPublic"` +} + +// UpdateCharacterRequest 更新角色卡请求 +type UpdateCharacterRequest struct { + ID uint `json:"id" binding:"required"` + Name string `json:"name" binding:"required,min=1,max=500"` + Description string `json:"description"` + Personality string `json:"personality"` + Scenario string `json:"scenario"` + Avatar string `json:"avatar"` + CreatorName string `json:"creatorName"` + CreatorNotes string `json:"creatorNotes"` + FirstMessage string `json:"firstMessage"` + ExampleMessages []string `json:"exampleMessages"` + Tags []string `json:"tags"` + IsPublic bool `json:"isPublic"` +} + +// CharacterListRequest 角色卡列表请求 +type CharacterListRequest struct { + Page int `form:"page" binding:"min=1"` + PageSize int `form:"pageSize" binding:"min=1,max=100"` + Keyword string `form:"keyword"` + Tags []string `form:"tags"` + SortBy string `form:"sortBy"` // newest, popular, mostChats, mostLikes +} + +// ImportCharacterRequest 导入角色卡请求 +type ImportCharacterRequest struct { + File *multipart.FileHeader `form:"file" binding:"required"` + IsPublic bool `form:"isPublic"` +} + +// CharacterActionRequest 角色卡操作请求(点赞、收藏等) +type CharacterActionRequest struct { + CharacterID uint `json:"characterId" binding:"required"` +} diff --git a/server/model/app/response/auth.go b/server/model/app/response/auth.go new file mode 100644 index 0000000..361c92b --- /dev/null +++ b/server/model/app/response/auth.go @@ -0,0 +1,54 @@ +package response + +import ( + "git.echol.cn/loser/st/server/model/app" + "time" +) + +// LoginResponse 登录响应 +type LoginResponse struct { + User AppUserResponse `json:"user"` + Token string `json:"token"` + RefreshToken string `json:"refreshToken"` + ExpiresAt int64 `json:"expiresAt"` // Unix 时间戳 +} + +// AppUserResponse 用户信息响应(不包含密码) +type AppUserResponse struct { + ID uint `json:"id"` + UUID string `json:"uuid"` + Username string `json:"username"` + NickName string `json:"nickName"` + Email string `json:"email"` + Phone string `json:"phone"` + Avatar string `json:"avatar"` + Status string `json:"status"` + Enable bool `json:"enable"` + LastLoginAt *time.Time `json:"lastLoginAt"` + ChatCount int `json:"chatCount"` + MessageCount int `json:"messageCount"` + AISettings interface{} `json:"aiSettings"` + Preferences interface{} `json:"preferences"` + CreatedAt time.Time `json:"createdAt"` +} + +// ToAppUserResponse 将 AppUser 转换为 AppUserResponse +func ToAppUserResponse(user *app.AppUser) AppUserResponse { + return AppUserResponse{ + ID: user.ID, + UUID: user.UUID, + Username: user.Username, + NickName: user.NickName, + Email: user.Email, + Phone: user.Phone, + Avatar: user.Avatar, + Status: user.Status, + Enable: user.Enable, + LastLoginAt: user.LastLoginAt, + ChatCount: user.ChatCount, + MessageCount: user.MessageCount, + AISettings: user.AISettings, + Preferences: user.Preferences, + CreatedAt: user.CreatedAt, + } +} diff --git a/server/model/app/response/character.go b/server/model/app/response/character.go new file mode 100644 index 0000000..07beb36 --- /dev/null +++ b/server/model/app/response/character.go @@ -0,0 +1,79 @@ +package response + +import ( + "git.echol.cn/loser/st/server/model/app" + "time" +) + +// CharacterResponse 角色卡响应 +type CharacterResponse struct { + ID uint `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Personality string `json:"personality"` + Scenario string `json:"scenario"` + Avatar string `json:"avatar"` + CreatorID *uint `json:"creatorId"` + CreatorName string `json:"creatorName"` + CreatorNotes string `json:"creatorNotes"` + Tags []string `json:"tags"` + IsPublic bool `json:"isPublic"` + Version int `json:"version"` + FirstMessage string `json:"firstMessage"` + ExampleMessages []string `json:"exampleMessages"` + TotalChats int `json:"totalChats"` + TotalLikes int `json:"totalLikes"` + UsageCount int `json:"usageCount"` + FavoriteCount int `json:"favoriteCount"` + TokenCount int `json:"tokenCount"` + IsFavorited bool `json:"isFavorited"` // 当前用户是否收藏 + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// CharacterListResponse 角色卡列表响应 +type CharacterListResponse struct { + List []CharacterResponse `json:"list"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"pageSize"` +} + +// ToCharacterResponse 转换为角色卡响应 +func ToCharacterResponse(character *app.AICharacter, isFavorited bool) CharacterResponse { + // pq.StringArray 可以直接赋值给 []string + tags := []string{} + if character.Tags != nil { + tags = character.Tags + } + + exampleMessages := []string{} + if character.ExampleMessages != nil { + exampleMessages = character.ExampleMessages + } + + return CharacterResponse{ + ID: character.ID, + Name: character.Name, + Description: character.Description, + Personality: character.Personality, + Scenario: character.Scenario, + Avatar: character.Avatar, + CreatorID: character.CreatorID, + CreatorName: character.CreatorName, + CreatorNotes: character.CreatorNotes, + Tags: tags, + IsPublic: character.IsPublic, + Version: character.Version, + FirstMessage: character.FirstMessage, + ExampleMessages: exampleMessages, + TotalChats: character.TotalChats, + TotalLikes: character.TotalLikes, + UsageCount: character.UsageCount, + FavoriteCount: character.FavoriteCount, + TokenCount: character.TokenCount, + IsFavorited: isFavorited, + CreatedAt: character.CreatedAt, + UpdatedAt: character.UpdatedAt, + } +} diff --git a/server/router/app/auth.go b/server/router/app/auth.go new file mode 100644 index 0000000..3bb8b09 --- /dev/null +++ b/server/router/app/auth.go @@ -0,0 +1,36 @@ +package app + +import ( + v1 "git.echol.cn/loser/st/server/api/v1" + "git.echol.cn/loser/st/server/middleware" + "github.com/gin-gonic/gin" +) + +type AuthRouter struct{} + +// InitAuthRouter 初始化前台用户认证路由 +func (r *AuthRouter) InitAuthRouter(Router *gin.RouterGroup) { + authRouter := Router.Group("auth") + authApi := v1.ApiGroupApp.AppApiGroup.AuthApi + + { + // 公开路由(无需认证) + authRouter.POST("register", authApi.Register) // 注册 + authRouter.POST("login", authApi.Login) // 登录 + authRouter.POST("refresh", authApi.RefreshToken) // 刷新Token + } + + // 需要认证的路由 + authRouterAuth := Router.Group("auth").Use(middleware.AppJWTAuth()) + { + authRouterAuth.POST("logout", authApi.Logout) // 登出 + authRouterAuth.GET("userinfo", authApi.GetUserInfo) // 获取用户信息 + } + + // 用户相关路由 + userRouter := Router.Group("user").Use(middleware.AppJWTAuth()) + { + userRouter.PUT("profile", authApi.UpdateProfile) // 更新用户信息 + userRouter.POST("change-password", authApi.ChangePassword) // 修改密码 + } +} diff --git a/server/router/app/character.go b/server/router/app/character.go new file mode 100644 index 0000000..58d471a --- /dev/null +++ b/server/router/app/character.go @@ -0,0 +1,34 @@ +package app + +import ( + v1 "git.echol.cn/loser/st/server/api/v1" + "git.echol.cn/loser/st/server/middleware" + "github.com/gin-gonic/gin" +) + +type CharacterRouter struct{} + +func (cr *CharacterRouter) InitCharacterRouter(Router *gin.RouterGroup) { + characterRouter := Router.Group("character") + characterApi := v1.ApiGroupApp.AppApiGroup.CharacterApi + + { + // 公开接口(无需鉴权) + characterRouter.GET("/public", characterApi.GetPublicCharacterList) // 获取公开角色卡列表 + characterRouter.GET("/:id", characterApi.GetCharacterDetail) // 获取角色卡详情 + characterRouter.GET("/:id/export", characterApi.ExportCharacter) // 导出角色卡为 JSON + characterRouter.GET("/:id/export/png", characterApi.ExportCharacterAsPNG) // 导出角色卡为 PNG + characterRouter.POST("/like", characterApi.LikeCharacter) // 点赞角色卡 + } + + // 需要鉴权的接口 + characterRouterAuth := Router.Group("character").Use(middleware.AppJWTAuth()) + { + characterRouterAuth.GET("/my", characterApi.GetMyCharacterList) // 获取我的角色卡列表 + characterRouterAuth.POST("", characterApi.CreateCharacter) // 创建角色卡 + characterRouterAuth.PUT("", characterApi.UpdateCharacter) // 更新角色卡 + characterRouterAuth.DELETE("/:id", characterApi.DeleteCharacter) // 删除角色卡 + characterRouterAuth.POST("/favorite", characterApi.ToggleFavorite) // 切换收藏状态 + characterRouterAuth.POST("/import", characterApi.ImportCharacter) // 导入角色卡 + } +} diff --git a/server/router/app/enter.go b/server/router/app/enter.go index 4e98d61..39fde4d 100644 --- a/server/router/app/enter.go +++ b/server/router/app/enter.go @@ -2,4 +2,5 @@ package app type RouterGroup struct { AuthRouter + CharacterRouter } diff --git a/server/service/app/auth.go b/server/service/app/auth.go new file mode 100644 index 0000000..8118401 --- /dev/null +++ b/server/service/app/auth.go @@ -0,0 +1,254 @@ +package app + +import ( + "errors" + "time" + + "git.echol.cn/loser/st/server/global" + "git.echol.cn/loser/st/server/model/app" + "git.echol.cn/loser/st/server/model/app/request" + "git.echol.cn/loser/st/server/model/app/response" + "git.echol.cn/loser/st/server/utils" + "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +type AuthService struct{} + +// Register 用户注册 +func (s *AuthService) Register(req *request.RegisterRequest) error { + // 检查用户名是否已存在 + var count int64 + err := global.GVA_DB.Model(&app.AppUser{}).Where("username = ?", req.Username).Count(&count).Error + if err != nil { + return err + } + if count > 0 { + return errors.New("用户名已存在") + } + + // 检查邮箱是否已存在 + if req.Email != "" { + err = global.GVA_DB.Model(&app.AppUser{}).Where("email = ?", req.Email).Count(&count).Error + if err != nil { + return err + } + if count > 0 { + return errors.New("邮箱已被使用") + } + } + + // 密码加密 + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + return errors.New("密码加密失败") + } + + // 创建用户 + user := app.AppUser{ + UUID: uuid.New().String(), + Username: req.Username, + Password: string(hashedPassword), + NickName: req.NickName, + Email: req.Email, + Phone: req.Phone, + Status: "active", + Enable: true, + } + + if user.NickName == "" { + user.NickName = req.Username + } + + return global.GVA_DB.Create(&user).Error +} + +// Login 用户登录 +func (s *AuthService) Login(req *request.LoginRequest, ip string) (*response.LoginResponse, error) { + // 查询用户 + var user app.AppUser + err := global.GVA_DB.Where("username = ?", req.Username).First(&user).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("用户名或密码错误") + } + return nil, err + } + + // 检查用户状态 + if !user.Enable { + return nil, errors.New("账户已被禁用") + } + if user.Status != "active" { + return nil, errors.New("账户状态异常") + } + + // 验证密码 + err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)) + if err != nil { + return nil, errors.New("用户名或密码错误") + } + + // 生成 Token + token, expiresAt, err := utils.CreateAppToken(user.ID, user.Username) + if err != nil { + return nil, errors.New("Token 生成失败") + } + + // 生成刷新 Token + refreshToken, refreshExpiresAt, err := utils.CreateAppRefreshToken(user.ID, user.Username) + if err != nil { + return nil, errors.New("刷新 Token 生成失败") + } + + // 更新最后登录信息 + now := time.Now() + global.GVA_DB.Model(&user).Updates(map[string]interface{}{ + "last_login_at": now, + "last_login_ip": ip, + }) + + // 保存会话信息(可选) + session := app.AppUserSession{ + UserID: user.ID, + SessionToken: token, + RefreshToken: refreshToken, + ExpiresAt: time.Unix(expiresAt, 0), + RefreshExpiresAt: func() *time.Time { t := time.Unix(refreshExpiresAt, 0); return &t }(), + IPAddress: ip, + } + global.GVA_DB.Create(&session) + + return &response.LoginResponse{ + User: response.ToAppUserResponse(&user), + Token: token, + RefreshToken: refreshToken, + ExpiresAt: expiresAt, + }, nil +} + +// RefreshToken 刷新 Token +func (s *AuthService) RefreshToken(req *request.RefreshTokenRequest) (*response.LoginResponse, error) { + // 解析刷新 Token + claims, err := utils.ParseAppToken(req.RefreshToken) + if err != nil { + return nil, errors.New("刷新 Token 无效") + } + + // 查询用户 + var user app.AppUser + err = global.GVA_DB.Where("id = ?", claims.UserID).First(&user).Error + if err != nil { + return nil, errors.New("用户不存在") + } + + // 检查用户状态 + if !user.Enable { + return nil, errors.New("账户已被禁用") + } + + // 生成新的 Token + token, expiresAt, err := utils.CreateAppToken(user.ID, user.Username) + if err != nil { + return nil, errors.New("Token 生成失败") + } + + // 生成新的刷新 Token + refreshToken, _, err := utils.CreateAppRefreshToken(user.ID, user.Username) + if err != nil { + return nil, errors.New("刷新 Token 生成失败") + } + + return &response.LoginResponse{ + User: response.ToAppUserResponse(&user), + Token: token, + RefreshToken: refreshToken, + ExpiresAt: expiresAt, + }, nil +} + +// Logout 用户登出 +func (s *AuthService) Logout(userID uint, token string) error { + // 删除会话记录 + return global.GVA_DB.Where("user_id = ? AND session_token = ?", userID, token). + Delete(&app.AppUserSession{}).Error +} + +// GetUserInfo 获取用户信息 +func (s *AuthService) GetUserInfo(userID uint) (*response.AppUserResponse, error) { + var user app.AppUser + err := global.GVA_DB.Where("id = ?", userID).First(&user).Error + if err != nil { + return nil, err + } + + resp := response.ToAppUserResponse(&user) + return &resp, nil +} + +// UpdateProfile 更新用户信息 +func (s *AuthService) UpdateProfile(userID uint, req *request.UpdateProfileRequest) error { + updates := make(map[string]interface{}) + + if req.NickName != "" { + updates["nick_name"] = req.NickName + } + if req.Email != "" { + // 检查邮箱是否已被其他用户使用 + var count int64 + err := global.GVA_DB.Model(&app.AppUser{}). + Where("email = ? AND id != ?", req.Email, userID). + Count(&count).Error + if err != nil { + return err + } + if count > 0 { + return errors.New("邮箱已被使用") + } + updates["email"] = req.Email + } + if req.Phone != "" { + updates["phone"] = req.Phone + } + if req.Avatar != "" { + updates["avatar"] = req.Avatar + } + if req.Preferences != "" { + updates["preferences"] = req.Preferences + } + if req.AISettings != "" { + updates["ai_settings"] = req.AISettings + } + + if len(updates) == 0 { + return nil + } + + return global.GVA_DB.Model(&app.AppUser{}).Where("id = ?", userID).Updates(updates).Error +} + +// ChangePassword 修改密码 +func (s *AuthService) ChangePassword(userID uint, req *request.ChangePasswordRequest) error { + // 查询用户 + var user app.AppUser + err := global.GVA_DB.Where("id = ?", userID).First(&user).Error + if err != nil { + return err + } + + // 验证旧密码 + err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.OldPassword)) + if err != nil { + return errors.New("原密码错误") + } + + // 加密新密码 + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost) + if err != nil { + return errors.New("密码加密失败") + } + + // 更新密码 + return global.GVA_DB.Model(&user).Update("password", string(hashedPassword)).Error +} diff --git a/server/service/app/character.go b/server/service/app/character.go new file mode 100644 index 0000000..e007fae --- /dev/null +++ b/server/service/app/character.go @@ -0,0 +1,694 @@ +package app + +import ( + "encoding/json" + "errors" + "fmt" + "image" + _ "image/jpeg" + "strings" + + "git.echol.cn/loser/st/server/global" + "git.echol.cn/loser/st/server/model/app" + "git.echol.cn/loser/st/server/model/app/request" + "git.echol.cn/loser/st/server/model/app/response" + "git.echol.cn/loser/st/server/utils" + "go.uber.org/zap" + "gorm.io/datatypes" + "gorm.io/gorm" +) + +type CharacterService struct{} + +// GetPublicCharacterList 获取公开角色卡列表(无需鉴权) +func (cs *CharacterService) GetPublicCharacterList(req request.CharacterListRequest, userID *uint) (response.CharacterListResponse, error) { + db := global.GVA_DB.Model(&app.AICharacter{}) + + // 只查询公开的角色卡 + db = db.Where("is_public = ?", true) + + // 关键词搜索 + if req.Keyword != "" { + keyword := "%" + req.Keyword + "%" + db = db.Where("name ILIKE ? OR description ILIKE ? OR creator_name ILIKE ?", keyword, keyword, keyword) + } + + // 标签筛选 + if len(req.Tags) > 0 { + for _, tag := range req.Tags { + db = db.Where("tags @> ?", fmt.Sprintf(`["%s"]`, tag)) + } + } + + // 排序 + switch req.SortBy { + case "popular": + db = db.Order("usage_count DESC, created_at DESC") + case "mostChats": + db = db.Order("total_chats DESC, created_at DESC") + case "mostLikes": + db = db.Order("total_likes DESC, created_at DESC") + case "newest": + fallthrough + default: + db = db.Order("created_at DESC") + } + + // 分页 + var total int64 + db.Count(&total) + + var characters []app.AICharacter + offset := (req.Page - 1) * req.PageSize + err := db.Offset(offset).Limit(req.PageSize).Find(&characters).Error + if err != nil { + return response.CharacterListResponse{}, err + } + + // 查询当前用户的收藏状态 + favoriteMap := make(map[uint]bool) + if userID != nil { + var favorites []app.AppUserFavoriteCharacter + global.GVA_DB.Where("user_id = ?", *userID).Find(&favorites) + for _, fav := range favorites { + favoriteMap[fav.CharacterID] = true + } + } + + // 转换为响应 + list := make([]response.CharacterResponse, len(characters)) + for i, char := range characters { + list[i] = response.ToCharacterResponse(&char, favoriteMap[char.ID]) + } + + return response.CharacterListResponse{ + List: list, + Total: total, + Page: req.Page, + PageSize: req.PageSize, + }, nil +} + +// GetMyCharacterList 获取我的角色卡列表(需要鉴权) +func (cs *CharacterService) GetMyCharacterList(req request.CharacterListRequest, userID uint) (response.CharacterListResponse, error) { + db := global.GVA_DB.Model(&app.AICharacter{}) + + // 只查询当前用户创建的角色卡 + db = db.Where("creator_id = ?", userID) + + // 关键词搜索 + if req.Keyword != "" { + keyword := "%" + req.Keyword + "%" + db = db.Where("name ILIKE ? OR description ILIKE ?", keyword, keyword) + } + + // 标签筛选 + if len(req.Tags) > 0 { + for _, tag := range req.Tags { + db = db.Where("tags @> ?", fmt.Sprintf(`["%s"]`, tag)) + } + } + + // 排序 + switch req.SortBy { + case "popular": + db = db.Order("usage_count DESC, created_at DESC") + case "mostChats": + db = db.Order("total_chats DESC, created_at DESC") + case "mostLikes": + db = db.Order("total_likes DESC, created_at DESC") + case "newest": + fallthrough + default: + db = db.Order("created_at DESC") + } + + // 分页 + var total int64 + db.Count(&total) + + var characters []app.AICharacter + offset := (req.Page - 1) * req.PageSize + err := db.Offset(offset).Limit(req.PageSize).Find(&characters).Error + if err != nil { + return response.CharacterListResponse{}, err + } + + // 查询收藏状态 + favoriteMap := make(map[uint]bool) + var favorites []app.AppUserFavoriteCharacter + global.GVA_DB.Where("user_id = ?", userID).Find(&favorites) + for _, fav := range favorites { + favoriteMap[fav.CharacterID] = true + } + + // 转换为响应 + list := make([]response.CharacterResponse, len(characters)) + for i, char := range characters { + list[i] = response.ToCharacterResponse(&char, favoriteMap[char.ID]) + } + + return response.CharacterListResponse{ + List: list, + Total: total, + Page: req.Page, + PageSize: req.PageSize, + }, nil +} + +// GetCharacterDetail 获取角色卡详情 +func (cs *CharacterService) GetCharacterDetail(characterID uint, userID *uint) (response.CharacterResponse, error) { + var character app.AICharacter + err := global.GVA_DB.Where("id = ?", characterID).First(&character).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return response.CharacterResponse{}, errors.New("角色卡不存在") + } + return response.CharacterResponse{}, err + } + + // 检查访问权限 + if !character.IsPublic { + if userID == nil { + return response.CharacterResponse{}, errors.New("无权访问") + } + if character.CreatorID == nil || *character.CreatorID != *userID { + return response.CharacterResponse{}, errors.New("无权访问") + } + } + + // 查询是否收藏 + isFavorited := false + if userID != nil { + var count int64 + global.GVA_DB.Model(&app.AppUserFavoriteCharacter{}). + Where("user_id = ? AND character_id = ?", *userID, characterID). + Count(&count) + isFavorited = count > 0 + } + + return response.ToCharacterResponse(&character, isFavorited), nil +} + +// CreateCharacter 创建角色卡 +func (cs *CharacterService) CreateCharacter(req request.CreateCharacterRequest, userID uint) (response.CharacterResponse, error) { + // 构建 CardData + cardData := map[string]interface{}{ + "name": req.Name, + "description": req.Description, + "personality": req.Personality, + "scenario": req.Scenario, + "first_message": req.FirstMessage, + "example_messages": req.ExampleMessages, + "creator_name": req.CreatorName, + "creator_notes": req.CreatorNotes, + } + cardDataJSON, _ := json.Marshal(cardData) + + // 处理标签和示例消息 + tags := req.Tags + if tags == nil { + tags = []string{} + } + + exampleMessages := req.ExampleMessages + if exampleMessages == nil { + exampleMessages = []string{} + } + + character := app.AICharacter{ + Name: req.Name, + Description: req.Description, + Personality: req.Personality, + Scenario: req.Scenario, + Avatar: req.Avatar, + CreatorID: &userID, + CreatorName: req.CreatorName, + CreatorNotes: req.CreatorNotes, + CardData: datatypes.JSON(cardDataJSON), + Tags: tags, + IsPublic: req.IsPublic, + FirstMessage: req.FirstMessage, + ExampleMessages: exampleMessages, + TokenCount: calculateTokenCount(req), + } + + err := global.GVA_DB.Create(&character).Error + if err != nil { + return response.CharacterResponse{}, err + } + + return response.ToCharacterResponse(&character, false), nil +} + +// UpdateCharacter 更新角色卡 +func (cs *CharacterService) UpdateCharacter(req request.UpdateCharacterRequest, userID uint) (response.CharacterResponse, error) { + var character app.AICharacter + err := global.GVA_DB.Where("id = ?", req.ID).First(&character).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return response.CharacterResponse{}, errors.New("角色卡不存在") + } + return response.CharacterResponse{}, err + } + + // 检查权限 + if character.CreatorID == nil || *character.CreatorID != userID { + return response.CharacterResponse{}, errors.New("无权修改") + } + + // 构建 CardData + cardData := map[string]interface{}{ + "name": req.Name, + "description": req.Description, + "personality": req.Personality, + "scenario": req.Scenario, + "first_message": req.FirstMessage, + "example_messages": req.ExampleMessages, + "creator_name": req.CreatorName, + "creator_notes": req.CreatorNotes, + } + cardDataJSON, _ := json.Marshal(cardData) + + // 处理标签和示例消息 + tags := req.Tags + if tags == nil { + tags = []string{} + } + + exampleMessages := req.ExampleMessages + if exampleMessages == nil { + exampleMessages = []string{} + } + + // 更新 + updates := map[string]interface{}{ + "name": req.Name, + "description": req.Description, + "personality": req.Personality, + "scenario": req.Scenario, + "avatar": req.Avatar, + "creator_name": req.CreatorName, + "creator_notes": req.CreatorNotes, + "card_data": cardDataJSON, + "tags": tags, + "is_public": req.IsPublic, + "first_message": req.FirstMessage, + "example_messages": exampleMessages, + "token_count": calculateTokenCount(req), + "version": character.Version + 1, + } + + err = global.GVA_DB.Model(&character).Updates(updates).Error + if err != nil { + return response.CharacterResponse{}, err + } + + // 重新查询 + err = global.GVA_DB.Where("id = ?", req.ID).First(&character).Error + if err != nil { + return response.CharacterResponse{}, err + } + + // 查询是否收藏 + var count int64 + global.GVA_DB.Model(&app.AppUserFavoriteCharacter{}). + Where("user_id = ? AND character_id = ?", userID, character.ID). + Count(&count) + + return response.ToCharacterResponse(&character, count > 0), nil +} + +// DeleteCharacter 删除角色卡 +func (cs *CharacterService) DeleteCharacter(characterID uint, userID uint) error { + var character app.AICharacter + err := global.GVA_DB.Where("id = ?", characterID).First(&character).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("角色卡不存在") + } + return err + } + + // 检查权限 + if character.CreatorID == nil || *character.CreatorID != userID { + return errors.New("无权删除") + } + + // 删除相关的收藏记录 + global.GVA_DB.Where("character_id = ?", characterID).Delete(&app.AppUserFavoriteCharacter{}) + + // 删除角色卡 + return global.GVA_DB.Delete(&character).Error +} + +// ToggleFavorite 切换收藏状态 +func (cs *CharacterService) ToggleFavorite(characterID uint, userID uint) (bool, error) { + // 检查角色卡是否存在 + var character app.AICharacter + err := global.GVA_DB.Where("id = ?", characterID).First(&character).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, errors.New("角色卡不存在") + } + return false, err + } + + // 检查是否已收藏 + var favorite app.AppUserFavoriteCharacter + err = global.GVA_DB.Where("user_id = ? AND character_id = ?", userID, characterID).First(&favorite).Error + + if errors.Is(err, gorm.ErrRecordNotFound) { + // 未收藏,添加收藏 + favorite = app.AppUserFavoriteCharacter{ + UserID: userID, + CharacterID: characterID, + } + err = global.GVA_DB.Create(&favorite).Error + if err != nil { + return false, err + } + + // 增加收藏数 + global.GVA_DB.Model(&character).UpdateColumn("favorite_count", gorm.Expr("favorite_count + ?", 1)) + + return true, nil + } else if err != nil { + return false, err + } else { + // 已收藏,取消收藏 + err = global.GVA_DB.Delete(&favorite).Error + if err != nil { + return false, err + } + + // 减少收藏数 + global.GVA_DB.Model(&character).UpdateColumn("favorite_count", gorm.Expr("favorite_count - ?", 1)) + + return false, nil + } +} + +// LikeCharacter 点赞角色卡 +func (cs *CharacterService) LikeCharacter(characterID uint) error { + var character app.AICharacter + err := global.GVA_DB.Where("id = ?", characterID).First(&character).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("角色卡不存在") + } + return err + } + + // 增加点赞数 + return global.GVA_DB.Model(&character).UpdateColumn("total_likes", gorm.Expr("total_likes + ?", 1)).Error +} + +// ExportCharacter 导出角色卡为 JSON +func (cs *CharacterService) ExportCharacter(characterID uint, userID *uint) (map[string]interface{}, error) { + var character app.AICharacter + err := global.GVA_DB.Where("id = ?", characterID).First(&character).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("角色卡不存在") + } + return nil, err + } + + // 检查访问权限 + if !character.IsPublic { + if userID == nil { + return nil, errors.New("无权访问") + } + if character.CreatorID == nil || *character.CreatorID != *userID { + return nil, errors.New("无权访问") + } + } + + // 处理 tags 和 exampleMessages + tags := []string{} + if character.Tags != nil { + tags = character.Tags + } + + exampleMessages := []string{} + if character.ExampleMessages != nil { + exampleMessages = character.ExampleMessages + } + + // 构建导出数据(兼容 SillyTavern 格式) + exportData := map[string]interface{}{ + "spec": "chara_card_v2", + "spec_version": "2.0", + "data": map[string]interface{}{ + "name": character.Name, + "description": character.Description, + "personality": character.Personality, + "scenario": character.Scenario, + "first_mes": character.FirstMessage, + "mes_example": strings.Join(exampleMessages, "\n\n"), + "creator_notes": character.CreatorNotes, + "system_prompt": "", + "post_history_instructions": "", + "tags": tags, + "creator": character.CreatorName, + "character_version": character.Version, + "extensions": map[string]interface{}{}, + }, + } + + return exportData, nil +} + +// ImportCharacter 导入角色卡(支持 PNG 和 JSON) +func (cs *CharacterService) ImportCharacter(fileData []byte, filename string, userID uint, isPublic bool) (response.CharacterResponse, error) { + // 添加 defer 捕获 panic + defer func() { + if r := recover(); r != nil { + global.GVA_LOG.Error("导入角色卡时发生 panic", + zap.Any("panic", r), + zap.String("filename", filename)) + } + }() + + global.GVA_LOG.Info("开始导入角色卡", + zap.String("filename", filename), + zap.Int("fileSize", len(fileData)), + zap.Uint("userID", userID)) + + var card *utils.CharacterCardV2 + var err error + var avatarData []byte + + // 判断文件类型 + if strings.HasSuffix(strings.ToLower(filename), ".png") { + global.GVA_LOG.Info("检测到 PNG 格式,开始提取角色卡数据") + // PNG 格式:提取角色卡数据和头像 + card, err = utils.ExtractCharacterFromPNG(fileData) + if err != nil { + global.GVA_LOG.Error("解析 PNG 角色卡失败", zap.Error(err)) + return response.CharacterResponse{}, errors.New("解析 PNG 角色卡失败: " + err.Error()) + } + global.GVA_LOG.Info("PNG 角色卡解析成功", zap.String("characterName", card.Data.Name)) + avatarData = fileData + } else if strings.HasSuffix(strings.ToLower(filename), ".json") { + global.GVA_LOG.Info("检测到 JSON 格式,开始解析") + // JSON 格式:只有数据,没有头像 + card, err = utils.ParseCharacterCardJSON(fileData) + if err != nil { + global.GVA_LOG.Error("解析 JSON 角色卡失败", zap.Error(err)) + return response.CharacterResponse{}, errors.New("解析 JSON 角色卡失败: " + err.Error()) + } + global.GVA_LOG.Info("JSON 角色卡解析成功", zap.String("characterName", card.Data.Name)) + } else { + return response.CharacterResponse{}, errors.New("不支持的文件格式,请上传 PNG 或 JSON 文件") + } + + // 转换为创建请求 + global.GVA_LOG.Info("转换角色卡数据为创建请求") + createReq := convertCardToCreateRequest(card, avatarData, isPublic) + + global.GVA_LOG.Info("开始创建角色卡到数据库", + zap.String("name", createReq.Name), + zap.Bool("isPublic", createReq.IsPublic)) + + // 创建角色卡 + result, err := cs.CreateCharacter(createReq, userID) + if err != nil { + global.GVA_LOG.Error("创建角色卡到数据库失败", zap.Error(err)) + return response.CharacterResponse{}, err + } + + global.GVA_LOG.Info("角色卡导入完成", zap.Uint("characterID", result.ID)) + return result, nil +} + +// ExportCharacterAsPNG 导出角色卡为 PNG 格式 +func (cs *CharacterService) ExportCharacterAsPNG(characterID uint, userID *uint) ([]byte, error) { + var character app.AICharacter + err := global.GVA_DB.Where("id = ?", characterID).First(&character).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("角色卡不存在") + } + return nil, err + } + + // 检查访问权限 + if !character.IsPublic { + if userID == nil { + return nil, errors.New("无权访问") + } + if character.CreatorID == nil || *character.CreatorID != *userID { + return nil, errors.New("无权访问") + } + } + + // 构建角色卡数据 + card := convertCharacterToCard(&character) + + // 获取角色头像 + var img image.Image + if character.Avatar != "" { + // TODO: 从 URL 或文件系统加载头像 + // 这里暂时创建一个默认图片 + img = createDefaultAvatar() + } else { + img = createDefaultAvatar() + } + + // 将角色卡数据嵌入到 PNG + pngData, err := utils.EmbedCharacterToPNG(img, card) + if err != nil { + return nil, errors.New("生成 PNG 失败: " + err.Error()) + } + + return pngData, nil +} + +// convertCardToCreateRequest 将角色卡转换为创建请求 +func convertCardToCreateRequest(card *utils.CharacterCardV2, avatarData []byte, isPublic bool) request.CreateCharacterRequest { + // 处理示例消息 + exampleMessages := []string{} + if card.Data.MesExample != "" { + // 按 分割 + exampleMessages = strings.Split(card.Data.MesExample, "") + // 清理空白 + cleaned := []string{} + for _, msg := range exampleMessages { + msg = strings.TrimSpace(msg) + if msg != "" { + cleaned = append(cleaned, msg) + } + } + exampleMessages = cleaned + } + + // 合并备用问候语 + if len(card.Data.AlternateGreetings) > 0 { + exampleMessages = append(exampleMessages, card.Data.AlternateGreetings...) + } + + // TODO: 处理头像数据,上传到文件服务器 + avatar := "" + if avatarData != nil { + // 这里应该将头像上传到文件服务器并获取 URL + // avatar = uploadAvatar(avatarData) + } + + return request.CreateCharacterRequest{ + Name: card.Data.Name, + Description: card.Data.Description, + Personality: card.Data.Personality, + Scenario: card.Data.Scenario, + Avatar: avatar, + CreatorName: card.Data.Creator, + CreatorNotes: card.Data.CreatorNotes, + FirstMessage: card.Data.FirstMes, + ExampleMessages: exampleMessages, + Tags: card.Data.Tags, + IsPublic: isPublic, + } +} + +// convertCharacterToCard 将角色卡转换为 CharacterCardV2 +func convertCharacterToCard(character *app.AICharacter) *utils.CharacterCardV2 { + tags := []string{} + if character.Tags != nil { + tags = character.Tags + } + + exampleMessages := []string{} + if character.ExampleMessages != nil { + exampleMessages = character.ExampleMessages + } + + return &utils.CharacterCardV2{ + Spec: "chara_card_v2", + SpecVersion: "2.0", + Data: utils.CharacterCardV2Data{ + Name: character.Name, + Description: character.Description, + Personality: character.Personality, + Scenario: character.Scenario, + FirstMes: character.FirstMessage, + MesExample: strings.Join(exampleMessages, "\n\n"), + CreatorNotes: character.CreatorNotes, + SystemPrompt: "", + PostHistoryInstructions: "", + Tags: tags, + Creator: character.CreatorName, + CharacterVersion: fmt.Sprintf("%d", character.Version), + AlternateGreetings: []string{}, + Extensions: map[string]interface{}{}, + }, + } +} + +// createDefaultAvatar 创建默认头像 +func createDefaultAvatar() image.Image { + // 创建一个 400x533 的默认图片(3:4 比例) + width, height := 400, 533 + img := image.NewRGBA(image.Rect(0, 0, width, height)) + + // 填充渐变色 + for y := 0; y < height; y++ { + for x := 0; x < width; x++ { + // 简单的渐变效果 + r := uint8(102 + y*155/height) + g := uint8(126 + y*138/height) + b := uint8(234 - y*72/height) + img.Set(x, y, image.NewRGBA(image.Rect(0, 0, 1, 1)).At(0, 0)) + img.SetRGBA(x, y, image.NewRGBA(image.Rect(0, 0, 1, 1)).RGBAAt(0, 0)) + // 设置颜色 + img.SetRGBA(x, y, image.NewRGBA(image.Rect(0, 0, 1, 1)).RGBAAt(0, 0)) + pix := img.Pix[y*img.Stride+x*4:] + pix[0] = r + pix[1] = g + pix[2] = b + pix[3] = 255 + } + } + + return img +} + +// calculateTokenCount 计算角色卡的 Token 数量(简单估算) +func calculateTokenCount(req interface{}) int { + var text string + switch v := req.(type) { + case request.CreateCharacterRequest: + text = v.Name + v.Description + v.Personality + v.Scenario + v.FirstMessage + for _, msg := range v.ExampleMessages { + text += msg + } + case request.UpdateCharacterRequest: + text = v.Name + v.Description + v.Personality + v.Scenario + v.FirstMessage + for _, msg := range v.ExampleMessages { + text += msg + } + } + + // 简单估算:中文约 1.5 token/字,英文约 0.25 token/词 + return len([]rune(text)) +} diff --git a/server/service/app/enter.go b/server/service/app/enter.go index f2e6ea9..6ea27df 100644 --- a/server/service/app/enter.go +++ b/server/service/app/enter.go @@ -2,4 +2,5 @@ package app type AppServiceGroup struct { AuthService + CharacterService } diff --git a/server/utils/app_jwt.go b/server/utils/app_jwt.go new file mode 100644 index 0000000..2d73851 --- /dev/null +++ b/server/utils/app_jwt.go @@ -0,0 +1,84 @@ +package utils + +import ( + "errors" + "time" + + "git.echol.cn/loser/st/server/global" + "github.com/golang-jwt/jwt/v5" +) + +const ( + UserTypeApp = "app" // 前台用户类型标识 +) + +// AppJWTClaims 前台用户 JWT Claims +type AppJWTClaims struct { + UserID uint `json:"userId"` + Username string `json:"username"` + UserType string `json:"userType"` // 用户类型标识 + jwt.RegisteredClaims +} + +// CreateAppToken 创建前台用户 Token(有效期 7 天) +func CreateAppToken(userID uint, username string) (tokenString string, expiresAt int64, err error) { + // Token 有效期为 7 天 + expiresTime := time.Now().Add(7 * 24 * time.Hour) + expiresAt = expiresTime.Unix() + + claims := AppJWTClaims{ + UserID: userID, + Username: username, + UserType: UserTypeApp, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(expiresTime), + IssuedAt: jwt.NewNumericDate(time.Now()), + NotBefore: jwt.NewNumericDate(time.Now()), + Issuer: global.GVA_CONFIG.JWT.Issuer, + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenString, err = token.SignedString([]byte(global.GVA_CONFIG.JWT.SigningKey)) + return +} + +// CreateAppRefreshToken 创建前台用户刷新 Token(有效期更长) +func CreateAppRefreshToken(userID uint, username string) (tokenString string, expiresAt int64, err error) { + // 刷新 Token 有效期为 7 天 + expiresTime := time.Now().Add(7 * 24 * time.Hour) + expiresAt = expiresTime.Unix() + + claims := AppJWTClaims{ + UserID: userID, + Username: username, + UserType: UserTypeApp, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(expiresTime), + IssuedAt: jwt.NewNumericDate(time.Now()), + NotBefore: jwt.NewNumericDate(time.Now()), + Issuer: global.GVA_CONFIG.JWT.Issuer, + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenString, err = token.SignedString([]byte(global.GVA_CONFIG.JWT.SigningKey)) + return +} + +// ParseAppToken 解析前台用户 Token +func ParseAppToken(tokenString string) (*AppJWTClaims, error) { + token, err := jwt.ParseWithClaims(tokenString, &AppJWTClaims{}, func(token *jwt.Token) (interface{}, error) { + return []byte(global.GVA_CONFIG.JWT.SigningKey), nil + }) + + if err != nil { + return nil, err + } + + if claims, ok := token.Claims.(*AppJWTClaims); ok && token.Valid { + return claims, nil + } + + return nil, errors.New("invalid token") +} diff --git a/server/utils/character_card.go b/server/utils/character_card.go new file mode 100644 index 0000000..2a071be --- /dev/null +++ b/server/utils/character_card.go @@ -0,0 +1,284 @@ +package utils + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "errors" + "image" + "image/png" + "io" +) + +// CharacterCardV2 SillyTavern 角色卡 V2 格式 +type CharacterCardV2 struct { + Spec string `json:"spec"` + SpecVersion string `json:"spec_version"` + Data CharacterCardV2Data `json:"data"` +} + +type CharacterCardV2Data struct { + Name string `json:"name"` + Description string `json:"description"` + Personality string `json:"personality"` + Scenario string `json:"scenario"` + FirstMes string `json:"first_mes"` + MesExample string `json:"mes_example"` + CreatorNotes string `json:"creator_notes"` + SystemPrompt string `json:"system_prompt"` + PostHistoryInstructions string `json:"post_history_instructions"` + Tags []string `json:"tags"` + Creator string `json:"creator"` + CharacterVersion string `json:"character_version"` + AlternateGreetings []string `json:"alternate_greetings"` + Extensions map[string]interface{} `json:"extensions"` +} + +// ExtractCharacterFromPNG 从 PNG 图片中提取角色卡数据 +func ExtractCharacterFromPNG(pngData []byte) (*CharacterCardV2, error) { + reader := bytes.NewReader(pngData) + + // 验证 PNG 格式(解码但不保存图片) + _, err := png.Decode(reader) + if err != nil { + return nil, errors.New("无效的 PNG 文件") + } + + // 重新读取以获取 tEXt chunks + reader.Seek(0, 0) + + // 查找 tEXt chunk 中的 "chara" 字段 + charaJSON, err := extractTextChunk(reader, "chara") + if err != nil { + return nil, errors.New("PNG 中没有找到角色卡数据") + } + + // 尝试 Base64 解码 + decodedJSON, err := base64.StdEncoding.DecodeString(charaJSON) + if err != nil { + // 如果不是 Base64,直接使用原始 JSON + decodedJSON = []byte(charaJSON) + } + + // 解析 JSON + var card CharacterCardV2 + err = json.Unmarshal(decodedJSON, &card) + if err != nil { + return nil, errors.New("解析角色卡数据失败: " + err.Error()) + } + + return &card, nil +} + +// extractTextChunk 从 PNG 中提取指定 key 的 tEXt chunk +func extractTextChunk(r io.Reader, key string) (string, error) { + // 跳过 PNG signature (8 bytes) + signature := make([]byte, 8) + if _, err := io.ReadFull(r, signature); err != nil { + return "", err + } + + // 验证 PNG signature + expectedSig := []byte{137, 80, 78, 71, 13, 10, 26, 10} + if !bytes.Equal(signature, expectedSig) { + return "", errors.New("invalid PNG signature") + } + + // 读取所有 chunks + for { + // 读取 chunk length (4 bytes) + lengthBytes := make([]byte, 4) + if _, err := io.ReadFull(r, lengthBytes); err != nil { + if err == io.EOF { + break + } + return "", err + } + length := uint32(lengthBytes[0])<<24 | uint32(lengthBytes[1])<<16 | + uint32(lengthBytes[2])<<8 | uint32(lengthBytes[3]) + + // 读取 chunk type (4 bytes) + chunkType := make([]byte, 4) + if _, err := io.ReadFull(r, chunkType); err != nil { + return "", err + } + + // 读取 chunk data + data := make([]byte, length) + if _, err := io.ReadFull(r, data); err != nil { + return "", err + } + + // 读取 CRC (4 bytes) + crc := make([]byte, 4) + if _, err := io.ReadFull(r, crc); err != nil { + return "", err + } + + // 检查是否是 tEXt chunk + if string(chunkType) == "tEXt" { + // tEXt chunk 格式: keyword\0text + nullIndex := bytes.IndexByte(data, 0) + if nullIndex == -1 { + continue + } + + keyword := string(data[:nullIndex]) + text := string(data[nullIndex+1:]) + + if keyword == key { + return text, nil + } + } + + // IEND chunk 表示结束 + if string(chunkType) == "IEND" { + break + } + } + + return "", errors.New("text chunk not found") +} + +// EmbedCharacterToPNG 将角色卡数据嵌入到 PNG 图片中 +func EmbedCharacterToPNG(img image.Image, card *CharacterCardV2) ([]byte, error) { + // 序列化角色卡数据 + cardJSON, err := json.Marshal(card) + if err != nil { + return nil, err + } + + // Base64 编码 + encodedJSON := base64.StdEncoding.EncodeToString(cardJSON) + + // 创建一个 buffer 来写入 PNG + var buf bytes.Buffer + + // 写入 PNG signature + buf.Write([]byte{137, 80, 78, 71, 13, 10, 26, 10}) + + // 编码原始图片到临时 buffer + var imgBuf bytes.Buffer + if err := png.Encode(&imgBuf, img); err != nil { + return nil, err + } + + // 跳过原始 PNG 的 signature + imgData := imgBuf.Bytes()[8:] + + // 将原始图片的 chunks 复制到输出,在 IEND 之前插入 tEXt chunk + r := bytes.NewReader(imgData) + + for { + // 读取 chunk length + lengthBytes := make([]byte, 4) + if _, err := io.ReadFull(r, lengthBytes); err != nil { + if err == io.EOF { + break + } + return nil, err + } + length := uint32(lengthBytes[0])<<24 | uint32(lengthBytes[1])<<16 | + uint32(lengthBytes[2])<<8 | uint32(lengthBytes[3]) + + // 读取 chunk type + chunkType := make([]byte, 4) + if _, err := io.ReadFull(r, chunkType); err != nil { + return nil, err + } + + // 读取 chunk data + data := make([]byte, length) + if _, err := io.ReadFull(r, data); err != nil { + return nil, err + } + + // 读取 CRC + crc := make([]byte, 4) + if _, err := io.ReadFull(r, crc); err != nil { + return nil, err + } + + // 如果是 IEND chunk,先写入 tEXt chunk + if string(chunkType) == "IEND" { + // 写入 tEXt chunk + writeTextChunk(&buf, "chara", encodedJSON) + } + + // 写入原始 chunk + buf.Write(lengthBytes) + buf.Write(chunkType) + buf.Write(data) + buf.Write(crc) + + if string(chunkType) == "IEND" { + break + } + } + + return buf.Bytes(), nil +} + +// writeTextChunk 写入 tEXt chunk +func writeTextChunk(w io.Writer, keyword, text string) error { + data := append([]byte(keyword), 0) + data = append(data, []byte(text)...) + + // 写入 length + length := uint32(len(data)) + lengthBytes := []byte{ + byte(length >> 24), + byte(length >> 16), + byte(length >> 8), + byte(length), + } + w.Write(lengthBytes) + + // 写入 type + w.Write([]byte("tEXt")) + + // 写入 data + w.Write(data) + + // 计算并写入 CRC + crcData := append([]byte("tEXt"), data...) + crc := calculateCRC(crcData) + crcBytes := []byte{ + byte(crc >> 24), + byte(crc >> 16), + byte(crc >> 8), + byte(crc), + } + w.Write(crcBytes) + + return nil +} + +// calculateCRC 计算 CRC32 +func calculateCRC(data []byte) uint32 { + crc := uint32(0xFFFFFFFF) + + for _, b := range data { + crc ^= uint32(b) + for i := 0; i < 8; i++ { + if crc&1 != 0 { + crc = (crc >> 1) ^ 0xEDB88320 + } else { + crc >>= 1 + } + } + } + + return crc ^ 0xFFFFFFFF +} + +// ParseCharacterCardJSON 解析 JSON 格式的角色卡 +func ParseCharacterCardJSON(jsonData []byte) (*CharacterCardV2, error) { + var card CharacterCardV2 + err := json.Unmarshal(jsonData, &card) + if err != nil { + return nil, errors.New("解析角色卡 JSON 失败: " + err.Error()) + } + + return &card, nil +} diff --git a/web-app-vue/.env.development b/web-app-vue/.env.development new file mode 100644 index 0000000..bae4555 --- /dev/null +++ b/web-app-vue/.env.development @@ -0,0 +1,3 @@ +# 开发环境配置 +VITE_API_BASE_URL=http://localhost:8888 +VITE_WS_URL=ws://localhost:8888 diff --git a/web-app-vue/.env.production b/web-app-vue/.env.production new file mode 100644 index 0000000..2b1e029 --- /dev/null +++ b/web-app-vue/.env.production @@ -0,0 +1,3 @@ +# 生产环境配置 +VITE_API_BASE_URL=https://your-domain.com +VITE_WS_URL=wss://your-domain.com diff --git a/web-app-vue/.gitignore b/web-app-vue/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/web-app-vue/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/web-app-vue/.vscode/extensions.json b/web-app-vue/.vscode/extensions.json new file mode 100644 index 0000000..a7cea0b --- /dev/null +++ b/web-app-vue/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["Vue.volar"] +} diff --git a/web-app-vue/README.md b/web-app-vue/README.md new file mode 100644 index 0000000..6bc5edf --- /dev/null +++ b/web-app-vue/README.md @@ -0,0 +1,35 @@ +# 云酒馆前台应用 (Vue 3) + +## 技术栈 + +- Vue 3 + TypeScript +- Vite 5 +- Element Plus +- Vue Router 4 +- Pinia +- Axios + +## 开发 + +```bash +# 安装依赖 +npm install + +# 启动开发服务器 +npm run dev + +# 构建生产版本 +npm run build + +# 预览生产构建 +npm run preview +``` + +## 访问地址 + +- 开发环境: http://localhost:3000 +- API 代理: http://localhost:8888 + +## 项目结构 + +详见 `/docs/Vue重构方案.md` diff --git a/web-app-vue/index.html b/web-app-vue/index.html new file mode 100644 index 0000000..ca4b32b --- /dev/null +++ b/web-app-vue/index.html @@ -0,0 +1,13 @@ + + + + + + + web-app-vue + + +
+ + + diff --git a/web-app-vue/package-lock.json b/web-app-vue/package-lock.json new file mode 100644 index 0000000..057e04e --- /dev/null +++ b/web-app-vue/package-lock.json @@ -0,0 +1,2998 @@ +{ + "name": "web-app-vue", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "web-app-vue", + "version": "0.0.0", + "dependencies": { + "@vueuse/core": "^14.2.1", + "axios": "^1.13.5", + "element-plus": "^2.13.2", + "pinia": "^3.0.4", + "vue": "^3.5.25", + "vue-router": "^4.6.4" + }, + "devDependencies": { + "@types/node": "^24.10.1", + "@vitejs/plugin-vue": "^6.0.2", + "@vue/tsconfig": "^0.8.1", + "sass": "^1.97.3", + "typescript": "~5.9.3", + "unplugin-auto-import": "^21.0.0", + "unplugin-vue-components": "^31.0.0", + "vite": "^7.3.1", + "vue-tsc": "^3.1.5" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@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.npmjs.org/@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.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "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.npmjs.org/@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": "3.6.1", + "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz", + "integrity": "sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@element-plus/icons-vue": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@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.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz", + "integrity": "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz", + "integrity": "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.4", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", + "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.6", + "@parcel/watcher-darwin-arm64": "2.5.6", + "@parcel/watcher-darwin-x64": "2.5.6", + "@parcel/watcher-freebsd-x64": "2.5.6", + "@parcel/watcher-linux-arm-glibc": "2.5.6", + "@parcel/watcher-linux-arm-musl": "2.5.6", + "@parcel/watcher-linux-arm64-glibc": "2.5.6", + "@parcel/watcher-linux-arm64-musl": "2.5.6", + "@parcel/watcher-linux-x64-glibc": "2.5.6", + "@parcel/watcher-linux-x64-musl": "2.5.6", + "@parcel/watcher-win32-arm64": "2.5.6", + "@parcel/watcher-win32-ia32": "2.5.6", + "@parcel/watcher-win32-x64": "2.5.6" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", + "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", + "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", + "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", + "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", + "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", + "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", + "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", + "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", + "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", + "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", + "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", + "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", + "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@popperjs/core": { + "name": "@sxzz/popperjs-es", + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@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/@rolldown/pluginutils": { + "version": "1.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", + "integrity": "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/node": { + "version": "24.10.12", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.12.tgz", + "integrity": "sha512-68e+T28EbdmLSTkPgs3+UacC6rzmqrcWFPQs1C8mwJhI/r5Uxr0yEuQotczNRROd1gq30NGxee+fo0rSIxpyAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.4.tgz", + "integrity": "sha512-uM5iXipgYIn13UUQCZNdWkYk+sysBeA97d5mHsAoAt1u/wpN3+zxOmsVJWosuzX+IMGRzeYUNytztrYznboIkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.27", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.27.tgz", + "integrity": "sha512-DjmjBWZ4tJKxfNC1F6HyYERNHPYS7L7OPFyCrestykNdUZMFYzI9WTyvwPcaNaHlrEUwESHYsfEw3isInncZxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.27" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.27", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.27.tgz", + "integrity": "sha512-ynlcBReMgOZj2i6po+qVswtDUeeBRCTgDurjMGShbm8WYZgJ0PA4RmtebBJ0BCYol1qPv3GQF6jK7C9qoVc7lg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.27", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.27.tgz", + "integrity": "sha512-eWaYCcl/uAPInSK2Lze6IqVWaBu/itVqR5InXcHXFyles4zO++Mglt3oxdgj75BDcv1Knr9Y93nowS8U3wqhxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.27", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.28.tgz", + "integrity": "sha512-kviccYxTgoE8n6OCw96BNdYlBg2GOWfBuOW4Vqwrt7mSKWKwFVvI8egdTltqRgITGPsTFYtKYfxIG8ptX2PJHQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/shared": "3.5.28", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.28.tgz", + "integrity": "sha512-/1ZepxAb159jKR1btkefDP+J2xuWL5V3WtleRmxaT+K2Aqiek/Ab/+Ebrw2pPj0sdHO8ViAyyJWfhXXOP/+LQA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.28", + "@vue/shared": "3.5.28" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.28.tgz", + "integrity": "sha512-6TnKMiNkd6u6VeVDhZn/07KhEZuBSn43Wd2No5zaP5s3xm8IqFTHBj84HJah4UepSUJTro5SoqqlOY22FKY96g==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/compiler-core": "3.5.28", + "@vue/compiler-dom": "3.5.28", + "@vue/compiler-ssr": "3.5.28", + "@vue/shared": "3.5.28", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.28.tgz", + "integrity": "sha512-JCq//9w1qmC6UGLWJX7RXzrGpKkroubey/ZFqTpvEIDJEKGgntuDMqkuWiZvzTzTA5h2qZvFBFHY7fAAa9475g==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.28", + "@vue/shared": "3.5.28" + } + }, + "node_modules/@vue/devtools-api": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz", + "integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==", + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^7.7.9" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz", + "integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==", + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^7.7.9", + "birpc": "^2.3.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz", + "integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==", + "license": "MIT", + "dependencies": { + "rfdc": "^1.4.1" + } + }, + "node_modules/@vue/language-core": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.2.4.tgz", + "integrity": "sha512-bqBGuSG4KZM45KKTXzGtoCl9cWju5jsaBKaJJe3h5hRAAWpZUuj5G+L+eI01sPIkm4H6setKRlw7E85wLdDNew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.27", + "@vue/compiler-dom": "^3.5.0", + "@vue/shared": "^3.5.0", + "alien-signals": "^3.0.0", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1", + "picomatch": "^4.0.2" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.28.tgz", + "integrity": "sha512-gr5hEsxvn+RNyu9/9o1WtdYdwDjg5FgjUSBEkZWqgTKlo/fvwZ2+8W6AfKsc9YN2k/+iHYdS9vZYAhpi10kNaw==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.28" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.28.tgz", + "integrity": "sha512-POVHTdbgnrBBIpnbYU4y7pOMNlPn2QVxVzkvEA2pEgvzbelQq4ZOUxbp2oiyo+BOtiYlm8Q44wShHJoBvDPAjQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.28", + "@vue/shared": "3.5.28" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.28.tgz", + "integrity": "sha512-4SXxSF8SXYMuhAIkT+eBRqOkWEfPu6nhccrzrkioA6l0boiq7sp18HCOov9qWJA5HML61kW8p/cB4MmBiG9dSA==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.28", + "@vue/runtime-core": "3.5.28", + "@vue/shared": "3.5.28", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.28.tgz", + "integrity": "sha512-pf+5ECKGj8fX95bNincbzJ6yp6nyzuLDhYZCeFxUNp8EBrQpPpQaLX3nNCp49+UbgbPun3CeVE+5CXVV1Xydfg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.28", + "@vue/shared": "3.5.28" + }, + "peerDependencies": { + "vue": "3.5.28" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.28.tgz", + "integrity": "sha512-cfWa1fCGBxrvaHRhvV3Is0MgmrbSCxYTXCSCau2I0a1Xw1N1pHAvkWCiXPRAqjvToILvguNyEwjevUqAuBQWvQ==", + "license": "MIT" + }, + "node_modules/@vue/tsconfig": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@vue/tsconfig/-/tsconfig-0.8.1.tgz", + "integrity": "sha512-aK7feIWPXFSUhsCP9PFqPyFOcz4ENkb8hZ2pneL6m2UjCkccvaOhC/5KCKluuBufvp2KzkbdA2W2pk20vLzu3g==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "typescript": "5.x", + "vue": "^3.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/@vueuse/core": { + "version": "14.2.1", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.2.1.tgz", + "integrity": "sha512-3vwDzV+GDUNpdegRY6kzpLm4Igptq+GA0QkJ3W61Iv27YWwW/ufSlOfgQIpN6FZRMG0mkaz4gglJRtq5SeJyIQ==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "14.2.1", + "@vueuse/shared": "14.2.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@vueuse/metadata": { + "version": "14.2.1", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.2.1.tgz", + "integrity": "sha512-1ButlVtj5Sb/HDtIy1HFr1VqCP4G6Ypqt5MAo0lCgjokrk2mvQKsK2uuy0vqu/Ks+sHfuHo0B9Y9jn9xKdjZsw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "14.2.1", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.2.1.tgz", + "integrity": "sha512-shTJncjV9JTI4oVNyF1FQonetYAiTBd+Qj7cY89SWbXSkx7gyhrgtEdF2ZAVWS1S3SHlaROO6F2IesJxQEkZBw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/alien-signals": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.1.2.tgz", + "integrity": "sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", + "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/copy-anything": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", + "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", + "license": "MIT", + "dependencies": { + "is-what": "^5.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.19", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/element-plus": { + "version": "2.13.2", + "resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.13.2.tgz", + "integrity": "sha512-Zjzm1NnFXGhV4LYZ6Ze9skPlYi2B4KAmN18FL63A3PZcjhDfroHwhtM6RE8BonlOPHXUnPQynH0BgaoEfvhrGw==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^3.4.1", + "@element-plus/icons-vue": "^2.3.2", + "@floating-ui/dom": "^1.0.1", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.7", + "@types/lodash": "^4.17.20", + "@types/lodash-es": "^4.17.12", + "@vueuse/core": "^10.11.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.19", + "lodash": "^4.17.23", + "lodash-es": "^4.17.23", + "lodash-unified": "^1.0.3", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0" + }, + "peerDependencies": { + "vue": "^3.3.0" + } + }, + "node_modules/element-plus/node_modules/@types/web-bluetooth": { + "version": "0.0.20", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", + "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==", + "license": "MIT" + }, + "node_modules/element-plus/node_modules/@vueuse/core": { + "version": "10.11.1", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.11.1.tgz", + "integrity": "sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.20", + "@vueuse/metadata": "10.11.1", + "@vueuse/shared": "10.11.1", + "vue-demi": ">=0.14.8" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/element-plus/node_modules/@vueuse/core/node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/element-plus/node_modules/@vueuse/metadata": { + "version": "10.11.1", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.11.1.tgz", + "integrity": "sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/element-plus/node_modules/@vueuse/shared": { + "version": "10.11.1", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-10.11.1.tgz", + "integrity": "sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==", + "license": "MIT", + "dependencies": { + "vue-demi": ">=0.14.8" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/element-plus/node_modules/@vueuse/shared/node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/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/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/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/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "license": "MIT" + }, + "node_modules/immutable": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz", + "integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-what": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", + "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/local-pkg": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz", + "integrity": "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mlly": "^1.7.4", + "pkg-types": "^2.3.0", + "quansync": "^0.2.11" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz", + "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", + "license": "MIT" + }, + "node_modules/lodash-unified": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/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.npmjs.org/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/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, + "node_modules/mlly": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", + "integrity": "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.15.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.1" + } + }, + "node_modules/mlly/node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/mlly/node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "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/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/normalize-wheel-es": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", + "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==", + "license": "BSD-3-Clause" + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz", + "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^7.7.7" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.5.0", + "vue": "^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/pkg-types": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", + "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.2.2", + "exsolve": "^1.0.7", + "pathe": "^2.0.3" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "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.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/quansync": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", + "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/antfu" + }, + { + "type": "individual", + "url": "https://github.com/sponsors/sxzz" + } + ], + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "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.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/sass": { + "version": "1.97.3", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.97.3.tgz", + "integrity": "sha512-fDz1zJpd5GycprAbu4Q2PV/RprsRtKC/0z82z0JLgdytmcq0+ujJbJ/09bPGDxCLkKY3Np5cRAOcWiVkLXJURg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.0", + "immutable": "^5.0.2", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, + "node_modules/scule": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz", + "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", + "dev": true, + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/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/speakingurl": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", + "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/superjson": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", + "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", + "license": "MIT", + "dependencies": { + "copy-anything": "^4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/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/ufo": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/unimport": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/unimport/-/unimport-5.6.0.tgz", + "integrity": "sha512-8rqAmtJV8o60x46kBAJKtHpJDJWkA2xcBqWKPI14MgUb05o1pnpnCnXSxedUXyeq7p8fR5g3pTo2BaswZ9lD9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.15.0", + "escape-string-regexp": "^5.0.0", + "estree-walker": "^3.0.3", + "local-pkg": "^1.1.2", + "magic-string": "^0.30.21", + "mlly": "^1.8.0", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "pkg-types": "^2.3.0", + "scule": "^1.3.0", + "strip-literal": "^3.1.0", + "tinyglobby": "^0.2.15", + "unplugin": "^2.3.11", + "unplugin-utils": "^0.3.1" + }, + "engines": { + "node": ">=18.12.0" + } + }, + "node_modules/unimport/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/unplugin": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz", + "integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "acorn": "^8.15.0", + "picomatch": "^4.0.3", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": ">=18.12.0" + } + }, + "node_modules/unplugin-auto-import": { + "version": "21.0.0", + "resolved": "https://registry.npmjs.org/unplugin-auto-import/-/unplugin-auto-import-21.0.0.tgz", + "integrity": "sha512-vWuC8SwqJmxZFYwPojhOhOXDb5xFhNNcEVb9K/RFkyk/3VnfaOjzitWN7v+8DEKpMjSsY2AEGXNgt6I0yQrhRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "local-pkg": "^1.1.2", + "magic-string": "^0.30.21", + "picomatch": "^4.0.3", + "unimport": "^5.6.0", + "unplugin": "^2.3.11", + "unplugin-utils": "^0.3.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@nuxt/kit": "^4.0.0", + "@vueuse/core": "*" + }, + "peerDependenciesMeta": { + "@nuxt/kit": { + "optional": true + }, + "@vueuse/core": { + "optional": true + } + } + }, + "node_modules/unplugin-utils": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/unplugin-utils/-/unplugin-utils-0.3.1.tgz", + "integrity": "sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==", + "dev": true, + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/unplugin-vue-components": { + "version": "31.0.0", + "resolved": "https://registry.npmjs.org/unplugin-vue-components/-/unplugin-vue-components-31.0.0.tgz", + "integrity": "sha512-4ULwfTZTLuWJ7+S9P7TrcStYLsSRkk6vy2jt/WTfgUEUb0nW9//xxmrfhyHUEVpZ2UKRRwfRb8Yy15PDbVZf+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^5.0.0", + "local-pkg": "^1.1.2", + "magic-string": "^0.30.21", + "mlly": "^1.8.0", + "obug": "^2.1.1", + "picomatch": "^4.0.3", + "tinyglobby": "^0.2.15", + "unplugin": "^2.3.11", + "unplugin-utils": "^0.3.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@nuxt/kit": "^3.2.2 || ^4.0.0", + "vue": "^3.0.0" + }, + "peerDependenciesMeta": { + "@nuxt/kit": { + "optional": true + } + } + }, + "node_modules/unplugin-vue-components/node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/unplugin-vue-components/node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.28.tgz", + "integrity": "sha512-BRdrNfeoccSoIZeIhyPBfvWSLFP4q8J3u8Ju8Ug5vu3LdD+yTM13Sg4sKtljxozbnuMu1NB1X5HBHRYUzFocKg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.28", + "@vue/compiler-sfc": "3.5.28", + "@vue/runtime-dom": "3.5.28", + "@vue/server-renderer": "3.5.28", + "@vue/shared": "3.5.28" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/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-router/node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/vue-tsc": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.2.4.tgz", + "integrity": "sha512-xj3YCvSLNDKt1iF9OcImWHhmYcihVu9p4b9s4PGR/qp6yhW+tZJaypGxHScRyOrdnHvaOeF+YkZOdKwbgGvp5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.27", + "@vue/language-core": "3.2.4" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/web-app-vue/package.json b/web-app-vue/package.json new file mode 100644 index 0000000..8dad7ef --- /dev/null +++ b/web-app-vue/package.json @@ -0,0 +1,30 @@ +{ + "name": "web-app-vue", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@vueuse/core": "^14.2.1", + "axios": "^1.13.5", + "element-plus": "^2.13.2", + "pinia": "^3.0.4", + "vue": "^3.5.25", + "vue-router": "^4.6.4" + }, + "devDependencies": { + "@types/node": "^24.10.1", + "@vitejs/plugin-vue": "^6.0.2", + "@vue/tsconfig": "^0.8.1", + "sass": "^1.97.3", + "typescript": "~5.9.3", + "unplugin-auto-import": "^21.0.0", + "unplugin-vue-components": "^31.0.0", + "vite": "^7.3.1", + "vue-tsc": "^3.1.5" + } +} diff --git a/web-app-vue/public/vite.svg b/web-app-vue/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/web-app-vue/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web-app-vue/src/App.vue b/web-app-vue/src/App.vue new file mode 100644 index 0000000..27b26e8 --- /dev/null +++ b/web-app-vue/src/App.vue @@ -0,0 +1,22 @@ + + + + + diff --git a/web-app-vue/src/api/auth.ts b/web-app-vue/src/api/auth.ts new file mode 100644 index 0000000..913a9e2 --- /dev/null +++ b/web-app-vue/src/api/auth.ts @@ -0,0 +1,85 @@ +import request from '@/utils/request' +import type { ApiResponse } from '@/types/api' +import type { + LoginRequest, + LoginResponse, + RegisterRequest, + User, + ChangePasswordRequest, + UpdateProfileRequest, +} from '@/types/user' + +/** + * 用户登录 + */ +export function login(data: LoginRequest): Promise> { + return request({ + url: '/app/auth/login', + method: 'post', + data, + }) +} + +/** + * 用户注册 + */ +export function register(data: RegisterRequest): Promise> { + return request({ + url: '/app/auth/register', + method: 'post', + data, + }) +} + +/** + * 获取用户信息 + */ +export function getUserInfo(): Promise> { + return request({ + url: '/app/auth/userinfo', + method: 'get', + }) +} + +/** + * 用户登出 + */ +export function logout(): Promise> { + return request({ + url: '/app/auth/logout', + method: 'post', + }) +} + +/** + * 刷新 Token + */ +export function refreshToken(refreshToken: string): Promise> { + return request({ + url: '/app/auth/refresh', + method: 'post', + data: { refreshToken }, + }) +} + +/** + * 修改密码 + */ +export function changePassword(data: ChangePasswordRequest): Promise> { + return request({ + url: '/app/user/change-password', + method: 'post', + data, + }) +} + +/** + * 更新用户信息 + */ +export function updateProfile(data: UpdateProfileRequest): Promise> { + return request({ + url: '/app/user/profile', + method: 'put', + data, + }) +} diff --git a/web-app-vue/src/api/character.ts b/web-app-vue/src/api/character.ts new file mode 100644 index 0000000..56ebc36 --- /dev/null +++ b/web-app-vue/src/api/character.ts @@ -0,0 +1,128 @@ +import request from '@/utils/request' +import type { + Character, + CharacterListResponse, + CharacterListParams, + CreateCharacterRequest, + UpdateCharacterRequest, + CharacterExportData +} from '@/types/character' + +/** + * 获取公开角色卡列表(无需鉴权) + */ +export function getPublicCharacterList(params: CharacterListParams) { + return request.get('/app/character/public', { params }) +} + +/** + * 获取我的角色卡列表 + */ +export function getMyCharacterList(params: CharacterListParams) { + return request.get('/app/character/my', { params }) +} + +/** + * 获取角色卡详情 + */ +export function getCharacterDetail(id: number) { + return request.get(`/app/character/${id}`) +} + +/** + * 创建角色卡 + */ +export function createCharacter(data: CreateCharacterRequest) { + return request.post('/app/character', data) +} + +/** + * 更新角色卡 + */ +export function updateCharacter(data: UpdateCharacterRequest) { + return request.put('/app/character', data) +} + +/** + * 删除角色卡 + */ +export function deleteCharacter(id: number) { + return request.delete(`/app/character/${id}`) +} + +/** + * 切换收藏状态 + */ +export function toggleFavorite(characterId: number) { + return request.post<{ isFavorited: boolean }>('/app/character/favorite', { characterId }) +} + +/** + * 点赞角色卡 + */ +export function likeCharacter(characterId: number) { + return request.post('/app/character/like', { characterId }) +} + +/** + * 导出角色卡为 JSON + */ +export function exportCharacter(id: number) { + return request.get(`/app/character/${id}/export`) +} + +/** + * 导出角色卡为 PNG + */ +export function exportCharacterAsPNG(id: number) { + return request.get(`/app/character/${id}/export/png`, { + responseType: 'blob' + }) +} + +/** + * 下载角色卡为 JSON 文件 + */ +export async function downloadCharacterJSON(id: number, filename?: string) { + const data = await exportCharacter(id) + const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = filename || `character_${id}.json` + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(url) +} + +/** + * 下载角色卡为 PNG 文件 + */ +export async function downloadCharacterPNG(id: number, filename?: string) { + const { data } = await exportCharacterAsPNG(id) + const blob = new Blob([data], { type: 'image/png' }) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = filename || `character_${id}.png` + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(url) +} + +/** + * 导入角色卡 + */ +export function importCharacter(file: File, isPublic: boolean = false) { + const formData = new FormData() + formData.append('file', file) + formData.append('isPublic', isPublic.toString()) + + return request.post('/app/character/import', formData, { + headers: { + 'Content-Type': 'multipart/form-data' + } + }) +} diff --git a/web-app-vue/src/assets/styles/index.scss b/web-app-vue/src/assets/styles/index.scss new file mode 100644 index 0000000..cb8dc33 --- /dev/null +++ b/web-app-vue/src/assets/styles/index.scss @@ -0,0 +1,50 @@ +/* 全局样式 */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, +body { + width: 100%; + height: 100%; + font-family: 'Segoe UI', 'Noto Sans', -apple-system, BlinkMacSystemFont, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +#app { + width: 100%; + min-height: 100vh; +} + +/* 自定义滚动条 */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: var(--el-fill-color-light); +} + +::-webkit-scrollbar-thumb { + background: var(--el-border-color-darker); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--el-border-color-dark); +} + +/* 选中文本样式 */ +::selection { + background: var(--el-color-primary); + color: #fff; +} + +/* 平滑滚动 */ +html { + scroll-behavior: smooth; +} diff --git a/web-app-vue/src/assets/vue.svg b/web-app-vue/src/assets/vue.svg new file mode 100644 index 0000000..770e9d3 --- /dev/null +++ b/web-app-vue/src/assets/vue.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web-app-vue/src/auto-imports.d.ts b/web-app-vue/src/auto-imports.d.ts new file mode 100644 index 0000000..101b7d6 --- /dev/null +++ b/web-app-vue/src/auto-imports.d.ts @@ -0,0 +1,90 @@ +/* eslint-disable */ +/* prettier-ignore */ +// @ts-nocheck +// noinspection JSUnusedGlobalSymbols +// Generated by unplugin-auto-import +// biome-ignore lint: disable +export {} +declare global { + const EffectScope: typeof import('vue').EffectScope + const acceptHMRUpdate: typeof import('pinia').acceptHMRUpdate + const computed: typeof import('vue').computed + const createApp: typeof import('vue').createApp + const createPinia: typeof import('pinia').createPinia + const customRef: typeof import('vue').customRef + const defineAsyncComponent: typeof import('vue').defineAsyncComponent + const defineComponent: typeof import('vue').defineComponent + const defineStore: typeof import('pinia').defineStore + const effectScope: typeof import('vue').effectScope + const getActivePinia: typeof import('pinia').getActivePinia + const getCurrentInstance: typeof import('vue').getCurrentInstance + const getCurrentScope: typeof import('vue').getCurrentScope + const getCurrentWatcher: typeof import('vue').getCurrentWatcher + const h: typeof import('vue').h + const inject: typeof import('vue').inject + const isProxy: typeof import('vue').isProxy + const isReactive: typeof import('vue').isReactive + const isReadonly: typeof import('vue').isReadonly + const isRef: typeof import('vue').isRef + const isShallow: typeof import('vue').isShallow + const mapActions: typeof import('pinia').mapActions + const mapGetters: typeof import('pinia').mapGetters + const mapState: typeof import('pinia').mapState + const mapStores: typeof import('pinia').mapStores + const mapWritableState: typeof import('pinia').mapWritableState + const markRaw: typeof import('vue').markRaw + const nextTick: typeof import('vue').nextTick + const onActivated: typeof import('vue').onActivated + const onBeforeMount: typeof import('vue').onBeforeMount + const onBeforeRouteLeave: typeof import('vue-router').onBeforeRouteLeave + const onBeforeRouteUpdate: typeof import('vue-router').onBeforeRouteUpdate + const onBeforeUnmount: typeof import('vue').onBeforeUnmount + const onBeforeUpdate: typeof import('vue').onBeforeUpdate + const onDeactivated: typeof import('vue').onDeactivated + const onErrorCaptured: typeof import('vue').onErrorCaptured + const onMounted: typeof import('vue').onMounted + const onRenderTracked: typeof import('vue').onRenderTracked + const onRenderTriggered: typeof import('vue').onRenderTriggered + const onScopeDispose: typeof import('vue').onScopeDispose + const onServerPrefetch: typeof import('vue').onServerPrefetch + const onUnmounted: typeof import('vue').onUnmounted + const onUpdated: typeof import('vue').onUpdated + const onWatcherCleanup: typeof import('vue').onWatcherCleanup + const provide: typeof import('vue').provide + const reactive: typeof import('vue').reactive + const readonly: typeof import('vue').readonly + const ref: typeof import('vue').ref + const resolveComponent: typeof import('vue').resolveComponent + const setActivePinia: typeof import('pinia').setActivePinia + const setMapStoreSuffix: typeof import('pinia').setMapStoreSuffix + const shallowReactive: typeof import('vue').shallowReactive + const shallowReadonly: typeof import('vue').shallowReadonly + const shallowRef: typeof import('vue').shallowRef + const storeToRefs: typeof import('pinia').storeToRefs + const toRaw: typeof import('vue').toRaw + const toRef: typeof import('vue').toRef + const toRefs: typeof import('vue').toRefs + const toValue: typeof import('vue').toValue + const triggerRef: typeof import('vue').triggerRef + const unref: typeof import('vue').unref + const useAttrs: typeof import('vue').useAttrs + const useCssModule: typeof import('vue').useCssModule + const useCssVars: typeof import('vue').useCssVars + const useId: typeof import('vue').useId + const useLink: typeof import('vue-router').useLink + const useModel: typeof import('vue').useModel + const useRoute: typeof import('vue-router').useRoute + const useRouter: typeof import('vue-router').useRouter + const useSlots: typeof import('vue').useSlots + const useTemplateRef: typeof import('vue').useTemplateRef + const watch: typeof import('vue').watch + const watchEffect: typeof import('vue').watchEffect + const watchPostEffect: typeof import('vue').watchPostEffect + const watchSyncEffect: typeof import('vue').watchSyncEffect +} +// for type re-export +declare global { + // @ts-ignore + export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue' + import('vue') +} diff --git a/web-app-vue/src/components.d.ts b/web-app-vue/src/components.d.ts new file mode 100644 index 0000000..44c7e2e --- /dev/null +++ b/web-app-vue/src/components.d.ts @@ -0,0 +1,46 @@ +/* eslint-disable */ +// @ts-nocheck +// biome-ignore lint: disable +// oxlint-disable +// ------ +// Generated by unplugin-vue-components +// Read more: https://github.com/vuejs/core/pull/3399 + +export {} + +/* prettier-ignore */ +declare module 'vue' { + export interface GlobalComponents { + ElAvatar: typeof import('element-plus/es')['ElAvatar'] + ElButton: typeof import('element-plus/es')['ElButton'] + ElCard: typeof import('element-plus/es')['ElCard'] + ElCol: typeof import('element-plus/es')['ElCol'] + ElContainer: typeof import('element-plus/es')['ElContainer'] + ElDropdown: typeof import('element-plus/es')['ElDropdown'] + ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem'] + ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu'] + ElEmpty: typeof import('element-plus/es')['ElEmpty'] + ElForm: typeof import('element-plus/es')['ElForm'] + ElFormItem: typeof import('element-plus/es')['ElFormItem'] + ElHeader: typeof import('element-plus/es')['ElHeader'] + ElIcon: typeof import('element-plus/es')['ElIcon'] + ElInput: typeof import('element-plus/es')['ElInput'] + ElLink: typeof import('element-plus/es')['ElLink'] + ElMain: typeof import('element-plus/es')['ElMain'] + ElMenu: typeof import('element-plus/es')['ElMenu'] + ElMenuItem: typeof import('element-plus/es')['ElMenuItem'] + ElOption: typeof import('element-plus/es')['ElOption'] + ElRow: typeof import('element-plus/es')['ElRow'] + ElSelect: typeof import('element-plus/es')['ElSelect'] + ElStatistic: typeof import('element-plus/es')['ElStatistic'] + ElSwitch: typeof import('element-plus/es')['ElSwitch'] + ElTag: typeof import('element-plus/es')['ElTag'] + ElUpload: typeof import('element-plus/es')['ElUpload'] + HelloWorld: typeof import('./components/HelloWorld.vue')['default'] + RouterLink: typeof import('vue-router')['RouterLink'] + RouterView: typeof import('vue-router')['RouterView'] + } + export interface GlobalDirectives { + vLoading: typeof import('element-plus/es')['ElLoadingDirective'] + } +} diff --git a/web-app-vue/src/components/HelloWorld.vue b/web-app-vue/src/components/HelloWorld.vue new file mode 100644 index 0000000..b58e52b --- /dev/null +++ b/web-app-vue/src/components/HelloWorld.vue @@ -0,0 +1,41 @@ + + + + + diff --git a/web-app-vue/src/layouts/AuthLayout.vue b/web-app-vue/src/layouts/AuthLayout.vue new file mode 100644 index 0000000..a39dd0d --- /dev/null +++ b/web-app-vue/src/layouts/AuthLayout.vue @@ -0,0 +1,117 @@ + + + + + diff --git a/web-app-vue/src/layouts/DefaultLayout.vue b/web-app-vue/src/layouts/DefaultLayout.vue new file mode 100644 index 0000000..7e6f894 --- /dev/null +++ b/web-app-vue/src/layouts/DefaultLayout.vue @@ -0,0 +1,173 @@ + + + + + diff --git a/web-app-vue/src/main.ts b/web-app-vue/src/main.ts new file mode 100644 index 0000000..df2687c --- /dev/null +++ b/web-app-vue/src/main.ts @@ -0,0 +1,19 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import ElementPlus from 'element-plus' +import 'element-plus/dist/index.css' +import 'element-plus/theme-chalk/dark/css-vars.css' +import router from './router' +import App from './App.vue' +import './assets/styles/index.scss' + +const app = createApp(App) +const pinia = createPinia() + +app.use(pinia) +app.use(router) +app.use(ElementPlus, { + size: 'default', +}) + +app.mount('#app') diff --git a/web-app-vue/src/router/index.ts b/web-app-vue/src/router/index.ts new file mode 100644 index 0000000..1c4bc83 --- /dev/null +++ b/web-app-vue/src/router/index.ts @@ -0,0 +1,95 @@ +import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router' +import { ElMessage } from 'element-plus' + +const routes: RouteRecordRaw[] = [ + { + path: '/auth', + component: () => import('@/layouts/AuthLayout.vue'), + children: [ + { + path: '', + redirect: '/auth/login', + }, + { + path: 'login', + name: 'Login', + component: () => import('@/views/auth/Login.vue'), + meta: { title: '登录' }, + }, + { + path: 'register', + name: 'Register', + component: () => import('@/views/auth/Register.vue'), + meta: { title: '注册' }, + }, + ], + }, + { + path: '/', + component: () => import('@/layouts/DefaultLayout.vue'), + children: [ + { + path: '', + name: 'Home', + component: () => import('@/views/home/Index.vue'), + meta: { title: '角色广场', requiresAuth: false }, + }, + { + path: 'character/:id', + name: 'CharacterDetail', + component: () => import('@/views/character/Detail.vue'), + meta: { title: '角色详情', requiresAuth: false }, + }, + { + path: 'my-characters', + name: 'MyCharacters', + component: () => import('@/views/character/MyCharacters.vue'), + meta: { title: '我的角色卡', requiresAuth: true }, + }, + { + path: 'character/create', + name: 'CreateCharacter', + component: () => import('@/views/character/Edit.vue'), + meta: { title: '创建角色卡', requiresAuth: true }, + }, + { + path: 'character/:id/edit', + name: 'EditCharacter', + component: () => import('@/views/character/Edit.vue'), + meta: { title: '编辑角色卡', requiresAuth: true }, + }, + ], + }, +] + +const router = createRouter({ + history: createWebHistory(), + routes, +}) + +// 路由守卫 +router.beforeEach((to, _from, next) => { + const token = localStorage.getItem('st_access_token') + + // 设置页面标题 + document.title = to.meta.title ? `${to.meta.title} - 云酒馆` : '云酒馆' + + // 需要认证的路由 + if (to.meta.requiresAuth !== false && !to.path.startsWith('/auth')) { + if (!token) { + ElMessage.warning('请先登录') + next('/auth/login') + return + } + } + + // 已登录用户访问认证页面,重定向到首页 + if (token && to.path.startsWith('/auth')) { + next('/') + return + } + + next() +}) + +export default router diff --git a/web-app-vue/src/stores/auth.ts b/web-app-vue/src/stores/auth.ts new file mode 100644 index 0000000..15331c7 --- /dev/null +++ b/web-app-vue/src/stores/auth.ts @@ -0,0 +1,141 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { ElMessage } from 'element-plus' +import * as authApi from '@/api/auth' +import type { User, LoginRequest, RegisterRequest } from '@/types/user' + +export const useAuthStore = defineStore('auth', () => { + // 状态 + const token = ref(localStorage.getItem('st_access_token') || '') + const refreshToken = ref(localStorage.getItem('st_refresh_token') || '') + const user = ref(null) + const loading = ref(false) + + // 计算属性 + const isLoggedIn = computed(() => !!token.value) + const userAvatar = computed(() => user.value?.avatar || '') + const userName = computed(() => user.value?.nickName || user.value?.username || '') + + // 初始化用户信息 + function initUserInfo() { + const userInfoStr = localStorage.getItem('st_user_info') + if (userInfoStr) { + try { + user.value = JSON.parse(userInfoStr) + } catch (error) { + console.error('解析用户信息失败:', error) + } + } + } + + // 登录 + async function handleLogin(data: LoginRequest): Promise { + try { + loading.value = true + const res = await authApi.login(data) + + // 保存 token 和用户信息 + token.value = res.data.token + refreshToken.value = res.data.refreshToken + user.value = res.data.user + + localStorage.setItem('st_access_token', res.data.token) + localStorage.setItem('st_refresh_token', res.data.refreshToken) + localStorage.setItem('st_user_info', JSON.stringify(res.data.user)) + + ElMessage.success('登录成功') + return true + } catch (error) { + console.error('登录失败:', error) + return false + } finally { + loading.value = false + } + } + + // 注册 + async function handleRegister(data: RegisterRequest): Promise { + try { + loading.value = true + await authApi.register(data) + ElMessage.success('注册成功,请登录') + return true + } catch (error) { + console.error('注册失败:', error) + return false + } finally { + loading.value = false + } + } + + // 登出 + async function handleLogout() { + try { + await authApi.logout() + } catch (error) { + console.error('登出请求失败:', error) + } finally { + // 清除本地数据 + token.value = '' + refreshToken.value = '' + user.value = null + localStorage.removeItem('st_access_token') + localStorage.removeItem('st_refresh_token') + localStorage.removeItem('st_user_info') + + ElMessage.success('已退出登录') + } + } + + // 获取用户信息 + async function fetchUserInfo() { + try { + const res = await authApi.getUserInfo() + user.value = res.data + localStorage.setItem('st_user_info', JSON.stringify(res.data)) + } catch (error) { + console.error('获取用户信息失败:', error) + } + } + + // 刷新 Token + async function handleRefreshToken(): Promise { + try { + if (!refreshToken.value) { + return false + } + + const res = await authApi.refreshToken(refreshToken.value) + + token.value = res.data.token + refreshToken.value = res.data.refreshToken + user.value = res.data.user + + localStorage.setItem('st_access_token', res.data.token) + localStorage.setItem('st_refresh_token', res.data.refreshToken) + localStorage.setItem('st_user_info', JSON.stringify(res.data.user)) + + return true + } catch (error) { + console.error('刷新 Token 失败:', error) + await handleLogout() + return false + } + } + + return { + token, + refreshToken, + user, + loading, + isLoggedIn, + userAvatar, + userName, + initUserInfo, + handleLogin, + handleRegister, + handleLogout, + fetchUserInfo, + handleRefreshToken, + } +}) diff --git a/web-app-vue/src/stores/character.ts b/web-app-vue/src/stores/character.ts new file mode 100644 index 0000000..187f466 --- /dev/null +++ b/web-app-vue/src/stores/character.ts @@ -0,0 +1,334 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import type { + Character, + CharacterListParams, + CreateCharacterRequest, + UpdateCharacterRequest +} from '@/types/character' +import * as characterApi from '@/api/character' +import { ElMessage } from 'element-plus' + +export const useCharacterStore = defineStore('character', () => { + // 状态 + const publicCharacters = ref([]) + const myCharacters = ref([]) + const currentCharacter = ref(null) + const loading = ref(false) + const total = ref(0) + const page = ref(1) + const pageSize = ref(20) + + // 计算属性 + const hasMore = computed(() => publicCharacters.value.length < total.value) + + /** + * 获取公开角色卡列表 + */ + async function fetchPublicCharacters(params?: CharacterListParams, append = false) { + loading.value = true + try { + const { data } = await characterApi.getPublicCharacterList({ + page: page.value, + pageSize: pageSize.value, + ...params + }) + + if (append) { + publicCharacters.value.push(...data.list) + } else { + publicCharacters.value = data.list + } + + total.value = data.total + page.value = data.page + pageSize.value = data.pageSize + + return data + } catch (error) { + ElMessage.error('获取角色卡列表失败') + throw error + } finally { + loading.value = false + } + } + + /** + * 加载更多公开角色卡 + */ + async function loadMore(params?: CharacterListParams) { + if (!hasMore.value || loading.value) return + page.value += 1 + await fetchPublicCharacters(params, true) + } + + /** + * 重置分页 + */ + function resetPagination() { + page.value = 1 + total.value = 0 + publicCharacters.value = [] + } + + /** + * 获取我的角色卡列表 + */ + async function fetchMyCharacters(params?: CharacterListParams) { + loading.value = true + try { + const { data } = await characterApi.getMyCharacterList({ + page: 1, + pageSize: 100, + ...params + }) + myCharacters.value = data.list + return data + } catch (error) { + ElMessage.error('获取我的角色卡失败') + throw error + } finally { + loading.value = false + } + } + + /** + * 获取角色卡详情 + */ + async function fetchCharacterDetail(id: number) { + loading.value = true + try { + const { data } = await characterApi.getCharacterDetail(id) + currentCharacter.value = data + return data + } catch (error) { + ElMessage.error('获取角色卡详情失败') + throw error + } finally { + loading.value = false + } + } + + /** + * 创建角色卡 + */ + async function createCharacter(data: CreateCharacterRequest) { + loading.value = true + try { + const response = await characterApi.createCharacter(data) + ElMessage.success('创建成功') + + // 添加到我的角色卡列表 + myCharacters.value.unshift(response.data) + + return response.data + } catch (error) { + ElMessage.error('创建失败') + throw error + } finally { + loading.value = false + } + } + + /** + * 更新角色卡 + */ + async function updateCharacter(data: UpdateCharacterRequest) { + loading.value = true + try { + const response = await characterApi.updateCharacter(data) + ElMessage.success('更新成功') + + // 更新列表中的角色卡 + const index = myCharacters.value.findIndex(c => c.id === data.id) + if (index !== -1) { + myCharacters.value[index] = response.data + } + + // 更新当前角色卡 + if (currentCharacter.value?.id === data.id) { + currentCharacter.value = response.data + } + + return response.data + } catch (error) { + ElMessage.error('更新失败') + throw error + } finally { + loading.value = false + } + } + + /** + * 删除角色卡 + */ + async function deleteCharacter(id: number) { + loading.value = true + try { + await characterApi.deleteCharacter(id) + ElMessage.success('删除成功') + + // 从列表中移除 + myCharacters.value = myCharacters.value.filter(c => c.id !== id) + + // 清除当前角色卡 + if (currentCharacter.value?.id === id) { + currentCharacter.value = null + } + } catch (error) { + ElMessage.error('删除失败') + throw error + } finally { + loading.value = false + } + } + + /** + * 切换收藏状态 + */ + async function toggleFavorite(characterId: number) { + try { + const { data } = await characterApi.toggleFavorite(characterId) + + // 更新列表中的收藏状态 + const updateFavoriteStatus = (list: Character[]) => { + const character = list.find(c => c.id === characterId) + if (character) { + character.isFavorited = data.isFavorited + character.favoriteCount += data.isFavorited ? 1 : -1 + } + } + + updateFavoriteStatus(publicCharacters.value) + updateFavoriteStatus(myCharacters.value) + + // 更新当前角色卡 + if (currentCharacter.value?.id === characterId) { + currentCharacter.value.isFavorited = data.isFavorited + currentCharacter.value.favoriteCount += data.isFavorited ? 1 : -1 + } + + ElMessage.success(data.isFavorited ? '已收藏' : '已取消收藏') + return data.isFavorited + } catch (error) { + ElMessage.error('操作失败') + throw error + } + } + + /** + * 点赞角色卡 + */ + async function likeCharacter(characterId: number) { + try { + await characterApi.likeCharacter(characterId) + + // 更新列表中的点赞数 + const updateLikeCount = (list: Character[]) => { + const character = list.find(c => c.id === characterId) + if (character) { + character.totalLikes += 1 + } + } + + updateLikeCount(publicCharacters.value) + updateLikeCount(myCharacters.value) + + // 更新当前角色卡 + if (currentCharacter.value?.id === characterId) { + currentCharacter.value.totalLikes += 1 + } + + ElMessage.success('点赞成功') + } catch (error) { + ElMessage.error('点赞失败') + throw error + } + } + + /** + * 导出角色卡为 JSON + */ + async function downloadCharacterJSON(id: number) { + try { + const character = publicCharacters.value.find(c => c.id === id) || + myCharacters.value.find(c => c.id === id) || + currentCharacter.value + + const filename = character ? `${character.name}.json` : `character_${id}.json` + await characterApi.downloadCharacterJSON(id, filename) + ElMessage.success('导出成功') + } catch (error) { + ElMessage.error('导出失败') + throw error + } + } + + /** + * 导出角色卡为 PNG + */ + async function downloadCharacterPNG(id: number) { + try { + const character = publicCharacters.value.find(c => c.id === id) || + myCharacters.value.find(c => c.id === id) || + currentCharacter.value + + const filename = character ? `${character.name}.png` : `character_${id}.png` + await characterApi.downloadCharacterPNG(id, filename) + ElMessage.success('导出成功') + } catch (error) { + ElMessage.error('导出失败') + throw error + } + } + + /** + * 导入角色卡 + */ + async function importCharacter(file: File, isPublic: boolean = false) { + loading.value = true + try { + const { data } = await characterApi.importCharacter(file, isPublic) + ElMessage.success('导入成功') + + // 添加到我的角色卡列表 + myCharacters.value.unshift(data) + + return data + } catch (error) { + ElMessage.error('导入失败') + throw error + } finally { + loading.value = false + } + } + + return { + // 状态 + publicCharacters, + myCharacters, + currentCharacter, + loading, + total, + page, + pageSize, + + // 计算属性 + hasMore, + + // 方法 + fetchPublicCharacters, + loadMore, + resetPagination, + fetchMyCharacters, + fetchCharacterDetail, + createCharacter, + updateCharacter, + deleteCharacter, + toggleFavorite, + likeCharacter, + downloadCharacterJSON, + downloadCharacterPNG, + importCharacter + } +}) diff --git a/web-app-vue/src/style.css b/web-app-vue/src/style.css new file mode 100644 index 0000000..f691315 --- /dev/null +++ b/web-app-vue/src/style.css @@ -0,0 +1,79 @@ +:root { + font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} +a:hover { + color: #535bf2; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} +button:hover { + border-color: #646cff; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +.card { + padding: 2em; +} + +#app { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #f9f9f9; + } +} diff --git a/web-app-vue/src/types/api.d.ts b/web-app-vue/src/types/api.d.ts new file mode 100644 index 0000000..18416c7 --- /dev/null +++ b/web-app-vue/src/types/api.d.ts @@ -0,0 +1,24 @@ +/** + * API 响应类型定义 + */ + +// 统一响应格式 +export interface ApiResponse { + code: number + data: T + msg: string +} + +// 分页请求参数 +export interface PageRequest { + page: number + pageSize: number +} + +// 分页响应 +export interface PageResponse { + list: T[] + total: number + page: number + pageSize: number +} diff --git a/web-app-vue/src/types/character.d.ts b/web-app-vue/src/types/character.d.ts new file mode 100644 index 0000000..9794761 --- /dev/null +++ b/web-app-vue/src/types/character.d.ts @@ -0,0 +1,87 @@ +/** + * 角色卡相关类型定义 + */ + +// 角色卡 +export interface Character { + id: number + name: string + description: string + personality: string + scenario: string + avatar: string + creatorId?: number + creatorName: string + creatorNotes: string + tags: string[] + isPublic: boolean + version: number + firstMessage: string + exampleMessages: string[] + totalChats: number + totalLikes: number + usageCount: number + favoriteCount: number + tokenCount: number + isFavorited: boolean + createdAt: string + updatedAt: string +} + +// 角色卡列表响应 +export interface CharacterListResponse { + list: Character[] + total: number + page: number + pageSize: number +} + +// 角色卡列表请求参数 +export interface CharacterListParams { + page?: number + pageSize?: number + keyword?: string + tags?: string[] + sortBy?: 'newest' | 'popular' | 'mostChats' | 'mostLikes' +} + +// 创建角色卡请求 +export interface CreateCharacterRequest { + name: string + description?: string + personality?: string + scenario?: string + avatar?: string + creatorName?: string + creatorNotes?: string + firstMessage?: string + exampleMessages?: string[] + tags?: string[] + isPublic: boolean +} + +// 更新角色卡请求 +export interface UpdateCharacterRequest extends CreateCharacterRequest { + id: number +} + +// 角色卡导出数据 +export interface CharacterExportData { + spec: string + spec_version: string + data: { + name: string + description: string + personality: string + scenario: string + first_mes: string + mes_example: string + creator_notes: string + system_prompt: string + post_history_instructions: string + tags: string[] + creator: string + character_version: number + extensions: Record + } +} diff --git a/web-app-vue/src/types/user.d.ts b/web-app-vue/src/types/user.d.ts new file mode 100644 index 0000000..c1dc41b --- /dev/null +++ b/web-app-vue/src/types/user.d.ts @@ -0,0 +1,61 @@ +/** + * 用户相关类型定义 + */ + +// 用户信息 +export interface User { + id: number + uuid: string + username: string + nickName: string + email: string + phone: string + avatar: string + status: string + enable: boolean + lastLoginAt: string | null + chatCount: number + messageCount: number + aiSettings: Record | null + preferences: Record | null + createdAt: string +} + +// 登录请求 +export interface LoginRequest { + username: string + password: string +} + +// 注册请求 +export interface RegisterRequest { + username: string + password: string + nickName?: string + email?: string + phone?: string +} + +// 登录响应 +export interface LoginResponse { + user: User + token: string + refreshToken: string + expiresAt: number +} + +// 修改密码请求 +export interface ChangePasswordRequest { + oldPassword: string + newPassword: string +} + +// 更新用户信息请求 +export interface UpdateProfileRequest { + nickName?: string + email?: string + phone?: string + avatar?: string + preferences?: string + aiSettings?: string +} diff --git a/web-app-vue/src/utils/request.ts b/web-app-vue/src/utils/request.ts new file mode 100644 index 0000000..5228605 --- /dev/null +++ b/web-app-vue/src/utils/request.ts @@ -0,0 +1,84 @@ +import axios, { type AxiosInstance, type AxiosResponse } from 'axios' +import { ElMessage } from 'element-plus' +import type { ApiResponse } from '@/types/api' + +// 创建 axios 实例 +const service: AxiosInstance = axios.create({ + baseURL: (import.meta.env?.VITE_API_BASE_URL as string) || 'http://localhost:8888', + timeout: 10000, + headers: { + 'Content-Type': 'application/json', + }, +}) + +// 请求拦截器 +service.interceptors.request.use( + (config) => { + // 从 localStorage 获取 token + const token = localStorage.getItem('st_access_token') + if (token && config.headers) { + config.headers['x-token'] = token + } + return config + }, + (error) => { + console.error('请求错误:', error) + return Promise.reject(error) + } +) + +// 响应拦截器 +service.interceptors.response.use( + (response: AxiosResponse) => { + const res = response.data + + // code 不为 0 表示业务错误 + if (res.code !== 0) { + ElMessage.error(res.msg || '请求失败') + + // 401 未授权,跳转到登录页 + if (res.code === 401) { + localStorage.removeItem('st_access_token') + localStorage.removeItem('st_refresh_token') + localStorage.removeItem('st_user_info') + window.location.href = '/auth/login' + } + + return Promise.reject(new Error(res.msg || '请求失败')) + } + + return res + }, + (error) => { + console.error('响应错误:', error) + + let message = '网络错误' + if (error.response) { + switch (error.response.status) { + case 401: + message = '未授权,请重新登录' + localStorage.removeItem('st_access_token') + localStorage.removeItem('st_refresh_token') + localStorage.removeItem('st_user_info') + window.location.href = '/auth/login' + break + case 403: + message = '拒绝访问' + break + case 404: + message = '请求地址不存在' + break + case 500: + message = '服务器错误' + break + default: + message = error.response.data?.msg || '请求失败' + } + } + + ElMessage.error(message) + return Promise.reject(error) + } +) + +export default service diff --git a/web-app-vue/src/views/auth/Login.vue b/web-app-vue/src/views/auth/Login.vue new file mode 100644 index 0000000..0a3445b --- /dev/null +++ b/web-app-vue/src/views/auth/Login.vue @@ -0,0 +1,165 @@ + + + + + diff --git a/web-app-vue/src/views/auth/Register.vue b/web-app-vue/src/views/auth/Register.vue new file mode 100644 index 0000000..0cd5450 --- /dev/null +++ b/web-app-vue/src/views/auth/Register.vue @@ -0,0 +1,225 @@ + + + + + diff --git a/web-app-vue/src/views/character/Detail.vue b/web-app-vue/src/views/character/Detail.vue new file mode 100644 index 0000000..2ef268f --- /dev/null +++ b/web-app-vue/src/views/character/Detail.vue @@ -0,0 +1,426 @@ + + + + + diff --git a/web-app-vue/src/views/character/Edit.vue b/web-app-vue/src/views/character/Edit.vue new file mode 100644 index 0000000..4a31ba3 --- /dev/null +++ b/web-app-vue/src/views/character/Edit.vue @@ -0,0 +1,381 @@ + + + + + diff --git a/web-app-vue/src/views/character/MyCharacters.vue b/web-app-vue/src/views/character/MyCharacters.vue new file mode 100644 index 0000000..e110634 --- /dev/null +++ b/web-app-vue/src/views/character/MyCharacters.vue @@ -0,0 +1,310 @@ + + + + + diff --git a/web-app-vue/src/views/home/CharacterList.vue b/web-app-vue/src/views/home/CharacterList.vue new file mode 100644 index 0000000..3e20993 --- /dev/null +++ b/web-app-vue/src/views/home/CharacterList.vue @@ -0,0 +1,333 @@ + + + + + diff --git a/web-app-vue/src/views/home/Index.vue b/web-app-vue/src/views/home/Index.vue new file mode 100644 index 0000000..65697ca --- /dev/null +++ b/web-app-vue/src/views/home/Index.vue @@ -0,0 +1,7 @@ + + + diff --git a/web-app-vue/tsconfig.app.json b/web-app-vue/tsconfig.app.json new file mode 100644 index 0000000..8d16e42 --- /dev/null +++ b/web-app-vue/tsconfig.app.json @@ -0,0 +1,16 @@ +{ + "extends": "@vue/tsconfig/tsconfig.dom.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "types": ["vite/client"], + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"] +} diff --git a/web-app-vue/tsconfig.json b/web-app-vue/tsconfig.json new file mode 100644 index 0000000..16521e6 --- /dev/null +++ b/web-app-vue/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "preserve", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + + /* Path mapping */ + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/web-app-vue/tsconfig.node.json b/web-app-vue/tsconfig.node.json new file mode 100644 index 0000000..df0785b --- /dev/null +++ b/web-app-vue/tsconfig.node.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "composite": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/web-app-vue/vite.config.d.ts b/web-app-vue/vite.config.d.ts new file mode 100644 index 0000000..340562a --- /dev/null +++ b/web-app-vue/vite.config.d.ts @@ -0,0 +1,2 @@ +declare const _default: import("vite").UserConfig; +export default _default; diff --git a/web-app-vue/vite.config.js b/web-app-vue/vite.config.js new file mode 100644 index 0000000..3506a01 --- /dev/null +++ b/web-app-vue/vite.config.js @@ -0,0 +1,50 @@ +import { defineConfig } from 'vite'; +import vue from '@vitejs/plugin-vue'; +import { resolve } from 'path'; +import AutoImport from 'unplugin-auto-import/vite'; +import Components from 'unplugin-vue-components/vite'; +import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'; +export default defineConfig({ + plugins: [ + vue(), + AutoImport({ + resolvers: [ElementPlusResolver()], + imports: ['vue', 'vue-router', 'pinia'], + dts: 'src/auto-imports.d.ts', + }), + Components({ + resolvers: [ElementPlusResolver()], + dts: 'src/components.d.ts', + }), + ], + resolve: { + alias: { + '@': resolve(__dirname, 'src'), + }, + }, + server: { + port: 3000, + proxy: { + '/app': { + target: 'http://localhost:8888', + changeOrigin: true, + }, + '/api': { + target: 'http://localhost:8888', + changeOrigin: true, + }, + }, + }, + build: { + outDir: 'dist', + sourcemap: false, + rollupOptions: { + output: { + manualChunks: { + 'element-plus': ['element-plus'], + 'vue-vendor': ['vue', 'vue-router', 'pinia'], + }, + }, + }, + }, +}); diff --git a/web-app-vue/vite.config.ts b/web-app-vue/vite.config.ts new file mode 100644 index 0000000..1e2b510 --- /dev/null +++ b/web-app-vue/vite.config.ts @@ -0,0 +1,51 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import { resolve } from 'path' +import AutoImport from 'unplugin-auto-import/vite' +import Components from 'unplugin-vue-components/vite' +import { ElementPlusResolver } from 'unplugin-vue-components/resolvers' + +export default defineConfig({ + plugins: [ + vue(), + AutoImport({ + resolvers: [ElementPlusResolver()], + imports: ['vue', 'vue-router', 'pinia'], + dts: 'src/auto-imports.d.ts', + }), + Components({ + resolvers: [ElementPlusResolver()], + dts: 'src/components.d.ts', + }), + ], + resolve: { + alias: { + '@': resolve(__dirname, 'src'), + }, + }, + server: { + port: 3000, + proxy: { + '/app': { + target: 'http://localhost:8888', + changeOrigin: true, + }, + '/api': { + target: 'http://localhost:8888', + changeOrigin: true, + }, + }, + }, + build: { + outDir: 'dist', + sourcemap: false, + rollupOptions: { + output: { + manualChunks: { + 'element-plus': ['element-plus'], + 'vue-vendor': ['vue', 'vue-router', 'pinia'], + }, + }, + }, + }, +}) diff --git a/web-app-vue/启动指南.md b/web-app-vue/启动指南.md new file mode 100644 index 0000000..06d2b51 --- /dev/null +++ b/web-app-vue/启动指南.md @@ -0,0 +1,199 @@ +# 云酒馆 Vue 前台 - 快速启动指南 + +## ✅ 已完成的功能 + +### 认证模块 +- ✅ 用户登录 +- ✅ 用户注册 +- ✅ Token 管理 +- ✅ 路由守卫 +- ✅ 自动登录检查 + +### 页面 +- ✅ 登录页面(/auth/login) +- ✅ 注册页面(/auth/register) +- ✅ 首页(/) + +### 技术栈 +- ✅ Vue 3 + TypeScript +- ✅ Vite 5 +- ✅ Element Plus +- ✅ Pinia +- ✅ Vue Router 4 +- ✅ Axios + +## 🚀 启动步骤 + +### 1. 启动 Go 后端 +```bash +# 在项目根目录 +cd server +go run main.go +``` + +**确认后端启动成功:** +- 看到日志:`register table success` +- 看到日志:`前台静态文件服务已启动` +- 访问:http://localhost:8888/app/auth/login (应该返回 404 或错误,这是正常的) + +### 2. 启动 Vue 前端 +```bash +# 在项目根目录 +cd web-app-vue +npm run dev +``` + +**确认前端启动成功:** +- 看到:`VITE v5.x.x ready in xxx ms` +- 看到:`Local: http://localhost:3000/` +- 看到:`Network: use --host to expose` + +### 3. 访问应用 +打开浏览器访问:**http://localhost:3000** + +应该会自动跳转到登录页:**http://localhost:3000/auth/login** + +## 🧪 测试流程 + +### 第一步:注册新用户 +1. 点击「立即注册」链接,或直接访问 http://localhost:3000/auth/register +2. 填写注册信息: + ``` + 用户名:test001 + 密码:123456 + 确认密码:123456 + 昵称:测试用户(可选) + 邮箱:test@example.com(可选) + ``` +3. 点击「注册」按钮 +4. 看到成功提示:「注册成功,请登录」 +5. 自动跳转到登录页 + +### 第二步:登录 +1. 在登录页输入刚才注册的信息: + ``` + 用户名:test001 + 密码:123456 + ``` +2. 点击「登录」按钮 +3. 看到成功提示:「登录成功」 +4. 自动跳转到首页 + +### 第三步:查看首页 +1. 应该看到欢迎信息:「欢迎回来,测试用户!」 +2. 看到用户统计数据(对话数量、消息数量等) +3. 右上角显示用户头像和昵称 + +### 第四步:退出登录 +1. 点击右上角用户头像 +2. 选择「退出登录」 +3. 看到提示:「已退出登录」 +4. 自动跳转到登录页 + +### 第五步:测试路由守卫 +1. 退出登录状态 +2. 直接访问 http://localhost:3000/ +3. 应该自动跳转到 http://localhost:3000/auth/login +4. 看到提示:「请先登录」 + +## 📊 端口说明 + +| 服务 | 端口 | 地址 | +|-----|------|------| +| Go 后端 | 8888 | http://localhost:8888 | +| Vue 前端 | 3000 | http://localhost:3000 | +| API 代理 | - | /app/* → http://localhost:8888 | + +## 🔍 调试技巧 + +### 查看网络请求 +1. 打开浏览器开发者工具(F12) +2. 切换到 Network 标签 +3. 筛选 Fetch/XHR +4. 查看登录/注册的请求和响应 + +### 查看存储的数据 +在浏览器控制台运行: +```javascript +// 查看 Token +console.log(localStorage.getItem('st_access_token')) + +// 查看用户信息 +console.log(JSON.parse(localStorage.getItem('st_user_info'))) + +// 清除登录状态 +localStorage.clear() +``` + +### 查看 Pinia Store 状态 +安装 Vue DevTools 浏览器扩展,可以查看: +- Pinia Stores 状态 +- 组件树 +- 路由信息 + +## ❗ 常见问题 + +### Q1: 启动前端后,页面空白? +**A:** 检查浏览器控制台是否有错误。可能原因: +- 路由配置问题 +- 组件导入路径错误 +- 刷新页面试试 + +### Q2: 登录时提示「网络错误」? +**A:** 检查: +1. Go 后端是否正在运行 +2. 后端地址是否正确(http://localhost:8888) +3. 浏览器控制台的网络请求详情 +4. 是否有 CORS 错误 + +### Q3: 点击登录没反应? +**A:** 检查: +1. 浏览器控制台是否有错误 +2. 表单验证是否通过 +3. 网络请求是否发出 + +### Q4: 登录成功但没跳转? +**A:** 检查: +1. 路由配置是否正确 +2. 浏览器控制台是否有路由错误 +3. localStorage 是否保存了 token + +## 📝 开发技巧 + +### 热更新 +修改代码后,浏览器会自动刷新,无需手动刷新。 + +### 查看类型提示 +在 VSCode 中,鼠标悬停在变量上可以看到类型信息。 + +### 快捷键 +- `Ctrl + C` - 停止开发服务器 +- `Ctrl + Shift + I` - 打开浏览器开发者工具 + +## 🎯 下一步开发 + +### 立即可做 +1. 测试注册和登录功能 +2. 检查 UI 效果 +3. 优化样式细节 + +### 后续开发 +1. 角色管理模块 +2. 对话功能模块 +3. 设置页面 +4. WebSocket 集成 + +## 🎉 恭喜 + +Vue 认证模块已经完成!你现在拥有: +- 🎨 现代化的 UI +- 🔐 完整的认证流程 +- 💪 类型安全的代码 +- 🏗️ 清晰的项目结构 + +**开始测试吧!** 🚀 + +--- + +**创建日期**: 2026-02-10 +**版本**: v1.0.0 diff --git a/web-app/package-lock.json b/web-app/package-lock.json index e68b93c..4ce3ee0 100644 --- a/web-app/package-lock.json +++ b/web-app/package-lock.json @@ -546,7 +546,6 @@ "resolved": "https://registry.npmjs.org/@jimp/custom/-/custom-0.22.12.tgz", "integrity": "sha512-xcmww1O/JFP2MrlGUMd3Q78S3Qu6W3mYTXYuIqFq33EorgYHV/HqymHfXy9GjiCJ7OI+7lWx6nYFOzU7M4rd1Q==", "license": "MIT", - "peer": true, "dependencies": { "@jimp/core": "^0.22.12" } @@ -1008,7 +1007,6 @@ "resolved": "https://registry.npmjs.org/@jimp/plugin-resize/-/plugin-resize-1.6.0.tgz", "integrity": "sha512-uSUD1mqXN9i1SGSz5ov3keRZ7S9L32/mAQG08wUwZiEi5FpbV0K8A8l1zkazAIZi9IJzLlTauRNU41Mi8IF9fA==", "license": "MIT", - "peer": true, "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", @@ -1040,7 +1038,6 @@ "resolved": "https://registry.npmjs.org/@jimp/plugin-scale/-/plugin-scale-0.22.12.tgz", "integrity": "sha512-dghs92qM6MhHj0HrV2qAwKPMklQtjNpoYgAB94ysYpsXslhRTiPisueSIELRwZGEr0J0VUxpUY7HgJwlSIgGZw==", "license": "MIT", - "peer": true, "dependencies": { "@jimp/utils": "^0.22.12" }, @@ -1136,7 +1133,6 @@ "resolved": "https://registry.npmjs.org/@jimp/plugin-blit/-/plugin-blit-0.22.12.tgz", "integrity": "sha512-xslz2ZoFZOPLY8EZ4dC29m168BtDx95D6K80TzgUi8gqT7LY6CsajWO0FAxDwHz6h0eomHMfyGX0stspBrTKnQ==", "license": "MIT", - "peer": true, "dependencies": { "@jimp/utils": "^0.22.12" }, @@ -1173,7 +1169,6 @@ "resolved": "https://registry.npmjs.org/@jimp/plugin-color/-/plugin-color-0.22.12.tgz", "integrity": "sha512-xImhTE5BpS8xa+mAN6j4sMRWaUgUDLoaGHhJhpC+r7SKKErYDR0WQV4yCE4gP+N0gozD0F3Ka1LUSaMXrn7ZIA==", "license": "MIT", - "peer": true, "dependencies": { "@jimp/utils": "^0.22.12", "tinycolor2": "^1.6.0" @@ -1217,7 +1212,6 @@ "resolved": "https://registry.npmjs.org/@jimp/plugin-crop/-/plugin-crop-0.22.12.tgz", "integrity": "sha512-FNuUN0OVzRCozx8XSgP9MyLGMxNHHJMFt+LJuFjn1mu3k0VQxrzqbN06yIl46TVejhyAhcq5gLzqmSCHvlcBVw==", "license": "MIT", - "peer": true, "dependencies": { "@jimp/utils": "^0.22.12" }, @@ -1305,7 +1299,6 @@ "resolved": "https://registry.npmjs.org/@jimp/plugin-resize/-/plugin-resize-0.22.12.tgz", "integrity": "sha512-3NyTPlPbTnGKDIbaBgQ3HbE6wXbAlFfxHVERmrbqAi8R3r6fQPxpCauA8UVDnieg5eo04D0T8nnnNIX//i/sXg==", "license": "MIT", - "peer": true, "dependencies": { "@jimp/utils": "^0.22.12" }, @@ -1318,7 +1311,6 @@ "resolved": "https://registry.npmjs.org/@jimp/plugin-rotate/-/plugin-rotate-0.22.12.tgz", "integrity": "sha512-9YNEt7BPAFfTls2FGfKBVgwwLUuKqy+E8bDGGEsOqHtbuhbshVGxN2WMZaD4gh5IDWvR+emmmPPWGgaYNYt1gA==", "license": "MIT", - "peer": true, "dependencies": { "@jimp/utils": "^0.22.12" }, @@ -1932,7 +1924,6 @@ "integrity": "sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^4.17.33", @@ -2622,7 +2613,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3200,7 +3190,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001663", "electron-to-chromium": "^1.5.28", @@ -4483,7 +4472,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -7834,7 +7822,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", diff --git a/web-app/public/auth.html b/web-app/public/auth.html new file mode 100644 index 0000000..6c1212a --- /dev/null +++ b/web-app/public/auth.html @@ -0,0 +1,175 @@ + + + + + + + + + 云酒馆 - 登录注册 + + + + + +
+ +
+
+
+
+
+ + +
+ +
+ +

云酒馆

+

与 AI 角色开启奇妙对话

+
+ + +
+ + +
+ + +
+
+ + +
+ +
+ +
+ + +
+
+ +
+ + 忘记密码? +
+ + +
+ + +
+
+ + + 支持字母、数字、下划线 +
+ +
+ +
+ + +
+ 建议使用字母、数字、符号组合 +
+ +
+ +
+ + +
+
+ +
+ + +
+ +
+ + +
+ + +
+ + +
+
+ + + +
+ + +
+
+ + 处理中... +
+
+ + + + + + + + + + diff --git a/web-app/public/css/auth.css b/web-app/public/css/auth.css new file mode 100644 index 0000000..27dab3d --- /dev/null +++ b/web-app/public/css/auth.css @@ -0,0 +1,517 @@ +/* ===== 全局样式 ===== */ +:root { + --primary-color: #6366f1; + --primary-hover: #4f46e5; + --primary-light: #818cf8; + --danger-color: #ef4444; + --success-color: #10b981; + --warning-color: #f59e0b; + + --bg-primary: #0f172a; + --bg-secondary: #1e293b; + --bg-tertiary: #334155; + + --text-primary: #f1f5f9; + --text-secondary: #cbd5e1; + --text-tertiary: #94a3b8; + + --border-color: #334155; + --border-radius: 12px; + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1); + --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.2); + --shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.3); +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'Segoe UI', 'Noto Sans', -apple-system, BlinkMacSystemFont, sans-serif; + background: var(--bg-primary); + color: var(--text-primary); + line-height: 1.6; + min-height: 100vh; + overflow-x: hidden; +} + +/* ===== 认证容器 ===== */ +.auth-container { + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 20px; + position: relative; +} + +/* ===== 背景装饰 ===== */ +.auth-background { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 0; + overflow: hidden; + pointer-events: none; +} + +.gradient-orb { + position: absolute; + border-radius: 50%; + filter: blur(80px); + opacity: 0.5; + animation: float 20s infinite ease-in-out; +} + +.orb-1 { + width: 500px; + height: 500px; + background: linear-gradient(135deg, #6366f1, #8b5cf6); + top: -150px; + left: -150px; + animation-delay: 0s; +} + +.orb-2 { + width: 400px; + height: 400px; + background: linear-gradient(135deg, #ec4899, #f43f5e); + bottom: -100px; + right: -100px; + animation-delay: 7s; +} + +.orb-3 { + width: 300px; + height: 300px; + background: linear-gradient(135deg, #14b8a6, #06b6d4); + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + animation-delay: 14s; +} + +@keyframes float { + 0%, 100% { + transform: translate(0, 0) scale(1); + } + 33% { + transform: translate(30px, -30px) scale(1.1); + } + 66% { + transform: translate(-20px, 20px) scale(0.9); + } +} + +/* ===== 认证卡片 ===== */ +.auth-card { + position: relative; + z-index: 1; + background: rgba(30, 41, 59, 0.8); + backdrop-filter: blur(20px); + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + padding: 40px; + width: 100%; + max-width: 480px; + box-shadow: var(--shadow-xl); + animation: slideIn 0.5s ease-out; +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* ===== 头部区域 ===== */ +.auth-header { + text-align: center; + margin-bottom: 32px; +} + +.auth-logo { + width: 80px; + height: 80px; + margin-bottom: 16px; + animation: logoFloat 3s ease-in-out infinite; +} + +@keyframes logoFloat { + 0%, 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-10px); + } +} + +.auth-title { + font-size: 32px; + font-weight: 700; + background: linear-gradient(135deg, #6366f1, #8b5cf6); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + margin-bottom: 8px; +} + +.auth-subtitle { + font-size: 14px; + color: var(--text-secondary); +} + +/* ===== 标签页 ===== */ +.auth-tabs { + display: flex; + gap: 12px; + margin-bottom: 32px; + background: var(--bg-primary); + border-radius: 8px; + padding: 4px; +} + +.auth-tab { + flex: 1; + padding: 12px 16px; + border: none; + background: transparent; + color: var(--text-secondary); + font-size: 15px; + font-weight: 500; + cursor: pointer; + border-radius: 6px; + transition: all 0.3s ease; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; +} + +.auth-tab:hover { + background: var(--bg-secondary); + color: var(--text-primary); +} + +.auth-tab.active { + background: var(--primary-color); + color: white; + box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3); +} + +/* ===== 表单 ===== */ +.auth-form { + display: none; +} + +.auth-form.active { + display: block; + animation: fadeIn 0.3s ease-out; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.form-group { + margin-bottom: 20px; +} + +.form-group label { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + margin-bottom: 8px; +} + +.form-group label i { + color: var(--primary-color); + width: 16px; +} + +.form-group input { + width: 100%; + padding: 12px 16px; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 8px; + color: var(--text-primary); + font-size: 15px; + transition: all 0.3s ease; +} + +.form-group input:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); +} + +.form-group input::placeholder { + color: var(--text-tertiary); +} + +.form-hint { + display: block; + font-size: 12px; + color: var(--text-tertiary); + margin-top: 6px; +} + +/* ===== 密码输入框 ===== */ +.password-input-wrapper { + position: relative; +} + +.password-input-wrapper input { + padding-right: 48px; +} + +.password-toggle { + position: absolute; + right: 12px; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + color: var(--text-tertiary); + cursor: pointer; + padding: 8px; + border-radius: 4px; + transition: all 0.2s ease; +} + +.password-toggle:hover { + background: var(--bg-secondary); + color: var(--text-primary); +} + +/* ===== 表单操作 ===== */ +.form-actions { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; + font-size: 14px; +} + +.remember-me { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + color: var(--text-secondary); +} + +.remember-me input[type="checkbox"] { + width: auto; + cursor: pointer; +} + +.forgot-password { + color: var(--primary-light); + text-decoration: none; + transition: color 0.2s ease; +} + +.forgot-password:hover { + color: var(--primary-color); +} + +/* ===== 提交按钮 ===== */ +.auth-button { + width: 100%; + padding: 14px 24px; + background: linear-gradient(135deg, #6366f1, #8b5cf6); + border: none; + border-radius: 8px; + color: white; + font-size: 16px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3); +} + +.auth-button:hover { + background: linear-gradient(135deg, #4f46e5, #7c3aed); + transform: translateY(-2px); + box-shadow: 0 6px 16px rgba(99, 102, 241, 0.4); +} + +.auth-button:active { + transform: translateY(0); +} + +.auth-button:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; +} + +/* ===== 消息提示框 ===== */ +.message-box { + margin-top: 16px; + padding: 12px 16px; + border-radius: 8px; + font-size: 14px; + display: none; + animation: slideDown 0.3s ease-out; +} + +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.message-box.show { + display: block; +} + +.message-box.success { + background: rgba(16, 185, 129, 0.1); + border: 1px solid var(--success-color); + color: var(--success-color); +} + +.message-box.error { + background: rgba(239, 68, 68, 0.1); + border: 1px solid var(--danger-color); + color: var(--danger-color); +} + +.message-box.warning { + background: rgba(245, 158, 11, 0.1); + border: 1px solid var(--warning-color); + color: var(--warning-color); +} + +/* ===== 页脚 ===== */ +.auth-footer { + position: relative; + z-index: 1; + margin-top: 24px; + text-align: center; + color: var(--text-tertiary); + font-size: 13px; +} + +/* ===== 加载动画 ===== */ +.loading-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(15, 23, 42, 0.9); + backdrop-filter: blur(4px); + display: none; + align-items: center; + justify-content: center; + z-index: 9999; +} + +.loading-overlay.show { + display: flex; +} + +.loading-spinner { + text-align: center; + color: white; +} + +.loading-spinner i { + font-size: 48px; + color: var(--primary-color); + margin-bottom: 16px; +} + +.loading-spinner span { + display: block; + font-size: 16px; + color: var(--text-secondary); +} + +/* ===== 响应式设计 ===== */ +@media (max-width: 640px) { + .auth-card { + padding: 24px; + } + + .auth-title { + font-size: 24px; + } + + .auth-logo { + width: 60px; + height: 60px; + } + + .form-actions { + flex-direction: column; + gap: 12px; + align-items: flex-start; + } + + .gradient-orb { + filter: blur(60px); + } +} + +/* ===== 深色模式优化 ===== */ +@media (prefers-color-scheme: light) { + :root { + --bg-primary: #f8fafc; + --bg-secondary: #f1f5f9; + --bg-tertiary: #e2e8f0; + + --text-primary: #0f172a; + --text-secondary: #475569; + --text-tertiary: #64748b; + + --border-color: #e2e8f0; + } + + .auth-card { + background: rgba(255, 255, 255, 0.9); + } +} + +/* ===== 平滑滚动 ===== */ +html { + scroll-behavior: smooth; +} + +/* ===== 选中文本样式 ===== */ +::selection { + background: var(--primary-color); + color: white; +} diff --git a/web-app/public/scripts/auth.js b/web-app/public/scripts/auth.js new file mode 100644 index 0000000..9bb7afc --- /dev/null +++ b/web-app/public/scripts/auth.js @@ -0,0 +1,342 @@ +/** + * 云酒馆 - 用户认证模块 + * 处理用户登录和注册功能 + */ + +// ===== 配置 ===== +const CONFIG = { + API_BASE_URL: 'http://localhost:8888/app', + TOKEN_KEY: 'st_access_token', + REFRESH_TOKEN_KEY: 'st_refresh_token', + USER_KEY: 'st_user_info', +}; + +// ===== DOM 元素 ===== +const elements = { + tabs: document.querySelectorAll('.auth-tab'), + loginForm: document.getElementById('loginForm'), + registerForm: document.getElementById('registerForm'), + messageBox: document.getElementById('messageBox'), + loadingOverlay: document.getElementById('loadingOverlay'), + passwordToggles: document.querySelectorAll('.password-toggle'), +}; + +// ===== 初始化 ===== +document.addEventListener('DOMContentLoaded', () => { + initTabs(); + initForms(); + initPasswordToggles(); + checkAutoLogin(); +}); + +// ===== 标签页切换 ===== +function initTabs() { + elements.tabs.forEach(tab => { + tab.addEventListener('click', () => { + const tabName = tab.dataset.tab; + switchTab(tabName); + }); + }); +} + +function switchTab(tabName) { + // 更新标签页状态 + elements.tabs.forEach(tab => { + tab.classList.toggle('active', tab.dataset.tab === tabName); + }); + + // 更新表单显示 + elements.loginForm.classList.toggle('active', tabName === 'login'); + elements.registerForm.classList.toggle('active', tabName === 'register'); + + // 清空消息 + hideMessage(); +} + +// ===== 表单初始化 ===== +function initForms() { + // 登录表单 + elements.loginForm.addEventListener('submit', async (e) => { + e.preventDefault(); + await handleLogin(); + }); + + // 注册表单 + elements.registerForm.addEventListener('submit', async (e) => { + e.preventDefault(); + await handleRegister(); + }); +} + +// ===== 密码显示/隐藏切换 ===== +function initPasswordToggles() { + elements.passwordToggles.forEach(toggle => { + toggle.addEventListener('click', () => { + const targetId = toggle.dataset.target; + const input = document.getElementById(targetId); + const icon = toggle.querySelector('i'); + + if (input.type === 'password') { + input.type = 'text'; + icon.classList.remove('fa-eye'); + icon.classList.add('fa-eye-slash'); + } else { + input.type = 'password'; + icon.classList.remove('fa-eye-slash'); + icon.classList.add('fa-eye'); + } + }); + }); +} + +// ===== 登录处理 ===== +async function handleLogin() { + const username = document.getElementById('loginUsername').value.trim(); + const password = document.getElementById('loginPassword').value; + const rememberMe = document.getElementById('rememberMe').checked; + + // 验证输入 + if (!username || !password) { + showMessage('请填写完整的登录信息', 'error'); + return; + } + + showLoading(); + + try { + const response = await fetch(`${CONFIG.API_BASE_URL}/auth/login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ username, password }), + }); + + const data = await response.json(); + + if (data.code === 0) { + // 登录成功 + saveLoginData(data.data, rememberMe); + showMessage('登录成功!正在跳转...', 'success'); + + // 延迟跳转到主页 + setTimeout(() => { + window.location.href = '/'; + }, 1500); + } else { + // 登录失败 + showMessage(data.msg || '登录失败,请检查用户名和密码', 'error'); + } + } catch (error) { + console.error('登录错误:', error); + showMessage('网络错误,请检查服务器连接', 'error'); + } finally { + hideLoading(); + } +} + +// ===== 注册处理 ===== +async function handleRegister() { + const username = document.getElementById('registerUsername').value.trim(); + const password = document.getElementById('registerPassword').value; + const passwordConfirm = document.getElementById('registerPasswordConfirm').value; + const nickName = document.getElementById('registerNickname').value.trim(); + const email = document.getElementById('registerEmail').value.trim(); + + // 验证输入 + if (!username || !password || !passwordConfirm) { + showMessage('请填写必填信息', 'error'); + return; + } + + if (username.length < 3 || username.length > 32) { + showMessage('用户名长度应为 3-32 个字符', 'error'); + return; + } + + if (password.length < 6 || password.length > 32) { + showMessage('密码长度应为 6-32 个字符', 'error'); + return; + } + + if (password !== passwordConfirm) { + showMessage('两次输入的密码不一致', 'error'); + return; + } + + // 验证用户名格式 + if (!/^[a-zA-Z0-9_]+$/.test(username)) { + showMessage('用户名只能包含字母、数字和下划线', 'error'); + return; + } + + showLoading(); + + try { + const requestBody = { + username, + password, + nickName: nickName || username, // 如果没有昵称,使用用户名 + }; + + // 如果填写了邮箱,添加到请求中 + if (email) { + requestBody.email = email; + } + + const response = await fetch(`${CONFIG.API_BASE_URL}/auth/register`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }); + + const data = await response.json(); + + if (data.code === 0) { + // 注册成功 + showMessage('注册成功!请登录', 'success'); + + // 清空注册表单 + elements.registerForm.reset(); + + // 切换到登录标签页,并填充用户名 + setTimeout(() => { + switchTab('login'); + document.getElementById('loginUsername').value = username; + document.getElementById('loginPassword').focus(); + }, 1500); + } else { + // 注册失败 + showMessage(data.msg || '注册失败,请稍后重试', 'error'); + } + } catch (error) { + console.error('注册错误:', error); + showMessage('网络错误,请检查服务器连接', 'error'); + } finally { + hideLoading(); + } +} + +// ===== 保存登录数据 ===== +function saveLoginData(loginData, rememberMe) { + const { token, refreshToken, user } = loginData; + + // 保存到 localStorage 或 sessionStorage + const storage = rememberMe ? localStorage : sessionStorage; + + storage.setItem(CONFIG.TOKEN_KEY, token); + storage.setItem(CONFIG.REFRESH_TOKEN_KEY, refreshToken); + storage.setItem(CONFIG.USER_KEY, JSON.stringify(user)); +} + +// ===== 自动登录检查 ===== +function checkAutoLogin() { + // 检查是否已经登录 + const token = localStorage.getItem(CONFIG.TOKEN_KEY) || + sessionStorage.getItem(CONFIG.TOKEN_KEY); + + if (token) { + // 已登录,尝试验证 token 是否有效 + verifyToken(token); + } +} + +// ===== 验证 Token ===== +async function verifyToken(token) { + try { + const response = await fetch(`${CONFIG.API_BASE_URL}/auth/userinfo`, { + method: 'GET', + headers: { + 'x-token': token, + }, + }); + + if (response.ok) { + const data = await response.json(); + if (data.code === 0) { + // Token 有效,直接跳转到主页 + showMessage('检测到已登录,正在跳转...', 'success'); + setTimeout(() => { + window.location.href = '/'; + }, 1000); + } + } + } catch (error) { + console.error('Token 验证失败:', error); + // Token 无效,清除存储 + clearLoginData(); + } +} + +// ===== 清除登录数据 ===== +function clearLoginData() { + localStorage.removeItem(CONFIG.TOKEN_KEY); + localStorage.removeItem(CONFIG.REFRESH_TOKEN_KEY); + localStorage.removeItem(CONFIG.USER_KEY); + sessionStorage.removeItem(CONFIG.TOKEN_KEY); + sessionStorage.removeItem(CONFIG.REFRESH_TOKEN_KEY); + sessionStorage.removeItem(CONFIG.USER_KEY); +} + +// ===== 消息提示 ===== +function showMessage(message, type = 'error') { + elements.messageBox.textContent = message; + elements.messageBox.className = `message-box show ${type}`; + + // 3秒后自动隐藏 + setTimeout(() => { + hideMessage(); + }, 5000); +} + +function hideMessage() { + elements.messageBox.classList.remove('show'); +} + +// ===== 加载动画 ===== +function showLoading() { + elements.loadingOverlay.classList.add('show'); +} + +function hideLoading() { + elements.loadingOverlay.classList.remove('show'); +} + +// ===== 工具函数:获取当前登录用户 ===== +export function getCurrentUser() { + const userJson = localStorage.getItem(CONFIG.USER_KEY) || + sessionStorage.getItem(CONFIG.USER_KEY); + return userJson ? JSON.parse(userJson) : null; +} + +// ===== 工具函数:获取 Token ===== +export function getToken() { + return localStorage.getItem(CONFIG.TOKEN_KEY) || + sessionStorage.getItem(CONFIG.TOKEN_KEY); +} + +// ===== 工具函数:登出 ===== +export async function logout() { + const token = getToken(); + + if (token) { + try { + await fetch(`${CONFIG.API_BASE_URL}/auth/logout`, { + method: 'POST', + headers: { + 'x-token': token, + }, + }); + } catch (error) { + console.error('登出请求失败:', error); + } + } + + clearLoginData(); + window.location.href = '/auth.html'; +} + +// ===== 导出配置供其他模块使用 ===== +export { CONFIG };