update
Some checks failed
deploy / deploy (push) Has been cancelled

This commit is contained in:
toom1996
2026-09-02 21:50:30 +08:00
parent 50e5775dc8
commit 64af75c0a9
23 changed files with 1730 additions and 285 deletions

192
src/lib/favorites.ts Normal file
View File

@ -0,0 +1,192 @@
// src/lib/favorites.ts — 收藏(服务端持久化 + 本地缓存兜底)
//
// 设计要点(应对"收藏量极大"场景):
// - 列表卡片打勾:只把「当前可见页的 id」发给后端 /auth/favorites/check,
// 问哪些已收藏。复杂度与「一屏多少张」成正比,与「用户收藏了多少」彻底解耦,
// 即便收藏 10 万也只传输约 24~50 个 id(常数级)。
// - 个人中心 Saved looks:走 /auth/favorites 的「服务端分页」,每次只取一页(默认 24 条),
// DOM 永远轻量,不存在一次渲染上万张卡片的问题。
// - 收藏/取消:本地先乐观更新(UI 即时),再后台同步到 /api/v1/auth/favorites(POST/DELETE),
// 即便网络失败也不回滚本地,保证前端不"卡死"。
// - 未登录:纯本地 localStorage,行为与旧版一致(按当前用户名隔离)。
//
// 对外函数:getFavorites / isFavorited / toggleFavorite / removeFavorite /
// fetchFavoritesPage / checkFavorited(列表与个人中心按需调用)。
import { getUser, getAccessToken, authJson } from "./api"
export type FavoriteType = "runway" | "street"
export interface Favorite {
id: string // 走秀/街拍的对外编码 id(带 r=/s= 前缀),与后端 target_uid 对齐
type: FavoriteType
title: string
cover: string
brand: string
addedAt: number
}
// 后端 FavoriteItem 响应结构({data:{items:[...]}} 解包后)
interface ServerFavorite {
id: string
type: string
title?: string
cover?: string
brand?: string
added_at?: number
}
// ---------- 本地存储(兜底 / 离线) ----------
function storageKey(): string | null {
const u = getUser()
return u ? `fa_fav_${u.username}` : null
}
function getLocal(): Favorite[] {
const k = storageKey()
if (!k) return []
try {
const arr = JSON.parse(localStorage.getItem(k) || "[]")
return Array.isArray(arr) ? arr : []
} catch {
return []
}
}
function setLocal(list: Favorite[]): void {
const k = storageKey()
if (!k) return
try {
localStorage.setItem(k, JSON.stringify(list))
} catch {
/* 隐私模式等写入失败,忽略 */
}
}
// ---------- 服务端模式判定 ----------
function serverMode(): boolean {
return !!(getUser() && getAccessToken())
}
// ---------- 对外 API ----------
// 本地收藏(兜底 / 离线态)。列表卡片打勾时用它做"即时填充"。
export function getFavorites(): Favorite[] {
return getLocal()
}
export function isFavorited(id: string): boolean {
return getLocal().some((f) => f.id === id)
}
/** 切换收藏状态:已收藏则移除返回 false,未收藏则新增返回 true(同步、即时)。 */
export function toggleFavorite(item: Omit<Favorite, "addedAt">): boolean {
const list = getLocal()
const i = list.findIndex((f) => f.id === item.id)
let newState: boolean
if (i >= 0) {
list.splice(i, 1)
newState = false
} else {
list.unshift({ ...item, addedAt: Date.now() })
newState = true
}
setLocal(list)
// 登录态:后台同步到服务端(失败不回滚本地,保证 UI 不卡)
if (serverMode()) {
syncServer(item, newState)
}
return newState
}
export function removeFavorite(id: string): void {
setLocal(getLocal().filter((f) => f.id !== id))
if (serverMode()) {
authJson("DELETE", `/auth/favorites/${encodeURIComponent(id)}`).catch(() => {
/* 失败不回滚本地 */
})
}
}
// ---------- 个人中心:服务端分页拉取 ----------
export interface FavPageResult {
items: Favorite[]
total: number
lastPage: number
}
/**
* 拉取收藏的某一页(默认 24 条/页)。
* 返回 { items, total, lastPage },前端据此渲染当前页 + 翻页器。
* 未登录回落本地收藏(无分页)。
*/
export async function fetchFavoritesPage(page = 1, perPage = 24): Promise<FavPageResult> {
if (!serverMode()) {
const local = getLocal()
return { items: local, total: local.length, lastPage: 1 }
}
try {
const r = await authJson<{ items: ServerFavorite[]; total: number; last_page: number }>(
"GET",
`/auth/favorites?page=${page}&per_page=${perPage}`,
)
const items = (r?.items ?? []).map((it) => ({
id: String(it.id),
type: it.type === "street" ? "street" : "runway",
title: it.title || "",
cover: it.cover || "",
brand: it.brand || "",
addedAt: Number(it.added_at) || 0,
}))
return {
items,
total: Number(r?.total || 0),
lastPage: Number(r?.last_page || 1),
}
} catch {
const local = getLocal()
return { items: local, total: local.length, lastPage: 1 }
}
}
// ---------- 列表卡片:批量校验当前页收藏态 ----------
/**
* 传入当前可见的卡片 id 列表,返回「已收藏」的 id 集合。
* 后端只对这些 id 做 WHERE target_uid IN (...) 查询,复杂度与收藏总量无关。
*/
export async function checkFavorited(ids: string[]): Promise<Set<string>> {
if (!serverMode() || ids.length === 0) return new Set()
try {
const arr = await authJson<string[]>("POST", "/auth/favorites/check", { ids })
return new Set(Array.isArray(arr) ? arr.map(String) : [])
} catch {
return new Set()
}
}
/** 服务端校验命中时把该篇补进本地收藏(详情页跨设备校正用;不触发 POST——服务端已有该收藏)。 */
export function hydrateFavorited(id: string, type: FavoriteType, title = "", cover = "", brand = ""): void {
const list = getLocal()
if (list.some((f) => f.id === id)) return
list.unshift({ id, type, title, cover, brand, addedAt: Date.now() })
setLocal(list)
}
// ---------- 内部:后台同步到服务端 ----------
function syncServer(item: Omit<Favorite, "addedAt">, newState: boolean): void {
const payload = {
target_type: item.type,
target_uid: item.id,
title: item.title,
cover: item.cover,
brand: item.brand,
}
if (newState) {
authJson("POST", "/auth/favorites", payload).catch(() => {
/* 失败不回滚本地 */
})
} else {
authJson("DELETE", `/auth/favorites/${encodeURIComponent(item.id)}`).catch(() => {
/* 失败不回滚本地 */
})
}
}