🎨 重构用户端前端为vue开发,完善基础类和角色相关接口

This commit is contained in:
2026-02-10 21:55:45 +08:00
parent db934ebed7
commit 56e821b222
92 changed files with 18377 additions and 21 deletions

57
create-vue-app.bat Normal file
View File

@@ -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

356
create-vue-app.sh Executable file
View File

@@ -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"

14
deploy/.gitignore vendored Normal file
View File

@@ -0,0 +1,14 @@
# PostgreSQL 数据
postgres/data/
*.sql.backup
*.dump
# 日志文件
*.log
# 环境变量文件(如果创建的话)
.env
.env.local
# pgAdmin 数据
pgadmin_data/

293
deploy/README.postgres.md Normal file
View File

@@ -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/)

View File

@@ -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

View File

@@ -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

View File

@@ -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"]

82
deploy/postgres/init.sql Normal file
View File

@@ -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 ''

View File

@@ -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 # 排序和哈希表内存
# WALWrite-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

109
deploy/start-postgres.sh Normal file
View File

@@ -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 "=========================================="

41
deploy/stop-postgres.sh Normal file
View File

@@ -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 "=========================================="

648
docs/Vue重构方案.md Normal file
View File

@@ -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<string>(localStorage.getItem('st_access_token') || '')
const user = ref<User | null>(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
<!-- src/components/character/CharacterCard.vue -->
<template>
<div class="character-card" @click="handleClick">
<div class="card-header">
<img :src="character.avatar" :alt="character.name" class="avatar" />
<el-icon v-if="isFavorite" class="favorite-icon">
<Star />
</el-icon>
</div>
<div class="card-body">
<h3 class="character-name">{{ character.name }}</h3>
<p class="character-description">{{ character.description }}</p>
</div>
<div class="card-footer">
<el-tag v-for="tag in character.tags" :key="tag" size="small">
{{ tag }}
</el-tag>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { Character } from '@/types/character'
interface Props {
character: Character
}
const props = defineProps<Props>()
const emit = defineEmits<{
click: [character: Character]
}>()
const isFavorite = computed(() => props.character.isFavorite)
function handleClick() {
emit('click', props.character)
}
</script>
<style scoped lang="scss">
.character-card {
border-radius: 12px;
overflow: hidden;
cursor: pointer;
transition: all 0.3s;
background: var(--el-bg-color);
&:hover {
transform: translateY(-4px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
}
.card-header {
position: relative;
.avatar {
width: 100%;
height: 200px;
object-fit: cover;
}
.favorite-icon {
position: absolute;
top: 12px;
right: 12px;
color: #f59e0b;
font-size: 24px;
}
}
.card-body {
padding: 16px;
.character-name {
font-size: 18px;
font-weight: bold;
margin-bottom: 8px;
}
.character-description {
color: var(--el-text-color-secondary);
font-size: 14px;
line-height: 1.6;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
}
.card-footer {
padding: 0 16px 16px;
display: flex;
gap: 8px;
flex-wrap: wrap;
}
}
</style>
```
## 🔄 迁移策略
### 渐进式迁移(推荐)
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

View File

@@ -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
- [ ] 多设备登录管理
- [ ] 用户在线状态

3077
docs/重构实施方案.md Normal file

File diff suppressed because it is too large Load Diff

1986
docs/重构进度管理.md Normal file

File diff suppressed because it is too large Load Diff

194
server/api/v1/app/auth.go Normal file
View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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
)

View File

@@ -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 {

View File

@@ -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")
}

View File

@@ -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")
@@ -123,6 +145,7 @@ func Routers() *gin.Engine {
{
appGroup := PublicGroup.Group("app") // 统一使用 /app 前缀
appRouter.InitAuthRouter(appGroup) // 认证路由:/app/auth/* 和 /app/user/*
appRouter.InitCharacterRouter(appGroup) // 角色卡路由:/app/character/*
}
//插件路由安装

View File

@@ -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)))
}

213
server/model/app/README.md Normal file
View File

@@ -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` 类型,维度为 1536OpenAI 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
**维护者**: 开发团队

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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:所属用户IDNULL表示系统预设"`
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"
}

View File

@@ -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:用户IDNULL表示系统配置"`
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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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 字符串
}

View File

@@ -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"`
}

View File

@@ -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,
}
}

View File

@@ -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,
}
}

36
server/router/app/auth.go Normal file
View File

@@ -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) // 修改密码
}
}

View File

@@ -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) // 导入角色卡
}
}

View File

@@ -2,4 +2,5 @@ package app
type RouterGroup struct {
AuthRouter
CharacterRouter
}

254
server/service/app/auth.go Normal file
View File

@@ -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
}

View File

@@ -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<START>\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 != "" {
// 按 <START> 分割
exampleMessages = strings.Split(card.Data.MesExample, "<START>")
// 清理空白
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<START>\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))
}

View File

@@ -2,4 +2,5 @@ package app
type AppServiceGroup struct {
AuthService
CharacterService
}

84
server/utils/app_jwt.go Normal file
View File

@@ -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")
}

View File

@@ -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
}

View File

@@ -0,0 +1,3 @@
# 开发环境配置
VITE_API_BASE_URL=http://localhost:8888
VITE_WS_URL=ws://localhost:8888

View File

@@ -0,0 +1,3 @@
# 生产环境配置
VITE_API_BASE_URL=https://your-domain.com
VITE_WS_URL=wss://your-domain.com

24
web-app-vue/.gitignore vendored Normal file
View File

@@ -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?

3
web-app-vue/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

35
web-app-vue/README.md Normal file
View File

