diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 1b0d392..8f5a5db 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -25,6 +25,13 @@ export function getCsrfToken(): string | null { return csrfToken } +// 401 统一回调:由 AuthProvider 注册(置未登录态 → RequireAuth 跳 /admin/login) +let unauthorizedHandler: (() => void) | null = null + +export function setUnauthorizedHandler(fn: (() => void) | null) { + unauthorizedHandler = fn +} + interface Envelope { data?: T error?: { code: string; message: string; field?: string } @@ -36,7 +43,9 @@ export async function api( ): Promise { const method = (options.method ?? 'GET').toUpperCase() const headers = new Headers(options.headers) - if (options.body && !headers.has('Content-Type')) { + // FormData 由浏览器自动生成含 boundary 的 multipart Content-Type,不可覆写 + // (曾因此破坏图片上传,评审 round2 P1-2) + if (options.body && !(options.body instanceof FormData) && !headers.has('Content-Type')) { headers.set('Content-Type', 'application/json') } if (method !== 'GET' && method !== 'HEAD' && csrfToken) { @@ -54,8 +63,9 @@ export async function api( if (!res.ok) { const err = body?.error if (res.status === 401) { - // 登录态失效:清内存态,统一由 AuthContext 跳登录页 + // 登录态失效:清内存态并通知全局回调(AuthProvider 跳登录页) setCsrfToken(null) + unauthorizedHandler?.() } throw new ApiError( res.status, diff --git a/web/src/lib/auth.tsx b/web/src/lib/auth.tsx index 2e816ac..03d7098 100644 --- a/web/src/lib/auth.tsx +++ b/web/src/lib/auth.tsx @@ -1,6 +1,6 @@ -import { createContext, useCallback, useContext, useEffect, useState } from 'react' +import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react' import type { ReactNode } from 'react' -import { api, setCsrfToken } from './api' +import { api, setCsrfToken, setUnauthorizedHandler } from './api' interface Me { authenticated: boolean @@ -22,10 +22,15 @@ const AuthContext = createContext(null) export function AuthProvider({ children }: { children: ReactNode }) { const [loading, setLoading] = useState(true) const [authenticated, setAuthenticated] = useState(false) + // 请求序号:login/logout 递增,使竞态中的迟到 /api/me 响应被丢弃 + // (防登录成功后被先前的匿名响应覆盖,评审 round2 P2-15) + const seqRef = useRef(0) const refresh = useCallback(async () => { + const seq = ++seqRef.current try { const me = await api('/api/me') + if (seqRef.current !== seq) return // 状态已被 login/logout 改变,丢弃迟到响应 setAuthenticated(me.authenticated) if (me.authenticated && me.csrf_token) { setCsrfToken(me.csrf_token) @@ -33,10 +38,11 @@ export function AuthProvider({ children }: { children: ReactNode }) { setCsrfToken(null) } } catch { + if (seqRef.current !== seq) return setAuthenticated(false) setCsrfToken(null) } finally { - setLoading(false) + if (seqRef.current === seq) setLoading(false) } }, []) @@ -44,12 +50,22 @@ export function AuthProvider({ children }: { children: ReactNode }) { void refresh() }, [refresh]) + // api 层 401 统一回调:置未登录态,RequireAuth 随即跳转 /admin/login + useEffect(() => { + setUnauthorizedHandler(() => { + setAuthenticated(false) + setCsrfToken(null) + }) + return () => setUnauthorizedHandler(null) + }, []) + const login = useCallback( async (password: string) => { const res = await api<{ csrf_token: string }>('/api/auth/login', { method: 'POST', body: JSON.stringify({ password }), }) + seqRef.current++ // 使在途的匿名 /api/me 响应失效 setCsrfToken(res.csrf_token) setAuthenticated(true) }, @@ -58,6 +74,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { const logout = useCallback(async () => { await api('/api/auth/logout', { method: 'POST' }) + seqRef.current++ setCsrfToken(null) setAuthenticated(false) }, []) diff --git a/web/src/lib/invalidate.ts b/web/src/lib/invalidate.ts new file mode 100644 index 0000000..a5aad8f --- /dev/null +++ b/web/src/lib/invalidate.ts @@ -0,0 +1,14 @@ +import type { QueryClient } from '@tanstack/react-query' + +/** + * 写操作成功后失效公共数据缓存(列表/标签页/详情/标签云)。 + * 公共查询 staleTime=30s 且不随窗口聚焦刷新,管理端改动若不失效, + * 返回公共页最长 30s 展示旧内容(评审 round2 P2-15)。 + * 前缀匹配:['notes'] → ['notes',page,tag],['note'] → ['note',slug]。 + */ +export function invalidatePublic(qc: QueryClient) { + void qc.invalidateQueries({ queryKey: ['notes'] }) + void qc.invalidateQueries({ queryKey: ['tag'] }) + void qc.invalidateQueries({ queryKey: ['note'] }) + void qc.invalidateQueries({ queryKey: ['tags'] }) +} diff --git a/web/src/pages/AdminEdit.tsx b/web/src/pages/AdminEdit.tsx index d10cff2..62455a6 100644 --- a/web/src/pages/AdminEdit.tsx +++ b/web/src/pages/AdminEdit.tsx @@ -4,6 +4,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { Bold, Italic, Link2, Code, Table, Eye, EyeOff } from 'lucide-react' import { api, ApiError } from '../lib/api' import type { NoteItem } from '../lib/api' +import { invalidatePublic } from '../lib/invalidate' import MarkdownViewer from '../components/MarkdownViewer' import { slugPreview } from '../lib/utils' @@ -110,7 +111,10 @@ export default function AdminEdit() { setSlugFieldError(null) setErrorMsg(null) dirtyRef.current = false - void qc.invalidateQueries({ queryKey: ['admin'] }) + // 仅失效列表与公共缓存;不失效当前编辑详情(['admin','note',id]), + // 防 refetch 回滚保存期间继续输入的内容并抑制下一轮自动保存(评审 round2 P1-4) + void qc.invalidateQueries({ queryKey: ['admin', 'notes'] }) + invalidatePublic(qc) if (isNew) { navigate(`/admin/notes/${saved.id}/edit`, { replace: true }) } diff --git a/web/src/pages/AdminList.tsx b/web/src/pages/AdminList.tsx index 80ff7a1..a324f9e 100644 --- a/web/src/pages/AdminList.tsx +++ b/web/src/pages/AdminList.tsx @@ -4,6 +4,7 @@ import { Pencil, Plus, Trash2 } from 'lucide-react' import { useState } from 'react' import { api } from '../lib/api' import type { NoteItem } from '../lib/api' +import { invalidatePublic } from '../lib/invalidate' import { useAuth } from '../lib/auth' import { formatDateTime } from '../lib/utils' @@ -26,6 +27,7 @@ export default function AdminList() { onSuccess: () => { setConfirmId(null) void qc.invalidateQueries({ queryKey: ['admin'] }) + invalidatePublic(qc) }, }) @@ -46,7 +48,10 @@ export default function AdminList() { }), }) }, - onSuccess: () => void qc.invalidateQueries({ queryKey: ['admin'] }), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ['admin'] }) + invalidatePublic(qc) + }, }) return ( diff --git a/web/src/pages/AdminSettings.tsx b/web/src/pages/AdminSettings.tsx index cd6dd81..4a8e9e6 100644 --- a/web/src/pages/AdminSettings.tsx +++ b/web/src/pages/AdminSettings.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { api, ApiError } from '../lib/api' import type { SiteSettings } from '../lib/api' +import { invalidatePublic } from '../lib/invalidate' export default function AdminSettings() { const qc = useQueryClient() @@ -32,6 +33,7 @@ export default function AdminSettings() { onSuccess: () => { setMsg('已保存') void qc.invalidateQueries({ queryKey: ['site'] }) + invalidatePublic(qc) // page_size 等设置影响公共列表 }, onError: (e) => setMsg(e instanceof ApiError ? e.message : '保存失败'), }) diff --git a/web/src/pages/AdminTrash.tsx b/web/src/pages/AdminTrash.tsx index 1cbe9e8..54323e1 100644 --- a/web/src/pages/AdminTrash.tsx +++ b/web/src/pages/AdminTrash.tsx @@ -2,6 +2,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { RotateCcw } from 'lucide-react' import { api } from '../lib/api' import type { NoteItem } from '../lib/api' +import { invalidatePublic } from '../lib/invalidate' import { formatDateTime } from '../lib/utils' export default function AdminTrash() { @@ -13,7 +14,10 @@ export default function AdminTrash() { const restore = useMutation({ mutationFn: (id: number) => api(`/api/admin/trash/${id}/restore`, { method: 'POST' }), - onSuccess: () => void qc.invalidateQueries({ queryKey: ['admin'] }), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ['admin'] }) + invalidatePublic(qc) + }, }) return ( diff --git a/web/src/pages/Home.tsx b/web/src/pages/Home.tsx index 8988a1f..3b1c643 100644 --- a/web/src/pages/Home.tsx +++ b/web/src/pages/Home.tsx @@ -7,7 +7,8 @@ import type { NoteItem, NoteList, TagCount } from '../lib/api' import { formatDate } from '../lib/utils' function useNotes(page: number, tag?: string) { - const qs = new URLSearchParams({ page: String(page), page_size: '10' }) + // 不传 page_size:服务端默认值即站点设置(管理员可改),首页随设置生效 + const qs = new URLSearchParams({ page: String(page) }) if (tag) qs.set('tag', tag) return useQuery({ queryKey: ['notes', page, tag ?? ''], diff --git a/web/src/pages/TagPage.tsx b/web/src/pages/TagPage.tsx index b9241b7..a493944 100644 --- a/web/src/pages/TagPage.tsx +++ b/web/src/pages/TagPage.tsx @@ -6,7 +6,7 @@ import { formatDate } from '../lib/utils' export default function TagPage() { const { tag = '' } = useParams() - const { data, isLoading } = useQuery({ + const { data, isLoading, error } = useQuery({ queryKey: ['tag', tag], queryFn: () => api(`/api/notes?tag=${encodeURIComponent(tag)}&page=1&page_size=100`), }) @@ -24,6 +24,8 @@ export default function TagPage() { {isLoading ? (

