🎨 重构用户端前端为vue开发,完善基础类和角色相关接口
This commit is contained in:
57
create-vue-app.bat
Normal file
57
create-vue-app.bat
Normal 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
356
create-vue-app.sh
Executable 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
14
deploy/.gitignore
vendored
Normal 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
293
deploy/README.postgres.md
Normal 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/)
|
||||
86
deploy/docker-compose.postgres-official.yml
Normal file
86
deploy/docker-compose.postgres-official.yml
Normal 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
|
||||
82
deploy/docker-compose.postgres.yml
Normal file
82
deploy/docker-compose.postgres.yml
Normal 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
|
||||
42
deploy/postgres/Dockerfile
Normal file
42
deploy/postgres/Dockerfile
Normal 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
82
deploy/postgres/init.sql
Normal 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 ''
|
||||
79
deploy/postgres/postgresql.conf
Normal file
79
deploy/postgres/postgresql.conf
Normal 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 # 排序和哈希表内存
|
||||
|
||||
# WAL(Write-Ahead Logging)设置
|
||||
# ====================================================
|
||||
wal_buffers = 16MB
|
||||
min_wal_size = 1GB
|
||||
max_wal_size = 4GB
|
||||
checkpoint_completion_target = 0.9
|
||||
|
||||
# 查询优化
|
||||
# ====================================================
|
||||
random_page_cost = 1.1 # SSD 优化(默认 4.0)
|
||||
effective_io_concurrency = 200 # SSD 优化(默认 1)
|
||||
default_statistics_target = 100
|
||||
|
||||
# 日志设置
|
||||
# ====================================================
|
||||
logging_collector = on
|
||||
log_destination = 'stderr'
|
||||
log_directory = 'log'
|
||||
log_filename = 'postgresql-%Y-%m-%d_%H%M%S.log'
|
||||
log_rotation_age = 1d
|
||||
log_rotation_size = 100MB
|
||||
log_line_prefix = '%m [%p] %u@%d '
|
||||
log_timezone = 'Asia/Shanghai'
|
||||
|
||||
# 慢查询日志
|
||||
log_min_duration_statement = 1000 # 记录超过 1 秒的查询
|
||||
log_statement = 'ddl' # 记录 DDL 语句
|
||||
|
||||
# 扩展配置
|
||||
# ====================================================
|
||||
shared_preload_libraries = 'vector' # 预加载 pgvector 扩展
|
||||
|
||||
# JIT 编译(提升性能)
|
||||
# ====================================================
|
||||
jit = on
|
||||
jit_above_cost = 100000
|
||||
jit_inline_above_cost = 500000
|
||||
jit_optimize_above_cost = 500000
|
||||
|
||||
# 时区设置
|
||||
# ====================================================
|
||||
timezone = 'Asia/Shanghai'
|
||||
|
||||
# 客户端连接默认设置
|
||||
# ====================================================
|
||||
client_encoding = 'UTF8'
|
||||
lc_messages = 'C'
|
||||
lc_monetary = 'C'
|
||||
lc_numeric = 'C'
|
||||
lc_time = 'C'
|
||||
|
||||
# 并行查询设置(提升大数据量查询性能)
|
||||
# ====================================================
|
||||
max_parallel_workers_per_gather = 2
|
||||
max_parallel_workers = 4
|
||||
max_worker_processes = 4
|
||||
|
||||
# 自动清理设置
|
||||
# ====================================================
|
||||
autovacuum = on
|
||||
autovacuum_max_workers = 3
|
||||
autovacuum_naptime = 1min
|
||||
109
deploy/start-postgres.sh
Normal file
109
deploy/start-postgres.sh
Normal 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
41
deploy/stop-postgres.sh
Normal 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
648
docs/Vue重构方案.md
Normal 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
|
||||
382
docs/用户认证API文档.md
Normal file
382
docs/用户认证API文档.md
Normal 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
3077
docs/重构实施方案.md
Normal file
File diff suppressed because it is too large
Load Diff
1986
docs/重构进度管理.md
Normal file
1986
docs/重构进度管理.md
Normal file
File diff suppressed because it is too large
Load Diff
194
server/api/v1/app/auth.go
Normal file
194
server/api/v1/app/auth.go
Normal 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)
|
||||
}
|
||||
392
server/api/v1/app/character.go
Normal file
392
server/api/v1/app/character.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
48
server/initialize/gorm_pgsql_extension.go
Normal file
48
server/initialize/gorm_pgsql_extension.go
Normal 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")
|
||||
}
|
||||
@@ -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/*
|
||||
}
|
||||
|
||||
//插件路由安装
|
||||
|
||||
147
server/middleware/app_jwt.go
Normal file
147
server/middleware/app_jwt.go
Normal 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
213
server/model/app/README.md
Normal 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` 类型,维度为 1536(OpenAI text-embedding-ada-002)。
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
1. **不要修改 system 包**:所有管理后台相关的模型在 `model/system/` 包中,**不要修改**
|
||||
2. **表名前缀**:
|
||||
- 前台用户相关:`app_*`
|
||||
- AI 功能相关:`ai_*`
|
||||
- 系统管理相关:`sys_*`(不修改)
|
||||
3. **UUID 生成**:`AppUser.UUID` 使用数据库自动生成(PostgreSQL 的 `gen_random_uuid()`)
|
||||
4. **软删除**:所有模型继承 `global.GVA_MODEL`,自动支持软删除
|
||||
5. **时间字段**:`CreatedAt`、`UpdatedAt`、`DeletedAt` 由 GORM 自动管理
|
||||
|
||||
## 📊 ER 图关系
|
||||
|
||||
```
|
||||
AppUser (前台用户)
|
||||
├── AppUserSession (会话)
|
||||
├── AICharacter (创建的角色)
|
||||
├── AIChat (对话)
|
||||
├── AppUserFavoriteCharacter (收藏的角色)
|
||||
├── AIMemoryVector (记忆)
|
||||
├── AIProvider (AI 提供商配置)
|
||||
├── AIFile (文件)
|
||||
├── AIPreset (预设)
|
||||
├── AIWorldInfo (世界书)
|
||||
└── AIUsageStat (使用统计)
|
||||
|
||||
AICharacter (AI 角色)
|
||||
├── AIChat (对话)
|
||||
├── AIChatMember (群聊成员)
|
||||
├── AppUserFavoriteCharacter (被收藏)
|
||||
└── AIMemoryVector (记忆)
|
||||
|
||||
AIChat (对话)
|
||||
├── AIMessage (消息)
|
||||
├── AIChatMember (群聊成员)
|
||||
└── AIMemoryVector (记忆)
|
||||
|
||||
AIMessage (消息)
|
||||
└── AIMessageSwipe (消息变体)
|
||||
|
||||
AIProvider (AI 提供商)
|
||||
└── AIModel (AI 模型)
|
||||
```
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
1. 确保 PostgreSQL 已安装 pgvector 扩展
|
||||
2. 配置 `config.yaml` 中的数据库连接
|
||||
3. 启动服务,AutoMigrate 会自动创建所有表
|
||||
4. 检查日志确认表创建成功
|
||||
|
||||
```bash
|
||||
# 启动服务
|
||||
go run main.go
|
||||
|
||||
# 查看日志
|
||||
# [GVA] pgvector extension is ready
|
||||
# [GVA] vector indexes created successfully
|
||||
# [GVA] register table success
|
||||
```
|
||||
|
||||
## 📝 开发建议
|
||||
|
||||
1. 查询时使用预加载避免 N+1 问题:
|
||||
```go
|
||||
db.Preload("User").Preload("Character").Find(&chats)
|
||||
```
|
||||
|
||||
2. 向量搜索示例:
|
||||
```go
|
||||
db.Order("embedding <=> ?", queryVector).Limit(10).Find(&memories)
|
||||
```
|
||||
|
||||
3. JSONB 查询示例:
|
||||
```go
|
||||
db.Where("ai_settings->>'model' = ?", "gpt-4").Find(&users)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**创建日期**: 2026-02-10
|
||||
**维护者**: 开发团队
|
||||
49
server/model/app/ai_character.go
Normal file
49
server/model/app/ai_character.go
Normal 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"
|
||||
}
|
||||
41
server/model/app/ai_chat.go
Normal file
41
server/model/app/ai_chat.go
Normal 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"
|
||||
}
|
||||
26
server/model/app/ai_file.go
Normal file
26
server/model/app/ai_file.go
Normal 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"
|
||||
}
|
||||
26
server/model/app/ai_memory.go
Normal file
26
server/model/app/ai_memory.go
Normal 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"
|
||||
}
|
||||
46
server/model/app/ai_message.go
Normal file
46
server/model/app/ai_message.go
Normal 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"
|
||||
}
|
||||
22
server/model/app/ai_preset.go
Normal file
22
server/model/app/ai_preset.go
Normal 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:所属用户ID(NULL表示系统预设)"`
|
||||
User *AppUser `json:"user" gorm:"foreignKey:UserID"`
|
||||
PresetType string `json:"presetType" gorm:"type:varchar(100);not null;index;comment:预设类型"`
|
||||
Config datatypes.JSON `json:"config" gorm:"type:jsonb;not null;comment:预设配置"`
|
||||
IsSystem bool `json:"isSystem" gorm:"default:false;comment:是否为系统预设"`
|
||||
IsDefault bool `json:"isDefault" gorm:"default:false;comment:是否为默认预设"`
|
||||
}
|
||||
|
||||
func (AIPreset) TableName() string {
|
||||
return "ai_presets"
|
||||
}
|
||||
36
server/model/app/ai_provider.go
Normal file
36
server/model/app/ai_provider.go
Normal 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:用户ID(NULL表示系统配置)"`
|
||||
User *AppUser `json:"user" gorm:"foreignKey:UserID"`
|
||||
ProviderName string `json:"providerName" gorm:"type:varchar(100);not null;index;comment:提供商名称"`
|
||||
APIConfig datatypes.JSON `json:"apiConfig" gorm:"type:jsonb;not null;comment:API配置(加密存储)"`
|
||||
IsEnabled bool `json:"isEnabled" gorm:"default:true;comment:是否启用"`
|
||||
IsDefault bool `json:"isDefault" gorm:"default:false;comment:是否为默认提供商"`
|
||||
}
|
||||
|
||||
func (AIProvider) TableName() string {
|
||||
return "ai_providers"
|
||||
}
|
||||
|
||||
// AIModel AI 模型配置
|
||||
type AIModel struct {
|
||||
global.GVA_MODEL
|
||||
ProviderID uint `json:"providerId" gorm:"not null;index;comment:提供商ID"`
|
||||
Provider *AIProvider `json:"provider" gorm:"foreignKey:ProviderID"`
|
||||
ModelName string `json:"modelName" gorm:"type:varchar(200);not null;comment:模型名称"`
|
||||
DisplayName string `json:"displayName" gorm:"type:varchar(200);comment:模型显示名称"`
|
||||
Config datatypes.JSON `json:"config" gorm:"type:jsonb;comment:模型参数配置"`
|
||||
IsEnabled bool `json:"isEnabled" gorm:"default:true;comment:是否启用"`
|
||||
}
|
||||
|
||||
func (AIModel) TableName() string {
|
||||
return "ai_models"
|
||||
}
|
||||
25
server/model/app/ai_usage_stat.go
Normal file
25
server/model/app/ai_usage_stat.go
Normal 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"
|
||||
}
|
||||
26
server/model/app/ai_world_info.go
Normal file
26
server/model/app/ai_world_info.go
Normal 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"
|
||||
}
|
||||
31
server/model/app/app_user.go
Normal file
31
server/model/app/app_user.go
Normal 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"
|
||||
}
|
||||
24
server/model/app/app_user_session.go
Normal file
24
server/model/app/app_user_session.go
Normal 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"
|
||||
}
|
||||
37
server/model/app/request/auth.go
Normal file
37
server/model/app/request/auth.go
Normal 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 字符串
|
||||
}
|
||||
54
server/model/app/request/character.go
Normal file
54
server/model/app/request/character.go
Normal 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"`
|
||||
}
|
||||
54
server/model/app/response/auth.go
Normal file
54
server/model/app/response/auth.go
Normal 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,
|
||||
}
|
||||
}
|
||||
79
server/model/app/response/character.go
Normal file
79
server/model/app/response/character.go
Normal 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
36
server/router/app/auth.go
Normal 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) // 修改密码
|
||||
}
|
||||
}
|
||||
34
server/router/app/character.go
Normal file
34
server/router/app/character.go
Normal 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) // 导入角色卡
|
||||
}
|
||||
}
|
||||
@@ -2,4 +2,5 @@ package app
|
||||
|
||||
type RouterGroup struct {
|
||||
AuthRouter
|
||||
CharacterRouter
|
||||
}
|
||||
|
||||
254
server/service/app/auth.go
Normal file
254
server/service/app/auth.go
Normal 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
|
||||
}
|
||||
694
server/service/app/character.go
Normal file
694
server/service/app/character.go
Normal 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))
|
||||
}
|
||||
@@ -2,4 +2,5 @@ package app
|
||||
|
||||
type AppServiceGroup struct {
|
||||
AuthService
|
||||
CharacterService
|
||||
}
|
||||
|
||||
84
server/utils/app_jwt.go
Normal file
84
server/utils/app_jwt.go
Normal 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")
|
||||
}
|
||||
284
server/utils/character_card.go
Normal file
284
server/utils/character_card.go
Normal 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
|
||||
}
|
||||
3
web-app-vue/.env.development
Normal file
3
web-app-vue/.env.development
Normal file
@@ -0,0 +1,3 @@
|
||||
# 开发环境配置
|
||||
VITE_API_BASE_URL=http://localhost:8888
|
||||
VITE_WS_URL=ws://localhost:8888
|
||||
3
web-app-vue/.env.production
Normal file
3
web-app-vue/.env.production
Normal 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
24
web-app-vue/.gitignore
vendored
Normal 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
3
web-app-vue/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
35
web-app-vue/README.md
Normal file
35
web-app-vue/README.md
Normal 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
13
web-app-vue/index.html
Normal 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
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
30
web-app-vue/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
1
web-app-vue/public/vite.svg
Normal file
1
web-app-vue/public/vite.svg
Normal 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
22
web-app-vue/src/App.vue
Normal 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>
|
||||
85
web-app-vue/src/api/auth.ts
Normal file
85
web-app-vue/src/api/auth.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
128
web-app-vue/src/api/character.ts
Normal file
128
web-app-vue/src/api/character.ts
Normal 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'
|
||||
}
|
||||
})
|
||||
}
|
||||
50
web-app-vue/src/assets/styles/index.scss
Normal file
50
web-app-vue/src/assets/styles/index.scss
Normal 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;
|
||||
}
|
||||
1
web-app-vue/src/assets/vue.svg
Normal file
1
web-app-vue/src/assets/vue.svg
Normal 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
90
web-app-vue/src/auto-imports.d.ts
vendored
Normal 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
46
web-app-vue/src/components.d.ts
vendored
Normal 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']
|
||||
}
|
||||
}
|
||||
41
web-app-vue/src/components/HelloWorld.vue
Normal file
41
web-app-vue/src/components/HelloWorld.vue
Normal 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>
|
||||
117
web-app-vue/src/layouts/AuthLayout.vue
Normal file
117
web-app-vue/src/layouts/AuthLayout.vue
Normal 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>
|
||||
173
web-app-vue/src/layouts/DefaultLayout.vue
Normal file
173
web-app-vue/src/layouts/DefaultLayout.vue
Normal 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
19
web-app-vue/src/main.ts
Normal 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')
|
||||
95
web-app-vue/src/router/index.ts
Normal file
95
web-app-vue/src/router/index.ts
Normal 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
|
||||
141
web-app-vue/src/stores/auth.ts
Normal file
141
web-app-vue/src/stores/auth.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
334
web-app-vue/src/stores/character.ts
Normal file
334
web-app-vue/src/stores/character.ts
Normal 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
79
web-app-vue/src/style.css
Normal 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
24
web-app-vue/src/types/api.d.ts
vendored
Normal 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
87
web-app-vue/src/types/character.d.ts
vendored
Normal 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
61
web-app-vue/src/types/user.d.ts
vendored
Normal 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
|
||||
}
|
||||
84
web-app-vue/src/utils/request.ts
Normal file
84
web-app-vue/src/utils/request.ts
Normal 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
|
||||
165
web-app-vue/src/views/auth/Login.vue
Normal file
165
web-app-vue/src/views/auth/Login.vue
Normal 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>
|
||||
225
web-app-vue/src/views/auth/Register.vue
Normal file
225
web-app-vue/src/views/auth/Register.vue
Normal 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>
|
||||
426
web-app-vue/src/views/character/Detail.vue
Normal file
426
web-app-vue/src/views/character/Detail.vue
Normal 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>
|
||||
381
web-app-vue/src/views/character/Edit.vue
Normal file
381
web-app-vue/src/views/character/Edit.vue
Normal 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>
|
||||
310
web-app-vue/src/views/character/MyCharacters.vue
Normal file
310
web-app-vue/src/views/character/MyCharacters.vue
Normal 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>
|
||||
333
web-app-vue/src/views/home/CharacterList.vue
Normal file
333
web-app-vue/src/views/home/CharacterList.vue
Normal 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>
|
||||
7
web-app-vue/src/views/home/Index.vue
Normal file
7
web-app-vue/src/views/home/Index.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<CharacterList />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import CharacterList from './CharacterList.vue'
|
||||
</script>
|
||||
16
web-app-vue/tsconfig.app.json
Normal file
16
web-app-vue/tsconfig.app.json
Normal 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
31
web-app-vue/tsconfig.json
Normal 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" }]
|
||||
}
|
||||
26
web-app-vue/tsconfig.node.json
Normal file
26
web-app-vue/tsconfig.node.json
Normal 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
2
web-app-vue/vite.config.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
declare const _default: import("vite").UserConfig;
|
||||
export default _default;
|
||||
50
web-app-vue/vite.config.js
Normal file
50
web-app-vue/vite.config.js
Normal 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'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
51
web-app-vue/vite.config.ts
Normal file
51
web-app-vue/vite.config.ts
Normal 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
199
web-app-vue/启动指南.md
Normal 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
|
||||
13
web-app/package-lock.json
generated
13
web-app/package-lock.json
generated
@@ -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
175
web-app/public/auth.html
Normal 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
517
web-app/public/css/auth.css
Normal 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;
|
||||
}
|
||||
342
web-app/public/scripts/auth.js
Normal file
342
web-app/public/scripts/auth.js
Normal 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 };
|
||||
Reference in New Issue
Block a user