@@ -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`

13
web-app-vue/index.html Normal file
View File

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

2998
web-app-vue/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
web-app-vue/package.json Normal file
View File

@@ -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"
}
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

22
web-app-vue/src/App.vue Normal file
View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
// 初始化用户信息
onMounted(() => {
authStore.initUserInfo()
})
</script>
<template>
<router-view />
</template>
<style>
#app {
width: 100%;
min-height: 100vh;
}
</style>

View File

@@ -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<ApiResponse<LoginResponse>> {
return request({
url: '/app/auth/login',
method: 'post',
data,
})
}
/**
* 用户注册
*/
export function register(data: RegisterRequest): Promise<ApiResponse<void>> {
return request({
url: '/app/auth/register',
method: 'post',
data,
})
}
/**
* 获取用户信息
*/
export function getUserInfo(): Promise<ApiResponse<User>> {
return request({
url: '/app/auth/userinfo',
method: 'get',
})
}
/**
* 用户登出
*/
export function logout(): Promise<ApiResponse<void>> {
return request({
url: '/app/auth/logout',
method: 'post',
})
}
/**
* 刷新 Token
*/
export function refreshToken(refreshToken: string): Promise<ApiResponse<LoginResponse>> {
return request({
url: '/app/auth/refresh',
method: 'post',
data: { refreshToken },
})
}
/**
* 修改密码
*/
export function changePassword(data: ChangePasswordRequest): Promise<ApiResponse<void>> {
return request({
url: '/app/user/change-password',
method: 'post',
data,
})
}
/**
* 更新用户信息
*/
export function updateProfile(data: UpdateProfileRequest): Promise<ApiResponse<void>> {
return request({
url: '/app/user/profile',
method: 'put',
data,
})
}

View File

@@ -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<CharacterListResponse>('/app/character/public', { params })
}
/**
* 获取我的角色卡列表
*/
export function getMyCharacterList(params: CharacterListParams) {
return request.get<CharacterListResponse>('/app/character/my', { params })
}
/**
* 获取角色卡详情
*/
export function getCharacterDetail(id: number) {
return request.get<Character>(`/app/character/${id}`)
}
/**
* 创建角色卡
*/
export function createCharacter(data: CreateCharacterRequest) {
return request.post<Character>('/app/character', data)
}
/**
* 更新角色卡
*/
export function updateCharacter(data: UpdateCharacterRequest) {
return request.put<Character>('/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<CharacterExportData>(`/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<Character>('/app/character/import', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
}

View File

@@ -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;
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

90
web-app-vue/src/auto-imports.d.ts vendored Normal file
View File

@@ -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')
}

46
web-app-vue/src/components.d.ts vendored Normal file
View File

@@ -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']
}
}

View File

@@ -0,0 +1,41 @@
<script setup lang="ts">
import { ref } from 'vue'
defineProps<{ msg: string }>()
const count = ref(0)
</script>
<template>
<h1>{{ msg }}</h1>
<div class="card">
<button type="button" @click="count++">count is {{ count }}</button>
<p>
Edit
<code>components/HelloWorld.vue</code> to test HMR
</p>
</div>
<p>
Check out
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
>create-vue</a
>, the official Vue + Vite starter
</p>
<p>
Learn more about IDE Support for Vue in the
<a
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
target="_blank"
>Vue Docs Scaling up Guide</a
>.
</p>
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
</template>
<style scoped>
.read-the-docs {
color: #888;
}
</style>

View File

@@ -0,0 +1,117 @@
<template>
<div class="auth-layout">
<!-- 背景装饰 -->
<div class="auth-background">
<div class="gradient-orb orb-1"></div>
<div class="gradient-orb orb-2"></div>
<div class="gradient-orb orb-3"></div>
</div>
<!-- 主内容 -->
<div class="auth-content">
<router-view />
</div>
<!-- 页脚 -->
<div class="auth-footer">
<p>© 2026 云酒馆 | Powered by AI</p>
</div>
</div>
</template>
<script setup lang="ts">
// 认证页面布局
</script>
<style scoped lang="scss">
.auth-layout {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px;
position: relative;
overflow: hidden;
}
.auth-background {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
pointer-events: none;
}
.gradient-orb {
position: absolute;
border-radius: 50%;
filter: blur(80px);
opacity: 0.4;
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-content {
position: relative;
z-index: 1;
width: 100%;
max-width: 480px;
}
.auth-footer {
position: fixed;
bottom: 20px;
text-align: center;
color: var(--el-text-color-secondary);
font-size: 13px;
z-index: 1;
}
@media (max-width: 640px) {
.gradient-orb {
filter: blur(60px);
}
}
</style>

View File

@@ -0,0 +1,173 @@
<template>
<el-container class="default-layout">
<!-- 顶部导航栏 -->
<el-header class="layout-header">
<div class="header-left">
<h1 class="logo" @click="router.push('/')">云酒馆</h1>
<!-- 主导航菜单 -->
<el-menu
:default-active="activeMenu"
mode="horizontal"
:ellipsis="false"
class="header-menu"
@select="handleMenuSelect"
>
<el-menu-item index="/">
<el-icon><Grid /></el-icon>
<span>角色广场</span>
</el-menu-item>
<el-menu-item v-if="authStore.isLoggedIn" index="/my-characters">
<el-icon><Files /></el-icon>
<span>我的角色卡</span>
</el-menu-item>
</el-menu>
</div>
<div class="header-right">
<!-- 未登录状态 -->
<div v-if="!authStore.isLoggedIn" class="auth-buttons">
<el-button size="small" @click="router.push('/auth/login')">
登录
</el-button>
<el-button type="primary" size="small" @click="router.push('/auth/register')">
注册
</el-button>
</div>
<!-- 已登录状态 -->
<el-dropdown v-else @command="handleCommand">
<div class="user-info">
<el-avatar :src="authStore.userAvatar" size="small">
{{ authStore.userName?.charAt(0) }}
</el-avatar>
<span class="username">{{ authStore.userName }}</span>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="profile">个人中心</el-dropdown-item>
<el-dropdown-item command="settings">设置</el-dropdown-item>
<el-dropdown-item divided command="logout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header>
<!-- 主内容区 -->
<el-main class="layout-main">
<router-view />
</el-main>
</el-container>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { Grid, Files } from '@element-plus/icons-vue'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
// 初始化用户信息
authStore.initUserInfo()
// 当前激活的菜单
const activeMenu = computed(() => {
if (route.path.startsWith('/my-characters')) return '/my-characters'
return '/'
})
// 菜单选择处理
function handleMenuSelect(index: string) {
router.push(index)
}
// 下拉菜单命令处理
function handleCommand(command: string) {
switch (command) {
case 'profile':
router.push('/user/profile')
break
case 'settings':
router.push('/settings')
break
case 'logout':
authStore.handleLogout().then(() => {
router.push('/auth/login')
})
break
}
}
</script>
<style scoped lang="scss">
.default-layout {
min-height: 100vh;
}
.layout-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
background: var(--el-bg-color);
border-bottom: 1px solid var(--el-border-color);
height: 60px;
.header-left {
display: flex;
align-items: center;
gap: 32px;
.logo {
font-size: 20px;
font-weight: bold;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin: 0;
cursor: pointer;
user-select: none;
}
.header-menu {
border: none;
background: transparent;
}
}
.header-right {
.auth-buttons {
display: flex;
gap: 12px;
}
.user-info {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 8px 12px;
border-radius: 8px;
transition: background 0.3s;
&:hover {
background: var(--el-fill-color-light);
}
.username {
font-size: 14px;
}
}
}
}
.layout-main {
padding: 20px;
background: var(--el-bg-color-page);
}
</style>

