Files
frontend_v2/src/lib/history.ts
toom1996 4b409b5a29 update
2026-09-20 00:44:14 +08:00

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