// 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): 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 { 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> { if (!serverMode() || ids.length === 0) return new Set() try { const arr = await authJson("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, 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(() => { /* 失败不回滚本地 */ }) } }