19
web-app-vue/src/main.ts Normal file
View File

@@ -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')

View File

@@ -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

View File

@@ -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<string>(localStorage.getItem('st_access_token') || '')
const refreshToken = ref<string>(localStorage.getItem('st_refresh_token') || '')
const user = ref<User | null>(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<boolean> {
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<boolean> {
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<boolean> {
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,
}
})

View File

@@ -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<Character[]>([])
const myCharacters = ref<Character[]>([])
const currentCharacter = ref<Character | null>(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
}
})

79
web-app-vue/src/style.css Normal file
View File

@@ -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;
}
}

24
web-app-vue/src/types/api.d.ts vendored Normal file
View File

@@ -0,0 +1,24 @@
/**
* API 响应类型定义
*/
// 统一响应格式
export interface ApiResponse<T = any> {
code: number
data: T
msg: string
}
// 分页请求参数
export interface PageRequest {
page: number
pageSize: number
}
// 分页响应
export interface PageResponse<T> {
list: T[]
total: number
page: number
pageSize: number
}

87
web-app-vue/src/types/character.d.ts vendored Normal file
View File

@@ -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<string, any>
}
}

61
web-app-vue/src/types/user.d.ts vendored Normal file
View File

@@ -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<string, any> | null
preferences: Record<string, any> | 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
}

View File

@@ -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<ApiResponse>) => {
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

View File

@@ -0,0 +1,165 @@
<template>
<div class="login-container">
<el-card class="login-card" shadow="always">
<!-- Logo 和标题 -->
<div class="login-header">
<h1 class="title">云酒馆</h1>
<p class="subtitle"> AI 角色开启奇妙对话</p>
</div>
<!-- 登录表单 -->
<el-form
ref="formRef"
:model="loginForm"
:rules="rules"
size="large"
@keyup.enter="handleLogin"
>
<el-form-item prop="username">
<el-input
v-model="loginForm.username"
placeholder="用户名"
:prefix-icon="User"
clearable
/>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="loginForm.password"
type="password"
placeholder="密码"
:prefix-icon="Lock"
show-password
clearable
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
class="login-button"
:loading="authStore.loading"
@click="handleLogin"
>
登录
</el-button>
</el-form-item>
</el-form>
<!-- 底部链接 -->
<div class="login-footer">
<span>还没有账号</span>
<el-link type="primary" @click="goToRegister">立即注册</el-link>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { User, Lock } from '@element-plus/icons-vue'
import type { FormInstance, FormRules } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
import type { LoginRequest } from '@/types/user'
const router = useRouter()
const authStore = useAuthStore()
const formRef = ref<FormInstance>()
// 表单数据
const loginForm = reactive<LoginRequest>({
username: '',
password: '',
})
// 表单验证规则
const rules: FormRules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, max: 32, message: '用户名长度为 3-32 个字符', trigger: 'blur' },
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, max: 32, message: '密码长度为 6-32 个字符', trigger: 'blur' },
],
}
// 处理登录
async function handleLogin() {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (!valid) return
const success = await authStore.handleLogin(loginForm)
if (success) {
// 登录成功,跳转到首页
router.push('/')
}
})
}
// 跳转到注册页
function goToRegister() {
router.push('/auth/register')
}
</script>
<style scoped lang="scss">
.login-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 80vh;
}
.login-card {
width: 100%;
max-width: 420px;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(20px);
.login-header {
text-align: center;
margin-bottom: 32px;
.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;
}
.subtitle {
font-size: 14px;
color: var(--el-text-color-secondary);
margin: 0;
}
}
.login-button {
width: 100%;
}
.login-footer {
text-align: center;
font-size: 14px;
color: var(--el-text-color-secondary);
span {
margin-right: 8px;
}
}
}
@media (max-width: 640px) {
.login-card {
margin: 0 16px;
}
}
</style>

View File

@@ -0,0 +1,225 @@
<template>
<div class="register-container">
<el-card class="register-card" shadow="always">
<!-- Logo 和标题 -->
<div class="register-header">
<h1 class="title">注册账号</h1>
<p class="subtitle">加入云酒馆开启你的 AI 对话之旅</p>
</div>
<!-- 注册表单 -->
<el-form
ref="formRef"
:model="registerForm"
:rules="rules"
size="large"
>
<el-form-item prop="username">
<el-input
v-model="registerForm.username"
placeholder="用户名3-32个字符"
:prefix-icon="User"
clearable
/>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="registerForm.password"
type="password"
placeholder="密码6-32个字符"
:prefix-icon="Lock"
show-password
clearable
/>
</el-form-item>
<el-form-item prop="confirmPassword">
<el-input
v-model="registerForm.confirmPassword"
type="password"
placeholder="确认密码"
:prefix-icon="Lock"
show-password
clearable
/>
</el-form-item>
<el-form-item prop="nickName">
<el-input
v-model="registerForm.nickName"
placeholder="昵称(可选)"
:prefix-icon="UserFilled"
clearable
/>
</el-form-item>
<el-form-item prop="email">
<el-input
v-model="registerForm.email"
placeholder="邮箱(可选,用于找回密码)"
:prefix-icon="Message"
clearable
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
class="register-button"
:loading="authStore.loading"
@click="handleRegister"
>
注册
</el-button>
</el-form-item>
</el-form>
<!-- 底部链接 -->
<div class="register-footer">
<span>已有账号</span>
<el-link type="primary" @click="goToLogin">立即登录</el-link>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { User, Lock, UserFilled, Message } from '@element-plus/icons-vue'
import type { FormInstance, FormRules } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
import type { RegisterRequest } from '@/types/user'
const router = useRouter()
const authStore = useAuthStore()
const formRef = ref<FormInstance>()
// 表单数据
interface RegisterForm extends RegisterRequest {
confirmPassword: string
}
const registerForm = reactive<RegisterForm>({
username: '',
password: '',
confirmPassword: '',
nickName: '',
email: '',
})
// 验证确认密码
const validateConfirmPassword = (_rule: any, value: string, callback: any) => {
if (value === '') {
callback(new Error('请再次输入密码'))
} else if (value !== registerForm.password) {
callback(new Error('两次输入密码不一致'))
} else {
callback()
}
}
// 表单验证规则
const rules: FormRules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, max: 32, message: '用户名长度为 3-32 个字符', trigger: 'blur' },
{
pattern: /^[a-zA-Z0-9_]+$/,
message: '用户名只能包含字母、数字和下划线',
trigger: 'blur',
},
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, max: 32, message: '密码长度为 6-32 个字符', trigger: 'blur' },
],
confirmPassword: [
{ required: true, validator: validateConfirmPassword, trigger: 'blur' },
],
email: [
{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' },
],
}
// 处理注册
async function handleRegister() {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (!valid) return
// 移除 confirmPassword 字段
const { confirmPassword, ...registerData } = registerForm
const success = await authStore.handleRegister(registerData)
if (success) {
// 注册成功,跳转到登录页
router.push('/auth/login')
}
})
}
// 跳转到登录页
function goToLogin() {
router.push('/auth/login')
}
</script>
<style scoped lang="scss">
.register-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 80vh;
}
.register-card {
width: 100%;
max-width: 420px;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(20px);
.register-header {
text-align: center;
margin-bottom: 32px;
.title {
font-size: 28px;
font-weight: 700;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 8px;
}
.subtitle {
font-size: 14px;
color: var(--el-text-color-secondary);
margin: 0;
}
}
.register-button {
width: 100%;
}
.register-footer {
text-align: center;
font-size: 14px;
color: var(--el-text-color-secondary);
span {
margin-right: 8px;
}
}
}
@media (max-width: 640px) {
.register-card {
margin: 0 16px;
}
}
</style>

