272 lines
8.7 KiB
TypeScript
272 lines
8.7 KiB
TypeScript
// src/lib/favorites.ts — 收藏(服务端持久化 + 本地缓存兜底)
|
||
//
|
||
// 设计要点(应对"收藏量极大"场景):
|
||
// - 列表卡片打勾:只把「当前可见页的 id」发给后端 /me/favorites/checks,
|
||
// 问哪些已收藏。复杂度与「一屏多少张」成正比,与「用户收藏了多少」彻底解耦,
|
||
// 即便收藏 10 万也只传输约 24~50 个 id(常数级)。
|
||
// - 个人中心 Saved looks:走 /me/favorites 的「服务端分页」,每次只取一页(默认 24 条),
|
||
// DOM 永远轻量,不存在一次渲染上万张卡片的问题。
|
||
// - 收藏/取消:本地先乐观更新(UI 即时),再后台同步到 /api/v1/me/favorites(POST/DELETE),
|
||
// 即便网络失败也不回滚本地,保证前端不"卡死"。
|
||
// - 未登录:纯本地 localStorage,行为与旧版一致(按当前用户名隔离)。
|
||
//
|
||
// 两种收藏粒度(共用一张 favorites 表,后端以 kind 区分):
|
||
// - gallery:整篇图集。id = 图集 uid(r/s 前缀),type = runway / street。
|
||
// - image :单张图片。id = 图片自身的编码 id(i/j 前缀),type = runway_image / snap_image,
|
||
// 额外带 imageUrl(这一张图)与 parentUid(所属图集,供从收藏回跳原图集)。
|
||
// 两种粒度的 id 前缀互不冲突,因此可放在同一张表、同一套接口里。
|
||
//
|
||
// 对外函数:getFavorites / isFavorited / toggleFavorite / toggleImageFavorite / removeFavorite /
|
||
// fetchFavoritesPage / checkFavorited(列表与个人中心按需调用)。
|
||
|
||
import { getUser, getAccessToken, authJson } from "./api"
|
||
|
||
export type FavoriteType = "runway" | "street" | "runway_image" | "snap_image"
|
||
export type FavoriteKind = "gallery" | "image"
|
||
|
||
export interface Favorite {
|
||
id: string // 图集 uid(r/s 前缀)或图片编码 id(i/j 前缀),与后端 target_uid 对齐
|
||
type: FavoriteType
|
||
title: string
|
||
cover: string
|
||
brand: string
|
||
addedAt: number
|
||
kind?: FavoriteKind // 缺省视为 gallery(兼容升级前的本地数据)
|
||
imageUrl?: string // kind=image:这一张图
|
||
parentUid?: string // kind=image:所属图集 uid
|
||
}
|
||
|
||
// 后端 FavoriteItem 响应结构({data:{items:[...]}} 解包后)
|
||
interface ServerFavorite {
|
||
id: string
|
||
type: string
|
||
title?: string
|
||
cover?: string
|
||
brand?: string
|
||
added_at?: number
|
||
kind?: string
|
||
image_url?: string
|
||
parent_uid?: string
|
||
}
|
||
|
||
// ---------- 本地存储(兜底 / 离线) ----------
|
||
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())
|
||
}
|
||
|
||
// ---------- 归一化(后端字符串 → 联合类型) ----------
|
||
function normalizeType(t?: string): FavoriteType {
|
||
if (t === "street" || t === "runway_image" || t === "snap_image") return t
|
||
return "runway"
|
||
}
|
||
function normalizeKind(k?: string): FavoriteKind {
|
||
return k === "image" ? "image" : "gallery"
|
||
}
|
||
|
||
// ---------- 对外 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
|
||
}
|
||
|
||
/**
|
||
* 收藏 / 取消单张图片(比 toggleFavorite 更省事的语义化封装)。
|
||
* cover 直接设为该图 URL,个人中心网格即可原样渲染这张图;
|
||
* parentUid 记下所属图集,收藏卡片点击时回跳到原图集。
|
||
*/
|
||
export function toggleImageFavorite(args: {
|
||
imageId: string
|
||
type: "runway_image" | "snap_image"
|
||
imageUrl: string
|
||
parentUid: string
|
||
title?: string
|
||
brand?: string
|
||
}): boolean {
|
||
return toggleFavorite({
|
||
id: args.imageId,
|
||
type: args.type,
|
||
title: args.title || "",
|
||
cover: args.imageUrl,
|
||
brand: args.brand || "",
|
||
kind: "image",
|
||
imageUrl: args.imageUrl,
|
||
parentUid: args.parentUid,
|
||
})
|
||
}
|
||
|
||
export function removeFavorite(id: string): void {
|
||
setLocal(getLocal().filter((f) => f.id !== id))
|
||
if (serverMode()) {
|
||
authJson("DELETE", `/me/favorites/${encodeURIComponent(id)}`).catch(() => {
|
||
/* 失败不回滚本地 */
|
||
})
|
||
}
|
||
}
|
||
|
||
// ---------- 个人中心:服务端分页拉取 ----------
|
||
export interface FavPageResult {
|
||
items: Favorite[]
|
||
total: number
|
||
lastPage: number
|
||
}
|
||
|
||
/**
|
||
* 拉取收藏的某一页(默认 24 条/页)。
|
||
* kind 传 gallery / image 可只取对应粒度(服务端过滤),留空为全部。
|
||
* 返回 { items, total, lastPage },前端据此渲染当前页 + 翻页器。
|
||
* 未登录回落本地收藏(无分页)。
|
||
*/
|
||
export async function fetchFavoritesPage(
|
||
page = 1,
|
||
perPage = 24,
|
||
kind?: FavoriteKind,
|
||
): Promise<FavPageResult> {
|
||
if (!serverMode()) {
|
||
const local = getLocal()
|
||
const items = kind ? local.filter((f) => normalizeKind(f.kind) === kind) : local
|
||
return { items, total: items.length, lastPage: 1 }
|
||
}
|
||
try {
|
||
const qs = kind ? `&kind=${kind}` : ""
|
||
const r = await authJson<{ items: ServerFavorite[]; total: number; last_page: number }>(
|
||
"GET",
|
||
`/me/favorites?page=${page}&per_page=${perPage}${qs}`,
|
||
)
|
||
const items = (r?.items ?? []).map((it) => ({
|
||
id: String(it.id),
|
||
type: normalizeType(it.type),
|
||
title: it.title || "",
|
||
cover: it.cover || "",
|
||
brand: it.brand || "",
|
||
addedAt: Number(it.added_at) || 0,
|
||
kind: normalizeKind(it.kind),
|
||
imageUrl: it.image_url || "",
|
||
parentUid: it.parent_uid || "",
|
||
}))
|
||
return {
|
||
items,
|
||
total: Number(r?.total || 0),
|
||
lastPage: Number(r?.last_page || 1),
|
||
}
|
||
} catch {
|
||
const local = getLocal()
|
||
const items = kind ? local.filter((f) => normalizeKind(f.kind) === kind) : local
|
||
return { items, total: items.length, lastPage: 1 }
|
||
}
|
||
}
|
||
|
||
// ---------- 列表卡片 / 详情页:批量校验当前页收藏态 ----------
|
||
/**
|
||
* 传入当前可见的卡片 id 列表,返回「已收藏」的 id 集合。
|
||
* 后端只对这些 id 做 WHERE target_uid IN (...) 查询,复杂度与收藏总量无关。
|
||
* 图集 id 与图片 id 可混传(前缀不同,互不影响)。
|
||
*/
|
||
export async function checkFavorited(ids: string[]): Promise<Set<string>> {
|
||
if (!serverMode() || ids.length === 0) return new Set()
|
||
try {
|
||
const arr = await authJson<string[]>("POST", "/me/favorites/checks", { 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 = "",
|
||
extra?: { kind?: FavoriteKind; imageUrl?: string; parentUid?: string },
|
||
): void {
|
||
const list = getLocal()
|
||
if (list.some((f) => f.id === id)) return
|
||
list.unshift({
|
||
id,
|
||
type,
|
||
title,
|
||
cover,
|
||
brand,
|
||
addedAt: Date.now(),
|
||
kind: normalizeKind(extra?.kind),
|
||
imageUrl: extra?.imageUrl || "",
|
||
parentUid: extra?.parentUid || "",
|
||
})
|
||
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,
|
||
kind: normalizeKind(item.kind),
|
||
image_url: item.imageUrl || "",
|
||
parent_uid: item.parentUid || "",
|
||
}
|
||
if (newState) {
|
||
authJson("POST", "/me/favorites", payload).catch(() => {
|
||
/* 失败不回滚本地 */
|
||
})
|
||
} else {
|
||
authJson("DELETE", `/me/favorites/${encodeURIComponent(item.id)}`).catch(() => {
|
||
/* 失败不回滚本地 */
|
||
})
|
||
}
|
||
}
|