update
Some checks failed
deploy / deploy (push) Has been cancelled

This commit is contained in:
toom1996
2026-09-02 21:50:30 +08:00
parent 50e5775dc8
commit 64af75c0a9
23 changed files with 1730 additions and 285 deletions

148
src/lib/history.ts Normal file
View File

@ -0,0 +1,148 @@
// src/lib/history.ts — 浏览历史(服务端持久化 + 本地缓存兜底)
//
// 2026-09-02 语义收敛(用户拍板):
// - 浏览历史 = 「点开过哪篇文章」,触发时机在 item 详情页(打开某篇走秀/街拍即记一条)。
// 列表页(Runway Looks / Street Snaps)进入不再记。
// - 后端 histories 表只存 (user_id, target_uid, viewed_at):target_uid 为文章对外编码串
// (r= 前缀走秀 / s= 前缀街拍,编码串自带类型,故不再有 kind/target_type 概念),
// 表已去掉 title/cover/brand 快照列。
// - 账户页 My History 展示:先取服务端 id 列表,再逐篇回查公开详情接口拿 title/cover/brand
// (用户选「只存 id、展示时回查」),历史卡片点击跳回文章详情。
// - 未登录:纯本地 localStorage(按用户名隔离);登录态写本地 + 后台 POST(失败不回滚本地)。
import { getUser, getAccessToken, authJson, getHistoryMeta } 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 // 账户页展示时回查详情获得;本地/回查失败时缺失(卡片占位渲染)
}
// 后端 HistoryItem 响应结构({data:{items:[...]}} 解包后,仅 id + viewed_at)
interface ServerHistory {
id: string
viewed_at?: number
}
// ---------- 本地存储(兜底 / 离线) ----------
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 ----------
/**
* 记录一次浏览:打开某篇文章详情时调用(见 item 详情页)。
* 登录态:写入本地缓存 + 后台 POST 服务端(upsert 刷新时间,失败不回滚本地)。
* 未登录:仅写入本地缓存。
*/
export function recordHistory(id: string): void {
if (!id) return
const entry: HistoryEntry = { id, viewedAt: Date.now() }
// 本地去重(同 id 只保留一条并提到最前)
const list = getLocal().filter((h) => h.id !== id)
list.unshift(entry)
if (list.length > 200) list.length = 200
setLocal(list)
if (serverMode()) {
authJson("POST", "/auth/history", { target_uid: id }).catch(() => {
/* 失败不回滚本地 */
})
}
}
export interface HistPageResult {
items: HistoryEntry[]
total: number
lastPage: number
}
/**
* 拉取浏览历史的一页(登录态走服务端分页 + 逐篇回查公开详情;未登录回落本地缓存)。
*/
export async function fetchHistoryPage(page = 1, perPage = 24): Promise<HistPageResult> {
if (!serverMode()) {
const local = getLocal()
return { items: local, total: local.length, lastPage: 1 }
}
try {
const qs = `page=${page}&per_page=${perPage}`
const r = await authJson<{ items: ServerHistory[]; total: number; last_page: number }>(
"GET",
`/auth/history?${qs}`,
)
const base: HistoryEntry[] = (r?.items ?? []).map((it) => ({
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
} catch {
/* 单条回查失败:保留无 data,卡片走占位渲染 */
}
return h
}),
)
return { items, total: Number(r?.total || 0), lastPage: Number(r?.last_page || 1) }
} catch {
const local = getLocal()
return { items: local, total: local.length, lastPage: 1 }
}
}
/** 删除一条历史(按文章 id)。 */
export function removeHistory(id: string): void {
setLocal(getLocal().filter((h) => h.id !== id))
if (serverMode()) {
authJson("DELETE", `/auth/history/${encodeURIComponent(id)}`).catch(() => {
/* 失败不回滚本地 */
})
}
}
/** 清空全部历史。 */
export function clearHistory(): void {
setLocal([])
if (serverMode()) {
authJson("DELETE", "/auth/history").catch(() => {
/* 失败不回滚本地 */
})
}
}