feat: 站点访问密码登录(SITE_PASSWORD)+ 退出登录按钮
- 后端:新增 /api/auth/login 与 /api/auth/status;HMAC 签名令牌 30 天有效、 服务重启不失效;设置 SITE_PASSWORD 后中间件拦截除登录/健康检查/CORS 预检 外的所有 /api/*,未启用密码时行为不变(本地开发默认开放) - 前端:AuthGate 登录层 + BookMate 风格全屏登录页(墨绿卡片、轨道环、书标金句); 令牌存 localStorage 随请求带 X-Access-Token 头,401 自动清令牌重新弹出登录页; 侧栏底部新增「退出登录」按钮(仅在启用密码时显示) - 认证通过后才挂载主应用,避免未登录先发起数据请求 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,10 @@ DATA_DIR=./data
|
|||||||
API_HOST=0.0.0.0
|
API_HOST=0.0.0.0
|
||||||
API_PORT=8000
|
API_PORT=8000
|
||||||
|
|
||||||
|
# 站点访问密码:留空 = 不启用登录(本地开发默认);
|
||||||
|
# 设置后整个应用需要密码才能使用,前端会显示登录页(令牌 30 天有效)
|
||||||
|
SITE_PASSWORD=
|
||||||
|
|
||||||
# ===== 模型 Provider(至少配一个;也可启动后在网页「管理模型」里添加)=====
|
# ===== 模型 Provider(至少配一个;也可启动后在网页「管理模型」里添加)=====
|
||||||
DEEPSEEK_API_KEY=
|
DEEPSEEK_API_KEY=
|
||||||
DEEPSEEK_BASE_URL=https://api.deepseek.com/v1
|
DEEPSEEK_BASE_URL=https://api.deepseek.com/v1
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import hmac
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.security import LOGIN_TTL_S, make_token, verify_token
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login")
|
||||||
|
async def login(req: LoginRequest):
|
||||||
|
"""校验站点访问密码,签发登录令牌(30 天有效,服务重启不失效)。"""
|
||||||
|
if not settings.site_password:
|
||||||
|
raise HTTPException(400, "未启用访问密码")
|
||||||
|
if not hmac.compare_digest(req.password or "", settings.site_password):
|
||||||
|
raise HTTPException(401, "访问密码错误")
|
||||||
|
return {"token": make_token(settings.site_password), "expires_in": LOGIN_TTL_S}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status")
|
||||||
|
async def auth_status(request: Request):
|
||||||
|
"""探测:是否要求密码 + 当前令牌是否有效(本身不鉴权,供登录层判断)。"""
|
||||||
|
required = bool(settings.site_password)
|
||||||
|
token = request.headers.get("x-access-token", "")
|
||||||
|
ok = required and bool(token) and verify_token(token, settings.site_password)
|
||||||
|
return {"required": required, "ok": ok}
|
||||||
@@ -10,6 +10,8 @@ class Settings(BaseSettings):
|
|||||||
data_dir: str = str(BASE_DIR / "data")
|
data_dir: str = str(BASE_DIR / "data")
|
||||||
api_host: str = "0.0.0.0"
|
api_host: str = "0.0.0.0"
|
||||||
api_port: int = 8000
|
api_port: int = 8000
|
||||||
|
# 站点访问密码:留空 = 完全开放(本地开发默认);设置后所有 /api/* 需登录
|
||||||
|
site_password: str = ""
|
||||||
|
|
||||||
# ===== 向量检索 & 重排(OpenAI 兼容 API,如硅基流动 SiliconFlow)=====
|
# ===== 向量检索 & 重排(OpenAI 兼容 API,如硅基流动 SiliconFlow)=====
|
||||||
# embedding_base_url: 支持 OpenAI 兼容 /embeddings 的端点前缀
|
# embedding_base_url: 支持 OpenAI 兼容 /embeddings 的端点前缀
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""站点访问密码:HMAC 签名令牌(无状态,服务重启后仍有效)。
|
||||||
|
|
||||||
|
令牌 = base64url( 过期时间戳 + "." + HMAC-SHA256(过期时间戳) )
|
||||||
|
校验签名 + 过期时间;密码本身不参与签名之外的任何存储。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import time
|
||||||
|
|
||||||
|
LOGIN_TTL_S = 30 * 24 * 3600 # 登录后 30 天内免输密码
|
||||||
|
|
||||||
|
|
||||||
|
def make_token(site_password: str, now: float | None = None) -> str:
|
||||||
|
exp = int(now if now is not None else time.time()) + LOGIN_TTL_S
|
||||||
|
sig = hmac.new(site_password.encode(), str(exp).encode(), hashlib.sha256).hexdigest()
|
||||||
|
raw = f"{exp}.{sig}".encode()
|
||||||
|
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
|
||||||
|
|
||||||
|
|
||||||
|
def verify_token(token: str, site_password: str, now: float | None = None) -> bool:
|
||||||
|
try:
|
||||||
|
pad = "=" * ((4 - len(token) % 4) % 4)
|
||||||
|
raw = base64.urlsafe_b64decode((token + pad).encode())
|
||||||
|
exp_s, sig = raw.decode().split(".", 1)
|
||||||
|
exp = int(exp_s)
|
||||||
|
if exp <= int(now if now is not None else time.time()):
|
||||||
|
return False
|
||||||
|
expect = hmac.new(site_password.encode(), str(exp).encode(), hashlib.sha256).hexdigest()
|
||||||
|
return hmac.compare_digest(sig, expect)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
+24
-1
@@ -3,14 +3,18 @@ from dotenv import load_dotenv
|
|||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
from app.core.database import init_db, Base
|
from app.core.database import init_db, Base
|
||||||
|
from app.core.security import verify_token
|
||||||
from app.api.books import router as books_router
|
from app.api.books import router as books_router
|
||||||
from app.api.chat import router as chat_router
|
from app.api.chat import router as chat_router
|
||||||
from app.api.models import router as models_router
|
from app.api.models import router as models_router
|
||||||
from app.api.mcp import router as mcp_router
|
from app.api.mcp import router as mcp_router
|
||||||
|
from app.api.auth import router as auth_router
|
||||||
|
|
||||||
from app.models.book import Book, Chapter, Segment
|
from app.models.book import Book, Chapter, Segment
|
||||||
from app.models.chat import Conversation, Message
|
from app.models.chat import Conversation, Message
|
||||||
@@ -26,6 +30,7 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
app = FastAPI(title="BookMate", version="0.1.0", lifespan=lifespan)
|
app = FastAPI(title="BookMate", version="0.1.0", lifespan=lifespan)
|
||||||
|
|
||||||
|
# CORS 先加,站点访问密码中间件后加 → 密码中间件在最外层先跑
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"],
|
allow_origins=["*"],
|
||||||
@@ -34,10 +39,28 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def site_auth_middleware(request: Request, call_next):
|
||||||
|
"""站点访问密码门:设置 SITE_PASSWORD 后,除登录/健康检查/CORS 预检外全部拦截。"""
|
||||||
|
if not settings.site_password:
|
||||||
|
return await call_next(request)
|
||||||
|
path = request.url.path
|
||||||
|
method = request.method
|
||||||
|
# CORS 预检、健康检查、登录相关端点放行
|
||||||
|
if method == "OPTIONS" or path == "/api/health" or path.startswith("/api/auth/"):
|
||||||
|
return await call_next(request)
|
||||||
|
token = request.headers.get("x-access-token", "")
|
||||||
|
if not token or not verify_token(token, settings.site_password):
|
||||||
|
return JSONResponse(status_code=401, content={"detail": "需要访问密码"})
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
|
||||||
app.include_router(books_router, prefix="/api")
|
app.include_router(books_router, prefix="/api")
|
||||||
app.include_router(chat_router, prefix="/api")
|
app.include_router(chat_router, prefix="/api")
|
||||||
app.include_router(models_router, prefix="/api")
|
app.include_router(models_router, prefix="/api")
|
||||||
app.include_router(mcp_router, prefix="/api")
|
app.include_router(mcp_router, prefix="/api")
|
||||||
|
app.include_router(auth_router, prefix="/api")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1,22 +1,24 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useMemo, useRef } from "react";
|
import { useState, useEffect, useMemo, useRef, useContext } from "react";
|
||||||
import {
|
import {
|
||||||
BookMarked, BookOpen, Bot, Check, ChevronDown, ChevronLeft, ChevronRight, Copy,
|
BookMarked, BookOpen, Bot, Check, ChevronDown, ChevronLeft, ChevronRight, Copy,
|
||||||
Download, GitBranch, Globe, Loader2, Paperclip, Plus, RotateCcw, Send, Settings2,
|
Download, GitBranch, Globe, Loader2, LogOut, Paperclip, Plus, RotateCcw, Send, Settings2,
|
||||||
Sparkles, Trash2, Upload, User, Wrench, X, ArrowUpRight, Library, Search, Menu, MessageSquare, ArrowLeft,
|
Sparkles, Trash2, Upload, User, Wrench, X, ArrowUpRight, Library, Search, Menu, MessageSquare, ArrowLeft,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api, Book, Conversation, Message, Citation, AiModelEntry, McpServer, formatCharCount } from "@/lib/api";
|
import { api, Book, Conversation, Message, Citation, AiModelEntry, McpServer, formatCharCount } from "@/lib/api";
|
||||||
import MarkdownRenderer from "@/components/MarkdownRenderer";
|
import MarkdownRenderer from "@/components/MarkdownRenderer";
|
||||||
import ModelManagerDialog from "@/components/ModelManagerDialog";
|
import ModelManagerDialog from "@/components/ModelManagerDialog";
|
||||||
import McpManagerDialog from "@/components/McpManagerDialog";
|
import McpManagerDialog from "@/components/McpManagerDialog";
|
||||||
|
import AuthGate, { AuthContext } from "@/components/AuthGate";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
import CitationReaderDialog from "@/components/CitationReaderDialog";
|
import CitationReaderDialog from "@/components/CitationReaderDialog";
|
||||||
import EpubReader from "@/components/EpubReader";
|
import EpubReader from "@/components/EpubReader";
|
||||||
|
|
||||||
const SIDEBAR_MIN = 180;
|
const SIDEBAR_MIN = 180;
|
||||||
const SIDEBAR_MAX = 480;
|
const SIDEBAR_MAX = 480;
|
||||||
|
|
||||||
export default function Home() {
|
function BookmateApp() {
|
||||||
const [books, setBooks] = useState<Book[]>([]);
|
const [books, setBooks] = useState<Book[]>([]);
|
||||||
const [selectedBook, setSelectedBook] = useState<Book | null>(null);
|
const [selectedBook, setSelectedBook] = useState<Book | null>(null);
|
||||||
const [conversations, setConversations] = useState<Conversation[]>([]);
|
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||||||
@@ -65,6 +67,13 @@ export default function Home() {
|
|||||||
setActiveTools([]);
|
setActiveTools([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 退出登录:清令牌 → AuthGate 重新探测 → 回到登录页(仅在启用站点密码时显示按钮)
|
||||||
|
const { required } = useContext(AuthContext);
|
||||||
|
const handleLogout = () => {
|
||||||
|
if (!confirm("退出登录?")) return;
|
||||||
|
auth.clear();
|
||||||
|
};
|
||||||
|
|
||||||
const loadBooks = () => {
|
const loadBooks = () => {
|
||||||
setBooksLoading(true);
|
setBooksLoading(true);
|
||||||
setBooksError(false);
|
setBooksError(false);
|
||||||
@@ -465,6 +474,9 @@ export default function Home() {
|
|||||||
<div className="footer-actions">
|
<div className="footer-actions">
|
||||||
<button onClick={() => setManagerOpen(true)}><Settings2 size={17} /><span>模型与设置</span><ChevronRight size={14} /></button>
|
<button onClick={() => setManagerOpen(true)}><Settings2 size={17} /><span>模型与设置</span><ChevronRight size={14} /></button>
|
||||||
<button onClick={() => setMcpOpen(true)}><Globe size={17} /><span>MCP 服务</span>{mcpServers.filter((s) => s.enabled).length > 0 && <em className="footer-badge">{mcpServers.filter((s) => s.enabled).length}</em>}<ChevronRight size={14} /></button>
|
<button onClick={() => setMcpOpen(true)}><Globe size={17} /><span>MCP 服务</span>{mcpServers.filter((s) => s.enabled).length > 0 && <em className="footer-badge">{mcpServers.filter((s) => s.enabled).length}</em>}<ChevronRight size={14} /></button>
|
||||||
|
{required && (
|
||||||
|
<button onClick={handleLogout} title="退出登录"><LogOut size={17} /><span>退出登录</span><ChevronRight size={14} /></button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="sidebar-edition">BOOKMATE · YOUR READING SPACE</span>
|
<span className="sidebar-edition">BOOKMATE · YOUR READING SPACE</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -769,6 +781,15 @@ export default function Home() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
// 站点访问密码登录层:认证通过后才挂载 BookmateApp(避免未登录先发起数据请求)
|
||||||
|
return (
|
||||||
|
<AuthGate>
|
||||||
|
<BookmateApp />
|
||||||
|
</AuthGate>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ChatBubble({
|
function ChatBubble({
|
||||||
message,
|
message,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useCallback, useContext, useEffect, useState } from "react";
|
||||||
|
import type { FormEvent, ReactNode } from "react";
|
||||||
|
import { BookMarked, Loader2, Lock, WifiOff } from "lucide-react";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
|
||||||
|
/** 是否启用了站点访问密码(供主应用按需显示「退出登录」等) */
|
||||||
|
export const AuthContext = createContext<{ required: boolean }>({ required: false });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 站点访问密码登录层:
|
||||||
|
* - 启动时探测 GET /api/auth/status → { required, ok }
|
||||||
|
* - required:false(未设置 SITE_PASSWORD / 本地开发)→ 直接放行
|
||||||
|
* - ok:true(本地已有有效令牌)→ 放行
|
||||||
|
* - 其余 → 显示全屏登录页
|
||||||
|
* - 登录成功后令牌存 localStorage,随请求以 X-Access-Token 头发送;
|
||||||
|
* 令牌失效(过期 / 密码已改 / 401)时自动清除并重新弹出登录页。
|
||||||
|
* - 后端不可达 → 显示「无法连接服务器」+ 重试。
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Status = "checking" | "open" | "locked" | "error";
|
||||||
|
|
||||||
|
export default function AuthGate({ children }: { children: ReactNode }) {
|
||||||
|
const [status, setStatus] = useState<Status>("checking");
|
||||||
|
const [required, setRequired] = useState(false);
|
||||||
|
const [reloadKey, setReloadKey] = useState(0);
|
||||||
|
|
||||||
|
const check = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const s = await api.authStatus();
|
||||||
|
setRequired(s.required);
|
||||||
|
setStatus(s.required ? (s.ok ? "open" : "locked") : "open");
|
||||||
|
} catch {
|
||||||
|
setStatus("error");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
check();
|
||||||
|
const unsubscribe = auth.subscribe(check); // 登录 / 401 清令牌后自动重新探测
|
||||||
|
return unsubscribe;
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [check, reloadKey]);
|
||||||
|
|
||||||
|
if (status === "checking") {
|
||||||
|
return <Splash />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "error") {
|
||||||
|
return (
|
||||||
|
<div className="login-screen">
|
||||||
|
<div className="login-card login-card-narrow">
|
||||||
|
<span className="login-state-icon"><WifiOff size={22} /></span>
|
||||||
|
<h2 className="login-state-title">无法连接服务器</h2>
|
||||||
|
<p className="login-state-desc">请确认后端服务已启动,然后再试一次。</p>
|
||||||
|
<button className="login-retry" onClick={() => setReloadKey((k) => k + 1)}>
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
<span className="login-edition">BOOKMATE · YOUR READING SPACE</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status !== "locked") {
|
||||||
|
return <AuthContext.Provider value={{ required }}>{children}</AuthContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <LoginScreen />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Splash() {
|
||||||
|
return (
|
||||||
|
<div className="login-screen">
|
||||||
|
<div className="login-splash">
|
||||||
|
<span className="login-mark"><BookMarked size={22} /></span>
|
||||||
|
<Loader2 size={22} className="login-spin animate-spin" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoginScreen() {
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const submit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!password.trim() || submitting) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const { token } = await api.login(password.trim());
|
||||||
|
auth.set(token); // 触发 AuthGate 重新探测 → 放行
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "登录失败,请重试");
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="login-screen">
|
||||||
|
<div className="login-card">
|
||||||
|
<div className="login-orbit login-orbit-one" />
|
||||||
|
<div className="login-orbit login-orbit-two" />
|
||||||
|
<span className="login-art-star">✳</span>
|
||||||
|
|
||||||
|
<div className="login-brand">
|
||||||
|
<span className="login-mark"><BookMarked size={22} /></span>
|
||||||
|
<span className="login-eyebrow">READ · THINK · DISCOVER</span>
|
||||||
|
<h1>BookMate</h1>
|
||||||
|
<p>让阅读,多一种可能</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className="login-form" onSubmit={submit}>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autoFocus
|
||||||
|
placeholder="输入访问密码"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
aria-label="访问密码"
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={!password.trim() || submitting}>
|
||||||
|
{submitting ? <Loader2 size={15} className="animate-spin" /> : <Lock size={15} />}
|
||||||
|
{submitting ? "验证中…" : "进入阅读空间"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{error && <p className="login-error">{error}</p>}
|
||||||
|
|
||||||
|
<span className="login-edition">BOOKMATE · YOUR READING SPACE</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+23
-5
@@ -94,12 +94,16 @@ export interface McpToolInfo {
|
|||||||
const API_BASE =
|
const API_BASE =
|
||||||
(process.env.NEXT_PUBLIC_API_BASE || "http://localhost:8000/api").replace(/\/+$/, "");
|
(process.env.NEXT_PUBLIC_API_BASE || "http://localhost:8000/api").replace(/\/+$/, "");
|
||||||
|
|
||||||
|
import { authHeaders, auth } from "./auth";
|
||||||
|
|
||||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||||
const res = await fetch(`${API_BASE}${path}`, {
|
const res = await fetch(`${API_BASE}${path}`, {
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json", ...authHeaders() },
|
||||||
...options,
|
...options,
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
// 令牌失效(过期/密码已改)→ 清空并让登录层重新弹出;登录接口本身除外
|
||||||
|
if (res.status === 401 && !path.startsWith("/auth/")) auth.clear();
|
||||||
const err = await res.json().catch(() => ({ detail: res.statusText }));
|
const err = await res.json().catch(() => ({ detail: res.statusText }));
|
||||||
throw new Error(err.detail || "Request failed");
|
throw new Error(err.detail || "Request failed");
|
||||||
}
|
}
|
||||||
@@ -111,7 +115,10 @@ export const api = {
|
|||||||
listBooks: () => request<Book[]>("/books"),
|
listBooks: () => request<Book[]>("/books"),
|
||||||
getBook: (id: string) => request<BookDetail>(`/books/${id}`),
|
getBook: (id: string) => request<BookDetail>(`/books/${id}`),
|
||||||
getBookFile: async (id: string, signal?: AbortSignal) => {
|
getBookFile: async (id: string, signal?: AbortSignal) => {
|
||||||
const response = await fetch(`${API_BASE}/books/${encodeURIComponent(id)}/file`, { signal });
|
const response = await fetch(`${API_BASE}/books/${encodeURIComponent(id)}/file`, {
|
||||||
|
headers: authHeaders(),
|
||||||
|
signal,
|
||||||
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json().catch(() => ({ detail: "无法打开 EPUB 文件" }));
|
const error = await response.json().catch(() => ({ detail: "无法打开 EPUB 文件" }));
|
||||||
throw new Error(error.detail || "无法打开 EPUB 文件");
|
throw new Error(error.detail || "无法打开 EPUB 文件");
|
||||||
@@ -124,7 +131,7 @@ export const api = {
|
|||||||
uploadBook: async (file: File) => {
|
uploadBook: async (file: File) => {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append("file", file);
|
form.append("file", file);
|
||||||
const res = await fetch(`${API_BASE}/books`, { method: "POST", body: form });
|
const res = await fetch(`${API_BASE}/books`, { method: "POST", headers: authHeaders(), body: form });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({ detail: "Upload failed" }));
|
const err = await res.json().catch(() => ({ detail: "Upload failed" }));
|
||||||
throw new Error(err.detail || "Upload failed");
|
throw new Error(err.detail || "Upload failed");
|
||||||
@@ -149,7 +156,7 @@ export const api = {
|
|||||||
sendMessage: (convId: string, content: string, model?: string, replyTo?: string) => {
|
sendMessage: (convId: string, content: string, model?: string, replyTo?: string) => {
|
||||||
return fetch(`${API_BASE}/chat/conversations/${convId}/messages`, {
|
return fetch(`${API_BASE}/chat/conversations/${convId}/messages`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json", ...authHeaders() },
|
||||||
body: JSON.stringify({ content, model, ...(replyTo ? { reply_to: replyTo } : {}) }),
|
body: JSON.stringify({ content, model, ...(replyTo ? { reply_to: replyTo } : {}) }),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -171,7 +178,10 @@ export const api = {
|
|||||||
deleteMessage: (msgId: string) =>
|
deleteMessage: (msgId: string) =>
|
||||||
request<{ deleted: string }>(`/chat/messages/${msgId}`, { method: "DELETE" }),
|
request<{ deleted: string }>(`/chat/messages/${msgId}`, { method: "DELETE" }),
|
||||||
retryMessage: (msgId: string) => {
|
retryMessage: (msgId: string) => {
|
||||||
return fetch(`${API_BASE}/chat/messages/${msgId}/retry`, { method: "POST" });
|
return fetch(`${API_BASE}/chat/messages/${msgId}/retry`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: authHeaders(),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
branchConversation: (convId: string, fromMessageId: string) =>
|
branchConversation: (convId: string, fromMessageId: string) =>
|
||||||
request<Conversation>(`/chat/conversations/${convId}/branch?from_message_id=${fromMessageId}`, {
|
request<Conversation>(`/chat/conversations/${convId}/branch?from_message_id=${fromMessageId}`, {
|
||||||
@@ -209,6 +219,14 @@ export const api = {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// Auth(站点访问密码)
|
||||||
|
authStatus: () => request<{ required: boolean; ok: boolean }>("/auth/status"),
|
||||||
|
login: (password: string) =>
|
||||||
|
request<{ token: string }>("/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ password }),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
export function formatCharCount(n: number): string {
|
export function formatCharCount(n: number): string {
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* 站点访问密码(SITE_PASSWORD)的令牌存取。
|
||||||
|
* 令牌只存浏览器 localStorage,随请求以 X-Access-Token 头发送;401 时清空触发重新登录。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ACCESS_TOKEN_KEY = "bookmate.access_token";
|
||||||
|
type Listener = () => void;
|
||||||
|
const listeners = new Set<Listener>();
|
||||||
|
|
||||||
|
export const auth = {
|
||||||
|
get token(): string {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(ACCESS_TOKEN_KEY) || "";
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
set(t: string) {
|
||||||
|
try {
|
||||||
|
if (t) localStorage.setItem(ACCESS_TOKEN_KEY, t);
|
||||||
|
else localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||||
|
} catch {
|
||||||
|
// localStorage 不可用(隐私模式等)时忽略,功能退化为每次都要输密码
|
||||||
|
}
|
||||||
|
listeners.forEach((l) => l());
|
||||||
|
},
|
||||||
|
clear() {
|
||||||
|
this.set("");
|
||||||
|
},
|
||||||
|
/** 令牌变化(登录/登出/401 清空)时触发;返回取消订阅函数 */
|
||||||
|
subscribe(fn: Listener): () => void {
|
||||||
|
listeners.add(fn);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(fn);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 附到 API 请求上的鉴权头 */
|
||||||
|
export function authHeaders(): Record<string, string> {
|
||||||
|
const t = auth.token;
|
||||||
|
return t ? { "X-Access-Token": t } : {};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user