init
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.pytest_cache/
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.npm
|
||||
.eslintcache
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# 环境变量文件
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# 数据库文件(不包含在镜像中)
|
||||
data/*.db
|
||||
backend/data/*.db
|
||||
|
||||
# 日志文件
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# Git
|
||||
.git/
|
||||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
# 文档
|
||||
docs/
|
||||
*.md
|
||||
!README.md
|
||||
|
||||
# 测试
|
||||
tests/
|
||||
test/
|
||||
*.test.js
|
||||
*.spec.js
|
||||
|
||||
# 前端构建文件(会在Docker内部构建)
|
||||
frontend/dist/
|
||||
frontend/build/
|
||||
|
||||
# 后端静态文件(会从前端构建阶段复制)
|
||||
backend/static/
|
||||
|
||||
# Docker相关
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
docker-compose*.yml
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
pip-wheel-metadata/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# Virtual Environment
|
||||
venv/
|
||||
ENV/
|
||||
env/
|
||||
.venv
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Database files
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Node.js
|
||||
node_modules/
|
||||
.npm
|
||||
.eslintcache
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
backend/static/
|
||||
|
||||
# OS
|
||||
Thumbs.db
|
||||
.DS_Store
|
||||
*.tmp
|
||||
|
||||
# User data
|
||||
backend/data/*.db
|
||||
backend/data/*.db-shm
|
||||
backend/data/*.db-wal
|
||||
backend/data/users.json
|
||||
backend/data/admins.json
|
||||
|
||||
# Temporary files
|
||||
*.bak
|
||||
*.swp
|
||||
*.tmp
|
||||
.temp/
|
||||
temp/
|
||||
tmp/
|
||||
|
||||
# Coverage
|
||||
.coverage
|
||||
htmlcov/
|
||||
*.cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
|
||||
# Type checking
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
data/
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
# 多阶段构建 Dockerfile for AI Story Creator
|
||||
# 阶段1: 构建前端
|
||||
FROM node:22-alpine AS frontend-builder
|
||||
|
||||
WORKDIR /frontend
|
||||
|
||||
# 复制前端依赖文件
|
||||
COPY frontend/package*.json ./
|
||||
|
||||
# 使用国内npm镜像加速
|
||||
RUN npm config set registry https://registry.npmmirror.com
|
||||
|
||||
# 安装依赖
|
||||
RUN npm install
|
||||
|
||||
# 复制前端源代码
|
||||
COPY frontend/ ./
|
||||
|
||||
# 临时修改vite配置,使其输出到dist目录(而不是../backend/static)
|
||||
RUN sed -i "s|outDir: '../backend/static'|outDir: 'dist'|g" vite.config.ts
|
||||
|
||||
# 构建前端
|
||||
RUN npm run build
|
||||
|
||||
# 阶段2: 构建最终镜像
|
||||
FROM python:3.11-slim
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 使用国内镜像源加速
|
||||
RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources \
|
||||
&& sed -i 's/security.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources
|
||||
|
||||
# 安装系统依赖
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 复制后端依赖文件
|
||||
COPY backend/requirements.txt ./
|
||||
|
||||
# 安装Python依赖
|
||||
RUN pip install --no-cache-dir -r requirements.txt -i https://mirrors.aliyun.com/pypi/simple/
|
||||
|
||||
# 复制后端代码
|
||||
COPY backend/ ./
|
||||
|
||||
# 从前端构建阶段复制构建好的静态文件
|
||||
COPY --from=frontend-builder /frontend/dist ./static
|
||||
|
||||
# 创建必要的目录
|
||||
RUN mkdir -p /app/data /app/logs
|
||||
|
||||
# 复制环境变量示例文件
|
||||
COPY backend/.env.example ./.env.example
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 8000
|
||||
|
||||
# 设置环境变量
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV APP_HOST=0.0.0.0
|
||||
ENV APP_PORT=8000
|
||||
|
||||
# 健康检查
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
|
||||
|
||||
# 启动命令
|
||||
CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1,600 @@
|
||||
# MuMuAINovel 📚✨
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
**一款基于 AI 的智能小说创作助手,帮助你轻松创作精彩故事**
|
||||
|
||||
[特性](#特性) • [快速开始](#快速开始) • [部署方式](#部署方式) • [配置说明](#配置说明) • [项目结构](#项目结构)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## ✨ 特性
|
||||
|
||||
- 🤖 **多 AI 模型支持** - 支持 OpenAI、Google Gemini、Anthropic Claude 等主流 AI 模型
|
||||
- 📝 **智能向导** - 通过向导式引导快速创建小说项目,AI 自动生成大纲、角色和世界观
|
||||
- 👥 **角色管理** - 创建和管理小说角色,包括人物关系、组织架构等
|
||||
- 📖 **章节编辑** - 支持章节的创建、编辑、重新生成和润色功能
|
||||
- 🌐 **世界观设定** - 构建完整的故事世界观和背景设定
|
||||
- 🔐 **多种登录方式** - 支持 LinuxDO OAuth 登录和本地账户登录
|
||||
- 🐳 **Docker 部署** - 一键部署,开箱即用
|
||||
- 💾 **数据持久化** - 基于 SQLite 的本地数据存储,支持多用户隔离
|
||||
- 🎨 **现代化 UI** - 基于 Ant Design 的美观界面,响应式设计
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 前置要求
|
||||
|
||||
- **Docker 部署**:Docker 和 Docker Compose
|
||||
- **本地开发**:Python 3.11+ 和 Node.js 18+
|
||||
- **必需**:至少一个 AI 服务的 API Key(OpenAI/Gemini/Anthropic)
|
||||
|
||||
### 方式一:从源码构建 Docker 镜像
|
||||
|
||||
```bash
|
||||
# 1. 克隆项目
|
||||
git clone https://github.com/xiamuceer-j/MuMuAINovel.git
|
||||
cd MuMuAINovel
|
||||
|
||||
# 2. 配置环境变量
|
||||
cp backend/.env.example .env
|
||||
# 编辑 .env 文件,填入你的 API Keys
|
||||
|
||||
# 3. 启动服务(会自动构建镜像)
|
||||
docker-compose up -d
|
||||
|
||||
# 4. 访问应用
|
||||
# 打开浏览器访问 http://localhost:8000
|
||||
```
|
||||
|
||||
### 方式二:本地开发
|
||||
|
||||
#### 后端设置
|
||||
|
||||
```bash
|
||||
# 进入后端目录
|
||||
cd backend
|
||||
|
||||
# 创建虚拟环境
|
||||
python -m venv .venv
|
||||
|
||||
# 激活虚拟环境
|
||||
# Windows:
|
||||
.venv\Scripts\activate
|
||||
# Linux/Mac:
|
||||
source .venv/bin/activate
|
||||
|
||||
# 安装依赖
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 配置环境变量
|
||||
cp .env.example .env
|
||||
# 编辑 .env 文件,填入你的配置
|
||||
|
||||
# 启动后端服务
|
||||
python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
```
|
||||
|
||||
#### 前端设置
|
||||
|
||||
```bash
|
||||
# 进入前端目录
|
||||
cd frontend
|
||||
|
||||
# 安装依赖
|
||||
npm install
|
||||
|
||||
# 开发模式(需要后端已启动)
|
||||
npm run dev
|
||||
|
||||
# 或构建生产版本
|
||||
npm run build
|
||||
```
|
||||
|
||||
## 🐳 部署方式
|
||||
|
||||
### Docker Compose 部署
|
||||
|
||||
#### 使用 Docker Hub 镜像(推荐)
|
||||
|
||||
项目已发布到 Docker Hub,可直接拉取使用:
|
||||
|
||||
```bash
|
||||
# 查看可用版本
|
||||
docker pull mumujie/mumuainovel:latest
|
||||
|
||||
# 启动服务
|
||||
docker-compose up -d
|
||||
|
||||
# 查看日志
|
||||
docker-compose logs -f
|
||||
|
||||
# 停止服务
|
||||
docker-compose down
|
||||
|
||||
# 重启服务
|
||||
docker-compose restart
|
||||
|
||||
# 更新到最新版本
|
||||
docker-compose pull
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
#### Docker Compose 配置文件示例
|
||||
|
||||
使用 Docker Hub 镜像的完整配置:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
ai-story:
|
||||
image: mumujie/mumuainovel:latest
|
||||
container_name: mumuainovel
|
||||
ports:
|
||||
- "8800:8000" # 宿主机端口:容器端口
|
||||
volumes:
|
||||
# 持久化数据库和日志
|
||||
- ./data:/app/data
|
||||
- ./logs:/app/logs
|
||||
# 挂载环境变量文件
|
||||
- ./.env:/app/.env:ro
|
||||
environment:
|
||||
- APP_NAME=mumuainovel
|
||||
- APP_VERSION=1.0.0
|
||||
- APP_HOST=0.0.0.0
|
||||
- APP_PORT=8000
|
||||
- DEBUG=false
|
||||
# 其他环境变量会从 .env 文件自动加载
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
networks:
|
||||
- ai-story-network
|
||||
|
||||
networks:
|
||||
ai-story-network:
|
||||
driver: bridge
|
||||
```
|
||||
|
||||
### 生产环境部署建议
|
||||
|
||||
#### 1. 环境变量配置
|
||||
|
||||
**必需配置**:
|
||||
- `OPENAI_API_KEY` 或 `GEMINI_API_KEY`:至少配置一个 AI 服务
|
||||
- `LOCAL_AUTH_PASSWORD`:修改为强密码
|
||||
|
||||
**推荐配置**:
|
||||
- `OPENAI_BASE_URL`:如果使用中转 API,修改为中转服务地址
|
||||
- `DEFAULT_AI_PROVIDER`:根据你的 API Key 选择 `openai`、`gemini` 或 `anthropic`
|
||||
- `DEFAULT_MODEL`:选择合适的模型(如 `gpt-4o-mini`、`gemini-2.0-flash-exp`)
|
||||
|
||||
#### 2. 数据持久化
|
||||
|
||||
数据目录已通过 volume 挂载,数据不会丢失:
|
||||
- `./data`:SQLite 数据库文件
|
||||
- `./logs`:应用日志文件
|
||||
|
||||
#### 3. 端口配置
|
||||
|
||||
默认端口映射:`8800:8000`
|
||||
- 宿主机端口:`8800`(可自定义修改)
|
||||
- 容器内端口:`8000`(固定,不要修改)
|
||||
|
||||
访问地址:`http://your-server-ip:8800`
|
||||
|
||||
#### 4. 反向代理配置(Nginx)
|
||||
|
||||
推荐使用 Nginx 配置 HTTPS:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name your-domain.com;
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name your-domain.com;
|
||||
|
||||
ssl_certificate /path/to/cert.pem;
|
||||
ssl_certificate_key /path/to/key.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:8800;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# 支持 SSE(服务器推送事件)
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_set_header Connection '';
|
||||
proxy_http_version 1.1;
|
||||
chunked_transfer_encoding off;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
配置后记得更新 `.env` 中的 `LINUXDO_REDIRECT_URI` 和 `FRONTEND_URL`。
|
||||
|
||||
#### 5. 资源限制(可选)
|
||||
|
||||
在 `docker-compose.yml` 中添加资源限制:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
ai-story:
|
||||
# ... 其他配置
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '2.0'
|
||||
memory: 2G
|
||||
reservations:
|
||||
cpus: '0.5'
|
||||
memory: 512M
|
||||
```
|
||||
|
||||
### 端口说明
|
||||
|
||||
- **默认端口**:`8800`(宿主机)→ `8000`(容器)
|
||||
- **可自定义**:修改 docker-compose.yml 中的 `ports` 配置
|
||||
- **健康检查**:容器内部使用 `8000` 端口进行健康检查
|
||||
|
||||
## ⚙️ 配置说明
|
||||
|
||||
### 环境变量
|
||||
|
||||
创建 `.env` 文件并配置以下变量:
|
||||
|
||||
```bash
|
||||
# ===== AI 服务配置(必填)=====
|
||||
# OpenAI 配置(支持官方API和中转API)
|
||||
OPENAI_API_KEY=your_openai_key_here
|
||||
OPENAI_BASE_URL=https://api.openai.com/v1
|
||||
|
||||
# Google Gemini 配置(推荐,免费额度大)
|
||||
# GEMINI_API_KEY=your_gemini_key_here
|
||||
|
||||
# Anthropic 配置
|
||||
# ANTHROPIC_API_KEY=your_anthropic_key_here
|
||||
# ANTHROPIC_BASE_URL=https://api.anthropic.com
|
||||
|
||||
# 中转API配置示例(使用OpenAI格式)
|
||||
# New API 中转服务
|
||||
# OPENAI_API_KEY=your_newapi_key_here
|
||||
# OPENAI_BASE_URL=https://api.new-api.com/v1
|
||||
|
||||
# API2D 中转服务
|
||||
# OPENAI_API_KEY=your_api2d_key_here
|
||||
# OPENAI_BASE_URL=https://api.api2d.com/v1
|
||||
|
||||
# OpenAI-SB 中转服务
|
||||
# OPENAI_API_KEY=your_openai_sb_key_here
|
||||
# OPENAI_BASE_URL=https://api.openai-sb.com/v1
|
||||
|
||||
# 其他支持 OpenAI 格式的中转服务
|
||||
# OPENAI_API_KEY=your_api_key_here
|
||||
# OPENAI_BASE_URL=https://your-api-proxy.com/v1
|
||||
|
||||
# 默认 AI 提供商和模型
|
||||
DEFAULT_AI_PROVIDER=openai
|
||||
DEFAULT_MODEL=gpt-4o-mini
|
||||
DEFAULT_TEMPERATURE=0.8
|
||||
DEFAULT_MAX_TOKENS=32000
|
||||
|
||||
# ===== 应用配置 =====
|
||||
APP_NAME=MuMuAINovel
|
||||
APP_VERSION=1.0.0
|
||||
APP_HOST=0.0.0.0
|
||||
APP_PORT=8000
|
||||
DEBUG=false
|
||||
|
||||
# ===== LinuxDO OAuth 配置(可选)=====
|
||||
LINUXDO_CLIENT_ID=your_client_id_here
|
||||
LINUXDO_CLIENT_SECRET=your_client_secret_here
|
||||
LINUXDO_REDIRECT_URI=http://localhost:8000/api/auth/callback
|
||||
FRONTEND_URL=http://localhost:8000
|
||||
|
||||
# ===== 本地账户登录配置 =====
|
||||
LOCAL_AUTH_ENABLED=true
|
||||
LOCAL_AUTH_USERNAME=admin
|
||||
LOCAL_AUTH_PASSWORD=your_secure_password_here
|
||||
LOCAL_AUTH_DISPLAY_NAME=管理员
|
||||
|
||||
# ===== CORS 配置(生产环境)=====
|
||||
# CORS_ORIGINS=https://your-domain.com,https://www.your-domain.com
|
||||
```
|
||||
|
||||
### AI 模型配置
|
||||
|
||||
项目支持多个 AI 提供商,你可以根据需要配置:
|
||||
|
||||
| 提供商 | 推荐模型 | 用途 |
|
||||
|--------|---------|------|
|
||||
| OpenAI | gpt-4, gpt-3.5-turbo | 高质量文本生成 |
|
||||
| Anthropic | claude-3-opus, claude-3-sonnet | 长文本创作 |
|
||||
|
||||
#### 使用中转API服务
|
||||
|
||||
如果你无法直接访问 OpenAI 官方 API,或者想使用更经济实惠的中转服务,本项目完全支持各种 OpenAI 兼容格式的中转 API:
|
||||
|
||||
##### 配置方法
|
||||
|
||||
只需修改 `.env` 文件中的两个参数:
|
||||
|
||||
```bash
|
||||
# 1. 填入中转服务提供的 API Key
|
||||
OPENAI_API_KEY=your_api_key_from_proxy_service
|
||||
|
||||
# 2. 修改 Base URL 为中转服务的地址
|
||||
OPENAI_BASE_URL=https://your-proxy-service.com/v1
|
||||
```
|
||||
|
||||
##### 常见中转服务配置示例
|
||||
|
||||
**New API**
|
||||
```bash
|
||||
OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxx
|
||||
OPENAI_BASE_URL=https://api.new-api.com/v1
|
||||
```
|
||||
|
||||
**API2D**
|
||||
```bash
|
||||
OPENAI_API_KEY=fk-xxxxxxxxxxxxxxxx
|
||||
OPENAI_BASE_URL=https://api.api2d.com/v1
|
||||
```
|
||||
|
||||
**OpenAI-SB**
|
||||
```bash
|
||||
OPENAI_API_KEY=sb-xxxxxxxxxxxxxxxx
|
||||
OPENAI_BASE_URL=https://api.openai-sb.com/v1
|
||||
```
|
||||
|
||||
**自建 One API / New API**
|
||||
```bash
|
||||
OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxx
|
||||
OPENAI_BASE_URL=https://your-domain.com/v1
|
||||
```
|
||||
|
||||
##### 注意事项
|
||||
|
||||
- ✅ 所有支持 OpenAI 接口格式的服务都可以使用
|
||||
- ✅ 确保中转服务的 Base URL 以 `/v1` 结尾
|
||||
- ✅ 根据中转服务支持的模型,修改 `DEFAULT_MODEL` 参数
|
||||
- ⚠️ 不同中转服务的模型名称可能不同,请参考服务商文档
|
||||
- ⚠️ 部分中转服务可能对请求频率或并发有限制
|
||||
|
||||
##### 推荐的中转服务
|
||||
|
||||
如果你需要中转服务,以下是一些常见选择:
|
||||
|
||||
1. **New API** - 开源的 API 分发系统,支持多种模型
|
||||
2. **API2D** - 国内稳定的 API 中转服务
|
||||
3. **OpenAI-SB** - 提供多种 AI 模型的中转
|
||||
4. **自建服务** - 使用 One API 或 New API 自行搭建
|
||||
|
||||
> 💡 提示:使用中转服务时,请确保服务提供商的可靠性和数据安全性
|
||||
|
||||
### 登录方式配置
|
||||
|
||||
#### 本地账户登录(默认启用)
|
||||
|
||||
适合个人使用或小型团队:
|
||||
|
||||
```bash
|
||||
LOCAL_AUTH_ENABLED=true
|
||||
LOCAL_AUTH_USERNAME=admin
|
||||
LOCAL_AUTH_PASSWORD=your_password
|
||||
```
|
||||
|
||||
#### LinuxDO OAuth 登录
|
||||
|
||||
适合需要社区集成的场景,需要在 [LinuxDO](https://linux.do) 注册 OAuth 应用:
|
||||
|
||||
```bash
|
||||
LINUXDO_CLIENT_ID=your_client_id
|
||||
LINUXDO_CLIENT_SECRET=your_client_secret
|
||||
LINUXDO_REDIRECT_URI=http://your-domain:8000/api/auth/callback
|
||||
```
|
||||
|
||||
## 📁 项目结构
|
||||
|
||||
```
|
||||
MuMuAINovel/
|
||||
├── backend/ # 后端服务
|
||||
│ ├── app/
|
||||
│ │ ├── api/ # API 路由
|
||||
│ │ │ ├── auth.py # 认证接口
|
||||
│ │ │ ├── projects.py # 项目管理
|
||||
│ │ │ ├── chapters.py # 章节管理
|
||||
│ │ │ ├── characters.py # 角色管理
|
||||
│ │ │ ├── wizard_stream.py # 向导流式生成
|
||||
│ │ │ └── ...
|
||||
│ │ ├── models/ # 数据模型
|
||||
│ │ ├── schemas/ # Pydantic 模型
|
||||
│ │ ├── services/ # 业务逻辑
|
||||
│ │ │ ├── ai_service.py # AI 服务封装
|
||||
│ │ │ └── oauth_service.py # OAuth 服务
|
||||
│ │ ├── middleware/ # 中间件
|
||||
│ │ ├── utils/ # 工具函数
|
||||
│ │ ├── config.py # 配置管理
|
||||
│ │ ├── database.py # 数据库连接
|
||||
│ │ └── main.py # 应用入口
|
||||
│ ├── data/ # 数据存储目录
|
||||
│ ├── static/ # 前端静态文件(构建后)
|
||||
│ ├── requirements.txt # Python 依赖
|
||||
│ └── .env.example # 环境变量示例
|
||||
├── frontend/ # 前端应用
|
||||
│ ├── src/
|
||||
│ │ ├── pages/ # 页面组件
|
||||
│ │ │ ├── ProjectList.tsx # 项目列表
|
||||
│ │ │ ├── ProjectWizardNew.tsx # 创建向导
|
||||
│ │ │ ├── Chapters.tsx # 章节管理
|
||||
│ │ │ ├── Characters.tsx # 角色管理
|
||||
│ │ │ └── ...
|
||||
│ │ ├── components/ # 通用组件
|
||||
│ │ ├── services/ # API 服务
|
||||
│ │ ├── store/ # 状态管理(Zustand)
|
||||
│ │ ├── types/ # TypeScript 类型
|
||||
│ │ └── utils/ # 工具函数
|
||||
│ ├── package.json
|
||||
│ └── vite.config.ts
|
||||
├── docker-compose.yml # Docker Compose 配置
|
||||
├── Dockerfile # Docker 镜像构建
|
||||
└── README.md # 项目说明文档
|
||||
```
|
||||
|
||||
## 🛠️ 技术栈
|
||||
|
||||
### 后端
|
||||
|
||||
- **框架**:FastAPI 0.109.0
|
||||
- **数据库**:SQLite + SQLAlchemy(异步)
|
||||
- **AI 集成**:OpenAI、Anthropic、Google Gemini SDK
|
||||
- **认证**:LinuxDO OAuth2、本地账户
|
||||
- **日志**:Python logging + 文件轮转
|
||||
|
||||
### 前端
|
||||
|
||||
- **框架**:React 18.3 + TypeScript
|
||||
- **UI 库**:Ant Design 5.27
|
||||
- **路由**:React Router 6.28
|
||||
- **状态管理**:Zustand 5.0
|
||||
- **HTTP 客户端**:Axios
|
||||
- **构建工具**:Vite 7.1
|
||||
|
||||
## 📖 使用指南
|
||||
|
||||
### 创建第一个小说项目
|
||||
|
||||
1. **登录系统**
|
||||
- 使用本地账户或 LinuxDO 账户登录
|
||||
|
||||
2. **创建项目**
|
||||
- 点击"创建项目"按钮
|
||||
- 选择"使用向导创建"或"手动创建"
|
||||
|
||||
3. **使用向导(推荐)**
|
||||
- 输入小说基本信息(标题、类型、背景等)
|
||||
- AI 自动生成大纲、角色和世界观
|
||||
- 实时查看生成进度
|
||||
|
||||
4. **编辑和完善**
|
||||
- 在项目详情页查看和编辑大纲
|
||||
- 管理角色和人物关系
|
||||
- 生成和编辑章节内容
|
||||
|
||||
|
||||
### API 文档
|
||||
|
||||
应用启动后,可访问自动生成的 API 文档:
|
||||
|
||||
- Swagger UI:`http://localhost:8000/docs`
|
||||
- ReDoc:`http://localhost:8000/redoc`
|
||||
|
||||
## 🔧 开发指南
|
||||
|
||||
### 后端开发
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
# 激活虚拟环境
|
||||
source .venv/bin/activate # Linux/Mac
|
||||
# 或
|
||||
.venv\Scripts\activate # Windows
|
||||
|
||||
# 启动开发服务器(热重载)
|
||||
python -m uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
### 前端开发
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
|
||||
# 启动开发服务器
|
||||
npm run dev
|
||||
|
||||
# 构建生产版本
|
||||
npm run build
|
||||
|
||||
# 预览生产版本
|
||||
npm run preview
|
||||
```
|
||||
|
||||
### 代码规范
|
||||
|
||||
- **后端**:遵循 PEP 8 规范
|
||||
- **前端**:使用 ESLint + TypeScript 严格模式
|
||||
- **提交**:建议使用语义化提交信息
|
||||
|
||||
## 🤝 贡献
|
||||
|
||||
欢迎提交 Issue 和 Pull Request!
|
||||
|
||||
1. Fork 本项目
|
||||
2. 创建特性分支 (`git checkout -b feature/AmazingFeature`)
|
||||
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
|
||||
4. 推送到分支 (`git push origin feature/AmazingFeature`)
|
||||
5. 提交 Pull Request
|
||||
|
||||
## 📝 许可证
|
||||
|
||||
本项目采用 [GNU General Public License v3.0](https://www.gnu.org/licenses/gpl-3.0.html) 开源协议
|
||||
|
||||
**这意味着:**
|
||||
|
||||
- ✅ **可以** - 自由使用、复制、修改和分发本项目
|
||||
- ✅ **可以** - 用于商业目的
|
||||
- ✅ **可以** - 用于个人学习和研究
|
||||
- 📝 **必须** - 开源你的修改版本
|
||||
- 📝 **必须** - 保留原作者版权声明
|
||||
- 📝 **必须** - 以相同的 GPL v3 协议发布衍生作品
|
||||
|
||||
详见 [LICENSE](LICENSE) 文件
|
||||
|
||||
## 🙏 致谢
|
||||
|
||||
- [FastAPI](https://fastapi.tiangolo.com/) - 现代化的 Python Web 框架
|
||||
- [React](https://react.dev/) - 用户界面构建库
|
||||
- [Ant Design](https://ant.design/) - 企业级 UI 设计语言
|
||||
- [OpenAI](https://openai.com/) / [Anthropic](https://www.anthropic.com/) - AI 模型提供商
|
||||
|
||||
## 📧 联系方式
|
||||
|
||||
如有问题或建议,欢迎通过以下方式联系:
|
||||
|
||||
- 提交 [Issue](https://github.com/yourusername/MuMuAINovel/issues)
|
||||
- Linux DO [LD](https://linux.do/t/topic/1100112)
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
**如果这个项目对你有帮助,请给个 ⭐️ Star 支持一下!**
|
||||
|
||||
Made with ❤️
|
||||
|
||||
</div>
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#xiamuceer-j/MuMuAINovel&type=date&legend=top-left)
|
||||
|
||||

|
||||
@@ -0,0 +1,48 @@
|
||||
# AI服务配置
|
||||
# OpenAI配置
|
||||
OPENAI_API_KEY=your_openai_key_here
|
||||
OPENAI_BASE_URL=https://api.openai.com/v1
|
||||
|
||||
# Anthropic配置
|
||||
ANTHROPIC_API_KEY=your_anthropic_key_here
|
||||
ANTHROPIC_BASE_URL=https://api.anthropic.com
|
||||
|
||||
# 默认AI提供商:openai, gemini, anthropic
|
||||
DEFAULT_AI_PROVIDER=openai
|
||||
DEFAULT_MODEL=gpt-4.1
|
||||
DEFAULT_TEMPERATURE=0.8
|
||||
DEFAULT_MAX_TOKENS=32000
|
||||
|
||||
# 应用配置
|
||||
APP_NAME=MuMuAINovel
|
||||
APP_VERSION=1.0.0
|
||||
APP_HOST=0.0.0.0
|
||||
APP_PORT=8000
|
||||
DEBUG=true
|
||||
|
||||
# LinuxDO OAuth2 配置(可选)
|
||||
# 注意:Docker部署时,LINUXDO_REDIRECT_URI 应该使用实际的域名或服务器IP
|
||||
# 本地开发: http://localhost:8000/api/auth/callback
|
||||
# 生产环境: https://your-domain.com/api/auth/callback 或 http://your-server-ip:8000/api/auth/callback
|
||||
LINUXDO_CLIENT_ID=your_client_id_here
|
||||
LINUXDO_CLIENT_SECRET=your_client_secret_here
|
||||
LINUXDO_REDIRECT_URI=http://localhost:8000/api/auth/callback
|
||||
|
||||
# 前端URL配置(用于OAuth回调后重定向到前端)
|
||||
# 本地开发: http://localhost:8000
|
||||
# 生产环境: https://your-domain.com 或 http://your-server-ip:8000
|
||||
FRONTEND_URL=http://localhost:8000
|
||||
|
||||
# 本地账户登录配置
|
||||
# 启用本地账户登录(true/false)
|
||||
LOCAL_AUTH_ENABLED=true
|
||||
# 本地登录用户名
|
||||
LOCAL_AUTH_USERNAME=admin
|
||||
# 本地登录密码
|
||||
LOCAL_AUTH_PASSWORD=your_secure_password_here
|
||||
# 本地用户显示名称
|
||||
LOCAL_AUTH_DISPLAY_NAME=管理员
|
||||
|
||||
# CORS配置(生产环境)
|
||||
# 允许的跨域来源,多个用逗号分隔
|
||||
# CORS_ORIGINS=https://your-domain.com,https://www.your-domain.com
|
||||
@@ -0,0 +1,2 @@
|
||||
"""AI Story Creator - 后端应用包"""
|
||||
__version__ = "1.0.0"
|
||||
@@ -0,0 +1 @@
|
||||
"""API路由模块"""
|
||||
@@ -0,0 +1,230 @@
|
||||
"""
|
||||
认证 API - LinuxDO OAuth2 登录 + 本地账户登录
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Response, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
import hashlib
|
||||
from app.services.oauth_service import LinuxDOOAuthService
|
||||
from app.user_manager import user_manager
|
||||
from app.database import init_db
|
||||
from app.logger import get_logger
|
||||
from app.config import settings
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["认证"])
|
||||
|
||||
# OAuth2 服务实例
|
||||
oauth_service = LinuxDOOAuthService()
|
||||
|
||||
# State 临时存储(生产环境应使用 Redis)
|
||||
_state_storage = {}
|
||||
|
||||
|
||||
class AuthUrlResponse(BaseModel):
|
||||
auth_url: str
|
||||
state: str
|
||||
|
||||
|
||||
class LocalLoginRequest(BaseModel):
|
||||
"""本地登录请求"""
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class LocalLoginResponse(BaseModel):
|
||||
"""本地登录响应"""
|
||||
success: bool
|
||||
message: str
|
||||
user: Optional[dict] = None
|
||||
|
||||
|
||||
@router.get("/config")
|
||||
async def get_auth_config():
|
||||
"""获取认证配置信息"""
|
||||
return {
|
||||
"local_auth_enabled": settings.LOCAL_AUTH_ENABLED,
|
||||
"linuxdo_enabled": bool(settings.LINUXDO_CLIENT_ID and settings.LINUXDO_CLIENT_SECRET)
|
||||
}
|
||||
|
||||
|
||||
@router.post("/local/login", response_model=LocalLoginResponse)
|
||||
async def local_login(request: LocalLoginRequest, response: Response):
|
||||
"""本地账户登录"""
|
||||
# 检查是否启用本地登录
|
||||
if not settings.LOCAL_AUTH_ENABLED:
|
||||
raise HTTPException(status_code=403, detail="本地账户登录未启用")
|
||||
|
||||
# 检查是否配置了本地账户
|
||||
if not settings.LOCAL_AUTH_USERNAME or not settings.LOCAL_AUTH_PASSWORD:
|
||||
raise HTTPException(status_code=500, detail="本地账户未配置")
|
||||
|
||||
# 验证用户名和密码
|
||||
if request.username != settings.LOCAL_AUTH_USERNAME or request.password != settings.LOCAL_AUTH_PASSWORD:
|
||||
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
||||
|
||||
# 生成本地用户ID(使用用户名的hash)
|
||||
user_id = f"local_{hashlib.md5(request.username.encode()).hexdigest()[:16]}"
|
||||
|
||||
# 创建或更新本地用户
|
||||
user = await user_manager.create_or_update_from_linuxdo(
|
||||
linuxdo_id=user_id,
|
||||
username=request.username,
|
||||
display_name=settings.LOCAL_AUTH_DISPLAY_NAME,
|
||||
avatar_url=None,
|
||||
trust_level=9 # 本地用户给予高信任级别
|
||||
)
|
||||
|
||||
# 初始化用户数据库
|
||||
try:
|
||||
await init_db(user.user_id)
|
||||
logger.info(f"本地用户 {user.user_id} 数据库初始化成功")
|
||||
except Exception as e:
|
||||
logger.error(f"本地用户 {user.user_id} 数据库初始化失败: {e}")
|
||||
|
||||
# 设置 Cookie(7天有效)
|
||||
response.set_cookie(
|
||||
key="user_id",
|
||||
value=user.user_id,
|
||||
max_age=7 * 24 * 60 * 60, # 7天
|
||||
httponly=True,
|
||||
samesite="lax"
|
||||
)
|
||||
|
||||
return LocalLoginResponse(
|
||||
success=True,
|
||||
message="登录成功",
|
||||
user=user.dict()
|
||||
)
|
||||
|
||||
|
||||
@router.get("/linuxdo/url", response_model=AuthUrlResponse)
|
||||
async def get_linuxdo_auth_url():
|
||||
"""获取 LinuxDO 授权 URL"""
|
||||
state = oauth_service.generate_state()
|
||||
auth_url = oauth_service.get_authorization_url(state)
|
||||
|
||||
# 临时存储 state(5分钟有效)
|
||||
_state_storage[state] = True
|
||||
|
||||
return AuthUrlResponse(auth_url=auth_url, state=state)
|
||||
|
||||
|
||||
async def _handle_callback(
|
||||
code: Optional[str] = None,
|
||||
state: Optional[str] = None,
|
||||
error: Optional[str] = None,
|
||||
response: Response = None
|
||||
):
|
||||
"""
|
||||
LinuxDO OAuth2 回调处理
|
||||
|
||||
成功后重定向到前端首页,并设置 user_id Cookie
|
||||
"""
|
||||
# 检查是否有错误
|
||||
if error:
|
||||
raise HTTPException(status_code=400, detail=f"授权失败: {error}")
|
||||
|
||||
# 检查必需参数
|
||||
if not code or not state:
|
||||
raise HTTPException(status_code=400, detail="缺少 code 或 state 参数")
|
||||
|
||||
# 验证 state(防止 CSRF)
|
||||
if state not in _state_storage:
|
||||
raise HTTPException(status_code=400, detail="无效的 state 参数")
|
||||
|
||||
# 删除已使用的 state
|
||||
del _state_storage[state]
|
||||
|
||||
# 1. 使用 code 获取 access_token
|
||||
token_data = await oauth_service.get_access_token(code)
|
||||
if not token_data or "access_token" not in token_data:
|
||||
raise HTTPException(status_code=400, detail="获取访问令牌失败")
|
||||
|
||||
access_token = token_data["access_token"]
|
||||
|
||||
# 2. 使用 access_token 获取用户信息
|
||||
user_info = await oauth_service.get_user_info(access_token)
|
||||
if not user_info:
|
||||
raise HTTPException(status_code=400, detail="获取用户信息失败")
|
||||
|
||||
# 3. 创建或更新用户
|
||||
linuxdo_id = str(user_info.get("id"))
|
||||
username = user_info.get("username", "")
|
||||
display_name = user_info.get("name", username)
|
||||
avatar_url = user_info.get("avatar_url")
|
||||
trust_level = user_info.get("trust_level", 0)
|
||||
|
||||
user = await user_manager.create_or_update_from_linuxdo(
|
||||
linuxdo_id=linuxdo_id,
|
||||
username=username,
|
||||
display_name=display_name,
|
||||
avatar_url=avatar_url,
|
||||
trust_level=trust_level
|
||||
)
|
||||
|
||||
# 3.5. 初始化用户数据库(如果是新用户)
|
||||
try:
|
||||
await init_db(user.user_id)
|
||||
logger.info(f"用户 {user.user_id} 数据库初始化成功")
|
||||
except Exception as e:
|
||||
logger.error(f"用户 {user.user_id} 数据库初始化失败: {e}")
|
||||
# 继续执行,不影响登录流程(可能是已存在的用户)
|
||||
|
||||
# 4. 设置 Cookie 并重定向到前端回调页面
|
||||
# 使用配置的前端URL,支持不同的部署环境
|
||||
frontend_url = settings.FRONTEND_URL.rstrip('/')
|
||||
redirect_url = f"{frontend_url}/auth/callback"
|
||||
logger.info(f"OAuth回调成功,重定向到前端: {redirect_url}")
|
||||
redirect_response = RedirectResponse(url=redirect_url)
|
||||
|
||||
# 设置 httponly Cookie(7天有效)
|
||||
redirect_response.set_cookie(
|
||||
key="user_id",
|
||||
value=user.user_id,
|
||||
max_age=7 * 24 * 60 * 60, # 7天
|
||||
httponly=True,
|
||||
samesite="lax"
|
||||
)
|
||||
|
||||
return redirect_response
|
||||
|
||||
|
||||
@router.get("/linuxdo/callback")
|
||||
async def linuxdo_callback(
|
||||
code: Optional[str] = None,
|
||||
state: Optional[str] = None,
|
||||
error: Optional[str] = None,
|
||||
response: Response = None
|
||||
):
|
||||
"""LinuxDO OAuth2 回调处理(标准路径)"""
|
||||
return await _handle_callback(code, state, error, response)
|
||||
|
||||
|
||||
@router.get("/callback")
|
||||
async def callback_alias(
|
||||
code: Optional[str] = None,
|
||||
state: Optional[str] = None,
|
||||
error: Optional[str] = None,
|
||||
response: Response = None
|
||||
):
|
||||
"""LinuxDO OAuth2 回调处理(兼容路径)"""
|
||||
return await _handle_callback(code, state, error, response)
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(response: Response):
|
||||
"""退出登录"""
|
||||
response.delete_cookie("user_id")
|
||||
return {"message": "退出登录成功"}
|
||||
|
||||
|
||||
@router.get("/user")
|
||||
async def get_current_user(request: Request):
|
||||
"""获取当前登录用户信息"""
|
||||
if not hasattr(request.state, "user") or not request.state.user:
|
||||
raise HTTPException(status_code=401, detail="未登录")
|
||||
|
||||
return request.state.user.dict()
|
||||
@@ -0,0 +1,655 @@
|
||||
"""章节管理API"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
import json
|
||||
import asyncio
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.chapter import Chapter
|
||||
from app.models.project import Project
|
||||
from app.models.outline import Outline
|
||||
from app.models.character import Character
|
||||
from app.models.generation_history import GenerationHistory
|
||||
from app.schemas.chapter import (
|
||||
ChapterCreate,
|
||||
ChapterUpdate,
|
||||
ChapterResponse,
|
||||
ChapterListResponse
|
||||
)
|
||||
from app.services.ai_service import ai_service
|
||||
from app.services.prompt_service import prompt_service
|
||||
from app.logger import get_logger
|
||||
|
||||
router = APIRouter(prefix="/chapters", tags=["章节管理"])
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@router.post("", response_model=ChapterResponse, summary="创建章节")
|
||||
async def create_chapter(
|
||||
chapter: ChapterCreate,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""创建新的章节"""
|
||||
# 验证项目是否存在
|
||||
result = await db.execute(
|
||||
select(Project).where(Project.id == chapter.project_id)
|
||||
)
|
||||
project = result.scalar_one_or_none()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="项目不存在")
|
||||
|
||||
# 计算字数
|
||||
word_count = len(chapter.content)
|
||||
|
||||
db_chapter = Chapter(
|
||||
**chapter.model_dump(),
|
||||
word_count=word_count
|
||||
)
|
||||
db.add(db_chapter)
|
||||
|
||||
# 更新项目的当前字数
|
||||
project.current_words = project.current_words + word_count
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(db_chapter)
|
||||
return db_chapter
|
||||
|
||||
|
||||
@router.get("/project/{project_id}", response_model=ChapterListResponse, summary="获取项目的所有章节")
|
||||
async def get_project_chapters(
|
||||
project_id: str,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""获取指定项目的所有章节(路径参数版本)"""
|
||||
# 获取总数
|
||||
count_result = await db.execute(
|
||||
select(func.count(Chapter.id)).where(Chapter.project_id == project_id)
|
||||
)
|
||||
total = count_result.scalar_one()
|
||||
|
||||
# 获取章节列表
|
||||
result = await db.execute(
|
||||
select(Chapter)
|
||||
.where(Chapter.project_id == project_id)
|
||||
.order_by(Chapter.chapter_number)
|
||||
)
|
||||
chapters = result.scalars().all()
|
||||
|
||||
return ChapterListResponse(total=total, items=chapters)
|
||||
|
||||
|
||||
@router.get("/{chapter_id}", response_model=ChapterResponse, summary="获取章节详情")
|
||||
async def get_chapter(
|
||||
chapter_id: str,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""根据ID获取章节详情"""
|
||||
result = await db.execute(
|
||||
select(Chapter).where(Chapter.id == chapter_id)
|
||||
)
|
||||
chapter = result.scalar_one_or_none()
|
||||
|
||||
if not chapter:
|
||||
raise HTTPException(status_code=404, detail="章节不存在")
|
||||
|
||||
return chapter
|
||||
|
||||
|
||||
@router.put("/{chapter_id}", response_model=ChapterResponse, summary="更新章节")
|
||||
async def update_chapter(
|
||||
chapter_id: str,
|
||||
chapter_update: ChapterUpdate,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""更新章节信息"""
|
||||
result = await db.execute(
|
||||
select(Chapter).where(Chapter.id == chapter_id)
|
||||
)
|
||||
chapter = result.scalar_one_or_none()
|
||||
|
||||
if not chapter:
|
||||
raise HTTPException(status_code=404, detail="章节不存在")
|
||||
|
||||
# 记录旧字数
|
||||
old_word_count = chapter.word_count or 0
|
||||
|
||||
# 更新字段
|
||||
update_data = chapter_update.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(chapter, field, value)
|
||||
|
||||
# 如果内容更新了,重新计算字数
|
||||
if "content" in update_data and chapter.content:
|
||||
new_word_count = len(chapter.content)
|
||||
chapter.word_count = new_word_count
|
||||
|
||||
# 更新项目字数
|
||||
result = await db.execute(
|
||||
select(Project).where(Project.id == chapter.project_id)
|
||||
)
|
||||
project = result.scalar_one_or_none()
|
||||
if project:
|
||||
project.current_words = project.current_words - old_word_count + new_word_count
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(chapter)
|
||||
return chapter
|
||||
|
||||
|
||||
@router.delete("/{chapter_id}", summary="删除章节")
|
||||
async def delete_chapter(
|
||||
chapter_id: str,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""删除章节"""
|
||||
result = await db.execute(
|
||||
select(Chapter).where(Chapter.id == chapter_id)
|
||||
)
|
||||
chapter = result.scalar_one_or_none()
|
||||
|
||||
if not chapter:
|
||||
raise HTTPException(status_code=404, detail="章节不存在")
|
||||
|
||||
# 更新项目字数
|
||||
result = await db.execute(
|
||||
select(Project).where(Project.id == chapter.project_id)
|
||||
)
|
||||
project = result.scalar_one_or_none()
|
||||
if project:
|
||||
project.current_words = max(0, project.current_words - chapter.word_count)
|
||||
|
||||
await db.delete(chapter)
|
||||
await db.commit()
|
||||
|
||||
return {"message": "章节删除成功"}
|
||||
|
||||
|
||||
async def check_prerequisites(db: AsyncSession, chapter: Chapter) -> tuple[bool, str, list[Chapter]]:
|
||||
"""
|
||||
检查章节前置条件
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
chapter: 当前章节
|
||||
|
||||
Returns:
|
||||
(可否生成, 错误信息, 前置章节列表)
|
||||
"""
|
||||
# 如果是第一章,无需检查前置
|
||||
if chapter.chapter_number == 1:
|
||||
return True, "", []
|
||||
|
||||
# 查询所有前置章节(序号小于当前章节的)
|
||||
result = await db.execute(
|
||||
select(Chapter)
|
||||
.where(Chapter.project_id == chapter.project_id)
|
||||
.where(Chapter.chapter_number < chapter.chapter_number)
|
||||
.order_by(Chapter.chapter_number)
|
||||
)
|
||||
previous_chapters = result.scalars().all()
|
||||
|
||||
# 检查是否所有前置章节都有内容
|
||||
incomplete_chapters = [
|
||||
ch for ch in previous_chapters
|
||||
if not ch.content or ch.content.strip() == ""
|
||||
]
|
||||
|
||||
if incomplete_chapters:
|
||||
missing_numbers = [str(ch.chapter_number) for ch in incomplete_chapters]
|
||||
error_msg = f"需要先完成前置章节:第 {', '.join(missing_numbers)} 章"
|
||||
return False, error_msg, previous_chapters
|
||||
|
||||
return True, "", previous_chapters
|
||||
|
||||
|
||||
@router.get("/{chapter_id}/can-generate", summary="检查章节是否可以生成")
|
||||
async def check_can_generate(
|
||||
chapter_id: str,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
检查章节是否满足生成条件
|
||||
返回可生成状态和前置章节信息
|
||||
"""
|
||||
# 获取章节
|
||||
result = await db.execute(
|
||||
select(Chapter).where(Chapter.id == chapter_id)
|
||||
)
|
||||
chapter = result.scalar_one_or_none()
|
||||
if not chapter:
|
||||
raise HTTPException(status_code=404, detail="章节不存在")
|
||||
|
||||
# 检查前置条件
|
||||
can_generate, error_msg, previous_chapters = await check_prerequisites(db, chapter)
|
||||
|
||||
# 构建前置章节信息
|
||||
previous_info = [
|
||||
{
|
||||
"id": ch.id,
|
||||
"chapter_number": ch.chapter_number,
|
||||
"title": ch.title,
|
||||
"has_content": bool(ch.content and ch.content.strip()),
|
||||
"word_count": ch.word_count or 0
|
||||
}
|
||||
for ch in previous_chapters
|
||||
]
|
||||
|
||||
return {
|
||||
"can_generate": can_generate,
|
||||
"reason": error_msg if not can_generate else "",
|
||||
"previous_chapters": previous_info,
|
||||
"chapter_number": chapter.chapter_number
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{chapter_id}/generate", summary="AI创作章节内容")
|
||||
async def generate_chapter_content(
|
||||
chapter_id: str,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
根据大纲、前置章节内容和项目信息AI创作章节完整内容
|
||||
要求:必须按顺序生成,确保前置章节都已完成
|
||||
"""
|
||||
# 获取章节
|
||||
result = await db.execute(
|
||||
select(Chapter).where(Chapter.id == chapter_id)
|
||||
)
|
||||
chapter = result.scalar_one_or_none()
|
||||
if not chapter:
|
||||
raise HTTPException(status_code=404, detail="章节不存在")
|
||||
|
||||
# 检查前置条件
|
||||
can_generate, error_msg, previous_chapters = await check_prerequisites(db, chapter)
|
||||
if not can_generate:
|
||||
raise HTTPException(status_code=400, detail=error_msg)
|
||||
|
||||
try:
|
||||
# 获取项目信息
|
||||
project_result = await db.execute(
|
||||
select(Project).where(Project.id == chapter.project_id)
|
||||
)
|
||||
project = project_result.scalar_one_or_none()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="项目不存在")
|
||||
|
||||
# 获取对应的大纲(使用新的查询确保获取最新数据)
|
||||
outline_result = await db.execute(
|
||||
select(Outline)
|
||||
.where(Outline.project_id == chapter.project_id)
|
||||
.where(Outline.order_index == chapter.chapter_number)
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
outline = outline_result.scalar_one_or_none()
|
||||
|
||||
# 获取所有大纲用于上下文(使用新的查询确保获取最新数据)
|
||||
all_outlines_result = await db.execute(
|
||||
select(Outline)
|
||||
.where(Outline.project_id == chapter.project_id)
|
||||
.order_by(Outline.order_index)
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
all_outlines = all_outlines_result.scalars().all()
|
||||
outlines_context = "\n".join([
|
||||
f"第{o.order_index}章 {o.title}: {o.content[:100]}..."
|
||||
for o in all_outlines
|
||||
])
|
||||
|
||||
# 获取角色信息
|
||||
characters_result = await db.execute(
|
||||
select(Character).where(Character.project_id == chapter.project_id)
|
||||
)
|
||||
characters = characters_result.scalars().all()
|
||||
characters_info = "\n".join([
|
||||
f"- {c.name}({'组织' if c.is_organization else '角色'}, {c.role_type}): {c.personality[:100] if c.personality else ''}"
|
||||
for c in characters
|
||||
])
|
||||
|
||||
# 构建前置章节内容上下文(如果有前置章节)
|
||||
previous_content = ""
|
||||
if previous_chapters:
|
||||
# Token控制:保留最近3章的完整内容,早期章节使用摘要
|
||||
recent_chapters = previous_chapters[-3:] if len(previous_chapters) > 3 else previous_chapters
|
||||
early_chapters = previous_chapters[:-3] if len(previous_chapters) > 3 else []
|
||||
|
||||
# 早期章节摘要
|
||||
if early_chapters:
|
||||
early_summary = "【前期剧情概要】\n" + "\n".join([
|
||||
f"第{ch.chapter_number}章《{ch.title}》:{ch.content[:200] if ch.content else ''}..."
|
||||
for ch in early_chapters
|
||||
])
|
||||
previous_content += early_summary + "\n\n"
|
||||
|
||||
# 最近章节完整内容
|
||||
if recent_chapters:
|
||||
recent_content = "【最近章节完整内容】\n" + "\n\n".join([
|
||||
f"=== 第{ch.chapter_number}章:{ch.title} ===\n{ch.content}"
|
||||
for ch in recent_chapters
|
||||
])
|
||||
previous_content += recent_content
|
||||
|
||||
logger.info(f"构建前置上下文:{len(early_chapters)}章摘要 + {len(recent_chapters)}章完整内容")
|
||||
|
||||
# 根据是否有前置内容选择不同的提示词
|
||||
if previous_content:
|
||||
# 使用带上下文的提示词
|
||||
prompt = prompt_service.get_chapter_generation_with_context_prompt(
|
||||
title=project.title,
|
||||
theme=project.theme or '',
|
||||
genre=project.genre or '',
|
||||
narrative_perspective=project.narrative_perspective or '第三人称',
|
||||
time_period=project.world_time_period or '未设定',
|
||||
location=project.world_location or '未设定',
|
||||
atmosphere=project.world_atmosphere or '未设定',
|
||||
rules=project.world_rules or '未设定',
|
||||
characters_info=characters_info or '暂无角色信息',
|
||||
outlines_context=outlines_context,
|
||||
previous_content=previous_content,
|
||||
chapter_number=chapter.chapter_number,
|
||||
chapter_title=chapter.title,
|
||||
chapter_outline=outline.content if outline else chapter.summary or '暂无大纲'
|
||||
)
|
||||
else:
|
||||
# 第一章,使用原有提示词
|
||||
prompt = prompt_service.get_chapter_generation_prompt(
|
||||
title=project.title,
|
||||
theme=project.theme or '',
|
||||
genre=project.genre or '',
|
||||
narrative_perspective=project.narrative_perspective or '第三人称',
|
||||
time_period=project.world_time_period or '未设定',
|
||||
location=project.world_location or '未设定',
|
||||
atmosphere=project.world_atmosphere or '未设定',
|
||||
rules=project.world_rules or '未设定',
|
||||
characters_info=characters_info or '暂无角色信息',
|
||||
outlines_context=outlines_context,
|
||||
chapter_number=chapter.chapter_number,
|
||||
chapter_title=chapter.title,
|
||||
chapter_outline=outline.content if outline else chapter.summary or '暂无大纲'
|
||||
)
|
||||
|
||||
logger.info(f"开始AI创作章节 {chapter_id}")
|
||||
|
||||
# 调用AI生成
|
||||
ai_content = await ai_service.generate_text(
|
||||
prompt=prompt
|
||||
)
|
||||
|
||||
# 更新章节内容
|
||||
old_word_count = chapter.word_count or 0
|
||||
chapter.content = ai_content
|
||||
new_word_count = len(ai_content)
|
||||
chapter.word_count = new_word_count
|
||||
chapter.status = "completed"
|
||||
|
||||
# 更新项目字数
|
||||
project.current_words = project.current_words - old_word_count + new_word_count
|
||||
|
||||
# 记录生成历史
|
||||
history = GenerationHistory(
|
||||
project_id=chapter.project_id,
|
||||
chapter_id=chapter.id,
|
||||
prompt=f"创作章节: 第{chapter.chapter_number}章 {chapter.title}",
|
||||
generated_content=ai_content[:500] if len(ai_content) > 500 else ai_content,
|
||||
model="default"
|
||||
)
|
||||
db.add(history)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(chapter)
|
||||
|
||||
logger.info(f"成功创作章节 {chapter_id},共 {new_word_count} 字")
|
||||
|
||||
return {"content": ai_content}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"创作章节失败: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"创作章节失败: {str(e)}")
|
||||
|
||||
@router.post("/{chapter_id}/generate-stream", summary="AI创作章节内容(流式)")
|
||||
async def generate_chapter_content_stream(
|
||||
chapter_id: str,
|
||||
request: Request
|
||||
):
|
||||
"""
|
||||
根据大纲、前置章节内容和项目信息AI创作章节完整内容(流式返回)
|
||||
要求:必须按顺序生成,确保前置章节都已完成
|
||||
|
||||
注意:此函数不使用依赖注入的db,而是在生成器内部创建独立的数据库会话
|
||||
以避免流式响应期间的连接泄漏问题
|
||||
"""
|
||||
# 预先验证章节存在性(使用临时会话)
|
||||
async for temp_db in get_db(request):
|
||||
try:
|
||||
result = await temp_db.execute(
|
||||
select(Chapter).where(Chapter.id == chapter_id)
|
||||
)
|
||||
chapter = result.scalar_one_or_none()
|
||||
if not chapter:
|
||||
raise HTTPException(status_code=404, detail="章节不存在")
|
||||
|
||||
# 检查前置条件
|
||||
can_generate, error_msg, previous_chapters = await check_prerequisites(temp_db, chapter)
|
||||
if not can_generate:
|
||||
raise HTTPException(status_code=400, detail=error_msg)
|
||||
|
||||
# 保存前置章节数据供生成器使用
|
||||
previous_chapters_data = [
|
||||
{
|
||||
'id': ch.id,
|
||||
'chapter_number': ch.chapter_number,
|
||||
'title': ch.title,
|
||||
'content': ch.content
|
||||
}
|
||||
for ch in previous_chapters
|
||||
]
|
||||
finally:
|
||||
await temp_db.close()
|
||||
break
|
||||
|
||||
async def event_generator():
|
||||
# 在生成器内部创建独立的数据库会话
|
||||
db_session = None
|
||||
db_committed = False
|
||||
try:
|
||||
# 创建新的数据库会话
|
||||
async for db_session in get_db(request):
|
||||
# 重新获取章节信息
|
||||
chapter_result = await db_session.execute(
|
||||
select(Chapter).where(Chapter.id == chapter_id)
|
||||
)
|
||||
current_chapter = chapter_result.scalar_one_or_none()
|
||||
if not current_chapter:
|
||||
yield f"data: {json.dumps({'type': 'error', 'error': '章节不存在'}, ensure_ascii=False)}\n\n"
|
||||
return
|
||||
|
||||
# 获取项目信息
|
||||
project_result = await db_session.execute(
|
||||
select(Project).where(Project.id == current_chapter.project_id)
|
||||
)
|
||||
project = project_result.scalar_one_or_none()
|
||||
if not project:
|
||||
yield f"data: {json.dumps({'type': 'error', 'error': '项目不存在'}, ensure_ascii=False)}\n\n"
|
||||
return
|
||||
|
||||
# 获取对应的大纲
|
||||
outline_result = await db_session.execute(
|
||||
select(Outline)
|
||||
.where(Outline.project_id == current_chapter.project_id)
|
||||
.where(Outline.order_index == current_chapter.chapter_number)
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
outline = outline_result.scalar_one_or_none()
|
||||
|
||||
# 获取所有大纲用于上下文
|
||||
all_outlines_result = await db_session.execute(
|
||||
select(Outline)
|
||||
.where(Outline.project_id == current_chapter.project_id)
|
||||
.order_by(Outline.order_index)
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
all_outlines = all_outlines_result.scalars().all()
|
||||
outlines_context = "\n".join([
|
||||
f"第{o.order_index}章 {o.title}: {o.content[:100]}..."
|
||||
for o in all_outlines
|
||||
])
|
||||
|
||||
# 获取角色信息
|
||||
characters_result = await db_session.execute(
|
||||
select(Character).where(Character.project_id == current_chapter.project_id)
|
||||
)
|
||||
characters = characters_result.scalars().all()
|
||||
characters_info = "\n".join([
|
||||
f"- {c.name}({'组织' if c.is_organization else '角色'}, {c.role_type}): {c.personality[:100] if c.personality else ''}"
|
||||
for c in characters
|
||||
])
|
||||
|
||||
# 构建前置章节内容上下文(使用之前保存的数据)
|
||||
previous_content = ""
|
||||
if previous_chapters_data:
|
||||
recent_chapters = previous_chapters_data[-3:] if len(previous_chapters_data) > 3 else previous_chapters_data
|
||||
early_chapters = previous_chapters_data[:-3] if len(previous_chapters_data) > 3 else []
|
||||
|
||||
if early_chapters:
|
||||
early_summary = "【前期剧情概要】\n" + "\n".join([
|
||||
f"第{ch['chapter_number']}章《{ch['title']}》:{ch['content'][:200] if ch['content'] else ''}..."
|
||||
for ch in early_chapters
|
||||
])
|
||||
previous_content += early_summary + "\n\n"
|
||||
|
||||
if recent_chapters:
|
||||
recent_content = "【最近章节完整内容】\n" + "\n\n".join([
|
||||
f"=== 第{ch['chapter_number']}章:{ch['title']} ===\n{ch['content']}"
|
||||
for ch in recent_chapters
|
||||
])
|
||||
previous_content += recent_content
|
||||
|
||||
logger.info(f"构建前置上下文:{len(early_chapters)}章摘要 + {len(recent_chapters)}章完整内容")
|
||||
|
||||
# 发送开始事件
|
||||
yield f"data: {json.dumps({'type': 'start', 'message': '开始AI创作...'}, ensure_ascii=False)}\n\n"
|
||||
|
||||
# 根据是否有前置内容选择不同的提示词
|
||||
if previous_content:
|
||||
prompt = prompt_service.get_chapter_generation_with_context_prompt(
|
||||
title=project.title,
|
||||
theme=project.theme or '',
|
||||
genre=project.genre or '',
|
||||
narrative_perspective=project.narrative_perspective or '第三人称',
|
||||
time_period=project.world_time_period or '未设定',
|
||||
location=project.world_location or '未设定',
|
||||
atmosphere=project.world_atmosphere or '未设定',
|
||||
rules=project.world_rules or '未设定',
|
||||
characters_info=characters_info or '暂无角色信息',
|
||||
outlines_context=outlines_context,
|
||||
previous_content=previous_content,
|
||||
chapter_number=current_chapter.chapter_number,
|
||||
chapter_title=current_chapter.title,
|
||||
chapter_outline=outline.content if outline else current_chapter.summary or '暂无大纲'
|
||||
)
|
||||
else:
|
||||
prompt = prompt_service.get_chapter_generation_prompt(
|
||||
title=project.title,
|
||||
theme=project.theme or '',
|
||||
genre=project.genre or '',
|
||||
narrative_perspective=project.narrative_perspective or '第三人称',
|
||||
time_period=project.world_time_period or '未设定',
|
||||
location=project.world_location or '未设定',
|
||||
atmosphere=project.world_atmosphere or '未设定',
|
||||
rules=project.world_rules or '未设定',
|
||||
characters_info=characters_info or '暂无角色信息',
|
||||
outlines_context=outlines_context,
|
||||
chapter_number=current_chapter.chapter_number,
|
||||
chapter_title=current_chapter.title,
|
||||
chapter_outline=outline.content if outline else current_chapter.summary or '暂无大纲'
|
||||
)
|
||||
|
||||
logger.info(f"开始AI流式创作章节 {chapter_id}")
|
||||
|
||||
# 流式生成内容
|
||||
full_content = ""
|
||||
async for chunk in ai_service.generate_text_stream(prompt=prompt):
|
||||
full_content += chunk
|
||||
yield f"data: {json.dumps({'type': 'content', 'content': chunk}, ensure_ascii=False)}\n\n"
|
||||
await asyncio.sleep(0) # 让出控制权
|
||||
|
||||
# 更新章节内容到数据库
|
||||
old_word_count = current_chapter.word_count or 0
|
||||
current_chapter.content = full_content
|
||||
new_word_count = len(full_content)
|
||||
current_chapter.word_count = new_word_count
|
||||
current_chapter.status = "completed"
|
||||
|
||||
# 更新项目字数
|
||||
project.current_words = project.current_words - old_word_count + new_word_count
|
||||
|
||||
# 记录生成历史
|
||||
history = GenerationHistory(
|
||||
project_id=current_chapter.project_id,
|
||||
chapter_id=current_chapter.id,
|
||||
prompt=f"创作章节: 第{current_chapter.chapter_number}章 {current_chapter.title}",
|
||||
generated_content=full_content[:500] if len(full_content) > 500 else full_content,
|
||||
model="default"
|
||||
)
|
||||
db_session.add(history)
|
||||
|
||||
await db_session.commit()
|
||||
db_committed = True
|
||||
await db_session.refresh(current_chapter)
|
||||
|
||||
logger.info(f"成功创作章节 {chapter_id},共 {new_word_count} 字")
|
||||
|
||||
# 发送完成事件
|
||||
yield f"data: {json.dumps({'type': 'done', 'message': '创作完成', 'word_count': new_word_count}, ensure_ascii=False)}\n\n"
|
||||
|
||||
break # 退出async for db_session循环
|
||||
|
||||
except GeneratorExit:
|
||||
# SSE连接断开
|
||||
logger.warning("章节生成器被提前关闭(SSE断开)")
|
||||
if db_session and not db_committed:
|
||||
try:
|
||||
if db_session.in_transaction():
|
||||
await db_session.rollback()
|
||||
logger.info("章节生成事务已回滚(GeneratorExit)")
|
||||
except Exception as e:
|
||||
logger.error(f"GeneratorExit回滚失败: {str(e)}")
|
||||
except Exception as e:
|
||||
logger.error(f"流式创作章节失败: {str(e)}")
|
||||
if db_session and not db_committed:
|
||||
try:
|
||||
if db_session.in_transaction():
|
||||
await db_session.rollback()
|
||||
logger.info("章节生成事务已回滚(异常)")
|
||||
except Exception as rollback_error:
|
||||
logger.error(f"回滚失败: {str(rollback_error)}")
|
||||
yield f"data: {json.dumps({'type': 'error', 'error': str(e)}, ensure_ascii=False)}\n\n"
|
||||
finally:
|
||||
# 确保数据库会话被正确关闭
|
||||
if db_session:
|
||||
try:
|
||||
# 最后检查:确保没有未提交的事务
|
||||
if not db_committed and db_session.in_transaction():
|
||||
await db_session.rollback()
|
||||
logger.warning("在finally中发现未提交事务,已回滚")
|
||||
|
||||
await db_session.close()
|
||||
logger.info("数据库会话已关闭")
|
||||
except Exception as close_error:
|
||||
logger.error(f"关闭数据库会话失败: {str(close_error)}")
|
||||
# 强制关闭
|
||||
try:
|
||||
await db_session.close()
|
||||
except:
|
||||
pass
|
||||
|
||||
return StreamingResponse(
|
||||
event_generator(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no"
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,491 @@
|
||||
"""角色管理API"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
import json
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.character import Character
|
||||
from app.models.project import Project
|
||||
from app.models.generation_history import GenerationHistory
|
||||
from app.models.relationship import CharacterRelationship, Organization, OrganizationMember, RelationshipType
|
||||
from app.schemas.character import (
|
||||
CharacterUpdate,
|
||||
CharacterResponse,
|
||||
CharacterListResponse,
|
||||
CharacterGenerateRequest
|
||||
)
|
||||
from app.services.ai_service import ai_service
|
||||
from app.services.prompt_service import prompt_service
|
||||
from app.logger import get_logger
|
||||
|
||||
router = APIRouter(prefix="/characters", tags=["角色管理"])
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@router.get("", response_model=CharacterListResponse, summary="获取角色列表")
|
||||
async def get_characters(
|
||||
project_id: str,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""获取指定项目的所有角色(query参数版本)"""
|
||||
# 获取总数
|
||||
count_result = await db.execute(
|
||||
select(func.count(Character.id)).where(Character.project_id == project_id)
|
||||
)
|
||||
total = count_result.scalar_one()
|
||||
|
||||
# 获取角色列表
|
||||
result = await db.execute(
|
||||
select(Character)
|
||||
.where(Character.project_id == project_id)
|
||||
.order_by(Character.created_at.desc())
|
||||
)
|
||||
characters = result.scalars().all()
|
||||
|
||||
return CharacterListResponse(total=total, items=characters)
|
||||
|
||||
|
||||
@router.get("/project/{project_id}", response_model=CharacterListResponse, summary="获取项目的所有角色")
|
||||
async def get_project_characters(
|
||||
project_id: str,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""获取指定项目的所有角色(路径参数版本)"""
|
||||
# 获取总数
|
||||
count_result = await db.execute(
|
||||
select(func.count(Character.id)).where(Character.project_id == project_id)
|
||||
)
|
||||
total = count_result.scalar_one()
|
||||
|
||||
# 获取角色列表
|
||||
result = await db.execute(
|
||||
select(Character)
|
||||
.where(Character.project_id == project_id)
|
||||
.order_by(Character.created_at.desc())
|
||||
)
|
||||
characters = result.scalars().all()
|
||||
|
||||
return CharacterListResponse(total=total, items=characters)
|
||||
|
||||
|
||||
@router.get("/{character_id}", response_model=CharacterResponse, summary="获取角色详情")
|
||||
async def get_character(
|
||||
character_id: str,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""根据ID获取角色详情"""
|
||||
result = await db.execute(
|
||||
select(Character).where(Character.id == character_id)
|
||||
)
|
||||
character = result.scalar_one_or_none()
|
||||
|
||||
if not character:
|
||||
raise HTTPException(status_code=404, detail="角色不存在")
|
||||
|
||||
return character
|
||||
|
||||
|
||||
@router.put("/{character_id}", response_model=CharacterResponse, summary="更新角色")
|
||||
async def update_character(
|
||||
character_id: str,
|
||||
character_update: CharacterUpdate,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""更新角色信息"""
|
||||
result = await db.execute(
|
||||
select(Character).where(Character.id == character_id)
|
||||
)
|
||||
character = result.scalar_one_or_none()
|
||||
|
||||
if not character:
|
||||
raise HTTPException(status_code=404, detail="角色不存在")
|
||||
|
||||
# 更新字段
|
||||
update_data = character_update.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(character, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(character)
|
||||
return character
|
||||
|
||||
|
||||
@router.delete("/{character_id}", summary="删除角色")
|
||||
async def delete_character(
|
||||
character_id: str,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""删除角色"""
|
||||
result = await db.execute(
|
||||
select(Character).where(Character.id == character_id)
|
||||
)
|
||||
character = result.scalar_one_or_none()
|
||||
|
||||
if not character:
|
||||
raise HTTPException(status_code=404, detail="角色不存在")
|
||||
|
||||
await db.delete(character)
|
||||
await db.commit()
|
||||
|
||||
return {"message": "角色删除成功"}
|
||||
|
||||
|
||||
@router.post("/generate", response_model=CharacterResponse, summary="AI生成角色")
|
||||
async def generate_character(
|
||||
request: CharacterGenerateRequest,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
使用AI生成角色卡
|
||||
|
||||
根据用户输入的信息,结合项目的世界观、主题等背景,
|
||||
AI会生成一个完整、详细的角色设定卡片。
|
||||
|
||||
生成内容包括:姓名、年龄、性别、性格、外貌、背景故事、人际关系等
|
||||
"""
|
||||
# 验证项目是否存在并获取项目信息
|
||||
result = await db.execute(
|
||||
select(Project).where(Project.id == request.project_id)
|
||||
)
|
||||
project = result.scalar_one_or_none()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="项目不存在")
|
||||
|
||||
try:
|
||||
# 获取已存在的角色列表,用于关系网络
|
||||
existing_chars_result = await db.execute(
|
||||
select(Character)
|
||||
.where(Character.project_id == request.project_id)
|
||||
.order_by(Character.created_at.desc())
|
||||
)
|
||||
existing_characters = existing_chars_result.scalars().all()
|
||||
|
||||
# 构建现有角色信息摘要(包含组织)
|
||||
existing_chars_info = ""
|
||||
character_list = []
|
||||
organization_list = []
|
||||
|
||||
if existing_characters:
|
||||
for c in existing_characters[:10]: # 最多显示10个
|
||||
if c.is_organization:
|
||||
organization_list.append(f"- {c.name} [{c.organization_type or '组织'}]")
|
||||
else:
|
||||
character_list.append(f"- {c.name}({c.role_type or '未知'})")
|
||||
|
||||
if character_list:
|
||||
existing_chars_info += "\n已有角色:\n" + "\n".join(character_list)
|
||||
if organization_list:
|
||||
existing_chars_info += "\n\n已有组织:\n" + "\n".join(organization_list)
|
||||
|
||||
# 构建项目上下文信息
|
||||
project_context = f"""
|
||||
项目信息:
|
||||
- 书名:{project.title}
|
||||
- 主题:{project.theme or '未设定'}
|
||||
- 类型:{project.genre or '未设定'}
|
||||
- 时间背景:{project.world_time_period or '未设定'}
|
||||
- 地理位置:{project.world_location or '未设定'}
|
||||
- 氛围基调:{project.world_atmosphere or '未设定'}
|
||||
- 世界规则:{project.world_rules or '未设定'}
|
||||
{existing_chars_info}
|
||||
"""
|
||||
|
||||
# 构建用户输入信息
|
||||
user_input = f"""
|
||||
用户要求:
|
||||
- 角色名称:{request.name or '请AI生成'}
|
||||
- 角色定位:{request.role_type or 'supporting'}(protagonist=主角, supporting=配角, antagonist=反派)
|
||||
- 背景设定:{request.background or '无特殊要求'}
|
||||
- 其他要求:{request.requirements or '无'}
|
||||
"""
|
||||
|
||||
# 使用统一的提示词服务
|
||||
prompt = prompt_service.get_single_character_prompt(
|
||||
project_context=project_context,
|
||||
user_input=user_input
|
||||
)
|
||||
|
||||
# 调用AI生成角色
|
||||
logger.info(f"🎯 开始为项目 {request.project_id} 生成角色")
|
||||
logger.info(f" - 角色名:{request.name or 'AI生成'}")
|
||||
logger.info(f" - 角色定位:{request.role_type}")
|
||||
logger.info(f" - 背景设定:{request.background or '无'}")
|
||||
logger.info(f" - AI提供商:{request.provider or 'default'}")
|
||||
logger.info(f" - AI模型:{request.model or 'default'}")
|
||||
logger.info(f" - Prompt长度:{len(prompt)} 字符")
|
||||
|
||||
try:
|
||||
ai_response = await ai_service.generate_text(
|
||||
prompt=prompt,
|
||||
provider=request.provider,
|
||||
model=request.model
|
||||
)
|
||||
logger.info(f"✅ AI响应接收完成,长度:{len(ai_response) if ai_response else 0} 字符")
|
||||
except Exception as ai_error:
|
||||
logger.error(f"❌ AI服务调用异常:{str(ai_error)}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"AI服务调用失败:{str(ai_error)}"
|
||||
)
|
||||
|
||||
# 检查AI响应
|
||||
if not ai_response or not ai_response.strip():
|
||||
logger.error("❌ AI返回了空响应")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="AI服务返回空响应。可能原因:1) API配置错误 2) 模型不支持 3) 网络问题。请检查后端日志。"
|
||||
)
|
||||
|
||||
logger.info(f"📝 开始清理AI响应")
|
||||
# 清理AI响应,移除可能的markdown标记
|
||||
cleaned_response = ai_response.strip()
|
||||
original_length = len(cleaned_response)
|
||||
|
||||
if cleaned_response.startswith("```json"):
|
||||
cleaned_response = cleaned_response[7:]
|
||||
logger.info(" - 移除了 ```json 标记")
|
||||
if cleaned_response.startswith("```"):
|
||||
cleaned_response = cleaned_response[3:]
|
||||
logger.info(" - 移除了 ``` 标记")
|
||||
if cleaned_response.endswith("```"):
|
||||
cleaned_response = cleaned_response[:-3]
|
||||
logger.info(" - 移除了末尾 ``` 标记")
|
||||
cleaned_response = cleaned_response.strip()
|
||||
|
||||
logger.info(f" - 清理前长度:{original_length},清理后长度:{len(cleaned_response)}")
|
||||
logger.info(f" - 清理后内容预览(前300字符):{cleaned_response[:300]}")
|
||||
|
||||
# 解析AI响应
|
||||
logger.info(f"🔍 开始解析JSON")
|
||||
try:
|
||||
character_data = json.loads(cleaned_response)
|
||||
logger.info(f"✅ JSON解析成功")
|
||||
logger.info(f" - 解析后的字段:{list(character_data.keys())}")
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"❌ JSON解析失败")
|
||||
logger.error(f" - 错误位置:line {e.lineno}, column {e.colno}")
|
||||
logger.error(f" - 错误信息:{str(e)}")
|
||||
logger.error(f" - 完整响应内容(前1000字符):{cleaned_response[:1000]}")
|
||||
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"AI返回的内容无法解析为JSON。错误:{str(e)}。响应内容已记录到日志,请查看后端日志排查。"
|
||||
)
|
||||
|
||||
# 转换traits为JSON字符串
|
||||
traits_json = json.dumps(character_data.get("traits", []), ensure_ascii=False) if character_data.get("traits") else None
|
||||
|
||||
# 判断是否为组织
|
||||
is_organization = character_data.get("is_organization", False)
|
||||
|
||||
# 创建角色
|
||||
character = Character(
|
||||
project_id=request.project_id,
|
||||
name=character_data.get("name", request.name or "未命名角色"),
|
||||
age=str(character_data.get("age", "")),
|
||||
gender=character_data.get("gender"),
|
||||
is_organization=is_organization,
|
||||
role_type=request.role_type or "supporting",
|
||||
personality=character_data.get("personality", ""),
|
||||
background=character_data.get("background", ""),
|
||||
appearance=character_data.get("appearance", ""),
|
||||
relationships=character_data.get("relationships_text", character_data.get("relationships", "")), # 优先使用文本描述
|
||||
organization_type=character_data.get("organization_type") if is_organization else None,
|
||||
organization_purpose=character_data.get("organization_purpose") if is_organization else None,
|
||||
organization_members=json.dumps(character_data.get("organization_members", []), ensure_ascii=False) if is_organization else None,
|
||||
traits=traits_json
|
||||
)
|
||||
db.add(character)
|
||||
await db.flush() # 获取character.id
|
||||
|
||||
logger.info(f"✅ 角色创建成功:{character.name} (ID: {character.id}, 是否组织: {is_organization})")
|
||||
|
||||
# 如果是组织,自动创建Organization详情记录
|
||||
if is_organization:
|
||||
org_check = await db.execute(
|
||||
select(Organization).where(Organization.character_id == character.id)
|
||||
)
|
||||
existing_org = org_check.scalar_one_or_none()
|
||||
|
||||
if not existing_org:
|
||||
organization = Organization(
|
||||
character_id=character.id,
|
||||
project_id=request.project_id,
|
||||
member_count=0,
|
||||
power_level=character_data.get("power_level", 50),
|
||||
location=character_data.get("location"),
|
||||
motto=character_data.get("motto")
|
||||
)
|
||||
db.add(organization)
|
||||
await db.flush()
|
||||
logger.info(f"✅ 自动创建组织详情:{character.name} (Org ID: {organization.id})")
|
||||
else:
|
||||
logger.info(f"ℹ️ 组织详情已存在:{character.name}")
|
||||
|
||||
# 处理结构化关系数据(仅针对非组织角色)
|
||||
if not is_organization:
|
||||
relationships_data = character_data.get("relationships", [])
|
||||
if relationships_data and isinstance(relationships_data, list):
|
||||
logger.info(f"📊 开始处理 {len(relationships_data)} 条关系数据")
|
||||
created_rels = 0
|
||||
|
||||
for rel in relationships_data:
|
||||
try:
|
||||
target_name = rel.get("target_character_name")
|
||||
if not target_name:
|
||||
logger.debug(f" ⚠️ 关系缺少target_character_name,跳过")
|
||||
continue
|
||||
|
||||
target_result = await db.execute(
|
||||
select(Character).where(
|
||||
Character.project_id == request.project_id,
|
||||
Character.name == target_name
|
||||
)
|
||||
)
|
||||
target_char = target_result.scalar_one_or_none()
|
||||
|
||||
if target_char:
|
||||
# 检查是否已存在相同关系
|
||||
existing_rel = await db.execute(
|
||||
select(CharacterRelationship).where(
|
||||
CharacterRelationship.project_id == request.project_id,
|
||||
CharacterRelationship.character_from_id == character.id,
|
||||
CharacterRelationship.character_to_id == target_char.id
|
||||
)
|
||||
)
|
||||
if existing_rel.scalar_one_or_none():
|
||||
logger.debug(f" ℹ️ 关系已存在:{character.name} -> {target_name}")
|
||||
continue
|
||||
|
||||
relationship = CharacterRelationship(
|
||||
project_id=request.project_id,
|
||||
character_from_id=character.id,
|
||||
character_to_id=target_char.id,
|
||||
relationship_name=rel.get("relationship_type", "未知关系"),
|
||||
intimacy_level=rel.get("intimacy_level", 50),
|
||||
description=rel.get("description", ""),
|
||||
started_at=rel.get("started_at"),
|
||||
source="ai"
|
||||
)
|
||||
|
||||
# 匹配预定义关系类型
|
||||
rel_type_result = await db.execute(
|
||||
select(RelationshipType).where(
|
||||
RelationshipType.name == rel.get("relationship_type")
|
||||
)
|
||||
)
|
||||
rel_type = rel_type_result.scalar_one_or_none()
|
||||
if rel_type:
|
||||
relationship.relationship_type_id = rel_type.id
|
||||
|
||||
db.add(relationship)
|
||||
created_rels += 1
|
||||
logger.info(f" ✅ 创建关系:{character.name} -> {target_name} ({rel.get('relationship_type')})")
|
||||
else:
|
||||
logger.warning(f" ⚠️ 目标角色不存在:{target_name}")
|
||||
|
||||
except Exception as rel_error:
|
||||
logger.warning(f" ❌ 创建关系失败:{str(rel_error)}")
|
||||
continue
|
||||
|
||||
logger.info(f"✅ 成功创建 {created_rels} 条关系记录")
|
||||
|
||||
# 处理组织成员关系(仅针对非组织角色)
|
||||
if not is_organization:
|
||||
org_memberships = character_data.get("organization_memberships", [])
|
||||
if org_memberships and isinstance(org_memberships, list):
|
||||
logger.info(f"🏢 开始处理 {len(org_memberships)} 条组织成员关系")
|
||||
created_members = 0
|
||||
|
||||
for membership in org_memberships:
|
||||
try:
|
||||
org_name = membership.get("organization_name")
|
||||
if not org_name:
|
||||
logger.debug(f" ⚠️ 组织成员关系缺少organization_name,跳过")
|
||||
continue
|
||||
|
||||
org_char_result = await db.execute(
|
||||
select(Character).where(
|
||||
Character.project_id == request.project_id,
|
||||
Character.name == org_name,
|
||||
Character.is_organization == True
|
||||
)
|
||||
)
|
||||
org_char = org_char_result.scalar_one_or_none()
|
||||
|
||||
if org_char:
|
||||
# 获取或创建Organization记录
|
||||
org_result = await db.execute(
|
||||
select(Organization).where(Organization.character_id == org_char.id)
|
||||
)
|
||||
org = org_result.scalar_one_or_none()
|
||||
|
||||
if not org:
|
||||
# 如果组织Character存在但Organization不存在,自动创建
|
||||
org = Organization(
|
||||
character_id=org_char.id,
|
||||
project_id=request.project_id,
|
||||
member_count=0
|
||||
)
|
||||
db.add(org)
|
||||
await db.flush()
|
||||
logger.info(f" ℹ️ 自动创建缺失的组织详情:{org_name}")
|
||||
|
||||
# 检查是否已存在成员关系
|
||||
existing_member = await db.execute(
|
||||
select(OrganizationMember).where(
|
||||
OrganizationMember.organization_id == org.id,
|
||||
OrganizationMember.character_id == character.id
|
||||
)
|
||||
)
|
||||
if existing_member.scalar_one_or_none():
|
||||
logger.debug(f" ℹ️ 成员关系已存在:{character.name} -> {org_name}")
|
||||
continue
|
||||
|
||||
# 创建成员关系
|
||||
member = OrganizationMember(
|
||||
organization_id=org.id,
|
||||
character_id=character.id,
|
||||
position=membership.get("position", "成员"),
|
||||
rank=membership.get("rank", 0),
|
||||
loyalty=membership.get("loyalty", 50),
|
||||
joined_at=membership.get("joined_at"),
|
||||
status=membership.get("status", "active"),
|
||||
source="ai"
|
||||
)
|
||||
db.add(member)
|
||||
|
||||
# 更新组织成员计数
|
||||
org.member_count += 1
|
||||
|
||||
created_members += 1
|
||||
logger.info(f" ✅ 添加成员:{character.name} -> {org_name} ({membership.get('position')})")
|
||||
else:
|
||||
logger.warning(f" ⚠️ 组织不存在:{org_name}")
|
||||
|
||||
except Exception as org_error:
|
||||
logger.warning(f" ❌ 添加组织成员失败:{str(org_error)}")
|
||||
continue
|
||||
|
||||
logger.info(f"✅ 成功创建 {created_members} 条组织成员记录")
|
||||
|
||||
# 记录生成历史
|
||||
history = GenerationHistory(
|
||||
project_id=request.project_id,
|
||||
prompt=prompt,
|
||||
generated_content=ai_response,
|
||||
model=request.model or "default"
|
||||
)
|
||||
db.add(history)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(character)
|
||||
|
||||
logger.info(f"🎉 成功为项目 {request.project_id} 生成角色: {character.name}")
|
||||
|
||||
return character
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"生成角色失败: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"生成角色失败: {str(e)}")
|
||||
@@ -0,0 +1,341 @@
|
||||
"""组织管理API"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, and_
|
||||
from typing import List
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.relationship import Organization, OrganizationMember
|
||||
from app.models.character import Character
|
||||
from app.schemas.relationship import (
|
||||
OrganizationCreate,
|
||||
OrganizationUpdate,
|
||||
OrganizationResponse,
|
||||
OrganizationDetailResponse,
|
||||
OrganizationMemberCreate,
|
||||
OrganizationMemberUpdate,
|
||||
OrganizationMemberResponse,
|
||||
OrganizationMemberDetailResponse
|
||||
)
|
||||
from app.logger import get_logger
|
||||
|
||||
router = APIRouter(prefix="/organizations", tags=["组织管理"])
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@router.get("/project/{project_id}", response_model=List[OrganizationDetailResponse], summary="获取项目的所有组织")
|
||||
async def get_project_organizations(
|
||||
project_id: str,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
获取项目中的所有组织及其详情
|
||||
|
||||
返回组织的基本信息和统计数据
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Organization).where(Organization.project_id == project_id)
|
||||
)
|
||||
organizations = result.scalars().all()
|
||||
|
||||
# 获取每个组织的角色信息
|
||||
org_list = []
|
||||
for org in organizations:
|
||||
char_result = await db.execute(
|
||||
select(Character).where(Character.id == org.character_id)
|
||||
)
|
||||
char = char_result.scalar_one_or_none()
|
||||
|
||||
if char:
|
||||
org_list.append(OrganizationDetailResponse(
|
||||
id=org.id,
|
||||
character_id=org.character_id,
|
||||
name=char.name,
|
||||
type=char.organization_type,
|
||||
purpose=char.organization_purpose,
|
||||
member_count=org.member_count,
|
||||
power_level=org.power_level,
|
||||
location=org.location,
|
||||
motto=org.motto,
|
||||
color=org.color
|
||||
))
|
||||
|
||||
logger.info(f"获取项目 {project_id} 的组织列表,共 {len(org_list)} 个")
|
||||
return org_list
|
||||
|
||||
|
||||
@router.get("/{org_id}", response_model=OrganizationResponse, summary="获取组织详情")
|
||||
async def get_organization(
|
||||
org_id: str,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""获取组织的详细信息"""
|
||||
result = await db.execute(
|
||||
select(Organization).where(Organization.id == org_id)
|
||||
)
|
||||
org = result.scalar_one_or_none()
|
||||
|
||||
if not org:
|
||||
raise HTTPException(status_code=404, detail="组织不存在")
|
||||
|
||||
return org
|
||||
|
||||
|
||||
@router.post("/", response_model=OrganizationResponse, summary="创建组织")
|
||||
async def create_organization(
|
||||
organization: OrganizationCreate,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
创建新组织
|
||||
|
||||
- 需要关联到一个已存在的角色记录(is_organization=True)
|
||||
- 可以设置父组织、势力等级等属性
|
||||
"""
|
||||
# 验证角色是否存在且是组织
|
||||
char_result = await db.execute(
|
||||
select(Character).where(Character.id == organization.character_id)
|
||||
)
|
||||
char = char_result.scalar_one_or_none()
|
||||
|
||||
if not char:
|
||||
raise HTTPException(status_code=404, detail="关联的角色不存在")
|
||||
if not char.is_organization:
|
||||
raise HTTPException(status_code=400, detail="关联的角色不是组织类型")
|
||||
|
||||
# 检查是否已存在
|
||||
existing = await db.execute(
|
||||
select(Organization).where(Organization.character_id == organization.character_id)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="该角色已有组织详情记录")
|
||||
|
||||
# 创建组织
|
||||
db_org = Organization(**organization.model_dump())
|
||||
db.add(db_org)
|
||||
await db.commit()
|
||||
await db.refresh(db_org)
|
||||
|
||||
logger.info(f"创建组织成功:{db_org.id} - {char.name}")
|
||||
return db_org
|
||||
|
||||
|
||||
@router.put("/{org_id}", response_model=OrganizationResponse, summary="更新组织")
|
||||
async def update_organization(
|
||||
org_id: str,
|
||||
organization: OrganizationUpdate,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""更新组织的属性"""
|
||||
result = await db.execute(
|
||||
select(Organization).where(Organization.id == org_id)
|
||||
)
|
||||
db_org = result.scalar_one_or_none()
|
||||
|
||||
if not db_org:
|
||||
raise HTTPException(status_code=404, detail="组织不存在")
|
||||
|
||||
# 更新字段
|
||||
update_data = organization.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(db_org, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(db_org)
|
||||
|
||||
logger.info(f"更新组织成功:{org_id}")
|
||||
return db_org
|
||||
|
||||
|
||||
@router.delete("/{org_id}", summary="删除组织")
|
||||
async def delete_organization(
|
||||
org_id: str,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""删除组织(会级联删除所有成员关系)"""
|
||||
result = await db.execute(
|
||||
select(Organization).where(Organization.id == org_id)
|
||||
)
|
||||
db_org = result.scalar_one_or_none()
|
||||
|
||||
if not db_org:
|
||||
raise HTTPException(status_code=404, detail="组织不存在")
|
||||
|
||||
await db.delete(db_org)
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"删除组织成功:{org_id}")
|
||||
return {"message": "组织删除成功", "id": org_id}
|
||||
|
||||
|
||||
# ============ 组织成员管理 ============
|
||||
|
||||
@router.get("/{org_id}/members", response_model=List[OrganizationMemberDetailResponse], summary="获取组织成员")
|
||||
async def get_organization_members(
|
||||
org_id: str,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
获取组织的所有成员
|
||||
|
||||
按职位等级(rank)降序排列
|
||||
"""
|
||||
# 验证组织存在
|
||||
org_result = await db.execute(
|
||||
select(Organization).where(Organization.id == org_id)
|
||||
)
|
||||
if not org_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="组织不存在")
|
||||
|
||||
# 获取成员列表
|
||||
result = await db.execute(
|
||||
select(OrganizationMember)
|
||||
.where(OrganizationMember.organization_id == org_id)
|
||||
.order_by(OrganizationMember.rank.desc(), OrganizationMember.created_at)
|
||||
)
|
||||
members = result.scalars().all()
|
||||
|
||||
# 获取成员角色信息
|
||||
member_list = []
|
||||
for member in members:
|
||||
char_result = await db.execute(
|
||||
select(Character).where(Character.id == member.character_id)
|
||||
)
|
||||
char = char_result.scalar_one_or_none()
|
||||
|
||||
if char:
|
||||
member_list.append(OrganizationMemberDetailResponse(
|
||||
id=member.id,
|
||||
character_id=member.character_id,
|
||||
character_name=char.name,
|
||||
position=member.position,
|
||||
rank=member.rank,
|
||||
loyalty=member.loyalty,
|
||||
contribution=member.contribution,
|
||||
status=member.status,
|
||||
joined_at=member.joined_at,
|
||||
left_at=member.left_at,
|
||||
notes=member.notes
|
||||
))
|
||||
|
||||
logger.info(f"获取组织 {org_id} 的成员列表,共 {len(member_list)} 人")
|
||||
return member_list
|
||||
|
||||
|
||||
@router.post("/{org_id}/members", response_model=OrganizationMemberResponse, summary="添加组织成员")
|
||||
async def add_organization_member(
|
||||
org_id: str,
|
||||
member: OrganizationMemberCreate,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
添加角色到组织
|
||||
|
||||
- 一个角色在同一组织中只能有一个职位
|
||||
- 会自动更新组织的成员计数
|
||||
"""
|
||||
# 验证组织存在
|
||||
org_result = await db.execute(
|
||||
select(Organization).where(Organization.id == org_id)
|
||||
)
|
||||
org = org_result.scalar_one_or_none()
|
||||
if not org:
|
||||
raise HTTPException(status_code=404, detail="组织不存在")
|
||||
|
||||
# 验证角色存在
|
||||
char_result = await db.execute(
|
||||
select(Character).where(Character.id == member.character_id)
|
||||
)
|
||||
char = char_result.scalar_one_or_none()
|
||||
if not char:
|
||||
raise HTTPException(status_code=404, detail="角色不存在")
|
||||
if char.is_organization:
|
||||
raise HTTPException(status_code=400, detail="不能将组织添加为成员")
|
||||
|
||||
# 检查是否已存在
|
||||
existing = await db.execute(
|
||||
select(OrganizationMember).where(
|
||||
and_(
|
||||
OrganizationMember.organization_id == org_id,
|
||||
OrganizationMember.character_id == member.character_id
|
||||
)
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="该角色已在组织中")
|
||||
|
||||
# 创建成员关系
|
||||
db_member = OrganizationMember(
|
||||
organization_id=org_id,
|
||||
**member.model_dump(),
|
||||
source="manual"
|
||||
)
|
||||
db.add(db_member)
|
||||
|
||||
# 更新组织成员计数
|
||||
org.member_count += 1
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(db_member)
|
||||
|
||||
logger.info(f"添加成员成功:{char.name} 加入组织 {org_id}")
|
||||
return db_member
|
||||
|
||||
|
||||
@router.put("/members/{member_id}", response_model=OrganizationMemberResponse, summary="更新成员信息")
|
||||
async def update_organization_member(
|
||||
member_id: str,
|
||||
member: OrganizationMemberUpdate,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""更新组织成员的职位、忠诚度等信息"""
|
||||
result = await db.execute(
|
||||
select(OrganizationMember).where(OrganizationMember.id == member_id)
|
||||
)
|
||||
db_member = result.scalar_one_or_none()
|
||||
|
||||
if not db_member:
|
||||
raise HTTPException(status_code=404, detail="成员记录不存在")
|
||||
|
||||
# 更新字段
|
||||
update_data = member.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(db_member, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(db_member)
|
||||
|
||||
logger.info(f"更新成员信息成功:{member_id}")
|
||||
return db_member
|
||||
|
||||
|
||||
@router.delete("/members/{member_id}", summary="移除组织成员")
|
||||
async def remove_organization_member(
|
||||
member_id: str,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
从组织中移除成员
|
||||
|
||||
会自动更新组织的成员计数
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(OrganizationMember).where(OrganizationMember.id == member_id)
|
||||
)
|
||||
db_member = result.scalar_one_or_none()
|
||||
|
||||
if not db_member:
|
||||
raise HTTPException(status_code=404, detail="成员记录不存在")
|
||||
|
||||
# 更新组织成员计数
|
||||
org_result = await db.execute(
|
||||
select(Organization).where(Organization.id == db_member.organization_id)
|
||||
)
|
||||
org = org_result.scalar_one()
|
||||
org.member_count = max(0, org.member_count - 1)
|
||||
|
||||
await db.delete(db_member)
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"移除成员成功:{member_id}")
|
||||
return {"message": "成员移除成功", "id": member_id}
|
||||
@@ -0,0 +1,657 @@
|
||||
"""大纲管理API"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, delete
|
||||
from typing import List
|
||||
import json
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.outline import Outline
|
||||
from app.models.project import Project
|
||||
from app.models.chapter import Chapter
|
||||
from app.models.character import Character
|
||||
from app.models.generation_history import GenerationHistory
|
||||
from app.schemas.outline import (
|
||||
OutlineCreate,
|
||||
OutlineUpdate,
|
||||
OutlineResponse,
|
||||
OutlineListResponse,
|
||||
OutlineGenerateRequest,
|
||||
OutlineReorderRequest
|
||||
)
|
||||
from app.services.ai_service import ai_service
|
||||
from app.services.prompt_service import prompt_service
|
||||
from app.logger import get_logger
|
||||
|
||||
router = APIRouter(prefix="/outlines", tags=["大纲管理"])
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@router.post("", response_model=OutlineResponse, summary="创建大纲")
|
||||
async def create_outline(
|
||||
outline: OutlineCreate,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""创建新的章节大纲,同时创建对应的章节记录"""
|
||||
# 验证项目是否存在
|
||||
result = await db.execute(
|
||||
select(Project).where(Project.id == outline.project_id)
|
||||
)
|
||||
project = result.scalar_one_or_none()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="项目不存在")
|
||||
|
||||
# 创建大纲
|
||||
db_outline = Outline(**outline.model_dump())
|
||||
db.add(db_outline)
|
||||
|
||||
# 同步创建对应的章节记录
|
||||
chapter = Chapter(
|
||||
project_id=outline.project_id,
|
||||
chapter_number=outline.order_index,
|
||||
title=outline.title,
|
||||
summary=outline.content[:500] if len(outline.content) > 500 else outline.content,
|
||||
status="draft"
|
||||
)
|
||||
db.add(chapter)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(db_outline)
|
||||
return db_outline
|
||||
|
||||
|
||||
@router.get("", response_model=OutlineListResponse, summary="获取大纲列表")
|
||||
async def get_outlines(
|
||||
project_id: str,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""获取指定项目的所有大纲"""
|
||||
# 获取总数
|
||||
count_result = await db.execute(
|
||||
select(func.count(Outline.id)).where(Outline.project_id == project_id)
|
||||
)
|
||||
total = count_result.scalar_one()
|
||||
|
||||
# 获取大纲列表
|
||||
result = await db.execute(
|
||||
select(Outline)
|
||||
.where(Outline.project_id == project_id)
|
||||
.order_by(Outline.order_index)
|
||||
)
|
||||
outlines = result.scalars().all()
|
||||
|
||||
return OutlineListResponse(total=total, items=outlines)
|
||||
|
||||
|
||||
@router.get("/project/{project_id}", response_model=OutlineListResponse, summary="获取项目的所有大纲")
|
||||
async def get_project_outlines(
|
||||
project_id: str,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""获取指定项目的所有大纲(路径参数版本)"""
|
||||
# 获取总数
|
||||
count_result = await db.execute(
|
||||
select(func.count(Outline.id)).where(Outline.project_id == project_id)
|
||||
)
|
||||
total = count_result.scalar_one()
|
||||
|
||||
# 获取大纲列表
|
||||
result = await db.execute(
|
||||
select(Outline)
|
||||
.where(Outline.project_id == project_id)
|
||||
.order_by(Outline.order_index)
|
||||
)
|
||||
outlines = result.scalars().all()
|
||||
|
||||
return OutlineListResponse(total=total, items=outlines)
|
||||
|
||||
|
||||
@router.get("/{outline_id}", response_model=OutlineResponse, summary="获取大纲详情")
|
||||
async def get_outline(
|
||||
outline_id: str,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""根据ID获取大纲详情"""
|
||||
result = await db.execute(
|
||||
select(Outline).where(Outline.id == outline_id)
|
||||
)
|
||||
outline = result.scalar_one_or_none()
|
||||
|
||||
if not outline:
|
||||
raise HTTPException(status_code=404, detail="大纲不存在")
|
||||
|
||||
return outline
|
||||
|
||||
|
||||
@router.put("/{outline_id}", response_model=OutlineResponse, summary="更新大纲")
|
||||
async def update_outline(
|
||||
outline_id: str,
|
||||
outline_update: OutlineUpdate,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""更新大纲信息,同步更新对应章节和structure字段"""
|
||||
result = await db.execute(
|
||||
select(Outline).where(Outline.id == outline_id)
|
||||
)
|
||||
outline = result.scalar_one_or_none()
|
||||
|
||||
if not outline:
|
||||
raise HTTPException(status_code=404, detail="大纲不存在")
|
||||
|
||||
# 更新字段
|
||||
update_data = outline_update.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(outline, field, value)
|
||||
|
||||
# 如果修改了content或title,同步更新structure字段
|
||||
if 'content' in update_data or 'title' in update_data:
|
||||
try:
|
||||
# 尝试解析现有的structure
|
||||
if outline.structure:
|
||||
structure_data = json.loads(outline.structure)
|
||||
else:
|
||||
structure_data = {}
|
||||
|
||||
# 更新structure中的对应字段
|
||||
if 'title' in update_data:
|
||||
structure_data['title'] = outline.title
|
||||
if 'content' in update_data:
|
||||
structure_data['summary'] = outline.content
|
||||
structure_data['content'] = outline.content
|
||||
|
||||
# 保存更新后的structure
|
||||
outline.structure = json.dumps(structure_data, ensure_ascii=False)
|
||||
logger.info(f"同步更新大纲 {outline_id} 的structure字段")
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(f"大纲 {outline_id} 的structure字段格式错误,跳过更新")
|
||||
|
||||
# 同步更新对应的章节标题和摘要
|
||||
if 'title' in update_data or 'content' in update_data:
|
||||
chapter_result = await db.execute(
|
||||
select(Chapter).where(
|
||||
Chapter.project_id == outline.project_id,
|
||||
Chapter.chapter_number == outline.order_index
|
||||
)
|
||||
)
|
||||
chapter = chapter_result.scalar_one_or_none()
|
||||
|
||||
if chapter:
|
||||
if 'title' in update_data:
|
||||
chapter.title = outline.title
|
||||
if 'content' in update_data:
|
||||
# 更新章节摘要(取content前500字符)
|
||||
chapter.summary = outline.content[:500] if len(outline.content) > 500 else outline.content
|
||||
logger.info(f"同步更新章节 {chapter.id} 的标题和摘要")
|
||||
else:
|
||||
logger.warning(f"未找到对应的章节记录 (order_index={outline.order_index})")
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(outline)
|
||||
return outline
|
||||
|
||||
|
||||
@router.delete("/{outline_id}", summary="删除大纲")
|
||||
async def delete_outline(
|
||||
outline_id: str,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""删除大纲,同步删除章节,并重新排序后续项"""
|
||||
result = await db.execute(
|
||||
select(Outline).where(Outline.id == outline_id)
|
||||
)
|
||||
outline = result.scalar_one_or_none()
|
||||
|
||||
if not outline:
|
||||
raise HTTPException(status_code=404, detail="大纲不存在")
|
||||
|
||||
project_id = outline.project_id
|
||||
deleted_order = outline.order_index
|
||||
|
||||
# 删除对应的章节
|
||||
await db.execute(
|
||||
delete(Chapter).where(
|
||||
Chapter.project_id == project_id,
|
||||
Chapter.chapter_number == deleted_order
|
||||
)
|
||||
)
|
||||
|
||||
# 删除大纲
|
||||
await db.delete(outline)
|
||||
|
||||
# 重新排序后续的大纲和章节(序号-1)
|
||||
result = await db.execute(
|
||||
select(Outline).where(
|
||||
Outline.project_id == project_id,
|
||||
Outline.order_index > deleted_order
|
||||
)
|
||||
)
|
||||
subsequent_outlines = result.scalars().all()
|
||||
|
||||
for o in subsequent_outlines:
|
||||
old_order = o.order_index
|
||||
o.order_index -= 1
|
||||
|
||||
# 同步更新对应的章节
|
||||
chapter_result = await db.execute(
|
||||
select(Chapter).where(
|
||||
Chapter.project_id == project_id,
|
||||
Chapter.chapter_number == old_order
|
||||
)
|
||||
)
|
||||
chapter = chapter_result.scalar_one_or_none()
|
||||
if chapter:
|
||||
chapter.chapter_number = old_order - 1
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {"message": "大纲删除成功"}
|
||||
|
||||
|
||||
@router.post("/reorder", summary="批量重排序大纲")
|
||||
async def reorder_outlines(
|
||||
request: OutlineReorderRequest,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
批量调整大纲顺序,同步更新章节序号
|
||||
|
||||
策略:先收集所有变更,最后一次性提交,避免临时冲突
|
||||
"""
|
||||
try:
|
||||
# 第一步:收集所有大纲和对应的章节
|
||||
outline_chapter_map = {} # {outline_id: (outline, chapter, old_order, new_order)}
|
||||
|
||||
for item in request.orders:
|
||||
outline_id = item.id
|
||||
new_order = item.order_index
|
||||
|
||||
# 获取大纲
|
||||
result = await db.execute(
|
||||
select(Outline).where(Outline.id == outline_id)
|
||||
)
|
||||
outline = result.scalar_one_or_none()
|
||||
|
||||
if not outline:
|
||||
logger.warning(f"大纲 {outline_id} 不存在,跳过")
|
||||
continue
|
||||
|
||||
old_order = outline.order_index
|
||||
|
||||
# 获取对应的章节(通过旧的chapter_number匹配)
|
||||
chapter_result = await db.execute(
|
||||
select(Chapter).where(
|
||||
Chapter.project_id == outline.project_id,
|
||||
Chapter.chapter_number == old_order
|
||||
)
|
||||
)
|
||||
chapter = chapter_result.first()
|
||||
chapter_obj = chapter[0] if chapter else None
|
||||
|
||||
outline_chapter_map[outline_id] = (outline, chapter_obj, old_order, new_order)
|
||||
|
||||
# 第二步:批量更新所有大纲和章节
|
||||
updated_outlines = 0
|
||||
updated_chapters = 0
|
||||
|
||||
for outline_id, (outline, chapter, old_order, new_order) in outline_chapter_map.items():
|
||||
# 更新大纲
|
||||
outline.order_index = new_order
|
||||
updated_outlines += 1
|
||||
|
||||
# 更新章节
|
||||
if chapter:
|
||||
chapter.chapter_number = new_order
|
||||
chapter.title = outline.title # 同步更新标题
|
||||
updated_chapters += 1
|
||||
else:
|
||||
logger.warning(f"章节 {old_order} 不存在,跳过")
|
||||
|
||||
# 第三步:一次性提交所有更改
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"重排序成功:更新了 {updated_outlines} 个大纲,{updated_chapters} 个章节")
|
||||
|
||||
return {
|
||||
"message": "重排序成功",
|
||||
"updated_outlines": updated_outlines,
|
||||
"updated_chapters": updated_chapters
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"重排序失败: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"重排序失败: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/generate", response_model=OutlineListResponse, summary="AI生成/续写大纲")
|
||||
async def generate_outline(
|
||||
request: OutlineGenerateRequest,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
使用AI生成或续写小说大纲 - 智能模式
|
||||
|
||||
支持三种模式:
|
||||
- auto: 自动判断(无大纲→新建,有大纲→续写)
|
||||
- new: 强制全新生成
|
||||
- continue: 强制续写模式
|
||||
"""
|
||||
# 验证项目是否存在
|
||||
result = await db.execute(
|
||||
select(Project).where(Project.id == request.project_id)
|
||||
)
|
||||
project = result.scalar_one_or_none()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="项目不存在")
|
||||
|
||||
try:
|
||||
# 获取现有大纲(强制从数据库获取最新数据,包括用户手动修改的内容)
|
||||
existing_result = await db.execute(
|
||||
select(Outline)
|
||||
.where(Outline.project_id == request.project_id)
|
||||
.order_by(Outline.order_index)
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
existing_outlines = existing_result.scalars().all()
|
||||
|
||||
# 判断实际执行模式
|
||||
actual_mode = request.mode
|
||||
if actual_mode == "auto":
|
||||
actual_mode = "continue" if existing_outlines else "new"
|
||||
logger.info(f"自动判断模式:{'续写' if existing_outlines else '新建'}")
|
||||
|
||||
# 模式:全新生成
|
||||
if actual_mode == "new":
|
||||
return await _generate_new_outline(
|
||||
request, project, db
|
||||
)
|
||||
|
||||
# 模式:续写
|
||||
elif actual_mode == "continue":
|
||||
if not existing_outlines:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="续写模式需要已有大纲,当前项目没有大纲"
|
||||
)
|
||||
|
||||
return await _continue_outline(
|
||||
request, project, existing_outlines, db
|
||||
)
|
||||
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"不支持的模式: {request.mode}"
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"生成大纲失败: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"生成大纲失败: {str(e)}")
|
||||
|
||||
|
||||
async def _generate_new_outline(
|
||||
request: OutlineGenerateRequest,
|
||||
project: Project,
|
||||
db: AsyncSession
|
||||
) -> OutlineListResponse:
|
||||
"""全新生成大纲"""
|
||||
logger.info(f"全新生成大纲 - 项目: {project.id}, keep_existing: {request.keep_existing}")
|
||||
|
||||
# 获取角色信息
|
||||
characters_result = await db.execute(
|
||||
select(Character).where(Character.project_id == project.id)
|
||||
)
|
||||
characters = characters_result.scalars().all()
|
||||
characters_info = "\n".join([
|
||||
f"- {char.name} ({'组织' if char.is_organization else '角色'}, {char.role_type}): "
|
||||
f"{char.personality[:100] if char.personality else '暂无描述'}"
|
||||
for char in characters
|
||||
])
|
||||
|
||||
# 使用完整提示词
|
||||
prompt = prompt_service.get_complete_outline_prompt(
|
||||
title=project.title,
|
||||
theme=request.theme or project.theme or "未设定",
|
||||
genre=request.genre or project.genre or "通用",
|
||||
chapter_count=request.chapter_count,
|
||||
narrative_perspective=request.narrative_perspective,
|
||||
target_words=request.target_words,
|
||||
time_period=project.world_time_period or "未设定",
|
||||
location=project.world_location or "未设定",
|
||||
atmosphere=project.world_atmosphere or "未设定",
|
||||
rules=project.world_rules or "未设定",
|
||||
characters_info=characters_info or "暂无角色信息",
|
||||
requirements=request.requirements or ""
|
||||
)
|
||||
|
||||
# 调用AI
|
||||
ai_response = await ai_service.generate_text(
|
||||
prompt=prompt,
|
||||
provider=request.provider,
|
||||
model=request.model
|
||||
)
|
||||
|
||||
# 解析响应
|
||||
outline_data = _parse_ai_response(ai_response)
|
||||
|
||||
# 全新生成模式:必须删除旧大纲和章节
|
||||
# 注意:这是"new"模式的核心逻辑,应该始终删除旧数据
|
||||
logger.info(f"删除项目 {project.id} 的旧大纲和章节")
|
||||
await db.execute(
|
||||
delete(Outline).where(Outline.project_id == project.id)
|
||||
)
|
||||
await db.execute(
|
||||
delete(Chapter).where(Chapter.project_id == project.id)
|
||||
)
|
||||
|
||||
# 保存新大纲
|
||||
outlines = await _save_outlines(
|
||||
project.id, outline_data, db, start_index=1
|
||||
)
|
||||
|
||||
# 记录历史
|
||||
history = GenerationHistory(
|
||||
project_id=project.id,
|
||||
prompt=prompt,
|
||||
generated_content=ai_response,
|
||||
model=request.model or "default"
|
||||
)
|
||||
db.add(history)
|
||||
|
||||
await db.commit()
|
||||
|
||||
for outline in outlines:
|
||||
await db.refresh(outline)
|
||||
|
||||
logger.info(f"全新生成完成 - {len(outlines)} 章")
|
||||
return OutlineListResponse(total=len(outlines), items=outlines)
|
||||
|
||||
|
||||
async def _continue_outline(
|
||||
request: OutlineGenerateRequest,
|
||||
project: Project,
|
||||
existing_outlines: List[Outline],
|
||||
db: AsyncSession
|
||||
) -> OutlineListResponse:
|
||||
"""续写大纲"""
|
||||
logger.info(f"续写大纲 - 项目: {project.id}, 已有: {len(existing_outlines)} 章")
|
||||
|
||||
# 分析已有大纲
|
||||
current_chapter_count = len(existing_outlines)
|
||||
last_chapter_number = existing_outlines[-1].order_index
|
||||
|
||||
# 获取最近2章的剧情
|
||||
recent_outlines = existing_outlines[-2:] if len(existing_outlines) >= 2 else existing_outlines
|
||||
recent_plot = "\n".join([
|
||||
f"第{o.order_index}章《{o.title}》: {o.content}"
|
||||
for o in recent_outlines
|
||||
])
|
||||
# logger.debug(f"最近三章内容:{recent_plot}")
|
||||
# 全部章节概览
|
||||
all_chapters_brief = "\n".join([
|
||||
f"第{o.order_index}章: {o.title}"
|
||||
for o in existing_outlines
|
||||
])
|
||||
|
||||
# 获取角色信息
|
||||
characters_result = await db.execute(
|
||||
select(Character).where(Character.project_id == project.id)
|
||||
)
|
||||
characters = characters_result.scalars().all()
|
||||
characters_info = "\n".join([
|
||||
f"- {char.name} ({'组织' if char.is_organization else '角色'}, {char.role_type}): "
|
||||
f"{char.personality[:100] if char.personality else '暂无描述'}"
|
||||
for char in characters
|
||||
])
|
||||
|
||||
# 情节阶段指导
|
||||
stage_instructions = {
|
||||
"development": "继续展开情节,深化角色关系,推进主线冲突",
|
||||
"climax": "进入故事高潮,矛盾激化,关键冲突爆发",
|
||||
"ending": "解决主要冲突,收束伏笔,给出结局"
|
||||
}
|
||||
stage_instruction = stage_instructions.get(request.plot_stage, "")
|
||||
|
||||
# 使用标准续写提示词模板
|
||||
prompt = prompt_service.get_outline_continue_prompt(
|
||||
title=project.title,
|
||||
theme=request.theme or project.theme or "未设定",
|
||||
genre=request.genre or project.genre or "通用",
|
||||
narrative_perspective=request.narrative_perspective,
|
||||
chapter_count=request.chapter_count,
|
||||
time_period=project.world_time_period or "未设定",
|
||||
location=project.world_location or "未设定",
|
||||
atmosphere=project.world_atmosphere or "未设定",
|
||||
rules=project.world_rules or "未设定",
|
||||
characters_info=characters_info or "暂无角色信息",
|
||||
current_chapter_count=current_chapter_count,
|
||||
all_chapters_brief=all_chapters_brief,
|
||||
recent_plot=recent_plot,
|
||||
plot_stage_instruction=stage_instruction,
|
||||
start_chapter=last_chapter_number + 1,
|
||||
story_direction=request.story_direction or "自然延续",
|
||||
requirements=request.requirements or ""
|
||||
)
|
||||
|
||||
# 调用AI
|
||||
ai_response = await ai_service.generate_text(
|
||||
prompt=prompt,
|
||||
provider=request.provider,
|
||||
model=request.model
|
||||
)
|
||||
|
||||
# 解析响应
|
||||
outline_data = _parse_ai_response(ai_response)
|
||||
|
||||
# 保存续写的大纲
|
||||
new_outlines = await _save_outlines(
|
||||
project.id, outline_data, db, start_index=last_chapter_number + 1
|
||||
)
|
||||
|
||||
# 记录历史
|
||||
history = GenerationHistory(
|
||||
project_id=project.id,
|
||||
prompt=prompt,
|
||||
generated_content=ai_response,
|
||||
model=request.model or "default"
|
||||
)
|
||||
db.add(history)
|
||||
|
||||
await db.commit()
|
||||
|
||||
for outline in new_outlines:
|
||||
await db.refresh(outline)
|
||||
|
||||
# 返回所有大纲(包括旧的和新的)
|
||||
all_result = await db.execute(
|
||||
select(Outline)
|
||||
.where(Outline.project_id == project.id)
|
||||
.order_by(Outline.order_index)
|
||||
)
|
||||
all_outlines = all_result.scalars().all()
|
||||
|
||||
logger.info(f"续写完成 - 新增 {len(new_outlines)} 章,总计 {len(all_outlines)} 章")
|
||||
return OutlineListResponse(total=len(all_outlines), items=all_outlines)
|
||||
|
||||
|
||||
def _parse_ai_response(ai_response: str) -> list:
|
||||
"""解析AI响应为章节数据列表"""
|
||||
try:
|
||||
# 清理响应文本
|
||||
cleaned_text = ai_response.strip()
|
||||
if cleaned_text.startswith('```json'):
|
||||
cleaned_text = cleaned_text[7:]
|
||||
if cleaned_text.startswith('```'):
|
||||
cleaned_text = cleaned_text[3:]
|
||||
if cleaned_text.endswith('```'):
|
||||
cleaned_text = cleaned_text[:-3]
|
||||
cleaned_text = cleaned_text.strip()
|
||||
|
||||
outline_data = json.loads(cleaned_text)
|
||||
|
||||
# 确保是列表格式
|
||||
if not isinstance(outline_data, list):
|
||||
# 如果是对象,尝试提取chapters字段
|
||||
if isinstance(outline_data, dict):
|
||||
outline_data = outline_data.get("chapters", [outline_data])
|
||||
else:
|
||||
outline_data = [outline_data]
|
||||
|
||||
return outline_data
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"AI响应解析失败: {e}")
|
||||
# 返回一个包含原始内容的章节
|
||||
return [{
|
||||
"title": "AI生成的大纲",
|
||||
"content": ai_response[:1000],
|
||||
"summary": ai_response[:1000]
|
||||
}]
|
||||
|
||||
|
||||
async def _save_outlines(
|
||||
project_id: str,
|
||||
outline_data: list,
|
||||
db: AsyncSession,
|
||||
start_index: int = 1
|
||||
) -> List[Outline]:
|
||||
"""保存大纲到数据库"""
|
||||
outlines = []
|
||||
|
||||
for idx, chapter_data in enumerate(outline_data):
|
||||
order_idx = chapter_data.get("chapter_number", start_index + idx)
|
||||
title = chapter_data.get("title", f"第{order_idx}章")
|
||||
|
||||
# 优先使用summary,其次content
|
||||
content = chapter_data.get("summary") or chapter_data.get("content", "")
|
||||
|
||||
# 如果有额外信息,添加到内容中
|
||||
if "key_events" in chapter_data:
|
||||
content += f"\n\n关键事件:" + "、".join(chapter_data["key_events"])
|
||||
if "characters_involved" in chapter_data:
|
||||
content += f"\n涉及角色:" + "、".join(chapter_data["characters_involved"])
|
||||
|
||||
# 创建大纲
|
||||
outline = Outline(
|
||||
project_id=project_id,
|
||||
title=title,
|
||||
content=content,
|
||||
structure=json.dumps(chapter_data, ensure_ascii=False),
|
||||
order_index=order_idx
|
||||
)
|
||||
db.add(outline)
|
||||
outlines.append(outline)
|
||||
|
||||
# 同步创建章节记录
|
||||
chapter = Chapter(
|
||||
project_id=project_id,
|
||||
chapter_number=order_idx,
|
||||
title=title,
|
||||
summary=content[:500] if len(content) > 500 else content,
|
||||
status="draft"
|
||||
)
|
||||
db.add(chapter)
|
||||
|
||||
return outlines
|
||||
@@ -0,0 +1,124 @@
|
||||
"""AI去味API - 核心特色功能"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.generation_history import GenerationHistory
|
||||
from app.schemas.polish import PolishRequest, PolishResponse
|
||||
from app.services.ai_service import ai_service
|
||||
from app.services.prompt_service import prompt_service
|
||||
from app.logger import get_logger
|
||||
|
||||
router = APIRouter(prefix="/polish", tags=["AI去味"])
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@router.post("", response_model=PolishResponse, summary="AI去味")
|
||||
async def polish_text(
|
||||
request: PolishRequest,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
AI去味 - 将AI生成的文本改写得更像人类作家的手笔
|
||||
|
||||
核心功能:
|
||||
- 去除AI痕迹(工整排比、重复修辞、机械总结)
|
||||
- 增加人性化(口语化、不完美细节、真实情感)
|
||||
- 优化叙事(自然节奏、简单词汇、松弛感)
|
||||
- 让对话更生活化
|
||||
|
||||
这是本项目的核心特色功能!
|
||||
"""
|
||||
try:
|
||||
# 构建AI去味提示词
|
||||
prompt = prompt_service.get_denoising_prompt(
|
||||
original_text=request.original_text
|
||||
)
|
||||
|
||||
logger.info(f"开始AI去味处理,原文长度: {len(request.original_text)}")
|
||||
|
||||
# 调用AI进行去味处理
|
||||
polished_text = await ai_service.generate_text(
|
||||
prompt=prompt,
|
||||
provider=request.provider,
|
||||
model=request.model,
|
||||
temperature=request.temperature,
|
||||
max_tokens=len(request.original_text) * 2 # 预留足够token
|
||||
)
|
||||
|
||||
# 计算字数
|
||||
word_count_before = len(request.original_text)
|
||||
word_count_after = len(polished_text)
|
||||
|
||||
logger.info(f"AI去味完成,处理后长度: {word_count_after}")
|
||||
|
||||
# 如果提供了项目ID,记录到历史
|
||||
if request.project_id:
|
||||
history = GenerationHistory(
|
||||
project_id=request.project_id,
|
||||
generation_type="polish",
|
||||
prompt=f"原文: {request.original_text[:100]}...",
|
||||
result=polished_text,
|
||||
provider=request.provider or "default",
|
||||
model=request.model or "default"
|
||||
)
|
||||
db.add(history)
|
||||
await db.commit()
|
||||
|
||||
return PolishResponse(
|
||||
original_text=request.original_text,
|
||||
polished_text=polished_text,
|
||||
word_count_before=word_count_before,
|
||||
word_count_after=word_count_after
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"AI去味失败: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"AI去味失败: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/batch", summary="批量AI去味")
|
||||
async def polish_batch(
|
||||
texts: list[str],
|
||||
project_id: int = None,
|
||||
provider: str = None,
|
||||
model: str = None,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
批量处理多个文本的AI去味
|
||||
|
||||
适用于一次性处理多个章节或段落
|
||||
"""
|
||||
try:
|
||||
results = []
|
||||
|
||||
for idx, text in enumerate(texts):
|
||||
logger.info(f"处理第 {idx+1}/{len(texts)} 个文本")
|
||||
|
||||
prompt = prompt_service.get_denoising_prompt(original_text=text)
|
||||
|
||||
polished_text = await ai_service.generate_text(
|
||||
prompt=prompt,
|
||||
provider=provider,
|
||||
model=model
|
||||
)
|
||||
|
||||
results.append({
|
||||
"index": idx,
|
||||
"original": text,
|
||||
"polished": polished_text,
|
||||
"word_count_before": len(text),
|
||||
"word_count_after": len(polished_text)
|
||||
})
|
||||
|
||||
logger.info(f"批量AI去味完成,共处理 {len(results)} 个文本")
|
||||
|
||||
return {
|
||||
"total": len(results),
|
||||
"results": results
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"批量AI去味失败: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"批量AI去味失败: {str(e)}")
|
||||
@@ -0,0 +1,414 @@
|
||||
"""项目管理API"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, delete
|
||||
from typing import List
|
||||
from app.database import get_db
|
||||
from app.models.project import Project
|
||||
from app.models.character import Character
|
||||
from app.models.outline import Outline
|
||||
from app.models.chapter import Chapter
|
||||
from app.models.generation_history import GenerationHistory
|
||||
from app.models.relationship import CharacterRelationship, Organization, OrganizationMember
|
||||
from app.schemas.project import (
|
||||
ProjectCreate,
|
||||
ProjectUpdate,
|
||||
ProjectResponse,
|
||||
ProjectListResponse
|
||||
)
|
||||
from app.logger import get_logger
|
||||
from app.utils.data_consistency import (
|
||||
run_full_data_consistency_check,
|
||||
fix_missing_organization_records,
|
||||
fix_organization_member_counts
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
router = APIRouter(prefix="/projects", tags=["项目管理"])
|
||||
|
||||
|
||||
@router.post("", response_model=ProjectResponse, summary="创建项目")
|
||||
async def create_project(
|
||||
project: ProjectCreate,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
try:
|
||||
logger.info(f"创建新项目: {project.title}")
|
||||
db_project = Project(**project.model_dump())
|
||||
db.add(db_project)
|
||||
await db.commit()
|
||||
await db.refresh(db_project)
|
||||
logger.info(f"项目创建成功: {db_project.id}")
|
||||
return db_project
|
||||
except Exception as e:
|
||||
logger.error(f"创建项目失败: {str(e)}", exc_info=True)
|
||||
raise
|
||||
|
||||
|
||||
@router.get("", response_model=ProjectListResponse, summary="获取项目列表")
|
||||
async def get_projects(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""获取所有项目列表"""
|
||||
try:
|
||||
logger.debug(f"获取项目列表: skip={skip}, limit={limit}")
|
||||
count_result = await db.execute(select(func.count(Project.id)))
|
||||
total = count_result.scalar_one()
|
||||
|
||||
result = await db.execute(
|
||||
select(Project)
|
||||
.order_by(Project.updated_at.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
)
|
||||
projects = result.scalars().all()
|
||||
logger.info(f"获取项目列表成功: 共{total}个项目")
|
||||
|
||||
return ProjectListResponse(total=total, items=projects)
|
||||
except Exception as e:
|
||||
logger.error(f"获取项目列表失败: {str(e)}", exc_info=True)
|
||||
raise
|
||||
|
||||
|
||||
@router.get("/{project_id}", response_model=ProjectResponse, summary="获取项目详情")
|
||||
async def get_project(
|
||||
project_id: str,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
try:
|
||||
logger.debug(f"获取项目详情: {project_id}")
|
||||
result = await db.execute(
|
||||
select(Project).where(Project.id == project_id)
|
||||
)
|
||||
project = result.scalar_one_or_none()
|
||||
|
||||
if not project:
|
||||
logger.warning(f"项目不存在: {project_id}")
|
||||
raise HTTPException(status_code=404, detail="项目不存在")
|
||||
|
||||
logger.info(f"获取项目详情成功: {project.title}")
|
||||
return project
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"获取项目详情失败: {str(e)}", exc_info=True)
|
||||
raise
|
||||
|
||||
|
||||
@router.put("/{project_id}", response_model=ProjectResponse, summary="更新项目")
|
||||
async def update_project(
|
||||
project_id: str,
|
||||
project_update: ProjectUpdate,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
try:
|
||||
logger.info(f"更新项目: {project_id}")
|
||||
result = await db.execute(
|
||||
select(Project).where(Project.id == project_id)
|
||||
)
|
||||
project = result.scalar_one_or_none()
|
||||
|
||||
if not project:
|
||||
logger.warning(f"项目不存在: {project_id}")
|
||||
raise HTTPException(status_code=404, detail="项目不存在")
|
||||
|
||||
update_data = project_update.model_dump(exclude_unset=True)
|
||||
logger.debug(f"更新字段: {list(update_data.keys())}")
|
||||
for field, value in update_data.items():
|
||||
setattr(project, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(project)
|
||||
logger.info(f"项目更新成功: {project.title}")
|
||||
return project
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"更新项目失败: {str(e)}", exc_info=True)
|
||||
raise
|
||||
|
||||
|
||||
@router.delete("/{project_id}", summary="删除项目")
|
||||
async def delete_project(
|
||||
project_id: str,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
try:
|
||||
logger.info(f"删除项目: {project_id}")
|
||||
result = await db.execute(
|
||||
select(Project).where(Project.id == project_id)
|
||||
)
|
||||
project = result.scalar_one_or_none()
|
||||
|
||||
if not project:
|
||||
logger.warning(f"项目不存在: {project_id}")
|
||||
raise HTTPException(status_code=404, detail="项目不存在")
|
||||
|
||||
project_title = project.title
|
||||
|
||||
relationships_result = await db.execute(
|
||||
delete(CharacterRelationship).where(CharacterRelationship.project_id == project_id)
|
||||
)
|
||||
logger.debug(f"删除角色关系数: {relationships_result.rowcount}")
|
||||
|
||||
orgs_result = await db.execute(
|
||||
select(Organization).where(Organization.project_id == project_id)
|
||||
)
|
||||
orgs = orgs_result.scalars().all()
|
||||
org_member_count = 0
|
||||
for org in orgs:
|
||||
members_result = await db.execute(
|
||||
delete(OrganizationMember).where(OrganizationMember.organization_id == org.id)
|
||||
)
|
||||
org_member_count += members_result.rowcount
|
||||
logger.debug(f"删除组织成员数: {org_member_count}")
|
||||
|
||||
organizations_result = await db.execute(
|
||||
delete(Organization).where(Organization.project_id == project_id)
|
||||
)
|
||||
logger.debug(f"删除组织数: {organizations_result.rowcount}")
|
||||
|
||||
history_result = await db.execute(
|
||||
delete(GenerationHistory).where(GenerationHistory.project_id == project_id)
|
||||
)
|
||||
logger.debug(f"删除生成历史数: {history_result.rowcount}")
|
||||
|
||||
chapters_result = await db.execute(
|
||||
delete(Chapter).where(Chapter.project_id == project_id)
|
||||
)
|
||||
logger.debug(f"删除章节数: {chapters_result.rowcount}")
|
||||
|
||||
outlines_result = await db.execute(
|
||||
delete(Outline).where(Outline.project_id == project_id)
|
||||
)
|
||||
logger.debug(f"删除大纲数: {outlines_result.rowcount}")
|
||||
|
||||
characters_result = await db.execute(
|
||||
delete(Character).where(Character.project_id == project_id)
|
||||
)
|
||||
logger.debug(f"删除角色数: {characters_result.rowcount}")
|
||||
|
||||
await db.delete(project)
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"项目删除成功: {project_title}")
|
||||
return {"message": "项目及所有关联数据删除成功"}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"删除项目失败: {str(e)}", exc_info=True)
|
||||
raise
|
||||
|
||||
|
||||
@router.get("/{project_id}/export", summary="导出项目章节为TXT")
|
||||
async def export_project_chapters(
|
||||
project_id: str,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
导出项目的所有章节内容为TXT文本文件
|
||||
按章节顺序组织,包含项目基本信息
|
||||
"""
|
||||
try:
|
||||
logger.info(f"开始导出项目: {project_id}")
|
||||
|
||||
result = await db.execute(
|
||||
select(Project).where(Project.id == project_id)
|
||||
)
|
||||
project = result.scalar_one_or_none()
|
||||
|
||||
if not project:
|
||||
logger.warning(f"项目不存在: {project_id}")
|
||||
raise HTTPException(status_code=404, detail="项目不存在")
|
||||
|
||||
chapters_result = await db.execute(
|
||||
select(Chapter)
|
||||
.where(Chapter.project_id == project_id)
|
||||
.order_by(Chapter.chapter_number)
|
||||
)
|
||||
chapters = chapters_result.scalars().all()
|
||||
|
||||
if not chapters:
|
||||
logger.warning(f"项目没有章节: {project_id}")
|
||||
raise HTTPException(status_code=404, detail="项目没有任何章节")
|
||||
|
||||
txt_content = []
|
||||
|
||||
txt_content.append("=" * 80)
|
||||
txt_content.append(f"项目标题: {project.title}")
|
||||
txt_content.append("=" * 80)
|
||||
|
||||
if project.description:
|
||||
txt_content.append(f"\n简介: {project.description}\n")
|
||||
|
||||
if project.theme:
|
||||
txt_content.append(f"主题: {project.theme}")
|
||||
|
||||
if project.genre:
|
||||
txt_content.append(f"类型: {project.genre}")
|
||||
|
||||
txt_content.append(f"总章节数: {len(chapters)}")
|
||||
txt_content.append(f"总字数: {project.current_words}")
|
||||
txt_content.append("\n" + "=" * 80 + "\n\n")
|
||||
|
||||
for chapter in chapters:
|
||||
txt_content.append(f"第 {chapter.chapter_number} 章 {chapter.title}")
|
||||
txt_content.append("-" * 80)
|
||||
txt_content.append("") # 空行
|
||||
|
||||
if chapter.content:
|
||||
txt_content.append(chapter.content)
|
||||
else:
|
||||
txt_content.append("(本章暂无内容)")
|
||||
|
||||
txt_content.append("\n\n" + "=" * 80 + "\n\n")
|
||||
|
||||
txt_content.append(f"--- 全文完 ---")
|
||||
txt_content.append(f"\n导出时间: {func.now()}")
|
||||
|
||||
final_content = "\n".join(txt_content)
|
||||
|
||||
safe_title = "".join(c for c in project.title if c.isalnum() or c in (' ', '-', '_', ',', '。', '、'))
|
||||
filename = f"{safe_title}.txt"
|
||||
|
||||
from urllib.parse import quote
|
||||
encoded_filename = quote(filename)
|
||||
|
||||
logger.info(f"导出成功: {filename}, 共{len(chapters)}章, {len(final_content)}字符")
|
||||
|
||||
return Response(
|
||||
content=final_content.encode('utf-8'),
|
||||
media_type="text/plain; charset=utf-8",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}",
|
||||
"Content-Type": "text/plain; charset=utf-8"
|
||||
}
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"导出项目失败: {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"导出失败: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/{project_id}/check-consistency", summary="检查数据一致性")
|
||||
async def check_project_consistency(
|
||||
project_id: str,
|
||||
auto_fix: bool = True,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
检查并修复项目的数据一致性问题
|
||||
|
||||
Args:
|
||||
project_id: 项目ID
|
||||
auto_fix: 是否自动修复问题(默认True)
|
||||
|
||||
返回检查报告,包含:
|
||||
- organization_records: 检查并修复缺失的Organization记录
|
||||
- member_counts: 检查并修复组织成员计数
|
||||
- relationships: 验证关系数据完整性
|
||||
- organization_members: 验证组织成员数据完整性
|
||||
"""
|
||||
try:
|
||||
logger.info(f"开始数据一致性检查: {project_id}, auto_fix={auto_fix}")
|
||||
|
||||
result = await db.execute(
|
||||
select(Project).where(Project.id == project_id)
|
||||
)
|
||||
project = result.scalar_one_or_none()
|
||||
|
||||
if not project:
|
||||
logger.warning(f"项目不存在: {project_id}")
|
||||
raise HTTPException(status_code=404, detail="项目不存在")
|
||||
|
||||
report = await run_full_data_consistency_check(project_id, db, auto_fix)
|
||||
|
||||
logger.info(f"数据一致性检查完成: {project_id}")
|
||||
return report
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"数据一致性检查失败: {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"检查失败: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/{project_id}/fix-organizations", summary="修复组织记录")
|
||||
async def fix_project_organizations(
|
||||
project_id: str,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
修复项目中缺失的Organization记录
|
||||
|
||||
为所有is_organization=True但没有Organization记录的Character创建记录
|
||||
"""
|
||||
try:
|
||||
logger.info(f"开始修复组织记录: {project_id}")
|
||||
|
||||
result = await db.execute(
|
||||
select(Project).where(Project.id == project_id)
|
||||
)
|
||||
project = result.scalar_one_or_none()
|
||||
|
||||
if not project:
|
||||
logger.warning(f"项目不存在: {project_id}")
|
||||
raise HTTPException(status_code=404, detail="项目不存在")
|
||||
|
||||
fixed_count, total_count = await fix_missing_organization_records(project_id, db)
|
||||
|
||||
logger.info(f"组织记录修复完成: {project_id}, 修复{fixed_count}/{total_count}")
|
||||
return {
|
||||
"message": "组织记录修复完成",
|
||||
"fixed": fixed_count,
|
||||
"total": total_count
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"修复组织记录失败: {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"修复失败: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/{project_id}/fix-member-counts", summary="修复成员计数")
|
||||
async def fix_project_member_counts(
|
||||
project_id: str,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
修复项目中所有组织的成员计数
|
||||
|
||||
从实际成员记录重新计算每个组织的member_count
|
||||
"""
|
||||
try:
|
||||
logger.info(f"开始修复成员计数: {project_id}")
|
||||
|
||||
result = await db.execute(
|
||||
select(Project).where(Project.id == project_id)
|
||||
)
|
||||
project = result.scalar_one_or_none()
|
||||
|
||||
if not project:
|
||||
logger.warning(f"项目不存在: {project_id}")
|
||||
raise HTTPException(status_code=404, detail="项目不存在")
|
||||
|
||||
fixed_count, total_count = await fix_organization_member_counts(project_id, db)
|
||||
|
||||
logger.info(f"成员计数修复完成: {project_id}, 修复{fixed_count}/{total_count}")
|
||||
return {
|
||||
"message": "成员计数修复完成",
|
||||
"fixed": fixed_count,
|
||||
"total": total_count
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"修复成员计数失败: {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"修复失败: {str(e)}")
|
||||
@@ -0,0 +1,209 @@
|
||||
"""关系管理API"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, or_, and_
|
||||
from typing import List, Optional
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.relationship import (
|
||||
RelationshipType,
|
||||
CharacterRelationship,
|
||||
Organization,
|
||||
OrganizationMember
|
||||
)
|
||||
from app.models.character import Character
|
||||
from app.schemas.relationship import (
|
||||
RelationshipTypeResponse,
|
||||
CharacterRelationshipCreate,
|
||||
CharacterRelationshipUpdate,
|
||||
CharacterRelationshipResponse,
|
||||
RelationshipGraphData,
|
||||
RelationshipGraphNode,
|
||||
RelationshipGraphLink
|
||||
)
|
||||
from app.logger import get_logger
|
||||
|
||||
router = APIRouter(prefix="/relationships", tags=["关系管理"])
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@router.get("/types", response_model=List[RelationshipTypeResponse], summary="获取关系类型列表")
|
||||
async def get_relationship_types(db: AsyncSession = Depends(get_db)):
|
||||
"""获取所有预定义的关系类型"""
|
||||
result = await db.execute(select(RelationshipType).order_by(RelationshipType.category, RelationshipType.id))
|
||||
types = result.scalars().all()
|
||||
return types
|
||||
|
||||
|
||||
@router.get("/project/{project_id}", response_model=List[CharacterRelationshipResponse], summary="获取项目的所有关系")
|
||||
async def get_project_relationships(
|
||||
project_id: str,
|
||||
character_id: Optional[str] = Query(None, description="筛选特定角色的关系"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
获取项目中的所有角色关系
|
||||
|
||||
- 如果提供character_id,则只返回与该角色相关的关系(作为发起方或接收方)
|
||||
- 否则返回项目中的所有关系
|
||||
"""
|
||||
query = select(CharacterRelationship).where(
|
||||
CharacterRelationship.project_id == project_id
|
||||
)
|
||||
|
||||
if character_id:
|
||||
query = query.where(
|
||||
or_(
|
||||
CharacterRelationship.character_from_id == character_id,
|
||||
CharacterRelationship.character_to_id == character_id
|
||||
)
|
||||
)
|
||||
|
||||
query = query.order_by(CharacterRelationship.created_at.desc())
|
||||
result = await db.execute(query)
|
||||
relationships = result.scalars().all()
|
||||
|
||||
logger.info(f"获取项目 {project_id} 的关系列表,共 {len(relationships)} 条")
|
||||
return relationships
|
||||
|
||||
|
||||
@router.get("/graph/{project_id}", response_model=RelationshipGraphData, summary="获取关系图谱数据")
|
||||
async def get_relationship_graph(
|
||||
project_id: str,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
获取用于可视化的关系图谱数据
|
||||
|
||||
返回格式:
|
||||
- nodes: 角色节点列表
|
||||
- links: 关系连线列表
|
||||
"""
|
||||
# 获取所有角色(节点)
|
||||
chars_result = await db.execute(
|
||||
select(Character).where(Character.project_id == project_id)
|
||||
)
|
||||
characters = chars_result.scalars().all()
|
||||
|
||||
nodes = [
|
||||
RelationshipGraphNode(
|
||||
id=c.id,
|
||||
name=c.name,
|
||||
type="organization" if c.is_organization else "character",
|
||||
role_type=c.role_type,
|
||||
avatar=c.avatar_url
|
||||
)
|
||||
for c in characters
|
||||
]
|
||||
|
||||
# 获取所有关系(边)
|
||||
rels_result = await db.execute(
|
||||
select(CharacterRelationship).where(
|
||||
CharacterRelationship.project_id == project_id
|
||||
)
|
||||
)
|
||||
relationships = rels_result.scalars().all()
|
||||
|
||||
links = [
|
||||
RelationshipGraphLink(
|
||||
source=r.character_from_id,
|
||||
target=r.character_to_id,
|
||||
relationship=r.relationship_name or "未知关系",
|
||||
intimacy=r.intimacy_level,
|
||||
status=r.status
|
||||
)
|
||||
for r in relationships
|
||||
]
|
||||
|
||||
logger.info(f"获取项目 {project_id} 的关系图谱:{len(nodes)} 个节点,{len(links)} 条关系")
|
||||
return RelationshipGraphData(nodes=nodes, links=links)
|
||||
|
||||
|
||||
@router.post("/", response_model=CharacterRelationshipResponse, summary="创建角色关系")
|
||||
async def create_relationship(
|
||||
relationship: CharacterRelationshipCreate,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
手动创建角色关系
|
||||
|
||||
- 需要提供角色A和角色B的ID
|
||||
- 可以指定预定义的关系类型或自定义关系名称
|
||||
- 可以设置亲密度、状态等属性
|
||||
"""
|
||||
# 验证角色是否存在
|
||||
char_from = await db.execute(
|
||||
select(Character).where(Character.id == relationship.character_from_id)
|
||||
)
|
||||
char_to = await db.execute(
|
||||
select(Character).where(Character.id == relationship.character_to_id)
|
||||
)
|
||||
|
||||
if not char_from.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail=f"角色A(ID: {relationship.character_from_id})不存在")
|
||||
if not char_to.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail=f"角色B(ID: {relationship.character_to_id})不存在")
|
||||
|
||||
# 创建关系
|
||||
db_relationship = CharacterRelationship(
|
||||
**relationship.model_dump(),
|
||||
source="manual"
|
||||
)
|
||||
db.add(db_relationship)
|
||||
await db.commit()
|
||||
await db.refresh(db_relationship)
|
||||
|
||||
logger.info(f"创建关系成功:{relationship.character_from_id} -> {relationship.character_to_id}")
|
||||
return db_relationship
|
||||
|
||||
|
||||
@router.put("/{relationship_id}", response_model=CharacterRelationshipResponse, summary="更新关系")
|
||||
async def update_relationship(
|
||||
relationship_id: str,
|
||||
relationship: CharacterRelationshipUpdate,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""更新角色关系的属性(亲密度、状态等)"""
|
||||
result = await db.execute(
|
||||
select(CharacterRelationship).where(
|
||||
CharacterRelationship.id == relationship_id
|
||||
)
|
||||
)
|
||||
db_rel = result.scalar_one_or_none()
|
||||
|
||||
if not db_rel:
|
||||
raise HTTPException(status_code=404, detail="关系不存在")
|
||||
|
||||
# 更新字段
|
||||
update_data = relationship.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(db_rel, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(db_rel)
|
||||
|
||||
logger.info(f"更新关系成功:{relationship_id}")
|
||||
return db_rel
|
||||
|
||||
|
||||
@router.delete("/{relationship_id}", summary="删除关系")
|
||||
async def delete_relationship(
|
||||
relationship_id: str,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""删除角色关系"""
|
||||
result = await db.execute(
|
||||
select(CharacterRelationship).where(
|
||||
CharacterRelationship.id == relationship_id
|
||||
)
|
||||
)
|
||||
db_rel = result.scalar_one_or_none()
|
||||
|
||||
if not db_rel:
|
||||
raise HTTPException(status_code=404, detail="关系不存在")
|
||||
|
||||
await db.delete(db_rel)
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"删除关系成功:{relationship_id}")
|
||||
return {"message": "关系删除成功", "id": relationship_id}
|
||||
@@ -0,0 +1,125 @@
|
||||
"""
|
||||
用户管理 API
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Request, Depends
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional
|
||||
from app.user_manager import user_manager, User
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["用户管理"])
|
||||
|
||||
|
||||
def require_login(request: Request):
|
||||
"""依赖:要求用户已登录"""
|
||||
if not hasattr(request.state, "user") or not request.state.user:
|
||||
raise HTTPException(status_code=401, detail="需要登录")
|
||||
return request.state.user
|
||||
|
||||
|
||||
def require_admin(request: Request):
|
||||
"""依赖:要求用户为管理员"""
|
||||
user = require_login(request)
|
||||
if not request.state.is_admin:
|
||||
raise HTTPException(status_code=403, detail="需要管理员权限")
|
||||
return user
|
||||
|
||||
|
||||
class SetAdminRequest(BaseModel):
|
||||
user_id: str
|
||||
is_admin: bool
|
||||
|
||||
|
||||
@router.get("/current")
|
||||
async def get_current_user(user: User = Depends(require_login)):
|
||||
"""获取当前登录用户信息"""
|
||||
return user.dict()
|
||||
|
||||
|
||||
@router.get("", response_model=List[dict])
|
||||
async def list_users(admin_user: User = Depends(require_admin)):
|
||||
"""
|
||||
获取所有用户列表(仅管理员)
|
||||
"""
|
||||
users = await user_manager.get_all_users()
|
||||
return [user.dict() for user in users]
|
||||
|
||||
|
||||
@router.post("/set-admin")
|
||||
async def set_admin(
|
||||
data: SetAdminRequest,
|
||||
request: Request,
|
||||
admin_user: User = Depends(require_admin)
|
||||
):
|
||||
"""
|
||||
设置用户的管理员权限(仅管理员)
|
||||
|
||||
限制:
|
||||
- 不能撤销自己的管理员权限
|
||||
- 至少保留一个管理员
|
||||
"""
|
||||
# 检查是否尝试撤销自己的权限
|
||||
if data.user_id == admin_user.user_id and not data.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="不能撤销自己的管理员权限"
|
||||
)
|
||||
|
||||
# 尝试设置管理员权限
|
||||
success = await user_manager.set_admin(data.user_id, data.is_admin)
|
||||
|
||||
if not success:
|
||||
if not data.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="无法撤销管理员权限,至少需要保留一个管理员"
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="用户不存在"
|
||||
)
|
||||
|
||||
return {
|
||||
"message": f"已{'授予' if data.is_admin else '撤销'}管理员权限",
|
||||
"user_id": data.user_id,
|
||||
"is_admin": data.is_admin
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{user_id}")
|
||||
async def delete_user(
|
||||
user_id: str,
|
||||
admin_user: User = Depends(require_admin)
|
||||
):
|
||||
"""
|
||||
删除用户(仅管理员)
|
||||
|
||||
限制:
|
||||
- 不能删除管理员用户
|
||||
"""
|
||||
success = await user_manager.delete_user(user_id)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="无法删除该用户(用户不存在或为管理员)"
|
||||
)
|
||||
|
||||
return {
|
||||
"message": "用户已删除",
|
||||
"user_id": user_id
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{user_id}")
|
||||
async def get_user(
|
||||
user_id: str,
|
||||
admin_user: User = Depends(require_admin)
|
||||
):
|
||||
"""获取指定用户信息(仅管理员)"""
|
||||
user = await user_manager.get_user(user_id)
|
||||
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
|
||||
return user.dict()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,90 @@
|
||||
"""应用配置管理"""
|
||||
from pydantic_settings import BaseSettings
|
||||
from typing import Optional
|
||||
from pathlib import Path
|
||||
import logging
|
||||
|
||||
# 获取项目根目录(从backend/app/config.py向上两级)
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
DATA_DIR = PROJECT_ROOT / "data"
|
||||
DATA_DIR.mkdir(exist_ok=True)
|
||||
|
||||
# 配置模块使用标准logging(在logger.py初始化之前)
|
||||
config_logger = logging.getLogger(__name__)
|
||||
|
||||
# 数据库文件路径(绝对路径)
|
||||
DB_FILE = DATA_DIR / "ai_story.db"
|
||||
|
||||
# 生成数据库URL(在类外部生成,确保使用绝对路径)
|
||||
# 将Windows反斜杠转换为正斜杠,SQLite URL格式要求
|
||||
DATABASE_URL = f"sqlite+aiosqlite:///{str(DB_FILE.absolute()).replace(chr(92), '/')}"
|
||||
config_logger.debug(f"数据库文件路径: {DB_FILE}")
|
||||
config_logger.debug(f"数据库URL: {DATABASE_URL}")
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""应用配置"""
|
||||
|
||||
# 应用配置
|
||||
app_name: str = "MuMuAINovel"
|
||||
app_version: str = "1.0.0"
|
||||
app_host: str = "0.0.0.0"
|
||||
app_port: int = 8000
|
||||
debug: bool = True
|
||||
|
||||
# 日志配置
|
||||
log_level: str = "INFO" # DEBUG, INFO, WARNING, ERROR, CRITICAL
|
||||
log_to_file: bool = True # 是否输出到文件
|
||||
log_file_path: str = str(PROJECT_ROOT / "logs" / "app.log")
|
||||
log_max_bytes: int = 10 * 1024 * 1024 # 10MB
|
||||
log_backup_count: int = 30 # 保留30个备份文件
|
||||
|
||||
# CORS配置
|
||||
cors_origins: list[str] = ["http://localhost:8000", "http://127.0.0.1:8000"]
|
||||
|
||||
# 数据库配置 - 使用预先计算好的绝对路径URL
|
||||
database_url: str = DATABASE_URL
|
||||
|
||||
# AI服务配置
|
||||
openai_api_key: Optional[str] = None
|
||||
openai_base_url: Optional[str] = None
|
||||
gemini_api_key: Optional[str] = None
|
||||
gemini_base_url: Optional[str] = None
|
||||
anthropic_api_key: Optional[str] = None
|
||||
anthropic_base_url: Optional[str] = None
|
||||
default_ai_provider: str = "openai"
|
||||
default_model: str = "gpt-4"
|
||||
default_temperature: float = 0.7
|
||||
default_max_tokens: int = 2000
|
||||
|
||||
# LinuxDO OAuth2 配置
|
||||
LINUXDO_CLIENT_ID: Optional[str] = None
|
||||
LINUXDO_CLIENT_SECRET: Optional[str] = None
|
||||
# 回调地址:Docker部署时必须使用实际域名或服务器IP,不能使用localhost
|
||||
# 本地开发: http://localhost:8000/api/auth/callback
|
||||
# 生产环境: https://your-domain.com/api/auth/callback 或 http://your-ip:8000/api/auth/callback
|
||||
LINUXDO_REDIRECT_URI: Optional[str] = None
|
||||
|
||||
# 前端URL配置(用于OAuth回调后重定向)
|
||||
# 本地开发: http://localhost:8000
|
||||
# 生产环境: https://your-domain.com 或 http://your-ip:8000
|
||||
FRONTEND_URL: str = "http://localhost:8000"
|
||||
|
||||
# 初始管理员配置(LinuxDO user_id)
|
||||
INITIAL_ADMIN_LINUXDO_ID: Optional[str] = None
|
||||
|
||||
# 本地账户登录配置
|
||||
LOCAL_AUTH_ENABLED: bool = True # 是否启用本地账户登录
|
||||
LOCAL_AUTH_USERNAME: Optional[str] = None # 本地登录用户名
|
||||
LOCAL_AUTH_PASSWORD: Optional[str] = None # 本地登录密码
|
||||
LOCAL_AUTH_DISPLAY_NAME: str = "本地用户" # 本地用户显示名称
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
case_sensitive = False
|
||||
|
||||
|
||||
# 创建全局配置实例
|
||||
settings = Settings()
|
||||
config_logger.info(f"配置加载完成: {settings.app_name} v{settings.app_version}")
|
||||
config_logger.debug(f"调试模式: {settings.debug}")
|
||||
config_logger.debug(f"AI提供商: {settings.default_ai_provider}")
|
||||
@@ -0,0 +1,261 @@
|
||||
"""数据库连接和会话管理 - 支持多用户数据隔离"""
|
||||
import asyncio
|
||||
from typing import Dict, Any
|
||||
from datetime import datetime
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.orm import declarative_base
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from fastapi import Request, HTTPException
|
||||
from app.config import settings
|
||||
from app.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# 创建基类
|
||||
Base = declarative_base()
|
||||
|
||||
# 引擎缓存:每个用户一个引擎
|
||||
_engine_cache: Dict[str, Any] = {}
|
||||
|
||||
# 锁管理:用于保护引擎创建过程
|
||||
_engine_locks: Dict[str, asyncio.Lock] = {}
|
||||
_cache_lock = asyncio.Lock()
|
||||
|
||||
# 会话统计(用于监控连接泄漏)
|
||||
_session_stats = {
|
||||
"created": 0,
|
||||
"closed": 0,
|
||||
"active": 0,
|
||||
"errors": 0,
|
||||
"generator_exits": 0,
|
||||
"last_check": None
|
||||
}
|
||||
|
||||
|
||||
async def get_engine(user_id: str):
|
||||
"""获取或创建用户专属的数据库引擎(线程安全)
|
||||
|
||||
Args:
|
||||
user_id: 用户ID
|
||||
|
||||
Returns:
|
||||
用户专属的异步引擎
|
||||
"""
|
||||
if user_id in _engine_cache:
|
||||
return _engine_cache[user_id]
|
||||
|
||||
async with _cache_lock:
|
||||
if user_id not in _engine_locks:
|
||||
_engine_locks[user_id] = asyncio.Lock()
|
||||
user_lock = _engine_locks[user_id]
|
||||
|
||||
async with user_lock:
|
||||
if user_id not in _engine_cache:
|
||||
db_url = f"sqlite+aiosqlite:///data/ai_story_user_{user_id}.db"
|
||||
engine = create_async_engine(
|
||||
db_url,
|
||||
echo=False,
|
||||
future=True,
|
||||
poolclass=StaticPool,
|
||||
pool_pre_ping=True,
|
||||
pool_recycle=3600,
|
||||
connect_args={
|
||||
"timeout": 30,
|
||||
"check_same_thread": False
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text("PRAGMA journal_mode=WAL"))
|
||||
await conn.execute(text("PRAGMA synchronous=NORMAL"))
|
||||
await conn.execute(text("PRAGMA cache_size=-64000"))
|
||||
await conn.execute(text("PRAGMA temp_store=MEMORY"))
|
||||
await conn.execute(text("PRAGMA busy_timeout=5000"))
|
||||
|
||||
logger.info(f"✅ 用户 {user_id} 的数据库已优化(WAL模式 + 64MB缓存)")
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ 用户 {user_id} 数据库优化失败: {str(e)}")
|
||||
_engine_cache[user_id] = engine
|
||||
logger.info(f"为用户 {user_id} 创建数据库引擎")
|
||||
|
||||
return _engine_cache[user_id]
|
||||
|
||||
|
||||
async def get_db(request: Request):
|
||||
"""获取数据库会话的依赖函数
|
||||
|
||||
从 request.state.user_id 获取用户ID,然后返回该用户的数据库会话
|
||||
"""
|
||||
user_id = getattr(request.state, "user_id", None)
|
||||
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="未登录或用户ID缺失")
|
||||
|
||||
engine = await get_engine(user_id)
|
||||
|
||||
AsyncSessionLocal = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False
|
||||
)
|
||||
|
||||
session = AsyncSessionLocal()
|
||||
session_id = id(session)
|
||||
|
||||
global _session_stats
|
||||
_session_stats["created"] += 1
|
||||
_session_stats["active"] += 1
|
||||
|
||||
logger.debug(f"📊 会话创建 [User:{user_id}][ID:{session_id}] - 活跃:{_session_stats['active']}, 总创建:{_session_stats['created']}, 总关闭:{_session_stats['closed']}")
|
||||
|
||||
try:
|
||||
yield session
|
||||
if session.in_transaction():
|
||||
await session.rollback()
|
||||
except GeneratorExit:
|
||||
_session_stats["generator_exits"] += 1
|
||||
logger.warning(f"⚠️ GeneratorExit [User:{user_id}][ID:{session_id}] - SSE连接断开(总计:{_session_stats['generator_exits']}次)")
|
||||
try:
|
||||
if session.in_transaction():
|
||||
await session.rollback()
|
||||
logger.info(f"✅ 事务已回滚 [User:{user_id}][ID:{session_id}](GeneratorExit)")
|
||||
except Exception as rollback_error:
|
||||
_session_stats["errors"] += 1
|
||||
logger.error(f"❌ GeneratorExit回滚失败 [User:{user_id}][ID:{session_id}]: {str(rollback_error)}")
|
||||
except Exception as e:
|
||||
_session_stats["errors"] += 1
|
||||
logger.error(f"❌ 会话异常 [User:{user_id}][ID:{session_id}]: {str(e)}")
|
||||
try:
|
||||
if session.in_transaction():
|
||||
await session.rollback()
|
||||
logger.info(f"✅ 事务已回滚 [User:{user_id}][ID:{session_id}](异常)")
|
||||
except Exception as rollback_error:
|
||||
logger.error(f"❌ 异常回滚失败 [User:{user_id}][ID:{session_id}]: {str(rollback_error)}")
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
if session.in_transaction():
|
||||
await session.rollback()
|
||||
logger.warning(f"⚠️ finally中发现未提交事务 [User:{user_id}][ID:{session_id}],已回滚")
|
||||
|
||||
await session.close()
|
||||
|
||||
_session_stats["closed"] += 1
|
||||
_session_stats["active"] -= 1
|
||||
_session_stats["last_check"] = datetime.now().isoformat()
|
||||
|
||||
logger.debug(f"📊 会话关闭 [User:{user_id}][ID:{session_id}] - 活跃:{_session_stats['active']}, 总创建:{_session_stats['created']}, 总关闭:{_session_stats['closed']}, 错误:{_session_stats['errors']}")
|
||||
|
||||
if _session_stats["active"] > 10:
|
||||
logger.warning(f"🚨 活跃会话数过多: {_session_stats['active']},可能存在连接泄漏!")
|
||||
elif _session_stats["active"] < 0:
|
||||
logger.error(f"🚨 活跃会话数异常: {_session_stats['active']},统计可能不准确!")
|
||||
|
||||
except Exception as e:
|
||||
_session_stats["errors"] += 1
|
||||
logger.error(f"❌ 关闭会话时出错 [User:{user_id}][ID:{session_id}]: {str(e)}", exc_info=True)
|
||||
try:
|
||||
await session.close()
|
||||
except:
|
||||
pass
|
||||
|
||||
async def _init_relationship_types(user_id: str):
|
||||
"""为指定用户初始化预置的关系类型数据
|
||||
|
||||
Args:
|
||||
user_id: 用户ID
|
||||
"""
|
||||
from app.models.relationship import RelationshipType
|
||||
|
||||
relationship_types = [
|
||||
{"name": "父亲", "category": "family", "reverse_name": "子女", "intimacy_range": "high", "icon": "👨"},
|
||||
{"name": "母亲", "category": "family", "reverse_name": "子女", "intimacy_range": "high", "icon": "👩"},
|
||||
{"name": "兄弟", "category": "family", "reverse_name": "兄弟", "intimacy_range": "high", "icon": "👬"},
|
||||
{"name": "姐妹", "category": "family", "reverse_name": "姐妹", "intimacy_range": "high", "icon": "👭"},
|
||||
{"name": "子女", "category": "family", "reverse_name": "父母", "intimacy_range": "high", "icon": "👶"},
|
||||
{"name": "配偶", "category": "family", "reverse_name": "配偶", "intimacy_range": "high", "icon": "💑"},
|
||||
{"name": "恋人", "category": "family", "reverse_name": "恋人", "intimacy_range": "high", "icon": "💕"},
|
||||
|
||||
{"name": "师父", "category": "social", "reverse_name": "徒弟", "intimacy_range": "high", "icon": "🎓"},
|
||||
{"name": "徒弟", "category": "social", "reverse_name": "师父", "intimacy_range": "high", "icon": "📚"},
|
||||
{"name": "朋友", "category": "social", "reverse_name": "朋友", "intimacy_range": "medium", "icon": "🤝"},
|
||||
{"name": "同学", "category": "social", "reverse_name": "同学", "intimacy_range": "medium", "icon": "🎒"},
|
||||
{"name": "邻居", "category": "social", "reverse_name": "邻居", "intimacy_range": "low", "icon": "🏘️"},
|
||||
{"name": "知己", "category": "social", "reverse_name": "知己", "intimacy_range": "high", "icon": "💙"},
|
||||
|
||||
{"name": "上司", "category": "professional", "reverse_name": "下属", "intimacy_range": "low", "icon": "👔"},
|
||||
{"name": "下属", "category": "professional", "reverse_name": "上司", "intimacy_range": "low", "icon": "💼"},
|
||||
{"name": "同事", "category": "professional", "reverse_name": "同事", "intimacy_range": "medium", "icon": "🤵"},
|
||||
{"name": "合作伙伴", "category": "professional", "reverse_name": "合作伙伴", "intimacy_range": "medium", "icon": "🤜🤛"},
|
||||
|
||||
{"name": "敌人", "category": "hostile", "reverse_name": "敌人", "intimacy_range": "low", "icon": "⚔️"},
|
||||
{"name": "仇人", "category": "hostile", "reverse_name": "仇人", "intimacy_range": "low", "icon": "💢"},
|
||||
{"name": "竞争对手", "category": "hostile", "reverse_name": "竞争对手", "intimacy_range": "low", "icon": "🎯"},
|
||||
{"name": "宿敌", "category": "hostile", "reverse_name": "宿敌", "intimacy_range": "low", "icon": "⚡"},
|
||||
]
|
||||
|
||||
try:
|
||||
engine = await get_engine(user_id)
|
||||
AsyncSessionLocal = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False
|
||||
)
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(select(RelationshipType))
|
||||
existing = result.scalars().first()
|
||||
|
||||
if existing:
|
||||
logger.info(f"用户 {user_id} 的关系类型数据已存在,跳过初始化")
|
||||
return
|
||||
|
||||
logger.info(f"开始为用户 {user_id} 插入关系类型数据...")
|
||||
for rt_data in relationship_types:
|
||||
relationship_type = RelationshipType(**rt_data)
|
||||
session.add(relationship_type)
|
||||
|
||||
await session.commit()
|
||||
logger.info(f"成功为用户 {user_id} 插入 {len(relationship_types)} 条关系类型数据")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"用户 {user_id} 初始化关系类型数据失败: {str(e)}", exc_info=True)
|
||||
raise
|
||||
|
||||
|
||||
|
||||
async def init_db(user_id: str):
|
||||
"""初始化指定用户的数据库,创建所有表并插入预置数据
|
||||
|
||||
Args:
|
||||
user_id: 用户ID
|
||||
"""
|
||||
try:
|
||||
logger.info(f"开始初始化用户 {user_id} 的数据库...")
|
||||
engine = await get_engine(user_id)
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
await _init_relationship_types(user_id)
|
||||
|
||||
logger.info(f"用户 {user_id} 的数据库初始化成功")
|
||||
except Exception as e:
|
||||
logger.error(f"用户 {user_id} 的数据库初始化失败: {str(e)}", exc_info=True)
|
||||
raise
|
||||
|
||||
|
||||
async def close_db():
|
||||
"""关闭所有数据库连接"""
|
||||
try:
|
||||
logger.info("正在关闭所有数据库连接...")
|
||||
for user_id, engine in _engine_cache.items():
|
||||
await engine.dispose()
|
||||
logger.info(f"用户 {user_id} 的数据库连接已关闭")
|
||||
_engine_cache.clear()
|
||||
logger.info("所有数据库连接已关闭")
|
||||
except Exception as e:
|
||||
logger.error(f"关闭数据库连接失败: {str(e)}", exc_info=True)
|
||||
raise
|
||||
@@ -0,0 +1,73 @@
|
||||
"""初始化关系类型数据"""
|
||||
import asyncio
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models.relationship import RelationshipType
|
||||
from app.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
async def init_relationship_types():
|
||||
"""初始化预置的关系类型数据"""
|
||||
|
||||
# 预置关系类型数据
|
||||
relationship_types = [
|
||||
# 家族关系
|
||||
{"name": "父亲", "category": "family", "reverse_name": "子女", "intimacy_range": "high", "icon": "👨"},
|
||||
{"name": "母亲", "category": "family", "reverse_name": "子女", "intimacy_range": "high", "icon": "👩"},
|
||||
{"name": "兄弟", "category": "family", "reverse_name": "兄弟", "intimacy_range": "high", "icon": "👬"},
|
||||
{"name": "姐妹", "category": "family", "reverse_name": "姐妹", "intimacy_range": "high", "icon": "👭"},
|
||||
{"name": "子女", "category": "family", "reverse_name": "父母", "intimacy_range": "high", "icon": "👶"},
|
||||
{"name": "配偶", "category": "family", "reverse_name": "配偶", "intimacy_range": "high", "icon": "💑"},
|
||||
{"name": "恋人", "category": "family", "reverse_name": "恋人", "intimacy_range": "high", "icon": "💕"},
|
||||
|
||||
# 社交关系
|
||||
{"name": "师父", "category": "social", "reverse_name": "徒弟", "intimacy_range": "high", "icon": "🎓"},
|
||||
{"name": "徒弟", "category": "social", "reverse_name": "师父", "intimacy_range": "high", "icon": "📚"},
|
||||
{"name": "朋友", "category": "social", "reverse_name": "朋友", "intimacy_range": "medium", "icon": "🤝"},
|
||||
{"name": "同学", "category": "social", "reverse_name": "同学", "intimacy_range": "medium", "icon": "🎒"},
|
||||
{"name": "邻居", "category": "social", "reverse_name": "邻居", "intimacy_range": "low", "icon": "🏘️"},
|
||||
{"name": "知己", "category": "social", "reverse_name": "知己", "intimacy_range": "high", "icon": "💙"},
|
||||
|
||||
# 职业关系
|
||||
{"name": "上司", "category": "professional", "reverse_name": "下属", "intimacy_range": "low", "icon": "👔"},
|
||||
{"name": "下属", "category": "professional", "reverse_name": "上司", "intimacy_range": "low", "icon": "💼"},
|
||||
{"name": "同事", "category": "professional", "reverse_name": "同事", "intimacy_range": "medium", "icon": "🤵"},
|
||||
{"name": "合作伙伴", "category": "professional", "reverse_name": "合作伙伴", "intimacy_range": "medium", "icon": "🤜🤛"},
|
||||
|
||||
# 敌对关系
|
||||
{"name": "敌人", "category": "hostile", "reverse_name": "敌人", "intimacy_range": "low", "icon": "⚔️"},
|
||||
{"name": "仇人", "category": "hostile", "reverse_name": "仇人", "intimacy_range": "low", "icon": "💢"},
|
||||
{"name": "竞争对手", "category": "hostile", "reverse_name": "竞争对手", "intimacy_range": "low", "icon": "🎯"},
|
||||
{"name": "宿敌", "category": "hostile", "reverse_name": "宿敌", "intimacy_range": "low", "icon": "⚡"},
|
||||
]
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
# 检查是否已经有数据
|
||||
result = await session.execute(select(RelationshipType))
|
||||
existing = result.scalars().first()
|
||||
|
||||
if existing:
|
||||
logger.info("关系类型数据已存在,跳过初始化")
|
||||
return
|
||||
|
||||
# 插入预置数据
|
||||
logger.info("开始插入关系类型数据...")
|
||||
for rt_data in relationship_types:
|
||||
relationship_type = RelationshipType(**rt_data)
|
||||
session.add(relationship_type)
|
||||
|
||||
await session.commit()
|
||||
logger.info(f"成功插入 {len(relationship_types)} 条关系类型数据")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"初始化关系类型数据失败: {str(e)}", exc_info=True)
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(init_relationship_types())
|
||||
@@ -0,0 +1,158 @@
|
||||
"""统一日志配置模块 - Uvicorn风格"""
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class UvicornFormatter(logging.Formatter):
|
||||
"""Uvicorn风格的日志格式化器"""
|
||||
|
||||
# 日志级别颜色(ANSI转义码)
|
||||
COLORS = {
|
||||
'DEBUG': '\033[36m', # 青色
|
||||
'INFO': '\033[32m', # 绿色
|
||||
'WARNING': '\033[33m', # 黄色
|
||||
'ERROR': '\033[31m', # 红色
|
||||
'CRITICAL': '\033[35m', # 紫色
|
||||
}
|
||||
RESET = '\033[0m'
|
||||
|
||||
def __init__(self, use_colors: bool = True):
|
||||
"""
|
||||
初始化格式化器
|
||||
|
||||
Args:
|
||||
use_colors: 是否使用颜色(控制台输出使用,文件输出不使用)
|
||||
"""
|
||||
super().__init__()
|
||||
self.use_colors = use_colors
|
||||
|
||||
def format(self, record):
|
||||
"""格式化日志记录为 Uvicorn 风格"""
|
||||
# 获取日志级别名称
|
||||
levelname = record.levelname
|
||||
|
||||
# 添加颜色(如果启用且终端支持)
|
||||
if self.use_colors and sys.stderr.isatty():
|
||||
colored_level = f"{self.COLORS.get(levelname, '')}{levelname}{self.RESET}"
|
||||
else:
|
||||
colored_level = levelname
|
||||
|
||||
# 添加请求追踪ID(如果存在)
|
||||
request_id = getattr(record, 'request_id', None)
|
||||
request_id_str = f" [{request_id}]" if request_id else ""
|
||||
|
||||
# Uvicorn风格格式: INFO: module_name - message [request_id]
|
||||
# 注意:INFO后面有5个空格,保持对齐
|
||||
return f"{colored_level}: {record.name}{request_id_str} - {record.getMessage()}"
|
||||
|
||||
|
||||
# 全局标志,防止重复初始化
|
||||
_logging_configured = False
|
||||
|
||||
def setup_logging(
|
||||
level: str = "INFO",
|
||||
log_to_file: bool = False,
|
||||
log_file_path: Optional[str] = None,
|
||||
max_bytes: int = 10 * 1024 * 1024,
|
||||
backup_count: int = 30
|
||||
):
|
||||
"""
|
||||
配置统一的 Uvicorn 风格日志系统
|
||||
|
||||
Args:
|
||||
level: 日志级别 (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||
log_to_file: 是否输出到文件
|
||||
log_file_path: 日志文件路径
|
||||
max_bytes: 单个日志文件最大字节数(默认10MB)
|
||||
backup_count: 保留的备份文件数量(默认30个)
|
||||
"""
|
||||
global _logging_configured
|
||||
|
||||
# 如果已经配置过,直接返回
|
||||
if _logging_configured:
|
||||
return logging.getLogger()
|
||||
|
||||
# 获取根日志器
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.setLevel(getattr(logging, level.upper()))
|
||||
|
||||
# 清除已有的处理器,避免重复
|
||||
root_logger.handlers.clear()
|
||||
|
||||
# 1. 创建控制台处理器(带颜色)
|
||||
console_handler = logging.StreamHandler(sys.stderr)
|
||||
console_handler.setLevel(getattr(logging, level.upper()))
|
||||
console_formatter = UvicornFormatter(use_colors=True)
|
||||
console_handler.setFormatter(console_formatter)
|
||||
root_logger.addHandler(console_handler)
|
||||
|
||||
# 2. 创建文件处理器(如果启用)
|
||||
if log_to_file and log_file_path:
|
||||
# 确保日志目录存在
|
||||
log_file = Path(log_file_path)
|
||||
log_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 使用RotatingFileHandler实现日志轮转
|
||||
file_handler = RotatingFileHandler(
|
||||
filename=log_file_path,
|
||||
maxBytes=max_bytes,
|
||||
backupCount=backup_count,
|
||||
encoding='utf-8'
|
||||
)
|
||||
file_handler.setLevel(getattr(logging, level.upper()))
|
||||
|
||||
# 文件日志不使用颜色
|
||||
file_formatter = UvicornFormatter(use_colors=False)
|
||||
file_handler.setFormatter(file_formatter)
|
||||
root_logger.addHandler(file_handler)
|
||||
|
||||
# 记录日志配置信息
|
||||
root_logger.info(f"日志文件输出已启用: {log_file_path}")
|
||||
root_logger.info(f"日志轮转配置: 单文件最大{max_bytes / 1024 / 1024:.1f}MB, 保留{backup_count}个备份")
|
||||
|
||||
# 配置第三方库的日志级别
|
||||
_configure_third_party_loggers()
|
||||
|
||||
# 标记为已配置
|
||||
_logging_configured = True
|
||||
|
||||
return root_logger
|
||||
|
||||
|
||||
def _configure_third_party_loggers():
|
||||
"""配置第三方库的日志级别"""
|
||||
# SQLAlchemy - 禁用SQL日志
|
||||
logging.getLogger('sqlalchemy.engine').setLevel(logging.WARNING)
|
||||
logging.getLogger('sqlalchemy.pool').setLevel(logging.WARNING)
|
||||
logging.getLogger('sqlalchemy.dialects').setLevel(logging.WARNING)
|
||||
logging.getLogger('sqlalchemy.orm').setLevel(logging.WARNING)
|
||||
|
||||
# Watchfiles - 开发时的文件监控,降低级别
|
||||
logging.getLogger('watchfiles').setLevel(logging.WARNING)
|
||||
|
||||
# httpx - HTTP客户端
|
||||
logging.getLogger('httpx').setLevel(logging.WARNING)
|
||||
|
||||
# openai/anthropic - AI客户端库
|
||||
logging.getLogger('openai').setLevel(logging.WARNING)
|
||||
logging.getLogger('anthropic').setLevel(logging.WARNING)
|
||||
|
||||
# 应用模块 - 可根据需要调整
|
||||
logging.getLogger('app.services.ai_service').setLevel(logging.WARNING)
|
||||
logging.getLogger('app.api.wizard').setLevel(logging.WARNING)
|
||||
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
"""
|
||||
获取指定名称的日志器
|
||||
|
||||
Args:
|
||||
name: 日志器名称,通常使用 __name__
|
||||
|
||||
Returns:
|
||||
配置好的日志器实例
|
||||
"""
|
||||
return logging.getLogger(name)
|
||||
@@ -0,0 +1,176 @@
|
||||
"""FastAPI应用主入口"""
|
||||
from fastapi import FastAPI, Request, status
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import JSONResponse, FileResponse
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import settings
|
||||
from app.database import close_db, _session_stats
|
||||
from app.logger import setup_logging, get_logger
|
||||
from app.middleware import RequestIDMiddleware
|
||||
from app.middleware.auth_middleware import AuthMiddleware
|
||||
|
||||
setup_logging(
|
||||
level=settings.log_level,
|
||||
log_to_file=settings.log_to_file,
|
||||
log_file_path=settings.log_file_path,
|
||||
max_bytes=settings.log_max_bytes,
|
||||
backup_count=settings.log_backup_count
|
||||
)
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""应用生命周期管理"""
|
||||
logger.info("应用启动,等待用户登录...")
|
||||
|
||||
yield
|
||||
await close_db()
|
||||
logger.info("应用已关闭")
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.app_name,
|
||||
version=settings.app_version,
|
||||
description="AI写小说工具 - 智能小说创作助手",
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
||||
"""处理请求验证错误"""
|
||||
logger.error(f"请求验证失败: {exc.errors()}")
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
content={
|
||||
"detail": "请求参数验证失败",
|
||||
"errors": exc.errors()
|
||||
}
|
||||
)
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request: Request, exc: Exception):
|
||||
"""处理所有未捕获的异常"""
|
||||
logger.error(f"未处理的异常: {type(exc).__name__}: {str(exc)}", exc_info=True)
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content={
|
||||
"detail": "服务器内部错误",
|
||||
"message": str(exc) if settings.debug else "请稍后重试"
|
||||
}
|
||||
)
|
||||
|
||||
app.add_middleware(RequestIDMiddleware)
|
||||
app.add_middleware(AuthMiddleware)
|
||||
|
||||
if settings.debug:
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
else:
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""健康检查"""
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/health/db-sessions")
|
||||
async def db_session_stats():
|
||||
"""
|
||||
数据库会话统计(监控连接泄漏)
|
||||
|
||||
返回:
|
||||
- created: 总创建会话数
|
||||
- closed: 总关闭会话数
|
||||
- active: 当前活跃会话数(应该接近0)
|
||||
- errors: 错误次数
|
||||
- generator_exits: SSE断开次数
|
||||
- last_check: 最后检查时间
|
||||
"""
|
||||
return {
|
||||
"status": "ok",
|
||||
"session_stats": _session_stats,
|
||||
"warning": "活跃会话数过多" if _session_stats["active"] > 10 else None
|
||||
}
|
||||
|
||||
|
||||
from app.api import (
|
||||
projects, outlines, characters, chapters,
|
||||
wizard_stream, relationships, organizations,
|
||||
auth, users
|
||||
)
|
||||
|
||||
app.include_router(auth.router, prefix="/api")
|
||||
app.include_router(users.router, prefix="/api")
|
||||
|
||||
app.include_router(projects.router, prefix="/api")
|
||||
app.include_router(wizard_stream.router, prefix="/api")
|
||||
app.include_router(outlines.router, prefix="/api")
|
||||
app.include_router(characters.router, prefix="/api")
|
||||
app.include_router(chapters.router, prefix="/api")
|
||||
app.include_router(relationships.router, prefix="/api")
|
||||
app.include_router(organizations.router, prefix="/api")
|
||||
|
||||
static_dir = Path(__file__).parent.parent / "static"
|
||||
if static_dir.exists():
|
||||
app.mount("/assets", StaticFiles(directory=str(static_dir / "assets")), name="assets")
|
||||
|
||||
@app.get("/{full_path:path}")
|
||||
async def serve_spa(full_path: str):
|
||||
"""服务单页应用,所有非API路径返回index.html"""
|
||||
if full_path.startswith("api/"):
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"detail": "API路径不存在"}
|
||||
)
|
||||
|
||||
file_path = static_dir / full_path
|
||||
if file_path.is_file():
|
||||
return FileResponse(file_path)
|
||||
|
||||
index_file = static_dir / "index.html"
|
||||
if index_file.exists():
|
||||
return FileResponse(index_file)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"detail": "页面不存在"}
|
||||
)
|
||||
else:
|
||||
logger.warning("静态文件目录不存在,请先构建前端: cd frontend && npm run build")
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {
|
||||
"message": "欢迎使用AI Story Creator",
|
||||
"version": settings.app_version,
|
||||
"docs": "/docs",
|
||||
"notice": "请先构建前端: cd frontend && npm run build"
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(
|
||||
"app.main:app",
|
||||
host=settings.app_host,
|
||||
port=settings.app_port,
|
||||
reload=settings.debug
|
||||
)
|
||||
@@ -0,0 +1,4 @@
|
||||
"""中间件模块"""
|
||||
from .request_id import RequestIDMiddleware
|
||||
|
||||
__all__ = ['RequestIDMiddleware']
|
||||
@@ -0,0 +1,39 @@
|
||||
"""
|
||||
认证中间件 - 从 Cookie 中提取用户信息并注入到 request.state
|
||||
"""
|
||||
from fastapi import Request
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from app.user_manager import user_manager
|
||||
|
||||
|
||||
class AuthMiddleware(BaseHTTPMiddleware):
|
||||
"""认证中间件"""
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
"""
|
||||
处理请求,从 Cookie 中提取用户 ID 并注入到 request.state
|
||||
"""
|
||||
# 从 Cookie 中获取用户 ID
|
||||
user_id = request.cookies.get("user_id")
|
||||
|
||||
# 注入到 request.state
|
||||
if user_id:
|
||||
user = await user_manager.get_user(user_id)
|
||||
if user:
|
||||
request.state.user_id = user_id
|
||||
request.state.user = user
|
||||
request.state.is_admin = user.is_admin
|
||||
else:
|
||||
# 用户不存在,清除状态
|
||||
request.state.user_id = None
|
||||
request.state.user = None
|
||||
request.state.is_admin = False
|
||||
else:
|
||||
# 未登录
|
||||
request.state.user_id = None
|
||||
request.state.user = None
|
||||
request.state.is_admin = False
|
||||
|
||||
# 继续处理请求
|
||||
response = await call_next(request)
|
||||
return response
|
||||
@@ -0,0 +1,78 @@
|
||||
"""请求追踪ID中间件"""
|
||||
import uuid
|
||||
import logging
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
from typing import Callable
|
||||
|
||||
|
||||
class RequestIDMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
请求追踪ID中间件
|
||||
|
||||
为每个请求生成唯一ID,并添加到日志上下文中
|
||||
"""
|
||||
|
||||
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
||||
"""
|
||||
处理请求,添加追踪ID
|
||||
|
||||
Args:
|
||||
request: 请求对象
|
||||
call_next: 下一个处理器
|
||||
|
||||
Returns:
|
||||
响应对象
|
||||
"""
|
||||
# 从请求头获取追踪ID,或生成新的
|
||||
request_id = request.headers.get('X-Request-ID') or str(uuid.uuid4())
|
||||
|
||||
# 将请求ID存储到request.state中,方便后续访问
|
||||
request.state.request_id = request_id
|
||||
|
||||
# 创建日志过滤器,自动添加request_id到日志记录
|
||||
log_filter = RequestIDFilter(request_id)
|
||||
|
||||
# 获取根日志器并添加过滤器
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.addFilter(log_filter)
|
||||
|
||||
try:
|
||||
# 处理请求
|
||||
response = await call_next(request)
|
||||
|
||||
# 将请求ID添加到响应头
|
||||
response.headers['X-Request-ID'] = request_id
|
||||
|
||||
return response
|
||||
finally:
|
||||
# 移除过滤器,避免影响其他请求
|
||||
root_logger.removeFilter(log_filter)
|
||||
|
||||
|
||||
class RequestIDFilter(logging.Filter):
|
||||
"""日志过滤器,为日志记录添加request_id属性"""
|
||||
|
||||
def __init__(self, request_id: str):
|
||||
"""
|
||||
初始化过滤器
|
||||
|
||||
Args:
|
||||
request_id: 请求追踪ID
|
||||
"""
|
||||
super().__init__()
|
||||
self.request_id = request_id
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
"""
|
||||
为日志记录添加request_id属性
|
||||
|
||||
Args:
|
||||
record: 日志记录
|
||||
|
||||
Returns:
|
||||
True(不过滤任何日志)
|
||||
"""
|
||||
record.request_id = self.request_id
|
||||
return True
|
||||
@@ -0,0 +1,26 @@
|
||||
"""数据库模型"""
|
||||
from app.models.project import Project
|
||||
from app.models.outline import Outline
|
||||
from app.models.character import Character
|
||||
from app.models.chapter import Chapter
|
||||
from app.models.generation_history import GenerationHistory
|
||||
from app.models.settings import Settings
|
||||
from app.models.relationship import (
|
||||
RelationshipType,
|
||||
CharacterRelationship,
|
||||
Organization,
|
||||
OrganizationMember
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Project",
|
||||
"Outline",
|
||||
"Character",
|
||||
"Chapter",
|
||||
"GenerationHistory",
|
||||
"Settings",
|
||||
"RelationshipType",
|
||||
"CharacterRelationship",
|
||||
"Organization",
|
||||
"OrganizationMember",
|
||||
]
|
||||
@@ -0,0 +1,24 @@
|
||||
"""章节数据模型"""
|
||||
from sqlalchemy import Column, String, Text, Integer, DateTime, ForeignKey
|
||||
from sqlalchemy.sql import func
|
||||
from app.database import Base
|
||||
import uuid
|
||||
|
||||
|
||||
class Chapter(Base):
|
||||
"""章节表"""
|
||||
__tablename__ = "chapters"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
project_id = Column(String(36), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False)
|
||||
chapter_number = Column(Integer, nullable=False, comment="章节序号")
|
||||
title = Column(String(200), nullable=False, comment="章节标题")
|
||||
content = Column(Text, comment="章节内容")
|
||||
summary = Column(Text, comment="章节摘要")
|
||||
word_count = Column(Integer, default=0, comment="字数统计")
|
||||
status = Column(String(20), default="draft", comment="章节状态")
|
||||
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Chapter(id={self.id}, chapter_number={self.chapter_number}, title={self.title})>"
|
||||
@@ -0,0 +1,44 @@
|
||||
"""角色数据模型"""
|
||||
from sqlalchemy import Column, String, Text, DateTime, ForeignKey, Boolean
|
||||
from sqlalchemy.sql import func
|
||||
from app.database import Base
|
||||
import uuid
|
||||
|
||||
|
||||
class Character(Base):
|
||||
"""角色表(包括角色和组织)"""
|
||||
__tablename__ = "characters"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
project_id = Column(String(36), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False)
|
||||
|
||||
# 基本信息
|
||||
name = Column(String(100), nullable=False, comment="角色/组织名称")
|
||||
age = Column(String(20), comment="年龄")
|
||||
gender = Column(String(20), comment="性别")
|
||||
is_organization = Column(Boolean, default=False, comment="是否为组织")
|
||||
|
||||
# 角色类型:protagonist(主角)/supporting(配角)/antagonist(反派)
|
||||
role_type = Column(String(50), comment="角色类型")
|
||||
|
||||
# 角色详细信息
|
||||
personality = Column(Text, comment="性格特点/组织特性")
|
||||
background = Column(Text, comment="背景故事")
|
||||
appearance = Column(Text, comment="外貌描述")
|
||||
relationships = Column(Text, comment="人物关系(JSON)")
|
||||
|
||||
# 组织特有字段
|
||||
organization_type = Column(String(100), comment="组织类型")
|
||||
organization_purpose = Column(String(500), comment="组织目的")
|
||||
organization_members = Column(Text, comment="组织成员(JSON)")
|
||||
|
||||
# 其他
|
||||
avatar_url = Column(String(500), comment="头像URL")
|
||||
traits = Column(Text, comment="特征标签(JSON)")
|
||||
|
||||
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
|
||||
|
||||
def __repr__(self):
|
||||
entity_type = "组织" if self.is_organization else "角色"
|
||||
return f"<Character(id={self.id}, name={self.name}, type={entity_type})>"
|
||||
@@ -0,0 +1,23 @@
|
||||
"""生成历史数据模型"""
|
||||
from sqlalchemy import Column, String, Text, Integer, Float, DateTime, ForeignKey
|
||||
from sqlalchemy.sql import func
|
||||
from app.database import Base
|
||||
import uuid
|
||||
|
||||
|
||||
class GenerationHistory(Base):
|
||||
"""生成历史表"""
|
||||
__tablename__ = "generation_history"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
project_id = Column(String(36), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False)
|
||||
chapter_id = Column(String(36), ForeignKey("chapters.id", ondelete="SET NULL"), nullable=True)
|
||||
prompt = Column(Text, comment="使用的提示词")
|
||||
generated_content = Column(Text, comment="生成的内容")
|
||||
model = Column(String(50), comment="使用的模型")
|
||||
tokens_used = Column(Integer, comment="消耗的token数")
|
||||
generation_time = Column(Float, comment="生成耗时(秒)")
|
||||
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<GenerationHistory(id={self.id}, model={self.model})>"
|
||||
@@ -0,0 +1,22 @@
|
||||
"""大纲数据模型"""
|
||||
from sqlalchemy import Column, String, Text, Integer, DateTime, ForeignKey
|
||||
from sqlalchemy.sql import func
|
||||
from app.database import Base
|
||||
import uuid
|
||||
|
||||
|
||||
class Outline(Base):
|
||||
"""大纲表"""
|
||||
__tablename__ = "outlines"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
project_id = Column(String(36), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False)
|
||||
title = Column(String(200), nullable=False, comment="大纲标题")
|
||||
content = Column(Text, comment="大纲内容")
|
||||
structure = Column(Text, comment="结构化大纲数据(JSON)")
|
||||
order_index = Column(Integer, comment="排序序号")
|
||||
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Outline(id={self.id}, title={self.title})>"
|
||||
@@ -0,0 +1,38 @@
|
||||
"""项目数据模型"""
|
||||
from sqlalchemy import Column, String, Text, DateTime, Integer
|
||||
from sqlalchemy.sql import func
|
||||
from app.database import Base
|
||||
import uuid
|
||||
|
||||
|
||||
class Project(Base):
|
||||
"""项目表"""
|
||||
__tablename__ = "projects"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
title = Column(String(200), nullable=False, comment="项目标题")
|
||||
description = Column(Text, comment="项目简介")
|
||||
theme = Column(Text, comment="主题")
|
||||
genre = Column(String(50), comment="小说类型")
|
||||
target_words = Column(Integer, default=0, comment="目标字数")
|
||||
current_words = Column(Integer, default=0, comment="当前字数")
|
||||
status = Column(String(20), default="planning", comment="创作状态")
|
||||
wizard_status = Column(String(20), default="incomplete", comment="向导完成状态: incomplete/completed")
|
||||
wizard_step = Column(Integer, default=0, comment="向导当前步骤: 0-4")
|
||||
|
||||
# 世界构建字段
|
||||
world_time_period = Column(Text, comment="时间背景")
|
||||
world_location = Column(Text, comment="地理位置")
|
||||
world_atmosphere = Column(Text, comment="氛围基调")
|
||||
world_rules = Column(Text, comment="世界规则")
|
||||
|
||||
# 项目配置
|
||||
chapter_count = Column(Integer, comment="章节数量")
|
||||
narrative_perspective = Column(String(50), comment="叙事视角:first_person/third_person/omniscient")
|
||||
character_count = Column(Integer, default=5, comment="角色数量")
|
||||
|
||||
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Project(id={self.id}, title={self.title})>"
|
||||
@@ -0,0 +1,116 @@
|
||||
"""角色关系和组织管理数据模型"""
|
||||
from sqlalchemy import Column, String, Integer, Text, DateTime, ForeignKey, Boolean
|
||||
from sqlalchemy.sql import func
|
||||
from app.database import Base
|
||||
import uuid
|
||||
|
||||
|
||||
class RelationshipType(Base):
|
||||
"""关系类型定义表"""
|
||||
__tablename__ = "relationship_types"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
name = Column(String(50), nullable=False, comment="关系名称")
|
||||
category = Column(String(20), nullable=False, comment="分类:family/social/hostile/professional")
|
||||
reverse_name = Column(String(50), comment="反向关系名称")
|
||||
intimacy_range = Column(String(20), comment="亲密度范围:high/medium/low")
|
||||
icon = Column(String(50), comment="图标标识")
|
||||
description = Column(Text, comment="关系描述")
|
||||
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<RelationshipType(id={self.id}, name={self.name}, category={self.category})>"
|
||||
|
||||
|
||||
class CharacterRelationship(Base):
|
||||
"""角色关系表"""
|
||||
__tablename__ = "character_relationships"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), comment="关系ID")
|
||||
project_id = Column(String(36), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True, comment="项目ID")
|
||||
|
||||
# 关系双方
|
||||
character_from_id = Column(String(36), ForeignKey("characters.id", ondelete="CASCADE"), nullable=False, index=True, comment="角色A的ID")
|
||||
character_to_id = Column(String(36), ForeignKey("characters.id", ondelete="CASCADE"), nullable=False, index=True, comment="角色B的ID")
|
||||
|
||||
# 关系类型
|
||||
relationship_type_id = Column(Integer, ForeignKey("relationship_types.id"), index=True, comment="关系类型ID")
|
||||
relationship_name = Column(String(100), comment="自定义关系名称")
|
||||
|
||||
# 关系属性
|
||||
intimacy_level = Column(Integer, default=50, comment="亲密度:0-100")
|
||||
status = Column(String(20), default="active", comment="状态:active/broken/past/complicated")
|
||||
description = Column(Text, comment="关系详细描述")
|
||||
|
||||
# 故事时间线
|
||||
started_at = Column(String(100), comment="关系开始时间(故事时间)")
|
||||
ended_at = Column(String(100), comment="关系结束时间(故事时间)")
|
||||
|
||||
# 来源标识
|
||||
source = Column(String(20), default="ai", comment="来源:ai/manual/imported")
|
||||
|
||||
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<CharacterRelationship(id={self.id}, from={self.character_from_id}, to={self.character_to_id})>"
|
||||
|
||||
|
||||
class Organization(Base):
|
||||
"""组织详情表"""
|
||||
__tablename__ = "organizations"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), comment="组织ID")
|
||||
character_id = Column(String(36), ForeignKey("characters.id", ondelete="CASCADE"), nullable=False, unique=True, comment="关联的角色ID")
|
||||
project_id = Column(String(36), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True, comment="项目ID")
|
||||
|
||||
# 组织层级
|
||||
parent_org_id = Column(String(36), ForeignKey("organizations.id", ondelete="SET NULL"), comment="父组织ID")
|
||||
level = Column(Integer, default=0, comment="组织层级")
|
||||
|
||||
# 组织属性
|
||||
power_level = Column(Integer, default=50, comment="势力等级:0-100")
|
||||
member_count = Column(Integer, default=0, comment="成员数量")
|
||||
location = Column(Text, comment="所在地")
|
||||
|
||||
# 组织特色
|
||||
motto = Column(String(200), comment="宗旨/口号")
|
||||
color = Column(String(20), comment="代表颜色")
|
||||
|
||||
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Organization(id={self.id}, character_id={self.character_id})>"
|
||||
|
||||
|
||||
class OrganizationMember(Base):
|
||||
"""组织成员关系表"""
|
||||
__tablename__ = "organization_members"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), comment="成员关系ID")
|
||||
organization_id = Column(String(36), ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True, comment="组织ID")
|
||||
character_id = Column(String(36), ForeignKey("characters.id", ondelete="CASCADE"), nullable=False, index=True, comment="角色ID")
|
||||
|
||||
# 职位信息
|
||||
position = Column(String(100), nullable=False, comment="职位名称")
|
||||
rank = Column(Integer, default=0, comment="职位等级")
|
||||
|
||||
# 成员状态
|
||||
status = Column(String(20), default="active", comment="状态:active/retired/expelled/deceased")
|
||||
joined_at = Column(String(100), comment="加入时间(故事时间)")
|
||||
left_at = Column(String(100), comment="离开时间(故事时间)")
|
||||
|
||||
# 成员属性
|
||||
loyalty = Column(Integer, default=50, comment="忠诚度:0-100")
|
||||
contribution = Column(Integer, default=0, comment="贡献度:0-100")
|
||||
|
||||
# 来源标识
|
||||
source = Column(String(20), default="ai", comment="来源:ai/manual")
|
||||
|
||||
notes = Column(Text, comment="备注")
|
||||
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<OrganizationMember(id={self.id}, org={self.organization_id}, char={self.character_id})>"
|
||||
@@ -0,0 +1,24 @@
|
||||
"""设置数据模型"""
|
||||
from sqlalchemy import Column, String, Text, Float, Integer, DateTime
|
||||
from sqlalchemy.sql import func
|
||||
from app.database import Base
|
||||
import uuid
|
||||
|
||||
|
||||
class Settings(Base):
|
||||
"""设置表"""
|
||||
__tablename__ = "settings"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
api_provider = Column(String(50), default="openai", comment="API提供商")
|
||||
api_key = Column(String(500), comment="API密钥")
|
||||
api_base_url = Column(String(500), comment="自定义API地址")
|
||||
model_name = Column(String(100), default="gpt-4", comment="模型名称")
|
||||
temperature = Column(Float, default=0.7, comment="温度参数")
|
||||
max_tokens = Column(Integer, default=2000, comment="最大token数")
|
||||
preferences = Column(Text, comment="其他偏好设置(JSON)")
|
||||
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Settings(id={self.id}, api_provider={self.api_provider})>"
|
||||
@@ -0,0 +1 @@
|
||||
"""Pydantic数据模型"""
|
||||
@@ -0,0 +1,57 @@
|
||||
"""章节相关的Pydantic模型"""
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class ChapterBase(BaseModel):
|
||||
"""章节基础模型"""
|
||||
title: str = Field(..., description="章节标题")
|
||||
chapter_number: int = Field(..., description="章节序号")
|
||||
content: Optional[str] = Field(None, description="章节内容")
|
||||
summary: Optional[str] = Field(None, description="章节摘要")
|
||||
word_count: Optional[int] = Field(0, description="字数")
|
||||
status: Optional[str] = Field("draft", description="章节状态")
|
||||
|
||||
|
||||
class ChapterCreate(BaseModel):
|
||||
"""创建章节的请求模型"""
|
||||
project_id: str = Field(..., description="所属项目ID")
|
||||
title: str = Field(..., description="章节标题")
|
||||
chapter_number: int = Field(..., description="章节序号")
|
||||
content: Optional[str] = Field(None, description="章节内容")
|
||||
summary: Optional[str] = Field(None, description="章节摘要")
|
||||
status: Optional[str] = Field("draft", description="章节状态")
|
||||
|
||||
|
||||
class ChapterUpdate(BaseModel):
|
||||
"""更新章节的请求模型"""
|
||||
title: Optional[str] = None
|
||||
content: Optional[str] = None
|
||||
# chapter_number 不允许修改,只能通过大纲的重排序来调整
|
||||
summary: Optional[str] = None
|
||||
# word_count 自动计算,不允许手动修改
|
||||
status: Optional[str] = None
|
||||
|
||||
|
||||
class ChapterResponse(BaseModel):
|
||||
"""章节响应模型"""
|
||||
id: str
|
||||
project_id: str
|
||||
title: str
|
||||
chapter_number: int
|
||||
content: Optional[str] = None
|
||||
summary: Optional[str] = None
|
||||
word_count: int = 0
|
||||
status: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ChapterListResponse(BaseModel):
|
||||
"""章节列表响应模型"""
|
||||
total: int
|
||||
items: list[ChapterResponse]
|
||||
@@ -0,0 +1,67 @@
|
||||
"""角色相关的Pydantic模型"""
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class CharacterBase(BaseModel):
|
||||
"""角色基础模型"""
|
||||
name: str = Field(..., description="角色/组织姓名")
|
||||
age: Optional[str] = Field(None, description="年龄")
|
||||
gender: Optional[str] = Field(None, description="性别")
|
||||
is_organization: bool = Field(False, description="是否为组织")
|
||||
role_type: Optional[str] = Field(None, description="角色类型:protagonist/supporting/antagonist")
|
||||
personality: Optional[str] = Field(None, description="性格特点/组织特性")
|
||||
background: Optional[str] = Field(None, description="背景故事")
|
||||
appearance: Optional[str] = Field(None, description="外貌特征")
|
||||
relationships: Optional[str] = Field(None, description="人际关系(JSON)")
|
||||
organization_type: Optional[str] = Field(None, description="组织类型")
|
||||
organization_purpose: Optional[str] = Field(None, description="组织目的")
|
||||
organization_members: Optional[str] = Field(None, description="组织成员(JSON)")
|
||||
traits: Optional[str] = Field(None, description="特征标签(JSON)")
|
||||
|
||||
|
||||
class CharacterUpdate(BaseModel):
|
||||
"""更新角色的请求模型"""
|
||||
name: Optional[str] = None
|
||||
age: Optional[str] = None
|
||||
gender: Optional[str] = None
|
||||
is_organization: Optional[bool] = None
|
||||
role_type: Optional[str] = None
|
||||
personality: Optional[str] = None
|
||||
background: Optional[str] = None
|
||||
appearance: Optional[str] = None
|
||||
relationships: Optional[str] = None
|
||||
organization_type: Optional[str] = None
|
||||
organization_purpose: Optional[str] = None
|
||||
organization_members: Optional[str] = None
|
||||
traits: Optional[str] = None
|
||||
|
||||
|
||||
class CharacterResponse(CharacterBase):
|
||||
"""角色响应模型"""
|
||||
id: str
|
||||
project_id: str
|
||||
avatar_url: Optional[str] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class CharacterGenerateRequest(BaseModel):
|
||||
"""AI生成角色的请求模型"""
|
||||
project_id: str = Field(..., description="项目ID")
|
||||
name: Optional[str] = Field(None, description="角色名称")
|
||||
role_type: Optional[str] = Field(None, description="角色类型")
|
||||
background: Optional[str] = Field(None, description="角色背景")
|
||||
requirements: Optional[str] = Field(None, description="特殊要求")
|
||||
provider: Optional[str] = Field(None, description="AI提供商")
|
||||
model: Optional[str] = Field(None, description="AI模型")
|
||||
|
||||
|
||||
class CharacterListResponse(BaseModel):
|
||||
"""角色列表响应模型"""
|
||||
total: int
|
||||
items: List[CharacterResponse]
|
||||
@@ -0,0 +1,88 @@
|
||||
"""大纲相关的Pydantic模型"""
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class OutlineBase(BaseModel):
|
||||
"""大纲基础模型"""
|
||||
title: str = Field(..., description="章节标题")
|
||||
content: str = Field(..., description="章节内容概要")
|
||||
|
||||
|
||||
class OutlineCreate(BaseModel):
|
||||
"""创建大纲的请求模型"""
|
||||
project_id: str = Field(..., description="所属项目ID")
|
||||
title: str = Field(..., description="章节标题")
|
||||
content: str = Field(..., description="章节内容概要")
|
||||
order_index: int = Field(..., description="章节序号", ge=1)
|
||||
structure: Optional[str] = Field(None, description="结构化大纲数据(JSON)")
|
||||
|
||||
|
||||
class OutlineUpdate(BaseModel):
|
||||
"""更新大纲的请求模型"""
|
||||
title: Optional[str] = None
|
||||
content: Optional[str] = None
|
||||
# order_index 不允许通过普通更新修改,只能通过 reorder_outlines 接口批量调整
|
||||
# structure 暂不支持修改
|
||||
|
||||
|
||||
class OutlineResponse(BaseModel):
|
||||
"""大纲响应模型"""
|
||||
id: str
|
||||
project_id: str
|
||||
title: str
|
||||
content: str
|
||||
structure: Optional[str] = None
|
||||
order_index: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class OutlineGenerateRequest(BaseModel):
|
||||
"""AI生成大纲的请求模型 - 支持全新生成和智能续写"""
|
||||
project_id: str = Field(..., description="项目ID")
|
||||
genre: Optional[str] = Field(None, description="小说类型,如:玄幻、都市、悬疑等")
|
||||
theme: str = Field(..., description="小说主题")
|
||||
chapter_count: int = Field(..., ge=1, description="章节数量")
|
||||
narrative_perspective: str = Field(..., description="叙事视角")
|
||||
world_context: Optional[dict] = Field(None, description="世界观背景")
|
||||
characters_context: Optional[list] = Field(None, description="角色信息")
|
||||
target_words: int = Field(100000, description="目标字数")
|
||||
requirements: Optional[str] = Field(None, description="其他特殊要求")
|
||||
provider: Optional[str] = Field(None, description="AI提供商")
|
||||
model: Optional[str] = Field(None, description="AI模型")
|
||||
|
||||
# 续写相关参数
|
||||
mode: str = Field("auto", description="生成模式: auto(自动判断), new(全新生成), continue(续写)")
|
||||
story_direction: Optional[str] = Field(None, description="故事发展方向提示(续写时使用)")
|
||||
plot_stage: str = Field("development", description="情节阶段: development(发展), climax(高潮), ending(结局)")
|
||||
keep_existing: bool = Field(False, description="是否保留现有大纲(续写时)")
|
||||
|
||||
|
||||
class ChapterOutlineGenerateRequest(BaseModel):
|
||||
"""为单个章节生成大纲的请求模型"""
|
||||
outline_id: str = Field(..., description="大纲ID")
|
||||
context: Optional[str] = Field(None, description="额外上下文")
|
||||
provider: Optional[str] = Field(None, description="AI提供商")
|
||||
model: Optional[str] = Field(None, description="AI模型")
|
||||
|
||||
|
||||
class OutlineListResponse(BaseModel):
|
||||
"""大纲列表响应模型"""
|
||||
total: int
|
||||
items: list[OutlineResponse]
|
||||
|
||||
|
||||
class OutlineReorderItem(BaseModel):
|
||||
"""单个大纲重排序项"""
|
||||
id: str = Field(..., description="大纲ID")
|
||||
order_index: int = Field(..., description="新的序号", ge=1)
|
||||
|
||||
|
||||
class OutlineReorderRequest(BaseModel):
|
||||
"""大纲批量重排序请求"""
|
||||
orders: list[OutlineReorderItem] = Field(..., description="排序列表")
|
||||
@@ -0,0 +1,20 @@
|
||||
"""AI去味相关的Pydantic模型"""
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class PolishRequest(BaseModel):
|
||||
"""AI去味请求模型"""
|
||||
original_text: str = Field(..., description="原始文本(AI生成的文本)")
|
||||
project_id: Optional[int] = Field(None, description="项目ID(可选,用于记录历史)")
|
||||
provider: Optional[str] = Field(None, description="AI提供商")
|
||||
model: Optional[str] = Field(None, description="AI模型")
|
||||
temperature: Optional[float] = Field(0.8, description="温度参数,建议0.7-0.9")
|
||||
|
||||
|
||||
class PolishResponse(BaseModel):
|
||||
"""AI去味响应模型"""
|
||||
original_text: str = Field(..., description="原始文本")
|
||||
polished_text: str = Field(..., description="去味后的文本")
|
||||
word_count_before: int = Field(..., description="处理前字数")
|
||||
word_count_after: int = Field(..., description="处理后字数")
|
||||
@@ -0,0 +1,83 @@
|
||||
"""项目相关的Pydantic模型"""
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class ProjectBase(BaseModel):
|
||||
"""项目基础模型"""
|
||||
title: str = Field(..., description="项目标题")
|
||||
description: Optional[str] = Field(None, description="项目描述")
|
||||
theme: Optional[str] = Field(None, description="主题")
|
||||
genre: Optional[str] = Field(None, description="小说类型")
|
||||
target_words: Optional[int] = Field(None, description="目标字数")
|
||||
|
||||
|
||||
class ProjectCreate(ProjectBase):
|
||||
"""创建项目的请求模型"""
|
||||
pass
|
||||
|
||||
|
||||
class ProjectUpdate(BaseModel):
|
||||
"""更新项目的请求模型"""
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
theme: Optional[str] = None
|
||||
genre: Optional[str] = None
|
||||
target_words: Optional[int] = None
|
||||
status: Optional[str] = None
|
||||
# wizard_status 和 wizard_step 只能通过向导API修改,普通更新不允许
|
||||
world_time_period: Optional[str] = None
|
||||
world_location: Optional[str] = None
|
||||
world_atmosphere: Optional[str] = None
|
||||
world_rules: Optional[str] = None
|
||||
chapter_count: Optional[int] = None
|
||||
narrative_perspective: Optional[str] = None
|
||||
character_count: Optional[int] = None
|
||||
# current_words 由章节内容自动计算,不允许手动修改
|
||||
|
||||
|
||||
class ProjectResponse(ProjectBase):
|
||||
"""项目响应模型"""
|
||||
id: str # UUID字符串
|
||||
status: str
|
||||
current_words: int
|
||||
wizard_status: Optional[str] = None
|
||||
wizard_step: Optional[int] = None
|
||||
world_time_period: Optional[str] = None
|
||||
world_location: Optional[str] = None
|
||||
world_atmosphere: Optional[str] = None
|
||||
world_rules: Optional[str] = None
|
||||
chapter_count: Optional[int] = None
|
||||
narrative_perspective: Optional[str] = None
|
||||
character_count: Optional[int] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ProjectListResponse(BaseModel):
|
||||
"""项目列表响应模型"""
|
||||
total: int
|
||||
items: list[ProjectResponse]
|
||||
|
||||
|
||||
class ProjectWizardRequest(BaseModel):
|
||||
"""项目创建向导请求模型"""
|
||||
title: str = Field(..., description="书名")
|
||||
theme: str = Field(..., description="主题")
|
||||
genre: Optional[str] = Field(None, description="类型")
|
||||
chapter_count: int = Field(..., ge=1, description="章节数量")
|
||||
narrative_perspective: str = Field(..., description="叙事视角")
|
||||
character_count: int = Field(5, ge=5, description="角色数量(至少5个)")
|
||||
target_words: Optional[int] = Field(None, description="目标字数")
|
||||
|
||||
|
||||
class WorldBuildingResponse(BaseModel):
|
||||
"""世界构建响应模型"""
|
||||
time_period: str = Field(..., description="时间背景")
|
||||
location: str = Field(..., description="地理位置")
|
||||
atmosphere: str = Field(..., description="氛围基调")
|
||||
rules: str = Field(..., description="世界规则")
|
||||
@@ -0,0 +1,204 @@
|
||||
"""关系管理相关的Pydantic模型"""
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
# ============ 关系类型相关 ============
|
||||
|
||||
class RelationshipTypeResponse(BaseModel):
|
||||
"""关系类型响应模型"""
|
||||
id: int
|
||||
name: str
|
||||
category: str
|
||||
reverse_name: Optional[str] = None
|
||||
intimacy_range: Optional[str] = None
|
||||
icon: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ============ 角色关系相关 ============
|
||||
|
||||
class CharacterRelationshipBase(BaseModel):
|
||||
"""角色关系基础模型"""
|
||||
relationship_type_id: Optional[int] = Field(None, description="关系类型ID")
|
||||
relationship_name: Optional[str] = Field(None, description="自定义关系名称")
|
||||
intimacy_level: int = Field(50, ge=0, le=100, description="亲密度:0-100")
|
||||
status: str = Field("active", description="状态:active/broken/past/complicated")
|
||||
description: Optional[str] = Field(None, description="关系描述")
|
||||
started_at: Optional[str] = Field(None, description="关系开始时间(故事时间)")
|
||||
ended_at: Optional[str] = Field(None, description="关系结束时间")
|
||||
|
||||
|
||||
class CharacterRelationshipCreate(CharacterRelationshipBase):
|
||||
"""创建角色关系的请求模型"""
|
||||
project_id: str = Field(..., description="项目ID")
|
||||
character_from_id: str = Field(..., description="角色A的ID")
|
||||
character_to_id: str = Field(..., description="角色B的ID")
|
||||
|
||||
|
||||
class CharacterRelationshipUpdate(BaseModel):
|
||||
"""更新角色关系的请求模型"""
|
||||
relationship_type_id: Optional[int] = None
|
||||
relationship_name: Optional[str] = None
|
||||
intimacy_level: Optional[int] = Field(None, ge=0, le=100)
|
||||
status: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
started_at: Optional[str] = None
|
||||
ended_at: Optional[str] = None
|
||||
|
||||
|
||||
class CharacterRelationshipResponse(CharacterRelationshipBase):
|
||||
"""角色关系响应模型"""
|
||||
id: str
|
||||
project_id: str
|
||||
character_from_id: str
|
||||
character_to_id: str
|
||||
source: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class RelationshipGraphNode(BaseModel):
|
||||
"""关系图谱节点"""
|
||||
id: str
|
||||
name: str
|
||||
type: str # character / organization
|
||||
role_type: Optional[str] = None
|
||||
avatar: Optional[str] = None
|
||||
|
||||
|
||||
class RelationshipGraphLink(BaseModel):
|
||||
"""关系图谱连线"""
|
||||
source: str
|
||||
target: str
|
||||
relationship: str
|
||||
intimacy: int
|
||||
status: str
|
||||
|
||||
|
||||
class RelationshipGraphData(BaseModel):
|
||||
"""关系图谱数据"""
|
||||
nodes: List[RelationshipGraphNode]
|
||||
links: List[RelationshipGraphLink]
|
||||
|
||||
|
||||
# ============ 组织相关 ============
|
||||
|
||||
class OrganizationBase(BaseModel):
|
||||
"""组织基础模型"""
|
||||
parent_org_id: Optional[str] = Field(None, description="父组织ID")
|
||||
level: int = Field(0, description="组织层级")
|
||||
power_level: int = Field(50, ge=0, le=100, description="势力等级")
|
||||
location: Optional[str] = Field(None, description="所在地")
|
||||
motto: Optional[str] = Field(None, description="组织宗旨")
|
||||
color: Optional[str] = Field(None, description="代表颜色")
|
||||
|
||||
|
||||
class OrganizationCreate(OrganizationBase):
|
||||
"""创建组织的请求模型"""
|
||||
character_id: str = Field(..., description="关联的角色ID(组织记录)")
|
||||
project_id: str = Field(..., description="项目ID")
|
||||
|
||||
|
||||
class OrganizationUpdate(BaseModel):
|
||||
"""更新组织的请求模型"""
|
||||
parent_org_id: Optional[str] = None
|
||||
level: Optional[int] = None
|
||||
power_level: Optional[int] = Field(None, ge=0, le=100)
|
||||
location: Optional[str] = None
|
||||
motto: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
|
||||
|
||||
class OrganizationResponse(OrganizationBase):
|
||||
"""组织响应模型"""
|
||||
id: str
|
||||
character_id: str
|
||||
project_id: str
|
||||
member_count: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class OrganizationDetailResponse(BaseModel):
|
||||
"""组织详情响应(包含基本信息)"""
|
||||
id: str
|
||||
character_id: str
|
||||
name: str
|
||||
type: Optional[str] = None
|
||||
purpose: Optional[str] = None
|
||||
member_count: int
|
||||
power_level: int
|
||||
location: Optional[str] = None
|
||||
motto: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
|
||||
|
||||
# ============ 组织成员相关 ============
|
||||
|
||||
class OrganizationMemberBase(BaseModel):
|
||||
"""组织成员基础模型"""
|
||||
position: str = Field(..., description="职位名称")
|
||||
rank: int = Field(0, description="职位等级")
|
||||
status: str = Field("active", description="状态:active/retired/expelled/deceased")
|
||||
joined_at: Optional[str] = Field(None, description="加入时间(故事时间)")
|
||||
left_at: Optional[str] = Field(None, description="离开时间")
|
||||
loyalty: int = Field(50, ge=0, le=100, description="忠诚度")
|
||||
contribution: int = Field(0, ge=0, le=100, description="贡献度")
|
||||
notes: Optional[str] = Field(None, description="备注")
|
||||
|
||||
|
||||
class OrganizationMemberCreate(OrganizationMemberBase):
|
||||
"""创建组织成员的请求模型"""
|
||||
character_id: str = Field(..., description="角色ID")
|
||||
|
||||
|
||||
class OrganizationMemberUpdate(BaseModel):
|
||||
"""更新组织成员的请求模型"""
|
||||
position: Optional[str] = None
|
||||
rank: Optional[int] = None
|
||||
status: Optional[str] = None
|
||||
joined_at: Optional[str] = None
|
||||
left_at: Optional[str] = None
|
||||
loyalty: Optional[int] = Field(None, ge=0, le=100)
|
||||
contribution: Optional[int] = Field(None, ge=0, le=100)
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class OrganizationMemberResponse(OrganizationMemberBase):
|
||||
"""组织成员响应模型"""
|
||||
id: str
|
||||
organization_id: str
|
||||
character_id: str
|
||||
source: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class OrganizationMemberDetailResponse(BaseModel):
|
||||
"""组织成员详情响应(包含角色信息)"""
|
||||
id: str
|
||||
character_id: str
|
||||
character_name: str
|
||||
position: str
|
||||
rank: int
|
||||
loyalty: int
|
||||
contribution: int
|
||||
status: str
|
||||
joined_at: Optional[str] = None
|
||||
left_at: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
@@ -0,0 +1 @@
|
||||
"""服务层模块"""
|
||||
@@ -0,0 +1,363 @@
|
||||
"""AI服务封装 - 统一的OpenAI和Claude接口"""
|
||||
from typing import Optional, AsyncGenerator, List, Dict, Any
|
||||
from openai import AsyncOpenAI
|
||||
from anthropic import AsyncAnthropic
|
||||
from app.config import settings
|
||||
from app.logger import get_logger
|
||||
import httpx
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class AIService:
|
||||
"""AI服务统一接口"""
|
||||
|
||||
def __init__(self):
|
||||
"""初始化AI客户端(优化并发性能)"""
|
||||
# 初始化OpenAI客户端
|
||||
if settings.openai_api_key:
|
||||
# 创建自定义的httpx客户端来避免proxies参数问题
|
||||
try:
|
||||
# 配置连接池限制,支持高并发
|
||||
# max_keepalive_connections: 保持活跃的连接数(提高复用率)
|
||||
# max_connections: 最大并发连接数(防止资源耗尽)
|
||||
limits = httpx.Limits(
|
||||
max_keepalive_connections=50, # 保持50个活跃连接
|
||||
max_connections=100, # 最多100个并发连接
|
||||
keepalive_expiry=30.0 # 30秒后过期未使用的连接
|
||||
)
|
||||
|
||||
# 使用httpx.AsyncClient并设置超时和连接池
|
||||
# connect: 连接超时10秒
|
||||
# read: 读取超时180秒(3分钟,适合长文本生成)
|
||||
# write: 写入超时10秒
|
||||
# pool: 连接池超时10秒
|
||||
http_client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(
|
||||
connect=10.0,
|
||||
read=180.0,
|
||||
write=10.0,
|
||||
pool=10.0
|
||||
),
|
||||
limits=limits
|
||||
)
|
||||
|
||||
client_kwargs = {
|
||||
"api_key": settings.openai_api_key,
|
||||
"http_client": http_client
|
||||
}
|
||||
|
||||
if settings.openai_base_url:
|
||||
client_kwargs["base_url"] = settings.openai_base_url
|
||||
|
||||
self.openai_client = AsyncOpenAI(**client_kwargs)
|
||||
logger.info("✅ OpenAI客户端初始化成功")
|
||||
logger.info(" - 超时设置:连接10s,读取180s")
|
||||
logger.info(" - 连接池:50个保活连接,最大100个并发")
|
||||
except Exception as e:
|
||||
logger.error(f"OpenAI客户端初始化失败: {e}")
|
||||
self.openai_client = None
|
||||
else:
|
||||
self.openai_client = None
|
||||
logger.warning("OpenAI API key未配置")
|
||||
|
||||
# 初始化Anthropic客户端
|
||||
if settings.anthropic_api_key:
|
||||
try:
|
||||
# 为Anthropic设置相同的超时和连接池配置
|
||||
limits = httpx.Limits(
|
||||
max_keepalive_connections=50,
|
||||
max_connections=100,
|
||||
keepalive_expiry=30.0
|
||||
)
|
||||
|
||||
http_client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(
|
||||
connect=10.0,
|
||||
read=180.0,
|
||||
write=10.0,
|
||||
pool=10.0
|
||||
),
|
||||
limits=limits
|
||||
)
|
||||
|
||||
client_kwargs = {
|
||||
"api_key": settings.anthropic_api_key,
|
||||
"http_client": http_client
|
||||
}
|
||||
|
||||
if settings.anthropic_base_url:
|
||||
client_kwargs["base_url"] = settings.anthropic_base_url
|
||||
|
||||
self.anthropic_client = AsyncAnthropic(**client_kwargs)
|
||||
logger.info("✅ Anthropic客户端初始化成功")
|
||||
logger.info(" - 超时设置:连接10s,读取180s")
|
||||
logger.info(" - 连接池:50个保活连接,最大100个并发")
|
||||
except Exception as e:
|
||||
logger.error(f"Anthropic客户端初始化失败: {e}")
|
||||
self.anthropic_client = None
|
||||
else:
|
||||
self.anthropic_client = None
|
||||
logger.warning("Anthropic API key未配置")
|
||||
|
||||
async def generate_text(
|
||||
self,
|
||||
prompt: str,
|
||||
provider: Optional[str] = None,
|
||||
model: Optional[str] = None,
|
||||
temperature: Optional[float] = None,
|
||||
max_tokens: Optional[int] = None,
|
||||
system_prompt: Optional[str] = None
|
||||
) -> str:
|
||||
"""
|
||||
生成文本
|
||||
|
||||
Args:
|
||||
prompt: 用户提示词
|
||||
provider: AI提供商 (openai/anthropic)
|
||||
model: 模型名称
|
||||
temperature: 温度参数
|
||||
max_tokens: 最大token数
|
||||
system_prompt: 系统提示词
|
||||
|
||||
Returns:
|
||||
生成的文本
|
||||
"""
|
||||
provider = provider or settings.default_ai_provider
|
||||
model = model or settings.default_model
|
||||
temperature = temperature or settings.default_temperature
|
||||
max_tokens = max_tokens or settings.default_max_tokens
|
||||
|
||||
if provider == "openai":
|
||||
return await self._generate_openai(
|
||||
prompt, model, temperature, max_tokens, system_prompt
|
||||
)
|
||||
elif provider == "anthropic":
|
||||
return await self._generate_anthropic(
|
||||
prompt, model, temperature, max_tokens, system_prompt
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"不支持的AI提供商: {provider}")
|
||||
|
||||
async def generate_text_stream(
|
||||
self,
|
||||
prompt: str,
|
||||
provider: Optional[str] = None,
|
||||
model: Optional[str] = None,
|
||||
temperature: Optional[float] = None,
|
||||
max_tokens: Optional[int] = None,
|
||||
system_prompt: Optional[str] = None
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""
|
||||
流式生成文本
|
||||
|
||||
Args:
|
||||
prompt: 用户提示词
|
||||
provider: AI提供商
|
||||
model: 模型名称
|
||||
temperature: 温度参数
|
||||
max_tokens: 最大token数
|
||||
system_prompt: 系统提示词
|
||||
|
||||
Yields:
|
||||
生成的文本片段
|
||||
"""
|
||||
provider = provider or settings.default_ai_provider
|
||||
model = model or settings.default_model
|
||||
temperature = temperature or settings.default_temperature
|
||||
max_tokens = max_tokens or settings.default_max_tokens
|
||||
|
||||
if provider == "openai":
|
||||
async for chunk in self._generate_openai_stream(
|
||||
prompt, model, temperature, max_tokens, system_prompt
|
||||
):
|
||||
yield chunk
|
||||
elif provider == "anthropic":
|
||||
async for chunk in self._generate_anthropic_stream(
|
||||
prompt, model, temperature, max_tokens, system_prompt
|
||||
):
|
||||
yield chunk
|
||||
else:
|
||||
raise ValueError(f"不支持的AI提供商: {provider}")
|
||||
|
||||
async def _generate_openai(
|
||||
self,
|
||||
prompt: str,
|
||||
model: str,
|
||||
temperature: float,
|
||||
max_tokens: int,
|
||||
system_prompt: Optional[str]
|
||||
) -> str:
|
||||
"""使用OpenAI生成文本"""
|
||||
if not self.openai_client:
|
||||
raise ValueError("OpenAI客户端未初始化,请检查API key配置")
|
||||
|
||||
messages = []
|
||||
if system_prompt:
|
||||
messages.append({"role": "system", "content": system_prompt})
|
||||
messages.append({"role": "user", "content": prompt})
|
||||
|
||||
try:
|
||||
logger.info(f"🔵 开始调用OpenAI API")
|
||||
logger.info(f" - 模型: {model}")
|
||||
logger.info(f" - 温度: {temperature}")
|
||||
logger.info(f" - 最大tokens: {max_tokens}")
|
||||
logger.info(f" - Prompt长度: {len(prompt)} 字符")
|
||||
logger.info(f" - 消息数量: {len(messages)}")
|
||||
|
||||
response = await self.openai_client.chat.completions.create(
|
||||
model=model,
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens
|
||||
)
|
||||
|
||||
logger.info(f"✅ OpenAI API调用成功")
|
||||
logger.info(f" - 响应ID: {response.id if hasattr(response, 'id') else 'N/A'}")
|
||||
logger.info(f" - 选项数量: {len(response.choices)}")
|
||||
|
||||
if not response.choices:
|
||||
logger.error("❌ OpenAI返回的choices为空")
|
||||
return ""
|
||||
|
||||
content = response.choices[0].message.content
|
||||
logger.info(f" - 返回内容长度: {len(content) if content else 0} 字符")
|
||||
|
||||
if content:
|
||||
logger.info(f" - 返回内容预览(前200字符): {content[:200]}")
|
||||
return content
|
||||
else:
|
||||
logger.error("❌ OpenAI返回了空内容")
|
||||
logger.error(f" - 完整响应: {response}")
|
||||
raise ValueError("AI返回了空内容,请检查API配置或稍后重试")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ OpenAI API调用失败")
|
||||
logger.error(f" - 错误类型: {type(e).__name__}")
|
||||
logger.error(f" - 错误信息: {str(e)}")
|
||||
logger.error(f" - 模型: {model}")
|
||||
raise
|
||||
|
||||
async def _generate_openai_stream(
|
||||
self,
|
||||
prompt: str,
|
||||
model: str,
|
||||
temperature: float,
|
||||
max_tokens: int,
|
||||
system_prompt: Optional[str]
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""使用OpenAI流式生成文本"""
|
||||
if not self.openai_client:
|
||||
raise ValueError("OpenAI客户端未初始化,请检查API key配置")
|
||||
|
||||
messages = []
|
||||
if system_prompt:
|
||||
messages.append({"role": "system", "content": system_prompt})
|
||||
messages.append({"role": "user", "content": prompt})
|
||||
|
||||
try:
|
||||
logger.info(f"🔵 开始调用OpenAI流式API")
|
||||
logger.info(f" - 模型: {model}")
|
||||
logger.info(f" - Prompt长度: {len(prompt)} 字符")
|
||||
logger.info(f" - 最大tokens: {max_tokens}")
|
||||
|
||||
stream = await self.openai_client.chat.completions.create(
|
||||
model=model,
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
stream=True
|
||||
)
|
||||
|
||||
logger.info(f"✅ OpenAI流式API连接成功,开始接收数据...")
|
||||
|
||||
chunk_count = 0
|
||||
async for chunk in stream:
|
||||
if chunk.choices and len(chunk.choices) > 0:
|
||||
if chunk.choices[0].delta.content:
|
||||
chunk_count += 1
|
||||
yield chunk.choices[0].delta.content
|
||||
|
||||
logger.info(f"✅ OpenAI流式生成完成,共接收 {chunk_count} 个chunk")
|
||||
|
||||
except httpx.TimeoutException as e:
|
||||
logger.error(f"❌ OpenAI流式API超时")
|
||||
logger.error(f" - 错误: {str(e)}")
|
||||
logger.error(f" - 提示: 请检查网络连接或考虑缩短prompt长度")
|
||||
raise TimeoutError(f"AI服务超时(180秒),请稍后重试或减少上下文长度") from e
|
||||
except Exception as e:
|
||||
logger.error(f"❌ OpenAI流式API调用失败: {str(e)}")
|
||||
logger.error(f" - 错误类型: {type(e).__name__}")
|
||||
raise
|
||||
|
||||
async def _generate_anthropic(
|
||||
self,
|
||||
prompt: str,
|
||||
model: str,
|
||||
temperature: float,
|
||||
max_tokens: int,
|
||||
system_prompt: Optional[str]
|
||||
) -> str:
|
||||
"""使用Anthropic生成文本"""
|
||||
if not self.anthropic_client:
|
||||
raise ValueError("Anthropic客户端未初始化,请检查API key配置")
|
||||
|
||||
try:
|
||||
response = await self.anthropic_client.messages.create(
|
||||
model=model,
|
||||
max_tokens=max_tokens,
|
||||
temperature=temperature,
|
||||
system=system_prompt or "",
|
||||
messages=[{"role": "user", "content": prompt}]
|
||||
)
|
||||
return response.content[0].text
|
||||
except Exception as e:
|
||||
logger.error(f"Anthropic API调用失败: {str(e)}")
|
||||
raise
|
||||
|
||||
async def _generate_anthropic_stream(
|
||||
self,
|
||||
prompt: str,
|
||||
model: str,
|
||||
temperature: float,
|
||||
max_tokens: int,
|
||||
system_prompt: Optional[str]
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""使用Anthropic流式生成文本"""
|
||||
if not self.anthropic_client:
|
||||
raise ValueError("Anthropic客户端未初始化,请检查API key配置")
|
||||
|
||||
try:
|
||||
logger.info(f"🔵 开始调用Anthropic流式API")
|
||||
logger.info(f" - 模型: {model}")
|
||||
logger.info(f" - Prompt长度: {len(prompt)} 字符")
|
||||
logger.info(f" - 最大tokens: {max_tokens}")
|
||||
|
||||
async with self.anthropic_client.messages.stream(
|
||||
model=model,
|
||||
max_tokens=max_tokens,
|
||||
temperature=temperature,
|
||||
system=system_prompt or "",
|
||||
messages=[{"role": "user", "content": prompt}]
|
||||
) as stream:
|
||||
logger.info(f"✅ Anthropic流式API连接成功,开始接收数据...")
|
||||
|
||||
chunk_count = 0
|
||||
async for text in stream.text_stream:
|
||||
chunk_count += 1
|
||||
yield text
|
||||
|
||||
logger.info(f"✅ Anthropic流式生成完成,共接收 {chunk_count} 个chunk")
|
||||
|
||||
except httpx.TimeoutException as e:
|
||||
logger.error(f"❌ Anthropic流式API超时")
|
||||
logger.error(f" - 错误: {str(e)}")
|
||||
raise TimeoutError(f"AI服务超时(180秒),请稍后重试或减少上下文长度") from e
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Anthropic流式API调用失败: {str(e)}")
|
||||
logger.error(f" - 错误类型: {type(e).__name__}")
|
||||
raise
|
||||
|
||||
|
||||
# 创建全局AI服务实例
|
||||
ai_service = AIService()
|
||||
@@ -0,0 +1,149 @@
|
||||
"""
|
||||
LinuxDO OAuth2 服务
|
||||
"""
|
||||
import httpx
|
||||
import secrets
|
||||
from typing import Optional, Dict, Any
|
||||
from app.config import settings
|
||||
|
||||
|
||||
class LinuxDOOAuthService:
|
||||
"""LinuxDO OAuth2 服务类"""
|
||||
|
||||
# LinuxDO OAuth2 端点
|
||||
AUTHORIZE_URL = "https://connect.linux.do/oauth2/authorize"
|
||||
TOKEN_URL = "https://connect.linux.do/oauth2/token"
|
||||
USERINFO_URL = "https://connect.linux.do/api/user" # 修复:使用正确的用户信息端点
|
||||
|
||||
def __init__(self):
|
||||
self.client_id = settings.LINUXDO_CLIENT_ID
|
||||
self.client_secret = settings.LINUXDO_CLIENT_SECRET
|
||||
self.redirect_uri = settings.LINUXDO_REDIRECT_URI
|
||||
|
||||
# 验证redirect_uri配置
|
||||
if not self.redirect_uri:
|
||||
raise ValueError(
|
||||
"LINUXDO_REDIRECT_URI 未配置!\n"
|
||||
"请在 .env 文件中设置正确的回调地址:\n"
|
||||
"本地开发: LINUXDO_REDIRECT_URI=http://localhost:8000/api/auth/callback\n"
|
||||
"Docker部署: LINUXDO_REDIRECT_URI=https://your-domain.com/api/auth/callback"
|
||||
)
|
||||
|
||||
# 警告:检查是否使用了localhost(在非开发环境)
|
||||
if not settings.debug and "localhost" in self.redirect_uri.lower():
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning(
|
||||
f"⚠️ 生产环境检测到使用 localhost 作为回调地址: {self.redirect_uri}\n"
|
||||
"这可能导致OAuth回调失败!请使用实际的域名或服务器IP。"
|
||||
)
|
||||
|
||||
def generate_state(self) -> str:
|
||||
"""生成随机 state 参数"""
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
def get_authorization_url(self, state: str) -> str:
|
||||
"""
|
||||
获取授权 URL
|
||||
|
||||
Args:
|
||||
state: 随机 state 参数
|
||||
|
||||
Returns:
|
||||
授权 URL
|
||||
"""
|
||||
params = {
|
||||
"client_id": self.client_id,
|
||||
"redirect_uri": self.redirect_uri,
|
||||
"response_type": "code",
|
||||
"scope": "read",
|
||||
"state": state
|
||||
}
|
||||
|
||||
query_string = "&".join([f"{k}={v}" for k, v in params.items()])
|
||||
return f"{self.AUTHORIZE_URL}?{query_string}"
|
||||
|
||||
async def get_access_token(self, code: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
使用授权码获取访问令牌
|
||||
|
||||
Args:
|
||||
code: 授权码
|
||||
|
||||
Returns:
|
||||
包含 access_token 的字典,失败返回 None
|
||||
"""
|
||||
data = {
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret,
|
||||
"code": code,
|
||||
"grant_type": "authorization_code",
|
||||
"redirect_uri": self.redirect_uri
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
self.TOKEN_URL,
|
||||
data=data,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
print(f"获取访问令牌失败: {response.status_code} {response.text}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
print(f"获取访问令牌异常: {e}")
|
||||
return None
|
||||
|
||||
async def get_user_info(self, access_token: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
使用访问令牌获取用户信息
|
||||
|
||||
Args:
|
||||
access_token: 访问令牌
|
||||
|
||||
Returns:
|
||||
用户信息字典,失败返回 None
|
||||
"""
|
||||
try:
|
||||
# 添加真实浏览器请求头,避免被 Cloudflare 拦截
|
||||
headers = {
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
"Accept": "application/json",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
||||
}
|
||||
|
||||
# 不自动处理编码,让 httpx 自动解压
|
||||
async with httpx.AsyncClient(follow_redirects=True, timeout=30.0) as client:
|
||||
response = await client.get(
|
||||
self.USERINFO_URL,
|
||||
headers=headers
|
||||
)
|
||||
|
||||
print(f"获取用户信息响应状态: {response.status_code}")
|
||||
print(f"响应头: {response.headers}")
|
||||
|
||||
if response.status_code == 200:
|
||||
try:
|
||||
user_data = response.json()
|
||||
print(f"用户信息: {user_data}")
|
||||
return user_data
|
||||
except Exception as json_error:
|
||||
print(f"解析 JSON 失败: {json_error}")
|
||||
print(f"响应内容前100字符: {response.text[:100]}")
|
||||
return None
|
||||
else:
|
||||
print(f"获取用户信息失败: {response.status_code}")
|
||||
print(f"响应内容: {response.text[:200]}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
print(f"获取用户信息异常: {type(e).__name__}: {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return None
|
||||
@@ -0,0 +1,730 @@
|
||||
"""提示词管理服务"""
|
||||
from typing import Dict, Any
|
||||
import json
|
||||
|
||||
|
||||
class PromptService:
|
||||
"""提示词模板管理"""
|
||||
|
||||
# 世界构建提示词
|
||||
WORLD_BUILDING = """你是一位资深的世界观设计师。请根据以下信息构建一个完整的小说世界观:
|
||||
|
||||
书名:{title}
|
||||
主题:{theme}
|
||||
类型:{genre}
|
||||
|
||||
请生成包含以下内容的世界构建框架:
|
||||
|
||||
1. **时间背景**:具体的时代设定、时间流逝特点、重要历史事件
|
||||
2. **地理位置**:主要地点描述、地理环境特征、空间布局
|
||||
3. **氛围基调**:整体氛围感觉、情感色彩、视觉风格
|
||||
4. **世界规则**:基本运行法则、特殊设定、社会规则和禁忌、权力结构
|
||||
|
||||
要求:
|
||||
- 与主题高度契合
|
||||
- 设定要合理自洽
|
||||
- 为故事发展提供支撑
|
||||
- 具有独特性和吸引力
|
||||
|
||||
**重要:你必须只返回纯JSON格式,不要包含任何markdown标记、代码块标记或其他说明文字。**
|
||||
|
||||
请严格按照以下JSON格式返回(每个字段为200-300字的文本描述):
|
||||
{{
|
||||
"time_period": "时间背景的详细描述,包括时代设定、时间特点、历史事件",
|
||||
"location": "地理位置的详细描述,包括主要地点、环境特征、空间布局",
|
||||
"atmosphere": "氛围基调的详细描述,包括整体氛围、情感色彩、视觉风格",
|
||||
"rules": "世界规则的详细描述,包括运行法则、特殊设定、社会规则、权力结构"
|
||||
}}
|
||||
|
||||
再次强调:只返回纯JSON对象,不要有```json```这样的标记,不要有任何额外的文字说明。"""
|
||||
|
||||
# 批量角色生成提示词
|
||||
CHARACTERS_BATCH_GENERATION = """你是一位专业的角色设定师。请根据以下世界观和要求,生成{count}个立体丰满的角色和组织:
|
||||
|
||||
世界观信息:
|
||||
- 时间背景:{time_period}
|
||||
- 地理位置:{location}
|
||||
- 氛围基调:{atmosphere}
|
||||
- 世界规则:{rules}
|
||||
|
||||
主题:{theme}
|
||||
类型:{genre}
|
||||
特殊要求:{requirements}
|
||||
|
||||
【数量要求 - 必须严格遵守】
|
||||
请精确生成{count}个实体,不多不少。数组中必须包含且仅包含{count}个对象。
|
||||
|
||||
实体类型分配:
|
||||
- 至少1个主角(protagonist)
|
||||
- 多个配角(supporting)
|
||||
- 可以包含反派(antagonist)
|
||||
- 可以包含1-2个重要组织
|
||||
|
||||
要求:
|
||||
- 角色要符合世界观设定
|
||||
- 性格和背景要有深度
|
||||
- 角色之间要有关系网络
|
||||
- 组织要有存在的合理性
|
||||
- 所有实体要为故事服务
|
||||
|
||||
**重要:你必须只返回纯JSON数组格式,不要包含任何markdown标记、代码块标记或其他说明文字。**
|
||||
|
||||
请严格按照以下JSON数组格式返回(每个角色为数组中的一个对象):
|
||||
[
|
||||
{{
|
||||
"name": "角色姓名",
|
||||
"age": 25,
|
||||
"gender": "男/女/其他",
|
||||
"is_organization": false,
|
||||
"role_type": "protagonist/supporting/antagonist",
|
||||
"personality": "性格特点的详细描述(100-200字),包括核心性格、优缺点、特殊习惯",
|
||||
"background": "背景故事的详细描述(100-200字),包括家庭背景、成长经历、重要转折",
|
||||
"appearance": "外貌描述(50-100字),包括身高、体型、面容、着装风格",
|
||||
"traits": ["特长1", "特长2", "特长3"],
|
||||
"relationships_array": [
|
||||
{{
|
||||
"target_character_name": "已生成的角色名称",
|
||||
"relationship_type": "关系类型(师父/朋友/敌人/父亲/母亲等)",
|
||||
"intimacy_level": 75,
|
||||
"description": "关系描述"
|
||||
}}
|
||||
],
|
||||
"organization_memberships": [
|
||||
{{
|
||||
"organization_name": "已生成的组织名称",
|
||||
"position": "职位",
|
||||
"rank": 5,
|
||||
"loyalty": 80
|
||||
}}
|
||||
]
|
||||
}},
|
||||
{{
|
||||
"name": "组织名称",
|
||||
"is_organization": true,
|
||||
"role_type": "supporting",
|
||||
"personality": "组织特性描述(100-200字),包括运作方式、核心理念、行事风格",
|
||||
"background": "组织背景(100-200字),包括建立历史、发展历程、重要事件",
|
||||
"appearance": "组织外在表现(50-100字),如总部位置、标志性建筑等",
|
||||
"organization_type": "组织类型",
|
||||
"organization_purpose": "组织目的",
|
||||
"organization_members": ["成员1", "成员2"],
|
||||
"traits": []
|
||||
}}
|
||||
]
|
||||
|
||||
**关系类型参考(从中选择或自定义):**
|
||||
- 家族:父亲、母亲、兄弟、姐妹、子女、配偶、恋人
|
||||
- 社交:师父、徒弟、朋友、同学、同事、邻居、知己
|
||||
- 职业:上司、下属、合作伙伴
|
||||
- 敌对:敌人、仇人、竞争对手、宿敌
|
||||
|
||||
**重要说明:**
|
||||
1. **数量控制**:数组中必须精确包含{count}个对象,不能多也不能少
|
||||
2. **关系约束**:relationships_array只能引用本批次中已经出现的角色名称
|
||||
3. **组织约束**:organization_memberships只能引用本批次中is_organization=true的实体名称
|
||||
4. **禁止幻觉**:不要引用任何不存在的角色或组织,如果没有可引用的就留空数组[]
|
||||
5. intimacy_level和loyalty都是0-100的整数
|
||||
6. 角色之间要形成合理的关系网络
|
||||
|
||||
**示例说明**:
|
||||
- 如果生成了角色A、组织B、角色C,则角色A的organization_memberships只能是[组织B],不能是其他组织
|
||||
- 如果角色A在数组第一位,它的relationships_array必须为空[],因为还没有其他角色
|
||||
- 如果角色C在数组第三位,它的relationships_array可以引用角色A,但不能引用不存在的角色D
|
||||
|
||||
再次强调:
|
||||
1. 只返回纯JSON数组,不要有```json```这样的标记
|
||||
2. 数组中必须精确包含{count}个对象
|
||||
3. 不要引用任何本批次中不存在的角色或组织名称"""
|
||||
|
||||
# 完整大纲生成提示词
|
||||
COMPLETE_OUTLINE_GENERATION = """你是一位经验丰富的小说作家和编剧。请根据以下信息生成完整的{chapter_count}章小说大纲:
|
||||
|
||||
基本信息:
|
||||
- 书名:{title}
|
||||
- 主题:{theme}
|
||||
- 类型:{genre}
|
||||
- 章节数:{chapter_count}
|
||||
- 叙事视角:{narrative_perspective}
|
||||
- 目标字数:{target_words}
|
||||
|
||||
世界观:
|
||||
- 时间背景:{time_period}
|
||||
- 地理位置:{location}
|
||||
- 氛围基调:{atmosphere}
|
||||
- 世界规则:{rules}
|
||||
|
||||
角色信息:
|
||||
{characters_info}
|
||||
|
||||
其他要求:{requirements}
|
||||
|
||||
整体要求:
|
||||
- 结构完整:起承转合清晰
|
||||
- 情节连贯:章节之间紧密衔接
|
||||
- 冲突递进:矛盾逐步升级
|
||||
- 人物成长:角色有明确的变化弧线
|
||||
- 节奏把控:有张有弛
|
||||
- 视角统一:采用{narrative_perspective}视角叙事
|
||||
|
||||
**重要:你必须只返回纯JSON数组格式,不要包含任何markdown标记、代码块标记或其他说明文字。**
|
||||
|
||||
请严格按照以下JSON数组格式返回(共{chapter_count}个章节对象):
|
||||
[
|
||||
{{
|
||||
"chapter_number": 1,
|
||||
"title": "第一章标题",
|
||||
"summary": "章节概要的详细描述(100-200字),包含主要情节、冲突、转折等",
|
||||
"scenes": ["场景1描述", "场景2描述", "场景3描述"],
|
||||
"characters": ["角色1", "角色2"],
|
||||
"key_points": ["情节要点1", "情节要点2"],
|
||||
"emotion": "本章情感基调",
|
||||
"goal": "本章叙事目标"
|
||||
}},
|
||||
{{
|
||||
"chapter_number": 2,
|
||||
"title": "第二章标题",
|
||||
"summary": "章节概要...",
|
||||
"scenes": ["场景1", "场景2"],
|
||||
"characters": ["角色1", "角色2"],
|
||||
"key_points": ["要点1", "要点2"],
|
||||
"emotion": "情感基调",
|
||||
"goal": "叙事目标"
|
||||
}}
|
||||
]
|
||||
|
||||
再次强调:只返回纯JSON数组,不要有```json```这样的标记,不要有任何额外的文字说明。数组中要包含{chapter_count}个章节对象。"""
|
||||
|
||||
# 大纲续写提示词
|
||||
OUTLINE_CONTINUE_GENERATION = """你是一位经验丰富的小说作家和编剧。请基于以下信息续写小说大纲:
|
||||
|
||||
【项目信息】
|
||||
- 书名:{title}
|
||||
- 主题:{theme}
|
||||
- 类型:{genre}
|
||||
- 叙事视角:{narrative_perspective}
|
||||
- 续写章节数:{chapter_count}章
|
||||
|
||||
【世界观】
|
||||
- 时间背景:{time_period}
|
||||
- 地理位置:{location}
|
||||
- 氛围基调:{atmosphere}
|
||||
- 世界规则:{rules}
|
||||
|
||||
【角色信息】
|
||||
{characters_info}
|
||||
|
||||
【已有章节概览】(共{current_chapter_count}章)
|
||||
{all_chapters_brief}
|
||||
|
||||
【最近剧情】
|
||||
{recent_plot}
|
||||
|
||||
【续写指导】
|
||||
- 当前情节阶段:{plot_stage_instruction}
|
||||
- 起始章节编号:第{start_chapter}章
|
||||
- 故事发展方向:{story_direction}
|
||||
- 其他要求:{requirements}
|
||||
|
||||
请生成第{start_chapter}章到第{end_chapter}章的大纲。
|
||||
要求:
|
||||
- 与前文自然衔接,保持故事连贯性
|
||||
- 遵循情节阶段的发展要求
|
||||
- 保持与已有章节相同的风格和详细程度
|
||||
- 推进角色成长和情节发展
|
||||
|
||||
**重要:你必须只返回纯JSON数组格式,不要包含任何markdown标记、代码块标记或其他说明文字。**
|
||||
|
||||
请严格按照以下JSON数组格式返回(共{chapter_count}个章节对象):
|
||||
[
|
||||
{{
|
||||
"chapter_number": {start_chapter},
|
||||
"title": "章节标题",
|
||||
"summary": "章节概要的详细描述(100-200字),包含主要情节、角色互动、关键事件、冲突与转折",
|
||||
"scenes": ["场景1描述", "场景2描述", "场景3描述"],
|
||||
"characters": ["涉及角色1", "涉及角色2"],
|
||||
"key_points": ["情节要点1", "情节要点2"],
|
||||
"emotion": "本章情感基调",
|
||||
"goal": "本章叙事目标"
|
||||
}},
|
||||
{{
|
||||
"chapter_number": {start_chapter} + 1,
|
||||
"title": "章节标题",
|
||||
"summary": "章节概要...",
|
||||
"scenes": ["场景1", "场景2"],
|
||||
"characters": ["角色1", "角色2"],
|
||||
"key_points": ["要点1", "要点2"],
|
||||
"emotion": "情感基调",
|
||||
"goal": "叙事目标"
|
||||
}}
|
||||
]
|
||||
|
||||
再次强调:
|
||||
1. 只返回纯JSON数组,不要有```json```这样的标记
|
||||
2. 数组中要包含{chapter_count}个章节对象
|
||||
3. 每个summary必须是100-200字的详细描述
|
||||
4. 确保字段结构与已有章节完全一致"""
|
||||
|
||||
# AI去味提示词(核心特色功能)
|
||||
AI_DENOISING = """你是一位追求自然写作风格的编辑。你的任务是将AI生成的文本改写得更像人类作家的手笔。
|
||||
|
||||
原文:
|
||||
{original_text}
|
||||
|
||||
修改要求:
|
||||
1. 去除AI痕迹:
|
||||
- 删除过于工整的排比句
|
||||
- 减少重复的修辞手法
|
||||
- 去掉刻意的对称结构
|
||||
- 避免机械式的总结陈词
|
||||
|
||||
2. 增加人性化:
|
||||
- 使用更口语化的表达
|
||||
- 添加不完美的细节
|
||||
- 保留适度的随意性
|
||||
- 增加真实的情感波动
|
||||
|
||||
3. 优化叙事:
|
||||
- 让节奏更自然不做作
|
||||
- 用简单词汇替换华丽辞藻
|
||||
- 保持叙述的松弛感
|
||||
- 让对话更生活化
|
||||
|
||||
4. 保持原意:
|
||||
- 不改变核心情节
|
||||
- 保留关键信息点
|
||||
- 维持角色性格
|
||||
- 确保逻辑连贯
|
||||
|
||||
修改风格:
|
||||
- 像是一个喜欢讲故事的普通人写的
|
||||
- 有点粗糙但很真诚
|
||||
- 自然流畅不刻意
|
||||
- 让人读起来很舒服
|
||||
|
||||
请直接输出修改后的文本,无需解释。"""
|
||||
|
||||
# 章节完整创作提示词
|
||||
CHAPTER_GENERATION = """你是一位专业的小说作家。请根据以下信息创作本章内容:
|
||||
|
||||
项目信息:
|
||||
- 书名:{title}
|
||||
- 主题:{theme}
|
||||
- 类型:{genre}
|
||||
- 叙事视角:{narrative_perspective}
|
||||
|
||||
世界观:
|
||||
- 时间背景:{time_period}
|
||||
- 地理位置:{location}
|
||||
- 氛围基调:{atmosphere}
|
||||
- 世界规则:{rules}
|
||||
|
||||
角色信息:
|
||||
{characters_info}
|
||||
|
||||
全书大纲:
|
||||
{outlines_context}
|
||||
|
||||
本章信息:
|
||||
- 章节序号:第{chapter_number}章
|
||||
- 章节标题:{chapter_title}
|
||||
- 章节大纲:{chapter_outline}
|
||||
|
||||
创作要求:
|
||||
1. 严格按照大纲内容展开情节
|
||||
2. 保持与前后章节的连贯性
|
||||
3. 符合角色性格设定
|
||||
4. 体现世界观特色
|
||||
5. 使用{narrative_perspective}视角
|
||||
6. 字数不得低于3000字
|
||||
7. 语言自然流畅,避免AI痕迹
|
||||
|
||||
**写作风格要求(重要):**
|
||||
- 让故事自然流淌,写到哪算哪
|
||||
- 结尾处直接结束情节,不要加总结性段落
|
||||
- 不要在章节末尾写"这一天/这一夜就这样过去了"之类的总结句
|
||||
- 不要用"他/她陷入了沉思"作为结尾
|
||||
- 避免刻意的情感升华或哲理感悟收尾
|
||||
- 章节结尾可以戛然而止,可以是对话,可以是动作,可以是悬念
|
||||
- 就像在讲一个故事,讲完了就停,不需要画龙点睛
|
||||
|
||||
请直接输出章节正文内容,不要包含章节标题和其他说明文字。"""
|
||||
|
||||
# 章节完整创作提示词(带前置章节上下文)
|
||||
CHAPTER_GENERATION_WITH_CONTEXT = """你是一位专业的小说作家。请根据以下信息创作本章内容:
|
||||
|
||||
项目信息:
|
||||
- 书名:{title}
|
||||
- 主题:{theme}
|
||||
- 类型:{genre}
|
||||
- 叙事视角:{narrative_perspective}
|
||||
|
||||
世界观:
|
||||
- 时间背景:{time_period}
|
||||
- 地理位置:{location}
|
||||
- 氛围基调:{atmosphere}
|
||||
- 世界规则:{rules}
|
||||
|
||||
角色信息:
|
||||
{characters_info}
|
||||
|
||||
全书大纲:
|
||||
{outlines_context}
|
||||
|
||||
【已完成的前置章节内容】
|
||||
{previous_content}
|
||||
|
||||
本章信息:
|
||||
- 章节序号:第{chapter_number}章
|
||||
- 章节标题:{chapter_title}
|
||||
- 章节大纲:{chapter_outline}
|
||||
|
||||
创作要求:
|
||||
1. **剧情连贯性(最重要)**:
|
||||
- 必须承接前面章节的剧情发展
|
||||
- 注意角色状态、情节进展、时间线的连续性
|
||||
- 不能出现与前文矛盾的内容
|
||||
- 自然过渡,避免突兀的跳跃
|
||||
|
||||
2. **情节推进**:
|
||||
- 严格按照本章大纲展开情节
|
||||
- 推动故事向前发展
|
||||
- 保持与全书大纲的一致性
|
||||
|
||||
3. **角色一致性**:
|
||||
- 符合角色性格设定
|
||||
- 延续角色在前文中的成长和变化
|
||||
- 保持角色关系的连贯性
|
||||
|
||||
4. **写作风格**:
|
||||
- 使用{narrative_perspective}视角
|
||||
- 字数不得低于3000字
|
||||
- 语言自然流畅,避免AI痕迹
|
||||
- 体现世界观特色
|
||||
|
||||
5. **承上启下**:
|
||||
- 开头自然衔接上一章结尾
|
||||
- 结尾为下一章做好铺垫
|
||||
|
||||
**写作风格要求(重要):**
|
||||
- 让故事自然流淌,写到哪算哪
|
||||
- 结尾处直接结束情节,不要加总结性段落
|
||||
- 不要在章节末尾写"这一天/这一夜就这样过去了"之类的总结句
|
||||
- 不要用"他/她陷入了沉思"作为结尾
|
||||
- 避免刻意的情感升华或哲理感悟收尾
|
||||
- 章节结尾可以戛然而止,可以是对话,可以是动作,可以是悬念
|
||||
- 就像在讲一个故事,讲完了就停,不需要画龙点睛
|
||||
|
||||
请直接输出章节正文内容,不要包含章节标题和其他说明文字。"""
|
||||
|
||||
# 大纲生成提示词
|
||||
OUTLINE_GENERATION = """你是一位经验丰富的小说作家和编剧。请根据以下信息生成小说大纲:
|
||||
|
||||
类型:{genre}
|
||||
主题:{theme}
|
||||
目标字数:{target_words}
|
||||
其他要求:{requirements}
|
||||
|
||||
请生成一个完整的章节大纲框架,包含:
|
||||
1. 合理的章节数量(根据字数)
|
||||
2. 每章的标题和内容概要
|
||||
3. 清晰的故事结构(起承转合)
|
||||
4. 情节的递进和冲突升级
|
||||
5. 角色的成长弧线
|
||||
|
||||
**重要:你必须只返回纯JSON格式,不要包含任何markdown标记、代码块标记或其他说明文字。**
|
||||
|
||||
请严格按照以下JSON格式返回:
|
||||
{{
|
||||
"chapters": [
|
||||
{{
|
||||
"order": 1,
|
||||
"title": "章节标题",
|
||||
"content": "章节内容概要(150-200字)"
|
||||
}}
|
||||
]
|
||||
}}
|
||||
|
||||
再次强调:只返回纯JSON对象,不要有```json```这样的标记,不要有任何额外的文字说明。"""
|
||||
|
||||
# 单个角色生成提示词
|
||||
SINGLE_CHARACTER_GENERATION = """你是一位专业的角色设定师。请根据以下信息创建一个立体饱满的小说角色。
|
||||
|
||||
{project_context}
|
||||
|
||||
{user_input}
|
||||
|
||||
请生成一个完整的角色卡片,包含以下所有信息:
|
||||
|
||||
1. **基本信息**:
|
||||
- 姓名:如果用户未提供,请生成一个符合世界观的名字
|
||||
- 年龄:具体数字或年龄段
|
||||
- 性别:男/女/其他
|
||||
|
||||
2. **外貌特征**(100-150字):
|
||||
- 身高体型、面容特征、着装风格
|
||||
- 要符合角色定位和世界观设定
|
||||
|
||||
3. **性格特点**(150-200字):
|
||||
- 核心性格特质(至少3个)
|
||||
- 优点和缺点
|
||||
- 特殊习惯或癖好
|
||||
- 性格要有复杂性和矛盾性
|
||||
|
||||
4. **背景故事**(200-300字):
|
||||
- 家庭背景
|
||||
- 成长经历
|
||||
- 重要转折事件
|
||||
- 如何与项目主题关联
|
||||
- 融入用户提供的背景设定
|
||||
|
||||
5. **人际关系**:
|
||||
- 与现有角色的关系(如果有)
|
||||
- 重要的人际纽带
|
||||
- 社会地位和人脉
|
||||
|
||||
6. **特殊能力/特长**:
|
||||
- 擅长的领域
|
||||
- 特殊技能或知识
|
||||
- 符合世界观设定
|
||||
|
||||
**你必须只返回纯JSON格式,不要包含任何markdown标记、代码块标记或其他说明文字。**
|
||||
|
||||
请严格按照以下JSON格式返回:
|
||||
{{
|
||||
"name": "角色姓名",
|
||||
"age": "年龄",
|
||||
"gender": "性别",
|
||||
"appearance": "外貌描述(100-150字)",
|
||||
"personality": "性格特点(150-200字)",
|
||||
"background": "背景故事(200-300字)",
|
||||
"traits": ["特长1", "特长2", "特长3"],
|
||||
|
||||
"relationships_text": "人际关系的文字描述(用于显示)",
|
||||
|
||||
"relationships": [
|
||||
{{
|
||||
"target_character_name": "已存在的角色名称",
|
||||
"relationship_type": "关系类型(如:师父、朋友、敌人、父亲、母亲等)",
|
||||
"intimacy_level": 75,
|
||||
"description": "这段关系的详细描述",
|
||||
"started_at": "关系开始的故事时间点(可选)"
|
||||
}}
|
||||
],
|
||||
|
||||
"organization_memberships": [
|
||||
{{
|
||||
"organization_name": "已存在的组织名称",
|
||||
"position": "职位名称",
|
||||
"rank": 8,
|
||||
"loyalty": 80,
|
||||
"joined_at": "加入时间(可选)",
|
||||
"status": "active"
|
||||
}}
|
||||
]
|
||||
}}
|
||||
|
||||
**关系类型参考(请从中选择或自定义):**
|
||||
- 家族关系:父亲、母亲、兄弟、姐妹、子女、配偶、恋人
|
||||
- 社交关系:师父、徒弟、朋友、同学、同事、邻居、知己
|
||||
- 职业关系:上司、下属、合作伙伴
|
||||
- 敌对关系:敌人、仇人、竞争对手、宿敌
|
||||
|
||||
**重要说明:**
|
||||
1. relationships数组:只包含与上面列出的已存在角色的关系,通过target_character_name匹配
|
||||
2. organization_memberships数组:只包含与上面列出的已存在组织的关系
|
||||
3. intimacy_level和loyalty都是0-100的整数
|
||||
4. 如果没有关系或组织,对应数组为空[]
|
||||
5. relationships_text是自然语言描述,用于展示给用户看
|
||||
|
||||
**角色设定要求:**
|
||||
- 角色要符合项目的世界观和主题
|
||||
- 如果是主角,要有明确的成长空间和目标动机
|
||||
- 如果是反派,要有合理的动机,不能脸谱化
|
||||
- 配角要有独特性,不能是工具人
|
||||
- 所有设定要为故事服务
|
||||
|
||||
再次强调:只返回纯JSON对象,不要有```json```这样的标记,不要有任何额外的文字说明。"""
|
||||
|
||||
@staticmethod
|
||||
def format_prompt(template: str, **kwargs) -> str:
|
||||
"""
|
||||
格式化提示词模板
|
||||
|
||||
Args:
|
||||
template: 提示词模板
|
||||
**kwargs: 模板参数
|
||||
|
||||
Returns:
|
||||
格式化后的提示词
|
||||
"""
|
||||
try:
|
||||
return template.format(**kwargs)
|
||||
except KeyError as e:
|
||||
raise ValueError(f"缺少必需的参数: {e}")
|
||||
|
||||
@classmethod
|
||||
def get_denoising_prompt(cls, original_text: str) -> str:
|
||||
"""获取AI去味提示词"""
|
||||
return cls.format_prompt(
|
||||
cls.AI_DENOISING,
|
||||
original_text=original_text
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_world_building_prompt(cls, title: str, theme: str, genre: str = "") -> str:
|
||||
"""获取世界构建提示词"""
|
||||
return cls.format_prompt(
|
||||
cls.WORLD_BUILDING,
|
||||
title=title,
|
||||
theme=theme,
|
||||
genre=genre or "通用类型"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_characters_batch_prompt(cls, count: int, time_period: str, location: str,
|
||||
atmosphere: str, rules: str, theme: str,
|
||||
genre: str = "", requirements: str = "") -> str:
|
||||
"""获取批量角色生成提示词"""
|
||||
return cls.format_prompt(
|
||||
cls.CHARACTERS_BATCH_GENERATION,
|
||||
count=count,
|
||||
time_period=time_period,
|
||||
location=location,
|
||||
atmosphere=atmosphere,
|
||||
rules=rules,
|
||||
theme=theme,
|
||||
genre=genre or "通用类型",
|
||||
requirements=requirements or "无特殊要求"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_complete_outline_prompt(cls, title: str, theme: str, genre: str,
|
||||
chapter_count: int, narrative_perspective: str,
|
||||
target_words: int, time_period: str, location: str,
|
||||
atmosphere: str, rules: str, characters_info: str,
|
||||
requirements: str = "") -> str:
|
||||
"""获取完整大纲生成提示词"""
|
||||
return cls.format_prompt(
|
||||
cls.COMPLETE_OUTLINE_GENERATION,
|
||||
title=title,
|
||||
theme=theme,
|
||||
genre=genre,
|
||||
chapter_count=chapter_count,
|
||||
narrative_perspective=narrative_perspective,
|
||||
target_words=target_words,
|
||||
time_period=time_period,
|
||||
location=location,
|
||||
atmosphere=atmosphere,
|
||||
rules=rules,
|
||||
characters_info=characters_info,
|
||||
requirements=requirements or "无特殊要求"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_chapter_generation_prompt(cls, title: str, theme: str, genre: str,
|
||||
narrative_perspective: str, time_period: str,
|
||||
location: str, atmosphere: str, rules: str,
|
||||
characters_info: str, outlines_context: str,
|
||||
chapter_number: int, chapter_title: str,
|
||||
chapter_outline: str) -> str:
|
||||
"""获取章节完整创作提示词"""
|
||||
return cls.format_prompt(
|
||||
cls.CHAPTER_GENERATION,
|
||||
title=title,
|
||||
theme=theme,
|
||||
genre=genre,
|
||||
narrative_perspective=narrative_perspective,
|
||||
time_period=time_period,
|
||||
location=location,
|
||||
atmosphere=atmosphere,
|
||||
rules=rules,
|
||||
characters_info=characters_info,
|
||||
outlines_context=outlines_context,
|
||||
chapter_number=chapter_number,
|
||||
chapter_title=chapter_title,
|
||||
chapter_outline=chapter_outline
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_chapter_generation_with_context_prompt(cls, title: str, theme: str, genre: str,
|
||||
narrative_perspective: str, time_period: str,
|
||||
location: str, atmosphere: str, rules: str,
|
||||
characters_info: str, outlines_context: str,
|
||||
previous_content: str, chapter_number: int,
|
||||
chapter_title: str, chapter_outline: str) -> str:
|
||||
"""获取章节完整创作提示词(带前置章节上下文)"""
|
||||
return cls.format_prompt(
|
||||
cls.CHAPTER_GENERATION_WITH_CONTEXT,
|
||||
title=title,
|
||||
theme=theme,
|
||||
genre=genre,
|
||||
narrative_perspective=narrative_perspective,
|
||||
time_period=time_period,
|
||||
location=location,
|
||||
atmosphere=atmosphere,
|
||||
rules=rules,
|
||||
characters_info=characters_info,
|
||||
outlines_context=outlines_context,
|
||||
previous_content=previous_content,
|
||||
chapter_number=chapter_number,
|
||||
chapter_title=chapter_title,
|
||||
chapter_outline=chapter_outline
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_outline_prompt(cls, genre: str, theme: str, target_words: int,
|
||||
requirements: str = "") -> str:
|
||||
"""获取大纲生成提示词"""
|
||||
return cls.format_prompt(
|
||||
cls.OUTLINE_GENERATION,
|
||||
genre=genre,
|
||||
theme=theme,
|
||||
target_words=target_words,
|
||||
requirements=requirements or "无特殊要求"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_outline_continue_prompt(cls, title: str, theme: str, genre: str,
|
||||
narrative_perspective: str, chapter_count: int,
|
||||
time_period: str, location: str, atmosphere: str,
|
||||
rules: str, characters_info: str,
|
||||
current_chapter_count: int, all_chapters_brief: str,
|
||||
recent_plot: str, plot_stage_instruction: str,
|
||||
start_chapter: int, story_direction: str,
|
||||
requirements: str = "") -> str:
|
||||
"""获取大纲续写提示词"""
|
||||
end_chapter = start_chapter + chapter_count - 1
|
||||
return cls.format_prompt(
|
||||
cls.OUTLINE_CONTINUE_GENERATION,
|
||||
title=title,
|
||||
theme=theme,
|
||||
genre=genre,
|
||||
narrative_perspective=narrative_perspective,
|
||||
chapter_count=chapter_count,
|
||||
time_period=time_period,
|
||||
location=location,
|
||||
atmosphere=atmosphere,
|
||||
rules=rules,
|
||||
characters_info=characters_info,
|
||||
current_chapter_count=current_chapter_count,
|
||||
all_chapters_brief=all_chapters_brief,
|
||||
recent_plot=recent_plot,
|
||||
plot_stage_instruction=plot_stage_instruction,
|
||||
start_chapter=start_chapter,
|
||||
end_chapter=end_chapter,
|
||||
story_direction=story_direction,
|
||||
requirements=requirements or "无特殊要求"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_single_character_prompt(cls, project_context: str, user_input: str) -> str:
|
||||
"""获取单个角色生成提示词"""
|
||||
return cls.format_prompt(
|
||||
cls.SINGLE_CHARACTER_GENERATION,
|
||||
project_context=project_context,
|
||||
user_input=user_input
|
||||
)
|
||||
|
||||
|
||||
# 创建全局提示词服务实例
|
||||
prompt_service = PromptService()
|
||||
@@ -0,0 +1,294 @@
|
||||
"""
|
||||
用户管理模块 - 支持 LinuxDO OAuth2
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, List
|
||||
from pydantic import BaseModel
|
||||
from app.config import settings, DATA_DIR
|
||||
|
||||
|
||||
class User(BaseModel):
|
||||
"""用户模型"""
|
||||
user_id: str # 格式: linuxdo_{linuxdo_id}
|
||||
username: str
|
||||
display_name: str
|
||||
avatar_url: Optional[str] = None
|
||||
trust_level: int = 0 # 仅用于显示
|
||||
is_admin: bool = False # 手动设置的管理员权限
|
||||
linuxdo_id: str # LinuxDO 用户 ID
|
||||
created_at: str
|
||||
last_login: str
|
||||
|
||||
|
||||
class UserManager:
|
||||
"""用户管理器 - 线程安全版本"""
|
||||
|
||||
USERS_FILE = str(DATA_DIR / "users.json")
|
||||
ADMINS_FILE = str(DATA_DIR / "admins.json")
|
||||
|
||||
def __init__(self):
|
||||
"""初始化用户管理器"""
|
||||
# DATA_DIR 已在 config.py 中创建,无需重复创建
|
||||
# 添加文件锁保护并发读写
|
||||
self._users_lock = asyncio.Lock()
|
||||
self._admins_lock = asyncio.Lock()
|
||||
self._ensure_files_exist()
|
||||
|
||||
def _ensure_files_exist(self):
|
||||
"""确保必要的文件存在"""
|
||||
if not os.path.exists(self.USERS_FILE):
|
||||
with open(self.USERS_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump({}, f, ensure_ascii=False, indent=2)
|
||||
|
||||
if not os.path.exists(self.ADMINS_FILE):
|
||||
with open(self.ADMINS_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump({"admins": []}, f, ensure_ascii=False, indent=2)
|
||||
|
||||
def _load_users_unsafe(self) -> Dict[str, dict]:
|
||||
"""加载用户数据(不加锁,内部使用)"""
|
||||
try:
|
||||
with open(self.USERS_FILE, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
print(f"加载用户数据失败: {e}")
|
||||
return {}
|
||||
|
||||
def _save_users_unsafe(self, users: Dict[str, dict]):
|
||||
"""保存用户数据(不加锁,内部使用)"""
|
||||
try:
|
||||
with open(self.USERS_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(users, f, ensure_ascii=False, indent=2)
|
||||
except Exception as e:
|
||||
print(f"保存用户数据失败: {e}")
|
||||
|
||||
async def _load_users(self) -> Dict[str, dict]:
|
||||
"""加载用户数据(加锁)"""
|
||||
async with self._users_lock:
|
||||
return self._load_users_unsafe()
|
||||
|
||||
async def _save_users(self, users: Dict[str, dict]):
|
||||
"""保存用户数据(加锁)"""
|
||||
async with self._users_lock:
|
||||
self._save_users_unsafe(users)
|
||||
|
||||
def _load_admin_list_unsafe(self) -> List[str]:
|
||||
"""加载管理员列表(不加锁,内部使用)"""
|
||||
try:
|
||||
with open(self.ADMINS_FILE, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return data.get("admins", [])
|
||||
except Exception as e:
|
||||
print(f"加载管理员列表失败: {e}")
|
||||
return []
|
||||
|
||||
def _save_admin_list_unsafe(self, admin_list: List[str]):
|
||||
"""保存管理员列表(不加锁,内部使用)"""
|
||||
try:
|
||||
with open(self.ADMINS_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump({"admins": admin_list}, f, ensure_ascii=False, indent=2)
|
||||
except Exception as e:
|
||||
print(f"保存管理员列表失败: {e}")
|
||||
|
||||
async def _load_admin_list(self) -> List[str]:
|
||||
"""加载管理员列表(加锁)"""
|
||||
async with self._admins_lock:
|
||||
return self._load_admin_list_unsafe()
|
||||
|
||||
async def _save_admin_list(self, admin_list: List[str]):
|
||||
"""保存管理员列表(加锁)"""
|
||||
async with self._admins_lock:
|
||||
self._save_admin_list_unsafe(admin_list)
|
||||
|
||||
async def create_or_update_from_linuxdo(
|
||||
self,
|
||||
linuxdo_id: str,
|
||||
username: str,
|
||||
display_name: str,
|
||||
avatar_url: Optional[str],
|
||||
trust_level: int
|
||||
) -> User:
|
||||
"""
|
||||
从 LinuxDO 用户信息创建或更新用户(线程安全)
|
||||
|
||||
Args:
|
||||
linuxdo_id: LinuxDO 用户 ID(本地用户时为 local_xxx 格式)
|
||||
username: 用户名
|
||||
display_name: 显示名称
|
||||
avatar_url: 头像 URL
|
||||
trust_level: 信任等级 (仅用于显示)
|
||||
|
||||
Returns:
|
||||
用户对象
|
||||
"""
|
||||
# 如果已经是 local_ 开头,直接使用;否则添加 linuxdo_ 前缀
|
||||
if linuxdo_id.startswith("local_"):
|
||||
user_id = linuxdo_id
|
||||
else:
|
||||
user_id = f"linuxdo_{linuxdo_id}"
|
||||
|
||||
# 使用锁保护整个读-改-写操作
|
||||
async with self._users_lock:
|
||||
async with self._admins_lock:
|
||||
users = self._load_users_unsafe()
|
||||
admin_list = self._load_admin_list_unsafe()
|
||||
|
||||
now = datetime.now().isoformat()
|
||||
|
||||
# 检查是否为初始管理员
|
||||
initial_admin_id = settings.INITIAL_ADMIN_LINUXDO_ID
|
||||
is_initial_admin = (initial_admin_id and linuxdo_id == initial_admin_id)
|
||||
|
||||
# 检查是否为本地用户(所有 local_ 开头的用户默认为管理员)
|
||||
is_local_user = user_id.startswith("local_")
|
||||
|
||||
if user_id in users:
|
||||
# 更新现有用户
|
||||
user_data = users[user_id]
|
||||
user_data["username"] = username
|
||||
user_data["display_name"] = display_name
|
||||
user_data["avatar_url"] = avatar_url
|
||||
user_data["trust_level"] = trust_level
|
||||
user_data["last_login"] = now
|
||||
|
||||
# 如果是初始管理员或本地用户且还不在管理员列表中,添加进去
|
||||
if (is_initial_admin or is_local_user) and user_id not in admin_list:
|
||||
admin_list.append(user_id)
|
||||
self._save_admin_list_unsafe(admin_list)
|
||||
user_data["is_admin"] = True
|
||||
else:
|
||||
# 从管理员列表同步 is_admin 状态
|
||||
user_data["is_admin"] = user_id in admin_list
|
||||
else:
|
||||
# 创建新用户(本地用户默认为管理员)
|
||||
is_admin = is_initial_admin or is_local_user
|
||||
if is_admin and user_id not in admin_list:
|
||||
admin_list.append(user_id)
|
||||
self._save_admin_list_unsafe(admin_list)
|
||||
|
||||
user_data = {
|
||||
"user_id": user_id,
|
||||
"username": username,
|
||||
"display_name": display_name,
|
||||
"avatar_url": avatar_url,
|
||||
"trust_level": trust_level,
|
||||
"is_admin": is_admin,
|
||||
"linuxdo_id": linuxdo_id,
|
||||
"created_at": now,
|
||||
"last_login": now
|
||||
}
|
||||
users[user_id] = user_data
|
||||
|
||||
self._save_users_unsafe(users)
|
||||
return User(**user_data)
|
||||
|
||||
async def get_user(self, user_id: str) -> Optional[User]:
|
||||
"""获取用户(线程安全)"""
|
||||
users = await self._load_users()
|
||||
user_data = users.get(user_id)
|
||||
if user_data:
|
||||
# 同步管理员状态
|
||||
admin_list = await self._load_admin_list()
|
||||
user_data["is_admin"] = user_id in admin_list
|
||||
return User(**user_data)
|
||||
return None
|
||||
|
||||
async def get_all_users(self) -> List[User]:
|
||||
"""获取所有用户(线程安全)"""
|
||||
users = await self._load_users()
|
||||
admin_list = await self._load_admin_list()
|
||||
|
||||
user_list = []
|
||||
for user_data in users.values():
|
||||
# 同步管理员状态
|
||||
user_data["is_admin"] = user_data["user_id"] in admin_list
|
||||
user_list.append(User(**user_data))
|
||||
|
||||
return user_list
|
||||
|
||||
async def set_admin(self, user_id: str, is_admin: bool) -> bool:
|
||||
"""
|
||||
设置用户的管理员权限(线程安全)
|
||||
|
||||
Args:
|
||||
user_id: 用户 ID
|
||||
is_admin: 是否为管理员
|
||||
|
||||
Returns:
|
||||
是否成功
|
||||
"""
|
||||
# 使用锁保护整个读-改-写操作
|
||||
async with self._users_lock:
|
||||
async with self._admins_lock:
|
||||
users = self._load_users_unsafe()
|
||||
if user_id not in users:
|
||||
return False
|
||||
|
||||
admin_list = self._load_admin_list_unsafe()
|
||||
|
||||
if is_admin:
|
||||
# 授予管理员权限
|
||||
if user_id not in admin_list:
|
||||
admin_list.append(user_id)
|
||||
self._save_admin_list_unsafe(admin_list)
|
||||
else:
|
||||
# 撤销管理员权限
|
||||
if user_id in admin_list:
|
||||
# 确保至少保留一个管理员
|
||||
if len(admin_list) <= 1:
|
||||
return False
|
||||
admin_list.remove(user_id)
|
||||
self._save_admin_list_unsafe(admin_list)
|
||||
|
||||
# 更新用户数据中的 is_admin 字段
|
||||
users[user_id]["is_admin"] = is_admin
|
||||
self._save_users_unsafe(users)
|
||||
|
||||
return True
|
||||
|
||||
async def delete_user(self, user_id: str) -> bool:
|
||||
"""
|
||||
删除用户(线程安全)
|
||||
|
||||
Args:
|
||||
user_id: 用户 ID
|
||||
|
||||
Returns:
|
||||
是否成功
|
||||
"""
|
||||
# 使用锁保护整个读-改-写操作
|
||||
async with self._users_lock:
|
||||
async with self._admins_lock:
|
||||
users = self._load_users_unsafe()
|
||||
if user_id not in users:
|
||||
return False
|
||||
|
||||
# 不能删除管理员
|
||||
admin_list = self._load_admin_list_unsafe()
|
||||
if user_id in admin_list:
|
||||
return False
|
||||
|
||||
# 删除用户数据
|
||||
del users[user_id]
|
||||
self._save_users_unsafe(users)
|
||||
|
||||
# 删除用户数据库文件(在锁外执行,避免阻塞)
|
||||
db_file = str(DATA_DIR / f"ai_story_user_{user_id}.db")
|
||||
if os.path.exists(db_file):
|
||||
try:
|
||||
os.remove(db_file)
|
||||
except Exception as e:
|
||||
print(f"删除用户数据库文件失败: {e}")
|
||||
|
||||
return True
|
||||
|
||||
async def is_admin(self, user_id: str) -> bool:
|
||||
"""检查用户是否为管理员(线程安全)"""
|
||||
admin_list = await self._load_admin_list()
|
||||
return user_id in admin_list
|
||||
|
||||
|
||||
# 全局用户管理器实例
|
||||
user_manager = UserManager()
|
||||
@@ -0,0 +1,347 @@
|
||||
"""数据一致性辅助函数"""
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from typing import Optional, Tuple, List
|
||||
from app.models.character import Character
|
||||
from app.models.relationship import Organization, OrganizationMember, CharacterRelationship
|
||||
from app.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
async def ensure_organization_record(
|
||||
character: Character,
|
||||
db: AsyncSession,
|
||||
power_level: int = 50,
|
||||
location: Optional[str] = None,
|
||||
motto: Optional[str] = None
|
||||
) -> Optional[Organization]:
|
||||
"""
|
||||
确保组织角色拥有对应的Organization记录
|
||||
|
||||
Args:
|
||||
character: Character对象(必须是is_organization=True)
|
||||
db: 数据库会话
|
||||
power_level: 势力等级(默认50)
|
||||
location: 所在地
|
||||
motto: 宗旨/口号
|
||||
|
||||
Returns:
|
||||
Organization对象,如果character不是组织则返回None
|
||||
"""
|
||||
if not character.is_organization:
|
||||
logger.debug(f"角色 {character.name} 不是组织,跳过Organization记录创建")
|
||||
return None
|
||||
|
||||
# 检查是否已存在
|
||||
result = await db.execute(
|
||||
select(Organization).where(Organization.character_id == character.id)
|
||||
)
|
||||
org = result.scalar_one_or_none()
|
||||
|
||||
if not org:
|
||||
# 创建新的Organization记录
|
||||
org = Organization(
|
||||
character_id=character.id,
|
||||
project_id=character.project_id,
|
||||
member_count=0,
|
||||
power_level=power_level,
|
||||
location=location,
|
||||
motto=motto
|
||||
)
|
||||
db.add(org)
|
||||
await db.flush()
|
||||
await db.refresh(org)
|
||||
logger.info(f"✅ 自动创建组织详情:{character.name} (Org ID: {org.id})")
|
||||
else:
|
||||
logger.debug(f"组织详情已存在:{character.name} (Org ID: {org.id})")
|
||||
|
||||
return org
|
||||
|
||||
|
||||
async def sync_organization_member_count(
|
||||
organization: Organization,
|
||||
db: AsyncSession
|
||||
) -> int:
|
||||
"""
|
||||
同步组织的成员计数,从实际成员记录计算
|
||||
|
||||
Args:
|
||||
organization: Organization对象
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
实际成员数量
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(OrganizationMember).where(
|
||||
OrganizationMember.organization_id == organization.id,
|
||||
OrganizationMember.status == "active"
|
||||
)
|
||||
)
|
||||
members = result.scalars().all()
|
||||
actual_count = len(members)
|
||||
|
||||
if organization.member_count != actual_count:
|
||||
logger.warning(
|
||||
f"组织 {organization.id} 成员计数不一致:"
|
||||
f"记录值={organization.member_count}, 实际值={actual_count},已修正"
|
||||
)
|
||||
organization.member_count = actual_count
|
||||
await db.flush()
|
||||
|
||||
return actual_count
|
||||
|
||||
|
||||
async def fix_missing_organization_records(
|
||||
project_id: str,
|
||||
db: AsyncSession
|
||||
) -> Tuple[int, int]:
|
||||
"""
|
||||
修复项目中缺失的Organization记录
|
||||
|
||||
为所有is_organization=True但没有Organization记录的Character创建记录
|
||||
|
||||
Args:
|
||||
project_id: 项目ID
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
(修复数量, 检查总数)
|
||||
"""
|
||||
# 查找所有组织角色
|
||||
result = await db.execute(
|
||||
select(Character).where(
|
||||
Character.project_id == project_id,
|
||||
Character.is_organization == True
|
||||
)
|
||||
)
|
||||
org_characters = result.scalars().all()
|
||||
|
||||
fixed_count = 0
|
||||
for char in org_characters:
|
||||
org = await ensure_organization_record(char, db)
|
||||
if org and org.id: # 新创建的才计数
|
||||
# 检查是否是新创建的(通过查询历史)
|
||||
result = await db.execute(
|
||||
select(Organization).where(Organization.character_id == char.id)
|
||||
)
|
||||
if result.scalar_one_or_none():
|
||||
fixed_count += 1
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"📊 修复统计 - 检查了 {len(org_characters)} 个组织,修复了 {fixed_count} 个缺失的Organization记录")
|
||||
return fixed_count, len(org_characters)
|
||||
|
||||
|
||||
async def fix_organization_member_counts(
|
||||
project_id: str,
|
||||
db: AsyncSession
|
||||
) -> Tuple[int, int]:
|
||||
"""
|
||||
修复项目中所有组织的成员计数
|
||||
|
||||
Args:
|
||||
project_id: 项目ID
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
(修复数量, 检查总数)
|
||||
"""
|
||||
# 查找所有组织
|
||||
result = await db.execute(
|
||||
select(Organization).where(Organization.project_id == project_id)
|
||||
)
|
||||
organizations = result.scalars().all()
|
||||
|
||||
fixed_count = 0
|
||||
for org in organizations:
|
||||
old_count = org.member_count
|
||||
actual_count = await sync_organization_member_count(org, db)
|
||||
if old_count != actual_count:
|
||||
fixed_count += 1
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"📊 修复统计 - 检查了 {len(organizations)} 个组织,修复了 {fixed_count} 个计数错误")
|
||||
return fixed_count, len(organizations)
|
||||
|
||||
|
||||
async def validate_relationships(
|
||||
project_id: str,
|
||||
db: AsyncSession
|
||||
) -> List[dict]:
|
||||
"""
|
||||
验证项目中的关系数据完整性
|
||||
|
||||
检查所有关系中的character_from_id和character_to_id是否都指向存在的角色
|
||||
|
||||
Args:
|
||||
project_id: 项目ID
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
问题列表,每个问题包含 {issue_type, relationship_id, details}
|
||||
"""
|
||||
issues = []
|
||||
|
||||
# 获取所有关系
|
||||
result = await db.execute(
|
||||
select(CharacterRelationship).where(CharacterRelationship.project_id == project_id)
|
||||
)
|
||||
relationships = result.scalars().all()
|
||||
|
||||
for rel in relationships:
|
||||
# 检查from角色
|
||||
from_char = await db.execute(
|
||||
select(Character).where(Character.id == rel.character_from_id)
|
||||
)
|
||||
if not from_char.scalar_one_or_none():
|
||||
issues.append({
|
||||
"issue_type": "missing_from_character",
|
||||
"relationship_id": rel.id,
|
||||
"details": f"关系 {rel.id} 的源角色 {rel.character_from_id} 不存在"
|
||||
})
|
||||
|
||||
# 检查to角色
|
||||
to_char = await db.execute(
|
||||
select(Character).where(Character.id == rel.character_to_id)
|
||||
)
|
||||
if not to_char.scalar_one_or_none():
|
||||
issues.append({
|
||||
"issue_type": "missing_to_character",
|
||||
"relationship_id": rel.id,
|
||||
"details": f"关系 {rel.id} 的目标角色 {rel.character_to_id} 不存在"
|
||||
})
|
||||
|
||||
if issues:
|
||||
logger.warning(f"⚠️ 发现 {len(issues)} 个关系数据问题")
|
||||
for issue in issues:
|
||||
logger.warning(f" - {issue['details']}")
|
||||
else:
|
||||
logger.info(f"✅ 所有 {len(relationships)} 条关系数据完整")
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
async def validate_organization_members(
|
||||
project_id: str,
|
||||
db: AsyncSession
|
||||
) -> List[dict]:
|
||||
"""
|
||||
验证项目中的组织成员数据完整性
|
||||
|
||||
检查所有成员关系中的organization_id和character_id是否都有效
|
||||
|
||||
Args:
|
||||
project_id: 项目ID
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
问题列表
|
||||
"""
|
||||
issues = []
|
||||
|
||||
# 获取所有成员关系
|
||||
result = await db.execute(
|
||||
select(OrganizationMember).where(
|
||||
OrganizationMember.organization_id.in_(
|
||||
select(Organization.id).where(Organization.project_id == project_id)
|
||||
)
|
||||
)
|
||||
)
|
||||
members = result.scalars().all()
|
||||
|
||||
for member in members:
|
||||
# 检查组织
|
||||
org = await db.execute(
|
||||
select(Organization).where(Organization.id == member.organization_id)
|
||||
)
|
||||
if not org.scalar_one_or_none():
|
||||
issues.append({
|
||||
"issue_type": "missing_organization",
|
||||
"member_id": member.id,
|
||||
"details": f"成员 {member.id} 的组织 {member.organization_id} 不存在"
|
||||
})
|
||||
|
||||
# 检查角色
|
||||
char = await db.execute(
|
||||
select(Character).where(Character.id == member.character_id)
|
||||
)
|
||||
if not char.scalar_one_or_none():
|
||||
issues.append({
|
||||
"issue_type": "missing_character",
|
||||
"member_id": member.id,
|
||||
"details": f"成员 {member.id} 的角色 {member.character_id} 不存在"
|
||||
})
|
||||
|
||||
if issues:
|
||||
logger.warning(f"⚠️ 发现 {len(issues)} 个组织成员数据问题")
|
||||
for issue in issues:
|
||||
logger.warning(f" - {issue['details']}")
|
||||
else:
|
||||
logger.info(f"✅ 所有 {len(members)} 条组织成员数据完整")
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
async def run_full_data_consistency_check(
|
||||
project_id: str,
|
||||
db: AsyncSession,
|
||||
auto_fix: bool = True
|
||||
) -> dict:
|
||||
"""
|
||||
对项目运行完整的数据一致性检查和修复
|
||||
|
||||
Args:
|
||||
project_id: 项目ID
|
||||
db: 数据库会话
|
||||
auto_fix: 是否自动修复问题(默认True)
|
||||
|
||||
Returns:
|
||||
检查报告字典
|
||||
"""
|
||||
logger.info(f"🔍 开始数据一致性检查 - 项目 {project_id}")
|
||||
|
||||
report = {
|
||||
"project_id": project_id,
|
||||
"checks": {}
|
||||
}
|
||||
|
||||
# 1. 检查并修复缺失的Organization记录
|
||||
if auto_fix:
|
||||
fixed, total = await fix_missing_organization_records(project_id, db)
|
||||
report["checks"]["organization_records"] = {
|
||||
"checked": total,
|
||||
"fixed": fixed,
|
||||
"status": "ok" if fixed == 0 else "fixed"
|
||||
}
|
||||
|
||||
# 2. 检查并修复成员计数
|
||||
if auto_fix:
|
||||
fixed, total = await fix_organization_member_counts(project_id, db)
|
||||
report["checks"]["member_counts"] = {
|
||||
"checked": total,
|
||||
"fixed": fixed,
|
||||
"status": "ok" if fixed == 0 else "fixed"
|
||||
}
|
||||
|
||||
# 3. 验证关系数据
|
||||
rel_issues = await validate_relationships(project_id, db)
|
||||
report["checks"]["relationships"] = {
|
||||
"issues_found": len(rel_issues),
|
||||
"issues": rel_issues,
|
||||
"status": "ok" if len(rel_issues) == 0 else "warning"
|
||||
}
|
||||
|
||||
# 4. 验证组织成员数据
|
||||
member_issues = await validate_organization_members(project_id, db)
|
||||
report["checks"]["organization_members"] = {
|
||||
"issues_found": len(member_issues),
|
||||
"issues": member_issues,
|
||||
"status": "ok" if len(member_issues) == 0 else "warning"
|
||||
}
|
||||
|
||||
logger.info(f"✅ 数据一致性检查完成")
|
||||
return report
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Server-Sent Events (SSE) 响应工具类"""
|
||||
import json
|
||||
import asyncio
|
||||
from typing import AsyncGenerator, Dict, Any, Optional
|
||||
from fastapi.responses import StreamingResponse
|
||||
from app.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class SSEResponse:
|
||||
"""SSE响应构建器"""
|
||||
|
||||
@staticmethod
|
||||
def format_sse(data: Dict[str, Any], event: Optional[str] = None) -> str:
|
||||
"""
|
||||
格式化SSE消息
|
||||
|
||||
Args:
|
||||
data: 要发送的数据字典
|
||||
event: 事件类型(可选)
|
||||
|
||||
Returns:
|
||||
格式化后的SSE消息字符串
|
||||
"""
|
||||
message = ""
|
||||
if event:
|
||||
message += f"event: {event}\n"
|
||||
message += f"data: {json.dumps(data, ensure_ascii=False)}\n\n"
|
||||
return message
|
||||
|
||||
@staticmethod
|
||||
async def send_progress(
|
||||
message: str,
|
||||
progress: int,
|
||||
status: str = "processing"
|
||||
) -> str:
|
||||
"""
|
||||
发送进度消息
|
||||
|
||||
Args:
|
||||
message: 进度消息
|
||||
progress: 进度百分比(0-100)
|
||||
status: 状态(processing/success/error)
|
||||
"""
|
||||
return SSEResponse.format_sse({
|
||||
"type": "progress",
|
||||
"message": message,
|
||||
"progress": progress,
|
||||
"status": status
|
||||
})
|
||||
|
||||
@staticmethod
|
||||
async def send_chunk(content: str) -> str:
|
||||
"""
|
||||
发送内容块(用于流式输出AI生成内容)
|
||||
|
||||
Args:
|
||||
content: 内容块
|
||||
"""
|
||||
return SSEResponse.format_sse({
|
||||
"type": "chunk",
|
||||
"content": content
|
||||
})
|
||||
|
||||
@staticmethod
|
||||
async def send_result(data: Dict[str, Any]) -> str:
|
||||
"""
|
||||
发送最终结果
|
||||
|
||||
Args:
|
||||
data: 结果数据
|
||||
"""
|
||||
return SSEResponse.format_sse({
|
||||
"type": "result",
|
||||
"data": data
|
||||
})
|
||||
|
||||
@staticmethod
|
||||
async def send_error(error: str, code: int = 500) -> str:
|
||||
"""
|
||||
发送错误消息
|
||||
|
||||
Args:
|
||||
error: 错误描述
|
||||
code: 错误码
|
||||
"""
|
||||
return SSEResponse.format_sse({
|
||||
"type": "error",
|
||||
"error": error,
|
||||
"code": code
|
||||
})
|
||||
|
||||
@staticmethod
|
||||
async def send_done() -> str:
|
||||
"""发送完成消息"""
|
||||
return SSEResponse.format_sse({
|
||||
"type": "done"
|
||||
})
|
||||
|
||||
@staticmethod
|
||||
async def send_heartbeat() -> str:
|
||||
"""发送心跳消息(保持连接活跃)"""
|
||||
return ": heartbeat\n\n"
|
||||
|
||||
|
||||
async def create_sse_generator(
|
||||
async_gen: AsyncGenerator[str, None],
|
||||
show_progress: bool = True
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""
|
||||
创建SSE生成器包装器
|
||||
|
||||
Args:
|
||||
async_gen: 异步生成器
|
||||
show_progress: 是否显示进度
|
||||
|
||||
Yields:
|
||||
格式化的SSE消息
|
||||
"""
|
||||
try:
|
||||
if show_progress:
|
||||
yield await SSEResponse.send_progress("开始生成...", 0)
|
||||
|
||||
# 累积内容用于进度计算
|
||||
accumulated_content = ""
|
||||
chunk_count = 0
|
||||
|
||||
async for chunk in async_gen:
|
||||
chunk_count += 1
|
||||
accumulated_content += chunk
|
||||
|
||||
# 发送内容块
|
||||
yield await SSEResponse.send_chunk(chunk)
|
||||
|
||||
# 每10个块发送一次心跳
|
||||
if chunk_count % 10 == 0:
|
||||
yield await SSEResponse.send_heartbeat()
|
||||
|
||||
if show_progress:
|
||||
yield await SSEResponse.send_progress("生成完成", 100, "success")
|
||||
|
||||
# 发送完成信号
|
||||
yield await SSEResponse.send_done()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"SSE生成器错误: {str(e)}")
|
||||
yield await SSEResponse.send_error(str(e))
|
||||
|
||||
|
||||
def create_sse_response(generator: AsyncGenerator[str, None]) -> StreamingResponse:
|
||||
"""
|
||||
创建SSE StreamingResponse
|
||||
|
||||
Args:
|
||||
generator: SSE消息生成器
|
||||
|
||||
Returns:
|
||||
StreamingResponse对象
|
||||
"""
|
||||
return StreamingResponse(
|
||||
generator,
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no", # 禁用nginx缓冲
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,20 @@
|
||||
# Web框架
|
||||
fastapi==0.109.0
|
||||
uvicorn[standard]==0.27.0
|
||||
python-multipart==0.0.6
|
||||
|
||||
# 数据库
|
||||
sqlalchemy==2.0.25
|
||||
aiosqlite==0.19.0
|
||||
|
||||
# 数据验证
|
||||
pydantic==2.5.3
|
||||
pydantic-settings==2.1.0
|
||||
|
||||
# AI服务
|
||||
openai==1.10.0
|
||||
anthropic==0.18.0
|
||||
|
||||
# 工具库
|
||||
httpx==0.26.0
|
||||
python-dotenv==1.0.0
|
||||
@@ -0,0 +1,50 @@
|
||||
services:
|
||||
ai-story:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: ai-story-creator:latest
|
||||
container_name: ai-story-creator
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
# 持久化数据库和日志
|
||||
- ./data:/app/data
|
||||
- ./logs:/app/logs
|
||||
# 挂载环境变量文件(可选)
|
||||
- ./.env:/app/.env:ro
|
||||
environment:
|
||||
# 应用配置
|
||||
- APP_NAME=AI Story Creator
|
||||
- APP_VERSION=1.0.0
|
||||
- APP_HOST=0.0.0.0
|
||||
- APP_PORT=8000
|
||||
- DEBUG=false
|
||||
|
||||
# 重要:环境变量会从 .env 文件自动加载
|
||||
# 也可以在这里显式设置,优先级:此处设置 > .env 文件
|
||||
|
||||
# AI服务配置(建议在 .env 文件中设置)
|
||||
# - OPENAI_API_KEY=${OPENAI_API_KEY}
|
||||
# - GEMINI_API_KEY=${GEMINI_API_KEY}
|
||||
# - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
|
||||
# - DEFAULT_AI_PROVIDER=${DEFAULT_AI_PROVIDER:-gemini}
|
||||
# - DEFAULT_MODEL=${DEFAULT_MODEL:-gemini-2.5-flash}
|
||||
|
||||
# LinuxDO OAuth配置(⚠️ 必须设置正确的回调地址)
|
||||
# - LINUXDO_CLIENT_ID=${LINUXDO_CLIENT_ID}
|
||||
# - LINUXDO_CLIENT_SECRET=${LINUXDO_CLIENT_SECRET}
|
||||
# - LINUXDO_REDIRECT_URI=${LINUXDO_REDIRECT_URI}
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
networks:
|
||||
- ai-story-network
|
||||
|
||||
networks:
|
||||
ai-story-network:
|
||||
driver: bridge
|
||||
@@ -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?
|
||||
@@ -0,0 +1,73 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
@@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs['recommended-latest'],
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>MuMuのAI小说</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+4997
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.6.1",
|
||||
"antd": "^5.27.6",
|
||||
"axios": "^1.12.2",
|
||||
"dayjs": "^1.11.13",
|
||||
"react": "^18.3.1",
|
||||
"react-beautiful-dnd": "^13.1.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.36.0",
|
||||
"@types/node": "^24.6.0",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-beautiful-dnd": "^13.1.8",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"eslint": "^9.36.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.22",
|
||||
"globals": "^16.4.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.45.0",
|
||||
"vite": "^7.1.7"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 6.1 KiB |
@@ -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 |
@@ -0,0 +1,5 @@
|
||||
#root {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import ProjectList from './pages/ProjectList';
|
||||
import ProjectWizardNew from './pages/ProjectWizardNew';
|
||||
import ProjectDetail from './pages/ProjectDetail';
|
||||
import WorldSetting from './pages/WorldSetting';
|
||||
import Outline from './pages/Outline';
|
||||
import Characters from './pages/Characters';
|
||||
import Relationships from './pages/Relationships';
|
||||
import Organizations from './pages/Organizations';
|
||||
import Chapters from './pages/Chapters';
|
||||
// import Polish from './pages/Polish';
|
||||
import Login from './pages/Login';
|
||||
import AuthCallback from './pages/AuthCallback';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
import './App.css';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ConfigProvider locale={zhCN}>
|
||||
<BrowserRouter
|
||||
future={{
|
||||
v7_startTransition: true,
|
||||
v7_relativeSplatPath: true,
|
||||
}}
|
||||
>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/auth/callback" element={<AuthCallback />} />
|
||||
|
||||
<Route path="/" element={<ProtectedRoute><ProjectList /></ProtectedRoute>} />
|
||||
<Route path="/wizard" element={<ProtectedRoute><ProjectWizardNew /></ProtectedRoute>} />
|
||||
<Route path="/project/:projectId" element={<ProtectedRoute><ProjectDetail /></ProtectedRoute>}>
|
||||
<Route index element={<Navigate to="world-setting" replace />} />
|
||||
<Route path="world-setting" element={<WorldSetting />} />
|
||||
<Route path="outline" element={<Outline />} />
|
||||
<Route path="characters" element={<Characters />} />
|
||||
<Route path="relationships" element={<Relationships />} />
|
||||
<Route path="organizations" element={<Organizations />} />
|
||||
<Route path="chapters" element={<Chapters />} />
|
||||
{/* <Route path="polish" element={<Polish />} /> */}
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -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="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,127 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
// 统一的卡片样式配置
|
||||
export const cardStyles = {
|
||||
// 基础卡片样式
|
||||
base: {
|
||||
borderRadius: 12,
|
||||
transition: 'all 0.3s ease',
|
||||
} as CSSProperties,
|
||||
|
||||
// 悬浮效果
|
||||
hoverable: {
|
||||
cursor: 'pointer',
|
||||
} as CSSProperties,
|
||||
|
||||
// 角色卡片样式
|
||||
character: {
|
||||
// height: 320,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
borderColor: '#1890ff',
|
||||
borderRadius: 12,
|
||||
} as CSSProperties,
|
||||
|
||||
// 组织卡片样式
|
||||
organization: {
|
||||
// height: 320,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
borderColor: '#52c41a',
|
||||
backgroundColor: '#f6ffed',
|
||||
borderRadius: 12,
|
||||
} as CSSProperties,
|
||||
|
||||
// 项目卡片样式
|
||||
project: {
|
||||
height: '100%',
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
background: '#fff',
|
||||
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.08)',
|
||||
transition: 'all 0.3s ease',
|
||||
} as CSSProperties,
|
||||
|
||||
// 卡片内容区域样式
|
||||
body: {
|
||||
padding: 20,
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
} as CSSProperties,
|
||||
|
||||
// 卡片描述区域样式(固定高度,内容截断)
|
||||
description: {
|
||||
marginTop: 12,
|
||||
maxHeight: 200,
|
||||
overflow: 'hidden' as const,
|
||||
} as CSSProperties,
|
||||
|
||||
// 文本截断样式
|
||||
ellipsis: {
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap' as const,
|
||||
} as CSSProperties,
|
||||
|
||||
// 多行文本截断
|
||||
ellipsisMultiline: (lines: number = 2) => ({
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: lines,
|
||||
WebkitBoxOrient: 'vertical' as const,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
} as CSSProperties),
|
||||
};
|
||||
|
||||
// 卡片悬浮动画
|
||||
export const cardHoverHandlers = {
|
||||
onMouseEnter: (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const target = e.currentTarget;
|
||||
target.style.transform = 'translateY(-8px)';
|
||||
target.style.boxShadow = '0 12px 32px rgba(0, 0, 0, 0.15)';
|
||||
},
|
||||
onMouseLeave: (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const target = e.currentTarget;
|
||||
target.style.transform = 'translateY(0)';
|
||||
target.style.boxShadow = '0 4px 16px rgba(0, 0, 0, 0.08)';
|
||||
},
|
||||
};
|
||||
|
||||
// 响应式网格配置
|
||||
export const gridConfig = {
|
||||
gutter: [16, 16] as [number, number],
|
||||
xs: 24,
|
||||
sm: 12,
|
||||
lg: 8,
|
||||
xl: 6,
|
||||
};
|
||||
|
||||
// 角色卡片网格配置
|
||||
export const characterGridConfig = {
|
||||
gutter: 0, // 移除 gutter,避免负边距
|
||||
xs: 24, // 手机:1列
|
||||
sm: 12, // 平板:2列
|
||||
md: 12, // 中等屏幕:3列
|
||||
lg: 6, // 大屏:4列
|
||||
xl: 6, // 超大屏:4列
|
||||
xxl: 5, // 超超大屏:6列
|
||||
};
|
||||
|
||||
// 文本样式
|
||||
export const textStyles = {
|
||||
label: {
|
||||
fontSize: 12,
|
||||
color: 'rgba(0, 0, 0, 0.45)',
|
||||
} as CSSProperties,
|
||||
|
||||
value: {
|
||||
fontSize: 14,
|
||||
color: 'rgba(0, 0, 0, 0.85)',
|
||||
} as CSSProperties,
|
||||
|
||||
description: {
|
||||
fontSize: 12,
|
||||
color: 'rgba(0, 0, 0, 0.45)',
|
||||
lineHeight: 1.6,
|
||||
} as CSSProperties,
|
||||
};
|
||||
@@ -0,0 +1,173 @@
|
||||
import { Card, Space, Tag, Typography, Popconfirm } from 'antd';
|
||||
import { EditOutlined, DeleteOutlined, UserOutlined, BankOutlined } from '@ant-design/icons';
|
||||
import { cardStyles } from './CardStyles';
|
||||
import type { Character } from '../types';
|
||||
|
||||
const { Text, Paragraph } = Typography;
|
||||
|
||||
interface CharacterCardProps {
|
||||
character: Character;
|
||||
onEdit?: (character: Character) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
export const CharacterCard: React.FC<CharacterCardProps> = ({ character, onEdit, onDelete }) => {
|
||||
const getRoleTypeColor = (roleType?: string) => {
|
||||
const roleColors: Record<string, string> = {
|
||||
'protagonist': 'blue',
|
||||
'supporting': 'green',
|
||||
'antagonist': 'red',
|
||||
};
|
||||
return roleColors[roleType || ''] || 'default';
|
||||
};
|
||||
|
||||
const getRoleTypeLabel = (roleType?: string) => {
|
||||
const roleLabels: Record<string, string> = {
|
||||
'protagonist': '主角',
|
||||
'supporting': '配角',
|
||||
'antagonist': '反派',
|
||||
};
|
||||
return roleLabels[roleType || ''] || '其他';
|
||||
};
|
||||
|
||||
const isOrganization = character.is_organization;
|
||||
|
||||
return (
|
||||
<Card
|
||||
hoverable
|
||||
style={isOrganization ? cardStyles.organization : cardStyles.character}
|
||||
styles={{
|
||||
body: {
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
},
|
||||
actions: {
|
||||
borderRadius: '0 0 12px 12px'
|
||||
}
|
||||
}}
|
||||
actions={[
|
||||
...(onEdit ? [<EditOutlined key="edit" onClick={() => onEdit(character)} />] : []),
|
||||
<Popconfirm
|
||||
key="delete"
|
||||
title={`确定删除这个${isOrganization ? '组织' : '角色'}吗?`}
|
||||
onConfirm={() => onDelete(character.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<DeleteOutlined />
|
||||
</Popconfirm>,
|
||||
]}
|
||||
>
|
||||
<Card.Meta
|
||||
avatar={
|
||||
isOrganization ? (
|
||||
<BankOutlined style={{ fontSize: 32, color: '#52c41a' }} />
|
||||
) : (
|
||||
<UserOutlined style={{ fontSize: 32, color: '#1890ff' }} />
|
||||
)
|
||||
}
|
||||
title={
|
||||
<Space>
|
||||
<span style={cardStyles.ellipsis}>{character.name}</span>
|
||||
{isOrganization ? (
|
||||
<Tag color="green">组织</Tag>
|
||||
) : (
|
||||
character.role_type && (
|
||||
<Tag color={getRoleTypeColor(character.role_type)}>
|
||||
{getRoleTypeLabel(character.role_type)}
|
||||
</Tag>
|
||||
)
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
description={
|
||||
<div style={cardStyles.description}>
|
||||
{/* 角色特有字段 */}
|
||||
{!isOrganization && (
|
||||
<>
|
||||
{character.age && (
|
||||
<div style={{ marginBottom: 8, display: 'flex', alignItems: 'flex-start' }}>
|
||||
<Text type="secondary" style={{ flexShrink: 0 }}>年龄:</Text>
|
||||
<Text style={{ flex: 1 }}>{character.age}</Text>
|
||||
</div>
|
||||
)}
|
||||
{character.gender && (
|
||||
<div style={{ marginBottom: 8, display: 'flex', alignItems: 'flex-start' }}>
|
||||
<Text type="secondary" style={{ flexShrink: 0 }}>性别:</Text>
|
||||
<Text style={{ flex: 1 }}>{character.gender}</Text>
|
||||
</div>
|
||||
)}
|
||||
{character.personality && (
|
||||
<div style={{ marginBottom: 8, display: 'flex', alignItems: 'flex-start' }}>
|
||||
<Text type="secondary" style={{ flexShrink: 0 }}>性格:</Text>
|
||||
<Text
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
ellipsis={{ tooltip: character.personality }}
|
||||
>
|
||||
{character.personality}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 组织特有字段 */}
|
||||
{isOrganization && (
|
||||
<>
|
||||
{character.organization_type && (
|
||||
<div style={{ marginBottom: 8, display: 'flex', alignItems: 'center' }}>
|
||||
<Text type="secondary" style={{ flexShrink: 0 }}>类型:</Text>
|
||||
<Tag color="cyan">{character.organization_type}</Tag>
|
||||
</div>
|
||||
)}
|
||||
{character.organization_purpose && (
|
||||
<div style={{ marginBottom: 8, display: 'flex', alignItems: 'flex-start' }}>
|
||||
<Text type="secondary" style={{ flexShrink: 0 }}>目的:</Text>
|
||||
<Text
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
ellipsis={{ tooltip: character.organization_purpose }}
|
||||
>
|
||||
{character.organization_purpose}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
{character.organization_members && (
|
||||
<div style={{ marginBottom: 8, display: 'flex', alignItems: 'flex-start' }}>
|
||||
<Text type="secondary" style={{ flexShrink: 0 }}>成员:</Text>
|
||||
<Text
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
ellipsis={{
|
||||
tooltip: typeof character.organization_members === 'string'
|
||||
? character.organization_members
|
||||
: JSON.stringify(character.organization_members)
|
||||
}}
|
||||
>
|
||||
{typeof character.organization_members === 'string'
|
||||
? character.organization_members
|
||||
: JSON.stringify(character.organization_members)}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 通用字段 - 背景信息截断显示 */}
|
||||
{character.background && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Paragraph
|
||||
type="secondary"
|
||||
style={{ fontSize: 12, marginBottom: 0 }}
|
||||
ellipsis={{ tooltip: character.background, rows: 3 }}
|
||||
>
|
||||
{character.background}
|
||||
</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { Spin } from 'antd';
|
||||
import { authApi } from '../services/api';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function ProtectedRoute({ children }: ProtectedRouteProps) {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
await authApi.getCurrentUser();
|
||||
setIsAuthenticated(true);
|
||||
} catch {
|
||||
setIsAuthenticated(false);
|
||||
}
|
||||
};
|
||||
checkAuth();
|
||||
}, []);
|
||||
|
||||
if (isAuthenticated === null) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: '100vh',
|
||||
}}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to={`/login?redirect=${encodeURIComponent(location.pathname)}`} replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import React from 'react';
|
||||
import { Spin } from 'antd';
|
||||
import { LoadingOutlined } from '@ant-design/icons';
|
||||
|
||||
interface SSELoadingOverlayProps {
|
||||
loading: boolean;
|
||||
progress: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export const SSELoadingOverlay: React.FC<SSELoadingOverlayProps> = ({
|
||||
loading,
|
||||
progress,
|
||||
message
|
||||
}) => {
|
||||
if (!loading) return null;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0, 0, 0, 0.45)',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 9999
|
||||
}}>
|
||||
<div style={{
|
||||
background: '#fff',
|
||||
borderRadius: 12,
|
||||
padding: '40px 60px',
|
||||
minWidth: 400,
|
||||
maxWidth: 600,
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.2)'
|
||||
}}>
|
||||
{/* 标题和图标 */}
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
marginBottom: 24
|
||||
}}>
|
||||
<Spin
|
||||
indicator={<LoadingOutlined style={{ fontSize: 48, color: '#1890ff' }} spin />}
|
||||
/>
|
||||
<div style={{
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
marginTop: 16,
|
||||
color: '#262626'
|
||||
}}>
|
||||
AI生成中...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 进度条 */}
|
||||
<div style={{
|
||||
marginBottom: 16
|
||||
}}>
|
||||
<div style={{
|
||||
height: 12,
|
||||
background: '#f0f0f0',
|
||||
borderRadius: 6,
|
||||
overflow: 'hidden',
|
||||
marginBottom: 12
|
||||
}}>
|
||||
<div style={{
|
||||
height: '100%',
|
||||
background: progress === 100
|
||||
? 'linear-gradient(90deg, #52c41a 0%, #73d13d 100%)'
|
||||
: 'linear-gradient(90deg, #1890ff 0%, #40a9ff 100%)',
|
||||
width: `${progress}%`,
|
||||
transition: 'all 0.3s ease',
|
||||
borderRadius: 6,
|
||||
boxShadow: progress > 0 ? '0 0 10px rgba(24, 144, 255, 0.3)' : 'none'
|
||||
}} />
|
||||
</div>
|
||||
|
||||
{/* 进度百分比 */}
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
fontSize: 32,
|
||||
fontWeight: 'bold',
|
||||
color: progress === 100 ? '#52c41a' : '#1890ff',
|
||||
marginBottom: 8
|
||||
}}>
|
||||
{progress}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 状态消息 */}
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
fontSize: 16,
|
||||
color: '#595959',
|
||||
minHeight: 24,
|
||||
padding: '0 20px'
|
||||
}}>
|
||||
{message || '准备生成...'}
|
||||
</div>
|
||||
|
||||
{/* 提示文字 */}
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
fontSize: 13,
|
||||
color: '#8c8c8c',
|
||||
marginTop: 16
|
||||
}}>
|
||||
请勿关闭页面,生成过程需要一定时间
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
|
||||
interface SSEProgressBarProps {
|
||||
loading: boolean;
|
||||
progress: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export const SSEProgressBar: React.FC<SSEProgressBarProps> = ({
|
||||
loading,
|
||||
progress,
|
||||
message
|
||||
}) => {
|
||||
if (!loading) return null;
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
{/* 进度条 */}
|
||||
<div style={{
|
||||
height: 8,
|
||||
background: '#f0f0f0',
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
marginBottom: 8
|
||||
}}>
|
||||
<div style={{
|
||||
height: '100%',
|
||||
background: progress === 100 ? '#52c41a' : '#1890ff',
|
||||
width: `${progress}%`,
|
||||
transition: 'all 0.3s ease',
|
||||
borderRadius: 4
|
||||
}} />
|
||||
</div>
|
||||
|
||||
{/* 进度信息 */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
fontSize: 14
|
||||
}}>
|
||||
<span style={{ color: '#666' }}>
|
||||
{message || '准备生成...'}
|
||||
</span>
|
||||
<span style={{
|
||||
fontWeight: 'bold',
|
||||
color: progress === 100 ? '#52c41a' : '#1890ff'
|
||||
}}>
|
||||
{progress}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,346 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Dropdown, Avatar, Space, Typography, message, Modal, Table, Button, Tag, Popconfirm, Pagination } from 'antd';
|
||||
import { UserOutlined, LogoutOutlined, TeamOutlined, CrownOutlined } from '@ant-design/icons';
|
||||
import { authApi, userApi } from '../services/api';
|
||||
import type { User } from '../types';
|
||||
import type { MenuProps } from 'antd';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export default function UserMenu() {
|
||||
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||
const [showUserManagement, setShowUserManagement] = useState(false);
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
|
||||
useEffect(() => {
|
||||
loadCurrentUser();
|
||||
}, []);
|
||||
|
||||
const loadCurrentUser = async () => {
|
||||
try {
|
||||
const user = await authApi.getCurrentUser();
|
||||
setCurrentUser(user);
|
||||
} catch (error) {
|
||||
console.error('获取用户信息失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await authApi.logout();
|
||||
message.success('已退出登录');
|
||||
window.location.href = '/login';
|
||||
} catch (error) {
|
||||
console.error('退出登录失败:', error);
|
||||
message.error('退出登录失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleShowUserManagement = async () => {
|
||||
if (!currentUser?.is_admin) {
|
||||
message.warning('只有管理员可以访问用户管理');
|
||||
return;
|
||||
}
|
||||
|
||||
setShowUserManagement(true);
|
||||
loadUsers();
|
||||
};
|
||||
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const userList = await userApi.listUsers();
|
||||
setUsers(userList);
|
||||
} catch (error) {
|
||||
console.error('获取用户列表失败:', error);
|
||||
message.error('获取用户列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetAdmin = async (userId: string, isAdmin: boolean) => {
|
||||
try {
|
||||
await userApi.setAdmin(userId, isAdmin);
|
||||
message.success(isAdmin ? '已设置为管理员' : '已取消管理员权限');
|
||||
loadUsers();
|
||||
} catch (error) {
|
||||
console.error('设置管理员失败:', error);
|
||||
message.error('设置管理员失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteUser = async (userId: string) => {
|
||||
try {
|
||||
await userApi.deleteUser(userId);
|
||||
message.success('用户已删除');
|
||||
loadUsers();
|
||||
} catch (error) {
|
||||
console.error('删除用户失败:', error);
|
||||
message.error('删除用户失败');
|
||||
}
|
||||
};
|
||||
|
||||
const menuItems: MenuProps['items'] = [
|
||||
{
|
||||
key: 'user-info',
|
||||
label: (
|
||||
<div style={{ padding: '8px 0' }}>
|
||||
<Text strong>{currentUser?.display_name || currentUser?.username}</Text>
|
||||
<br />
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Trust Level: {currentUser?.trust_level}
|
||||
{currentUser?.is_admin && ' · 管理员'}
|
||||
</Text>
|
||||
</div>
|
||||
),
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
...(currentUser?.is_admin ? [{
|
||||
key: 'user-management',
|
||||
icon: <TeamOutlined />,
|
||||
label: '用户管理',
|
||||
onClick: handleShowUserManagement,
|
||||
}, {
|
||||
type: 'divider' as const,
|
||||
}] : []),
|
||||
{
|
||||
key: 'logout',
|
||||
icon: <LogoutOutlined />,
|
||||
label: '退出登录',
|
||||
onClick: handleLogout,
|
||||
},
|
||||
];
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '用户名',
|
||||
dataIndex: 'username',
|
||||
key: 'username',
|
||||
render: (text: string, record: User) => (
|
||||
<Space>
|
||||
<Avatar src={record.avatar_url} icon={<UserOutlined />} size="small" />
|
||||
<div>
|
||||
<div>{record.display_name || text}</div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{text}</Text>
|
||||
</div>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Trust Level',
|
||||
dataIndex: 'trust_level',
|
||||
key: 'trust_level',
|
||||
width: 120,
|
||||
render: (level: number) => <Tag color="blue">{level}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '角色',
|
||||
dataIndex: 'is_admin',
|
||||
key: 'is_admin',
|
||||
width: 100,
|
||||
render: (isAdmin: boolean) => (
|
||||
isAdmin ? <Tag color="gold" icon={<CrownOutlined />}>管理员</Tag> : <Tag>普通用户</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '最后登录',
|
||||
dataIndex: 'last_login',
|
||||
key: 'last_login',
|
||||
width: 180,
|
||||
render: (date: string) => new Date(date).toLocaleString('zh-CN'),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 200,
|
||||
render: (_: unknown, record: User) => {
|
||||
const isSelf = record.user_id === currentUser?.user_id;
|
||||
return (
|
||||
<Space>
|
||||
{record.is_admin ? (
|
||||
<Popconfirm
|
||||
title="确定要取消管理员权限吗?"
|
||||
onConfirm={() => handleSetAdmin(record.user_id, false)}
|
||||
disabled={isSelf}
|
||||
>
|
||||
<Button size="small" disabled={isSelf}>
|
||||
取消管理员
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
) : (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
onClick={() => handleSetAdmin(record.user_id, true)}
|
||||
>
|
||||
设为管理员
|
||||
</Button>
|
||||
)}
|
||||
<Popconfirm
|
||||
title="确定要删除该用户吗?此操作不可恢复!"
|
||||
onConfirm={() => handleDeleteUser(record.user_id)}
|
||||
disabled={isSelf}
|
||||
>
|
||||
<Button size="small" danger disabled={isSelf}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (!currentUser) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dropdown menu={{ items: menuItems }} placement="bottomRight">
|
||||
<div
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: '8px 16px',
|
||||
background: 'rgba(255, 255, 255, 0.95)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
WebkitBackdropFilter: 'blur(10px)',
|
||||
borderRadius: 24,
|
||||
border: '1px solid rgba(102, 126, 234, 0.2)',
|
||||
transition: 'all 0.3s ease',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 1)';
|
||||
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(102, 126, 234, 0.3)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.95)';
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.1)';
|
||||
}}
|
||||
>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Avatar
|
||||
src={currentUser.avatar_url}
|
||||
icon={<UserOutlined />}
|
||||
size={40}
|
||||
style={{
|
||||
backgroundColor: '#1890ff',
|
||||
border: '3px solid #fff',
|
||||
boxShadow: '0 2px 8px rgba(102, 126, 234, 0.3)',
|
||||
}}
|
||||
/>
|
||||
{currentUser.is_admin && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: -2,
|
||||
right: -2,
|
||||
width: 18,
|
||||
height: 18,
|
||||
background: 'linear-gradient(135deg, #ffd700 0%, #ffaa00 100%)',
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
border: '2px solid white',
|
||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.2)',
|
||||
}}>
|
||||
<CrownOutlined style={{ fontSize: 9, color: '#fff' }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Space direction="vertical" size={0} style={{ display: window.innerWidth <= 768 ? 'none' : 'flex' }}>
|
||||
<Text strong style={{
|
||||
color: '#262626',
|
||||
fontSize: 14,
|
||||
lineHeight: '20px',
|
||||
}}>
|
||||
{currentUser.display_name || currentUser.username}
|
||||
</Text>
|
||||
<Text style={{
|
||||
color: '#8c8c8c',
|
||||
fontSize: 12,
|
||||
lineHeight: '18px',
|
||||
}}>
|
||||
{currentUser.is_admin ? '👑 管理员' : `🎖️ Trust Level ${currentUser.trust_level}`}
|
||||
</Text>
|
||||
</Space>
|
||||
</div>
|
||||
</Dropdown>
|
||||
|
||||
<Modal
|
||||
title="用户管理"
|
||||
open={showUserManagement}
|
||||
onCancel={() => setShowUserManagement(false)}
|
||||
footer={null}
|
||||
width={900}
|
||||
centered
|
||||
styles={{
|
||||
body: {
|
||||
padding: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: 'calc(100vh - 200px)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
}}>
|
||||
<div style={{
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
padding: '0 12px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={users.slice((currentPage - 1) * pageSize, currentPage * pageSize)}
|
||||
rowKey="user_id"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
scroll={{ x: 800, y: 'calc(100vh - 340px)' }}
|
||||
sticky
|
||||
/>
|
||||
</div>
|
||||
<div style={{
|
||||
padding: '16px 24px',
|
||||
borderTop: '1px solid #f0f0f0',
|
||||
background: '#fff',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<Pagination
|
||||
current={currentPage}
|
||||
pageSize={pageSize}
|
||||
total={users.length}
|
||||
showSizeChanger
|
||||
showTotal={(total) => `共 ${total} 个用户`}
|
||||
pageSizeOptions={['10', '20', '50', '100']}
|
||||
onChange={(page, newPageSize) => {
|
||||
setCurrentPage(page);
|
||||
setPageSize(newPageSize);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
:root {
|
||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(0, 0, 0, 0.87);
|
||||
background-color: #f0f2f5;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
/* 移动端视口适配 */
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
/* 禁止移动端双击缩放 */
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 自定义滚动条样式 */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 4px;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
|
||||
/* Firefox 滚动条样式 */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #c1c1c1 #f1f1f1;
|
||||
}
|
||||
|
||||
/* 移动端响应式样式 */
|
||||
@media (max-width: 768px) {
|
||||
:root {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 移动端隐藏滚动条 */
|
||||
::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
/* 移动端优化触摸区域 */
|
||||
button, a, [role="button"] {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
:root {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 移动端安全区域适配 (iPhone X+) */
|
||||
@supports (padding: max(0px)) {
|
||||
body {
|
||||
padding-left: env(safe-area-inset-left);
|
||||
padding-right: env(safe-area-inset-right);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
}
|
||||
|
||||
/* 移动端禁止长按选择 */
|
||||
@media (max-width: 768px) {
|
||||
img, button {
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
|
||||
/* 修复移动端表格分页器对齐问题 */
|
||||
.ant-pagination-simple {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ant-pagination-simple .ant-pagination-simple-pager {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
.ant-pagination-simple .ant-pagination-simple-pager input {
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.ant-pagination-simple .ant-pagination-prev,
|
||||
.ant-pagination-simple .ant-pagination-next {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { ConfigProvider } from 'antd'
|
||||
import zhCN from 'antd/locale/zh_CN'
|
||||
import 'antd/dist/reset.css'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<ConfigProvider locale={zhCN}>
|
||||
<App />
|
||||
</ConfigProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Spin, Result, Button } from 'antd';
|
||||
import { authApi } from '../services/api';
|
||||
|
||||
export default function AuthCallback() {
|
||||
const navigate = useNavigate();
|
||||
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const handleCallback = async () => {
|
||||
try {
|
||||
// 后端会通过 Cookie 自动设置认证信息
|
||||
// 这里只需要验证登录状态
|
||||
await authApi.getCurrentUser();
|
||||
|
||||
setStatus('success');
|
||||
|
||||
// 从 sessionStorage 获取重定向地址
|
||||
const redirect = sessionStorage.getItem('login_redirect') || '/';
|
||||
sessionStorage.removeItem('login_redirect');
|
||||
|
||||
// 延迟一下再跳转,让用户看到成功提示
|
||||
setTimeout(() => {
|
||||
navigate(redirect);
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
console.error('登录失败:', error);
|
||||
setStatus('error');
|
||||
setErrorMessage('登录失败,请重试');
|
||||
}
|
||||
};
|
||||
|
||||
handleCallback();
|
||||
}, [navigate]);
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
}}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Spin size="large" />
|
||||
<div style={{ marginTop: 20, color: 'white', fontSize: 16 }}>
|
||||
正在处理登录...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'error') {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
}}>
|
||||
<Result
|
||||
status="error"
|
||||
title="登录失败"
|
||||
subTitle={errorMessage}
|
||||
extra={
|
||||
<Button type="primary" onClick={() => navigate('/login')}>
|
||||
返回登录
|
||||
</Button>
|
||||
}
|
||||
style={{ background: 'white', padding: 40, borderRadius: 8 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
}}>
|
||||
<Result
|
||||
status="success"
|
||||
title="登录成功"
|
||||
subTitle="正在跳转..."
|
||||
style={{ background: 'white', padding: 40, borderRadius: 8 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,570 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { List, Button, Modal, Form, Input, Select, message, Empty, Space, Badge, Tag, Card, Tooltip } from 'antd';
|
||||
import { EditOutlined, FileTextOutlined, ThunderboltOutlined, LockOutlined, DownloadOutlined, SettingOutlined } from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import { useChapterSync } from '../store/hooks';
|
||||
import { projectApi } from '../services/api';
|
||||
import type { Chapter, ChapterUpdate, ApiError } from '../types';
|
||||
import { cardStyles } from '../components/CardStyles';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
export default function Chapters() {
|
||||
const { currentProject, chapters, setCurrentChapter, setCurrentProject } = useStore();
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isEditorOpen, setIsEditorOpen] = useState(false);
|
||||
const [isContinuing, setIsContinuing] = useState(false);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [editorForm] = Form.useForm();
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth <= 768);
|
||||
const contentTextAreaRef = useRef<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setIsMobile(window.innerWidth <= 768);
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
const {
|
||||
refreshChapters,
|
||||
updateChapter,
|
||||
generateChapterContentStream
|
||||
} = useChapterSync();
|
||||
|
||||
useEffect(() => {
|
||||
if (currentProject?.id) {
|
||||
refreshChapters();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentProject?.id]);
|
||||
|
||||
if (!currentProject) return null;
|
||||
|
||||
const canGenerateChapter = (chapter: Chapter): boolean => {
|
||||
if (chapter.chapter_number === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const previousChapters = chapters.filter(
|
||||
c => c.chapter_number < chapter.chapter_number
|
||||
);
|
||||
|
||||
return previousChapters.every(c => c.content && c.content.trim() !== '');
|
||||
};
|
||||
|
||||
const getGenerateDisabledReason = (chapter: Chapter): string => {
|
||||
if (chapter.chapter_number === 1) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const previousChapters = chapters.filter(
|
||||
c => c.chapter_number < chapter.chapter_number
|
||||
);
|
||||
|
||||
const incompleteChapters = previousChapters.filter(
|
||||
c => !c.content || c.content.trim() === ''
|
||||
);
|
||||
|
||||
if (incompleteChapters.length > 0) {
|
||||
const numbers = incompleteChapters.map(c => c.chapter_number).join('、');
|
||||
return `需要先完成前置章节:第 ${numbers} 章`;
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
const handleOpenModal = (id: string) => {
|
||||
const chapter = chapters.find(c => c.id === id);
|
||||
if (chapter) {
|
||||
form.setFieldsValue(chapter);
|
||||
setEditingId(id);
|
||||
setIsModalOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (values: ChapterUpdate) => {
|
||||
if (!editingId) return;
|
||||
|
||||
try {
|
||||
await updateChapter(editingId, values);
|
||||
message.success('章节更新成功');
|
||||
setIsModalOpen(false);
|
||||
form.resetFields();
|
||||
} catch {
|
||||
message.error('操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenEditor = (id: string) => {
|
||||
const chapter = chapters.find(c => c.id === id);
|
||||
if (chapter) {
|
||||
setCurrentChapter(chapter);
|
||||
editorForm.setFieldsValue({
|
||||
title: chapter.title,
|
||||
content: chapter.content,
|
||||
});
|
||||
setEditingId(id);
|
||||
setIsEditorOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditorSubmit = async (values: ChapterUpdate) => {
|
||||
if (!editingId || !currentProject) return;
|
||||
|
||||
try {
|
||||
await updateChapter(editingId, values);
|
||||
|
||||
// 刷新项目信息以更新总字数统计
|
||||
const updatedProject = await projectApi.getProject(currentProject.id);
|
||||
setCurrentProject(updatedProject);
|
||||
|
||||
message.success('章节保存成功');
|
||||
setIsEditorOpen(false);
|
||||
} catch {
|
||||
message.error('保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!editingId) return;
|
||||
|
||||
try {
|
||||
setIsContinuing(true);
|
||||
setIsGenerating(true);
|
||||
|
||||
await generateChapterContentStream(editingId, (content) => {
|
||||
editorForm.setFieldsValue({ content });
|
||||
|
||||
if (contentTextAreaRef.current) {
|
||||
const textArea = contentTextAreaRef.current.resizableTextArea?.textArea;
|
||||
if (textArea) {
|
||||
textArea.scrollTop = textArea.scrollHeight;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
message.success('AI创作成功');
|
||||
} catch (error) {
|
||||
const apiError = error as ApiError;
|
||||
message.error('AI创作失败:' + (apiError.response?.data?.detail || apiError.message || '未知错误'));
|
||||
} finally {
|
||||
setIsContinuing(false);
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const showGenerateModal = (chapter: Chapter) => {
|
||||
const previousChapters = chapters.filter(
|
||||
c => c.chapter_number < chapter.chapter_number
|
||||
).sort((a, b) => a.chapter_number - b.chapter_number);
|
||||
|
||||
const modal = Modal.confirm({
|
||||
title: 'AI创作章节内容',
|
||||
width: 700,
|
||||
centered: true,
|
||||
content: (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<p>AI将根据以下信息创作本章内容:</p>
|
||||
<ul>
|
||||
<li>章节大纲和要求</li>
|
||||
<li>项目的世界观设定</li>
|
||||
<li>相关角色信息</li>
|
||||
<li><strong>前面已完成章节的内容(确保剧情连贯)</strong></li>
|
||||
</ul>
|
||||
|
||||
{previousChapters.length > 0 && (
|
||||
<div style={{
|
||||
marginTop: 16,
|
||||
padding: 12,
|
||||
background: '#f0f5ff',
|
||||
borderRadius: 4,
|
||||
border: '1px solid #adc6ff'
|
||||
}}>
|
||||
<div style={{ marginBottom: 8, fontWeight: 500, color: '#1890ff' }}>
|
||||
📚 将引用的前置章节(共{previousChapters.length}章):
|
||||
</div>
|
||||
<div style={{ maxHeight: 150, overflowY: 'auto' }}>
|
||||
{previousChapters.map(ch => (
|
||||
<div key={ch.id} style={{ padding: '4px 0', fontSize: 13 }}>
|
||||
✓ 第{ch.chapter_number}章:{ch.title} ({ch.word_count || 0}字)
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: '#666' }}>
|
||||
💡 AI会参考这些章节内容,确保情节连贯、角色状态一致
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p style={{ color: '#ff4d4f', marginTop: 16, marginBottom: 0 }}>
|
||||
⚠️ 注意:此操作将覆盖当前章节内容
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
okText: '开始创作',
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
modal.update({
|
||||
okButtonProps: { danger: true, loading: true },
|
||||
cancelButtonProps: { disabled: true },
|
||||
closable: false,
|
||||
maskClosable: false,
|
||||
keyboard: false,
|
||||
});
|
||||
|
||||
try {
|
||||
await handleGenerate();
|
||||
modal.destroy();
|
||||
} catch (error) {
|
||||
modal.update({
|
||||
okButtonProps: { danger: true, loading: false },
|
||||
cancelButtonProps: { disabled: false },
|
||||
closable: true,
|
||||
maskClosable: true,
|
||||
keyboard: true,
|
||||
});
|
||||
}
|
||||
},
|
||||
onCancel: () => {
|
||||
if (isGenerating) {
|
||||
message.warning('AI正在创作中,请等待完成');
|
||||
return false;
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
'draft': 'default',
|
||||
'writing': 'processing',
|
||||
'completed': 'success',
|
||||
};
|
||||
return colors[status] || 'default';
|
||||
};
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
const texts: Record<string, string> = {
|
||||
'draft': '草稿',
|
||||
'writing': '创作中',
|
||||
'completed': '已完成',
|
||||
};
|
||||
return texts[status] || status;
|
||||
};
|
||||
|
||||
const sortedChapters = [...chapters].sort((a, b) => a.chapter_number - b.chapter_number);
|
||||
|
||||
const handleExport = () => {
|
||||
if (chapters.length === 0) {
|
||||
message.warning('当前项目没有章节,无法导出');
|
||||
return;
|
||||
}
|
||||
|
||||
Modal.confirm({
|
||||
title: '导出项目章节',
|
||||
content: `确定要将《${currentProject.title}》的所有章节导出为TXT文件吗?`,
|
||||
centered: true,
|
||||
okText: '确定导出',
|
||||
cancelText: '取消',
|
||||
onOk: () => {
|
||||
try {
|
||||
projectApi.exportProject(currentProject.id);
|
||||
message.success('开始下载导出文件');
|
||||
} catch {
|
||||
message.error('导出失败,请重试');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div style={{
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 10,
|
||||
backgroundColor: '#fff',
|
||||
padding: isMobile ? '12px 0' : '16px 0',
|
||||
marginBottom: isMobile ? 12 : 16,
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
display: 'flex',
|
||||
flexDirection: isMobile ? 'column' : 'row',
|
||||
gap: isMobile ? 12 : 0,
|
||||
justifyContent: 'space-between',
|
||||
alignItems: isMobile ? 'stretch' : 'center'
|
||||
}}>
|
||||
<h2 style={{ margin: 0, fontSize: isMobile ? 18 : 24 }}>章节管理</h2>
|
||||
<Space direction={isMobile ? 'vertical' : 'horizontal'} style={{ width: isMobile ? '100%' : 'auto' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={handleExport}
|
||||
disabled={chapters.length === 0}
|
||||
block={isMobile}
|
||||
size={isMobile ? 'middle' : 'middle'}
|
||||
>
|
||||
导出为TXT
|
||||
</Button>
|
||||
{!isMobile && <Tag color="blue">章节由大纲管理,请在大纲页面添加/删除</Tag>}
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
{chapters.length === 0 ? (
|
||||
<Empty description="还没有章节,开始创作吧!" />
|
||||
) : (
|
||||
<Card style={cardStyles.base}>
|
||||
<List
|
||||
dataSource={sortedChapters}
|
||||
renderItem={(item) => (
|
||||
<List.Item
|
||||
style={{
|
||||
padding: '16px 0',
|
||||
borderRadius: 8,
|
||||
transition: 'background 0.3s ease',
|
||||
flexDirection: isMobile ? 'column' : 'row',
|
||||
alignItems: isMobile ? 'flex-start' : 'center'
|
||||
}}
|
||||
actions={isMobile ? undefined : [
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleOpenEditor(item.id)}
|
||||
>
|
||||
编辑内容
|
||||
</Button>,
|
||||
<Button
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleOpenModal(item.id)}
|
||||
>
|
||||
修改信息
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<div style={{ width: '100%' }}>
|
||||
<List.Item.Meta
|
||||
avatar={!isMobile && <FileTextOutlined style={{ fontSize: 32, color: '#1890ff' }} />}
|
||||
title={
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: isMobile ? 4 : 8, flexWrap: 'wrap', fontSize: isMobile ? 14 : 16 }}>
|
||||
<span>第{item.chapter_number}章:{item.title}</span>
|
||||
<Tag color={getStatusColor(item.status)}>{getStatusText(item.status)}</Tag>
|
||||
<Badge count={`${item.word_count || 0}字`} style={{ backgroundColor: '#52c41a' }} />
|
||||
{!canGenerateChapter(item) && (
|
||||
<Tooltip title={getGenerateDisabledReason(item)}>
|
||||
<Tag icon={<LockOutlined />} color="warning">
|
||||
需前置章节
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
description={
|
||||
item.content ? (
|
||||
<div style={{ marginTop: 8, color: 'rgba(0,0,0,0.65)', lineHeight: 1.6, fontSize: isMobile ? 12 : 14 }}>
|
||||
{item.content.substring(0, isMobile ? 80 : 150)}
|
||||
{item.content.length > (isMobile ? 80 : 150) && '...'}
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ color: 'rgba(0,0,0,0.45)', fontSize: isMobile ? 12 : 14 }}>暂无内容</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{isMobile && (
|
||||
<Space style={{ marginTop: 12, width: '100%', justifyContent: 'flex-end' }} wrap>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleOpenEditor(item.id)}
|
||||
size="small"
|
||||
title="编辑内容"
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<SettingOutlined />}
|
||||
onClick={() => handleOpenModal(item.id)}
|
||||
size="small"
|
||||
title="修改信息"
|
||||
/>
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title={editingId ? '编辑章节信息' : '添加章节'}
|
||||
open={isModalOpen}
|
||||
onCancel={() => setIsModalOpen(false)}
|
||||
footer={null}
|
||||
centered={!isMobile}
|
||||
width={isMobile ? 'calc(100% - 32px)' : 520}
|
||||
style={isMobile ? {
|
||||
top: 20,
|
||||
paddingBottom: 0,
|
||||
maxWidth: 'calc(100vw - 32px)',
|
||||
margin: '0 16px'
|
||||
} : undefined}
|
||||
styles={{
|
||||
body: {
|
||||
maxHeight: isMobile ? 'calc(100vh - 150px)' : 'calc(80vh - 110px)',
|
||||
overflowY: 'auto'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||
<Form.Item
|
||||
label="章节标题"
|
||||
name="title"
|
||||
tooltip="章节标题由大纲管理,建议在大纲页面统一修改"
|
||||
>
|
||||
<Input placeholder="输入章节标题" disabled />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="章节序号"
|
||||
name="chapter_number"
|
||||
tooltip="章节序号由大纲的顺序决定,无法修改。请在大纲页面使用上移/下移功能调整顺序"
|
||||
>
|
||||
<Input type="number" placeholder="章节排序序号" disabled />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="状态" name="status">
|
||||
<Select placeholder="选择状态">
|
||||
<Select.Option value="draft">草稿</Select.Option>
|
||||
<Select.Option value="writing">创作中</Select.Option>
|
||||
<Select.Option value="completed">已完成</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Space style={{ float: 'right' }}>
|
||||
<Button onClick={() => setIsModalOpen(false)}>取消</Button>
|
||||
<Button type="primary" htmlType="submit">
|
||||
更新
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="编辑章节内容"
|
||||
open={isEditorOpen}
|
||||
onCancel={() => {
|
||||
if (isGenerating) {
|
||||
message.warning('AI正在创作中,请等待完成后再关闭');
|
||||
return;
|
||||
}
|
||||
setIsEditorOpen(false);
|
||||
}}
|
||||
closable={!isGenerating}
|
||||
maskClosable={!isGenerating}
|
||||
keyboard={!isGenerating}
|
||||
width={isMobile ? 'calc(100% - 32px)' : '85%'}
|
||||
centered={!isMobile}
|
||||
style={isMobile ? {
|
||||
top: 20,
|
||||
paddingBottom: 0,
|
||||
maxWidth: 'calc(100vw - 32px)',
|
||||
margin: '0 16px'
|
||||
} : undefined}
|
||||
styles={{
|
||||
body: {
|
||||
maxHeight: isMobile ? 'calc(100vh - 150px)' : 'calc(85vh - 110px)',
|
||||
overflowY: 'auto',
|
||||
padding: isMobile ? '16px 12px' : '8px'
|
||||
}
|
||||
}}
|
||||
footer={null}
|
||||
>
|
||||
<Form form={editorForm} layout="vertical" onFinish={handleEditorSubmit}>
|
||||
<Form.Item
|
||||
label="章节标题"
|
||||
tooltip="章节标题由大纲统一管理,建议在大纲页面修改以保持一致性"
|
||||
>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Form.Item
|
||||
name="title"
|
||||
noStyle
|
||||
>
|
||||
<Input size="large" disabled style={{ flex: 1 }} />
|
||||
</Form.Item>
|
||||
{editingId && (() => {
|
||||
const currentChapter = chapters.find(c => c.id === editingId);
|
||||
const canGenerate = currentChapter ? canGenerateChapter(currentChapter) : false;
|
||||
const disabledReason = currentChapter ? getGenerateDisabledReason(currentChapter) : '';
|
||||
|
||||
return (
|
||||
<Tooltip title={!canGenerate ? disabledReason : '根据大纲和前置章节内容创作'}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={canGenerate ? <ThunderboltOutlined /> : <LockOutlined />}
|
||||
onClick={() => currentChapter && showGenerateModal(currentChapter)}
|
||||
loading={isContinuing}
|
||||
disabled={!canGenerate}
|
||||
danger={!canGenerate}
|
||||
size="large"
|
||||
style={{ fontWeight: 'bold' }}
|
||||
>
|
||||
{isMobile ? 'AI创作' : 'AI创作章节内容'}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
})()}
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="章节内容" name="content">
|
||||
<TextArea
|
||||
ref={contentTextAreaRef}
|
||||
rows={isMobile ? 12 : 20}
|
||||
placeholder="开始写作..."
|
||||
style={{ fontFamily: 'monospace', fontSize: isMobile ? 12 : 14 }}
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Space style={{ width: '100%', justifyContent: 'flex-end', flexDirection: isMobile ? 'column' : 'row', alignItems: isMobile ? 'stretch' : 'center' }}>
|
||||
<Space style={{ width: isMobile ? '100%' : 'auto' }}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (isGenerating) {
|
||||
message.warning('AI正在创作中,请等待完成后再关闭');
|
||||
return;
|
||||
}
|
||||
setIsEditorOpen(false);
|
||||
}}
|
||||
block={isMobile}
|
||||
disabled={isGenerating}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
block={isMobile}
|
||||
disabled={isGenerating}
|
||||
>
|
||||
保存章节
|
||||
</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button, Modal, Form, Input, Select, message, Row, Col, Empty, Tabs, Divider, Typography, Space } from 'antd';
|
||||
import { ThunderboltOutlined, UserOutlined, TeamOutlined } from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import { useCharacterSync } from '../store/hooks';
|
||||
import { characterGridConfig } from '../components/CardStyles';
|
||||
import { CharacterCard } from '../components/CharacterCard';
|
||||
import type { Character, CharacterUpdate } from '../types';
|
||||
import { characterApi } from '../services/api';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
export default function Characters() {
|
||||
const { currentProject, characters } = useStore();
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<'all' | 'character' | 'organization'>('all');
|
||||
const [generateForm] = Form.useForm();
|
||||
const [editForm] = Form.useForm();
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [editingCharacter, setEditingCharacter] = useState<Character | null>(null);
|
||||
|
||||
const {
|
||||
refreshCharacters,
|
||||
deleteCharacter,
|
||||
generateCharacter
|
||||
} = useCharacterSync();
|
||||
|
||||
useEffect(() => {
|
||||
if (currentProject?.id) {
|
||||
refreshCharacters();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentProject?.id]);
|
||||
|
||||
if (!currentProject) return null;
|
||||
|
||||
const handleDeleteCharacter = async (id: string) => {
|
||||
try {
|
||||
await deleteCharacter(id);
|
||||
message.success('删除成功');
|
||||
} catch {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerate = async (values: { name?: string; role_type: string; background?: string }) => {
|
||||
try {
|
||||
setIsGenerating(true);
|
||||
await generateCharacter({
|
||||
project_id: currentProject.id,
|
||||
name: values.name,
|
||||
role_type: values.role_type,
|
||||
background: values.background,
|
||||
});
|
||||
message.success('AI生成角色成功');
|
||||
Modal.destroyAll();
|
||||
} catch {
|
||||
message.error('AI生成失败');
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditCharacter = (character: Character) => {
|
||||
setEditingCharacter(character);
|
||||
editForm.setFieldsValue(character);
|
||||
setIsEditModalOpen(true);
|
||||
};
|
||||
|
||||
const handleUpdateCharacter = async (values: CharacterUpdate) => {
|
||||
if (!editingCharacter) return;
|
||||
|
||||
try {
|
||||
await characterApi.updateCharacter(editingCharacter.id, values);
|
||||
message.success('更新成功');
|
||||
setIsEditModalOpen(false);
|
||||
editForm.resetFields();
|
||||
setEditingCharacter(null);
|
||||
await refreshCharacters();
|
||||
} catch {
|
||||
message.error('更新失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteCharacterWrapper = (id: string) => {
|
||||
handleDeleteCharacter(id);
|
||||
};
|
||||
|
||||
const showGenerateModal = () => {
|
||||
Modal.confirm({
|
||||
title: 'AI生成角色',
|
||||
width: 600,
|
||||
centered: true,
|
||||
content: (
|
||||
<Form form={generateForm} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item
|
||||
label="角色名称"
|
||||
name="name"
|
||||
>
|
||||
<Input placeholder="如:张三、李四(可选,AI会自动生成)" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="角色定位"
|
||||
name="role_type"
|
||||
rules={[{ required: true, message: '请选择角色定位' }]}
|
||||
>
|
||||
<Select placeholder="选择角色定位">
|
||||
<Select.Option value="protagonist">主角</Select.Option>
|
||||
<Select.Option value="supporting">配角</Select.Option>
|
||||
<Select.Option value="antagonist">反派</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label="背景设定" name="background">
|
||||
<TextArea rows={3} placeholder="简要描述角色背景和故事环境..." />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
),
|
||||
okText: '生成',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
const values = await generateForm.validateFields();
|
||||
await handleGenerate(values);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const characterList = characters.filter(c => !c.is_organization);
|
||||
const organizationList = characters.filter(c => c.is_organization);
|
||||
|
||||
const getDisplayList = () => {
|
||||
if (activeTab === 'character') return characterList;
|
||||
if (activeTab === 'organization') return organizationList;
|
||||
return characters;
|
||||
};
|
||||
|
||||
const displayList = getDisplayList();
|
||||
|
||||
const isMobile = window.innerWidth <= 768;
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div style={{
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 10,
|
||||
backgroundColor: '#fff',
|
||||
padding: isMobile ? '12px 0' : '16px 0',
|
||||
marginBottom: isMobile ? 12 : 16,
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
display: 'flex',
|
||||
flexDirection: isMobile ? 'column' : 'row',
|
||||
gap: isMobile ? 12 : 0,
|
||||
justifyContent: 'space-between',
|
||||
alignItems: isMobile ? 'stretch' : 'center'
|
||||
}}>
|
||||
<h2 style={{ margin: 0, fontSize: isMobile ? 18 : 24 }}>角色与组织管理</h2>
|
||||
<Button
|
||||
type="dashed"
|
||||
icon={<ThunderboltOutlined />}
|
||||
onClick={showGenerateModal}
|
||||
loading={isGenerating}
|
||||
block={isMobile}
|
||||
>
|
||||
AI生成角色
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{characters.length > 0 && (
|
||||
<div style={{
|
||||
position: 'sticky',
|
||||
top: isMobile ? 60 : 72,
|
||||
zIndex: 9,
|
||||
backgroundColor: '#fff',
|
||||
paddingBottom: 8,
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
}}>
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={(key) => setActiveTab(key as 'all' | 'character' | 'organization')}
|
||||
items={[
|
||||
{
|
||||
key: 'all',
|
||||
label: `全部 (${characters.length})`,
|
||||
},
|
||||
{
|
||||
key: 'character',
|
||||
label: (
|
||||
<span>
|
||||
<UserOutlined /> 角色 ({characterList.length})
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'organization',
|
||||
label: (
|
||||
<span>
|
||||
<TeamOutlined /> 组织 ({organizationList.length})
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
{characters.length === 0 ? (
|
||||
<Empty description="还没有角色或组织,开始创建吧!" />
|
||||
) : (
|
||||
<>
|
||||
<Row gutter={isMobile ? [8, 8] : characterGridConfig.gutter}>
|
||||
{activeTab === 'all' && (
|
||||
<>
|
||||
{characterList.length > 0 && (
|
||||
<>
|
||||
<Col span={24}>
|
||||
<Divider orientation="left">
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
<UserOutlined style={{ marginRight: 8 }} />
|
||||
角色 ({characterList.length})
|
||||
</Title>
|
||||
</Divider>
|
||||
</Col>
|
||||
{characterList.map((character) => (
|
||||
<Col
|
||||
xs={24}
|
||||
sm={characterGridConfig.sm}
|
||||
md={characterGridConfig.md}
|
||||
lg={characterGridConfig.lg}
|
||||
xl={characterGridConfig.xl}
|
||||
key={character.id}
|
||||
style={{ padding: isMobile ? '4px' : '8px' }}
|
||||
>
|
||||
<CharacterCard
|
||||
character={character}
|
||||
onEdit={handleEditCharacter}
|
||||
onDelete={handleDeleteCharacterWrapper}
|
||||
/>
|
||||
</Col>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{organizationList.length > 0 && (
|
||||
<>
|
||||
<Col span={24}>
|
||||
<Divider orientation="left">
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
<TeamOutlined style={{ marginRight: 8 }} />
|
||||
组织 ({organizationList.length})
|
||||
</Title>
|
||||
</Divider>
|
||||
</Col>
|
||||
{organizationList.map((org) => (
|
||||
<Col
|
||||
xs={24}
|
||||
sm={characterGridConfig.sm}
|
||||
md={characterGridConfig.md}
|
||||
lg={characterGridConfig.lg}
|
||||
xl={characterGridConfig.xl}
|
||||
key={org.id}
|
||||
style={{ padding: isMobile ? '4px' : '8px' }}
|
||||
>
|
||||
<CharacterCard
|
||||
character={org}
|
||||
onEdit={handleEditCharacter}
|
||||
onDelete={handleDeleteCharacterWrapper}
|
||||
/>
|
||||
</Col>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'character' && characterList.map((character) => (
|
||||
<Col
|
||||
xs={24}
|
||||
sm={characterGridConfig.sm}
|
||||
md={characterGridConfig.md}
|
||||
lg={characterGridConfig.lg}
|
||||
xl={characterGridConfig.xl}
|
||||
key={character.id}
|
||||
style={{ padding: isMobile ? '4px' : '8px' }}
|
||||
>
|
||||
<CharacterCard
|
||||
character={character}
|
||||
onEdit={handleEditCharacter}
|
||||
onDelete={handleDeleteCharacterWrapper}
|
||||
/>
|
||||
</Col>
|
||||
))}
|
||||
|
||||
{activeTab === 'organization' && organizationList.map((org) => (
|
||||
<Col
|
||||
xs={24}
|
||||
sm={characterGridConfig.sm}
|
||||
md={characterGridConfig.md}
|
||||
lg={characterGridConfig.lg}
|
||||
xl={characterGridConfig.xl}
|
||||
key={org.id}
|
||||
style={{ padding: isMobile ? '4px' : '8px' }}
|
||||
>
|
||||
<CharacterCard
|
||||
character={org}
|
||||
onEdit={handleEditCharacter}
|
||||
onDelete={handleDeleteCharacterWrapper}
|
||||
/>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
{displayList.length === 0 && (
|
||||
<Empty
|
||||
description={
|
||||
activeTab === 'character'
|
||||
? '暂无角色'
|
||||
: activeTab === 'organization'
|
||||
? '暂无组织'
|
||||
: '暂无数据'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title={editingCharacter?.is_organization ? '编辑组织' : '编辑角色'}
|
||||
open={isEditModalOpen}
|
||||
onCancel={() => {
|
||||
setIsEditModalOpen(false);
|
||||
editForm.resetFields();
|
||||
setEditingCharacter(null);
|
||||
}}
|
||||
footer={null}
|
||||
centered={!isMobile}
|
||||
width={isMobile ? '100%' : 600}
|
||||
style={isMobile ? { top: 0, paddingBottom: 0, maxWidth: '100vw' } : undefined}
|
||||
styles={isMobile ? { body: { maxHeight: 'calc(100vh - 110px)', overflowY: 'auto' } } : undefined}
|
||||
>
|
||||
<Form form={editForm} layout="vertical" onFinish={handleUpdateCharacter}>
|
||||
<Row gutter={16}>
|
||||
<Col span={editingCharacter?.is_organization ? 24 : 12}>
|
||||
<Form.Item
|
||||
label={editingCharacter?.is_organization ? '组织名称' : '角色名称'}
|
||||
name="name"
|
||||
rules={[{ required: true, message: `请输入${editingCharacter?.is_organization ? '组织' : '角色'}名称` }]}
|
||||
>
|
||||
<Input placeholder={`输入${editingCharacter?.is_organization ? '组织' : '角色'}名称`} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
|
||||
{!editingCharacter?.is_organization && (
|
||||
<Col span={12}>
|
||||
<Form.Item label="角色定位" name="role_type">
|
||||
<Select>
|
||||
<Select.Option value="protagonist">主角</Select.Option>
|
||||
<Select.Option value="supporting">配角</Select.Option>
|
||||
<Select.Option value="antagonist">反派</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
|
||||
{!editingCharacter?.is_organization && (
|
||||
<>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="年龄" name="age">
|
||||
<Input placeholder="如:25、30岁" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="性别" name="gender">
|
||||
<Select placeholder="选择性别">
|
||||
<Select.Option value="男">男</Select.Option>
|
||||
<Select.Option value="女">女</Select.Option>
|
||||
<Select.Option value="其他">其他</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item label="性格特点" name="personality">
|
||||
<TextArea rows={2} placeholder="描述角色的性格特点..." />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="外貌描写" name="appearance">
|
||||
<TextArea rows={2} placeholder="描述角色的外貌特征..." />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="人际关系" name="relationships">
|
||||
<TextArea rows={2} placeholder="描述角色与其他角色的关系..." />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
{editingCharacter?.is_organization && (
|
||||
<>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
label="组织类型"
|
||||
name="organization_type"
|
||||
rules={[{ required: true, message: '请输入组织类型' }]}
|
||||
>
|
||||
<Input placeholder="如:帮派、公司、门派、学院" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="主要成员" name="organization_members">
|
||||
<Input placeholder="如:张三、李四、王五" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item
|
||||
label="组织目的"
|
||||
name="organization_purpose"
|
||||
rules={[{ required: true, message: '请输入组织目的' }]}
|
||||
>
|
||||
<TextArea rows={2} placeholder="描述组织的宗旨和目标..." />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Form.Item label={editingCharacter?.is_organization ? '组织背景' : '角色背景'} name="background">
|
||||
<TextArea rows={3} placeholder={`描述${editingCharacter?.is_organization ? '组织' : '角色'}的背景故事...`} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
|
||||
<Button onClick={() => {
|
||||
setIsEditModalOpen(false);
|
||||
editForm.resetFields();
|
||||
setEditingCharacter(null);
|
||||
}}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="primary" htmlType="submit">
|
||||
保存
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Card, Space, Typography, message, Spin, Form, Input, Tabs } from 'antd';
|
||||
import { UserOutlined, LockOutlined } from '@ant-design/icons';
|
||||
import { authApi } from '../services/api';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
|
||||
const { Title, Paragraph } = Typography;
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [checking, setChecking] = useState(true);
|
||||
const [localAuthEnabled, setLocalAuthEnabled] = useState(false);
|
||||
const [linuxdoEnabled, setLinuxdoEnabled] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
// 检查是否已登录和获取认证配置
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
await authApi.getCurrentUser();
|
||||
// 已登录,重定向到首页
|
||||
const redirect = searchParams.get('redirect') || '/';
|
||||
navigate(redirect);
|
||||
} catch {
|
||||
// 未登录,获取认证配置
|
||||
try {
|
||||
const config = await authApi.getAuthConfig();
|
||||
setLocalAuthEnabled(config.local_auth_enabled);
|
||||
setLinuxdoEnabled(config.linuxdo_enabled);
|
||||
} catch (error) {
|
||||
console.error('获取认证配置失败:', error);
|
||||
// 默认显示LinuxDO登录
|
||||
setLinuxdoEnabled(true);
|
||||
}
|
||||
setChecking(false);
|
||||
}
|
||||
};
|
||||
checkAuth();
|
||||
}, [navigate, searchParams]);
|
||||
|
||||
const handleLocalLogin = async (values: { username: string; password: string }) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await authApi.localLogin(values.username, values.password);
|
||||
|
||||
if (response.success) {
|
||||
message.success('登录成功!');
|
||||
const redirect = searchParams.get('redirect') || '/';
|
||||
navigate(redirect);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('本地登录失败:', error);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLinuxDOLogin = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await authApi.getLinuxDOAuthUrl();
|
||||
|
||||
// 保存重定向地址到 sessionStorage
|
||||
const redirect = searchParams.get('redirect');
|
||||
if (redirect) {
|
||||
sessionStorage.setItem('login_redirect', redirect);
|
||||
}
|
||||
|
||||
// 跳转到 LinuxDO 授权页面
|
||||
window.location.href = response.auth_url;
|
||||
} catch (error) {
|
||||
console.error('获取授权地址失败:', error);
|
||||
message.error('获取授权地址失败,请稍后重试');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (checking) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
}}>
|
||||
<Spin size="large" style={{ color: '#fff' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 渲染本地登录表单
|
||||
const renderLocalLogin = () => (
|
||||
<Form
|
||||
form={form}
|
||||
onFinish={handleLocalLogin}
|
||||
size="large"
|
||||
style={{ marginTop: '24px' }}
|
||||
>
|
||||
<Form.Item
|
||||
name="username"
|
||||
rules={[{ required: true, message: '请输入用户名' }]}
|
||||
>
|
||||
<Input
|
||||
prefix={<UserOutlined style={{ color: '#999' }} />}
|
||||
placeholder="用户名"
|
||||
autoComplete="username"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="password"
|
||||
rules={[{ required: true, message: '请输入密码' }]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined style={{ color: '#999' }} />}
|
||||
placeholder="密码"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item style={{ marginBottom: 0 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={loading}
|
||||
block
|
||||
style={{
|
||||
height: 48,
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 4px 16px rgba(102, 126, 234, 0.4)',
|
||||
}}
|
||||
>
|
||||
登录
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
);
|
||||
|
||||
// 渲染LinuxDO登录
|
||||
const renderLinuxDOLogin = () => (
|
||||
<div style={{ padding: '24px 0 8px' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
icon={
|
||||
<img
|
||||
src="/favicon.ico"
|
||||
alt="LinuxDO"
|
||||
style={{
|
||||
width: 20,
|
||||
height: 20,
|
||||
marginRight: 8,
|
||||
verticalAlign: 'middle',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
loading={loading}
|
||||
onClick={handleLinuxDOLogin}
|
||||
block
|
||||
style={{
|
||||
height: 52,
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 4px 16px rgba(102, 126, 234, 0.4)',
|
||||
transition: 'all 0.3s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||
e.currentTarget.style.boxShadow = '0 6px 24px rgba(102, 126, 234, 0.5)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 16px rgba(102, 126, 234, 0.4)';
|
||||
}}
|
||||
>
|
||||
使用 LinuxDO 登录
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
padding: '20px',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{/* 装饰性背景元素 */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '-10%',
|
||||
right: '-5%',
|
||||
width: '400px',
|
||||
height: '400px',
|
||||
background: 'rgba(255, 255, 255, 0.1)',
|
||||
borderRadius: '50%',
|
||||
filter: 'blur(60px)',
|
||||
}} />
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: '-10%',
|
||||
left: '-5%',
|
||||
width: '350px',
|
||||
height: '350px',
|
||||
background: 'rgba(255, 255, 255, 0.08)',
|
||||
borderRadius: '50%',
|
||||
filter: 'blur(60px)',
|
||||
}} />
|
||||
|
||||
<Card
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: 420,
|
||||
background: 'rgba(255, 255, 255, 0.95)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
WebkitBackdropFilter: 'blur(20px)',
|
||||
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.3), 0 0 0 1px rgba(255, 255, 255, 0.2)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.3)',
|
||||
borderRadius: '16px',
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
}}
|
||||
bodyStyle={{
|
||||
padding: '40px 32px',
|
||||
}}
|
||||
>
|
||||
<Space direction="vertical" size="large" style={{ width: '100%', textAlign: 'center' }}>
|
||||
{/* Logo区域 */}
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
<div style={{
|
||||
width: '72px',
|
||||
height: '72px',
|
||||
margin: '0 auto 20px',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
borderRadius: '20px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 8px 24px rgba(102, 126, 234, 0.4)',
|
||||
}}>
|
||||
<img
|
||||
src="/logo.svg"
|
||||
alt="Logo"
|
||||
style={{
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
filter: 'brightness(0) invert(1)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Title level={2} style={{
|
||||
marginBottom: 8,
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text',
|
||||
fontWeight: 700,
|
||||
}}>
|
||||
AI小说创作助手
|
||||
</Title>
|
||||
<Paragraph style={{
|
||||
color: '#666',
|
||||
fontSize: '14px',
|
||||
marginBottom: 0,
|
||||
}}>
|
||||
{localAuthEnabled && linuxdoEnabled ? '选择登录方式' :
|
||||
localAuthEnabled ? '使用账户密码登录' :
|
||||
'使用 LinuxDO 账号登录'}
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
{/* 登录方式 */}
|
||||
{localAuthEnabled && linuxdoEnabled ? (
|
||||
<Tabs
|
||||
defaultActiveKey="local"
|
||||
centered
|
||||
items={[
|
||||
{
|
||||
key: 'local',
|
||||
label: '账户密码',
|
||||
children: renderLocalLogin(),
|
||||
},
|
||||
{
|
||||
key: 'linuxdo',
|
||||
label: 'LinuxDO',
|
||||
children: renderLinuxDOLogin(),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : localAuthEnabled ? (
|
||||
renderLocalLogin()
|
||||
) : (
|
||||
renderLinuxDOLogin()
|
||||
)}
|
||||
|
||||
{/* 提示信息 */}
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
background: 'rgba(102, 126, 234, 0.08)',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid rgba(102, 126, 234, 0.1)',
|
||||
}}>
|
||||
<Paragraph style={{
|
||||
fontSize: 13,
|
||||
color: '#666',
|
||||
marginBottom: 0,
|
||||
lineHeight: 1.6,
|
||||
}}>
|
||||
🎉 首次登录将自动创建账号
|
||||
<br />
|
||||
🔒 每个用户拥有独立的数据空间
|
||||
</Paragraph>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Card, Table, Tag, Button, Space, message, Modal, Form, Select, InputNumber, Input, Descriptions } from 'antd';
|
||||
import { PlusOutlined, TeamOutlined, UserOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import axios from 'axios';
|
||||
|
||||
interface Organization {
|
||||
id: string;
|
||||
character_id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
purpose: string;
|
||||
member_count: number;
|
||||
power_level: number;
|
||||
location?: string;
|
||||
motto?: string;
|
||||
}
|
||||
|
||||
interface OrganizationMember {
|
||||
id: string;
|
||||
character_id: string;
|
||||
character_name: string;
|
||||
position: string;
|
||||
rank: number;
|
||||
loyalty: number;
|
||||
contribution: number;
|
||||
status: string;
|
||||
joined_at?: string;
|
||||
}
|
||||
|
||||
interface Character {
|
||||
id: string;
|
||||
name: string;
|
||||
is_organization: boolean;
|
||||
}
|
||||
|
||||
export default function Organizations() {
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
const { currentProject } = useStore();
|
||||
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||
const [selectedOrg, setSelectedOrg] = useState<Organization | null>(null);
|
||||
const [members, setMembers] = useState<OrganizationMember[]>([]);
|
||||
const [characters, setCharacters] = useState<Character[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isAddMemberModalOpen, setIsAddMemberModalOpen] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth <= 768);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setIsMobile(window.innerWidth <= 768);
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
const loadOrganizations = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await axios.get(`/api/organizations/project/${projectId}`);
|
||||
setOrganizations(res.data);
|
||||
if (res.data.length > 0 && !selectedOrg) {
|
||||
setSelectedOrg(res.data[0]);
|
||||
loadMembers(res.data[0].id);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('加载组织列表失败');
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId, selectedOrg]);
|
||||
|
||||
const loadCharacters = useCallback(async () => {
|
||||
try {
|
||||
const res = await axios.get(`/api/characters?project_id=${projectId}`);
|
||||
setCharacters(res.data.items || []);
|
||||
} catch (error) {
|
||||
console.error('加载角色列表失败', error);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (projectId) {
|
||||
loadOrganizations();
|
||||
loadCharacters();
|
||||
}
|
||||
}, [projectId, loadOrganizations, loadCharacters]);
|
||||
|
||||
const loadMembers = async (orgId: string) => {
|
||||
try {
|
||||
const res = await axios.get(`/api/organizations/${orgId}/members`);
|
||||
setMembers(res.data);
|
||||
} catch (error) {
|
||||
message.error('加载成员列表失败');
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectOrganization = (org: Organization) => {
|
||||
setSelectedOrg(org);
|
||||
loadMembers(org.id);
|
||||
};
|
||||
|
||||
const handleAddMember = async (values: Record<string, unknown>) => {
|
||||
if (!selectedOrg) return;
|
||||
|
||||
try {
|
||||
await axios.post(`/api/organizations/${selectedOrg.id}/members`, values);
|
||||
message.success('成员添加成功');
|
||||
setIsAddMemberModalOpen(false);
|
||||
form.resetFields();
|
||||
loadMembers(selectedOrg.id);
|
||||
loadOrganizations(); // 刷新成员计数
|
||||
} catch (error) {
|
||||
message.error('添加成员失败');
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveMember = async (memberId: string) => {
|
||||
Modal.confirm({
|
||||
title: '确认移除',
|
||||
content: '确定要移除该成员吗?',
|
||||
centered: true,
|
||||
okText: '移除',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await axios.delete(`/api/organizations/members/${memberId}`);
|
||||
message.success('成员移除成功');
|
||||
if (selectedOrg) {
|
||||
loadMembers(selectedOrg.id);
|
||||
loadOrganizations(); // 刷新成员计数
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('移除失败');
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
active: 'green',
|
||||
retired: 'default',
|
||||
expelled: 'red',
|
||||
deceased: 'black'
|
||||
};
|
||||
return colors[status] || 'default';
|
||||
};
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
const texts: Record<string, string> = {
|
||||
active: '在职',
|
||||
retired: '退休',
|
||||
expelled: '除名',
|
||||
deceased: '已故'
|
||||
};
|
||||
return texts[status] || status;
|
||||
};
|
||||
|
||||
const memberColumns = [
|
||||
{
|
||||
title: '姓名',
|
||||
dataIndex: 'character_name',
|
||||
key: 'name',
|
||||
render: (name: string) => (
|
||||
<Space>
|
||||
<UserOutlined />
|
||||
<span>{name}</span>
|
||||
</Space>
|
||||
),
|
||||
width: isMobile ? 80 : undefined,
|
||||
},
|
||||
{
|
||||
title: '职位',
|
||||
dataIndex: 'position',
|
||||
key: 'position',
|
||||
render: (position: string, record: OrganizationMember) => (
|
||||
<Tag color="blue">{position} {!isMobile && `(级别 ${record.rank})`}</Tag>
|
||||
),
|
||||
width: isMobile ? 80 : undefined,
|
||||
},
|
||||
...(!isMobile ? [
|
||||
{
|
||||
title: '忠诚度',
|
||||
dataIndex: 'loyalty',
|
||||
key: 'loyalty',
|
||||
render: (loyalty: number) => (
|
||||
<span style={{ color: loyalty >= 70 ? 'green' : loyalty >= 40 ? 'orange' : 'red' }}>
|
||||
{loyalty}%
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '贡献度',
|
||||
dataIndex: 'contribution',
|
||||
key: 'contribution',
|
||||
render: (contribution: number) => `${contribution}%`,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (status: string) => (
|
||||
<Tag color={getStatusColor(status)}>{getStatusText(status)}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '加入时间',
|
||||
dataIndex: 'joined_at',
|
||||
key: 'joined_at',
|
||||
render: (time: string) => time || '-',
|
||||
}
|
||||
] : []),
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render: (_: unknown, record: OrganizationMember) => (
|
||||
<Space>
|
||||
{!isMobile && (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="link"
|
||||
danger
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => handleRemoveMember(record.id)}
|
||||
>
|
||||
{isMobile ? '删除' : '移除'}
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
width: isMobile ? 60 : undefined,
|
||||
fixed: isMobile ? 'right' as const : undefined,
|
||||
},
|
||||
];
|
||||
|
||||
// 过滤掉已是成员的角色
|
||||
const availableCharacters = characters.filter(
|
||||
c => !c.is_organization && !members.some(m => m.character_id === c.id)
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card
|
||||
title={
|
||||
<Space wrap>
|
||||
<TeamOutlined />
|
||||
<span style={{ fontSize: isMobile ? 14 : 16 }}>组织管理</span>
|
||||
{!isMobile && <Tag color="blue">{currentProject?.title}</Tag>}
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<div style={{
|
||||
display: isMobile ? 'flex' : 'grid',
|
||||
flexDirection: isMobile ? 'column' : undefined,
|
||||
gridTemplateColumns: isMobile ? undefined : '300px 1fr',
|
||||
gap: isMobile ? '16px' : '24px',
|
||||
maxHeight: isMobile ? 'calc(100vh - 200px)' : undefined,
|
||||
overflowY: isMobile ? 'auto' : undefined
|
||||
}}>
|
||||
{/* 左侧:组织列表 */}
|
||||
<div>
|
||||
<Card
|
||||
size="small"
|
||||
title={`组织列表 (${organizations.length})`}
|
||||
loading={loading}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{organizations.map(org => (
|
||||
<Card
|
||||
key={org.id}
|
||||
size="small"
|
||||
hoverable
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
border: selectedOrg?.id === org.id ? '2px solid #1890ff' : '1px solid #d9d9d9'
|
||||
}}
|
||||
onClick={() => handleSelectOrganization(org)}
|
||||
>
|
||||
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||
<strong>{org.name}</strong>
|
||||
<Tag>{org.type}</Tag>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>
|
||||
成员: {org.member_count} | 势力: {org.power_level}
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
))}
|
||||
</Space>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 右侧:组织详情和成员 */}
|
||||
<div style={{ minHeight: isMobile ? 'auto' : undefined }}>
|
||||
{selectedOrg ? (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
||||
<Card title="组织详情" size="small">
|
||||
<Descriptions column={isMobile ? 1 : 2} size="small">
|
||||
<Descriptions.Item label="组织名称">{selectedOrg.name}</Descriptions.Item>
|
||||
<Descriptions.Item label="类型">{selectedOrg.type}</Descriptions.Item>
|
||||
<Descriptions.Item label="成员数量">{selectedOrg.member_count}</Descriptions.Item>
|
||||
<Descriptions.Item label="势力等级">{selectedOrg.power_level}</Descriptions.Item>
|
||||
{selectedOrg.location && (
|
||||
<Descriptions.Item label="所在地">{selectedOrg.location}</Descriptions.Item>
|
||||
)}
|
||||
{selectedOrg.motto && (
|
||||
<Descriptions.Item label="宗旨" span={2}>{selectedOrg.motto}</Descriptions.Item>
|
||||
)}
|
||||
<Descriptions.Item label="目标/宗旨" span={2}>
|
||||
{selectedOrg.purpose}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title={`组织成员 (${members.length})`}
|
||||
extra={
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setIsAddMemberModalOpen(true)}
|
||||
disabled={availableCharacters.length === 0}
|
||||
>
|
||||
添加成员
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
columns={memberColumns}
|
||||
dataSource={members}
|
||||
rowKey="id"
|
||||
pagination={isMobile ? { simple: true, pageSize: 10 } : false}
|
||||
size="small"
|
||||
scroll={isMobile ? { x: 'max-content', y: 400 } : undefined}
|
||||
/>
|
||||
</Card>
|
||||
</Space>
|
||||
) : (
|
||||
<Card>
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: '#999' }}>
|
||||
请从左侧选择一个组织查看详情
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 添加成员模态框 */}
|
||||
<Modal
|
||||
title="添加组织成员"
|
||||
open={isAddMemberModalOpen}
|
||||
onCancel={() => {
|
||||
setIsAddMemberModalOpen(false);
|
||||
form.resetFields();
|
||||
}}
|
||||
footer={null}
|
||||
centered={!isMobile}
|
||||
width={isMobile ? '100%' : 500}
|
||||
style={isMobile ? { top: 0, paddingBottom: 0, maxWidth: '100vw' } : undefined}
|
||||
styles={isMobile ? { body: { maxHeight: 'calc(100vh - 110px)', overflowY: 'auto' } } : undefined}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleAddMember}
|
||||
>
|
||||
<Form.Item
|
||||
name="character_id"
|
||||
label="选择角色"
|
||||
rules={[{ required: true, message: '请选择角色' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="选择要加入的角色"
|
||||
showSearch
|
||||
filterOption={(input, option) =>
|
||||
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
options={availableCharacters.map(c => ({
|
||||
label: c.name,
|
||||
value: c.id
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="position"
|
||||
label="职位"
|
||||
rules={[{ required: true, message: '请输入职位' }]}
|
||||
>
|
||||
<Input placeholder="如:掌门、长老、弟子" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="rank"
|
||||
label="职位等级"
|
||||
initialValue={5}
|
||||
tooltip="数字越大等级越高"
|
||||
>
|
||||
<InputNumber min={0} max={10} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="loyalty"
|
||||
label="初始忠诚度"
|
||||
initialValue={50}
|
||||
>
|
||||
<InputNumber min={0} max={100} style={{ width: '100%' }} addonAfter="%" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="status"
|
||||
label="状态"
|
||||
initialValue="active"
|
||||
>
|
||||
<Select>
|
||||
<Select.Option value="active">在职</Select.Option>
|
||||
<Select.Option value="retired">退休</Select.Option>
|
||||
<Select.Option value="expelled">除名</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
|
||||
<Button onClick={() => setIsAddMemberModalOpen(false)}>取消</Button>
|
||||
<Button type="primary" htmlType="submit">
|
||||
添加
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button, List, Modal, Form, Input, message, Empty, Space, Popconfirm, Card, Select, Radio, Tag } from 'antd';
|
||||
import { EditOutlined, DeleteOutlined, ThunderboltOutlined, ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import { useOutlineSync } from '../store/hooks';
|
||||
import { cardStyles } from '../components/CardStyles';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
export default function Outline() {
|
||||
const { currentProject, outlines } = useStore();
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [editForm] = Form.useForm();
|
||||
const [generateForm] = Form.useForm();
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth <= 768);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setIsMobile(window.innerWidth <= 768);
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
// 使用同步 hooks(移除createOutline)
|
||||
const {
|
||||
refreshOutlines,
|
||||
updateOutline,
|
||||
deleteOutline,
|
||||
reorderOutlines,
|
||||
generateOutlines
|
||||
} = useOutlineSync();
|
||||
|
||||
// 初始加载大纲列表
|
||||
useEffect(() => {
|
||||
if (currentProject?.id) {
|
||||
refreshOutlines();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentProject?.id]); // 只依赖 ID,不依赖函数
|
||||
|
||||
// 移除事件监听,避免无限循环
|
||||
// Hook 内部已经更新了 store,不需要再次刷新
|
||||
|
||||
if (!currentProject) return null;
|
||||
|
||||
// 确保大纲按 order_index 排序
|
||||
const sortedOutlines = [...outlines].sort((a, b) => a.order_index - b.order_index);
|
||||
|
||||
const handleOpenEditModal = (id: string) => {
|
||||
const outline = outlines.find(o => o.id === id);
|
||||
if (outline) {
|
||||
editForm.setFieldsValue(outline);
|
||||
Modal.confirm({
|
||||
title: '编辑大纲',
|
||||
width: 600,
|
||||
centered: true,
|
||||
content: (
|
||||
<Form
|
||||
form={editForm}
|
||||
layout="vertical"
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
<Form.Item
|
||||
label="标题"
|
||||
name="title"
|
||||
rules={[{ required: true, message: '请输入标题' }]}
|
||||
>
|
||||
<Input placeholder="输入大纲标题" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="内容"
|
||||
name="content"
|
||||
rules={[{ required: true, message: '请输入内容' }]}
|
||||
>
|
||||
<TextArea rows={6} placeholder="输入大纲内容..." />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
),
|
||||
okText: '更新',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
const values = await editForm.validateFields();
|
||||
try {
|
||||
await updateOutline(id, values);
|
||||
message.success('大纲更新成功');
|
||||
} catch {
|
||||
message.error('更新失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteOutline = async (id: string) => {
|
||||
try {
|
||||
await deleteOutline(id);
|
||||
message.success('删除成功');
|
||||
} catch {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleMoveUp = async (index: number) => {
|
||||
if (index === 0) return;
|
||||
|
||||
const items = Array.from(sortedOutlines);
|
||||
[items[index - 1], items[index]] = [items[index], items[index - 1]];
|
||||
|
||||
const newOrders = items.map((item, idx) => ({
|
||||
id: item.id,
|
||||
order_index: idx + 1
|
||||
}));
|
||||
|
||||
try {
|
||||
await reorderOutlines(newOrders);
|
||||
message.success('上移成功');
|
||||
} catch (error) {
|
||||
message.error('调整失败');
|
||||
console.error('重排序失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMoveDown = async (index: number) => {
|
||||
if (index === sortedOutlines.length - 1) return;
|
||||
|
||||
const items = Array.from(sortedOutlines);
|
||||
[items[index], items[index + 1]] = [items[index + 1], items[index]];
|
||||
|
||||
const newOrders = items.map((item, idx) => ({
|
||||
id: item.id,
|
||||
order_index: idx + 1
|
||||
}));
|
||||
|
||||
try {
|
||||
await reorderOutlines(newOrders);
|
||||
message.success('下移成功');
|
||||
} catch (error) {
|
||||
message.error('调整失败');
|
||||
console.error('重排序失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
interface GenerateFormValues {
|
||||
theme?: string;
|
||||
chapter_count?: number;
|
||||
narrative_perspective?: string;
|
||||
requirements?: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
mode?: 'auto' | 'new' | 'continue';
|
||||
story_direction?: string;
|
||||
plot_stage?: 'development' | 'climax' | 'ending';
|
||||
keep_existing?: boolean;
|
||||
}
|
||||
|
||||
const handleGenerate = async (values: GenerateFormValues) => {
|
||||
try {
|
||||
setIsGenerating(true);
|
||||
// 如果是全新生成模式,keep_existing应该为false
|
||||
const isNewMode = values.mode === 'new';
|
||||
const result = await generateOutlines({
|
||||
project_id: currentProject.id,
|
||||
genre: currentProject.genre || '通用',
|
||||
theme: values.theme || currentProject.theme || '',
|
||||
chapter_count: values.chapter_count || 5,
|
||||
narrative_perspective: values.narrative_perspective || currentProject.narrative_perspective || '第三人称',
|
||||
target_words: currentProject.target_words || 100000,
|
||||
requirements: values.requirements,
|
||||
// 续写参数
|
||||
mode: values.mode || 'auto',
|
||||
story_direction: values.story_direction,
|
||||
plot_stage: values.plot_stage || 'development',
|
||||
keep_existing: !isNewMode, // 全新生成模式下不保留旧大纲
|
||||
});
|
||||
message.success(`成功生成 ${result.length} 条大纲`);
|
||||
Modal.destroyAll();
|
||||
// 刷新大纲列表,确保显示最新数据
|
||||
await refreshOutlines();
|
||||
} catch (error) {
|
||||
console.error('AI生成失败:', error);
|
||||
message.error('AI生成失败');
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const showGenerateModal = () => {
|
||||
const hasOutlines = outlines.length > 0;
|
||||
const initialMode = hasOutlines ? 'continue' : 'new';
|
||||
|
||||
Modal.confirm({
|
||||
title: hasOutlines ? (
|
||||
<Space>
|
||||
<span>AI生成/续写大纲</span>
|
||||
<Tag color="blue">当前已有 {outlines.length} 章</Tag>
|
||||
</Space>
|
||||
) : 'AI生成大纲',
|
||||
width: 700,
|
||||
centered: true,
|
||||
content: (
|
||||
<Form
|
||||
form={generateForm}
|
||||
layout="vertical"
|
||||
style={{ marginTop: 16 }}
|
||||
initialValues={{
|
||||
mode: initialMode,
|
||||
chapter_count: 5,
|
||||
narrative_perspective: currentProject.narrative_perspective || '第三人称',
|
||||
plot_stage: 'development',
|
||||
keep_existing: true,
|
||||
theme: currentProject.theme || '',
|
||||
}}
|
||||
>
|
||||
{hasOutlines && (
|
||||
<Form.Item
|
||||
label="生成模式"
|
||||
name="mode"
|
||||
tooltip="自动判断:根据是否有大纲自动选择;全新生成:删除旧大纲重新生成;续写模式:基于已有大纲继续创作"
|
||||
>
|
||||
<Radio.Group buttonStyle="solid">
|
||||
<Radio.Button value="auto">自动判断</Radio.Button>
|
||||
<Radio.Button value="new">全新生成</Radio.Button>
|
||||
<Radio.Button value="continue">续写模式</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prevValues, currentValues) => prevValues.mode !== currentValues.mode}
|
||||
>
|
||||
{({ getFieldValue }) => {
|
||||
const mode = getFieldValue('mode');
|
||||
const isContinue = mode === 'continue' || (mode === 'auto' && hasOutlines);
|
||||
|
||||
// 续写模式不显示主题输入,使用项目原有主题
|
||||
if (isContinue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 全新生成模式需要输入主题
|
||||
return (
|
||||
<Form.Item
|
||||
label="故事主题"
|
||||
name="theme"
|
||||
rules={[{ required: true, message: '请输入故事主题' }]}
|
||||
>
|
||||
<TextArea rows={3} placeholder="描述你的故事主题、核心设定和主要情节..." />
|
||||
</Form.Item>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prevValues, currentValues) => prevValues.mode !== currentValues.mode}
|
||||
>
|
||||
{({ getFieldValue }) => {
|
||||
const mode = getFieldValue('mode');
|
||||
const isContinue = mode === 'continue' || (mode === 'auto' && hasOutlines);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isContinue && (
|
||||
<>
|
||||
<Form.Item
|
||||
label="故事发展方向"
|
||||
name="story_direction"
|
||||
tooltip="告诉AI你希望故事接下来如何发展"
|
||||
>
|
||||
<TextArea
|
||||
rows={3}
|
||||
placeholder="例如:主角遇到新的挑战、引入新角色、揭示关键秘密等..."
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="情节阶段"
|
||||
name="plot_stage"
|
||||
tooltip="帮助AI理解当前故事所处的阶段"
|
||||
>
|
||||
<Select>
|
||||
<Select.Option value="development">发展阶段 - 继续展开情节</Select.Option>
|
||||
<Select.Option value="climax">高潮阶段 - 矛盾激化</Select.Option>
|
||||
<Select.Option value="ending">结局阶段 - 收束伏笔</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Form.Item
|
||||
label={isContinue ? "续写章节数" : "章节数量"}
|
||||
name="chapter_count"
|
||||
rules={[{ required: true, message: '请输入章节数量' }]}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={50}
|
||||
placeholder={isContinue ? "建议5-10章" : "如:30"}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="叙事视角"
|
||||
name="narrative_perspective"
|
||||
rules={[{ required: true, message: '请选择叙事视角' }]}
|
||||
>
|
||||
<Select>
|
||||
<Select.Option value="第一人称">第一人称</Select.Option>
|
||||
<Select.Option value="第三人称">第三人称</Select.Option>
|
||||
<Select.Option value="全知视角">全知视角</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="其他要求" name="requirements">
|
||||
<TextArea rows={2} placeholder="其他特殊要求(可选)" />
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
</Form>
|
||||
),
|
||||
okText: hasOutlines ? '开始续写' : '开始生成',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
const values = await generateForm.validateFields();
|
||||
await handleGenerate(values);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
{/* 固定头部 */}
|
||||
<div style={{
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 10,
|
||||
backgroundColor: '#fff',
|
||||
padding: isMobile ? '12px 0' : '16px 0',
|
||||
marginBottom: isMobile ? 12 : 16,
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
display: 'flex',
|
||||
flexDirection: isMobile ? 'column' : 'row',
|
||||
gap: isMobile ? 12 : 0,
|
||||
justifyContent: 'space-between',
|
||||
alignItems: isMobile ? 'stretch' : 'center'
|
||||
}}>
|
||||
<h2 style={{ margin: 0, fontSize: isMobile ? 18 : 24 }}>故事大纲</h2>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<ThunderboltOutlined />}
|
||||
onClick={showGenerateModal}
|
||||
loading={isGenerating}
|
||||
block={isMobile}
|
||||
>
|
||||
{isMobile ? 'AI生成/续写' : 'AI生成/续写大纲'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 可滚动内容区域 */}
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
{outlines.length === 0 ? (
|
||||
<Empty description="还没有大纲,开始创建吧!" />
|
||||
) : (
|
||||
<Card style={cardStyles.base}>
|
||||
<List
|
||||
dataSource={sortedOutlines}
|
||||
renderItem={(item, index) => (
|
||||
<List.Item
|
||||
style={{
|
||||
padding: '16px 0',
|
||||
borderRadius: 8,
|
||||
transition: 'background 0.3s ease',
|
||||
flexDirection: isMobile ? 'column' : 'row',
|
||||
alignItems: isMobile ? 'flex-start' : 'center'
|
||||
}}
|
||||
actions={isMobile ? undefined : [
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ArrowUpOutlined />}
|
||||
onClick={() => handleMoveUp(index)}
|
||||
disabled={index === 0}
|
||||
title="上移"
|
||||
>
|
||||
上移
|
||||
</Button>,
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ArrowDownOutlined />}
|
||||
onClick={() => handleMoveDown(index)}
|
||||
disabled={index === sortedOutlines.length - 1}
|
||||
title="下移"
|
||||
>
|
||||
下移
|
||||
</Button>,
|
||||
<Button
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleOpenEditModal(item.id)}
|
||||
>
|
||||
编辑
|
||||
</Button>,
|
||||
<Popconfirm
|
||||
title="确定删除这条大纲吗?"
|
||||
onConfirm={() => handleDeleteOutline(item.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button type="text" danger icon={<DeleteOutlined />}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>,
|
||||
]}
|
||||
>
|
||||
<div style={{ width: '100%' }}>
|
||||
<List.Item.Meta
|
||||
title={
|
||||
<span style={{ fontSize: isMobile ? 14 : 16 }}>
|
||||
<span style={{ color: '#1890ff', marginRight: 8, fontWeight: 'bold' }}>
|
||||
第{item.order_index || '?'}章
|
||||
</span>
|
||||
{item.title}
|
||||
</span>
|
||||
}
|
||||
description={
|
||||
<div style={{ fontSize: isMobile ? 12 : 14 }}>
|
||||
{item.content}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 移动端:按钮显示在内容下方 */}
|
||||
{isMobile && (
|
||||
<Space style={{ marginTop: 12, width: '100%', justifyContent: 'flex-end' }} wrap>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ArrowUpOutlined />}
|
||||
onClick={() => handleMoveUp(index)}
|
||||
disabled={index === 0}
|
||||
size="small"
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ArrowDownOutlined />}
|
||||
onClick={() => handleMoveDown(index)}
|
||||
disabled={index === sortedOutlines.length - 1}
|
||||
size="small"
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleOpenEditModal(item.id)}
|
||||
size="small"
|
||||
/>
|
||||
<Popconfirm
|
||||
title="确定删除这条大纲吗?"
|
||||
onConfirm={() => handleDeleteOutline(item.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button type="text" danger icon={<DeleteOutlined />} size="small" />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useState } from 'react';
|
||||
import { Card, Input, Button, message, Space } from 'antd';
|
||||
import { ThunderboltOutlined } from '@ant-design/icons';
|
||||
import { polishApi } from '../services/api';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
export default function Polish() {
|
||||
const [originalText, setOriginalText] = useState('');
|
||||
const [polishedText, setPolishedText] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handlePolish = async () => {
|
||||
if (!originalText.trim()) {
|
||||
message.warning('请输入要去味的文本');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const result = await polishApi.polishText({ text: originalText });
|
||||
setPolishedText(result.polished_text);
|
||||
message.success('AI去味完成');
|
||||
} catch {
|
||||
message.error('AI去味失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(polishedText);
|
||||
message.success('已复制到剪贴板');
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 style={{ marginBottom: 16 }}>AI去味工具</h2>
|
||||
<p style={{ color: 'rgba(0,0,0,0.45)', marginBottom: 24 }}>
|
||||
将AI生成的文本变得更自然、更像人类作家的手笔
|
||||
</p>
|
||||
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
||||
<Card title="原始文本" extra={
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<ThunderboltOutlined />}
|
||||
onClick={handlePolish}
|
||||
loading={loading}
|
||||
>
|
||||
开始去味
|
||||
</Button>
|
||||
}>
|
||||
<TextArea
|
||||
rows={10}
|
||||
placeholder="粘贴或输入需要去味的文本..."
|
||||
value={originalText}
|
||||
onChange={(e) => setOriginalText(e.target.value)}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{polishedText && (
|
||||
<Card title="去味后文本" extra={
|
||||
<Button onClick={handleCopy}>复制文本</Button>
|
||||
}>
|
||||
<TextArea
|
||||
rows={10}
|
||||
value={polishedText}
|
||||
readOnly
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useParams, useNavigate, Outlet, Link, useLocation } from 'react-router-dom';
|
||||
import { Layout, Menu, Spin, Button, Statistic, Row, Col, Card, Drawer } from 'antd';
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
FileTextOutlined,
|
||||
TeamOutlined,
|
||||
BookOutlined,
|
||||
// ToolOutlined,
|
||||
GlobalOutlined,
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
ApartmentOutlined,
|
||||
BankOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import { useCharacterSync, useOutlineSync, useChapterSync } from '../store/hooks';
|
||||
import { projectApi } from '../services/api';
|
||||
|
||||
const { Header, Sider, Content } = Layout;
|
||||
|
||||
// 判断是否为移动端
|
||||
const isMobile = () => window.innerWidth <= 768;
|
||||
|
||||
export default function ProjectDetail() {
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [drawerVisible, setDrawerVisible] = useState(false);
|
||||
const [mobile, setMobile] = useState(isMobile());
|
||||
|
||||
// 监听窗口大小变化
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setMobile(isMobile());
|
||||
if (!isMobile()) {
|
||||
setDrawerVisible(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
const {
|
||||
currentProject,
|
||||
setCurrentProject,
|
||||
clearProjectData,
|
||||
loading,
|
||||
setLoading,
|
||||
outlines,
|
||||
characters,
|
||||
chapters,
|
||||
} = useStore();
|
||||
|
||||
// 使用同步 hooks
|
||||
const { refreshCharacters } = useCharacterSync();
|
||||
const { refreshOutlines } = useOutlineSync();
|
||||
const { refreshChapters } = useChapterSync();
|
||||
|
||||
useEffect(() => {
|
||||
const loadProjectData = async (id: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
// 加载项目基本信息
|
||||
const project = await projectApi.getProject(id);
|
||||
setCurrentProject(project);
|
||||
|
||||
// 并行加载其他数据
|
||||
await Promise.all([
|
||||
refreshOutlines(id),
|
||||
refreshCharacters(id),
|
||||
refreshChapters(id),
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error('加载项目数据失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (projectId) {
|
||||
loadProjectData(projectId);
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearProjectData();
|
||||
};
|
||||
}, [projectId, clearProjectData, setLoading, setCurrentProject, refreshOutlines, refreshCharacters, refreshChapters]);
|
||||
|
||||
// 移除事件监听,避免无限循环
|
||||
// Hook 内部已经更新了 store,不需要再次刷新
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
key: 'world-setting',
|
||||
icon: <GlobalOutlined />,
|
||||
label: <Link to={`/project/${projectId}/world-setting`}>世界设定</Link>,
|
||||
},
|
||||
{
|
||||
key: 'characters',
|
||||
icon: <TeamOutlined />,
|
||||
label: <Link to={`/project/${projectId}/characters`}>角色管理</Link>,
|
||||
},
|
||||
{
|
||||
key: 'relationships',
|
||||
icon: <ApartmentOutlined />,
|
||||
label: <Link to={`/project/${projectId}/relationships`}>关系管理</Link>,
|
||||
},
|
||||
{
|
||||
key: 'organizations',
|
||||
icon: <BankOutlined />,
|
||||
label: <Link to={`/project/${projectId}/organizations`}>组织管理</Link>,
|
||||
},
|
||||
{
|
||||
key: 'outline',
|
||||
icon: <FileTextOutlined />,
|
||||
label: <Link to={`/project/${projectId}/outline`}>大纲管理</Link>,
|
||||
},
|
||||
{
|
||||
key: 'chapters',
|
||||
icon: <BookOutlined />,
|
||||
label: <Link to={`/project/${projectId}/chapters`}>章节管理</Link>,
|
||||
},
|
||||
// {
|
||||
// key: 'polish',
|
||||
// icon: <ToolOutlined />,
|
||||
// label: <Link to={`/project/${projectId}/polish`}>AI去味</Link>,
|
||||
// },
|
||||
];
|
||||
|
||||
// 根据当前路径动态确定选中的菜单项
|
||||
const selectedKey = useMemo(() => {
|
||||
const path = location.pathname;
|
||||
if (path.includes('/world-setting')) return 'world-setting';
|
||||
if (path.includes('/relationships')) return 'relationships';
|
||||
if (path.includes('/organizations')) return 'organizations';
|
||||
if (path.includes('/outline')) return 'outline';
|
||||
if (path.includes('/characters')) return 'characters';
|
||||
if (path.includes('/chapters')) return 'chapters';
|
||||
// if (path.includes('/polish')) return 'polish';
|
||||
return 'world-setting'; // 默认选中世界设定
|
||||
}, [location.pathname]);
|
||||
|
||||
if (loading || !currentProject) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 渲染菜单内容
|
||||
const renderMenu = () => (
|
||||
<div style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden'
|
||||
}}>
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={[selectedKey]}
|
||||
style={{
|
||||
borderRight: 0,
|
||||
paddingTop: '16px'
|
||||
}}
|
||||
items={menuItems}
|
||||
onClick={() => mobile && setDrawerVisible(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh', height: '100vh', overflow: 'hidden' }}>
|
||||
<Header style={{
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
padding: mobile ? '0 12px' : '0 24px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 1000,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
height: mobile ? 56 : 70
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', zIndex: 1 }}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={mobile ? <MenuUnfoldOutlined /> : (collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />)}
|
||||
onClick={() => mobile ? setDrawerVisible(true) : setCollapsed(!collapsed)}
|
||||
style={{
|
||||
fontSize: mobile ? '18px' : '20px',
|
||||
color: '#fff',
|
||||
width: mobile ? '36px' : '40px',
|
||||
height: mobile ? '36px' : '40px'
|
||||
}}
|
||||
/>
|
||||
{!mobile && (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate('/')}
|
||||
style={{
|
||||
fontSize: '16px',
|
||||
color: '#fff',
|
||||
height: '40px',
|
||||
padding: '0 16px'
|
||||
}}
|
||||
>
|
||||
返回主页
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h2 style={{
|
||||
margin: 0,
|
||||
color: '#fff',
|
||||
fontSize: mobile ? '16px' : '24px',
|
||||
fontWeight: 600,
|
||||
textShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
position: mobile ? 'static' : 'absolute',
|
||||
left: mobile ? 'auto' : '50%',
|
||||
transform: mobile ? 'none' : 'translateX(-50%)',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
flex: mobile ? 1 : 'none',
|
||||
textAlign: mobile ? 'center' : 'left',
|
||||
paddingLeft: mobile ? '8px' : '0',
|
||||
paddingRight: mobile ? '8px' : '0'
|
||||
}}>
|
||||
{currentProject.title}
|
||||
</h2>
|
||||
|
||||
{mobile && (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate('/')}
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
color: '#fff',
|
||||
height: '36px',
|
||||
padding: '0 8px',
|
||||
zIndex: 1
|
||||
}}
|
||||
>
|
||||
主页
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!mobile && (
|
||||
<Row gutter={12} style={{ width: '450px', justifyContent: 'flex-end', zIndex: 1 }}>
|
||||
<Col>
|
||||
<Card
|
||||
size="small"
|
||||
style={{
|
||||
background: 'rgba(255,255,255,0.95)',
|
||||
borderRadius: '6px',
|
||||
border: 'none',
|
||||
minWidth: '80px',
|
||||
textAlign: 'center',
|
||||
padding: '4px 8px'
|
||||
}}
|
||||
styles={{ body: { padding: '8px' } }}
|
||||
>
|
||||
<Statistic
|
||||
title={<span style={{ fontSize: '11px', color: '#666' }}>大纲</span>}
|
||||
value={outlines.length}
|
||||
suffix="条"
|
||||
valueStyle={{ fontSize: '16px', fontWeight: 600, color: '#667eea' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col>
|
||||
<Card
|
||||
size="small"
|
||||
style={{
|
||||
background: 'rgba(255,255,255,0.95)',
|
||||
borderRadius: '6px',
|
||||
border: 'none',
|
||||
minWidth: '80px',
|
||||
textAlign: 'center',
|
||||
padding: '4px 8px'
|
||||
}}
|
||||
styles={{ body: { padding: '8px' } }}
|
||||
>
|
||||
<Statistic
|
||||
title={<span style={{ fontSize: '11px', color: '#666' }}>角色</span>}
|
||||
value={characters.length}
|
||||
suffix="个"
|
||||
valueStyle={{ fontSize: '16px', fontWeight: 600, color: '#52c41a' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col>
|
||||
<Card
|
||||
size="small"
|
||||
style={{
|
||||
background: 'rgba(255,255,255,0.95)',
|
||||
borderRadius: '6px',
|
||||
border: 'none',
|
||||
minWidth: '80px',
|
||||
textAlign: 'center',
|
||||
padding: '4px 8px'
|
||||
}}
|
||||
styles={{ body: { padding: '8px' } }}
|
||||
>
|
||||
<Statistic
|
||||
title={<span style={{ fontSize: '11px', color: '#666' }}>章节</span>}
|
||||
value={chapters.length}
|
||||
suffix="章"
|
||||
valueStyle={{ fontSize: '16px', fontWeight: 600, color: '#1890ff' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col>
|
||||
<Card
|
||||
size="small"
|
||||
style={{
|
||||
background: 'rgba(255,255,255,0.95)',
|
||||
borderRadius: '6px',
|
||||
border: 'none',
|
||||
minWidth: '80px',
|
||||
textAlign: 'center',
|
||||
padding: '4px 8px'
|
||||
}}
|
||||
styles={{ body: { padding: '8px' } }}
|
||||
>
|
||||
<Statistic
|
||||
title={<span style={{ fontSize: '11px', color: '#666' }}>已写</span>}
|
||||
value={currentProject.current_words}
|
||||
suffix="字"
|
||||
valueStyle={{ fontSize: '16px', fontWeight: 600, color: '#fa8c16' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
</Header>
|
||||
|
||||
<Layout style={{ marginTop: mobile ? 56 : 70 }}>
|
||||
{mobile ? (
|
||||
<Drawer
|
||||
title="导航菜单"
|
||||
placement="left"
|
||||
onClose={() => setDrawerVisible(false)}
|
||||
open={drawerVisible}
|
||||
width={280}
|
||||
styles={{ body: { padding: 0, display: 'flex', flexDirection: 'column' } }}
|
||||
>
|
||||
{renderMenu()}
|
||||
</Drawer>
|
||||
) : (
|
||||
<Sider
|
||||
collapsible
|
||||
collapsed={collapsed}
|
||||
onCollapse={setCollapsed}
|
||||
trigger={null}
|
||||
width={220}
|
||||
collapsedWidth={60}
|
||||
style={{
|
||||
background: '#fff',
|
||||
position: 'fixed',
|
||||
left: 0,
|
||||
top: 70,
|
||||
bottom: 0,
|
||||
overflow: 'hidden',
|
||||
boxShadow: '2px 0 12px rgba(0,0,0,0.08)',
|
||||
transition: 'all 0.2s',
|
||||
height: 'calc(100vh - 70px)'
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}>
|
||||
{renderMenu()}
|
||||
</div>
|
||||
</Sider>
|
||||
)}
|
||||
|
||||
<Layout style={{
|
||||
marginLeft: mobile ? 0 : (collapsed ? 60 : 220),
|
||||
transition: 'all 0.2s'
|
||||
}}>
|
||||
<Content
|
||||
style={{
|
||||
background: '#f5f7fa',
|
||||
padding: mobile ? 12 : 24,
|
||||
height: mobile ? 'calc(100vh - 56px)' : 'calc(100vh - 70px)',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
background: '#fff',
|
||||
padding: mobile ? 12 : 24,
|
||||
borderRadius: mobile ? '8px' : '12px',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}>
|
||||
<Outlet />
|
||||
</div>
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card, Button, Empty, Modal, message, Spin, Row, Col, Statistic, Space, Tag, Progress, Typography, Tooltip, Badge } from 'antd';
|
||||
import { EditOutlined, DeleteOutlined, BookOutlined, RocketOutlined, CalendarOutlined, FileTextOutlined, TrophyOutlined, FireOutlined } from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import { useProjectSync } from '../store/hooks';
|
||||
import type { ReactNode } from 'react';
|
||||
import { cardStyles, cardHoverHandlers, gridConfig } from '../components/CardStyles';
|
||||
import UserMenu from '../components/UserMenu';
|
||||
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
|
||||
export default function ProjectList() {
|
||||
const navigate = useNavigate();
|
||||
const { projects, loading } = useStore();
|
||||
|
||||
const { refreshProjects, deleteProject } = useProjectSync();
|
||||
|
||||
useEffect(() => {
|
||||
refreshProjects();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
if (!document.hidden) {
|
||||
refreshProjects();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
const isMobile = window.innerWidth <= 768;
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: '删除项目将同时删除所有相关数据,此操作不可恢复。确定要删除吗?',
|
||||
okText: '确定',
|
||||
cancelText: '取消',
|
||||
okType: 'danger',
|
||||
centered: true,
|
||||
...(isMobile && {
|
||||
style: { top: 'auto' }
|
||||
}),
|
||||
onOk: async () => {
|
||||
try {
|
||||
await deleteProject(id);
|
||||
message.success('项目删除成功');
|
||||
} catch {
|
||||
message.error('删除项目失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleEnterProject = (id: string) => {
|
||||
const project = projects.find(p => p.id === id);
|
||||
if (project) {
|
||||
console.log('项目信息:', {
|
||||
id: project.id,
|
||||
title: project.title,
|
||||
wizard_status: project.wizard_status,
|
||||
wizard_step: project.wizard_step
|
||||
});
|
||||
|
||||
if (project.wizard_status === 'incomplete' || !project.wizard_status) {
|
||||
console.log('向导未完成,跳转到向导页面');
|
||||
navigate(`/wizard?projectId=${id}&step=${project.wizard_step || 0}`);
|
||||
} else {
|
||||
console.log('向导已完成,进入项目管理界面');
|
||||
navigate(`/project/${id}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusTag = (status: string) => {
|
||||
const statusConfig: Record<string, { color: string; text: string; icon: ReactNode }> = {
|
||||
planning: { color: 'blue', text: '规划中', icon: <CalendarOutlined /> },
|
||||
writing: { color: 'green', text: '创作中', icon: <EditOutlined /> },
|
||||
revising: { color: 'orange', text: '修改中', icon: <FileTextOutlined /> },
|
||||
completed: { color: 'purple', text: '已完成', icon: <TrophyOutlined /> },
|
||||
};
|
||||
const config = statusConfig[status] || statusConfig.planning;
|
||||
return (
|
||||
<Tag color={config.color} icon={config.icon}>
|
||||
{config.text}
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
|
||||
const getProgress = (current: number, target: number) => {
|
||||
if (!target) return 0;
|
||||
return Math.min(Math.round((current / target) * 100), 100);
|
||||
};
|
||||
|
||||
const getProgressColor = (progress: number) => {
|
||||
if (progress >= 80) return '#52c41a';
|
||||
if (progress >= 50) return '#1890ff';
|
||||
if (progress >= 20) return '#faad14';
|
||||
return '#ff4d4f';
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (days === 0) return '今天';
|
||||
if (days === 1) return '昨天';
|
||||
if (days < 7) return `${days}天前`;
|
||||
if (days < 30) return `${Math.floor(days / 7)}周前`;
|
||||
return date.toLocaleDateString('zh-CN');
|
||||
};
|
||||
|
||||
const totalWords = projects.reduce((sum, p) => sum + (p.current_words || 0), 0);
|
||||
const activeProjects = projects.filter(p => p.status === 'writing').length;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
padding: window.innerWidth <= 768 ? '20px 16px' : '40px 24px'
|
||||
}}>
|
||||
<div style={{
|
||||
maxWidth: 1400,
|
||||
margin: '0 auto',
|
||||
marginBottom: window.innerWidth <= 768 ? 20 : 40
|
||||
}}>
|
||||
<Card
|
||||
variant="borderless"
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.95)',
|
||||
borderRadius: window.innerWidth <= 768 ? 12 : 16,
|
||||
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
>
|
||||
<Row align="middle" justify="space-between" gutter={[16, 16]}>
|
||||
<Col xs={24} sm={12} md={10}>
|
||||
<Space direction="vertical" size={4}>
|
||||
<Title level={window.innerWidth <= 768 ? 3 : 2} style={{ margin: 0 }}>
|
||||
<FireOutlined style={{ color: '#ff4d4f', marginRight: 8 }} />
|
||||
我的创作空间
|
||||
</Title>
|
||||
<Text type="secondary" style={{ fontSize: window.innerWidth <= 768 ? 12 : 14 }}>
|
||||
开启你的小说创作之旅
|
||||
</Text>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={14} style={{ display: 'flex', justifyContent: window.innerWidth <= 768 ? 'space-between' : 'flex-end', alignItems: 'center', gap: 16 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
size={window.innerWidth <= 768 ? 'middle' : 'large'}
|
||||
icon={<RocketOutlined />}
|
||||
onClick={() => navigate('/wizard')}
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
border: 'none'
|
||||
}}
|
||||
>
|
||||
向导创建
|
||||
</Button>
|
||||
<UserMenu />
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{projects.length > 0 && (
|
||||
<Row gutter={[16, 16]} style={{ marginTop: window.innerWidth <= 768 ? 16 : 24 }}>
|
||||
<Col xs={24} sm={8}>
|
||||
<Card variant="borderless" style={{ background: '#f0f5ff', borderRadius: 12 }}>
|
||||
<Statistic
|
||||
title={<span style={{ fontSize: window.innerWidth <= 768 ? 12 : 14, color: '#595959' }}>总项目数</span>}
|
||||
value={projects.length}
|
||||
prefix={<BookOutlined style={{ color: '#1890ff' }} />}
|
||||
suffix="个"
|
||||
valueStyle={{ color: '#1890ff', fontSize: window.innerWidth <= 768 ? 20 : 28, fontWeight: 'bold' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Card variant="borderless" style={{ background: '#f6ffed', borderRadius: 12 }}>
|
||||
<Statistic
|
||||
title={<span style={{ fontSize: window.innerWidth <= 768 ? 12 : 14, color: '#595959' }}>创作中</span>}
|
||||
value={activeProjects}
|
||||
prefix={<EditOutlined style={{ color: '#52c41a' }} />}
|
||||
suffix="个"
|
||||
valueStyle={{ color: '#52c41a', fontSize: window.innerWidth <= 768 ? 20 : 28, fontWeight: 'bold' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Card variant="borderless" style={{ background: '#fff7e6', borderRadius: 12 }}>
|
||||
<Statistic
|
||||
title={<span style={{ fontSize: window.innerWidth <= 768 ? 12 : 14, color: '#595959' }}>总字数</span>}
|
||||
value={totalWords}
|
||||
prefix={<FileTextOutlined style={{ color: '#faad14' }} />}
|
||||
suffix="字"
|
||||
valueStyle={{ color: '#faad14', fontSize: window.innerWidth <= 768 ? 20 : 28, fontWeight: 'bold' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div style={{ maxWidth: 1400, margin: '0 auto' }}>
|
||||
<Spin spinning={loading}>
|
||||
{!Array.isArray(projects) || projects.length === 0 ? (
|
||||
<Card
|
||||
variant="borderless"
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.95)',
|
||||
borderRadius: 16,
|
||||
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
>
|
||||
<Empty
|
||||
description={
|
||||
<Space direction="vertical" size={16}>
|
||||
<Text style={{ fontSize: 16, color: '#8c8c8c' }}>
|
||||
还没有项目,开始创建你的第一个小说项目吧!
|
||||
</Text>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
icon={<RocketOutlined />}
|
||||
onClick={() => navigate('/wizard')}
|
||||
>
|
||||
向导创建
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
style={{ padding: '80px 0' }}
|
||||
/>
|
||||
</Card>
|
||||
) : (
|
||||
<Row gutter={[16, 16]}>
|
||||
{projects.map((project) => {
|
||||
const progress = getProgress(project.current_words, project.target_words || 0);
|
||||
const isWizardComplete = project.wizard_status === 'completed';
|
||||
|
||||
return (
|
||||
<Col {...gridConfig} key={project.id}>
|
||||
<Badge.Ribbon
|
||||
text={isWizardComplete ? getStatusTag(project.status) : <Tag color="orange" icon={<RocketOutlined />}>创建中</Tag>}
|
||||
color="transparent"
|
||||
style={{ top: 12, right: 12 }}
|
||||
>
|
||||
<Card
|
||||
hoverable
|
||||
variant="borderless"
|
||||
onClick={() => handleEnterProject(project.id)}
|
||||
style={cardStyles.project}
|
||||
styles={{ body: { padding: 0, overflow: 'hidden' } }}
|
||||
{...cardHoverHandlers}
|
||||
>
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
padding: window.innerWidth <= 768 ? '16px' : '24px',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: window.innerWidth <= 768 ? 8 : 12 }}>
|
||||
<BookOutlined style={{ fontSize: window.innerWidth <= 768 ? 20 : 28, color: '#fff' }} />
|
||||
<Title level={window.innerWidth <= 768 ? 5 : 4} style={{ margin: 0, color: '#fff', flex: 1 }} ellipsis>
|
||||
{project.title}
|
||||
</Title>
|
||||
</div>
|
||||
{project.genre && (
|
||||
<Tag color="rgba(255,255,255,0.3)" style={{ color: '#fff', border: 'none' }}>
|
||||
{project.genre}
|
||||
</Tag>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: window.innerWidth <= 768 ? '16px' : '20px' }}>
|
||||
<Paragraph
|
||||
ellipsis={{ rows: 2 }}
|
||||
style={{
|
||||
color: 'rgba(0,0,0,0.65)',
|
||||
minHeight: 44,
|
||||
marginBottom: 16
|
||||
}}
|
||||
>
|
||||
{project.description || '暂无描述'}
|
||||
</Paragraph>
|
||||
|
||||
{isWizardComplete ? (
|
||||
<>
|
||||
{project.target_words && project.target_words > 0 && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>完成进度</Text>
|
||||
<Text strong style={{ fontSize: 12 }}>{progress}%</Text>
|
||||
</div>
|
||||
<Progress
|
||||
percent={progress}
|
||||
strokeColor={getProgressColor(progress)}
|
||||
showInfo={false}
|
||||
size={{ height: 8 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Row gutter={12}>
|
||||
<Col span={12}>
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '12px 0',
|
||||
background: '#f5f5f5',
|
||||
borderRadius: 8
|
||||
}}>
|
||||
<div style={{ fontSize: 20, fontWeight: 'bold', color: '#1890ff' }}>
|
||||
{(project.current_words / 1000).toFixed(1)}K
|
||||
</div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>已写字数</Text>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '12px 0',
|
||||
background: '#f5f5f5',
|
||||
borderRadius: 8
|
||||
}}>
|
||||
<div style={{ fontSize: 20, fontWeight: 'bold', color: '#52c41a' }}>
|
||||
{project.target_words ? (project.target_words / 1000).toFixed(0) + 'K' : '--'}
|
||||
</div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>目标字数</Text>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
) : (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '24px 0',
|
||||
background: '#f5f5f5',
|
||||
borderRadius: 8
|
||||
}}>
|
||||
<RocketOutlined style={{ fontSize: 32, color: '#faad14', marginBottom: 12 }} />
|
||||
<div style={{ color: '#faad14', fontWeight: 'bold', marginBottom: 4 }}>
|
||||
项目创建中
|
||||
</div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
点击继续创建向导
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
marginTop: 16,
|
||||
paddingTop: 16,
|
||||
borderTop: '1px solid #f0f0f0',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
<CalendarOutlined style={{ marginRight: 4 }} />
|
||||
{formatDate(project.updated_at)}
|
||||
</Text>
|
||||
<Space size={8}>
|
||||
<Tooltip title="删除">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(project.id);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Badge.Ribbon>
|
||||
</Col>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
)}
|
||||
</Spin>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,455 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Card, Table, Tag, Button, Space, message, Modal, Form, Select, Slider, Input, Tabs } from 'antd';
|
||||
import { PlusOutlined, TeamOutlined, UserOutlined } from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import axios from 'axios';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
interface Relationship {
|
||||
id: string;
|
||||
character_from_id: string;
|
||||
character_to_id: string;
|
||||
relationship_name: string;
|
||||
intimacy_level: number;
|
||||
status: string;
|
||||
description?: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
interface RelationshipType {
|
||||
id: number;
|
||||
name: string;
|
||||
category: string;
|
||||
reverse_name?: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
interface Character {
|
||||
id: string;
|
||||
name: string;
|
||||
is_organization: boolean;
|
||||
}
|
||||
|
||||
export default function Relationships() {
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
const { currentProject } = useStore();
|
||||
const [relationships, setRelationships] = useState<Relationship[]>([]);
|
||||
const [relationshipTypes, setRelationshipTypes] = useState<RelationshipType[]>([]);
|
||||
const [characters, setCharacters] = useState<Character[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth <= 768);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setIsMobile(window.innerWidth <= 768);
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (projectId) {
|
||||
loadData();
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [relsRes, typesRes, charsRes] = await Promise.all([
|
||||
axios.get(`/api/relationships/project/${projectId}`),
|
||||
axios.get('/api/relationships/types'),
|
||||
axios.get(`/api/characters?project_id=${projectId}`)
|
||||
]);
|
||||
|
||||
setRelationships(relsRes.data);
|
||||
setRelationshipTypes(typesRes.data);
|
||||
setCharacters(charsRes.data.items || []);
|
||||
} catch (error) {
|
||||
message.error('加载数据失败');
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateRelationship = async (values: {
|
||||
character_from_id: string;
|
||||
character_to_id: string;
|
||||
relationship_name: string;
|
||||
intimacy_level: number;
|
||||
status: string;
|
||||
description?: string;
|
||||
}) => {
|
||||
try {
|
||||
await axios.post('/api/relationships/', {
|
||||
project_id: projectId,
|
||||
...values
|
||||
});
|
||||
message.success('关系创建成功');
|
||||
setIsModalOpen(false);
|
||||
form.resetFields();
|
||||
loadData();
|
||||
} catch (error) {
|
||||
message.error('创建关系失败');
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteRelationship = async (id: string) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: '确定要删除这条关系吗?',
|
||||
centered: true,
|
||||
okText: '删除',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await axios.delete(`/api/relationships/${id}`);
|
||||
message.success('关系删除成功');
|
||||
loadData();
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const getCharacterName = (id: string) => {
|
||||
const char = characters.find(c => c.id === id);
|
||||
return char?.name || '未知';
|
||||
};
|
||||
|
||||
const getIntimacyColor = (level: number) => {
|
||||
if (level >= 75) return 'green';
|
||||
if (level >= 50) return 'blue';
|
||||
if (level >= 25) return 'orange';
|
||||
return 'red';
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
active: 'green',
|
||||
broken: 'red',
|
||||
past: 'default',
|
||||
complicated: 'orange'
|
||||
};
|
||||
return colors[status] || 'default';
|
||||
};
|
||||
|
||||
const getCategoryColor = (category: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
family: 'magenta',
|
||||
social: 'blue',
|
||||
hostile: 'red',
|
||||
professional: 'cyan'
|
||||
};
|
||||
return colors[category] || 'default';
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '角色A',
|
||||
dataIndex: 'character_from_id',
|
||||
key: 'from',
|
||||
render: (id: string) => (
|
||||
<Tag icon={<UserOutlined />} color="blue">
|
||||
{getCharacterName(id)}
|
||||
</Tag>
|
||||
),
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '关系',
|
||||
dataIndex: 'relationship_name',
|
||||
key: 'relationship',
|
||||
render: (name: string) => <strong>{name}</strong>,
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '角色B',
|
||||
dataIndex: 'character_to_id',
|
||||
key: 'to',
|
||||
render: (id: string) => (
|
||||
<Tag icon={<UserOutlined />} color="purple">
|
||||
{getCharacterName(id)}
|
||||
</Tag>
|
||||
),
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '亲密度',
|
||||
dataIndex: 'intimacy_level',
|
||||
key: 'intimacy',
|
||||
render: (level: number) => (
|
||||
<Tag color={getIntimacyColor(level)}>{level}</Tag>
|
||||
),
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (status: string) => (
|
||||
<Tag color={getStatusColor(status)}>{status}</Tag>
|
||||
),
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: '来源',
|
||||
dataIndex: 'source',
|
||||
key: 'source',
|
||||
render: (source: string) => (
|
||||
<Tag>{source === 'ai' ? 'AI生成' : '手动创建'}</Tag>
|
||||
),
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render: (_: unknown, record: Relationship) => (
|
||||
<Button
|
||||
type="link"
|
||||
danger
|
||||
size="small"
|
||||
onClick={() => handleDeleteRelationship(record.id)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
),
|
||||
width: 80,
|
||||
fixed: isMobile ? ('right' as const) : undefined,
|
||||
},
|
||||
];
|
||||
|
||||
// 按类别分组关系类型
|
||||
const groupedTypes = relationshipTypes.reduce((acc, type) => {
|
||||
if (!acc[type.category]) {
|
||||
acc[type.category] = [];
|
||||
}
|
||||
acc[type.category].push(type);
|
||||
return acc;
|
||||
}, {} as Record<string, RelationshipType[]>);
|
||||
|
||||
const categoryLabels: Record<string, string> = {
|
||||
family: '家族关系',
|
||||
social: '社交关系',
|
||||
professional: '职业关系',
|
||||
hostile: '敌对关系'
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card
|
||||
title={
|
||||
<Space wrap>
|
||||
<TeamOutlined />
|
||||
<span style={{ fontSize: isMobile ? 14 : 16 }}>关系管理</span>
|
||||
{!isMobile && <Tag color="blue">{currentProject?.title}</Tag>}
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
size={isMobile ? 'small' : 'middle'}
|
||||
>
|
||||
{isMobile ? '添加' : '添加关系'}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'list',
|
||||
label: `关系列表 (${relationships.length})`,
|
||||
children: (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={relationships}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: currentPage,
|
||||
pageSize: isMobile ? 10 : pageSize,
|
||||
pageSizeOptions: ['10', '20', '50', '100'],
|
||||
position: ['bottomCenter'],
|
||||
showSizeChanger: !isMobile,
|
||||
showQuickJumper: !isMobile,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
simple: isMobile,
|
||||
onChange: (page, size) => {
|
||||
setCurrentPage(page);
|
||||
if (size !== pageSize) {
|
||||
setPageSize(size);
|
||||
setCurrentPage(1); // 切换每页条数时重置到第一页
|
||||
}
|
||||
},
|
||||
onShowSizeChange: (_, size) => {
|
||||
setPageSize(size);
|
||||
setCurrentPage(1);
|
||||
}
|
||||
}}
|
||||
scroll={{
|
||||
x: 700,
|
||||
y: isMobile ? 'calc(100vh - 360px)' : 'calc(100vh - 440px)'
|
||||
}}
|
||||
size={isMobile ? 'small' : 'middle'}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'types',
|
||||
label: `关系类型 (${relationshipTypes.length})`,
|
||||
children: (
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: isMobile ? '1fr' : 'repeat(auto-fill, minmax(200px, 1fr))',
|
||||
gap: isMobile ? '12px' : '16px',
|
||||
maxHeight: isMobile ? 'calc(100vh - 400px)' : 'calc(100vh - 350px)',
|
||||
overflow: 'auto'
|
||||
}}>
|
||||
{Object.entries(groupedTypes).map(([category, types]) => (
|
||||
<Card
|
||||
key={category}
|
||||
size="small"
|
||||
title={categoryLabels[category] || category}
|
||||
headStyle={{ backgroundColor: '#f5f5f5' }}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{types.map(type => (
|
||||
<Tag key={type.id} color={getCategoryColor(category)}>
|
||||
{type.icon} {type.name}
|
||||
{type.reverse_name && ` ↔ ${type.reverse_name}`}
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title="添加关系"
|
||||
open={isModalOpen}
|
||||
onCancel={() => {
|
||||
setIsModalOpen(false);
|
||||
form.resetFields();
|
||||
}}
|
||||
footer={null}
|
||||
centered={!isMobile}
|
||||
width={isMobile ? '100%' : 600}
|
||||
style={isMobile ? { top: 0, paddingBottom: 0, maxWidth: '100vw' } : undefined}
|
||||
styles={isMobile ? { body: { maxHeight: 'calc(100vh - 110px)', overflowY: 'auto' } } : undefined}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleCreateRelationship}
|
||||
>
|
||||
<Form.Item
|
||||
name="character_from_id"
|
||||
label="角色A"
|
||||
rules={[{ required: true, message: '请选择角色A' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="选择角色"
|
||||
showSearch
|
||||
filterOption={(input, option) =>
|
||||
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
options={characters
|
||||
.filter(c => !c.is_organization)
|
||||
.map(c => ({ label: c.name, value: c.id }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="relationship_name"
|
||||
label="关系类型"
|
||||
rules={[{ required: true, message: '请选择或输入关系类型' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="选择预定义类型或输入自定义"
|
||||
showSearch
|
||||
allowClear
|
||||
options={relationshipTypes.map(t => ({
|
||||
label: `${t.icon || ''} ${t.name} (${categoryLabels[t.category]})`,
|
||||
value: t.name
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="character_to_id"
|
||||
label="角色B"
|
||||
rules={[{ required: true, message: '请选择角色B' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="选择角色"
|
||||
showSearch
|
||||
filterOption={(input, option) =>
|
||||
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
options={characters
|
||||
.filter(c => !c.is_organization)
|
||||
.map(c => ({ label: c.name, value: c.id }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="intimacy_level"
|
||||
label="亲密度"
|
||||
initialValue={50}
|
||||
>
|
||||
<Slider
|
||||
min={0}
|
||||
max={100}
|
||||
marks={{ 0: '0', 25: '25', 50: '50', 75: '75', 100: '100' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="status"
|
||||
label="状态"
|
||||
initialValue="active"
|
||||
>
|
||||
<Select>
|
||||
<Select.Option value="active">活跃</Select.Option>
|
||||
<Select.Option value="broken">破裂</Select.Option>
|
||||
<Select.Option value="past">过去</Select.Option>
|
||||
<Select.Option value="complicated">复杂</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="description" label="关系描述">
|
||||
<TextArea rows={3} placeholder="描述这段关系的细节..." />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
|
||||
<Button onClick={() => setIsModalOpen(false)}>取消</Button>
|
||||
<Button type="primary" htmlType="submit">
|
||||
创建
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { Card, Descriptions, Empty, Typography } from 'antd';
|
||||
import { GlobalOutlined } from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import { cardStyles } from '../components/CardStyles';
|
||||
|
||||
const { Title, Paragraph } = Typography;
|
||||
|
||||
export default function WorldSetting() {
|
||||
const { currentProject } = useStore();
|
||||
|
||||
if (!currentProject) return null;
|
||||
|
||||
// 检查是否有世界设定信息
|
||||
const hasWorldSetting = currentProject.world_time_period ||
|
||||
currentProject.world_location ||
|
||||
currentProject.world_atmosphere ||
|
||||
currentProject.world_rules;
|
||||
|
||||
if (!hasWorldSetting) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
{/* 固定头部 */}
|
||||
<div style={{
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 10,
|
||||
backgroundColor: '#fff',
|
||||
padding: '16px 0',
|
||||
marginBottom: 16,
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
display: 'flex',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<GlobalOutlined style={{ fontSize: 24, marginRight: 12, color: '#1890ff' }} />
|
||||
<h2 style={{ margin: 0 }}>世界设定</h2>
|
||||
</div>
|
||||
|
||||
{/* 可滚动内容区域 */}
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
<Empty
|
||||
description="暂无世界设定信息"
|
||||
style={{ marginTop: 60 }}
|
||||
>
|
||||
<Paragraph type="secondary">
|
||||
世界设定信息在创建项目向导中生成,用于构建小说的世界观背景。
|
||||
</Paragraph>
|
||||
</Empty>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
{/* 固定头部 */}
|
||||
<div style={{
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 10,
|
||||
backgroundColor: '#fff',
|
||||
padding: '16px 0',
|
||||
marginBottom: 24,
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
display: 'flex',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<GlobalOutlined style={{ fontSize: 24, marginRight: 12, color: '#1890ff' }} />
|
||||
<h2 style={{ margin: 0 }}>世界设定</h2>
|
||||
</div>
|
||||
|
||||
{/* 可滚动内容区域 */}
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
<Card
|
||||
style={{
|
||||
...cardStyles.base,
|
||||
marginBottom: 16
|
||||
}}
|
||||
title={
|
||||
<span style={{ fontSize: 18, fontWeight: 500 }}>
|
||||
基础信息
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Descriptions bordered column={1} styles={{ label: { width: 120, fontWeight: 500 } }}>
|
||||
<Descriptions.Item label="小说名称">{currentProject.title}</Descriptions.Item>
|
||||
{currentProject.description && (
|
||||
<Descriptions.Item label="小说简介">{currentProject.description}</Descriptions.Item>
|
||||
)}
|
||||
<Descriptions.Item label="小说主题">{currentProject.theme || '未设定'}</Descriptions.Item>
|
||||
<Descriptions.Item label="小说类型">{currentProject.genre || '未设定'}</Descriptions.Item>
|
||||
<Descriptions.Item label="叙事视角">{currentProject.narrative_perspective || '未设定'}</Descriptions.Item>
|
||||
<Descriptions.Item label="目标字数">
|
||||
{currentProject.target_words ? `${currentProject.target_words.toLocaleString()} 字` : '未设定'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
style={{
|
||||
...cardStyles.base,
|
||||
marginBottom: 16
|
||||
}}
|
||||
title={
|
||||
<span style={{ fontSize: 18, fontWeight: 500 }}>
|
||||
<GlobalOutlined style={{ marginRight: 8 }} />
|
||||
小说世界观
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<div style={{ padding: '16px 0' }}>
|
||||
{currentProject.world_time_period && (
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Title level={5} style={{ color: '#1890ff', marginBottom: 12 }}>
|
||||
时间设定
|
||||
</Title>
|
||||
<Paragraph style={{
|
||||
fontSize: 15,
|
||||
lineHeight: 1.8,
|
||||
padding: 16,
|
||||
background: '#f5f5f5',
|
||||
borderRadius: 8,
|
||||
borderLeft: '4px solid #1890ff'
|
||||
}}>
|
||||
{currentProject.world_time_period}
|
||||
</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentProject.world_location && (
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Title level={5} style={{ color: '#52c41a', marginBottom: 12 }}>
|
||||
地点设定
|
||||
</Title>
|
||||
<Paragraph style={{
|
||||
fontSize: 15,
|
||||
lineHeight: 1.8,
|
||||
padding: 16,
|
||||
background: '#f5f5f5',
|
||||
borderRadius: 8,
|
||||
borderLeft: '4px solid #52c41a'
|
||||
}}>
|
||||
{currentProject.world_location}
|
||||
</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentProject.world_atmosphere && (
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Title level={5} style={{ color: '#faad14', marginBottom: 12 }}>
|
||||
氛围设定
|
||||
</Title>
|
||||
<Paragraph style={{
|
||||
fontSize: 15,
|
||||
lineHeight: 1.8,
|
||||
padding: 16,
|
||||
background: '#f5f5f5',
|
||||
borderRadius: 8,
|
||||
borderLeft: '4px solid #faad14'
|
||||
}}>
|
||||
{currentProject.world_atmosphere}
|
||||
</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentProject.world_rules && (
|
||||
<div style={{ marginBottom: 0 }}>
|
||||
<Title level={5} style={{ color: '#f5222d', marginBottom: 12 }}>
|
||||
规则设定
|
||||
</Title>
|
||||
<Paragraph style={{
|
||||
fontSize: 15,
|
||||
lineHeight: 1.8,
|
||||
padding: 16,
|
||||
background: '#f5f5f5',
|
||||
borderRadius: 8,
|
||||
borderLeft: '4px solid #f5222d'
|
||||
}}>
|
||||
{currentProject.world_rules}
|
||||
</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
import axios from 'axios';
|
||||
import { message } from 'antd';
|
||||
import { ssePost } from '../utils/sseClient';
|
||||
import type { SSEClientOptions } from '../utils/sseClient';
|
||||
import type {
|
||||
User,
|
||||
AuthUrlResponse,
|
||||
Project,
|
||||
ProjectCreate,
|
||||
ProjectUpdate,
|
||||
WorldBuildingResponse,
|
||||
Outline,
|
||||
OutlineCreate,
|
||||
OutlineUpdate,
|
||||
OutlineReorderRequest,
|
||||
Character,
|
||||
CharacterUpdate,
|
||||
Chapter,
|
||||
ChapterCreate,
|
||||
ChapterUpdate,
|
||||
GenerateOutlineRequest,
|
||||
GenerateCharacterRequest,
|
||||
PolishTextRequest,
|
||||
GenerateCharactersResponse,
|
||||
GenerateOutlineResponse,
|
||||
} from '../types';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 120000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => {
|
||||
return response.data;
|
||||
},
|
||||
(error) => {
|
||||
let errorMessage = '请求失败';
|
||||
|
||||
if (error.response) {
|
||||
const status = error.response.status;
|
||||
const data = error.response.data;
|
||||
|
||||
switch (status) {
|
||||
case 400:
|
||||
errorMessage = data?.detail || '请求参数错误';
|
||||
break;
|
||||
case 401:
|
||||
errorMessage = '未授权,请先登录';
|
||||
if (window.location.pathname !== '/login') {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
break;
|
||||
case 403:
|
||||
errorMessage = '没有权限访问';
|
||||
break;
|
||||
case 404:
|
||||
errorMessage = data?.detail || '请求的资源不存在';
|
||||
break;
|
||||
case 422:
|
||||
errorMessage = data?.detail || '请求参数验证失败';
|
||||
if (data?.errors) {
|
||||
console.error('验证错误详情:', data.errors);
|
||||
}
|
||||
break;
|
||||
case 500:
|
||||
errorMessage = data?.detail || '服务器内部错误';
|
||||
break;
|
||||
case 503:
|
||||
errorMessage = '服务暂时不可用,请稍后重试';
|
||||
break;
|
||||
default:
|
||||
errorMessage = data?.detail || data?.message || `请求失败 (${status})`;
|
||||
}
|
||||
} else if (error.request) {
|
||||
errorMessage = '网络错误,请检查网络连接';
|
||||
} else {
|
||||
errorMessage = error.message || '请求失败';
|
||||
}
|
||||
|
||||
message.error(errorMessage);
|
||||
console.error('API Error:', errorMessage, error);
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export const authApi = {
|
||||
getAuthConfig: () => api.get<unknown, { local_auth_enabled: boolean; linuxdo_enabled: boolean }>('/auth/config'),
|
||||
|
||||
localLogin: (username: string, password: string) =>
|
||||
api.post<unknown, { success: boolean; message: string; user: User }>('/auth/local/login', { username, password }),
|
||||
|
||||
getLinuxDOAuthUrl: () => api.get<unknown, AuthUrlResponse>('/auth/linuxdo/url'),
|
||||
|
||||
getCurrentUser: () => api.get<unknown, User>('/auth/user'),
|
||||
|
||||
logout: () => api.post('/auth/logout'),
|
||||
};
|
||||
|
||||
export const userApi = {
|
||||
getCurrentUser: () => api.get<unknown, User>('/users/current'),
|
||||
|
||||
listUsers: () => api.get<unknown, User[]>('/users'),
|
||||
|
||||
setAdmin: (userId: string, isAdmin: boolean) =>
|
||||
api.post('/users/set-admin', { user_id: userId, is_admin: isAdmin }),
|
||||
|
||||
deleteUser: (userId: string) => api.delete(`/users/${userId}`),
|
||||
|
||||
getUser: (userId: string) => api.get<unknown, User>(`/users/${userId}`),
|
||||
};
|
||||
|
||||
export const projectApi = {
|
||||
getProjects: () => api.get<unknown, Project[]>('/projects'),
|
||||
|
||||
getProject: (id: string) => api.get<unknown, Project>(`/projects/${id}`),
|
||||
|
||||
createProject: (data: ProjectCreate) => api.post<unknown, Project>('/projects', data),
|
||||
|
||||
updateProject: (id: string, data: ProjectUpdate) =>
|
||||
api.put<unknown, Project>(`/projects/${id}`, data),
|
||||
|
||||
deleteProject: (id: string) => api.delete(`/projects/${id}`),
|
||||
|
||||
exportProject: (id: string) => {
|
||||
window.open(`/api/projects/${id}/export`, '_blank');
|
||||
},
|
||||
};
|
||||
|
||||
export const outlineApi = {
|
||||
getOutlines: (projectId: string) =>
|
||||
api.get<unknown, { total: number; items: Outline[] }>(`/outlines/project/${projectId}`).then(res => res.items),
|
||||
|
||||
getOutline: (id: string) => api.get<unknown, Outline>(`/outlines/${id}`),
|
||||
|
||||
createOutline: (data: OutlineCreate) => api.post<unknown, Outline>('/outlines', data),
|
||||
|
||||
updateOutline: (id: string, data: OutlineUpdate) =>
|
||||
api.put<unknown, Outline>(`/outlines/${id}`, data),
|
||||
|
||||
deleteOutline: (id: string) => api.delete(`/outlines/${id}`),
|
||||
|
||||
reorderOutlines: (data: OutlineReorderRequest) =>
|
||||
api.post<unknown, { message: string; updated_outlines: number; updated_chapters: number }>('/outlines/reorder', data),
|
||||
|
||||
generateOutline: (data: GenerateOutlineRequest) =>
|
||||
api.post<unknown, { total: number; items: Outline[] }>('/outlines/generate', data).then(res => res.items),
|
||||
};
|
||||
|
||||
export const characterApi = {
|
||||
getCharacters: (projectId: string) =>
|
||||
api.get<unknown, Character[]>(`/characters/project/${projectId}`),
|
||||
|
||||
getCharacter: (id: string) => api.get<unknown, Character>(`/characters/${id}`),
|
||||
|
||||
updateCharacter: (id: string, data: CharacterUpdate) =>
|
||||
api.put<unknown, Character>(`/characters/${id}`, data),
|
||||
|
||||
deleteCharacter: (id: string) => api.delete(`/characters/${id}`),
|
||||
|
||||
generateCharacter: (data: GenerateCharacterRequest) =>
|
||||
api.post<unknown, Character>('/characters/generate', data),
|
||||
};
|
||||
|
||||
export const chapterApi = {
|
||||
getChapters: (projectId: string) =>
|
||||
api.get<unknown, Chapter[]>(`/chapters/project/${projectId}`),
|
||||
|
||||
getChapter: (id: string) => api.get<unknown, Chapter>(`/chapters/${id}`),
|
||||
|
||||
createChapter: (data: ChapterCreate) => api.post<unknown, Chapter>('/chapters', data),
|
||||
|
||||
updateChapter: (id: string, data: ChapterUpdate) =>
|
||||
api.put<unknown, Chapter>(`/chapters/${id}`, data),
|
||||
|
||||
deleteChapter: (id: string) => api.delete(`/chapters/${id}`),
|
||||
|
||||
checkCanGenerate: (chapterId: string) =>
|
||||
api.get<unknown, import('../types').ChapterCanGenerateResponse>(`/chapters/${chapterId}/can-generate`),
|
||||
|
||||
generateChapterContent: (chapterId: string) =>
|
||||
api.post<unknown, { content: string }>(`/chapters/${chapterId}/generate`, {}),
|
||||
};
|
||||
|
||||
export const polishApi = {
|
||||
polishText: (data: PolishTextRequest) =>
|
||||
api.post<unknown, { polished_text: string }>('/polish', data),
|
||||
|
||||
polishBatch: (texts: string[]) =>
|
||||
api.post<unknown, { polished_texts: string[] }>('/polish/batch', { texts }),
|
||||
};
|
||||
export default api;
|
||||
|
||||
|
||||
export const wizardStreamApi = {
|
||||
generateWorldBuildingStream: (
|
||||
data: {
|
||||
title: string;
|
||||
description: string;
|
||||
theme: string;
|
||||
genre: string | string[];
|
||||
narrative_perspective?: string;
|
||||
target_words?: number;
|
||||
chapter_count?: number;
|
||||
character_count?: number;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
},
|
||||
options?: SSEClientOptions
|
||||
) => ssePost<WorldBuildingResponse>(
|
||||
'/api/wizard-stream/world-building',
|
||||
data,
|
||||
options
|
||||
),
|
||||
|
||||
generateCharactersStream: (
|
||||
data: {
|
||||
project_id: string;
|
||||
count?: number;
|
||||
world_context?: Record<string, string>;
|
||||
theme?: string;
|
||||
genre?: string;
|
||||
requirements?: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
},
|
||||
options?: SSEClientOptions
|
||||
) => ssePost<GenerateCharactersResponse>(
|
||||
'/api/wizard-stream/characters',
|
||||
data,
|
||||
options
|
||||
),
|
||||
|
||||
generateCompleteOutlineStream: (
|
||||
data: {
|
||||
project_id: string;
|
||||
chapter_count: number;
|
||||
narrative_perspective: string;
|
||||
target_words?: number;
|
||||
requirements?: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
},
|
||||
options?: SSEClientOptions
|
||||
) => ssePost<GenerateOutlineResponse>(
|
||||
'/api/wizard-stream/outline',
|
||||
data,
|
||||
options
|
||||
),
|
||||
|
||||
updateWorldBuildingStream: (
|
||||
projectId: string,
|
||||
data: {
|
||||
time_period?: string;
|
||||
location?: string;
|
||||
atmosphere?: string;
|
||||
rules?: string;
|
||||
},
|
||||
options?: SSEClientOptions
|
||||
) => ssePost<WorldBuildingResponse>(
|
||||
`/api/wizard-stream/world-building/${projectId}`,
|
||||
data,
|
||||
options
|
||||
),
|
||||
|
||||
regenerateWorldBuildingStream: (
|
||||
projectId: string,
|
||||
data?: {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
},
|
||||
options?: SSEClientOptions
|
||||
) => ssePost<WorldBuildingResponse>(
|
||||
`/api/wizard-stream/world-building/${projectId}/regenerate`,
|
||||
data || {},
|
||||
options
|
||||
),
|
||||
|
||||
cleanupWizardDataStream: (
|
||||
projectId: string,
|
||||
options?: SSEClientOptions
|
||||
) => ssePost<{ message: string; deleted: { characters: number; outlines: number; chapters: number } }>(
|
||||
`/api/wizard-stream/cleanup/${projectId}`,
|
||||
{},
|
||||
options
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* 事件总线 - 用于跨组件/页面的数据同步通信
|
||||
*
|
||||
* 使用方式:
|
||||
* - eventBus.on('eventName', callback) - 监听事件
|
||||
* - eventBus.off('eventName', callback) - 取消监听
|
||||
* - eventBus.emit('eventName', data) - 触发事件
|
||||
* - eventBus.once('eventName', callback) - 一次性监听
|
||||
*/
|
||||
|
||||
type EventCallback = (data?: unknown) => void;
|
||||
|
||||
class EventBus {
|
||||
private events: Map<string, EventCallback[]> = new Map();
|
||||
|
||||
/**
|
||||
* 监听事件
|
||||
*/
|
||||
on(event: string, callback: EventCallback): void {
|
||||
if (!this.events.has(event)) {
|
||||
this.events.set(event, []);
|
||||
}
|
||||
this.events.get(event)!.push(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消监听事件
|
||||
*/
|
||||
off(event: string, callback: EventCallback): void {
|
||||
const callbacks = this.events.get(event);
|
||||
if (callbacks) {
|
||||
const index = callbacks.indexOf(callback);
|
||||
if (index > -1) {
|
||||
callbacks.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发事件
|
||||
*/
|
||||
emit(event: string, data?: unknown): void {
|
||||
const callbacks = this.events.get(event);
|
||||
if (callbacks) {
|
||||
callbacks.forEach(cb => {
|
||||
try {
|
||||
cb(data);
|
||||
} catch (error) {
|
||||
console.error(`事件处理器执行失败 [${event}]:`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次性监听事件
|
||||
*/
|
||||
once(event: string, callback: EventCallback): void {
|
||||
const onceCallback: EventCallback = (data) => {
|
||||
callback(data);
|
||||
this.off(event, onceCallback);
|
||||
};
|
||||
this.on(event, onceCallback);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除某个事件的所有监听器
|
||||
*/
|
||||
removeAllListeners(event?: string): void {
|
||||
if (event) {
|
||||
this.events.delete(event);
|
||||
} else {
|
||||
this.events.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取事件的监听器数量
|
||||
*/
|
||||
listenerCount(event: string): number {
|
||||
return this.events.get(event)?.length || 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export const eventBus = new EventBus();
|
||||
|
||||
// 导出事件名称常量,避免字符串拼写错误
|
||||
export const EventNames = {
|
||||
// 项目相关事件
|
||||
PROJECT_CREATED: 'project:created',
|
||||
PROJECT_UPDATED: 'project:updated',
|
||||
PROJECT_DELETED: 'project:deleted',
|
||||
PROJECT_NEEDS_REFRESH: 'project:needsRefresh',
|
||||
|
||||
// 角色相关事件
|
||||
CHARACTER_CREATED: 'character:created',
|
||||
CHARACTER_UPDATED: 'character:updated',
|
||||
CHARACTER_DELETED: 'character:deleted',
|
||||
CHARACTER_NEEDS_REFRESH: 'character:needsRefresh',
|
||||
|
||||
// 大纲相关事件
|
||||
OUTLINE_CREATED: 'outline:created',
|
||||
OUTLINE_UPDATED: 'outline:updated',
|
||||
OUTLINE_DELETED: 'outline:deleted',
|
||||
OUTLINE_REORDERED: 'outline:reordered',
|
||||
OUTLINE_GENERATED: 'outline:generated',
|
||||
OUTLINE_NEEDS_REFRESH: 'outline:needsRefresh',
|
||||
|
||||
// 章节相关事件
|
||||
CHAPTER_CREATED: 'chapter:created',
|
||||
CHAPTER_UPDATED: 'chapter:updated',
|
||||
CHAPTER_DELETED: 'chapter:deleted',
|
||||
CHAPTER_NEEDS_REFRESH: 'chapter:needsRefresh',
|
||||
} as const;
|
||||
@@ -0,0 +1,400 @@
|
||||
/**
|
||||
* Store Hooks - 提供数据获取和自动同步功能
|
||||
* 这些 hooks 封装了数据获取逻辑,并自动更新 store
|
||||
*/
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { message } from 'antd';
|
||||
import { useStore } from './index';
|
||||
import { projectApi, outlineApi, characterApi, chapterApi } from '../services/api';
|
||||
import type {
|
||||
PaginationResponse,
|
||||
Outline,
|
||||
Character,
|
||||
Chapter,
|
||||
Project,
|
||||
ProjectCreate,
|
||||
ProjectUpdate,
|
||||
OutlineCreate,
|
||||
OutlineUpdate,
|
||||
ChapterCreate,
|
||||
ChapterUpdate,
|
||||
GenerateOutlineRequest,
|
||||
GenerateCharacterRequest
|
||||
} from '../types';
|
||||
|
||||
/**
|
||||
* 项目数据同步 Hook
|
||||
*/
|
||||
export function useProjectSync() {
|
||||
const { setProjects, setLoading, addProject, updateProject, removeProject } = useStore();
|
||||
|
||||
// 刷新项目列表
|
||||
const refreshProjects = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await projectApi.getProjects();
|
||||
const projects = Array.isArray(data) ? data : (data as PaginationResponse<Project>).items || [];
|
||||
setProjects(projects);
|
||||
return projects;
|
||||
} catch (error) {
|
||||
console.error('刷新项目列表失败:', error);
|
||||
message.error('刷新项目列表失败');
|
||||
return [];
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [setProjects, setLoading]);
|
||||
|
||||
// 创建项目(带同步)
|
||||
const createProject = useCallback(async (data: ProjectCreate) => {
|
||||
try {
|
||||
const created = await projectApi.createProject(data);
|
||||
addProject(created);
|
||||
return created;
|
||||
} catch (error) {
|
||||
console.error('创建项目失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [addProject]);
|
||||
|
||||
// 更新项目(带同步)
|
||||
const updateProjectSync = useCallback(async (id: string, data: ProjectUpdate) => {
|
||||
try {
|
||||
const updated = await projectApi.updateProject(id, data);
|
||||
updateProject(id, updated);
|
||||
return updated;
|
||||
} catch (error) {
|
||||
console.error('更新项目失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [updateProject]);
|
||||
|
||||
// 删除项目(带同步)
|
||||
const deleteProject = useCallback(async (id: string) => {
|
||||
try {
|
||||
await projectApi.deleteProject(id);
|
||||
removeProject(id);
|
||||
} catch (error) {
|
||||
console.error('删除项目失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [removeProject]);
|
||||
|
||||
return {
|
||||
refreshProjects,
|
||||
createProject,
|
||||
updateProject: updateProjectSync,
|
||||
deleteProject,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 角色数据同步 Hook
|
||||
*/
|
||||
export function useCharacterSync() {
|
||||
const { currentProject, setCharacters, addCharacter, removeCharacter } = useStore();
|
||||
|
||||
// 刷新角色列表
|
||||
const refreshCharacters = useCallback(async (projectId?: string) => {
|
||||
const id = projectId || currentProject?.id;
|
||||
if (!id) return [];
|
||||
|
||||
try {
|
||||
const data = await characterApi.getCharacters(id);
|
||||
const characters = Array.isArray(data) ? data : (data as PaginationResponse<Character>).items || [];
|
||||
setCharacters(characters);
|
||||
return characters;
|
||||
} catch (error) {
|
||||
console.error('刷新角色列表失败:', error);
|
||||
message.error('刷新角色列表失败');
|
||||
return [];
|
||||
}
|
||||
}, [currentProject?.id, setCharacters]);
|
||||
|
||||
// 删除角色(带同步)
|
||||
const deleteCharacter = useCallback(async (id: string) => {
|
||||
try {
|
||||
await characterApi.deleteCharacter(id);
|
||||
removeCharacter(id);
|
||||
} catch (error) {
|
||||
console.error('删除角色失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [removeCharacter]);
|
||||
|
||||
// AI生成角色(带同步)
|
||||
const generateCharacter = useCallback(async (data: GenerateCharacterRequest) => {
|
||||
try {
|
||||
const generated = await characterApi.generateCharacter(data);
|
||||
addCharacter(generated);
|
||||
return generated;
|
||||
} catch (error) {
|
||||
console.error('AI生成角色失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [addCharacter]);
|
||||
|
||||
return {
|
||||
refreshCharacters,
|
||||
deleteCharacter,
|
||||
generateCharacter,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 大纲数据同步 Hook
|
||||
*/
|
||||
export function useOutlineSync() {
|
||||
const { currentProject, setOutlines, addOutline, updateOutline, removeOutline } = useStore();
|
||||
|
||||
// 刷新大纲列表
|
||||
const refreshOutlines = useCallback(async (projectId?: string) => {
|
||||
const id = projectId || currentProject?.id;
|
||||
if (!id) return [];
|
||||
|
||||
try {
|
||||
const data = await outlineApi.getOutlines(id);
|
||||
const outlines = Array.isArray(data) ? data : (data as PaginationResponse<Outline>).items || [];
|
||||
setOutlines(outlines);
|
||||
return outlines;
|
||||
} catch (error) {
|
||||
console.error('刷新大纲列表失败:', error);
|
||||
message.error('刷新大纲列表失败');
|
||||
return [];
|
||||
}
|
||||
}, [currentProject?.id, setOutlines]); // 添加 currentProject?.id 到依赖数组
|
||||
|
||||
// 创建大纲(带同步)
|
||||
const createOutline = useCallback(async (data: OutlineCreate) => {
|
||||
try {
|
||||
const created = await outlineApi.createOutline(data);
|
||||
addOutline(created);
|
||||
return created;
|
||||
} catch (error) {
|
||||
console.error('创建大纲失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [addOutline]);
|
||||
|
||||
// 更新大纲(带同步)
|
||||
const updateOutlineSync = useCallback(async (id: string, data: OutlineUpdate) => {
|
||||
try {
|
||||
const updated = await outlineApi.updateOutline(id, data);
|
||||
updateOutline(id, updated);
|
||||
return updated;
|
||||
} catch (error) {
|
||||
console.error('更新大纲失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [updateOutline]);
|
||||
|
||||
// 删除大纲(带同步)
|
||||
const deleteOutline = useCallback(async (id: string) => {
|
||||
try {
|
||||
await outlineApi.deleteOutline(id);
|
||||
removeOutline(id);
|
||||
} catch (error) {
|
||||
console.error('删除大纲失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [removeOutline]);
|
||||
|
||||
// 重排序大纲(带同步)
|
||||
const reorderOutlines = useCallback(async (orders: Array<{ id: string; order_index: number }>, projectId?: string) => {
|
||||
try {
|
||||
await outlineApi.reorderOutlines({ orders });
|
||||
// 重新获取完整列表以确保顺序正确
|
||||
const id = projectId || currentProject?.id;
|
||||
if (id) {
|
||||
const data = await outlineApi.getOutlines(id);
|
||||
const outlines = Array.isArray(data) ? data : (data as PaginationResponse<Outline>).items || [];
|
||||
setOutlines(outlines);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('重排序大纲失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [currentProject?.id, setOutlines]); // 添加 currentProject?.id 到依赖数组
|
||||
|
||||
// AI生成大纲(带同步)
|
||||
const generateOutlines = useCallback(async (data: GenerateOutlineRequest) => {
|
||||
try {
|
||||
const result = await outlineApi.generateOutline(data);
|
||||
const outlines = Array.isArray(result) ? result : (result as PaginationResponse<Outline>).items || [];
|
||||
outlines.forEach((outline: Outline) => addOutline(outline));
|
||||
return outlines;
|
||||
} catch (error) {
|
||||
console.error('AI生成大纲失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [addOutline]);
|
||||
|
||||
return {
|
||||
refreshOutlines,
|
||||
createOutline,
|
||||
updateOutline: updateOutlineSync,
|
||||
deleteOutline,
|
||||
reorderOutlines,
|
||||
generateOutlines,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 章节数据同步 Hook
|
||||
*/
|
||||
export function useChapterSync() {
|
||||
const { currentProject, setChapters, addChapter, updateChapter, removeChapter } = useStore();
|
||||
|
||||
// 刷新章节列表
|
||||
const refreshChapters = useCallback(async (projectId?: string) => {
|
||||
const id = projectId || currentProject?.id;
|
||||
if (!id) return [];
|
||||
|
||||
try {
|
||||
const data = await chapterApi.getChapters(id);
|
||||
const chapters = Array.isArray(data) ? data : (data as PaginationResponse<Chapter>).items || [];
|
||||
setChapters(chapters);
|
||||
return chapters;
|
||||
} catch (error) {
|
||||
console.error('刷新章节列表失败:', error);
|
||||
message.error('刷新章节列表失败');
|
||||
return [];
|
||||
}
|
||||
}, [currentProject?.id, setChapters]); // 添加 currentProject?.id 到依赖数组
|
||||
|
||||
// 创建章节(带同步)
|
||||
const createChapter = useCallback(async (data: ChapterCreate) => {
|
||||
try {
|
||||
const created = await chapterApi.createChapter(data);
|
||||
addChapter(created);
|
||||
return created;
|
||||
} catch (error) {
|
||||
console.error('创建章节失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [addChapter]);
|
||||
|
||||
// 更新章节(带同步)
|
||||
const updateChapterSync = useCallback(async (id: string, data: ChapterUpdate) => {
|
||||
try {
|
||||
const updated = await chapterApi.updateChapter(id, data);
|
||||
updateChapter(id, updated);
|
||||
return updated;
|
||||
} catch (error) {
|
||||
console.error('更新章节失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [updateChapter]);
|
||||
|
||||
// 删除章节(带同步)
|
||||
const deleteChapter = useCallback(async (id: string) => {
|
||||
try {
|
||||
await chapterApi.deleteChapter(id);
|
||||
removeChapter(id);
|
||||
} catch (error) {
|
||||
console.error('删除章节失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [removeChapter]);
|
||||
|
||||
// AI生成章节内容(带同步)
|
||||
const generateChapterContent = useCallback(async (chapterId: string) => {
|
||||
try {
|
||||
const result = await chapterApi.generateChapterContent(chapterId);
|
||||
// 直接调用 API 更新
|
||||
const updated = await chapterApi.updateChapter(chapterId, { content: result.content });
|
||||
updateChapter(chapterId, updated);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('AI生成章节内容失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [updateChapter]);
|
||||
|
||||
// AI流式生成章节内容(带同步)
|
||||
const generateChapterContentStream = useCallback(async (
|
||||
chapterId: string,
|
||||
onProgress?: (content: string) => void
|
||||
) => {
|
||||
try {
|
||||
// 使用fetch处理流式响应
|
||||
const response = await fetch(`/api/chapters/${chapterId}/generate-stream`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
if (!reader) {
|
||||
throw new Error('无法获取响应流');
|
||||
}
|
||||
|
||||
let buffer = '';
|
||||
let fullContent = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// 处理缓冲区中的完整消息
|
||||
const lines = buffer.split('\n\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim() === '' || line.startsWith(':')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const dataMatch = line.match(/^data: (.+)$/m);
|
||||
if (dataMatch) {
|
||||
const message = JSON.parse(dataMatch[1]);
|
||||
|
||||
if (message.type === 'content' && message.content) {
|
||||
fullContent += message.content;
|
||||
if (onProgress) {
|
||||
onProgress(fullContent);
|
||||
}
|
||||
} else if (message.type === 'error') {
|
||||
throw new Error(message.error || '生成失败');
|
||||
} else if (message.type === 'done') {
|
||||
// 生成完成,刷新章节数据
|
||||
await refreshChapters();
|
||||
return { content: fullContent, word_count: message.word_count };
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('解析SSE消息失败:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { content: fullContent };
|
||||
} catch (error) {
|
||||
console.error('AI流式生成章节内容失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [refreshChapters]);
|
||||
|
||||
return {
|
||||
refreshChapters,
|
||||
createChapter,
|
||||
updateChapter: updateChapterSync,
|
||||
deleteChapter,
|
||||
generateChapterContent,
|
||||
generateChapterContentStream,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { create } from 'zustand';
|
||||
import type { Project, Outline, Character, Chapter } from '../types';
|
||||
|
||||
interface AppState {
|
||||
currentProject: Project | null;
|
||||
setCurrentProject: (project: Project | null) => void;
|
||||
|
||||
projects: Project[];
|
||||
setProjects: (projects: Project[]) => void;
|
||||
addProject: (project: Project) => void;
|
||||
updateProject: (id: string, project: Partial<Project>) => void;
|
||||
removeProject: (id: string) => void;
|
||||
|
||||
outlines: Outline[];
|
||||
setOutlines: (outlines: Outline[]) => void;
|
||||
addOutline: (outline: Outline) => void;
|
||||
updateOutline: (id: string, outline: Partial<Outline>) => void;
|
||||
removeOutline: (id: string) => void;
|
||||
|
||||
characters: Character[];
|
||||
setCharacters: (characters: Character[]) => void;
|
||||
addCharacter: (character: Character) => void;
|
||||
updateCharacter: (id: string, character: Partial<Character>) => void;
|
||||
removeCharacter: (id: string) => void;
|
||||
|
||||
chapters: Chapter[];
|
||||
setChapters: (chapters: Chapter[]) => void;
|
||||
addChapter: (chapter: Chapter) => void;
|
||||
updateChapter: (id: string, chapter: Partial<Chapter>) => void;
|
||||
removeChapter: (id: string) => void;
|
||||
|
||||
currentChapter: Chapter | null;
|
||||
setCurrentChapter: (chapter: Chapter | null) => void;
|
||||
|
||||
loading: boolean;
|
||||
setLoading: (loading: boolean) => void;
|
||||
|
||||
lastUpdated: {
|
||||
projects?: number;
|
||||
outlines?: number;
|
||||
characters?: number;
|
||||
chapters?: number;
|
||||
};
|
||||
markUpdated: (key: 'projects' | 'outlines' | 'characters' | 'chapters') => void;
|
||||
|
||||
clearProjectData: () => void;
|
||||
}
|
||||
|
||||
export const useStore = create<AppState>((set) => ({
|
||||
currentProject: null,
|
||||
setCurrentProject: (project) => set({ currentProject: project }),
|
||||
|
||||
projects: [],
|
||||
setProjects: (projects) => set({ projects }),
|
||||
addProject: (project) => set((state) => ({
|
||||
projects: [...state.projects, project]
|
||||
})),
|
||||
updateProject: (id, updatedProject) => set((state) => ({
|
||||
projects: state.projects.map((p) =>
|
||||
p.id === id ? { ...p, ...updatedProject } : p
|
||||
),
|
||||
currentProject: state.currentProject?.id === id
|
||||
? { ...state.currentProject, ...updatedProject }
|
||||
: state.currentProject,
|
||||
})),
|
||||
removeProject: (id) => set((state) => ({
|
||||
projects: state.projects.filter((p) => p.id !== id),
|
||||
currentProject: state.currentProject?.id === id ? null : state.currentProject,
|
||||
})),
|
||||
|
||||
outlines: [],
|
||||
setOutlines: (outlines) => set({ outlines }),
|
||||
addOutline: (outline) => set((state) => ({
|
||||
outlines: [...state.outlines, outline]
|
||||
})),
|
||||
updateOutline: (id, updatedOutline) => set((state) => ({
|
||||
outlines: state.outlines.map((o) =>
|
||||
o.id === id ? { ...o, ...updatedOutline } : o
|
||||
),
|
||||
})),
|
||||
removeOutline: (id) => set((state) => ({
|
||||
outlines: state.outlines.filter((o) => o.id !== id),
|
||||
})),
|
||||
|
||||
characters: [],
|
||||
setCharacters: (characters) => set({ characters }),
|
||||
addCharacter: (character) => set((state) => ({
|
||||
characters: [...state.characters, character]
|
||||
})),
|
||||
updateCharacter: (id, updatedCharacter) => set((state) => ({
|
||||
characters: state.characters.map((c) =>
|
||||
c.id === id ? { ...c, ...updatedCharacter } : c
|
||||
),
|
||||
})),
|
||||
removeCharacter: (id) => set((state) => ({
|
||||
characters: state.characters.filter((c) => c.id !== id),
|
||||
})),
|
||||
|
||||
chapters: [],
|
||||
setChapters: (chapters) => set({ chapters }),
|
||||
addChapter: (chapter) => set((state) => ({
|
||||
chapters: [...state.chapters, chapter]
|
||||
})),
|
||||
updateChapter: (id, updatedChapter) => set((state) => ({
|
||||
chapters: state.chapters.map((c) =>
|
||||
c.id === id ? { ...c, ...updatedChapter } : c
|
||||
),
|
||||
currentChapter: state.currentChapter?.id === id
|
||||
? { ...state.currentChapter, ...updatedChapter }
|
||||
: state.currentChapter,
|
||||
})),
|
||||
removeChapter: (id) => set((state) => ({
|
||||
chapters: state.chapters.filter((c) => c.id !== id),
|
||||
currentChapter: state.currentChapter?.id === id ? null : state.currentChapter,
|
||||
})),
|
||||
|
||||
currentChapter: null,
|
||||
setCurrentChapter: (chapter) => set({ currentChapter: chapter }),
|
||||
|
||||
loading: false,
|
||||
setLoading: (loading) => set({ loading }),
|
||||
|
||||
lastUpdated: {},
|
||||
markUpdated: (key) => set((state) => ({
|
||||
lastUpdated: {
|
||||
...state.lastUpdated,
|
||||
[key]: Date.now(),
|
||||
},
|
||||
})),
|
||||
|
||||
clearProjectData: () => set({
|
||||
outlines: [],
|
||||
characters: [],
|
||||
chapters: [],
|
||||
currentChapter: null,
|
||||
}),
|
||||
}));
|
||||
@@ -0,0 +1,304 @@
|
||||
// 用户类型定义
|
||||
export interface User {
|
||||
user_id: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
avatar_url?: string;
|
||||
trust_level: number;
|
||||
is_admin: boolean;
|
||||
linuxdo_id: string;
|
||||
created_at: string;
|
||||
last_login: string;
|
||||
}
|
||||
|
||||
// LinuxDO 授权 URL 响应
|
||||
export interface AuthUrlResponse {
|
||||
auth_url: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
// 项目类型定义
|
||||
export interface Project {
|
||||
id: string; // UUID字符串
|
||||
title: string;
|
||||
description?: string;
|
||||
theme?: string;
|
||||
genre?: string;
|
||||
target_words?: number;
|
||||
current_words: number;
|
||||
status: 'planning' | 'writing' | 'revising' | 'completed';
|
||||
wizard_status?: 'incomplete' | 'completed';
|
||||
wizard_step?: number;
|
||||
world_time_period?: string;
|
||||
world_location?: string;
|
||||
world_atmosphere?: string;
|
||||
world_rules?: string;
|
||||
chapter_count?: number;
|
||||
narrative_perspective?: string;
|
||||
character_count?: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ProjectCreate {
|
||||
title: string;
|
||||
description?: string;
|
||||
theme?: string;
|
||||
genre?: string;
|
||||
target_words?: number;
|
||||
wizard_status?: 'incomplete' | 'completed';
|
||||
wizard_step?: number;
|
||||
world_time_period?: string;
|
||||
world_location?: string;
|
||||
world_atmosphere?: string;
|
||||
world_rules?: string;
|
||||
}
|
||||
|
||||
export interface ProjectUpdate {
|
||||
title?: string;
|
||||
description?: string;
|
||||
theme?: string;
|
||||
genre?: string;
|
||||
target_words?: number;
|
||||
status?: 'planning' | 'writing' | 'revising' | 'completed';
|
||||
world_time_period?: string;
|
||||
world_location?: string;
|
||||
world_atmosphere?: string;
|
||||
world_rules?: string;
|
||||
chapter_count?: number;
|
||||
narrative_perspective?: string;
|
||||
character_count?: number;
|
||||
// current_words 由章节内容自动计算,不在此接口中
|
||||
}
|
||||
|
||||
// 向导专用的项目更新接口,包含向导流程控制字段
|
||||
export interface ProjectWizardUpdate extends ProjectUpdate {
|
||||
wizard_status?: 'incomplete' | 'completed';
|
||||
wizard_step?: number;
|
||||
}
|
||||
|
||||
// 项目创建向导
|
||||
export interface ProjectWizardRequest {
|
||||
title: string;
|
||||
theme: string;
|
||||
genre?: string;
|
||||
chapter_count: number;
|
||||
narrative_perspective: string;
|
||||
character_count?: number;
|
||||
target_words?: number;
|
||||
world_building?: {
|
||||
time_period: string;
|
||||
location: string;
|
||||
atmosphere: string;
|
||||
rules: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface WorldBuildingResponse {
|
||||
project_id: string;
|
||||
time_period: string;
|
||||
location: string;
|
||||
atmosphere: string;
|
||||
rules: string;
|
||||
}
|
||||
|
||||
// 大纲类型定义
|
||||
export interface Outline {
|
||||
id: string;
|
||||
project_id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
structure?: string;
|
||||
order_index: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface OutlineCreate {
|
||||
project_id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
structure?: string;
|
||||
order_index: number;
|
||||
}
|
||||
|
||||
export interface OutlineUpdate {
|
||||
title?: string;
|
||||
content?: string;
|
||||
// structure 暂不支持修改
|
||||
// order_index 只能通过 reorder 接口批量调整
|
||||
}
|
||||
|
||||
// 角色类型定义
|
||||
export interface Character {
|
||||
id: string;
|
||||
project_id: string;
|
||||
name: string;
|
||||
age?: string;
|
||||
gender?: string;
|
||||
is_organization: boolean;
|
||||
role_type?: string;
|
||||
personality?: string;
|
||||
background?: string;
|
||||
appearance?: string;
|
||||
relationships?: string;
|
||||
organization_type?: string;
|
||||
organization_purpose?: string;
|
||||
organization_members?: string;
|
||||
traits?: string;
|
||||
avatar_url?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CharacterUpdate {
|
||||
name?: string;
|
||||
age?: string;
|
||||
gender?: string;
|
||||
is_organization?: boolean;
|
||||
role_type?: string;
|
||||
personality?: string;
|
||||
background?: string;
|
||||
appearance?: string;
|
||||
relationships?: string;
|
||||
organization_type?: string;
|
||||
organization_purpose?: string;
|
||||
organization_members?: string;
|
||||
traits?: string;
|
||||
}
|
||||
|
||||
// 章节类型定义
|
||||
export interface Chapter {
|
||||
id: string;
|
||||
project_id: string;
|
||||
title: string;
|
||||
content?: string;
|
||||
summary?: string;
|
||||
chapter_number: number;
|
||||
word_count: number;
|
||||
status: 'draft' | 'writing' | 'completed';
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ChapterCreate {
|
||||
project_id: string;
|
||||
title: string;
|
||||
chapter_number: number;
|
||||
content?: string;
|
||||
summary?: string;
|
||||
status?: 'draft' | 'writing' | 'completed';
|
||||
}
|
||||
|
||||
export interface ChapterUpdate {
|
||||
title?: string;
|
||||
content?: string;
|
||||
// chapter_number 不允许修改,由大纲顺序决定
|
||||
summary?: string;
|
||||
// word_count 自动计算,不允许手动修改
|
||||
status?: 'draft' | 'writing' | 'completed';
|
||||
}
|
||||
|
||||
// 章节生成检查响应
|
||||
export interface ChapterCanGenerateResponse {
|
||||
can_generate: boolean;
|
||||
reason: string;
|
||||
previous_chapters: {
|
||||
id: string;
|
||||
chapter_number: number;
|
||||
title: string;
|
||||
has_content: boolean;
|
||||
word_count: number;
|
||||
}[];
|
||||
chapter_number: number;
|
||||
}
|
||||
|
||||
// AI生成请求类型
|
||||
export interface GenerateOutlineRequest {
|
||||
project_id: string;
|
||||
genre?: string;
|
||||
theme: string;
|
||||
chapter_count: number;
|
||||
narrative_perspective: string;
|
||||
world_context?: Record<string, unknown>;
|
||||
characters_context?: Character[];
|
||||
target_words?: number;
|
||||
requirements?: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
// 续写功能新增字段
|
||||
mode?: 'auto' | 'new' | 'continue';
|
||||
story_direction?: string;
|
||||
plot_stage?: 'development' | 'climax' | 'ending';
|
||||
keep_existing?: boolean;
|
||||
}
|
||||
|
||||
// 大纲重排序请求类型
|
||||
export interface OutlineReorderItem {
|
||||
id: string;
|
||||
order_index: number;
|
||||
}
|
||||
|
||||
export interface OutlineReorderRequest {
|
||||
orders: OutlineReorderItem[];
|
||||
}
|
||||
|
||||
export interface GenerateCharacterRequest {
|
||||
project_id: string;
|
||||
name?: string;
|
||||
role_type?: string;
|
||||
background?: string;
|
||||
requirements?: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export interface PolishTextRequest {
|
||||
text: string;
|
||||
style?: string;
|
||||
}
|
||||
|
||||
// 向导API响应类型
|
||||
export interface GenerateCharactersResponse {
|
||||
characters: Character[];
|
||||
}
|
||||
|
||||
export interface GenerateOutlineResponse {
|
||||
outlines: Outline[];
|
||||
}
|
||||
|
||||
// API响应类型
|
||||
export interface ApiResponse<T> {
|
||||
data: T;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface PaginationResponse<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
// 向导表单数据类型
|
||||
export interface WizardBasicInfo {
|
||||
title: string;
|
||||
description: string;
|
||||
theme: string;
|
||||
genre: string | string[];
|
||||
chapter_count: number;
|
||||
narrative_perspective: string;
|
||||
character_count?: number;
|
||||
target_words?: number;
|
||||
}
|
||||
|
||||
// API 错误响应类型
|
||||
export interface ApiError {
|
||||
response?: {
|
||||
data?: {
|
||||
detail?: string;
|
||||
};
|
||||
};
|
||||
message?: string;
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
export interface SSEMessage {
|
||||
type: 'progress' | 'chunk' | 'result' | 'error' | 'done';
|
||||
message?: string;
|
||||
progress?: number;
|
||||
status?: 'processing' | 'success' | 'error' | 'warning';
|
||||
content?: string;
|
||||
data?: any;
|
||||
error?: string;
|
||||
code?: number;
|
||||
}
|
||||
|
||||
export interface SSEClientOptions {
|
||||
onProgress?: (message: string, progress: number, status: string) => void;
|
||||
onChunk?: (content: string) => void;
|
||||
onResult?: (data: any) => void;
|
||||
onError?: (error: string, code?: number) => void;
|
||||
onComplete?: () => void;
|
||||
onConnectionError?: (error: Event) => void;
|
||||
}
|
||||
|
||||
export class SSEClient {
|
||||
private eventSource: EventSource | null = null;
|
||||
private url: string;
|
||||
private options: SSEClientOptions;
|
||||
private accumulatedContent: string = '';
|
||||
|
||||
constructor(url: string, options: SSEClientOptions = {}) {
|
||||
this.url = url;
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
connect(): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
this.eventSource = new EventSource(this.url);
|
||||
|
||||
this.eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const message: SSEMessage = JSON.parse(event.data);
|
||||
this.handleMessage(message, resolve, reject);
|
||||
} catch (error) {
|
||||
console.error('解析SSE消息失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
this.eventSource.onerror = (error) => {
|
||||
console.error('SSE连接错误:', error);
|
||||
if (this.options.onConnectionError) {
|
||||
this.options.onConnectionError(error);
|
||||
}
|
||||
this.close();
|
||||
reject(new Error('SSE连接失败'));
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private handleMessage(message: SSEMessage, resolve: Function, reject: Function) {
|
||||
switch (message.type) {
|
||||
case 'progress':
|
||||
if (this.options.onProgress && message.message && message.progress !== undefined) {
|
||||
this.options.onProgress(message.message, message.progress, message.status || 'processing');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'chunk':
|
||||
if (message.content) {
|
||||
this.accumulatedContent += message.content;
|
||||
if (this.options.onChunk) {
|
||||
this.options.onChunk(message.content);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'result':
|
||||
if (this.options.onResult && message.data) {
|
||||
this.options.onResult(message.data);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
if (this.options.onError) {
|
||||
this.options.onError(message.error || '未知错误', message.code);
|
||||
}
|
||||
this.close();
|
||||
reject(new Error(message.error || '未知错误'));
|
||||
break;
|
||||
|
||||
case 'done':
|
||||
if (this.options.onComplete) {
|
||||
this.options.onComplete();
|
||||
}
|
||||
this.close();
|
||||
if (!this.options.onResult && this.accumulatedContent) {
|
||||
resolve({ content: this.accumulatedContent });
|
||||
} else {
|
||||
resolve(true);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this.eventSource) {
|
||||
this.eventSource.close();
|
||||
this.eventSource = null;
|
||||
}
|
||||
}
|
||||
|
||||
getAccumulatedContent(): string {
|
||||
return this.accumulatedContent;
|
||||
}
|
||||
}
|
||||
|
||||
export class SSEPostClient {
|
||||
private url: string;
|
||||
private data: any;
|
||||
private options: SSEClientOptions;
|
||||
private abortController: AbortController | null = null;
|
||||
private accumulatedContent: string = '';
|
||||
|
||||
constructor(url: string, data: any, options: SSEClientOptions = {}) {
|
||||
this.url = url;
|
||||
this.data = data;
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
async connect(): Promise<any> {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
this.abortController = new AbortController();
|
||||
|
||||
const response = await fetch(this.url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(this.data),
|
||||
signal: this.abortController.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
if (!reader) {
|
||||
throw new Error('无法获取响应流');
|
||||
}
|
||||
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
const lines = buffer.split('\n\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim() === '' || line.startsWith(':')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const dataMatch = line.match(/^data: (.+)$/m);
|
||||
if (dataMatch) {
|
||||
const message: SSEMessage = JSON.parse(dataMatch[1]);
|
||||
await this.handleMessage(message, resolve, reject);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('解析SSE消息失败:', error, line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
if (error.name === 'AbortError') {
|
||||
console.log('请求已取消');
|
||||
} else {
|
||||
console.error('SSE POST请求失败:', error);
|
||||
if (this.options.onError) {
|
||||
this.options.onError(error.message || '请求失败');
|
||||
}
|
||||
reject(error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async handleMessage(message: SSEMessage, resolve: Function, reject: Function) {
|
||||
switch (message.type) {
|
||||
case 'progress':
|
||||
if (this.options.onProgress && message.message && message.progress !== undefined) {
|
||||
this.options.onProgress(message.message, message.progress, message.status || 'processing');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'chunk':
|
||||
if (message.content) {
|
||||
this.accumulatedContent += message.content;
|
||||
if (this.options.onChunk) {
|
||||
this.options.onChunk(message.content);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'result':
|
||||
if (this.options.onResult && message.data) {
|
||||
this.options.onResult(message.data);
|
||||
}
|
||||
(this as any).resultData = message.data;
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
if (this.options.onError) {
|
||||
this.options.onError(message.error || '未知错误', message.code);
|
||||
}
|
||||
reject(new Error(message.error || '未知错误'));
|
||||
break;
|
||||
|
||||
case 'done':
|
||||
if (this.options.onComplete) {
|
||||
this.options.onComplete();
|
||||
}
|
||||
if ((this as any).resultData) {
|
||||
resolve((this as any).resultData);
|
||||
} else if (this.accumulatedContent) {
|
||||
resolve({ content: this.accumulatedContent });
|
||||
} else {
|
||||
resolve(true);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
abort() {
|
||||
if (this.abortController) {
|
||||
this.abortController.abort();
|
||||
}
|
||||
}
|
||||
|
||||
getAccumulatedContent(): string {
|
||||
return this.accumulatedContent;
|
||||
}
|
||||
}
|
||||
|
||||
export async function ssePost<T = any>(
|
||||
url: string,
|
||||
data: any,
|
||||
options: SSEClientOptions = {}
|
||||
): Promise<T> {
|
||||
const client = new SSEPostClient(url, data, options);
|
||||
try {
|
||||
return await client.connect();
|
||||
} finally {
|
||||
client.abort();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"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",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: {
|
||||
outDir: '../backend/static',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user