加载中…

+ ) : error ? ( +

加载失败,请稍后重试

) : !data || data.items.length === 0 ? (

该标签下暂无公开笔记

) : ( diff --git a/web/tests/api.test.ts b/web/tests/api.test.ts new file mode 100644 index 0000000..92ef721 --- /dev/null +++ b/web/tests/api.test.ts @@ -0,0 +1,67 @@ +// api 客户端单测(评审 round2 P1-2 / P2-15 的回归守护): +// 1. FormData 请求不得强设 Content-Type(multipart boundary 由浏览器生成); +// 2. 401 触发全局未授权回调(AuthProvider 据此跳登录页)。 +import { describe, it, expect, vi, afterEach } from 'vitest' +import { api, ApiError, setUnauthorizedHandler } from '../src/lib/api' + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }) +} + +describe('api 客户端', () => { + afterEach(() => { + vi.unstubAllGlobals() + setUnauthorizedHandler(null) + }) + + it('FormData 请求不强设 Content-Type(保留浏览器 multipart boundary)', async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse(201, { data: { id: 1, url: '/api/images/1' } })) + vi.stubGlobal('fetch', fetchMock) + + const form = new FormData() + form.append('file', new Blob(['x'], { type: 'image/png' }), 'a.png') + await api<{ id: number }>('/api/admin/images', { method: 'POST', body: form }) + + expect(fetchMock).toHaveBeenCalledOnce() + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit] + const headers = init.headers as Headers + expect(headers.has('Content-Type')).toBe(false) + }) + + it('JSON 请求默认补 application/json 并携带 CSRF 头', async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse(200, { data: { ok: true } })) + vi.stubGlobal('fetch', fetchMock) + const { setCsrfToken } = await import('../src/lib/api') + setCsrfToken('token-abc') + + await api('/api/admin/notes/1', { method: 'PUT', body: JSON.stringify({ title: 'x' }) }) + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit] + const headers = init.headers as Headers + expect(headers.get('Content-Type')).toBe('application/json') + expect(headers.get('X-CSRF-Token')).toBe('token-abc') + setCsrfToken(null) + }) + + it('401 触发全局未授权回调并抛出 ApiError', async () => { + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse(401, { error: { code: 'unauthorized', message: '未登录或会话已过期' } }), + ) + vi.stubGlobal('fetch', fetchMock) + const handler = vi.fn() + setUnauthorizedHandler(handler) + + try { + await api('/api/admin/notes', { method: 'DELETE' }) + expect.unreachable('应抛出 ApiError') + } catch (e) { + expect(e).toBeInstanceOf(ApiError) + expect((e as ApiError).status).toBe(401) + expect((e as ApiError).code).toBe('unauthorized') + } + expect(handler).toHaveBeenCalledOnce() + }) +})