fix(web): 修复图片上传、自动保存竞态、登录竞态并统一失效公共缓存
FormData 不强设 Content-Type(P1-2),补 api 客户端回归测试; 自动保存成功仅失效列表与公共键,防详情 refetch 回滚输入(P1-4); login/logout 以请求序号丢弃迟到 /api/me 响应,401 统一回调跳登录(P2-15); 首页不硬编码 page_size,写操作后统一 invalidatePublic,TagPage 补 error 分支(P2-15)。
This commit is contained in:
+12
-2
@@ -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<T> {
|
||||
data?: T
|
||||
error?: { code: string; message: string; field?: string }
|
||||
@@ -36,7 +43,9 @@ export async function api<T>(
|
||||
): Promise<T> {
|
||||
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<T>(
|
||||
if (!res.ok) {
|
||||
const err = body?.error
|
||||
if (res.status === 401) {
|
||||
// 登录态失效:清内存态,统一由 AuthContext 跳登录页
|
||||
// 登录态失效:清内存态并通知全局回调(AuthProvider 跳登录页)
|
||||
setCsrfToken(null)
|
||||
unauthorizedHandler?.()
|
||||
}
|
||||
throw new ApiError(
|
||||
res.status,
|
||||
|
||||
+20
-3
@@ -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<AuthState | null>(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<Me>('/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)
|
||||
}, [])
|
||||
|
||||
@@ -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'] })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 : '保存失败'),
|
||||
})
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 ?? ''],
|
||||
|
||||
@@ -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<NoteList>(`/api/notes?tag=${encodeURIComponent(tag)}&page=1&page_size=100`),
|
||||
})
|
||||
@@ -24,6 +24,8 @@ export default function TagPage() {
|
||||
</header>
|
||||
{isLoading ? (
|
||||
<p className="py-12 text-center text-zinc-400">加载中…</p>
|
||||
) : error ? (
|
||||
<p className="py-12 text-center text-red-500">加载失败,请稍后重试</p>
|
||||
) : !data || data.items.length === 0 ? (
|
||||
<p className="py-12 text-center text-zinc-400">该标签下暂无公开笔记</p>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user