View File

@@ -0,0 +1,426 @@
<template>
<div v-loading="loading" class="character-detail">
<template v-if="character">
<!-- 角色头部 -->
<div class="character-header">
<div class="character-avatar-large">
<img :src="character.avatar || '/default-avatar.png'" :alt="character.name" />
</div>
<div class="character-header-info">
<h1 class="character-title">{{ character.name }}</h1>
<div class="character-meta">
<span v-if="character.creatorName" class="meta-item">
<el-icon><User /></el-icon>
{{ character.creatorName }}
</span>
<span class="meta-item">
<el-icon><Clock /></el-icon>
{{ formatDate(character.createdAt) }}
</span>
<span class="meta-item">
<el-icon><View /></el-icon>
使用 {{ character.usageCount }}
</span>
</div>
<!-- 操作按钮 -->
<div class="character-actions">
<el-button
type="primary"
:icon="ChatLineSquare"
@click="startChat"
>
开始对话
</el-button>
<el-button
:type="character.isFavorited ? 'warning' : 'default'"
:icon="character.isFavorited ? StarFilled : Star"
@click="handleFavorite"
>
{{ character.isFavorited ? '已收藏' : '收藏' }}
</el-button>
<el-button
:icon="Like"
@click="handleLike"
>
点赞 {{ character.totalLikes }}
</el-button>
<el-dropdown @command="handleExport">
<el-button :icon="Download">
导出
<el-icon class="el-icon--right"><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="json">导出为 JSON</el-dropdown-item>
<el-dropdown-item command="png">导出为 PNG</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button
v-if="isOwner"
:icon="Edit"
@click="goToEdit"
>
编辑
</el-button>
<el-button
v-if="isOwner"
type="danger"
:icon="Delete"
@click="handleDelete"
>
删除
</el-button>
</div>
</div>
</div>
<!-- 标签 -->
<div v-if="character.tags && character.tags.length > 0" class="character-tags-section">
<el-tag
v-for="tag in character.tags"
:key="tag"
type="info"
effect="plain"
>
{{ tag }}
</el-tag>
</div>
<!-- 详细信息 -->
<div class="character-details">
<el-tabs>
<el-tab-pane label="基本信息">
<div class="detail-section">
<h3>角色描述</h3>
<p class="detail-text">{{ character.description || '暂无描述' }}</p>
</div>
<div class="detail-section">
<h3>性格特点</h3>
<p class="detail-text">{{ character.personality || '暂无性格描述' }}</p>
</div>
<div class="detail-section">
<h3>场景设定</h3>
<p class="detail-text">{{ character.scenario || '暂无场景设定' }}</p>
</div>
</el-tab-pane>
<el-tab-pane label="对话示例">
<div class="detail-section">
<h3>第一条消息</h3>
<div class="message-box">
{{ character.firstMessage || '暂无第一条消息' }}
</div>
</div>
<div v-if="character.exampleMessages && character.exampleMessages.length > 0" class="detail-section">
<h3>示例对话</h3>
<div
v-for="(msg, index) in character.exampleMessages"
:key="index"
class="message-box"
>
{{ msg }}
</div>
</div>
</el-tab-pane>
<el-tab-pane label="统计信息">
<el-descriptions :column="2" border>
<el-descriptions-item label="对话总数">
{{ character.totalChats }}
</el-descriptions-item>
<el-descriptions-item label="点赞总数">
{{ character.totalLikes }}
</el-descriptions-item>
<el-descriptions-item label="使用次数">
{{ character.usageCount }}
</el-descriptions-item>
<el-descriptions-item label="收藏次数">
{{ character.favoriteCount }}
</el-descriptions-item>
<el-descriptions-item label="Token 数量">
{{ character.tokenCount }}
</el-descriptions-item>
<el-descriptions-item label="角色版本">
v{{ character.version }}
</el-descriptions-item>
<el-descriptions-item label="创建时间">
{{ formatDateTime(character.createdAt) }}
</el-descriptions-item>
<el-descriptions-item label="更新时间">
{{ formatDateTime(character.updatedAt) }}
</el-descriptions-item>
</el-descriptions>
</el-tab-pane>
<el-tab-pane v-if="character.creatorNotes" label="创建者备注">
<div class="detail-section">
<p class="detail-text">{{ character.creatorNotes }}</p>
</div>
</el-tab-pane>
</el-tabs>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useCharacterStore } from '@/stores/character'
import { useAuthStore } from '@/stores/auth'
import {
User,
Clock,
View,
ChatLineSquare,
Star,
StarFilled,
Like,
Download,
Edit,
Delete,
ArrowDown
} from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
const route = useRoute()
const router = useRouter()
const characterStore = useCharacterStore()
const authStore = useAuthStore()
const loading = ref(false)
const character = computed(() => characterStore.currentCharacter)
// 是否是创建者
const isOwner = computed(() => {
return authStore.isLoggedIn &&
character.value?.creatorId === authStore.user?.id
})
// 格式化日期
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleDateString('zh-CN')
}
function formatDateTime(dateStr: string) {
return new Date(dateStr).toLocaleString('zh-CN')
}
// 开始对话
function startChat() {
ElMessage.info('对话功能开发中...')
// TODO: router.push(`/chat/${character.value?.id}`)
}
// 切换收藏
async function handleFavorite() {
if (!authStore.isLoggedIn) {
ElMessage.warning('请先登录')
return
}
if (character.value) {
await characterStore.toggleFavorite(character.value.id)
}
}
// 点赞
async function handleLike() {
if (character.value) {
await characterStore.likeCharacter(character.value.id)
}
}
// 导出
async function handleExport(command: string) {
if (!character.value) return
if (command === 'json') {
await characterStore.downloadCharacterJSON(character.value.id)
} else if (command === 'png') {
await characterStore.downloadCharacterPNG(character.value.id)
}
}
// 编辑
function goToEdit() {
if (character.value) {
router.push(`/character/${character.value.id}/edit`)
}
}
// 删除
async function handleDelete() {
if (!character.value) return
try {
await ElMessageBox.confirm(
'确定要删除这个角色卡吗?此操作不可恢复。',
'确认删除',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
await characterStore.deleteCharacter(character.value.id)
router.push('/')
} catch (error) {
// 用户取消
}
}
// 初始化
onMounted(async () => {
const characterId = Number(route.params.id)
if (characterId) {
loading.value = true
try {
await characterStore.fetchCharacterDetail(characterId)
} finally {
loading.value = false
}
}
})
</script>
<style scoped lang="scss">
.character-detail {
max-width: 1200px;
margin: 0 auto;
padding: 24px;
}
.character-header {
display: flex;
gap: 32px;
margin-bottom: 32px;
padding: 24px;
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
@media (max-width: 768px) {
flex-direction: column;
gap: 20px;
}
}
.character-avatar-large {
flex-shrink: 0;
width: 240px;
height: 320px;
border-radius: 12px;
overflow: hidden;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
img {
width: 100%;
height: 100%;
object-fit: cover;
}
@media (max-width: 768px) {
width: 100%;
height: 400px;
}
}
.character-header-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 16px;
}
.character-title {
font-size: 32px;
font-weight: 600;
color: #303133;
margin: 0;
}
.character-meta {
display: flex;
flex-wrap: wrap;
gap: 16px;
color: #909399;
font-size: 14px;
.meta-item {
display: flex;
align-items: center;
gap: 6px;
}
}
.character-actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: auto;
}
.character-tags-section {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 16px 24px;
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
margin-bottom: 24px;
}
.character-details {
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
padding: 24px;
}
.detail-section {
margin-bottom: 24px;
&:last-child {
margin-bottom: 0;
}
h3 {
font-size: 18px;
font-weight: 600;
color: #303133;
margin-bottom: 12px;
}
}
.detail-text {
font-size: 14px;
line-height: 1.8;
color: #606266;
white-space: pre-wrap;
margin: 0;
}
.message-box {
background: #f5f7fa;
border-radius: 8px;
padding: 16px;
font-size: 14px;
line-height: 1.6;
color: #606266;
white-space: pre-wrap;
margin-bottom: 12px;
&:last-child {
margin-bottom: 0;
}
}
</style>

