新增前后端AI聊天模块,包括流式对话、配置管理和UI组件优化。
This commit is contained in:
12
.claude/launch.json
Normal file
12
.claude/launch.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"runtimeArgs": ["run", "dev"],
|
||||||
|
"port": 5173,
|
||||||
|
"cwd": "frontend"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
125
.claude/plan.md
Normal file
125
.claude/plan.md
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
# 大纲管理功能实现计划
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
为小说添加大纲管理功能,大纲属于小说的子资源,支持增删改查。前端采用**卷轴时间线**可视化展示,契合"墨韵书斋"设计风格。
|
||||||
|
|
||||||
|
## 视觉设计方向
|
||||||
|
不做传统列表,而是做一条**竖向卷轴时间线**:
|
||||||
|
- 中央一条细线作为"墨线",串联所有大纲节点
|
||||||
|
- 每个节点是一张展开的"纸笺"卡片,左右交替排列
|
||||||
|
- 卡片显示序号、一句话描述;hover展开详情
|
||||||
|
- 点击卡片可编辑、删除
|
||||||
|
- 底部有"添加新节点"按钮,点击弹出模态框
|
||||||
|
- 空状态:一卷未展开的空白卷轴动画
|
||||||
|
- 节点间可拖拽排序(用sort_order字段)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、后端
|
||||||
|
|
||||||
|
### 1. 数据模型 (`backend/app/models.py`)
|
||||||
|
在现有Novel模型同文件新增Outline模型:
|
||||||
|
```python
|
||||||
|
class Outline(SQLModel, table=True):
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
novel_id: int = Field(foreign_key="novel.id", index=True)
|
||||||
|
sort_order: int = Field(default=0) # 排序序号
|
||||||
|
summary: str = Field(max_length=200) # 一句话描述
|
||||||
|
detail: str = Field(default="", max_length=1000) # 详情(100-300字)
|
||||||
|
created_at: datetime = Field(default_factory=_now_utc)
|
||||||
|
updated_at: datetime = Field(default_factory=_now_utc)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 请求/响应模式 (`backend/app/schemas.py`)
|
||||||
|
新增:
|
||||||
|
- `OutlineCreate` — summary(必填), detail(可选)
|
||||||
|
- `OutlineUpdate` — summary, detail, sort_order 均可选
|
||||||
|
- `OutlineRead` — 全字段
|
||||||
|
- `OutlineList` — items + total
|
||||||
|
- `OutlineReorder` — 接收排序后的id列表
|
||||||
|
|
||||||
|
### 3. API路由 (`backend/app/routers/outlines.py`)
|
||||||
|
新建路由文件,前缀 `/api/novels/{novel_id}/outlines`:
|
||||||
|
- `GET /` — 列出该小说所有大纲(按sort_order排序)
|
||||||
|
- `POST /` — 创建大纲(自动设置sort_order为最大值+1)
|
||||||
|
- `GET /{outline_id}` — 获取单个大纲
|
||||||
|
- `PATCH /{outline_id}` — 更新大纲
|
||||||
|
- `DELETE /{outline_id}` — 删除大纲
|
||||||
|
- `PUT /reorder` — 批量更新排序
|
||||||
|
|
||||||
|
### 4. 注册路由 (`backend/app/main.py`)
|
||||||
|
引入并挂载outlines_router。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、前端
|
||||||
|
|
||||||
|
### 5. TypeScript类型 (`frontend/src/types/outline.ts`)
|
||||||
|
```typescript
|
||||||
|
export interface Outline {
|
||||||
|
id: number
|
||||||
|
novel_id: number
|
||||||
|
sort_order: number
|
||||||
|
summary: string
|
||||||
|
detail: string
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
export interface OutlineCreate { summary: string; detail?: string }
|
||||||
|
export interface OutlineUpdate { summary?: string; detail?: string; sort_order?: number }
|
||||||
|
export interface OutlineList { items: Outline[]; total: number }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. API调用 (`frontend/src/api/outlines.ts`)
|
||||||
|
跟随novels.ts的模式,所有请求挂在 `/novels/{novelId}/outlines` 下。
|
||||||
|
|
||||||
|
### 7. 路由 (`frontend/src/router/index.ts`)
|
||||||
|
新增路由:
|
||||||
|
- `/novels/:id/outlines` → OutlineView(大纲页面,从小说详情页进入)
|
||||||
|
|
||||||
|
### 8. 小说详情页更新 (`NovelDetailView.vue`)
|
||||||
|
添加"查看大纲"按钮/入口,链接到大纲页面。
|
||||||
|
|
||||||
|
### 9. 大纲视图页面 (`frontend/src/views/OutlineView.vue`)
|
||||||
|
主页面,包含:
|
||||||
|
- 返回小说详情的导航
|
||||||
|
- 小说标题显示
|
||||||
|
- 卷轴时间线组件
|
||||||
|
- 添加大纲的按钮
|
||||||
|
|
||||||
|
### 10. 卷轴时间线组件 (`frontend/src/components/outline/OutlineTimeline.vue`)
|
||||||
|
**核心可视化组件**,设计要点:
|
||||||
|
- 中央竖向墨线(2px,渐变从墨色到透明)
|
||||||
|
- 大纲节点左右交替排列(odd左even右)
|
||||||
|
- 每个节点通过横向短线连接到中央墨线
|
||||||
|
- 节点连接点有一个圆形墨点装饰
|
||||||
|
- 入场动画:依次从上到下淡入,配合墨线延伸
|
||||||
|
|
||||||
|
### 11. 大纲节点卡片 (`frontend/src/components/outline/OutlineNode.vue`)
|
||||||
|
单个大纲节点:
|
||||||
|
- 纸笺风格卡片(paper-50背景,细边框,微阴影)
|
||||||
|
- 顶部显示序号标记(朱砂红圆圈内数字)
|
||||||
|
- 一句话summary作为标题
|
||||||
|
- 默认折叠状态,hover或点击展开detail
|
||||||
|
- 展开状态下显示编辑/删除按钮
|
||||||
|
- hover微抬效果(与NovelCard一致的动效)
|
||||||
|
|
||||||
|
### 12. 大纲表单弹窗 (`frontend/src/components/outline/OutlineFormModal.vue`)
|
||||||
|
复用BaseModal + BaseInput + BaseTextarea:
|
||||||
|
- 新建/编辑共用一个组件
|
||||||
|
- summary输入框(单行,必填,max 200字)
|
||||||
|
- detail文本域(多行,100-300字建议)
|
||||||
|
- 字数统计提示
|
||||||
|
|
||||||
|
### 13. 大纲空状态 (`frontend/src/components/outline/OutlineEmptyState.vue`)
|
||||||
|
空白卷轴SVG动画 + "展开你的故事脉络"文案 + 创建按钮。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 实施顺序
|
||||||
|
1. 后端模型 + schemas + API路由(步骤1-4)
|
||||||
|
2. 前端类型 + API层(步骤5-6)
|
||||||
|
3. 路由 + 详情页入口(步骤7-8)
|
||||||
|
4. 大纲视图页面 + 时间线组件 + 节点卡片(步骤9-11)
|
||||||
|
5. 表单弹窗 + 空状态(步骤12-13)
|
||||||
|
6. 启动后端+前端验证全流程
|
||||||
27
.claude/settings.local.json
Normal file
27
.claude/settings.local.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(python -m venv .venv)",
|
||||||
|
"Bash(source .venv/Scripts/activate)",
|
||||||
|
"Bash(pip install:*)",
|
||||||
|
"Bash(py --version)",
|
||||||
|
"Bash(python3 -m venv .venv)",
|
||||||
|
"Bash(python3 -m venv /d/noval/backend/.venv)",
|
||||||
|
"Bash(python3 -m venv D:/noval/backend/.venv)",
|
||||||
|
"Bash(python3 -c \"import sys; print\\(sys.executable, sys.version\\)\")",
|
||||||
|
"Bash(py -3 --version)",
|
||||||
|
"Bash(py -3 -c \"import sys; print\\(sys.executable\\)\")",
|
||||||
|
"Bash(py -3 -m venv D:/noval/backend/.venv)",
|
||||||
|
"Bash(D:/noval/backend/.venv/Scripts/pip install:*)",
|
||||||
|
"Bash(npm create:*)",
|
||||||
|
"Bash(D:/noval/backend/.venv/Scripts/python -c \"from app.database import init_db; init_db\\(\\); print\\(''DB init OK''\\)\")",
|
||||||
|
"Bash(.venv/Scripts/python -c \"from app.database import init_db; init_db\\(\\); print\\(''DB init OK''\\)\")",
|
||||||
|
"Bash(curl -s -X POST http://127.0.0.1:8000/api/novels -H \"Content-Type: application/json; charset=utf-8\" --data-raw \"{\"\"title\"\":\"\"Test Novel\"\",\"\"description\"\":\"\"A test description\"\"}\")",
|
||||||
|
"Bash(curl -s http://127.0.0.1:8000/api/novels)",
|
||||||
|
"Bash(curl -s -X POST http://127.0.0.1:8000/api/novels -H \"Content-Type: application/json\" --data-raw \"{\"\"title\"\":\"\"Novel A\"\",\"\"description\"\":\"\"Desc A\"\"}\")",
|
||||||
|
"Bash(curl -s -X POST http://127.0.0.1:8000/api/novels -H \"Content-Type: application/json\" --data-raw \"{\"\"title\"\":\"\"Novel B\"\",\"\"description\"\":\"\"Desc B\"\"}\")",
|
||||||
|
"Bash(taskkill //F //IM uvicorn.exe)",
|
||||||
|
"Bash(taskkill //F //IM python.exe)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
28
.gitignore
vendored
Normal file
28
.gitignore
vendored
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
.venv/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
|
||||||
|
# SQLite
|
||||||
|
*.db
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Env
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
20
backend/app/database.py
Normal file
20
backend/app/database.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sqlmodel import SQLModel, Session, create_engine
|
||||||
|
|
||||||
|
# 数据库文件存放在 backend/data/ 目录下
|
||||||
|
DATA_DIR = Path(__file__).resolve().parent.parent / "data"
|
||||||
|
DATA_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
DATABASE_URL = f"sqlite:///{DATA_DIR / 'noval.db'}"
|
||||||
|
|
||||||
|
engine = create_engine(DATABASE_URL, echo=False)
|
||||||
|
|
||||||
|
|
||||||
|
def init_db() -> None:
|
||||||
|
SQLModel.metadata.create_all(engine)
|
||||||
|
|
||||||
|
|
||||||
|
def get_session():
|
||||||
|
with Session(engine) as session:
|
||||||
|
yield session
|
||||||
37
backend/app/main.py
Normal file
37
backend/app/main.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from .database import init_db
|
||||||
|
from .routers.novels import router as novels_router
|
||||||
|
from .routers.outlines import router as outlines_router
|
||||||
|
from .routers.ai_config import router as ai_config_router
|
||||||
|
from .routers.chat import router as chat_router
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
init_db()
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="Noval", version="0.1.0", lifespan=lifespan)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["http://localhost:5173"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
app.include_router(novels_router)
|
||||||
|
app.include_router(outlines_router)
|
||||||
|
app.include_router(ai_config_router)
|
||||||
|
app.include_router(chat_router)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
def health():
|
||||||
|
return {"status": "ok"}
|
||||||
50
backend/app/models.py
Normal file
50
backend/app/models.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlmodel import SQLModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
def _now_utc() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
class Novel(SQLModel, table=True):
|
||||||
|
"""小说主表"""
|
||||||
|
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
title: str = Field(max_length=200, index=True)
|
||||||
|
description: str = Field(default="", max_length=2000)
|
||||||
|
created_at: datetime = Field(default_factory=_now_utc)
|
||||||
|
updated_at: datetime = Field(default_factory=_now_utc)
|
||||||
|
|
||||||
|
|
||||||
|
class AIConfig(SQLModel, table=True):
|
||||||
|
"""AI服务配置(单行表)"""
|
||||||
|
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
provider: str = Field(default="openai") # "openai" | "anthropic"
|
||||||
|
base_url: str = Field(default="https://api.openai.com/v1")
|
||||||
|
api_key: str = Field(default="")
|
||||||
|
model: str = Field(default="gpt-4o")
|
||||||
|
updated_at: datetime = Field(default_factory=_now_utc)
|
||||||
|
|
||||||
|
|
||||||
|
class ChatMessage(SQLModel, table=True):
|
||||||
|
"""聊天消息"""
|
||||||
|
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
novel_id: Optional[int] = Field(default=None, foreign_key="novel.id", index=True)
|
||||||
|
role: str = Field(max_length=20) # "user" | "assistant"
|
||||||
|
content: str = Field(default="")
|
||||||
|
created_at: datetime = Field(default_factory=_now_utc)
|
||||||
|
|
||||||
|
class Outline(SQLModel, table=True):
|
||||||
|
"""大纲表,属于小说的子资源"""
|
||||||
|
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
novel_id: int = Field(foreign_key="novel.id", index=True)
|
||||||
|
sort_order: int = Field(default=0)
|
||||||
|
summary: str = Field(max_length=200)
|
||||||
|
detail: str = Field(default="", max_length=1000)
|
||||||
|
created_at: datetime = Field(default_factory=_now_utc)
|
||||||
|
updated_at: datetime = Field(default_factory=_now_utc)
|
||||||
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/__init__.py
Normal file
99
backend/app/routers/ai_config.py
Normal file
99
backend/app/routers/ai_config.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from ..database import get_session
|
||||||
|
from ..models import AIConfig
|
||||||
|
from ..schemas import AIConfigSave, AIConfigRead
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/ai", tags=["ai-config"])
|
||||||
|
|
||||||
|
|
||||||
|
def _mask_key(key: str) -> str:
|
||||||
|
if len(key) <= 8:
|
||||||
|
return "****"
|
||||||
|
return key[:4] + "****" + key[-4:]
|
||||||
|
|
||||||
|
|
||||||
|
def _get_or_create_config(session: Session) -> AIConfig:
|
||||||
|
config = session.exec(select(AIConfig)).first()
|
||||||
|
if not config:
|
||||||
|
config = AIConfig()
|
||||||
|
session.add(config)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(config)
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def _config_to_read(config: AIConfig) -> AIConfigRead:
|
||||||
|
return AIConfigRead(
|
||||||
|
id=config.id,
|
||||||
|
provider=config.provider,
|
||||||
|
base_url=config.base_url,
|
||||||
|
api_key_masked=_mask_key(config.api_key) if config.api_key else "",
|
||||||
|
model=config.model,
|
||||||
|
updated_at=config.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/config", response_model=AIConfigRead)
|
||||||
|
def get_config(session: Session = Depends(get_session)):
|
||||||
|
config = _get_or_create_config(session)
|
||||||
|
return _config_to_read(config)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/config", response_model=AIConfigRead)
|
||||||
|
def save_config(data: AIConfigSave, session: Session = Depends(get_session)):
|
||||||
|
config = _get_or_create_config(session)
|
||||||
|
config.provider = data.provider
|
||||||
|
config.base_url = data.base_url
|
||||||
|
config.api_key = data.api_key
|
||||||
|
config.model = data.model
|
||||||
|
config.updated_at = datetime.now(timezone.utc)
|
||||||
|
session.add(config)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(config)
|
||||||
|
return _config_to_read(config)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/config/test")
|
||||||
|
async def test_config(data: AIConfigSave):
|
||||||
|
"""测试AI连接是否有效"""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=15) as client:
|
||||||
|
if data.provider == "anthropic":
|
||||||
|
resp = await client.post(
|
||||||
|
f"{data.base_url.rstrip('/')}/messages",
|
||||||
|
headers={
|
||||||
|
"x-api-key": data.api_key,
|
||||||
|
"anthropic-version": "2023-06-01",
|
||||||
|
"content-type": "application/json",
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"model": data.model,
|
||||||
|
"max_tokens": 10,
|
||||||
|
"messages": [{"role": "user", "content": "Hi"}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{data.base_url.rstrip('/')}/chat/completions",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {data.api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"model": data.model,
|
||||||
|
"max_tokens": 10,
|
||||||
|
"messages": [{"role": "user", "content": "Hi"}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if resp.status_code < 400:
|
||||||
|
return {"ok": True, "message": "连接成功"}
|
||||||
|
return {"ok": False, "message": f"API返回错误: {resp.status_code}"}
|
||||||
|
except httpx.ConnectError:
|
||||||
|
return {"ok": False, "message": "无法连接到API地址"}
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "message": str(e)}
|
||||||
132
backend/app/routers/chat.py
Normal file
132
backend/app/routers/chat.py
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from sqlmodel import Session, select, func
|
||||||
|
|
||||||
|
from ..database import get_session, engine
|
||||||
|
from ..models import AIConfig, ChatMessage
|
||||||
|
from ..schemas import ChatRequest, ChatMessageRead, ChatMessageList
|
||||||
|
from ..services.ai_provider import stream_chat
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/chat", tags=["chat"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/stream")
|
||||||
|
async def chat_stream(
|
||||||
|
data: ChatRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
"""SSE 流式对话端点"""
|
||||||
|
config = session.exec(select(AIConfig)).first()
|
||||||
|
if not config or not config.api_key:
|
||||||
|
return StreamingResponse(
|
||||||
|
_error_stream("请先配置AI服务"),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 保存用户消息
|
||||||
|
user_msg = data.messages[-1] if data.messages else None
|
||||||
|
if user_msg and user_msg.role == "user":
|
||||||
|
db_msg = ChatMessage(
|
||||||
|
novel_id=data.novel_id,
|
||||||
|
role="user",
|
||||||
|
content=user_msg.content,
|
||||||
|
)
|
||||||
|
session.add(db_msg)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
# 构建消息列表
|
||||||
|
messages = [{"role": m.role, "content": m.content} for m in data.messages]
|
||||||
|
|
||||||
|
# 构建上下文摘要(前端展示用)
|
||||||
|
context_preview = [{"role": m["role"], "content": m["content"][:200]} for m in messages]
|
||||||
|
|
||||||
|
async def generate():
|
||||||
|
# 先发送上下文预览
|
||||||
|
yield f"data: {json.dumps({'context': context_preview})}\n\n"
|
||||||
|
|
||||||
|
full_response = ""
|
||||||
|
usage_info = {}
|
||||||
|
try:
|
||||||
|
async for event in stream_chat(config, messages):
|
||||||
|
if event.text:
|
||||||
|
full_response += event.text
|
||||||
|
yield f"data: {json.dumps({'content': event.text})}\n\n"
|
||||||
|
if event.usage:
|
||||||
|
# 累加usage
|
||||||
|
for k, v in event.usage.items():
|
||||||
|
if v:
|
||||||
|
usage_info[k] = usage_info.get(k, 0) + v
|
||||||
|
except Exception as e:
|
||||||
|
yield f"data: {json.dumps({'error': str(e)})}\n\n"
|
||||||
|
finally:
|
||||||
|
# 保存AI回复
|
||||||
|
if full_response:
|
||||||
|
with Session(engine) as s:
|
||||||
|
ai_msg = ChatMessage(
|
||||||
|
novel_id=data.novel_id,
|
||||||
|
role="assistant",
|
||||||
|
content=full_response,
|
||||||
|
)
|
||||||
|
s.add(ai_msg)
|
||||||
|
s.commit()
|
||||||
|
yield f"data: {json.dumps({'done': True, 'usage': usage_info or None})}\n\n"
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
generate(),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _error_stream(message: str):
|
||||||
|
yield f"data: {json.dumps({'error': message})}\n\n"
|
||||||
|
yield f"data: {json.dumps({'done': True})}\n\n"
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/messages", response_model=ChatMessageList)
|
||||||
|
def list_messages(
|
||||||
|
novel_id: int | None = Query(default=None),
|
||||||
|
limit: int = Query(default=50, ge=1, le=200),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
query = select(ChatMessage)
|
||||||
|
count_query = select(func.count(ChatMessage.id))
|
||||||
|
if novel_id is not None:
|
||||||
|
query = query.where(ChatMessage.novel_id == novel_id)
|
||||||
|
count_query = count_query.where(ChatMessage.novel_id == novel_id)
|
||||||
|
else:
|
||||||
|
query = query.where(ChatMessage.novel_id.is_(None)) # type: ignore
|
||||||
|
count_query = count_query.where(ChatMessage.novel_id.is_(None)) # type: ignore
|
||||||
|
|
||||||
|
total = session.exec(count_query).one()
|
||||||
|
items = session.exec(
|
||||||
|
query.order_by(ChatMessage.created_at.desc()).limit(limit)
|
||||||
|
).all()
|
||||||
|
# 返回时按时间正序
|
||||||
|
items = list(reversed(items))
|
||||||
|
return ChatMessageList(
|
||||||
|
items=[ChatMessageRead.model_validate(m, from_attributes=True) for m in items],
|
||||||
|
total=total,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/messages", status_code=204)
|
||||||
|
def clear_messages(
|
||||||
|
novel_id: int | None = Query(default=None),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
query = select(ChatMessage)
|
||||||
|
if novel_id is not None:
|
||||||
|
query = query.where(ChatMessage.novel_id == novel_id)
|
||||||
|
else:
|
||||||
|
query = query.where(ChatMessage.novel_id.is_(None)) # type: ignore
|
||||||
|
messages = session.exec(query).all()
|
||||||
|
for msg in messages:
|
||||||
|
session.delete(msg)
|
||||||
|
session.commit()
|
||||||
67
backend/app/routers/novels.py
Normal file
67
backend/app/routers/novels.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlmodel import Session, select, func
|
||||||
|
|
||||||
|
from ..database import get_session
|
||||||
|
from ..models import Novel
|
||||||
|
from ..schemas import NovelCreate, NovelUpdate, NovelRead, NovelList
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/novels", tags=["novels"])
|
||||||
|
|
||||||
|
|
||||||
|
def _get_novel_or_404(novel_id: int, session: Session) -> Novel:
|
||||||
|
novel = session.get(Novel, novel_id)
|
||||||
|
if not novel:
|
||||||
|
raise HTTPException(status_code=404, detail="小说不存在")
|
||||||
|
return novel
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=NovelList)
|
||||||
|
def list_novels(
|
||||||
|
skip: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(20, ge=1, le=100),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
total = session.exec(select(func.count(Novel.id))).one()
|
||||||
|
novels = session.exec(
|
||||||
|
select(Novel).order_by(Novel.updated_at.desc()).offset(skip).limit(limit)
|
||||||
|
).all()
|
||||||
|
items = [NovelRead.model_validate(n, from_attributes=True) for n in novels]
|
||||||
|
return NovelList(items=items, total=total)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=NovelRead, status_code=201)
|
||||||
|
def create_novel(data: NovelCreate, session: Session = Depends(get_session)):
|
||||||
|
novel = Novel(**data.model_dump())
|
||||||
|
session.add(novel)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(novel)
|
||||||
|
return novel
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{novel_id}", response_model=NovelRead)
|
||||||
|
def get_novel(novel_id: int, session: Session = Depends(get_session)):
|
||||||
|
return _get_novel_or_404(novel_id, session)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{novel_id}", response_model=NovelRead)
|
||||||
|
def update_novel(
|
||||||
|
novel_id: int, data: NovelUpdate, session: Session = Depends(get_session)
|
||||||
|
):
|
||||||
|
novel = _get_novel_or_404(novel_id, session)
|
||||||
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
|
for key, value in update_data.items():
|
||||||
|
setattr(novel, key, value)
|
||||||
|
novel.updated_at = datetime.now(timezone.utc)
|
||||||
|
session.add(novel)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(novel)
|
||||||
|
return novel
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{novel_id}", status_code=204)
|
||||||
|
def delete_novel(novel_id: int, session: Session = Depends(get_session)):
|
||||||
|
novel = _get_novel_or_404(novel_id, session)
|
||||||
|
session.delete(novel)
|
||||||
|
session.commit()
|
||||||
137
backend/app/routers/outlines.py
Normal file
137
backend/app/routers/outlines.py
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlmodel import Session, select, func
|
||||||
|
|
||||||
|
from ..database import get_session
|
||||||
|
from ..models import Novel, Outline
|
||||||
|
from ..schemas import (
|
||||||
|
OutlineCreate,
|
||||||
|
OutlineUpdate,
|
||||||
|
OutlineRead,
|
||||||
|
OutlineList,
|
||||||
|
OutlineReorder,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/novels/{novel_id}/outlines", tags=["outlines"])
|
||||||
|
|
||||||
|
|
||||||
|
def _get_novel_or_404(novel_id: int, session: Session) -> Novel:
|
||||||
|
novel = session.get(Novel, novel_id)
|
||||||
|
if not novel:
|
||||||
|
raise HTTPException(status_code=404, detail="小说不存在")
|
||||||
|
return novel
|
||||||
|
|
||||||
|
|
||||||
|
def _get_outline_or_404(
|
||||||
|
outline_id: int, novel_id: int, session: Session
|
||||||
|
) -> Outline:
|
||||||
|
outline = session.get(Outline, outline_id)
|
||||||
|
if not outline or outline.novel_id != novel_id:
|
||||||
|
raise HTTPException(status_code=404, detail="大纲不存在")
|
||||||
|
return outline
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=OutlineList)
|
||||||
|
def list_outlines(
|
||||||
|
novel_id: int,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
_get_novel_or_404(novel_id, session)
|
||||||
|
total = session.exec(
|
||||||
|
select(func.count(Outline.id)).where(Outline.novel_id == novel_id)
|
||||||
|
).one()
|
||||||
|
items = session.exec(
|
||||||
|
select(Outline)
|
||||||
|
.where(Outline.novel_id == novel_id)
|
||||||
|
.order_by(Outline.sort_order)
|
||||||
|
).all()
|
||||||
|
return OutlineList(
|
||||||
|
items=[OutlineRead.model_validate(o, from_attributes=True) for o in items],
|
||||||
|
total=total,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=OutlineRead, status_code=201)
|
||||||
|
def create_outline(
|
||||||
|
novel_id: int,
|
||||||
|
data: OutlineCreate,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
_get_novel_or_404(novel_id, session)
|
||||||
|
# 自动设置 sort_order 为当前最大值 + 1
|
||||||
|
max_order = session.exec(
|
||||||
|
select(func.max(Outline.sort_order)).where(Outline.novel_id == novel_id)
|
||||||
|
).one()
|
||||||
|
next_order = (max_order or 0) + 1
|
||||||
|
|
||||||
|
outline = Outline(novel_id=novel_id, sort_order=next_order, **data.model_dump())
|
||||||
|
session.add(outline)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(outline)
|
||||||
|
return outline
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/reorder", response_model=OutlineList)
|
||||||
|
def reorder_outlines(
|
||||||
|
novel_id: int,
|
||||||
|
data: OutlineReorder,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
_get_novel_or_404(novel_id, session)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
for idx, oid in enumerate(data.ordered_ids):
|
||||||
|
outline = _get_outline_or_404(oid, novel_id, session)
|
||||||
|
outline.sort_order = idx + 1
|
||||||
|
outline.updated_at = now
|
||||||
|
session.add(outline)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
items = session.exec(
|
||||||
|
select(Outline)
|
||||||
|
.where(Outline.novel_id == novel_id)
|
||||||
|
.order_by(Outline.sort_order)
|
||||||
|
).all()
|
||||||
|
total = len(items)
|
||||||
|
return OutlineList(
|
||||||
|
items=[OutlineRead.model_validate(o, from_attributes=True) for o in items],
|
||||||
|
total=total,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{outline_id}", response_model=OutlineRead)
|
||||||
|
def get_outline(
|
||||||
|
novel_id: int,
|
||||||
|
outline_id: int,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
return _get_outline_or_404(outline_id, novel_id, session)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{outline_id}", response_model=OutlineRead)
|
||||||
|
def update_outline(
|
||||||
|
novel_id: int,
|
||||||
|
outline_id: int,
|
||||||
|
data: OutlineUpdate,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
outline = _get_outline_or_404(outline_id, novel_id, session)
|
||||||
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
|
for key, value in update_data.items():
|
||||||
|
setattr(outline, key, value)
|
||||||
|
outline.updated_at = datetime.now(timezone.utc)
|
||||||
|
session.add(outline)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(outline)
|
||||||
|
return outline
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{outline_id}", status_code=204)
|
||||||
|
def delete_outline(
|
||||||
|
novel_id: int,
|
||||||
|
outline_id: int,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
outline = _get_outline_or_404(outline_id, novel_id, session)
|
||||||
|
session.delete(outline)
|
||||||
|
session.commit()
|
||||||
105
backend/app/schemas.py
Normal file
105
backend/app/schemas.py
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class NovelCreate(BaseModel):
|
||||||
|
title: str = Field(min_length=1, max_length=200)
|
||||||
|
description: str = Field(default="", max_length=2000)
|
||||||
|
|
||||||
|
|
||||||
|
class NovelUpdate(BaseModel):
|
||||||
|
title: str | None = Field(default=None, min_length=1, max_length=200)
|
||||||
|
description: str | None = Field(default=None, max_length=2000)
|
||||||
|
|
||||||
|
|
||||||
|
class NovelRead(BaseModel):
|
||||||
|
id: int
|
||||||
|
title: str
|
||||||
|
description: str
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class NovelList(BaseModel):
|
||||||
|
items: list[NovelRead]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
# ── 大纲 ──
|
||||||
|
|
||||||
|
|
||||||
|
class OutlineCreate(BaseModel):
|
||||||
|
summary: str = Field(min_length=1, max_length=200)
|
||||||
|
detail: str = Field(default="", max_length=1000)
|
||||||
|
|
||||||
|
|
||||||
|
class OutlineUpdate(BaseModel):
|
||||||
|
summary: str | None = Field(default=None, min_length=1, max_length=200)
|
||||||
|
detail: str | None = Field(default=None, max_length=1000)
|
||||||
|
sort_order: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class OutlineRead(BaseModel):
|
||||||
|
id: int
|
||||||
|
novel_id: int
|
||||||
|
sort_order: int
|
||||||
|
summary: str
|
||||||
|
detail: str
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class OutlineList(BaseModel):
|
||||||
|
items: list[OutlineRead]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class OutlineReorder(BaseModel):
|
||||||
|
"""排序请求:按顺序传入大纲id列表"""
|
||||||
|
ordered_ids: list[int]
|
||||||
|
|
||||||
|
|
||||||
|
# ── AI配置 ──
|
||||||
|
|
||||||
|
|
||||||
|
class AIConfigSave(BaseModel):
|
||||||
|
provider: str = Field(default="openai")
|
||||||
|
base_url: str = Field(default="https://api.openai.com/v1")
|
||||||
|
api_key: str = Field(min_length=1)
|
||||||
|
model: str = Field(default="gpt-4o")
|
||||||
|
|
||||||
|
|
||||||
|
class AIConfigRead(BaseModel):
|
||||||
|
id: int
|
||||||
|
provider: str
|
||||||
|
base_url: str
|
||||||
|
api_key_masked: str # 脱敏后的key
|
||||||
|
model: str
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
# ── 聊天 ──
|
||||||
|
|
||||||
|
|
||||||
|
class ChatMessageInput(BaseModel):
|
||||||
|
role: str
|
||||||
|
content: str
|
||||||
|
|
||||||
|
|
||||||
|
class ChatRequest(BaseModel):
|
||||||
|
messages: list[ChatMessageInput]
|
||||||
|
novel_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ChatMessageRead(BaseModel):
|
||||||
|
id: int
|
||||||
|
novel_id: int | None
|
||||||
|
role: str
|
||||||
|
content: str
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ChatMessageList(BaseModel):
|
||||||
|
items: list[ChatMessageRead]
|
||||||
|
total: int
|
||||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
171
backend/app/services/ai_provider.py
Normal file
171
backend/app/services/ai_provider.py
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
"""AI Provider 抽象层,支持 OpenAI 兼容 API 和 Anthropic 原生 API。"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import AsyncGenerator
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from ..models import AIConfig
|
||||||
|
|
||||||
|
|
||||||
|
class StreamEvent:
|
||||||
|
"""流式事件:文本chunk或usage信息"""
|
||||||
|
def __init__(self, text: str = "", usage: dict | None = None):
|
||||||
|
self.text = text
|
||||||
|
self.usage = usage
|
||||||
|
|
||||||
|
|
||||||
|
async def stream_chat(
|
||||||
|
config: AIConfig,
|
||||||
|
messages: list[dict],
|
||||||
|
system_prompt: str = "",
|
||||||
|
) -> AsyncGenerator[StreamEvent, None]:
|
||||||
|
"""根据 provider 类型调用对应的流式 API,返回 StreamEvent。"""
|
||||||
|
if config.provider == "anthropic":
|
||||||
|
async for event in _stream_anthropic(config, messages, system_prompt):
|
||||||
|
yield event
|
||||||
|
else:
|
||||||
|
async for event in _stream_openai(config, messages, system_prompt):
|
||||||
|
yield event
|
||||||
|
|
||||||
|
|
||||||
|
async def _stream_openai(
|
||||||
|
config: AIConfig,
|
||||||
|
messages: list[dict],
|
||||||
|
system_prompt: str,
|
||||||
|
) -> AsyncGenerator[StreamEvent, None]:
|
||||||
|
"""调用 OpenAI 兼容 API(stream=true)。"""
|
||||||
|
url = f"{config.base_url.rstrip('/')}/chat/completions"
|
||||||
|
|
||||||
|
payload_messages = []
|
||||||
|
if system_prompt:
|
||||||
|
payload_messages.append({"role": "system", "content": system_prompt})
|
||||||
|
payload_messages.extend(messages)
|
||||||
|
|
||||||
|
body = {
|
||||||
|
"model": config.model,
|
||||||
|
"messages": payload_messages,
|
||||||
|
"stream": True,
|
||||||
|
"stream_options": {"include_usage": True},
|
||||||
|
}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=120) as client:
|
||||||
|
async with client.stream(
|
||||||
|
"POST",
|
||||||
|
url,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {config.api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
json=body,
|
||||||
|
) as resp:
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
body_text = await resp.aread()
|
||||||
|
try:
|
||||||
|
err = json.loads(body_text)
|
||||||
|
msg = err.get("error", {}).get("message", "") or str(err)
|
||||||
|
except Exception:
|
||||||
|
msg = body_text.decode(errors="replace")[:200]
|
||||||
|
raise RuntimeError(f"API错误 ({resp.status_code}): {msg}")
|
||||||
|
|
||||||
|
ct = resp.headers.get("content-type", "")
|
||||||
|
if "text/html" in ct:
|
||||||
|
raise RuntimeError(f"API地址返回了HTML页面,请检查API地址是否正确(当前: {url})")
|
||||||
|
|
||||||
|
async for line in resp.aiter_lines():
|
||||||
|
if not line.startswith("data: "):
|
||||||
|
continue
|
||||||
|
data = line[6:]
|
||||||
|
if data.strip() == "[DONE]":
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
chunk = json.loads(data)
|
||||||
|
# 提取usage(OpenAI在最后一个chunk返回)
|
||||||
|
usage = chunk.get("usage")
|
||||||
|
if usage:
|
||||||
|
yield StreamEvent(usage={
|
||||||
|
"prompt_tokens": usage.get("prompt_tokens", 0),
|
||||||
|
"completion_tokens": usage.get("completion_tokens", 0),
|
||||||
|
"total_tokens": usage.get("total_tokens", 0),
|
||||||
|
})
|
||||||
|
choices = chunk.get("choices", [])
|
||||||
|
if choices:
|
||||||
|
delta = choices[0].get("delta", {})
|
||||||
|
content = delta.get("content")
|
||||||
|
if content:
|
||||||
|
yield StreamEvent(text=content)
|
||||||
|
except (json.JSONDecodeError, KeyError, IndexError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
|
||||||
|
async def _stream_anthropic(
|
||||||
|
config: AIConfig,
|
||||||
|
messages: list[dict],
|
||||||
|
system_prompt: str,
|
||||||
|
) -> AsyncGenerator[StreamEvent, None]:
|
||||||
|
"""调用 Anthropic Messages API(stream=true)。"""
|
||||||
|
url = f"{config.base_url.rstrip('/')}/messages"
|
||||||
|
|
||||||
|
body: dict = {
|
||||||
|
"model": config.model,
|
||||||
|
"max_tokens": 4096,
|
||||||
|
"messages": messages,
|
||||||
|
"stream": True,
|
||||||
|
}
|
||||||
|
if system_prompt:
|
||||||
|
body["system"] = system_prompt
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=120) as client:
|
||||||
|
async with client.stream(
|
||||||
|
"POST",
|
||||||
|
url,
|
||||||
|
headers={
|
||||||
|
"x-api-key": config.api_key,
|
||||||
|
"anthropic-version": "2023-06-01",
|
||||||
|
"content-type": "application/json",
|
||||||
|
},
|
||||||
|
json=body,
|
||||||
|
) as resp:
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
body_text = await resp.aread()
|
||||||
|
try:
|
||||||
|
err = json.loads(body_text)
|
||||||
|
msg = err.get("error", {}).get("message", "") or str(err)
|
||||||
|
except Exception:
|
||||||
|
msg = body_text.decode(errors="replace")[:200]
|
||||||
|
raise RuntimeError(f"API错误 ({resp.status_code}): {msg}")
|
||||||
|
|
||||||
|
ct = resp.headers.get("content-type", "")
|
||||||
|
if "text/html" in ct:
|
||||||
|
raise RuntimeError(f"API地址返回了HTML页面,请检查API地址是否正确(当前: {url})")
|
||||||
|
|
||||||
|
async for line in resp.aiter_lines():
|
||||||
|
if not line.startswith("data: "):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
event = json.loads(line[6:])
|
||||||
|
if event.get("type") == "content_block_delta":
|
||||||
|
text = event.get("delta", {}).get("text", "")
|
||||||
|
if text:
|
||||||
|
yield StreamEvent(text=text)
|
||||||
|
elif event.get("type") == "message_delta":
|
||||||
|
usage = event.get("usage", {})
|
||||||
|
if usage:
|
||||||
|
yield StreamEvent(usage={
|
||||||
|
"prompt_tokens": 0,
|
||||||
|
"completion_tokens": usage.get("output_tokens", 0),
|
||||||
|
"total_tokens": 0,
|
||||||
|
})
|
||||||
|
elif event.get("type") == "message_start":
|
||||||
|
msg = event.get("message", {})
|
||||||
|
usage = msg.get("usage", {})
|
||||||
|
if usage:
|
||||||
|
yield StreamEvent(usage={
|
||||||
|
"prompt_tokens": usage.get("input_tokens", 0),
|
||||||
|
"completion_tokens": 0,
|
||||||
|
"total_tokens": 0,
|
||||||
|
})
|
||||||
|
elif event.get("type") == "message_stop":
|
||||||
|
break
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
3
backend/requirements.txt
Normal file
3
backend/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
fastapi>=0.115.0
|
||||||
|
uvicorn[standard]>=0.34.0
|
||||||
|
sqlmodel>=0.0.22
|
||||||
39
frontend/.gitignore
vendored
Normal file
39
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
.DS_Store
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
coverage
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Cypress
|
||||||
|
/cypress/videos/
|
||||||
|
/cypress/screenshots/
|
||||||
|
|
||||||
|
# Vitest
|
||||||
|
__screenshots__/
|
||||||
|
|
||||||
|
# Vite
|
||||||
|
*.timestamp-*-*.mjs
|
||||||
42
frontend/README.md
Normal file
42
frontend/README.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# frontend
|
||||||
|
|
||||||
|
This template should help get you started developing with Vue 3 in Vite.
|
||||||
|
|
||||||
|
## Recommended IDE Setup
|
||||||
|
|
||||||
|
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
||||||
|
|
||||||
|
## Recommended Browser Setup
|
||||||
|
|
||||||
|
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
|
||||||
|
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
|
||||||
|
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
|
||||||
|
- Firefox:
|
||||||
|
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
|
||||||
|
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
|
||||||
|
|
||||||
|
## Type Support for `.vue` Imports in TS
|
||||||
|
|
||||||
|
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
|
||||||
|
|
||||||
|
## Customize configuration
|
||||||
|
|
||||||
|
See [Vite Configuration Reference](https://vite.dev/config/).
|
||||||
|
|
||||||
|
## Project Setup
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Compile and Hot-Reload for Development
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Type-Check, Compile and Minify for Production
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
1
frontend/env.d.ts
vendored
Normal file
1
frontend/env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<link rel="icon" href="/favicon.ico">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Vite App</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3423
frontend/package-lock.json
generated
Normal file
3423
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
frontend/package.json
Normal file
33
frontend/package.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "run-p type-check \"build-only {@}\" --",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"build-only": "vite build",
|
||||||
|
"type-check": "vue-tsc --build"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.13.6",
|
||||||
|
"marked": "^17.0.4",
|
||||||
|
"vue": "^3.5.29",
|
||||||
|
"vue-router": "^5.0.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tsconfig/node24": "^24.0.4",
|
||||||
|
"@types/node": "^24.11.0",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.4",
|
||||||
|
"@vue/tsconfig": "^0.8.1",
|
||||||
|
"npm-run-all2": "^8.0.4",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"vite": "^7.3.1",
|
||||||
|
"vite-plugin-vue-devtools": "^8.0.6",
|
||||||
|
"vue-tsc": "^3.2.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
frontend/public/favicon.ico
Normal file
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
67
frontend/src/App.vue
Normal file
67
frontend/src/App.vue
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { RouterView } from 'vue-router'
|
||||||
|
import AppHeader from '@/components/layout/AppHeader.vue'
|
||||||
|
import ToastContainer from '@/components/ui/ToastContainer.vue'
|
||||||
|
import ChatToggle from '@/components/chat/ChatToggle.vue'
|
||||||
|
import ChatPanel from '@/components/chat/ChatPanel.vue'
|
||||||
|
|
||||||
|
const chatOpen = ref(false)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="app-shell">
|
||||||
|
<!-- 宣纸纹理背景 -->
|
||||||
|
<div class="paper-texture" />
|
||||||
|
|
||||||
|
<AppHeader />
|
||||||
|
|
||||||
|
<main class="main-content">
|
||||||
|
<RouterView v-slot="{ Component }">
|
||||||
|
<Transition name="page" mode="out-in">
|
||||||
|
<component :is="Component" />
|
||||||
|
</Transition>
|
||||||
|
</RouterView>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<ToastContainer />
|
||||||
|
<ChatToggle :open="chatOpen" @toggle="chatOpen = !chatOpen" />
|
||||||
|
<ChatPanel :open="chatOpen" @close="chatOpen = false" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paper-texture {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -1;
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(
|
||||||
|
0deg,
|
||||||
|
transparent,
|
||||||
|
transparent 3px,
|
||||||
|
rgba(26, 26, 46, 0.008) 3px,
|
||||||
|
rgba(26, 26, 46, 0.008) 4px
|
||||||
|
),
|
||||||
|
repeating-linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent,
|
||||||
|
transparent 5px,
|
||||||
|
rgba(26, 26, 46, 0.005) 5px,
|
||||||
|
rgba(26, 26, 46, 0.005) 6px
|
||||||
|
),
|
||||||
|
linear-gradient(165deg, var(--paper-50) 0%, var(--paper-100) 50%, var(--paper-50) 100%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--space-xl) var(--space-lg);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
123
frontend/src/api/chat.ts
Normal file
123
frontend/src/api/chat.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { http } from './client'
|
||||||
|
import type { AIConfig, AIConfigSave, ChatMessageList } from '@/types/chat'
|
||||||
|
|
||||||
|
// ── AI配置 ──
|
||||||
|
|
||||||
|
export async function getAIConfig(): Promise<AIConfig> {
|
||||||
|
const { data } = await http.get<AIConfig>('/ai/config')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveAIConfig(payload: AIConfigSave): Promise<AIConfig> {
|
||||||
|
const { data } = await http.put<AIConfig>('/ai/config', payload)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testAIConfig(payload: AIConfigSave): Promise<{ ok: boolean; message: string }> {
|
||||||
|
const { data } = await http.post<{ ok: boolean; message: string }>('/ai/config/test', payload)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 聊天消息 ──
|
||||||
|
|
||||||
|
export async function fetchMessages(novelId?: number): Promise<ChatMessageList> {
|
||||||
|
const params: Record<string, unknown> = {}
|
||||||
|
if (novelId !== undefined) params.novel_id = novelId
|
||||||
|
const { data } = await http.get<ChatMessageList>('/chat/messages', { params })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearMessages(novelId?: number): Promise<void> {
|
||||||
|
const params: Record<string, unknown> = {}
|
||||||
|
if (novelId !== undefined) params.novel_id = novelId
|
||||||
|
await http.delete('/chat/messages', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 流式聊天 ──
|
||||||
|
|
||||||
|
export interface TokenUsage {
|
||||||
|
prompt_tokens: number
|
||||||
|
completion_tokens: number
|
||||||
|
total_tokens: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContextMessage {
|
||||||
|
role: string
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StreamChatOptions {
|
||||||
|
messages: { role: string; content: string }[]
|
||||||
|
novelId?: number
|
||||||
|
onChunk: (text: string) => void
|
||||||
|
onError: (error: string) => void
|
||||||
|
onDone: (usage?: TokenUsage) => void
|
||||||
|
onContext?: (context: ContextMessage[]) => void
|
||||||
|
signal?: AbortSignal
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function streamChat(options: StreamChatOptions): Promise<void> {
|
||||||
|
const { messages, novelId, onChunk, onError, onDone, onContext, signal } = options
|
||||||
|
|
||||||
|
const body = JSON.stringify({
|
||||||
|
messages,
|
||||||
|
novel_id: novelId ?? null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const resp = await fetch('/api/chat/stream', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body,
|
||||||
|
signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!resp.ok || !resp.body) {
|
||||||
|
onError(`请求失败: ${resp.status}`)
|
||||||
|
onDone()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = resp.body.getReader()
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
let buffer = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true })
|
||||||
|
const lines = buffer.split('\n')
|
||||||
|
buffer = lines.pop() ?? ''
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.startsWith('data: ')) continue
|
||||||
|
try {
|
||||||
|
const event = JSON.parse(line.slice(6))
|
||||||
|
if (event.done) {
|
||||||
|
onDone(event.usage ?? undefined)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.error) {
|
||||||
|
onError(event.error)
|
||||||
|
onDone()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.context && onContext) {
|
||||||
|
onContext(event.context)
|
||||||
|
}
|
||||||
|
if (event.content) {
|
||||||
|
onChunk(event.content)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 忽略解析错误
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if ((e as Error).name !== 'AbortError') {
|
||||||
|
onError((e as Error).message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onDone()
|
||||||
|
}
|
||||||
16
frontend/src/api/client.ts
Normal file
16
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
export const http = axios.create({
|
||||||
|
baseURL: '/api',
|
||||||
|
timeout: 10000,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
|
||||||
|
http.interceptors.response.use(
|
||||||
|
(res) => res,
|
||||||
|
(error) => {
|
||||||
|
const message = error.response?.data?.detail || error.message || '请求失败'
|
||||||
|
console.error('[API Error]', message)
|
||||||
|
return Promise.reject(error)
|
||||||
|
},
|
||||||
|
)
|
||||||
26
frontend/src/api/novels.ts
Normal file
26
frontend/src/api/novels.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { http } from './client'
|
||||||
|
import type { Novel, NovelCreate, NovelUpdate, NovelList } from '@/types/novel'
|
||||||
|
|
||||||
|
export async function fetchNovels(skip = 0, limit = 20): Promise<NovelList> {
|
||||||
|
const { data } = await http.get<NovelList>('/novels', { params: { skip, limit } })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchNovel(id: number): Promise<Novel> {
|
||||||
|
const { data } = await http.get<Novel>(`/novels/${id}`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createNovel(payload: NovelCreate): Promise<Novel> {
|
||||||
|
const { data } = await http.post<Novel>('/novels', payload)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateNovel(id: number, payload: NovelUpdate): Promise<Novel> {
|
||||||
|
const { data } = await http.patch<Novel>(`/novels/${id}`, payload)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteNovel(id: number): Promise<void> {
|
||||||
|
await http.delete(`/novels/${id}`)
|
||||||
|
}
|
||||||
33
frontend/src/api/outlines.ts
Normal file
33
frontend/src/api/outlines.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { http } from './client'
|
||||||
|
import type { Outline, OutlineCreate, OutlineUpdate, OutlineList, OutlineReorder } from '@/types/outline'
|
||||||
|
|
||||||
|
const base = (novelId: number) => `/novels/${novelId}/outlines`
|
||||||
|
|
||||||
|
export async function fetchOutlines(novelId: number): Promise<OutlineList> {
|
||||||
|
const { data } = await http.get<OutlineList>(base(novelId))
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchOutline(novelId: number, outlineId: number): Promise<Outline> {
|
||||||
|
const { data } = await http.get<Outline>(`${base(novelId)}/${outlineId}`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createOutline(novelId: number, payload: OutlineCreate): Promise<Outline> {
|
||||||
|
const { data } = await http.post<Outline>(base(novelId), payload)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateOutline(novelId: number, outlineId: number, payload: OutlineUpdate): Promise<Outline> {
|
||||||
|
const { data } = await http.patch<Outline>(`${base(novelId)}/${outlineId}`, payload)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteOutline(novelId: number, outlineId: number): Promise<void> {
|
||||||
|
await http.delete(`${base(novelId)}/${outlineId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reorderOutlines(novelId: number, orderedIds: number[]): Promise<OutlineList> {
|
||||||
|
const { data } = await http.put<OutlineList>(`${base(novelId)}/reorder`, { ordered_ids: orderedIds })
|
||||||
|
return data
|
||||||
|
}
|
||||||
206
frontend/src/components/chat/ChatConfigModal.vue
Normal file
206
frontend/src/components/chat/ChatConfigModal.vue
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { getAIConfig, saveAIConfig, testAIConfig } from '@/api/chat'
|
||||||
|
import { useToast } from '@/composables/useToast'
|
||||||
|
import BaseModal from '@/components/ui/BaseModal.vue'
|
||||||
|
import BaseInput from '@/components/ui/BaseInput.vue'
|
||||||
|
import BaseButton from '@/components/ui/BaseButton.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
show: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const provider = ref('openai')
|
||||||
|
const baseUrl = ref('https://api.openai.com/v1')
|
||||||
|
const apiKey = ref('')
|
||||||
|
const model = ref('gpt-4o')
|
||||||
|
const saving = ref(false)
|
||||||
|
const testing = ref(false)
|
||||||
|
|
||||||
|
watch(() => props.show, async (val) => {
|
||||||
|
if (val) {
|
||||||
|
try {
|
||||||
|
const config = await getAIConfig()
|
||||||
|
provider.value = config.provider
|
||||||
|
baseUrl.value = config.base_url
|
||||||
|
model.value = config.model
|
||||||
|
apiKey.value = '' // 不回填key
|
||||||
|
} catch {
|
||||||
|
// 使用默认值
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function onProviderChange() {
|
||||||
|
if (provider.value === 'anthropic') {
|
||||||
|
baseUrl.value = 'https://api.anthropic.com/v1'
|
||||||
|
model.value = 'claude-sonnet-4-20250514'
|
||||||
|
} else {
|
||||||
|
baseUrl.value = 'https://api.openai.com/v1'
|
||||||
|
model.value = 'gpt-4o'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTest() {
|
||||||
|
if (!apiKey.value.trim()) {
|
||||||
|
toast.error('请输入API Key')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
testing.value = true
|
||||||
|
try {
|
||||||
|
const result = await testAIConfig({
|
||||||
|
provider: provider.value,
|
||||||
|
base_url: baseUrl.value,
|
||||||
|
api_key: apiKey.value,
|
||||||
|
model: model.value,
|
||||||
|
})
|
||||||
|
if (result.ok) {
|
||||||
|
toast.success(result.message)
|
||||||
|
} else {
|
||||||
|
toast.error(result.message)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error('测试请求失败')
|
||||||
|
} finally {
|
||||||
|
testing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!apiKey.value.trim()) {
|
||||||
|
toast.error('请输入API Key')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
await saveAIConfig({
|
||||||
|
provider: provider.value,
|
||||||
|
base_url: baseUrl.value,
|
||||||
|
api_key: apiKey.value,
|
||||||
|
model: model.value,
|
||||||
|
})
|
||||||
|
toast.success('配置已保存')
|
||||||
|
emit('close')
|
||||||
|
} catch {
|
||||||
|
toast.error('保存失败')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<BaseModal :show="show" @close="emit('close')">
|
||||||
|
<form class="config-form" @submit.prevent="handleSave">
|
||||||
|
<h2 class="form-title">AI 配置</h2>
|
||||||
|
|
||||||
|
<div class="provider-select">
|
||||||
|
<label class="select-label">服务商</label>
|
||||||
|
<div class="provider-options">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="provider-btn"
|
||||||
|
:class="{ 'provider-btn--active': provider === 'openai' }"
|
||||||
|
@click="provider = 'openai'; onProviderChange()"
|
||||||
|
>
|
||||||
|
OpenAI 兼容
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="provider-btn"
|
||||||
|
:class="{ 'provider-btn--active': provider === 'anthropic' }"
|
||||||
|
@click="provider = 'anthropic'; onProviderChange()"
|
||||||
|
>
|
||||||
|
Anthropic
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BaseInput v-model="baseUrl" label="API 地址" />
|
||||||
|
<BaseInput v-model="apiKey" label="API Key" type="password" />
|
||||||
|
<BaseInput v-model="model" label="模型名称" />
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<BaseButton
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
:loading="testing"
|
||||||
|
@click="handleTest"
|
||||||
|
>
|
||||||
|
测试连接
|
||||||
|
</BaseButton>
|
||||||
|
<BaseButton variant="primary" type="submit" :loading="saving">
|
||||||
|
保存
|
||||||
|
</BaseButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</BaseModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.config-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ink-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-select {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--vermilion);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-options {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
border: 1.5px solid var(--paper-200);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--paper-50);
|
||||||
|
color: var(--ink-500);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--duration-fast) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-btn:hover {
|
||||||
|
border-color: var(--ink-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-btn--active {
|
||||||
|
border-color: var(--vermilion);
|
||||||
|
color: var(--vermilion);
|
||||||
|
background: var(--vermilion-ghost);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
padding-top: var(--space-sm);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
197
frontend/src/components/chat/ChatMessage.vue
Normal file
197
frontend/src/components/chat/ChatMessage.vue
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { marked } from 'marked'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
role: 'user' | 'assistant'
|
||||||
|
content: string
|
||||||
|
streaming?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// 配置 marked
|
||||||
|
marked.setOptions({
|
||||||
|
breaks: true,
|
||||||
|
gfm: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const renderedHtml = computed(() => {
|
||||||
|
if (props.role === 'user') return ''
|
||||||
|
return marked.parse(props.content) as string
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="chat-msg" :class="`chat-msg--${role}`">
|
||||||
|
<div class="msg-bubble">
|
||||||
|
<!-- 用户消息:纯文本 -->
|
||||||
|
<div v-if="role === 'user'" class="msg-content">{{ content }}</div>
|
||||||
|
<!-- AI消息:渲染markdown -->
|
||||||
|
<div v-else class="msg-content msg-markdown" v-html="renderedHtml" />
|
||||||
|
<span v-if="streaming" class="cursor-blink">▊</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chat-msg {
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
animation: fadeInUp var(--duration-fast) var(--ease-out-smooth) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-msg--user {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-msg--assistant {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-bubble {
|
||||||
|
max-width: 85%;
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
line-height: 1.6;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-msg--user .msg-bubble {
|
||||||
|
background: var(--vermilion);
|
||||||
|
color: white;
|
||||||
|
border-bottom-right-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-msg--assistant .msg-bubble {
|
||||||
|
background: var(--paper-100);
|
||||||
|
color: var(--ink-900);
|
||||||
|
border-bottom-left-radius: 2px;
|
||||||
|
border: 1px solid var(--paper-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-content {
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-msg--user .msg-content {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Markdown 排版样式 */
|
||||||
|
.msg-markdown :deep(p) {
|
||||||
|
margin: 0 0 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-markdown :deep(p:last-child) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-markdown :deep(h1),
|
||||||
|
.msg-markdown :deep(h2),
|
||||||
|
.msg-markdown :deep(h3),
|
||||||
|
.msg-markdown :deep(h4) {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0.8em 0 0.4em;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-markdown :deep(h1) { font-size: 1.2em; }
|
||||||
|
.msg-markdown :deep(h2) { font-size: 1.1em; }
|
||||||
|
.msg-markdown :deep(h3) { font-size: 1em; }
|
||||||
|
|
||||||
|
.msg-markdown :deep(strong) {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ink-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-markdown :deep(em) {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-markdown :deep(code) {
|
||||||
|
font-family: var(--font-mono, 'JetBrains Mono', monospace);
|
||||||
|
font-size: 0.85em;
|
||||||
|
background: rgba(26, 26, 46, 0.06);
|
||||||
|
padding: 0.15em 0.4em;
|
||||||
|
border-radius: 3px;
|
||||||
|
color: var(--vermilion-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-markdown :deep(pre) {
|
||||||
|
background: var(--ink-900);
|
||||||
|
color: var(--paper-100);
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 0.5em 0;
|
||||||
|
font-size: 0.82em;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-markdown :deep(pre code) {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
color: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-markdown :deep(ul),
|
||||||
|
.msg-markdown :deep(ol) {
|
||||||
|
margin: 0.4em 0;
|
||||||
|
padding-left: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-markdown :deep(li) {
|
||||||
|
margin-bottom: 0.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-markdown :deep(blockquote) {
|
||||||
|
border-left: 3px solid var(--vermilion);
|
||||||
|
padding-left: var(--space-md);
|
||||||
|
margin: 0.5em 0;
|
||||||
|
color: var(--ink-500);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-markdown :deep(hr) {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px dashed var(--paper-300);
|
||||||
|
margin: 0.8em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-markdown :deep(a) {
|
||||||
|
color: var(--vermilion);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-markdown :deep(table) {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0.5em 0;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-markdown :deep(th),
|
||||||
|
.msg-markdown :deep(td) {
|
||||||
|
border: 1px solid var(--paper-200);
|
||||||
|
padding: 0.3em 0.6em;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-markdown :deep(th) {
|
||||||
|
background: var(--paper-200);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cursor-blink {
|
||||||
|
display: inline;
|
||||||
|
animation: blink 0.8s step-end infinite;
|
||||||
|
color: var(--vermilion);
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
449
frontend/src/components/chat/ChatPanel.vue
Normal file
449
frontend/src/components/chat/ChatPanel.vue
Normal file
@@ -0,0 +1,449 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, nextTick, watch, onMounted } from 'vue'
|
||||||
|
import { useChatAgent } from '@/composables/useChatAgent'
|
||||||
|
import ChatMessageComp from './ChatMessage.vue'
|
||||||
|
import ChatConfigModal from './ChatConfigModal.vue'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
open: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { messages, isStreaming, streamingContent, lastUsage, loadHistory, sendMessage, stopStreaming, clearChat } = useChatAgent()
|
||||||
|
|
||||||
|
const input = ref('')
|
||||||
|
const messagesRef = ref<HTMLElement>()
|
||||||
|
const showConfig = ref(false)
|
||||||
|
|
||||||
|
// 常见模型的上下文窗口大小
|
||||||
|
const MODEL_CONTEXT: Record<string, number> = {
|
||||||
|
'gpt-4o': 128000,
|
||||||
|
'gpt-4o-mini': 128000,
|
||||||
|
'gpt-4-turbo': 128000,
|
||||||
|
'gpt-4': 8192,
|
||||||
|
'gpt-3.5-turbo': 16385,
|
||||||
|
'claude-sonnet-4-20250514': 200000,
|
||||||
|
'claude-opus-4-20250514': 200000,
|
||||||
|
'claude-haiku-4-5-20251001': 200000,
|
||||||
|
'claude-3-5-sonnet-20241022': 200000,
|
||||||
|
'claude-3-opus-20240229': 200000,
|
||||||
|
}
|
||||||
|
const DEFAULT_CONTEXT = 128000
|
||||||
|
|
||||||
|
// Token用量
|
||||||
|
const totalUsed = computed(() => {
|
||||||
|
if (!lastUsage.value) return 0
|
||||||
|
const u = lastUsage.value
|
||||||
|
return u.total_tokens || (u.prompt_tokens + u.completion_tokens)
|
||||||
|
})
|
||||||
|
|
||||||
|
const contextLimit = computed(() => DEFAULT_CONTEXT)
|
||||||
|
|
||||||
|
const usagePercent = computed(() => {
|
||||||
|
if (!totalUsed.value) return 0
|
||||||
|
return Math.min((totalUsed.value / contextLimit.value) * 100, 100)
|
||||||
|
})
|
||||||
|
|
||||||
|
const usageLabel = computed(() => {
|
||||||
|
if (!lastUsage.value) return ''
|
||||||
|
const u = lastUsage.value
|
||||||
|
const total = totalUsed.value
|
||||||
|
return `${total.toLocaleString()} / ${contextLimit.value.toLocaleString()} tokens(输入 ${u.prompt_tokens.toLocaleString()} · 输出 ${u.completion_tokens.toLocaleString()})`
|
||||||
|
})
|
||||||
|
|
||||||
|
function scrollToBottom() {
|
||||||
|
nextTick(() => {
|
||||||
|
if (messagesRef.value) {
|
||||||
|
messagesRef.value.scrollTop = messagesRef.value.scrollHeight
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSend() {
|
||||||
|
const text = input.value.trim()
|
||||||
|
if (!text || isStreaming.value) return
|
||||||
|
input.value = ''
|
||||||
|
await sendMessage(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
handleSend()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动滚动
|
||||||
|
watch([messages, streamingContent], scrollToBottom)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadHistory()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="panel">
|
||||||
|
<div v-if="open" class="chat-overlay" @click.self="emit('close')">
|
||||||
|
<div class="chat-panel">
|
||||||
|
<!-- 头部 -->
|
||||||
|
<div class="panel-header">
|
||||||
|
<h3 class="panel-title">AI 助手</h3>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button class="icon-btn" title="清空对话" @click="clearChat()">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||||
|
<path d="M2 4h12M5 4V2.5h6V4M3.5 4v9.5h9V4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="icon-btn" title="AI配置" @click="showConfig = true">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||||
|
<circle cx="8" cy="8" r="2.5" stroke="currentColor" stroke-width="1.2" />
|
||||||
|
<path d="M8 1v2M8 13v2M1 8h2M13 8h2M2.9 2.9l1.4 1.4M11.7 11.7l1.4 1.4M13.1 2.9l-1.4 1.4M4.3 11.7l-1.4 1.4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="icon-btn" title="关闭" @click="emit('close')">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||||
|
<path d="M12 4L4 12M4 4l8 8" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Token用量进度条 -->
|
||||||
|
<div v-if="lastUsage" class="usage-bar">
|
||||||
|
<div class="usage-bar-track">
|
||||||
|
<div
|
||||||
|
class="usage-bar-fill"
|
||||||
|
:class="{ 'usage-bar-fill--warn': usagePercent > 70, 'usage-bar-fill--danger': usagePercent > 90 }"
|
||||||
|
:style="{ width: usagePercent + '%' }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span class="usage-label">{{ usageLabel }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 消息区 -->
|
||||||
|
<div ref="messagesRef" class="panel-messages">
|
||||||
|
<div v-if="messages.length === 0 && !isStreaming" class="empty-chat">
|
||||||
|
<p class="empty-hint">有什么可以帮你的?</p>
|
||||||
|
<p class="empty-sub">关于小说创作的任何问题,都可以问我</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ChatMessageComp
|
||||||
|
v-for="msg in messages"
|
||||||
|
:key="msg.id"
|
||||||
|
:role="msg.role"
|
||||||
|
:content="msg.content"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 流式消息 -->
|
||||||
|
<ChatMessageComp
|
||||||
|
v-if="isStreaming && streamingContent"
|
||||||
|
role="assistant"
|
||||||
|
:content="streamingContent"
|
||||||
|
:streaming="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 加载指示 -->
|
||||||
|
<div v-if="isStreaming && !streamingContent" class="typing-indicator">
|
||||||
|
<span /><span /><span />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 输入区 -->
|
||||||
|
<div class="panel-input">
|
||||||
|
<textarea
|
||||||
|
v-model="input"
|
||||||
|
class="input-field"
|
||||||
|
placeholder="输入消息..."
|
||||||
|
rows="1"
|
||||||
|
:disabled="isStreaming"
|
||||||
|
@keydown="handleKeydown"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
v-if="!isStreaming"
|
||||||
|
class="send-btn"
|
||||||
|
:disabled="!input.trim()"
|
||||||
|
@click="handleSend"
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
|
||||||
|
<path d="M2 9l14-7-4 16-4-6z" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button v-else class="stop-btn" @click="stopStreaming">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||||
|
<rect x="2" y="2" width="10" height="10" rx="1" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
|
<ChatConfigModal :show="showConfig" @close="showConfig = false" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 遮罩 */
|
||||||
|
.chat-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 面板 */
|
||||||
|
.chat-panel {
|
||||||
|
width: 380px;
|
||||||
|
max-width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background: var(--paper-50);
|
||||||
|
border-left: 1px solid var(--paper-200);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: -8px 0 30px rgba(26, 26, 46, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 头部 */
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--space-md) var(--space-lg);
|
||||||
|
border-bottom: 1px solid var(--paper-200);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ink-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--ink-400);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--duration-fast) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn:hover {
|
||||||
|
background: var(--paper-100);
|
||||||
|
color: var(--ink-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Token用量进度条 */
|
||||||
|
.usage-bar {
|
||||||
|
padding: var(--space-sm) var(--space-lg);
|
||||||
|
border-bottom: 1px solid var(--paper-200);
|
||||||
|
background: var(--paper-100);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-bar-track {
|
||||||
|
height: 4px;
|
||||||
|
background: var(--paper-200);
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--bamboo);
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: width var(--duration-normal) var(--ease-out-smooth);
|
||||||
|
min-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-bar-fill--warn {
|
||||||
|
background: #d4a017;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-bar-fill--danger {
|
||||||
|
background: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-label {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
color: var(--ink-400);
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 消息区 */
|
||||||
|
.panel-messages {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-chat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-hint {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: var(--ink-500);
|
||||||
|
margin-bottom: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-sub {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--ink-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 打字指示器 */
|
||||||
|
.typing-indicator {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
width: fit-content;
|
||||||
|
background: var(--paper-100);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--paper-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-indicator span {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--ink-300);
|
||||||
|
animation: typingBounce 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
|
||||||
|
.typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
|
||||||
|
|
||||||
|
@keyframes typingBounce {
|
||||||
|
0%, 60%, 100% { transform: translateY(0); }
|
||||||
|
30% { transform: translateY(-6px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 输入区 */
|
||||||
|
.panel-input {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
padding: var(--space-md) var(--space-lg);
|
||||||
|
border-top: 1px solid var(--paper-200);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-field {
|
||||||
|
flex: 1;
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
color: var(--ink-900);
|
||||||
|
background: var(--paper-100);
|
||||||
|
border: 1px solid var(--paper-200);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
outline: none;
|
||||||
|
resize: none;
|
||||||
|
max-height: 120px;
|
||||||
|
line-height: 1.5;
|
||||||
|
transition: border-color var(--duration-fast) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-field:focus {
|
||||||
|
border-color: var(--vermilion);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-field:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-btn,
|
||||||
|
.stop-btn {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: all var(--duration-fast) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-btn {
|
||||||
|
background: var(--vermilion);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-btn:hover:not(:disabled) {
|
||||||
|
background: var(--vermilion-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stop-btn {
|
||||||
|
background: var(--danger);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stop-btn:hover {
|
||||||
|
background: var(--danger);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 转场动画 */
|
||||||
|
.panel-enter-active {
|
||||||
|
transition: opacity var(--duration-normal) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-enter-active .chat-panel {
|
||||||
|
animation: slideInFromRight var(--duration-normal) var(--ease-out-smooth) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-leave-active {
|
||||||
|
transition: opacity var(--duration-fast) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-leave-active .chat-panel {
|
||||||
|
animation: slideInFromRight var(--duration-fast) ease reverse both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-enter-from,
|
||||||
|
.panel-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideInFromRight {
|
||||||
|
from { transform: translateX(100%); }
|
||||||
|
to { transform: translateX(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 420px) {
|
||||||
|
.chat-panel {
|
||||||
|
width: 100vw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
61
frontend/src/components/chat/ChatToggle.vue
Normal file
61
frontend/src/components/chat/ChatToggle.vue
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
open: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
toggle: []
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
class="chat-toggle"
|
||||||
|
:class="{ 'chat-toggle--open': open }"
|
||||||
|
@click="$emit('toggle')"
|
||||||
|
:title="open ? '关闭对话' : '打开AI助手'"
|
||||||
|
>
|
||||||
|
<!-- 对话图标 -->
|
||||||
|
<svg v-if="!open" width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M21 11.5a8.38 8.38 0 01-.9 3.8 8.5 8.5 0 01-7.6 4.7 8.38 8.38 0 01-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 01-.9-3.8 8.5 8.5 0 014.7-7.6 8.38 8.38 0 013.8-.9h.5a8.48 8.48 0 018 8v.5z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
<!-- 关闭图标 -->
|
||||||
|
<svg v-else width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chat-toggle {
|
||||||
|
position: fixed;
|
||||||
|
bottom: var(--space-xl);
|
||||||
|
right: var(--space-xl);
|
||||||
|
z-index: 999;
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--vermilion);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
transition: all var(--duration-normal) var(--ease-out-back);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-toggle:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
box-shadow: 0 8px 30px rgba(199, 62, 29, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-toggle--open {
|
||||||
|
background: var(--ink-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-toggle--open:hover {
|
||||||
|
box-shadow: 0 8px 30px rgba(26, 26, 46, 0.4);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
62
frontend/src/components/layout/AppHeader.vue
Normal file
62
frontend/src/components/layout/AppHeader.vue
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<header class="app-header">
|
||||||
|
<div class="header-inner">
|
||||||
|
<RouterLink to="/" class="brand">
|
||||||
|
<span class="brand-text">Noval</span>
|
||||||
|
<span class="brand-dot" />
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
background: rgba(250, 248, 245, 0.85);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
border-bottom: 1px solid var(--paper-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-inner {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--space-md) var(--space-lg);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-text {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ink-900);
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--vermilion);
|
||||||
|
margin-top: 0.6rem;
|
||||||
|
transition: transform var(--duration-normal) var(--ease-out-back);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand:hover .brand-dot {
|
||||||
|
transform: scale(1.4);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
125
frontend/src/components/novel/NovelCard.vue
Normal file
125
frontend/src/components/novel/NovelCard.vue
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { Novel } from '@/types/novel'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
novel: Novel
|
||||||
|
index?: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// 根据 id 生成书脊颜色
|
||||||
|
const spineColors = [
|
||||||
|
'#c73e1d', '#5b8c5a', '#2d6a8e', '#8b5e3c', '#6b4c8a',
|
||||||
|
'#c4843e', '#3c7a6e', '#9b4d52', '#4a6741', '#7a6543',
|
||||||
|
]
|
||||||
|
|
||||||
|
const spineColor = computed(() => spineColors[props.novel.id % spineColors.length])
|
||||||
|
|
||||||
|
const animDelay = computed(() => `${(props.index ?? 0) * 80}ms`)
|
||||||
|
|
||||||
|
function formatDate(dateStr: string) {
|
||||||
|
const d = new Date(dateStr)
|
||||||
|
return d.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<RouterLink
|
||||||
|
:to="{ name: 'novel-detail', params: { id: novel.id } }"
|
||||||
|
class="novel-card"
|
||||||
|
:style="{ '--spine-color': spineColor, '--anim-delay': animDelay }"
|
||||||
|
>
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title">{{ novel.title }}</h3>
|
||||||
|
<p v-if="novel.description" class="card-desc">
|
||||||
|
{{ novel.description.slice(0, 120) }}{{ novel.description.length > 120 ? '...' : '' }}
|
||||||
|
</p>
|
||||||
|
<p v-else class="card-desc card-desc--empty">暂无简介</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer">
|
||||||
|
<time class="card-date">{{ formatDate(novel.updated_at) }}</time>
|
||||||
|
</div>
|
||||||
|
</RouterLink>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.novel-card {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--paper-50);
|
||||||
|
border: 1px solid var(--paper-200);
|
||||||
|
border-radius: 2px var(--radius-md) var(--radius-md) 2px;
|
||||||
|
padding: var(--space-xl) var(--space-lg) var(--space-lg);
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
transition:
|
||||||
|
transform var(--duration-normal) var(--ease-out-back),
|
||||||
|
box-shadow var(--duration-normal) ease;
|
||||||
|
animation: fadeInUp var(--duration-slow) var(--ease-out-smooth) both;
|
||||||
|
animation-delay: var(--anim-delay);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 书脊色带 */
|
||||||
|
.novel-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 5px;
|
||||||
|
background: var(--spine-color);
|
||||||
|
border-radius: 2px 0 0 2px;
|
||||||
|
transition: width var(--duration-normal) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.novel-card:hover {
|
||||||
|
transform: translateY(-6px) rotateY(-1.5deg);
|
||||||
|
box-shadow: var(--shadow-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.novel-card:hover::before {
|
||||||
|
width: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ink-900);
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-desc {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--ink-500);
|
||||||
|
line-height: 1.6;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-desc--empty {
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--ink-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-footer {
|
||||||
|
margin-top: var(--space-md);
|
||||||
|
padding-top: var(--space-sm);
|
||||||
|
border-top: 1px solid var(--paper-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-date {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--ink-300);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
98
frontend/src/components/novel/NovelEmptyState.vue
Normal file
98
frontend/src/components/novel/NovelEmptyState.vue
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
|
import BaseButton from '@/components/ui/BaseButton.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="empty-state">
|
||||||
|
<!-- 毛笔落笔 SVG 动画 -->
|
||||||
|
<svg class="brush-anim" viewBox="0 0 200 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- 横 -->
|
||||||
|
<path
|
||||||
|
class="stroke-path"
|
||||||
|
d="M 40 50 Q 70 48 100 50 Q 130 52 160 50"
|
||||||
|
stroke="var(--ink-700)"
|
||||||
|
stroke-width="3"
|
||||||
|
stroke-linecap="round"
|
||||||
|
fill="none"
|
||||||
|
style="--path-length: 1; --delay: 0.3s"
|
||||||
|
/>
|
||||||
|
<!-- 竖 -->
|
||||||
|
<path
|
||||||
|
class="stroke-path"
|
||||||
|
d="M 100 30 Q 98 55 100 80"
|
||||||
|
stroke="var(--ink-700)"
|
||||||
|
stroke-width="3"
|
||||||
|
stroke-linecap="round"
|
||||||
|
fill="none"
|
||||||
|
style="--path-length: 1; --delay: 0.8s"
|
||||||
|
/>
|
||||||
|
<!-- 撇 -->
|
||||||
|
<path
|
||||||
|
class="stroke-path"
|
||||||
|
d="M 95 52 Q 75 70 60 85"
|
||||||
|
stroke="var(--ink-700)"
|
||||||
|
stroke-width="2.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
fill="none"
|
||||||
|
style="--path-length: 1; --delay: 1.3s"
|
||||||
|
/>
|
||||||
|
<!-- 捺 -->
|
||||||
|
<path
|
||||||
|
class="stroke-path"
|
||||||
|
d="M 105 52 Q 125 70 145 85"
|
||||||
|
stroke="var(--ink-700)"
|
||||||
|
stroke-width="2.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
fill="none"
|
||||||
|
style="--path-length: 1; --delay: 1.8s"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<h2 class="empty-title">落笔生花</h2>
|
||||||
|
<p class="empty-desc">尚无作品,开始你的第一部创作吧</p>
|
||||||
|
|
||||||
|
<RouterLink :to="{ name: 'novel-create' }">
|
||||||
|
<BaseButton variant="primary">
|
||||||
|
开始创作
|
||||||
|
</BaseButton>
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-3xl) 0;
|
||||||
|
animation: fadeIn var(--duration-slow) ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brush-anim {
|
||||||
|
width: 160px;
|
||||||
|
height: 100px;
|
||||||
|
margin-bottom: var(--space-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stroke-path {
|
||||||
|
stroke-dasharray: 1;
|
||||||
|
stroke-dashoffset: 1;
|
||||||
|
animation: strokeDraw 0.6s ease forwards;
|
||||||
|
animation-delay: var(--delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.6rem;
|
||||||
|
color: var(--ink-700);
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-desc {
|
||||||
|
color: var(--ink-400);
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-bottom: var(--space-xl);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
40
frontend/src/components/novel/NovelGrid.vue
Normal file
40
frontend/src/components/novel/NovelGrid.vue
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Novel } from '@/types/novel'
|
||||||
|
import NovelCard from './NovelCard.vue'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
novels: Novel[]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="novel-grid">
|
||||||
|
<NovelCard
|
||||||
|
v-for="(novel, i) in novels"
|
||||||
|
:key="novel.id"
|
||||||
|
:novel="novel"
|
||||||
|
:index="i"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.novel-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(1, 1fr);
|
||||||
|
gap: var(--space-lg);
|
||||||
|
perspective: 1000px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.novel-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 960px) {
|
||||||
|
.novel-grid {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
131
frontend/src/components/outline/OutlineEmptyState.vue
Normal file
131
frontend/src/components/outline/OutlineEmptyState.vue
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import BaseButton from '@/components/ui/BaseButton.vue'
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
create: []
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="empty-state">
|
||||||
|
<!-- 卷轴SVG -->
|
||||||
|
<svg class="scroll-svg" viewBox="0 0 200 180" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- 卷轴杆(上) -->
|
||||||
|
<rect class="scroll-rod" x="40" y="20" width="120" height="8" rx="4" fill="#d9cebf" stroke="#9090a8" stroke-width="1" />
|
||||||
|
<circle class="rod-end" cx="40" cy="24" r="6" fill="#d9cebf" stroke="#9090a8" stroke-width="1" />
|
||||||
|
<circle class="rod-end" cx="160" cy="24" r="6" fill="#d9cebf" stroke="#9090a8" stroke-width="1" />
|
||||||
|
|
||||||
|
<!-- 展开的纸面 -->
|
||||||
|
<rect class="scroll-paper" x="50" y="28" width="100" height="100" rx="2" fill="#faf8f5" stroke="#e8e0d4" stroke-width="1" />
|
||||||
|
|
||||||
|
<!-- 虚线(待书写的文字行) -->
|
||||||
|
<line class="text-line text-line-1" x1="65" y1="50" x2="135" y2="50" stroke="#e8e0d4" stroke-width="1" stroke-dasharray="4 3" />
|
||||||
|
<line class="text-line text-line-2" x1="65" y1="65" x2="135" y2="65" stroke="#e8e0d4" stroke-width="1" stroke-dasharray="4 3" />
|
||||||
|
<line class="text-line text-line-3" x1="65" y1="80" x2="120" y2="80" stroke="#e8e0d4" stroke-width="1" stroke-dasharray="4 3" />
|
||||||
|
<line class="text-line text-line-4" x1="65" y1="95" x2="130" y2="95" stroke="#e8e0d4" stroke-width="1" stroke-dasharray="4 3" />
|
||||||
|
<line class="text-line text-line-5" x1="65" y1="110" x2="110" y2="110" stroke="#e8e0d4" stroke-width="1" stroke-dasharray="4 3" />
|
||||||
|
|
||||||
|
<!-- 卷轴杆(下) -->
|
||||||
|
<rect class="scroll-rod scroll-rod-bottom" x="40" y="128" width="120" height="8" rx="4" fill="#d9cebf" stroke="#9090a8" stroke-width="1" />
|
||||||
|
<circle class="rod-end rod-end-bottom" cx="40" cy="132" r="6" fill="#d9cebf" stroke="#9090a8" stroke-width="1" />
|
||||||
|
<circle class="rod-end rod-end-bottom" cx="160" cy="132" r="6" fill="#d9cebf" stroke="#9090a8" stroke-width="1" />
|
||||||
|
|
||||||
|
<!-- 一支毛笔 -->
|
||||||
|
<g class="brush" transform="translate(130, 55) rotate(25)">
|
||||||
|
<rect x="-2" y="0" width="4" height="28" rx="1.5" fill="#8b5e3c" />
|
||||||
|
<path d="M-1.5 28 Q0 40 1.5 28" fill="#1a1a2e" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<h3 class="empty-title">展开你的故事脉络</h3>
|
||||||
|
<p class="empty-desc">还没有大纲节点,创建第一个来构建你的故事框架</p>
|
||||||
|
<BaseButton variant="primary" @click="$emit('create')">
|
||||||
|
创建第一个节点
|
||||||
|
</BaseButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--space-3xl) var(--space-xl);
|
||||||
|
animation: fadeInUp var(--duration-slow) var(--ease-out-smooth) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-svg {
|
||||||
|
width: 200px;
|
||||||
|
height: 180px;
|
||||||
|
margin-bottom: var(--space-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 卷轴展开动画 */
|
||||||
|
.scroll-paper {
|
||||||
|
animation: scrollUnroll 1.2s var(--ease-out-smooth) both;
|
||||||
|
transform-origin: top center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scrollUnroll {
|
||||||
|
from {
|
||||||
|
transform: scaleY(0);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: scaleY(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-rod-bottom,
|
||||||
|
.rod-end-bottom {
|
||||||
|
animation: rodSlideDown 1.2s var(--ease-out-smooth) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rodSlideDown {
|
||||||
|
from {
|
||||||
|
transform: translateY(-100px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 文字行依次显现 */
|
||||||
|
.text-line {
|
||||||
|
opacity: 0;
|
||||||
|
animation: fadeIn 0.4s ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-line-1 { animation-delay: 0.6s; }
|
||||||
|
.text-line-2 { animation-delay: 0.8s; }
|
||||||
|
.text-line-3 { animation-delay: 1.0s; }
|
||||||
|
.text-line-4 { animation-delay: 1.2s; }
|
||||||
|
.text-line-5 { animation-delay: 1.4s; }
|
||||||
|
|
||||||
|
/* 毛笔轻微晃动 */
|
||||||
|
.brush {
|
||||||
|
animation: brushSway 3s ease-in-out infinite;
|
||||||
|
transform-origin: center top;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes brushSway {
|
||||||
|
0%, 100% { transform: translate(130px, 55px) rotate(25deg); }
|
||||||
|
50% { transform: translate(130px, 55px) rotate(20deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ink-900);
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-desc {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--ink-400);
|
||||||
|
margin-bottom: var(--space-xl);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
129
frontend/src/components/outline/OutlineFormModal.vue
Normal file
129
frontend/src/components/outline/OutlineFormModal.vue
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, computed } from 'vue'
|
||||||
|
import type { Outline, OutlineCreate, OutlineUpdate } from '@/types/outline'
|
||||||
|
import BaseModal from '@/components/ui/BaseModal.vue'
|
||||||
|
import BaseInput from '@/components/ui/BaseInput.vue'
|
||||||
|
import BaseTextarea from '@/components/ui/BaseTextarea.vue'
|
||||||
|
import BaseButton from '@/components/ui/BaseButton.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
show: boolean
|
||||||
|
outline: Outline | null
|
||||||
|
saving: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
submit: [data: OutlineCreate | OutlineUpdate]
|
||||||
|
close: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const summary = ref('')
|
||||||
|
const detail = ref('')
|
||||||
|
|
||||||
|
const isEdit = computed(() => !!props.outline)
|
||||||
|
const title = computed(() => isEdit.value ? '编辑大纲' : '新建大纲')
|
||||||
|
|
||||||
|
const detailLength = computed(() => detail.value.length)
|
||||||
|
const detailHint = computed(() => {
|
||||||
|
if (detailLength.value === 0) return '建议 100~300 字'
|
||||||
|
if (detailLength.value < 100) return `${detailLength.value} 字,建议至少 100 字`
|
||||||
|
if (detailLength.value <= 300) return `${detailLength.value} 字`
|
||||||
|
return `${detailLength.value} 字,建议不超过 300 字`
|
||||||
|
})
|
||||||
|
|
||||||
|
const canSubmit = computed(() => summary.value.trim().length > 0 && !props.saving)
|
||||||
|
|
||||||
|
watch(() => props.show, (val) => {
|
||||||
|
if (val) {
|
||||||
|
summary.value = props.outline?.summary ?? ''
|
||||||
|
detail.value = props.outline?.detail ?? ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
if (!canSubmit.value) return
|
||||||
|
emit('submit', {
|
||||||
|
summary: summary.value.trim(),
|
||||||
|
detail: detail.value.trim(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<BaseModal :show="show" @close="emit('close')">
|
||||||
|
<form class="outline-form" @submit.prevent="handleSubmit">
|
||||||
|
<h2 class="form-title">{{ title }}</h2>
|
||||||
|
|
||||||
|
<BaseInput
|
||||||
|
v-model="summary"
|
||||||
|
label="一句话概要"
|
||||||
|
:maxlength="200"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="detail-field">
|
||||||
|
<BaseTextarea
|
||||||
|
v-model="detail"
|
||||||
|
label="详细描述"
|
||||||
|
:rows="8"
|
||||||
|
:maxlength="1000"
|
||||||
|
:max-height="300"
|
||||||
|
/>
|
||||||
|
<span class="detail-counter" :class="{ 'detail-counter--warn': detailLength > 300 }">
|
||||||
|
{{ detailHint }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<BaseButton variant="secondary" type="button" @click="emit('close')">取消</BaseButton>
|
||||||
|
<BaseButton variant="primary" type="submit" :loading="saving" :disabled="!canSubmit">
|
||||||
|
{{ isEdit ? '保存' : '创建' }}
|
||||||
|
</BaseButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</BaseModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.outline-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ink-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-field {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-counter {
|
||||||
|
display: block;
|
||||||
|
text-align: right;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--ink-300);
|
||||||
|
margin-top: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-counter--warn {
|
||||||
|
color: var(--vermilion);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
padding-top: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 500px) {
|
||||||
|
.outline-form {
|
||||||
|
min-width: unset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
226
frontend/src/components/outline/OutlineNode.vue
Normal file
226
frontend/src/components/outline/OutlineNode.vue
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import type { Outline } from '@/types/outline'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
outline: Outline
|
||||||
|
index: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
edit: []
|
||||||
|
delete: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const expanded = ref(false)
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
expanded.value = !expanded.value
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="outline-node"
|
||||||
|
:class="{ 'outline-node--expanded': expanded }"
|
||||||
|
@click="toggle"
|
||||||
|
>
|
||||||
|
<!-- 拖拽手柄 -->
|
||||||
|
<div class="node-drag-handle" @click.stop>
|
||||||
|
<svg width="12" height="16" viewBox="0 0 12 16" fill="none">
|
||||||
|
<circle cx="3" cy="2" r="1.5" fill="currentColor" />
|
||||||
|
<circle cx="9" cy="2" r="1.5" fill="currentColor" />
|
||||||
|
<circle cx="3" cy="8" r="1.5" fill="currentColor" />
|
||||||
|
<circle cx="9" cy="8" r="1.5" fill="currentColor" />
|
||||||
|
<circle cx="3" cy="14" r="1.5" fill="currentColor" />
|
||||||
|
<circle cx="9" cy="14" r="1.5" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 概要 -->
|
||||||
|
<h3 class="node-summary">{{ outline.summary }}</h3>
|
||||||
|
|
||||||
|
<!-- 详情(可展开) -->
|
||||||
|
<div class="node-detail-wrapper" :class="{ 'node-detail-wrapper--open': expanded }">
|
||||||
|
<div class="node-detail-inner">
|
||||||
|
<p v-if="outline.detail" class="node-detail">{{ outline.detail }}</p>
|
||||||
|
<p v-else class="node-detail node-detail--empty">暂无详情,点击编辑添加</p>
|
||||||
|
|
||||||
|
<!-- 操作按钮 -->
|
||||||
|
<div class="node-actions" @click.stop>
|
||||||
|
<button class="action-btn action-btn--edit" @click="emit('edit')">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||||
|
<path d="M10.5 1.5l2 2-8 8H2.5v-2l8-8z" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
编辑
|
||||||
|
</button>
|
||||||
|
<button class="action-btn action-btn--delete" @click="emit('delete')">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||||
|
<path d="M2 4h10M5 4V2.5h4V4M3.5 4v8h7V4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 展开提示 -->
|
||||||
|
<div class="node-expand-hint">
|
||||||
|
<svg
|
||||||
|
class="expand-chevron"
|
||||||
|
:class="{ 'expand-chevron--open': expanded }"
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
viewBox="0 0 12 12"
|
||||||
|
fill="none"
|
||||||
|
>
|
||||||
|
<path d="M3 4.5l3 3 3-3" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.outline-node {
|
||||||
|
position: relative;
|
||||||
|
background: var(--paper-50);
|
||||||
|
border: 1px solid var(--paper-200);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-lg) var(--space-xl);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
transform var(--duration-normal) var(--ease-out-back),
|
||||||
|
box-shadow var(--duration-normal) ease,
|
||||||
|
border-color var(--duration-normal) ease;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outline-node:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
border-color: var(--paper-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
.outline-node--expanded {
|
||||||
|
border-color: var(--vermilion);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
border-left-width: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 拖拽手柄 */
|
||||||
|
.node-drag-handle {
|
||||||
|
position: absolute;
|
||||||
|
top: var(--space-sm);
|
||||||
|
right: var(--space-sm);
|
||||||
|
padding: var(--space-xs);
|
||||||
|
color: var(--ink-300);
|
||||||
|
cursor: grab;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity var(--duration-fast) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outline-node:hover .node-drag-handle {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-drag-handle:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 概要标题 */
|
||||||
|
.node-summary {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink-900);
|
||||||
|
line-height: 1.5;
|
||||||
|
padding-right: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 详情展开区 */
|
||||||
|
.node-detail-wrapper {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 0fr;
|
||||||
|
transition: grid-template-rows var(--duration-normal) var(--ease-out-smooth);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-detail-wrapper--open {
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-detail-inner {
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-detail {
|
||||||
|
margin-top: var(--space-md);
|
||||||
|
padding-top: var(--space-md);
|
||||||
|
border-top: 1px dashed var(--paper-200);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--ink-500);
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-detail--empty {
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--ink-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 操作按钮 */
|
||||||
|
.node-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
margin-top: var(--space-md);
|
||||||
|
padding-top: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--paper-200);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--duration-fast) ease;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn--edit {
|
||||||
|
color: var(--bamboo);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn--edit:hover {
|
||||||
|
background: rgba(91, 140, 90, 0.08);
|
||||||
|
border-color: var(--bamboo);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn--delete {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn--delete:hover {
|
||||||
|
background: var(--danger-ghost);
|
||||||
|
border-color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 展开箭头 */
|
||||||
|
.node-expand-hint {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: var(--space-sm);
|
||||||
|
color: var(--ink-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-chevron {
|
||||||
|
transition: transform var(--duration-normal) var(--ease-out-back);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-chevron--open {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
288
frontend/src/components/outline/OutlineTimeline.vue
Normal file
288
frontend/src/components/outline/OutlineTimeline.vue
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import type { Outline } from '@/types/outline'
|
||||||
|
import OutlineNode from './OutlineNode.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
outlines: Outline[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
edit: [outline: Outline]
|
||||||
|
delete: [outline: Outline]
|
||||||
|
reorder: [outlines: Outline[]]
|
||||||
|
add: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// 拖拽排序
|
||||||
|
const dragIndex = ref<number | null>(null)
|
||||||
|
const dragOverIndex = ref<number | null>(null)
|
||||||
|
|
||||||
|
function onDragStart(index: number) {
|
||||||
|
dragIndex.value = index
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragOver(index: number) {
|
||||||
|
if (dragIndex.value === null || dragIndex.value === index) return
|
||||||
|
dragOverIndex.value = index
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragEnd() {
|
||||||
|
if (dragIndex.value !== null && dragOverIndex.value !== null && dragIndex.value !== dragOverIndex.value) {
|
||||||
|
const items = [...props.outlines]
|
||||||
|
const [moved] = items.splice(dragIndex.value, 1)
|
||||||
|
items.splice(dragOverIndex.value, 0, moved)
|
||||||
|
emit('reorder', items)
|
||||||
|
}
|
||||||
|
dragIndex.value = null
|
||||||
|
dragOverIndex.value = null
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="timeline">
|
||||||
|
<!-- 中央墨线 -->
|
||||||
|
<div class="timeline-line" />
|
||||||
|
|
||||||
|
<!-- 大纲节点 -->
|
||||||
|
<div
|
||||||
|
v-for="(outline, index) in outlines"
|
||||||
|
:key="outline.id"
|
||||||
|
class="timeline-item"
|
||||||
|
:class="{
|
||||||
|
'timeline-item--left': index % 2 === 0,
|
||||||
|
'timeline-item--right': index % 2 !== 0,
|
||||||
|
'timeline-item--dragging': dragIndex === index,
|
||||||
|
'timeline-item--drag-over': dragOverIndex === index,
|
||||||
|
}"
|
||||||
|
:style="{ '--node-delay': `${index * 120}ms` }"
|
||||||
|
draggable="true"
|
||||||
|
@dragstart="onDragStart(index)"
|
||||||
|
@dragover.prevent="onDragOver(index)"
|
||||||
|
@dragend="onDragEnd"
|
||||||
|
>
|
||||||
|
<!-- 墨点(居中在时间线上) -->
|
||||||
|
<div class="timeline-dot">
|
||||||
|
<span class="dot-number">{{ index + 1 }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 连接横线 -->
|
||||||
|
<div class="timeline-bridge" />
|
||||||
|
|
||||||
|
<!-- 节点卡片 -->
|
||||||
|
<div class="timeline-card">
|
||||||
|
<OutlineNode
|
||||||
|
:outline="outline"
|
||||||
|
:index="index"
|
||||||
|
@edit="emit('edit', outline)"
|
||||||
|
@delete="emit('delete', outline)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 底部添加按钮 -->
|
||||||
|
<div class="timeline-add">
|
||||||
|
<div class="add-line" />
|
||||||
|
<button class="add-button" @click="emit('add')">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||||
|
<path d="M10 4v12M4 10h12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||||
|
</svg>
|
||||||
|
<span>续写脉络</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.timeline {
|
||||||
|
position: relative;
|
||||||
|
padding: var(--space-xl) 0 var(--space-3xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 中央墨线 */
|
||||||
|
.timeline-line {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 2px;
|
||||||
|
background: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
transparent,
|
||||||
|
var(--ink-300) 5%,
|
||||||
|
var(--ink-300) 90%,
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
transform: translateX(-50%);
|
||||||
|
animation: inkLineGrow var(--duration-slow) var(--ease-out-smooth) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes inkLineGrow {
|
||||||
|
from { clip-path: inset(0 0 100% 0); }
|
||||||
|
to { clip-path: inset(0 0 0% 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 时间线节点 - 使用flexbox + 绝对定位的墨点 */
|
||||||
|
.timeline-item {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: var(--space-xl);
|
||||||
|
animation: fadeInUp var(--duration-normal) var(--ease-out-smooth) both;
|
||||||
|
animation-delay: var(--node-delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 左侧节点:卡片在左半边 */
|
||||||
|
.timeline-item--left {
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding-right: calc(50% + var(--space-lg));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 右侧节点:卡片在右半边 */
|
||||||
|
.timeline-item--right {
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-left: calc(50% + var(--space-lg));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 拖拽状态 */
|
||||||
|
.timeline-item--dragging {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item--drag-over::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -6px;
|
||||||
|
left: 15%;
|
||||||
|
right: 15%;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--vermilion);
|
||||||
|
border-radius: 1px;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 墨点 - 始终在中央线上 */
|
||||||
|
.timeline-dot {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: var(--space-lg);
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--paper-50);
|
||||||
|
border: 2px solid var(--vermilion);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
transition: all var(--duration-normal) var(--ease-out-back);
|
||||||
|
box-shadow: 0 0 0 4px var(--paper-50);
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item:hover .timeline-dot {
|
||||||
|
background: var(--vermilion);
|
||||||
|
transform: translateX(-50%) scale(1.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item:hover .dot-number {
|
||||||
|
color: var(--paper-50);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot-number {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--vermilion);
|
||||||
|
transition: color var(--duration-normal) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 连接横线 - 从墨点到卡片 */
|
||||||
|
.timeline-bridge {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(var(--space-lg) + 12px);
|
||||||
|
height: 2px;
|
||||||
|
background: var(--paper-300);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 左侧节点:横线从卡片右边缘连到墨点左边缘 */
|
||||||
|
.timeline-item--left .timeline-bridge {
|
||||||
|
right: calc(50% + 14px);
|
||||||
|
left: calc(50% - var(--space-lg));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 右侧节点:横线从墨点右边缘连到卡片左边缘 */
|
||||||
|
.timeline-item--right .timeline-bridge {
|
||||||
|
left: calc(50% + 14px);
|
||||||
|
right: calc(50% - var(--space-lg));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 卡片容器 */
|
||||||
|
.timeline-card {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 底部添加按钮 */
|
||||||
|
.timeline-add {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-line {
|
||||||
|
width: 2px;
|
||||||
|
height: var(--space-xl);
|
||||||
|
background: linear-gradient(to bottom, var(--ink-300), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
padding: var(--space-sm) var(--space-lg);
|
||||||
|
background: var(--paper-50);
|
||||||
|
border: 1.5px dashed var(--ink-300);
|
||||||
|
border-radius: 20px;
|
||||||
|
color: var(--ink-400);
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--duration-normal) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-button:hover {
|
||||||
|
border-color: var(--vermilion);
|
||||||
|
color: var(--vermilion);
|
||||||
|
background: var(--vermilion-ghost);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式:小屏幕全部左对齐 */
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.timeline-line {
|
||||||
|
left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item--left,
|
||||||
|
.timeline-item--right {
|
||||||
|
padding-left: 56px;
|
||||||
|
padding-right: 0;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-dot {
|
||||||
|
left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item:hover .timeline-dot {
|
||||||
|
transform: translateX(-50%) scale(1.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-bridge {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
116
frontend/src/components/ui/BaseButton.vue
Normal file
116
frontend/src/components/ui/BaseButton.vue
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
variant?: 'primary' | 'secondary' | 'danger' | 'ghost'
|
||||||
|
loading?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
class="btn"
|
||||||
|
:class="[`btn--${variant ?? 'secondary'}`, { 'btn--loading': loading }]"
|
||||||
|
:disabled="disabled || loading"
|
||||||
|
>
|
||||||
|
<span v-if="loading" class="btn-spinner" />
|
||||||
|
<span class="btn-content" :class="{ 'btn-content--hidden': loading }">
|
||||||
|
<slot />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.btn {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
padding: 0.6rem 1.4rem;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition:
|
||||||
|
background var(--duration-fast) ease,
|
||||||
|
box-shadow var(--duration-normal) ease,
|
||||||
|
transform var(--duration-fast) ease;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:active:not(:disabled) {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Primary - 朱砂红 */
|
||||||
|
.btn--primary {
|
||||||
|
background: var(--vermilion);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 2px 8px rgba(199, 62, 29, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--primary:hover:not(:disabled) {
|
||||||
|
background: var(--vermilion-light);
|
||||||
|
box-shadow: 0 4px 16px rgba(199, 62, 29, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Secondary */
|
||||||
|
.btn--secondary {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ink-700);
|
||||||
|
border: 1px solid var(--paper-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--secondary:hover:not(:disabled) {
|
||||||
|
background: var(--paper-100);
|
||||||
|
border-color: var(--ink-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Danger */
|
||||||
|
.btn--danger {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--danger);
|
||||||
|
border: 1px solid var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--danger:hover:not(:disabled) {
|
||||||
|
background: var(--danger-ghost);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ghost */
|
||||||
|
.btn--ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ink-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--ghost:hover:not(:disabled) {
|
||||||
|
color: var(--ink-900);
|
||||||
|
background: var(--paper-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading */
|
||||||
|
.btn-spinner {
|
||||||
|
position: absolute;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border: 2px solid currentColor;
|
||||||
|
border-right-color: transparent;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.6s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-content--hidden {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
94
frontend/src/components/ui/BaseInput.vue
Normal file
94
frontend/src/components/ui/BaseInput.vue
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const model = defineModel<string>()
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
label: string
|
||||||
|
error?: string
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="input-group" :class="{ 'input-group--error': error }">
|
||||||
|
<input
|
||||||
|
class="input-field"
|
||||||
|
placeholder=" "
|
||||||
|
v-model="model"
|
||||||
|
/>
|
||||||
|
<label class="input-label">{{ label }}</label>
|
||||||
|
<div class="input-ink" />
|
||||||
|
<p v-if="error" class="input-error">{{ error }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.input-group {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-field {
|
||||||
|
width: 100%;
|
||||||
|
padding: 1rem 0 0.5rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
color: var(--ink-900);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid var(--paper-300);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color var(--duration-normal) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-label {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 1rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
color: var(--ink-400);
|
||||||
|
pointer-events: none;
|
||||||
|
transition:
|
||||||
|
top var(--duration-normal) var(--ease-out-smooth),
|
||||||
|
font-size var(--duration-normal) var(--ease-out-smooth),
|
||||||
|
color var(--duration-normal) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-field:focus + .input-label,
|
||||||
|
.input-field:not(:placeholder-shown) + .input-label {
|
||||||
|
top: -0.4rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--vermilion);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 墨水扩散效果 */
|
||||||
|
.input-ink {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 50%;
|
||||||
|
width: 100%;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--vermilion);
|
||||||
|
transform: scaleX(0) translateX(-50%);
|
||||||
|
transform-origin: center;
|
||||||
|
transition: transform var(--duration-normal) var(--ease-out-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-field:focus ~ .input-ink {
|
||||||
|
transform: scaleX(1) translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 错误状态 */
|
||||||
|
.input-group--error .input-field {
|
||||||
|
border-bottom-color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group--error .input-ink {
|
||||||
|
background: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-error {
|
||||||
|
margin-top: var(--space-xs);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
68
frontend/src/components/ui/BaseModal.vue
Normal file
68
frontend/src/components/ui/BaseModal.vue
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
show: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="modal">
|
||||||
|
<div v-if="show" class="modal-overlay" @click.self="emit('close')">
|
||||||
|
<div class="modal-panel">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(26, 26, 46, 0.3);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-panel {
|
||||||
|
background: var(--paper-50);
|
||||||
|
border: 1px solid var(--paper-200);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--space-xl);
|
||||||
|
max-width: 560px;
|
||||||
|
width: 90%;
|
||||||
|
max-height: 85vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 转场动画 */
|
||||||
|
.modal-enter-active {
|
||||||
|
transition: opacity var(--duration-normal) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-leave-active {
|
||||||
|
transition: opacity var(--duration-fast) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-enter-from,
|
||||||
|
.modal-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-enter-active .modal-panel {
|
||||||
|
animation: scaleIn var(--duration-normal) var(--ease-out-back) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-leave-active .modal-panel {
|
||||||
|
animation: scaleIn var(--duration-fast) ease reverse both;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
118
frontend/src/components/ui/BaseTextarea.vue
Normal file
118
frontend/src/components/ui/BaseTextarea.vue
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, nextTick, onMounted } from 'vue'
|
||||||
|
|
||||||
|
const model = defineModel<string>()
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
label: string
|
||||||
|
error?: string
|
||||||
|
rows?: number
|
||||||
|
maxlength?: number
|
||||||
|
maxHeight?: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const textareaRef = ref<HTMLTextAreaElement>()
|
||||||
|
|
||||||
|
function autoResize() {
|
||||||
|
const el = textareaRef.value
|
||||||
|
if (!el) return
|
||||||
|
el.style.height = 'auto'
|
||||||
|
const maxH = props.maxHeight ?? 400
|
||||||
|
if (el.scrollHeight > maxH) {
|
||||||
|
el.style.height = maxH + 'px'
|
||||||
|
el.style.overflowY = 'auto'
|
||||||
|
} else {
|
||||||
|
el.style.height = el.scrollHeight + 'px'
|
||||||
|
el.style.overflowY = 'hidden'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(model, () => nextTick(autoResize))
|
||||||
|
onMounted(() => nextTick(autoResize))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="textarea-group" :class="{ 'textarea-group--error': error }">
|
||||||
|
<textarea
|
||||||
|
ref="textareaRef"
|
||||||
|
class="textarea-field"
|
||||||
|
placeholder=" "
|
||||||
|
v-model="model"
|
||||||
|
:rows="props.rows ?? 3"
|
||||||
|
:maxlength="props.maxlength"
|
||||||
|
@input="autoResize"
|
||||||
|
/>
|
||||||
|
<label class="textarea-label">{{ label }}</label>
|
||||||
|
<div class="textarea-ink" />
|
||||||
|
<p v-if="error" class="textarea-error">{{ error }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.textarea-group {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea-field {
|
||||||
|
width: 100%;
|
||||||
|
padding: 1rem 0 0.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
color: var(--ink-900);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid var(--paper-300);
|
||||||
|
outline: none;
|
||||||
|
resize: none;
|
||||||
|
overflow-y: hidden;
|
||||||
|
line-height: 1.6;
|
||||||
|
transition: border-color var(--duration-normal) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea-label {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 1rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--ink-400);
|
||||||
|
pointer-events: none;
|
||||||
|
transition:
|
||||||
|
top var(--duration-normal) var(--ease-out-smooth),
|
||||||
|
font-size var(--duration-normal) var(--ease-out-smooth),
|
||||||
|
color var(--duration-normal) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea-field:focus + .textarea-label,
|
||||||
|
.textarea-field:not(:placeholder-shown) + .textarea-label {
|
||||||
|
top: -0.4rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--vermilion);
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea-ink {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 50%;
|
||||||
|
width: 100%;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--vermilion);
|
||||||
|
transform: scaleX(0) translateX(-50%);
|
||||||
|
transform-origin: center;
|
||||||
|
transition: transform var(--duration-normal) var(--ease-out-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea-field:focus ~ .textarea-ink {
|
||||||
|
transform: scaleX(1) translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea-group--error .textarea-field {
|
||||||
|
border-bottom-color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea-error {
|
||||||
|
margin-top: var(--space-xs);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
62
frontend/src/components/ui/ConfirmDialog.vue
Normal file
62
frontend/src/components/ui/ConfirmDialog.vue
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import BaseModal from './BaseModal.vue'
|
||||||
|
import BaseButton from './BaseButton.vue'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
show: boolean
|
||||||
|
title: string
|
||||||
|
message: string
|
||||||
|
confirmText?: string
|
||||||
|
loading?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
confirm: []
|
||||||
|
cancel: []
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<BaseModal :show="show" @close="emit('cancel')">
|
||||||
|
<div class="confirm-dialog">
|
||||||
|
<h3 class="confirm-title">{{ title }}</h3>
|
||||||
|
<p class="confirm-message">{{ message }}</p>
|
||||||
|
<div class="confirm-actions">
|
||||||
|
<BaseButton variant="ghost" @click="emit('cancel')">
|
||||||
|
取消
|
||||||
|
</BaseButton>
|
||||||
|
<BaseButton
|
||||||
|
variant="danger"
|
||||||
|
:loading="loading"
|
||||||
|
@click="emit('confirm')"
|
||||||
|
>
|
||||||
|
{{ confirmText ?? '确认' }}
|
||||||
|
</BaseButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</BaseModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.confirm-dialog {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-title {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-message {
|
||||||
|
color: var(--ink-500);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
margin-bottom: var(--space-xl);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-md);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
78
frontend/src/components/ui/ToastContainer.vue
Normal file
78
frontend/src/components/ui/ToastContainer.vue
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useToast } from '@/composables/useToast'
|
||||||
|
|
||||||
|
const { toasts } = useToast()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="toast-container">
|
||||||
|
<TransitionGroup name="toast">
|
||||||
|
<div
|
||||||
|
v-for="toast in toasts"
|
||||||
|
:key="toast.id"
|
||||||
|
class="toast"
|
||||||
|
:class="`toast--${toast.type}`"
|
||||||
|
>
|
||||||
|
<span class="toast-icon">
|
||||||
|
{{ toast.type === 'success' ? '✓' : toast.type === 'error' ? '✗' : '●' }}
|
||||||
|
</span>
|
||||||
|
<span class="toast-message">{{ toast.message }}</span>
|
||||||
|
</div>
|
||||||
|
</TransitionGroup>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.toast-container {
|
||||||
|
position: fixed;
|
||||||
|
top: var(--space-xl);
|
||||||
|
right: var(--space-xl);
|
||||||
|
z-index: 2000;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
padding: 0.75rem 1.2rem;
|
||||||
|
background: var(--paper-50);
|
||||||
|
border: 1px solid var(--paper-200);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
min-width: 240px;
|
||||||
|
border-left: 3px solid var(--ink-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast--success {
|
||||||
|
border-left-color: var(--bamboo);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast--error {
|
||||||
|
border-left-color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-icon {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast--success .toast-icon { color: var(--bamboo); }
|
||||||
|
.toast--error .toast-icon { color: var(--danger); }
|
||||||
|
|
||||||
|
/* 竹简滑入动画 */
|
||||||
|
.toast-enter-active {
|
||||||
|
animation: slideInRight var(--duration-normal) var(--ease-out-back) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-leave-active {
|
||||||
|
animation: slideOutRight var(--duration-fast) ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-move {
|
||||||
|
transition: transform var(--duration-normal) ease;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
114
frontend/src/composables/useChatAgent.ts
Normal file
114
frontend/src/composables/useChatAgent.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { streamChat, fetchMessages, clearMessages as apiClearMessages } from '@/api/chat'
|
||||||
|
import type { ChatMessage } from '@/types/chat'
|
||||||
|
import type { TokenUsage } from '@/api/chat'
|
||||||
|
|
||||||
|
// 全局状态(跨组件共享)
|
||||||
|
const messages = ref<ChatMessage[]>([])
|
||||||
|
const isStreaming = ref(false)
|
||||||
|
const streamingContent = ref('')
|
||||||
|
const lastUsage = ref<TokenUsage | null>(null)
|
||||||
|
let abortController: AbortController | null = null
|
||||||
|
let idCounter = Date.now()
|
||||||
|
|
||||||
|
export function useChatAgent() {
|
||||||
|
async function loadHistory(novelId?: number) {
|
||||||
|
try {
|
||||||
|
const list = await fetchMessages(novelId)
|
||||||
|
messages.value = list.items
|
||||||
|
} catch {
|
||||||
|
// 静默失败
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendMessage(content: string, novelId?: number) {
|
||||||
|
if (isStreaming.value || !content.trim()) return
|
||||||
|
|
||||||
|
// 添加用户消息到列表
|
||||||
|
const userMsg: ChatMessage = {
|
||||||
|
id: idCounter++,
|
||||||
|
novel_id: novelId ?? null,
|
||||||
|
role: 'user',
|
||||||
|
content: content.trim(),
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
messages.value.push(userMsg)
|
||||||
|
|
||||||
|
// 开始流式请求
|
||||||
|
isStreaming.value = true
|
||||||
|
streamingContent.value = ''
|
||||||
|
abortController = new AbortController()
|
||||||
|
|
||||||
|
// 构建发送给API的消息列表(取最近20条)
|
||||||
|
const recentMessages = messages.value
|
||||||
|
.filter(m => m.role === 'user' || m.role === 'assistant')
|
||||||
|
.slice(-20)
|
||||||
|
.map(m => ({ role: m.role, content: m.content }))
|
||||||
|
|
||||||
|
await streamChat({
|
||||||
|
messages: recentMessages,
|
||||||
|
novelId,
|
||||||
|
signal: abortController.signal,
|
||||||
|
onChunk(text) {
|
||||||
|
streamingContent.value += text
|
||||||
|
},
|
||||||
|
onError(error) {
|
||||||
|
const errMsg: ChatMessage = {
|
||||||
|
id: idCounter++,
|
||||||
|
novel_id: novelId ?? null,
|
||||||
|
role: 'assistant',
|
||||||
|
content: `⚠️ ${error}`,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
messages.value.push(errMsg)
|
||||||
|
},
|
||||||
|
onDone(usage) {
|
||||||
|
if (usage) {
|
||||||
|
lastUsage.value = usage
|
||||||
|
}
|
||||||
|
if (streamingContent.value) {
|
||||||
|
const aiMsg: ChatMessage = {
|
||||||
|
id: idCounter++,
|
||||||
|
novel_id: novelId ?? null,
|
||||||
|
role: 'assistant',
|
||||||
|
content: streamingContent.value,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
messages.value.push(aiMsg)
|
||||||
|
}
|
||||||
|
streamingContent.value = ''
|
||||||
|
isStreaming.value = false
|
||||||
|
abortController = null
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopStreaming() {
|
||||||
|
if (abortController) {
|
||||||
|
abortController.abort()
|
||||||
|
abortController = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearChat(novelId?: number) {
|
||||||
|
try {
|
||||||
|
await apiClearMessages(novelId)
|
||||||
|
} catch {
|
||||||
|
// 静默
|
||||||
|
}
|
||||||
|
messages.value = []
|
||||||
|
streamingContent.value = ''
|
||||||
|
lastUsage.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages,
|
||||||
|
isStreaming,
|
||||||
|
streamingContent,
|
||||||
|
lastUsage,
|
||||||
|
loadHistory,
|
||||||
|
sendMessage,
|
||||||
|
stopStreaming,
|
||||||
|
clearChat,
|
||||||
|
}
|
||||||
|
}
|
||||||
30
frontend/src/composables/useToast.ts
Normal file
30
frontend/src/composables/useToast.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
export interface Toast {
|
||||||
|
id: number
|
||||||
|
message: string
|
||||||
|
type: 'success' | 'error' | 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
const toasts = ref<Toast[]>([])
|
||||||
|
let nextId = 0
|
||||||
|
|
||||||
|
export function useToast() {
|
||||||
|
function show(message: string, type: Toast['type'] = 'info', duration = 3000) {
|
||||||
|
const id = nextId++
|
||||||
|
toasts.value.push({ id, message, type })
|
||||||
|
setTimeout(() => {
|
||||||
|
toasts.value = toasts.value.filter((t) => t.id !== id)
|
||||||
|
}, duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
function success(message: string) {
|
||||||
|
show(message, 'success')
|
||||||
|
}
|
||||||
|
|
||||||
|
function error(message: string) {
|
||||||
|
show(message, 'error')
|
||||||
|
}
|
||||||
|
|
||||||
|
return { toasts, show, success, error }
|
||||||
|
}
|
||||||
12
frontend/src/main.ts
Normal file
12
frontend/src/main.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import './style/variables.css'
|
||||||
|
import './style/reset.css'
|
||||||
|
import './style/typography.css'
|
||||||
|
import './style/animations.css'
|
||||||
|
|
||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
app.use(router)
|
||||||
|
app.mount('#app')
|
||||||
34
frontend/src/router/index.ts
Normal file
34
frontend/src/router/index.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: 'home',
|
||||||
|
component: () => import('@/views/HomeView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/novels/new',
|
||||||
|
name: 'novel-create',
|
||||||
|
component: () => import('@/views/NovelFormView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/novels/:id',
|
||||||
|
name: 'novel-detail',
|
||||||
|
component: () => import('@/views/NovelDetailView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/novels/:id/edit',
|
||||||
|
name: 'novel-edit',
|
||||||
|
component: () => import('@/views/NovelFormView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/novels/:id/outlines',
|
||||||
|
name: 'novel-outlines',
|
||||||
|
component: () => import('@/views/OutlineView.vue'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
81
frontend/src/style/animations.css
Normal file
81
frontend/src/style/animations.css
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
@keyframes fadeInUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideInRight {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(30px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideOutRight {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(30px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scaleIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes inkSpread {
|
||||||
|
from {
|
||||||
|
transform: scaleX(0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: scaleX(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { background-position: -200% 0; }
|
||||||
|
100% { background-position: 200% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 笔画描边动画 */
|
||||||
|
@keyframes strokeDraw {
|
||||||
|
from {
|
||||||
|
stroke-dashoffset: 1;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
stroke-dashoffset: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 页面转场 */
|
||||||
|
.page-enter-active {
|
||||||
|
animation: fadeInUp var(--duration-normal) var(--ease-out-smooth) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-leave-active {
|
||||||
|
animation: fadeIn var(--duration-fast) ease reverse both;
|
||||||
|
}
|
||||||
50
frontend/src/style/reset.css
Normal file
50
frontend/src/style/reset.css
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-size: 16px;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: var(--paper-50);
|
||||||
|
color: var(--ink-900);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
textarea {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul,
|
||||||
|
ol {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
18
frontend/src/style/typography.css
Normal file
18
frontend/src/style/typography.css
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@400;600;700&family=Crimson+Pro:ital,wght@0,400;0,600;0,700;1,400&display=swap');
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--font-display: 'Noto Serif SC', 'Crimson Pro', 'Source Han Serif SC', serif;
|
||||||
|
--font-body: 'Crimson Pro', 'Noto Sans SC', system-ui, sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', 'Cascadia Code', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: var(--ink-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 { font-size: 2.5rem; letter-spacing: -0.02em; }
|
||||||
|
h2 { font-size: 1.75rem; letter-spacing: -0.01em; }
|
||||||
|
h3 { font-size: 1.25rem; }
|
||||||
57
frontend/src/style/variables.css
Normal file
57
frontend/src/style/variables.css
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
:root {
|
||||||
|
/* 墨色系 */
|
||||||
|
--ink-900: #1a1a2e;
|
||||||
|
--ink-800: #222240;
|
||||||
|
--ink-700: #2d2d44;
|
||||||
|
--ink-500: #4a4a68;
|
||||||
|
--ink-400: #6b6b8a;
|
||||||
|
--ink-300: #9090a8;
|
||||||
|
|
||||||
|
/* 纸面色 */
|
||||||
|
--paper-50: #faf8f5;
|
||||||
|
--paper-100: #f3efe8;
|
||||||
|
--paper-200: #e8e0d4;
|
||||||
|
--paper-300: #d9cebf;
|
||||||
|
|
||||||
|
/* 朱砂红 */
|
||||||
|
--vermilion: #c73e1d;
|
||||||
|
--vermilion-light: #e85d3a;
|
||||||
|
--vermilion-dark: #a33218;
|
||||||
|
--vermilion-ghost: rgba(199, 62, 29, 0.08);
|
||||||
|
|
||||||
|
/* 竹青 */
|
||||||
|
--bamboo: #5b8c5a;
|
||||||
|
--bamboo-light: #7aab79;
|
||||||
|
|
||||||
|
/* 语义色 */
|
||||||
|
--danger: #b44c43;
|
||||||
|
--danger-ghost: rgba(180, 76, 67, 0.08);
|
||||||
|
--success: #5b8c5a;
|
||||||
|
|
||||||
|
/* 阴影 */
|
||||||
|
--shadow-sm: 0 1px 3px rgba(26, 26, 46, 0.06);
|
||||||
|
--shadow-md: 0 4px 12px rgba(26, 26, 46, 0.08);
|
||||||
|
--shadow-lg: 0 8px 24px rgba(26, 26, 46, 0.12);
|
||||||
|
--shadow-hover: 0 12px 32px rgba(26, 26, 46, 0.16);
|
||||||
|
|
||||||
|
/* 间距 */
|
||||||
|
--space-xs: 0.25rem;
|
||||||
|
--space-sm: 0.5rem;
|
||||||
|
--space-md: 1rem;
|
||||||
|
--space-lg: 1.5rem;
|
||||||
|
--space-xl: 2rem;
|
||||||
|
--space-2xl: 3rem;
|
||||||
|
--space-3xl: 4rem;
|
||||||
|
|
||||||
|
/* 圆角 */
|
||||||
|
--radius-sm: 4px;
|
||||||
|
--radius-md: 8px;
|
||||||
|
--radius-lg: 12px;
|
||||||
|
|
||||||
|
/* 过渡 */
|
||||||
|
--ease-out-back: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
--ease-out-smooth: cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||||
|
--duration-fast: 150ms;
|
||||||
|
--duration-normal: 300ms;
|
||||||
|
--duration-slow: 500ms;
|
||||||
|
}
|
||||||
28
frontend/src/types/chat.ts
Normal file
28
frontend/src/types/chat.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
export interface ChatMessage {
|
||||||
|
id: number
|
||||||
|
novel_id: number | null
|
||||||
|
role: 'user' | 'assistant'
|
||||||
|
content: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatMessageList {
|
||||||
|
items: ChatMessage[]
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AIConfig {
|
||||||
|
id: number
|
||||||
|
provider: string
|
||||||
|
base_url: string
|
||||||
|
api_key_masked: string
|
||||||
|
model: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AIConfigSave {
|
||||||
|
provider: string
|
||||||
|
base_url: string
|
||||||
|
api_key: string
|
||||||
|
model: string
|
||||||
|
}
|
||||||
22
frontend/src/types/novel.ts
Normal file
22
frontend/src/types/novel.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
export interface Novel {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NovelCreate {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NovelUpdate {
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NovelList {
|
||||||
|
items: Novel[]
|
||||||
|
total: number
|
||||||
|
}
|
||||||
29
frontend/src/types/outline.ts
Normal file
29
frontend/src/types/outline.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
export interface Outline {
|
||||||
|
id: number
|
||||||
|
novel_id: number
|
||||||
|
sort_order: number
|
||||||
|
summary: string
|
||||||
|
detail: string
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OutlineCreate {
|
||||||
|
summary: string
|
||||||
|
detail?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OutlineUpdate {
|
||||||
|
summary?: string
|
||||||
|
detail?: string
|
||||||
|
sort_order?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OutlineList {
|
||||||
|
items: Outline[]
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OutlineReorder {
|
||||||
|
ordered_ids: number[]
|
||||||
|
}
|
||||||
110
frontend/src/views/HomeView.vue
Normal file
110
frontend/src/views/HomeView.vue
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
|
import { fetchNovels } from '@/api/novels'
|
||||||
|
import type { Novel } from '@/types/novel'
|
||||||
|
import NovelGrid from '@/components/novel/NovelGrid.vue'
|
||||||
|
import NovelEmptyState from '@/components/novel/NovelEmptyState.vue'
|
||||||
|
import BaseButton from '@/components/ui/BaseButton.vue'
|
||||||
|
|
||||||
|
const novels = ref<Novel[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const result = await fetchNovels()
|
||||||
|
novels.value = result.items
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="home-view">
|
||||||
|
<div class="home-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="home-title">我的作品</h1>
|
||||||
|
<p class="home-subtitle">{{ novels.length ? `共 ${novels.length} 部作品` : '' }}</p>
|
||||||
|
</div>
|
||||||
|
<RouterLink v-if="novels.length" :to="{ name: 'novel-create' }">
|
||||||
|
<BaseButton variant="primary">
|
||||||
|
新建作品
|
||||||
|
</BaseButton>
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="loading-state">
|
||||||
|
<div class="loading-pulse" />
|
||||||
|
<div class="loading-pulse" style="animation-delay: 0.15s" />
|
||||||
|
<div class="loading-pulse" style="animation-delay: 0.3s" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<NovelGrid v-if="novels.length" :novels="novels" />
|
||||||
|
<NovelEmptyState v-else />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.home-view {
|
||||||
|
min-height: 60vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: var(--space-2xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 2rem;
|
||||||
|
color: var(--ink-900);
|
||||||
|
margin-bottom: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-subtitle {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--ink-400);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading skeleton */
|
||||||
|
.loading-state {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-pulse {
|
||||||
|
height: 180px;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
var(--paper-100) 25%,
|
||||||
|
var(--paper-200) 50%,
|
||||||
|
var(--paper-100) 75%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
animation: shimmer 1.5s ease infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 639px) {
|
||||||
|
.loading-state {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) and (max-width: 959px) {
|
||||||
|
.loading-state {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
204
frontend/src/views/NovelDetailView.vue
Normal file
204
frontend/src/views/NovelDetailView.vue
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { fetchNovel, deleteNovel } from '@/api/novels'
|
||||||
|
import { useToast } from '@/composables/useToast'
|
||||||
|
import type { Novel } from '@/types/novel'
|
||||||
|
import BaseButton from '@/components/ui/BaseButton.vue'
|
||||||
|
import ConfirmDialog from '@/components/ui/ConfirmDialog.vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const novel = ref<Novel | null>(null)
|
||||||
|
const loading = ref(true)
|
||||||
|
const showDelete = ref(false)
|
||||||
|
const deleting = ref(false)
|
||||||
|
|
||||||
|
const novelId = Number(route.params.id)
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
novel.value = await fetchNovel(novelId)
|
||||||
|
} catch {
|
||||||
|
toast.error('作品不存在')
|
||||||
|
router.push({ name: 'home' })
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
deleting.value = true
|
||||||
|
try {
|
||||||
|
await deleteNovel(novelId)
|
||||||
|
toast.success('作品已删除')
|
||||||
|
router.push({ name: 'home' })
|
||||||
|
} catch {
|
||||||
|
toast.error('删除失败')
|
||||||
|
} finally {
|
||||||
|
deleting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string) {
|
||||||
|
const d = new Date(dateStr)
|
||||||
|
return d.toLocaleDateString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="novel" class="detail-view">
|
||||||
|
<div class="detail-header">
|
||||||
|
<BaseButton variant="ghost" @click="router.push({ name: 'home' })">
|
||||||
|
← 返回
|
||||||
|
</BaseButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<article class="detail-content">
|
||||||
|
<h1 class="detail-title">{{ novel.title }}</h1>
|
||||||
|
|
||||||
|
<div class="detail-meta">
|
||||||
|
<span>创建于 {{ formatDate(novel.created_at) }}</span>
|
||||||
|
<span class="meta-dot">·</span>
|
||||||
|
<span>更新于 {{ formatDate(novel.updated_at) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="novel.description" class="detail-desc">
|
||||||
|
<span class="quote-mark">“</span>
|
||||||
|
<p>{{ novel.description }}</p>
|
||||||
|
<span class="quote-mark quote-mark--end">”</span>
|
||||||
|
</div>
|
||||||
|
<div v-else class="detail-desc detail-desc--empty">
|
||||||
|
<p>暂无简介,点击编辑添加</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-actions">
|
||||||
|
<RouterLink :to="{ name: 'novel-outlines', params: { id: novel.id } }">
|
||||||
|
<BaseButton variant="primary">大纲</BaseButton>
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink :to="{ name: 'novel-edit', params: { id: novel.id } }">
|
||||||
|
<BaseButton variant="secondary">编辑</BaseButton>
|
||||||
|
</RouterLink>
|
||||||
|
<BaseButton variant="danger" @click="showDelete = true">删除</BaseButton>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
:show="showDelete"
|
||||||
|
title="确认删除"
|
||||||
|
:message="`确定要删除「${novel.title}」吗?此操作不可撤销。`"
|
||||||
|
confirm-text="删除"
|
||||||
|
:loading="deleting"
|
||||||
|
@confirm="handleDelete"
|
||||||
|
@cancel="showDelete = false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="loading" class="detail-loading">
|
||||||
|
<div class="loading-pulse" style="height: 2.5rem; width: 60%; margin-bottom: 1rem" />
|
||||||
|
<div class="loading-pulse" style="height: 1rem; width: 40%; margin-bottom: 2rem" />
|
||||||
|
<div class="loading-pulse" style="height: 6rem; width: 100%" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.detail-view {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
animation: fadeInUp var(--duration-normal) var(--ease-out-smooth) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-header {
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 2.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ink-900);
|
||||||
|
line-height: 1.3;
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--bamboo);
|
||||||
|
margin-bottom: var(--space-2xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-dot {
|
||||||
|
color: var(--ink-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-desc {
|
||||||
|
position: relative;
|
||||||
|
padding: var(--space-xl) var(--space-2xl);
|
||||||
|
background: var(--paper-100);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
margin-bottom: var(--space-2xl);
|
||||||
|
line-height: 1.8;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: var(--ink-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-desc--empty {
|
||||||
|
color: var(--ink-300);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-mark {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.2rem;
|
||||||
|
left: 0.6rem;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 3rem;
|
||||||
|
color: var(--vermilion);
|
||||||
|
opacity: 0.3;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-mark--end {
|
||||||
|
top: auto;
|
||||||
|
left: auto;
|
||||||
|
bottom: 0.2rem;
|
||||||
|
right: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading */
|
||||||
|
.detail-loading {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: var(--space-3xl) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-pulse {
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
var(--paper-100) 25%,
|
||||||
|
var(--paper-200) 50%,
|
||||||
|
var(--paper-100) 75%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
animation: shimmer 1.5s ease infinite;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
145
frontend/src/views/NovelFormView.vue
Normal file
145
frontend/src/views/NovelFormView.vue
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { createNovel, fetchNovel, updateNovel } from '@/api/novels'
|
||||||
|
import { useToast } from '@/composables/useToast'
|
||||||
|
import BaseInput from '@/components/ui/BaseInput.vue'
|
||||||
|
import BaseTextarea from '@/components/ui/BaseTextarea.vue'
|
||||||
|
import BaseButton from '@/components/ui/BaseButton.vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const isEdit = computed(() => route.name === 'novel-edit')
|
||||||
|
const novelId = computed(() => Number(route.params.id))
|
||||||
|
|
||||||
|
const title = ref('')
|
||||||
|
const description = ref('')
|
||||||
|
const saving = ref(false)
|
||||||
|
const errors = ref<{ title?: string }>({})
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
if (!isEdit.value) return
|
||||||
|
try {
|
||||||
|
const novel = await fetchNovel(novelId.value)
|
||||||
|
title.value = novel.title
|
||||||
|
description.value = novel.description
|
||||||
|
} catch {
|
||||||
|
toast.error('作品不存在')
|
||||||
|
router.push({ name: 'home' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validate(): boolean {
|
||||||
|
errors.value = {}
|
||||||
|
if (!title.value.trim()) {
|
||||||
|
errors.value.title = '请输入作品名称'
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (title.value.length > 200) {
|
||||||
|
errors.value.title = '名称不能超过200字'
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!validate()) return
|
||||||
|
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
if (isEdit.value) {
|
||||||
|
await updateNovel(novelId.value, {
|
||||||
|
title: title.value.trim(),
|
||||||
|
description: description.value.trim(),
|
||||||
|
})
|
||||||
|
toast.success('保存成功')
|
||||||
|
router.push({ name: 'novel-detail', params: { id: novelId.value } })
|
||||||
|
} else {
|
||||||
|
const novel = await createNovel({
|
||||||
|
title: title.value.trim(),
|
||||||
|
description: description.value.trim(),
|
||||||
|
})
|
||||||
|
toast.success('创建成功')
|
||||||
|
router.push({ name: 'novel-detail', params: { id: novel.id } })
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error(isEdit.value ? '保存失败' : '创建失败')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="form-view">
|
||||||
|
<div class="form-header">
|
||||||
|
<BaseButton variant="ghost" @click="router.back()">
|
||||||
|
← 返回
|
||||||
|
</BaseButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-container">
|
||||||
|
<h1 class="form-title">{{ isEdit ? '编辑作品' : '新建作品' }}</h1>
|
||||||
|
|
||||||
|
<form @submit.prevent="handleSubmit" class="novel-form">
|
||||||
|
<BaseInput
|
||||||
|
v-model="title"
|
||||||
|
label="作品名称"
|
||||||
|
:error="errors.title"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BaseTextarea
|
||||||
|
v-model="description"
|
||||||
|
label="作品简介"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<BaseButton variant="ghost" type="button" @click="router.back()">
|
||||||
|
取消
|
||||||
|
</BaseButton>
|
||||||
|
<BaseButton variant="primary" type="submit" :loading="saving">
|
||||||
|
{{ isEdit ? '保存修改' : '开始创作' }}
|
||||||
|
</BaseButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.form-view {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
animation: fadeInUp var(--duration-normal) var(--ease-out-smooth) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-header {
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-container {
|
||||||
|
background: var(--paper-50);
|
||||||
|
border: 1px solid var(--paper-200);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--space-2xl);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.6rem;
|
||||||
|
color: var(--ink-900);
|
||||||
|
margin-bottom: var(--space-2xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--space-md);
|
||||||
|
margin-top: var(--space-xl);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
237
frontend/src/views/OutlineView.vue
Normal file
237
frontend/src/views/OutlineView.vue
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { fetchNovel } from '@/api/novels'
|
||||||
|
import { fetchOutlines, createOutline, updateOutline, deleteOutline, reorderOutlines } from '@/api/outlines'
|
||||||
|
import { useToast } from '@/composables/useToast'
|
||||||
|
import type { Novel } from '@/types/novel'
|
||||||
|
import type { Outline, OutlineCreate, OutlineUpdate } from '@/types/outline'
|
||||||
|
import BaseButton from '@/components/ui/BaseButton.vue'
|
||||||
|
import OutlineTimeline from '@/components/outline/OutlineTimeline.vue'
|
||||||
|
import OutlineEmptyState from '@/components/outline/OutlineEmptyState.vue'
|
||||||
|
import OutlineFormModal from '@/components/outline/OutlineFormModal.vue'
|
||||||
|
import ConfirmDialog from '@/components/ui/ConfirmDialog.vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const novelId = Number(route.params.id)
|
||||||
|
const novel = ref<Novel | null>(null)
|
||||||
|
const outlines = ref<Outline[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
|
||||||
|
// 表单弹窗
|
||||||
|
const showForm = ref(false)
|
||||||
|
const editingOutline = ref<Outline | null>(null)
|
||||||
|
const formSaving = ref(false)
|
||||||
|
|
||||||
|
// 删除确认
|
||||||
|
const showDeleteConfirm = ref(false)
|
||||||
|
const deletingOutline = ref<Outline | null>(null)
|
||||||
|
const deleting = ref(false)
|
||||||
|
|
||||||
|
const totalOutlines = computed(() => outlines.value.length)
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const [n, list] = await Promise.all([
|
||||||
|
fetchNovel(novelId),
|
||||||
|
fetchOutlines(novelId),
|
||||||
|
])
|
||||||
|
novel.value = n
|
||||||
|
outlines.value = list.items
|
||||||
|
} catch {
|
||||||
|
toast.error('加载失败')
|
||||||
|
router.push({ name: 'home' })
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
editingOutline.value = null
|
||||||
|
showForm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(outline: Outline) {
|
||||||
|
editingOutline.value = outline
|
||||||
|
showForm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFormSubmit(data: OutlineCreate | OutlineUpdate) {
|
||||||
|
formSaving.value = true
|
||||||
|
try {
|
||||||
|
if (editingOutline.value) {
|
||||||
|
const updated = await updateOutline(novelId, editingOutline.value.id, data as OutlineUpdate)
|
||||||
|
const idx = outlines.value.findIndex(o => o.id === updated.id)
|
||||||
|
if (idx !== -1) outlines.value[idx] = updated
|
||||||
|
toast.success('大纲已更新')
|
||||||
|
} else {
|
||||||
|
const created = await createOutline(novelId, data as OutlineCreate)
|
||||||
|
outlines.value.push(created)
|
||||||
|
toast.success('大纲已创建')
|
||||||
|
}
|
||||||
|
showForm.value = false
|
||||||
|
} catch {
|
||||||
|
toast.error('保存失败')
|
||||||
|
} finally {
|
||||||
|
formSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete(outline: Outline) {
|
||||||
|
deletingOutline.value = outline
|
||||||
|
showDeleteConfirm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!deletingOutline.value) return
|
||||||
|
deleting.value = true
|
||||||
|
try {
|
||||||
|
await deleteOutline(novelId, deletingOutline.value.id)
|
||||||
|
outlines.value = outlines.value.filter(o => o.id !== deletingOutline.value!.id)
|
||||||
|
toast.success('大纲已删除')
|
||||||
|
showDeleteConfirm.value = false
|
||||||
|
} catch {
|
||||||
|
toast.error('删除失败')
|
||||||
|
} finally {
|
||||||
|
deleting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReorder(reordered: Outline[]) {
|
||||||
|
outlines.value = reordered
|
||||||
|
try {
|
||||||
|
const result = await reorderOutlines(novelId, reordered.map(o => o.id))
|
||||||
|
outlines.value = result.items
|
||||||
|
} catch {
|
||||||
|
toast.error('排序保存失败')
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="!loading && novel" class="outline-view">
|
||||||
|
<div class="outline-header">
|
||||||
|
<BaseButton variant="ghost" @click="router.push({ name: 'novel-detail', params: { id: novelId } })">
|
||||||
|
← 返回
|
||||||
|
</BaseButton>
|
||||||
|
<div class="header-center">
|
||||||
|
<h1 class="outline-title">{{ novel.title }}</h1>
|
||||||
|
<p class="outline-subtitle">故事大纲 · 共 {{ totalOutlines }} 个节点</p>
|
||||||
|
</div>
|
||||||
|
<BaseButton v-if="totalOutlines > 0" variant="primary" @click="openCreate">
|
||||||
|
添加节点
|
||||||
|
</BaseButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<OutlineTimeline
|
||||||
|
v-if="totalOutlines > 0"
|
||||||
|
:outlines="outlines"
|
||||||
|
@edit="openEdit"
|
||||||
|
@delete="confirmDelete"
|
||||||
|
@reorder="handleReorder"
|
||||||
|
@add="openCreate"
|
||||||
|
/>
|
||||||
|
<OutlineEmptyState v-else @create="openCreate" />
|
||||||
|
|
||||||
|
<OutlineFormModal
|
||||||
|
:show="showForm"
|
||||||
|
:outline="editingOutline"
|
||||||
|
:saving="formSaving"
|
||||||
|
@submit="handleFormSubmit"
|
||||||
|
@close="showForm = false"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
:show="showDeleteConfirm"
|
||||||
|
title="确认删除"
|
||||||
|
:message="`确定要删除「${deletingOutline?.summary}」吗?`"
|
||||||
|
confirm-text="删除"
|
||||||
|
:loading="deleting"
|
||||||
|
@confirm="handleDelete"
|
||||||
|
@cancel="showDeleteConfirm = false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="loading" class="outline-loading">
|
||||||
|
<div class="loading-pulse" style="height: 2rem; width: 40%; margin: 0 auto var(--space-lg)" />
|
||||||
|
<div class="loading-pulse" style="height: 1rem; width: 25%; margin: 0 auto var(--space-2xl)" />
|
||||||
|
<div v-for="i in 3" :key="i" class="loading-node">
|
||||||
|
<div class="loading-pulse loading-circle" />
|
||||||
|
<div class="loading-pulse" style="height: 6rem; flex: 1" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.outline-view {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
animation: fadeInUp var(--duration-normal) var(--ease-out-smooth) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outline-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-md);
|
||||||
|
margin-bottom: var(--space-2xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-center {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outline-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ink-900);
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outline-subtitle {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--ink-400);
|
||||||
|
margin-top: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading */
|
||||||
|
.outline-loading {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: var(--space-3xl) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-node {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-md);
|
||||||
|
margin-bottom: var(--space-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-circle {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-pulse {
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
var(--paper-100) 25%,
|
||||||
|
var(--paper-200) 50%,
|
||||||
|
var(--paper-100) 75%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
animation: shimmer 1.5s ease infinite;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
18
frontend/tsconfig.app.json
Normal file
18
frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
|
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||||
|
"exclude": ["src/**/__tests__/*"],
|
||||||
|
"compilerOptions": {
|
||||||
|
// Extra safety for array and object lookups, but may have false positives.
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
|
||||||
|
// Path mapping for cleaner imports.
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
|
|
||||||
|
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
|
||||||
|
// Specified here to keep it out of the root directory.
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
|
||||||
|
}
|
||||||
|
}
|
||||||
11
frontend/tsconfig.json
Normal file
11
frontend/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.node.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.app.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
28
frontend/tsconfig.node.json
Normal file
28
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
// TSConfig for modules that run in Node.js environment via either transpilation or type-stripping.
|
||||||
|
{
|
||||||
|
"extends": "@tsconfig/node24/tsconfig.json",
|
||||||
|
"include": [
|
||||||
|
"vite.config.*",
|
||||||
|
"vitest.config.*",
|
||||||
|
"cypress.config.*",
|
||||||
|
"nightwatch.conf.*",
|
||||||
|
"playwright.config.*",
|
||||||
|
"eslint.config.*"
|
||||||
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
// Most tools use transpilation instead of Node.js's native type-stripping.
|
||||||
|
// Bundler mode provides a smoother developer experience.
|
||||||
|
"module": "preserve",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
|
||||||
|
// Include Node.js types and avoid accidentally including other `@types/*` packages.
|
||||||
|
"types": ["node"],
|
||||||
|
|
||||||
|
// Disable emitting output during `vue-tsc --build`, which is used for type-checking only.
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
|
||||||
|
// Specified here to keep it out of the root directory.
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
|
||||||
|
}
|
||||||
|
}
|
||||||
26
frontend/vite.config.ts
Normal file
26
frontend/vite.config.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
vueDevTools(),
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://127.0.0.1:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user