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