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