192
src/lib/favorites.ts
Normal file
192
src/lib/favorites.ts
Normal 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(() => {
|
||||
/* 失败不回滚本地 */
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user