Files
frontend_v2/src/lib/favorites.ts
toom1996 19fb597bb4
Some checks failed
deploy / deploy (push) Has been cancelled
update
2026-09-06 00:50:52 +08:00

272 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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(() => {
/* 失败不回滚本地 */
})
}
}