// src/lib/history.ts — 浏览历史(本地缓存兜底 + 服务端持久化) // // 职责:历史的本地缓存与去重、以及「服务端调用」的编排。 // 真正的 HTTP 端点(/me/history*)与单篇回查(getHistoryMeta)在 api.ts。 // // 语义(2026-09-02 用户拍板): // - 浏览历史 = 「点开过哪篇文章」,触发时机在走秀/街拍详情页(打开某篇即记一条)。 // 列表页进入不记。 // - 服务端只存 (user_id, target_uid, viewed_at):target_uid 为文章对外编码串 // (首字符区分类型:s 开头=街拍 / r 开头=走秀)。 // - 账户页 My History 展示:先取服务端 id 列表,再逐篇回查公开详情接口拿 title/cover/brand。 // - 未登录:纯本地 localStorage(按用户名隔离);登录态写本地 + 后台 POST(失败不回滚本地)。 import { getUser, getAccessToken } from "./auth" import { getHistoryMeta, addHistory, fetchHistory, deleteHistory, clearAllHistory, type ServerHistory, } from "./api" export interface HistoryData { title: string cover: string brand: string type: "street" | "runway" } export interface HistoryEntry { id: string // 对外编码串(服务端存储的原样值):r=xxx(走秀) / s=xxx(街拍)。删除时必须原样回传 viewedAt: number data?: HistoryData // 账户页展示时回查详情获得;本地/回查失败时缺失(卡片占位渲染) canonId?: string // 回查得到的「规范 id」(带类型前缀)。早期数据可能缺前缀(如 0008CZYI), // 同一篇文章因此会存成两行;链接统一用规范 id,保证只指向一个地址。 } // 去掉一个「类型前缀」后的串,作为「同一篇文章」的判据:r=走秀 / s=街拍 / i=走秀单图 / j=街拍单图。 // 早期历史数据没有类型前缀(0008CZYI),与新数据(r0008CZYI)指向同一篇,只按原样 id 去重会漏, // 故本地缓存按「去前缀」后的串判同。只剥已知前缀,避免误伤本身以字母开头的 hashid 本体。 function dedupeKey(id: string): string { const s = String(id || "") return /^[rsij]/.test(s) ? s.slice(1) : s } // ---------- 本地存储(兜底 / 离线) ---------- function storageKey(): string | null { const u = getUser() return u ? `fa_hist_${u.username}` : null } function getLocal(): HistoryEntry[] { const k = storageKey() if (!k) return [] try { const arr = JSON.parse(localStorage.getItem(k) || "[]") return Array.isArray(arr) ? arr : [] } catch { return [] } } function setLocal(list: HistoryEntry[]): void { const k = storageKey() if (!k) return try { localStorage.setItem(k, JSON.stringify(list)) } catch { /* 隐私模式等写入失败,忽略 */ } } // ---------- 服务端模式判定 ---------- function serverMode(): boolean { return !!(getUser() && getAccessToken()) } // ---------- 对外 API ---------- /** * 记录一次浏览:打开某篇文章详情时调用(走秀/街拍详情页)。 * 登录态:写入本地缓存 + 后台 POST 服务端(upsert 刷新时间,失败不回滚本地)。 * 未登录:仅写入本地缓存。 */ export function recordHistory(id: string): void { if (!id) return const entry: HistoryEntry = { id, viewedAt: Date.now() } // 本地去重:按「去类型前缀」后的串判同,避免同一篇文章因 id 形式不同(旧数据无前缀)留两条并排 const key = dedupeKey(id) const list = getLocal().filter((h) => dedupeKey(h.id) !== key) list.unshift(entry) if (list.length > 200) list.length = 200 setLocal(list) if (serverMode()) { addHistory(id).catch(() => { /* 失败不回滚本地 */ }) } } export interface HistPageResult { items: HistoryEntry[] total: number lastPage: number } // 按「规范 id」收敛同一篇文章,保留首次出现的那条(服务端已按 viewed_at 倒序 → 即最新的一条)。 // 用于消除「同一篇文章两行并排」:早期历史数据没有类型前缀,与带前缀的新数据指向同一篇。 function dedupeEntries(list: HistoryEntry[]): HistoryEntry[] { const seen = new Set() const out: HistoryEntry[] = [] for (const h of list) { const k = h.canonId || dedupeKey(h.id) if (seen.has(k)) continue seen.add(k) out.push(h) } return out } /** * 拉取浏览历史的一页(登录态走服务端分页 + 逐篇回查公开详情;未登录回落本地缓存)。 */ export async function fetchHistoryPage(page = 1, perPage = 24): Promise { if (!serverMode()) { const local = dedupeEntries(getLocal()) return { items: local, total: local.length, lastPage: 1 } } try { const r = await fetchHistory(page, perPage) const base: HistoryEntry[] = (r?.items ?? []).map((it: ServerHistory) => ({ id: String(it.id), viewedAt: Number(it.viewed_at) || 0, })) // 逐篇回查公开详情接口拿展示数据(每篇 1 次;单条失败容忍,卡片仍可点击) const items = await Promise.all( base.map(async (h) => { try { const meta = await getHistoryMeta(h.id) if (meta) { h.data = meta h.canonId = meta.id // 回查同时拿到规范 id(无前缀的旧 id 也能拿到带前缀的结果) } } catch { /* 单条回查失败:保留无 data,卡片走占位渲染 */ } return h }), ) // 收敛同一篇文章的历史行(旧数据缺前缀会与正常行并存);删除仍用原样 id,故不影响删除。 return { items: dedupeEntries(items), total: Number(r?.total || 0), lastPage: Number(r?.last_page || 1) } } catch { const local = dedupeEntries(getLocal()) return { items: local, total: local.length, lastPage: 1 } } } /** 删除一条历史(按文章 id)。 */ export function removeHistory(id: string): void { // 本地按「去类型前缀」判同删除,避免旧数据留下同篇的孪生行 const key = dedupeKey(id) setLocal(getLocal().filter((h) => dedupeKey(h.id) !== key)) if (serverMode()) { deleteHistory(id).catch(() => { /* 失败不回滚本地 */ }) } } /** 清空全部历史。 */ export function clearHistory(): void { setLocal([]) if (serverMode()) { clearAllHistory().catch(() => { /* 失败不回滚本地 */ }) } }