View File

@@ -0,0 +1,381 @@
<template>
<div class="character-edit-page">
<div class="page-header">
<h2>{{ isEdit ? '编辑角色卡' : '创建角色卡' }}</h2>
<div class="header-actions">
<el-button @click="goBack">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">
{{ isEdit ? '保存' : '创建' }}
</el-button>
</div>
</div>
<el-form
ref="formRef"
:model="formData"
:rules="rules"
label-width="120px"
class="character-form"
>
<el-card class="form-section">
<template #header>
<span>基本信息</span>
</template>
<el-form-item label="角色名称" prop="name">
<el-input
v-model="formData.name"
placeholder="请输入角色名称"
maxlength="100"
show-word-limit
/>
</el-form-item>
<el-form-item label="角色头像" prop="avatar">
<el-input
v-model="formData.avatar"
placeholder="请输入头像URL"
/>
<div v-if="formData.avatar" class="avatar-preview">
<img :src="formData.avatar" alt="预览" />
</div>
</el-form-item>
<el-form-item label="创建者名称">
<el-input
v-model="formData.creatorName"
placeholder="请输入创建者名称"
/>
</el-form-item>
<el-form-item label="是否公开">
<el-switch v-model="formData.isPublic" />
<span class="form-tip">公开后其他用户可以查看和使用此角色卡</span>
</el-form-item>
</el-card>
<el-card class="form-section">
<template #header>
<span>角色描述</span>
</template>
<el-form-item label="角色描述" prop="description">
<el-input
v-model="formData.description"
type="textarea"
:rows="4"
placeholder="请输入角色的基本描述..."
/>
</el-form-item>
<el-form-item label="性格特点">
<el-input
v-model="formData.personality"
type="textarea"
:rows="4"
placeholder="描述角色的性格、特点、行为习惯等..."
/>
</el-form-item>
<el-form-item label="场景设定">
<el-input
v-model="formData.scenario"
type="textarea"
:rows="4"
placeholder="描述角色所处的场景、背景故事等..."
/>
</el-form-item>
</el-card>
<el-card class="form-section">
<template #header>
<span>对话设置</span>
</template>
<el-form-item label="第一条消息">
<el-input
v-model="formData.firstMessage"
type="textarea"
:rows="4"
placeholder="角色在对话开始时的第一条消息..."
/>
</el-form-item>
<el-form-item label="示例对话">
<div class="example-messages">
<div
v-for="(msg, index) in formData.exampleMessages"
:key="index"
class="example-message-item"
>
<el-input
v-model="formData.exampleMessages[index]"
type="textarea"
:rows="3"
placeholder="输入示例对话..."
/>
<el-button
type="danger"
:icon="Delete"
circle
@click="removeExampleMessage(index)"
/>
</div>
<el-button
type="primary"
:icon="Plus"
plain
@click="addExampleMessage"
>
添加示例对话
</el-button>
</div>
</el-form-item>
</el-card>
<el-card class="form-section">
<template #header>
<span>标签和备注</span>
</template>
<el-form-item label="标签">
<el-select
v-model="formData.tags"
multiple
filterable
allow-create
placeholder="输入标签并回车添加"
class="tag-select"
>
<el-option
v-for="tag in commonTags"
:key="tag"
:label="tag"
:value="tag"
/>
</el-select>
</el-form-item>
<el-form-item label="创建者备注">
<el-input
v-model="formData.creatorNotes"
type="textarea"
:rows="4"
placeholder="添加一些创建者的备注信息..."
/>
</el-form-item>
</el-card>
</el-form>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useCharacterStore } from '@/stores/character'
import { Plus, Delete } from '@element-plus/icons-vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import type { CreateCharacterRequest, UpdateCharacterRequest } from '@/types/character'
const route = useRoute()
const router = useRouter()
const characterStore = useCharacterStore()
const formRef = ref<FormInstance>()
const saving = ref(false)
// 是否是编辑模式
const isEdit = computed(() => !!route.params.id)
// 表单数据
const formData = reactive<CreateCharacterRequest>({
name: '',
description: '',
personality: '',
scenario: '',
avatar: '',
creatorName: '',
creatorNotes: '',
firstMessage: '',
exampleMessages: [],
tags: [],
isPublic: false
})
// 常用标签
const commonTags = [
'奇幻',
'科幻',
'现代',
'历史',
'冒险',
'浪漫',
'悬疑',
'喜剧',
'恐怖',
'游戏',
'动漫',
'电影',
'小说',
'原创'
]
// 表单验证规则
const rules = reactive<FormRules>({
name: [
{ required: true, message: '请输入角色名称', trigger: 'blur' },
{ min: 1, max: 100, message: '长度在 1 到 100 个字符', trigger: 'blur' }
]
})
// 添加示例对话
function addExampleMessage() {
formData.exampleMessages?.push('')
}
// 删除示例对话
function removeExampleMessage(index: number) {
formData.exampleMessages?.splice(index, 1)
}
// 返回
function goBack() {
router.back()
}
// 保存
async function handleSave() {
if (!formRef.value) return
try {
await formRef.value.validate()
} catch (error) {
ElMessage.error('请检查表单填写')
return
}
saving.value = true
try {
if (isEdit.value) {
// 编辑
const updateData: UpdateCharacterRequest = {
id: Number(route.params.id),
...formData
}
await characterStore.updateCharacter(updateData)
router.push(`/character/${route.params.id}`)
} else {
// 创建
const character = await characterStore.createCharacter(formData)
router.push(`/character/${character.id}`)
}
} catch (error) {
console.error('保存失败:', error)
} finally {
saving.value = false
}
}
// 初始化
onMounted(async () => {
if (isEdit.value) {
// 编辑模式:加载角色卡数据
const characterId = Number(route.params.id)
await characterStore.fetchCharacterDetail(characterId)
const character = characterStore.currentCharacter
if (character) {
Object.assign(formData, {
name: character.name,
description: character.description,
personality: character.personality,
scenario: character.scenario,
avatar: character.avatar,
creatorName: character.creatorName,
creatorNotes: character.creatorNotes,
firstMessage: character.firstMessage,
exampleMessages: [...(character.exampleMessages || [])],
tags: [...(character.tags || [])],
isPublic: character.isPublic
})
}
}
})
</script>
<style scoped lang="scss">
.character-edit-page {
max-width: 1000px;
margin: 0 auto;
padding: 24px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
h2 {
font-size: 24px;
font-weight: 600;
color: #303133;
margin: 0;
}
.header-actions {
display: flex;
gap: 12px;
}
}
.character-form {
.form-section {
margin-bottom: 24px;
&:last-child {
margin-bottom: 0;
}
}
.form-tip {
margin-left: 12px;
font-size: 13px;
color: #909399;
}
.avatar-preview {
margin-top: 12px;
width: 200px;
height: 267px;
border-radius: 8px;
overflow: hidden;
border: 1px solid #dcdfe6;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.example-messages {
width: 100%;
.example-message-item {
display: flex;
gap: 12px;
margin-bottom: 12px;
align-items: flex-start;
.el-input {
flex: 1;
}
}
}
.tag-select {
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,310 @@
<template>
<div class="my-characters-page">
<div class="page-header">
<h2>我的角色卡</h2>
<div class="header-actions">
<el-upload
:show-file-list="false"
:before-upload="handleImport"
accept=".png,.json"
>
<el-button :icon="Upload">导入角色卡</el-button>
</el-upload>
<el-button type="primary" :icon="Plus" @click="goToCreate">
创建角色卡
</el-button>
</div>
</div>
<!-- 搜索栏 -->
<div class="search-bar">
<el-input
v-model="searchKeyword"
placeholder="搜索我的角色卡..."
clearable
@keyup.enter="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</div>
<!-- 角色卡列表 -->
<div v-loading="loading" class="character-grid">
<div
v-for="character in characters"
:key="character.id"
class="character-card"
>
<!-- 头像 -->
<div class="character-avatar" @click="goToDetail(character.id)">
<img
:src="character.avatar || '/default-avatar.png'"
:alt="character.name"
/>
<!-- 公开状态标识 -->
<el-tag
:type="character.isPublic ? 'success' : 'info'"
size="small"
class="public-tag"
>
{{ character.isPublic ? '公开' : '私密' }}
</el-tag>
</div>
<!-- 信息 -->
<div class="character-info">
<div class="character-name">{{ character.name }}</div>
<div class="character-stats">
<span><el-icon><ChatDotSquare /></el-icon> {{ character.totalChats }}</span>
<span><el-icon><StarFilled /></el-icon> {{ character.totalLikes }}</span>
</div>
<!-- 操作按钮 -->
<div class="character-actions">
<el-button size="small" :icon="View" @click="goToDetail(character.id)">
查看
</el-button>
<el-button size="small" :icon="Edit" @click="goToEdit(character.id)">
编辑
</el-button>
<el-button
size="small"
type="danger"
:icon="Delete"
@click="handleDelete(character.id)"
>
删除
</el-button>
</div>
</div>
</div>
</div>
<!-- 空状态 -->
<el-empty
v-if="!loading && characters.length === 0"
description="还没有创建角色卡"
>
<el-button type="primary" @click="goToCreate">立即创建</el-button>
</el-empty>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useCharacterStore } from '@/stores/character'
import {
Plus,
Upload,
Search,
View,
Edit,
Delete,
ChatDotSquare,
StarFilled
} from '@element-plus/icons-vue'
import { ElMessageBox, ElMessage } from 'element-plus'
import type { UploadRawFile } from 'element-plus'
const router = useRouter()
const characterStore = useCharacterStore()
const searchKeyword = ref('')
const loading = computed(() => characterStore.loading)
const characters = computed(() => characterStore.myCharacters)
// 搜索
function handleSearch() {
characterStore.fetchMyCharacters({
keyword: searchKeyword.value
})
}
// 跳转到创建页面
function goToCreate() {
router.push('/character/create')
}
// 跳转到详情页
function goToDetail(id: number) {
router.push(`/character/${id}`)
}
// 跳转到编辑页
function goToEdit(id: number) {
router.push(`/character/${id}/edit`)
}
// 删除角色卡
async function handleDelete(id: number) {
try {
await ElMessageBox.confirm(
'确定要删除这个角色卡吗?此操作不可恢复。',
'确认删除',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
await characterStore.deleteCharacter(id)
} catch (error) {
// 用户取消
}
}
// 导入角色卡
async function handleImport(file: UploadRawFile) {
try {
// 检查文件类型
const validTypes = ['image/png', 'application/json']
if (!validTypes.includes(file.type)) {
ElMessage.error('只支持 PNG 或 JSON 格式的角色卡文件')
return false
}
// 检查文件大小(最大 10MB
const maxSize = 10 * 1024 * 1024
if (file.size > maxSize) {
ElMessage.error('文件大小不能超过 10MB')
return false
}
// 导入角色卡
await characterStore.importCharacter(file, false)
// 刷新列表
await characterStore.fetchMyCharacters()
} catch (error) {
console.error('导入失败:', error)
}
return false // 阻止自动上传
}
// 初始化
onMounted(() => {
characterStore.fetchMyCharacters()
})
</script>
<style scoped lang="scss">
.my-characters-page {
max-width: 1400px;
margin: 0 auto;
padding: 24px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
h2 {
font-size: 24px;
font-weight: 600;
color: #303133;
margin: 0;
}
.header-actions {
display: flex;
gap: 12px;
}
}
.search-bar {
margin-bottom: 24px;
.el-input {
max-width: 500px;
}
}
.character-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 20px;
}
.character-card {
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
&:hover {
transform: translateY(-4px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}
}
.character-avatar {
position: relative;
width: 100%;
padding-top: 133.33%;
overflow: hidden;
cursor: pointer;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.public-tag {
position: absolute;
top: 8px;
right: 8px;
}
}
.character-info {
padding: 16px;
}
.character-name {
font-size: 16px;
font-weight: 600;
color: #303133;
margin-bottom: 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.character-stats {
display: flex;
align-items: center;
gap: 16px;
font-size: 13px;
color: #909399;
margin-bottom: 12px;
span {
display: flex;
align-items: center;
gap: 4px;
}
}
.character-actions {
display: flex;
gap: 8px;
.el-button {
flex: 1;
}
}
</style>

View File

@@ -0,0 +1,333 @@
<template>
<div class="character-list-page">
<!-- 搜索栏 -->
<div class="search-bar">
<el-input
v-model="searchKeyword"
placeholder="搜索角色卡..."
class="search-input"
clearable
@keyup.enter="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-select
v-model="sortBy"
placeholder="排序方式"
class="sort-select"
@change="handleSearch"
>
<el-option label="最新" value="newest" />
<el-option label="最热门" value="popular" />
<el-option label="对话最多" value="mostChats" />
<el-option label="点赞最多" value="mostLikes" />
</el-select>
</div>
<!-- 角色卡瀑布流 -->
<div v-loading="loading && characters.length === 0" class="character-grid">
<div
v-for="character in characters"
:key="character.id"
class="character-card"
@click="goToDetail(character.id)"
>
<!-- 角色头像 -->
<div class="character-avatar">
<img
:src="character.avatar || '/default-avatar.png'"
:alt="character.name"
@error="handleImageError"
/>
<!-- 悬浮操作按钮 -->
<div class="card-actions">
<el-button
circle
size="small"
:icon="character.isFavorited ? Star : StarFilled"
@click.stop="handleFavorite(character.id)"
/>
<el-button
circle
size="small"
:icon="ChatLineSquare"
@click.stop="startChat(character.id)"
/>
</div>
</div>
<!-- 角色信息 -->
<div class="character-info">
<div class="character-name">{{ character.name }}</div>
<div v-if="character.creatorName" class="character-creator">
{{ character.creatorName }}
</div>
<div v-if="character.description" class="character-desc">
{{ character.description }}
</div>
<!-- 标签 -->
<div v-if="character.tags && character.tags.length > 0" class="character-tags">
<el-tag
v-for="tag in character.tags.slice(0, 3)"
:key="tag"
size="small"
type="info"
>
{{ tag }}
</el-tag>
</div>
<!-- 统计信息 -->
<div class="character-stats">
<span class="stat-item">
<el-icon><ChatDotSquare /></el-icon>
{{ character.totalChats }}
</span>
<span class="stat-item">
<el-icon><StarFilled /></el-icon>
{{ character.totalLikes }}
</span>
</div>
</div>
</div>
</div>
<!-- 加载更多 -->
<div v-if="hasMore" class="load-more">
<el-button
:loading="loading"
type="primary"
@click="loadMore"
>
加载更多
</el-button>
</div>
<!-- 空状态 -->
<el-empty
v-if="!loading && characters.length === 0"
description="暂无角色卡"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useCharacterStore } from '@/stores/character'
import { Search, Star, StarFilled, ChatLineSquare, ChatDotSquare } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
const router = useRouter()
const characterStore = useCharacterStore()
// 搜索和排序
const searchKeyword = ref('')
const sortBy = ref('newest')
// 计算属性
const characters = computed(() => characterStore.publicCharacters)
const loading = computed(() => characterStore.loading)
const hasMore = computed(() => characterStore.hasMore)
// 搜索
function handleSearch() {
characterStore.resetPagination()
characterStore.fetchPublicCharacters({
keyword: searchKeyword.value,
sortBy: sortBy.value
})
}
// 加载更多
function loadMore() {
characterStore.loadMore({
keyword: searchKeyword.value,
sortBy: sortBy.value
})
}
// 跳转到详情页
function goToDetail(id: number) {
router.push(`/character/${id}`)
}
// 切换收藏
async function handleFavorite(id: number) {
try {
await characterStore.toggleFavorite(id)
} catch (error) {
console.error('收藏失败:', error)
}
}
// 开始对话
function startChat(id: number) {
ElMessage.info('对话功能开发中...')
// TODO: 实现对话功能
// router.push(`/chat/${id}`)
}
// 图片加载失败处理
function handleImageError(e: Event) {
const target = e.target as HTMLImageElement
target.src = '/default-avatar.png'
}
// 初始化
onMounted(() => {
characterStore.fetchPublicCharacters()
})
</script>
<style scoped lang="scss">
.character-list-page {
width: 100%;
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
.search-bar {
display: flex;
gap: 12px;
margin-bottom: 24px;
.search-input {
flex: 1;
max-width: 500px;
}
.sort-select {
width: 150px;
}
}
.character-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 20px;
margin-bottom: 24px;
}
.character-card {
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
cursor: pointer;
transition: all 0.3s ease;
&:hover {
transform: translateY(-4px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
.card-actions {
opacity: 1;
}
}
}
.character-avatar {
position: relative;
width: 100%;
padding-top: 133.33%; // 3:4 宽高比
overflow: hidden;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.card-actions {
position: absolute;
top: 8px;
right: 8px;
display: flex;
gap: 8px;
opacity: 0;
transition: opacity 0.3s ease;
.el-button {
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(8px);
&:hover {
background: white;
}
}
}
}
.character-info {
padding: 16px;
}
.character-name {
font-size: 16px;
font-weight: 600;
color: #303133;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.character-creator {
font-size: 12px;
color: #909399;
margin-bottom: 8px;
}
.character-desc {
font-size: 13px;
color: #606266;
line-height: 1.5;
margin-bottom: 12px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.character-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 12px;
}
.character-stats {
display: flex;
align-items: center;
gap: 16px;
font-size: 13px;
color: #909399;
.stat-item {
display: flex;
align-items: center;
gap: 4px;
.el-icon {
font-size: 14px;
}
}
}
.load-more {
display: flex;
justify-content: center;
padding: 24px 0;
}
</style>

View File

@@ -0,0 +1,7 @@
<template>
<CharacterList />
</template>
<script setup lang="ts">
import CharacterList from './CharacterList.vue'
</script>

View File

@@ -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"]
}

31
web-app-vue/tsconfig.json Normal file
View File

@@ -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" }]
}

View File

@@ -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"]
}

