新增前后端AI聊天模块,包括流式对话、配置管理和UI组件优化。

This commit is contained in:
2026-03-19 01:35:13 +08:00
commit ba6ceb94c8
66 changed files with 8631 additions and 0 deletions

12
.claude/launch.json Normal file
View 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
View 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. 启动后端+前端验证全流程

View 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
View 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
View File

20
backend/app/database.py Normal file
View 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
View 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
View 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)

View File

View 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
View 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()

View 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()

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

View File

View 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 兼容 APIstream=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)
# 提取usageOpenAI在最后一个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 APIstream=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
View File

@@ -0,0 +1,3 @@
fastapi>=0.115.0
uvicorn[standard]>=0.34.0
sqlmodel>=0.0.22

39
frontend/.gitignore vendored Normal file
View 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
View 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
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

13
frontend/index.html Normal file
View 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

File diff suppressed because it is too large Load Diff

33
frontend/package.json Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

67
frontend/src/App.vue Normal file
View 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
View 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()
}

View 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)
},
)

View 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}`)
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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' ? '&#10003;' : toast.type === 'error' ? '&#10007;' : '&#9679;' }}
</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>

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

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

View 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

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

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

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

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

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

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

View 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[]
}

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

View 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' })">
&larr; 返回
</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">&middot;</span>
<span>更新于 {{ formatDate(novel.updated_at) }}</span>
</div>
<div v-if="novel.description" class="detail-desc">
<span class="quote-mark">&ldquo;</span>
<p>{{ novel.description }}</p>
<span class="quote-mark quote-mark--end">&rdquo;</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>

View 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()">
&larr; 返回
</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>

View 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 } })">
&larr; 返回
</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>

View 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
View File

@@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
}
]
}

View 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
View 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,
},
},
},
})