2
web-app-vue/vite.config.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
declare const _default: import("vite").UserConfig;
export default _default;

View File

@@ -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'],
},
},
},
},
});

View File

@@ -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'],
},
},
},
},
})

199
web-app-vue/启动指南.md Normal file
View File

@@ -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

View File

@@ -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",

175
web-app/public/auth.html Normal file
View File

@@ -0,0 +1,175 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<title>云酒馆 - 登录注册</title>
<link rel="icon" href="favicon.ico" type="image/x-icon">
<link rel="stylesheet" href="css/auth.css">
</head>
<body>
<div class="auth-container">
<!-- 背景装饰 -->
<div class="auth-background">
<div class="gradient-orb orb-1"></div>
<div class="gradient-orb orb-2"></div>
<div class="gradient-orb orb-3"></div>
</div>
<!-- 主内容区 -->
<div class="auth-card">
<!-- Logo 区域 -->
<div class="auth-header">
<img src="img/logo.png" alt="云酒馆" class="auth-logo">
<h1 class="auth-title">云酒馆</h1>
<p class="auth-subtitle"> AI 角色开启奇妙对话</p>
</div>
<!-- 标签页切换 -->
<div class="auth-tabs">
<button class="auth-tab active" data-tab="login">
<i class="fa-solid fa-right-to-bracket"></i>
登录
</button>
<button class="auth-tab" data-tab="register">
<i class="fa-solid fa-user-plus"></i>
注册
</button>
</div>
<!-- 登录表单 -->
<form id="loginForm" class="auth-form active">
<div class="form-group">
<label for="loginUsername">
<i class="fa-solid fa-user"></i>
用户名
</label>
<input type="text" id="loginUsername" name="username" placeholder="请输入用户名" required
autocomplete="username">
</div>
<div class="form-group">
<label for="loginPassword">
<i class="fa-solid fa-lock"></i>
密码
</label>
<div class="password-input-wrapper">
<input type="password" id="loginPassword" name="password" placeholder="请输入密码" required
autocomplete="current-password">
<button type="button" class="password-toggle" data-target="loginPassword">
<i class="fa-solid fa-eye"></i>
</button>
</div>
</div>
<div class="form-actions">
<label class="remember-me">
<input type="checkbox" id="rememberMe">
<span>记住我</span>
</label>
<a href="#" class="forgot-password">忘记密码</a>
</div>
<button type="submit" class="auth-button">
<i class="fa-solid fa-right-to-bracket"></i>
立即登录
</button>
</form>
<!-- 注册表单 -->
<form id="registerForm" class="auth-form">
<div class="form-group">
<label for="registerUsername">
<i class="fa-solid fa-user"></i>
用户名
</label>
<input type="text" id="registerUsername" name="username" placeholder="3-32个字符" required
minlength="3" maxlength="32" autocomplete="username">
<span class="form-hint">支持字母数字下划线</span>
</div>
<div class="form-group">
<label for="registerPassword">
<i class="fa-solid fa-lock"></i>
密码
</label>
<div class="password-input-wrapper">
<input type="password" id="registerPassword" name="password" placeholder="6-32个字符" required
minlength="6" maxlength="32" autocomplete="new-password">
<button type="button" class="password-toggle" data-target="registerPassword">
<i class="fa-solid fa-eye"></i>
</button>
</div>
<span class="form-hint">建议使用字母数字符号组合</span>
</div>
<div class="form-group">
<label for="registerPasswordConfirm">
<i class="fa-solid fa-lock"></i>
确认密码
</label>
<div class="password-input-wrapper">
<input type="password" id="registerPasswordConfirm" name="passwordConfirm"
placeholder="请再次输入密码" required minlength="6" maxlength="32"
autocomplete="new-password">
<button type="button" class="password-toggle" data-target="registerPasswordConfirm">
<i class="fa-solid fa-eye"></i>
</button>
</div>
</div>
<div class="form-group">
<label for="registerNickname">
<i class="fa-solid fa-signature"></i>
昵称可选
</label>
<input type="text" id="registerNickname" name="nickName" placeholder="设置你的显示昵称"
maxlength="50">
</div>
<div class="form-group">
<label for="registerEmail">
<i class="fa-solid fa-envelope"></i>
邮箱可选
</label>
<input type="email" id="registerEmail" name="email" placeholder="用于找回密码"
autocomplete="email">
</div>
<button type="submit" class="auth-button">
<i class="fa-solid fa-user-plus"></i>
立即注册
</button>
</form>
<!-- 错误/成功提示 -->
<div id="messageBox" class="message-box"></div>
</div>
<!-- 页脚 -->
<div class="auth-footer">
<p>© 2026 云酒馆 | Powered by AI</p>
</div>
</div>
<!-- 加载动画 -->
<div id="loadingOverlay" class="loading-overlay">
<div class="loading-spinner">
<i class="fa-solid fa-circle-notch fa-spin"></i>
<span>处理中...</span>
</div>
</div>
<!-- FontAwesome -->
<link href="css/fontawesome.min.css" rel="stylesheet">
<link href="css/solid.min.css" rel="stylesheet">
<!-- Auth Script -->
<script src="scripts/auth.js" type="module"></script>
</body>
</html>

517
web-app/public/css/auth.css Normal file
View File

@@ -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;
}

View File

@@ -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 };