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

View File

@ -220,7 +220,8 @@ const seasonOptions: { value: string; label: string }[] = [
<!-- Card List -->
<template x-for="it in items" :key="it.id">
<a class="showscard" :href="itemHref(it)" :aria-label="`${it.brand_name} ${it.title}`">
<div class="showscard-wrap relative group">
<a class="showscard" :href="itemHref(it)" :aria-label="`${it.brand_name} ${it.title}`">
<div class="showscard-left">
<div class="showscard-img" :class="{ 'is-loaded': it._imgLoaded }">
<span class="showsspin" aria-hidden="true" x-html="SPIN_SVG"></span>
@ -241,7 +242,11 @@ const seasonOptions: { value: string; label: string }[] = [
</template>
</div>
</div>
</a>
</a>
<button type="button" class="fav-toggle" :class="{ 'is-on': favState(it.id) }" @click.prevent="toggleFav(it)" :aria-pressed="favState(it.id)" :title="favState(it.id) ? 'Saved' : 'Save'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M12 21C12 21 4 14.5 4 8.8C4 6.1 6.1 4 8.7 4C10.3 4 11.5 4.8 12 6C12.5 4.8 13.7 4 15.3 4C17.9 4 20 6.1 20 8.8C20 14.5 12 21 12 21Z"/></svg>
</button>
</div>
</template>
</div>
@ -298,6 +303,8 @@ const seasonOptions: { value: string; label: string }[] = [
import { getArticles, getBrands, toAbs, getUser, login, type ArticleItem, type BrandEntry } from "@/lib/api"
import { clientHref, ROUTES } from "@/lib/routes"
import { SPIN_SVG, makeCardSlots, buildPageList } from "@/lib/looks-grid"
import { toggleFavorite, checkFavorited, getFavorites } from "@/lib/favorites"
document.addEventListener("alpine:init", () => {
Alpine.data("showsPage", () => {
@ -334,6 +341,9 @@ const seasonOptions: { value: string; label: string }[] = [
lastPage: 1,
reqToken: 0,
// 收藏态:响应式 id 映射(卡片爱心从此读取;按当前页服务端校验,与收藏总量解耦)
favMap: {} as Record<string, boolean>,
// 登录门禁弹窗状态(硬门禁:未登录点筛选/翻页时弹出内联登录表单)
showLoginModal: false,
loginModalAccount: "",
@ -348,7 +358,9 @@ const seasonOptions: { value: string; label: string }[] = [
const ALLOWED = ['rtw', 'menswear', 'womenswear', 'couture', 'resort', 'pre_fall', 'bridal', 'swimwear']
if (ct && ALLOWED.includes(ct)) this.selectedCt = ct
// 首页(含筛选后的首页)公开:未登录也直接加载首屏,不弹登录框
// 收藏态在 loadPage 内按当前页校正(markFavs),与收藏总量解耦,10万也常数级
this.loadPage(1)
// 浏览历史:改为「点开文章详情」时记录(见 item 详情页),列表页不再记。
},
// 骨架屏数量与首屏每页条数一致(后端固定 ArticleFixedSize=24),消除加载抖动
@ -533,6 +545,7 @@ const seasonOptions: { value: string; label: string }[] = [
this.lastPage = json.last_page
this.page = page
this.items = rows
this.markFavs() // 当前页可见 id 走服务端校验收藏态(与收藏总量解耦)
} catch {
if (myToken !== this.reqToken) return
this.items = []
@ -577,7 +590,40 @@ const seasonOptions: { value: string; label: string }[] = [
return makeCardSlots(it.gallery || [], 5, {
moreLabel: () => (total > 30 ? "30+" : `${total}+`),
})
}
},
// ===== 收藏(图片收藏入口)=====
// 按当前页可见 id 拉服务端收藏态:本地先即时填充,登录态异步用服务端校正。
// 复杂度仅与「一屏多少张」相关,与用户收藏总量解耦,即便 10 万收藏也常数级。
async markFavs() {
const ids = this.items.map((i: any) => String(i.id))
if (!ids.length) return
// 本地即时填充(同设备秒显)
const m: Record<string, boolean> = { ...this.favMap }
for (const f of getFavorites()) {
const id = String(f.id)
if (!(id in m)) m[id] = true
}
this.favMap = m
if (!getUser()) return
try {
const set = await checkFavorited(ids)
const m2: Record<string, boolean> = { ...this.favMap }
for (const id of ids) m2[id] = set.has(id)
this.favMap = m2
} catch {
/* 失败维持本地态 */
}
},
favState(id: string) { return !!this.favMap[String(id)] },
toggleFav(it: any) {
if (!getUser()) { this.showLoginModal = true; return false }
const id = String(it.id)
const now = !this.favState(id)
toggleFavorite({ id, type: 'runway', title: it.title || '', cover: it.cover || '', brand: it.brand_name || '' })
this.favMap = { ...this.favMap, [id]: now }
return now
},
}
})
})

View File

@ -180,7 +180,8 @@ const cityOptions: { value: string; label: string }[] = [
<template x-if="!loading && items.length > 0">
<div class="snapsgrid">
<template x-for="it in items" :key="it.id">
<a class="snapscard" :href="itemHref(it)" :aria-label="cardTitle(it)">
<div class="showscard-wrap relative group">
<a class="snapscard" :href="itemHref(it)" :aria-label="cardTitle(it)">
<div class="snapscard-left">
<div class="snapscard-img" :class="{ 'is-loaded': it._imgLoaded }">
<span class="snapsspin" aria-hidden="true" x-html="SPIN_SVG"></span>
@ -201,7 +202,11 @@ const cityOptions: { value: string; label: string }[] = [
</template>
</div>
</div>
</a>
</a>
<button type="button" class="fav-toggle" :class="{ 'is-on': favState(it.id) }" @click.prevent="toggleFav(it)" :aria-pressed="favState(it.id)" :title="favState(it.id) ? 'Saved' : 'Save'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M12 21C12 21 4 14.5 4 8.8C4 6.1 6.1 4 8.7 4C10.3 4 11.5 4.8 12 6C12.5 4.8 13.7 4 15.3 4C17.9 4 20 6.1 20 8.8C20 14.5 12 21 12 21Z"/></svg>
</button>
</div>
</template>
</div>
</template>
@ -256,6 +261,8 @@ const cityOptions: { value: string; label: string }[] = [
import { getStreetSnaps, toAbs, getUser, login, type StreetSnapItem } from "@/lib/api"
import { clientHref, ROUTES } from "@/lib/routes"
import { SPIN_SVG, makeCardSlots, buildPageList } from "@/lib/looks-grid"
import { toggleFavorite, checkFavorited, getFavorites } from "@/lib/favorites"
const DEFAULT_SORT = "image_count"
@ -290,6 +297,9 @@ const cityOptions: { value: string; label: string }[] = [
loginModalError: "",
loginModalLoading: false,
// 收藏态:响应式 id 映射(卡片爱心从此读取;按当前页服务端校验,与收藏总量解耦)
favMap: {} as Record<string, boolean>,
init() {
this.locale = this.$el.dataset.locale || 'en'
// 支持从 header 下拉深链 ?city=xxx 直接进入对应地区(与侧栏 City 筛选一致)
@ -297,7 +307,9 @@ const cityOptions: { value: string; label: string }[] = [
const allowed = (this.cityOptions || []).map((c: any) => c.value)
if (city && allowed.includes(city)) this.selectedCity = city
// 首页公开:未登录也直接加载首屏,不弹登录框
// 收藏态在 loadPage 内按当前页校正(markFavs),与收藏总量解耦,10万也常数级
this.loadPage(1)
// 浏览历史:改为「点开文章详情」时记录(见 item 详情页),列表页不再记。
},
// ===== 侧栏筛选 =====
@ -390,6 +402,7 @@ const cityOptions: { value: string; label: string }[] = [
this.lastPage = json.last_page
this.page = page
this.items = rows
this.markFavs() // 当前页可见 id 走服务端校验收藏态(与收藏总量解耦)
} catch {
if (myToken !== this.reqToken) return
this.items = []
@ -426,6 +439,39 @@ const cityOptions: { value: string; label: string }[] = [
cardTitle(it: StreetSnapItem): string {
return (it.title || "").replace(/\t/g, " · ")
},
// ===== 收藏(图片收藏入口)=====
// 按当前页可见 id 拉服务端收藏态:本地先即时填充,登录态异步用服务端校正。
// 复杂度仅与「一屏多少张」相关,与用户收藏总量解耦,即便 10 万收藏也常数级。
async markFavs() {
const ids = this.items.map((i: any) => String(i.id))
if (!ids.length) return
// 本地即时填充(同设备秒显)
const m: Record<string, boolean> = { ...this.favMap }
for (const f of getFavorites()) {
const id = String(f.id)
if (!(id in m)) m[id] = true
}
this.favMap = m
if (!getUser()) return
try {
const set = await checkFavorited(ids)
const m2: Record<string, boolean> = { ...this.favMap }
for (const id of ids) m2[id] = set.has(id)
this.favMap = m2
} catch {
/* 失败维持本地态 */
}
},
favState(id: string) { return !!this.favMap[String(id)] },
toggleFav(it: any) {
if (!getUser()) { this.showLoginModal = true; return false }
const id = String(it.id)
const now = !this.favState(id)
toggleFavorite({ id, type: 'street', title: it.title || '', cover: it.cover || '', brand: (it as any).brand || '' })
this.favMap = { ...this.favMap, [id]: now }
return now
},
}
})
})

View File

@ -58,6 +58,55 @@ export const dict: Record<string, TranslationEntry> = {
'my history': { cn: '浏览历史' },
'coming soon': { cn: '敬请期待' },
// ── 账户中心 / Account Center(企业级个人中心)──
'overview': { cn: '概览' },
'sessions & security': { cn: '会话与安全' },
'settings': { cn: '设置' },
'saved looks': { cn: '收藏造型' },
'active sessions': { cn: '活跃会话' },
'member since': { cn: '加入于' },
'role': { cn: '角色' },
'internal member': { cn: '内部成员' },
'items': { cn: '条' },
'account overview': { cn: '账户概览' },
'account details': { cn: '账户详情' },
'username': { cn: '用户名' },
'edit profile': { cn: '编辑资料' },
'view archive': { cn: '浏览档案' },
'no collections yet': { cn: '暂无收藏内容' },
'no history yet': { cn: '暂无浏览记录' },
'browse the archive': { cn: '去浏览档案' },
'current session': { cn: '当前会话' },
'this device': { cn: '此设备' },
'last active': { cn: '最近活跃' },
'sign out all devices': { cn: '退出所有设备' },
'single session note': { cn: '为保障账号安全,同一账号仅允许在一处登录,新登录会顶替旧会话。' },
'preferences': { cn: '偏好设置' },
'appearance': { cn: '外观' },
'notifications': { cn: '通知' },
'soon': { cn: '即将推出' },
'quick stats': { cn: '数据概览' },
'now': { cn: '刚刚' },
'member profile': { cn: '会员资料' },
'save': { cn: '收藏' },
'saved': { cn: '已收藏' },
'remove': { cn: '移除' },
'cancel': { cn: '取消' },
'save changes': { cn: '保存修改' },
'display name': { cn: '昵称' },
'bio': { cn: '简介' },
'clear history': { cn: '清空历史' },
'no saved looks yet': { cn: '暂无收藏造型' },
'no history yet': { cn: '暂无浏览记录' },
'all': { cn: '全部' },
'image': { cn: '图片' },
'gallery': { cn: '图集' },
'viewed gallery': { cn: '浏览的图集' },
'light': { cn: '浅色' },
'dark': { cn: '深色' },
'on': { cn: '开' },
'off': { cn: '关' },
// ── 控件 / Controls ──
'back to index': { cn: '返回首页' },

View File

@ -207,8 +207,8 @@ async function request<T>(path: string, params?: URLSearchParams): Promise<T> {
// 接口:GET /api/public/runway-looks/:id
// 走秀详情(含完整图片集)。base 默认走 ssrBase()(运行期按需渲染取数),也可显式传入。
// locale 显式传入时优先(按需渲染 SSR 用它避免并发串 locale),否则走 currentLocale()。
// 仅本模块内部使用(getSsrArticle 在其之上包一层),不对外导出。
async function fetchArticleById(
// 对外导出:详情取数 / 个人中心逐篇回查(浏览器端按需取 title/cover 渲染历史卡片)。
export async function fetchArticleById(
id: number | string,
base: string = ssrBase(),
locale?: string
@ -383,6 +383,31 @@ async function authedFetch(url: string, opts: RequestInit = {}): Promise<Respons
return res
}
// 带鉴权的 JSON 请求:自动附 Bearer、遇 401 静默刷新一次后重试,
// 成功返回响应体中的 data 字段;非 2xx 抛出(含后端 message)。
// 收藏/个人中心等需要登录的写操作统一走这里。
export async function authJson<T = any>(method: string, path: string, body?: unknown): Promise<T> {
const url = `${BASE_API_URL}/api/v1${path}`
const headers: Record<string, string> = { "Content-Type": "application/json" }
const res = await authedFetch(url, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
})
if (!res.ok) {
let msg = `API ${res.status} ${method} ${path}`
try {
const j = await res.json()
if (j?.message) msg = String(j.message)
} catch {
/* 响应体非 JSON,沿用默认错误信息 */
}
throw new Error(msg)
}
const json: any = await res.json()
return (json?.data ?? null) as T
}
export async function fetchMe(): Promise<AuthUser | null> {
if (typeof localStorage === "undefined") return null
if (!getAccessToken()) return null
@ -417,6 +442,10 @@ export async function login(input: { account: string; password: string }): Promi
const user: any = json?.user
if (!access || !refresh || !user) throw new Error("login failed")
saveSession(access, refresh, user)
// 登录成功广播:列表页在未登录时进页没记图集,登录后由监听补记(与「进页面就记」一致)。
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("auth:login"))
}
return user as AuthUser
}
@ -454,6 +483,40 @@ export async function logoutAll(): Promise<void> {
clearSession()
}
// ===================== 浏览历史:单篇详情回查(账户页 My History 渲染用) =====================
// 历史只存文章对外编码串(r= 走秀 / s= 街拍),展示时逐篇回查公开详情接口取 title/cover/brand。
// 走 request():自动带 locale 与前端签名,登录态附带 Bearer。
export interface HistoryMeta {
title: string
cover: string
brand: string
type: "street" | "runway"
}
// 单篇回查:编码串自带类型(s= 前缀街拍,其余走秀)。失败返回 null,由调用方容忍。
export async function getHistoryMeta(id: string): Promise<HistoryMeta | null> {
const sid = String(id)
const type = sid.startsWith("s=") ? ("street" as const) : ("runway" as const)
const path =
type === "street"
? `/public/street-snaps/${encodeURIComponent(sid)}`
: `/public/runway-looks/${encodeURIComponent(sid)}`
try {
const j = await request<{ data?: any }>(path)
const d = j?.data
if (!d) return null
return {
title: (d.title || "").toString(),
cover: (d.cover || "").toString(),
brand: (d.brand_name || d.brand || "").toString(),
type,
}
} catch {
return null
}
}
// ===================== SSG 构建专用(/api/ssg/*,仅 Node 构建期) =====================
function ssgEnv(name: string): string | undefined {
@ -564,8 +627,8 @@ export async function getStreetSnaps(q: {
// 接口:GET /api/public/street-snaps/:id
// 街拍详情(含完整图片集)。base 默认走 ssrBase()(运行期按需渲染取数)。
// 仅本模块内部使用(getSsrStreetSnap 在其之上包一层),不对外导出。
async function fetchStreetSnapById(
// 对外导出:详情取数 / 个人中心逐篇回查。
export async function fetchStreetSnapById(
id: number | string,
base: string = ssrBase(),
locale?: string

192
src/lib/favorites.ts Normal file
View File

@ -0,0 +1,192 @@
// src/lib/favorites.ts — 收藏(服务端持久化 + 本地缓存兜底)
//
// 设计要点(应对"收藏量极大"场景):
// - 列表卡片打勾:只把「当前可见页的 id」发给后端 /auth/favorites/check,
// 问哪些已收藏。复杂度与「一屏多少张」成正比,与「用户收藏了多少」彻底解耦,
// 即便收藏 10 万也只传输约 24~50 个 id(常数级)。
// - 个人中心 Saved looks:走 /auth/favorites 的「服务端分页」,每次只取一页(默认 24 条),
// DOM 永远轻量,不存在一次渲染上万张卡片的问题。
// - 收藏/取消:本地先乐观更新(UI 即时),再后台同步到 /api/v1/auth/favorites(POST/DELETE),
// 即便网络失败也不回滚本地,保证前端不"卡死"。
// - 未登录:纯本地 localStorage,行为与旧版一致(按当前用户名隔离)。
//
// 对外函数:getFavorites / isFavorited / toggleFavorite / removeFavorite /
// fetchFavoritesPage / checkFavorited(列表与个人中心按需调用)。
import { getUser, getAccessToken, authJson } from "./api"
export type FavoriteType = "runway" | "street"
export interface Favorite {
id: string // 走秀/街拍的对外编码 id(带 r=/s= 前缀),与后端 target_uid 对齐
type: FavoriteType
title: string
cover: string
brand: string
addedAt: number
}
// 后端 FavoriteItem 响应结构({data:{items:[...]}} 解包后)
interface ServerFavorite {
id: string
type: string
title?: string
cover?: string
brand?: string
added_at?: number
}
// ---------- 本地存储(兜底 / 离线) ----------
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())
}
// ---------- 对外 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
}
export function removeFavorite(id: string): void {
setLocal(getLocal().filter((f) => f.id !== id))
if (serverMode()) {
authJson("DELETE", `/auth/favorites/${encodeURIComponent(id)}`).catch(() => {
/* 失败不回滚本地 */
})
}
}
// ---------- 个人中心:服务端分页拉取 ----------
export interface FavPageResult {
items: Favorite[]
total: number
lastPage: number
}
/**
* 拉取收藏的某一页(默认 24 条/页)。
* 返回 { items, total, lastPage },前端据此渲染当前页 + 翻页器。
* 未登录回落本地收藏(无分页)。
*/
export async function fetchFavoritesPage(page = 1, perPage = 24): Promise<FavPageResult> {
if (!serverMode()) {
const local = getLocal()
return { items: local, total: local.length, lastPage: 1 }
}
try {
const r = await authJson<{ items: ServerFavorite[]; total: number; last_page: number }>(
"GET",
`/auth/favorites?page=${page}&per_page=${perPage}`,
)
const items = (r?.items ?? []).map((it) => ({
id: String(it.id),
type: it.type === "street" ? "street" : "runway",
title: it.title || "",
cover: it.cover || "",
brand: it.brand || "",
addedAt: Number(it.added_at) || 0,
}))
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 列表,返回「已收藏」的 id 集合。
* 后端只对这些 id 做 WHERE target_uid IN (...) 查询,复杂度与收藏总量无关。
*/
export async function checkFavorited(ids: string[]): Promise<Set<string>> {
if (!serverMode() || ids.length === 0) return new Set()
try {
const arr = await authJson<string[]>("POST", "/auth/favorites/check", { 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 = ""): void {
const list = getLocal()
if (list.some((f) => f.id === id)) return
list.unshift({ id, type, title, cover, brand, addedAt: Date.now() })
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,
}
if (newState) {
authJson("POST", "/auth/favorites", payload).catch(() => {
/* 失败不回滚本地 */
})
} else {
authJson("DELETE", `/auth/favorites/${encodeURIComponent(item.id)}`).catch(() => {
/* 失败不回滚本地 */
})
}
}

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

34
src/lib/profile.ts Normal file
View File

@ -0,0 +1,34 @@
// src/lib/profile.ts — 个人资料(昵称/简介,客户端本地存储版)
//
// 后端 users 表当前只有 username/email,没有展示用的 display_name / bio。
// 在后端补齐字段前,这里先把「编辑资料」做成真实可用的本地版(按账号隔离),
// 后续接 /api/v1/me/profile 时把 saveProfile 换成请求即可。
import { getUser } from "./api"
export interface Profile {
displayName?: string
bio?: string
}
function storageKey(): string | null {
const u = getUser()
return u ? `fa_profile_${u.username}` : null
}
export function getProfile(): Profile {
const k = storageKey()
if (!k) return {}
try {
const obj = JSON.parse(localStorage.getItem(k) || "{}")
return obj && typeof obj === "object" ? obj : {}
} catch {
return {}
}
}
export function saveProfile(p: Profile): void {
const k = storageKey()
if (!k) return
localStorage.setItem(k, JSON.stringify(p))
}

View File

@ -1,102 +1,277 @@
---
import Layout from '@/layouts/Layout.astro'
import { getI18n } from '@/i18n/utils'
import { href, ROUTES } from '@/lib/routes'
import Layout from "@/layouts/Layout.astro"
import { getI18n } from "@/i18n/utils"
import { href, ROUTES } from "@/lib/routes"
const { locale, t } = getI18n(Astro)
const titleLabel = t('my account')
const pleaseLabel = t('please log in')
const loginLabel = t('login')
const accountLabel = t('account')
const emailLabel = t('email')
const signOutLabel = t('sign out')
const collectionsLabel = t('my collections')
const historyLabel = t('my history')
const soonLabel = t('coming soon')
const titleLabel = t("my account")
const pleaseLabel = t("please log in")
const loginLabel = t("login")
const historyLabel = t("my history")
const loginUrl = href(locale, ROUTES.login) + "?redirect=" + encodeURIComponent(href(locale, ROUTES.account))
const archiveUrl = href(locale, ROUTES.runwayLooks)
// 预翻译常量:Alpine 表达式里没有 t()(t 仅 Astro 服务端存在),文案一律走模板插值。
const removeLabel = t("remove")
const itemsLabel = t("items")
const clearHistoryLabel = t("clear history")
---
<Layout title={titleLabel}>
<main class="mx-auto w-full max-w-6xl px-6 py-12 md:px-8 lg:px-12 lg:py-16" x-data="accountPage" data-locale={locale}>
<div class="grid gap-8 lg:grid-cols-[0.9fr_1.5fr]">
<aside class="rounded-[30px] border border-black/10 bg-black p-6 text-white shadow-[0_20px_60px_rgba(0,0,0,0.08)] md:p-8">
<div class="mb-10 flex items-center justify-between gap-3">
<span class="text-[10px] font-mono uppercase tracking-[0.38em] text-white/60">Archive</span>
<span class="border border-white/20 bg-white/5 px-2 py-1 text-[9px] font-mono uppercase tracking-[0.32em] text-white">2026</span>
</div>
<main class="account-root mx-auto w-full max-w-7xl px-6 py-10 md:px-8 md:py-14" x-data="accountPage" data-locale={locale}>
<div class="mb-10">
<p class="mb-4 text-[10px] font-mono uppercase tracking-[0.38em] text-white/50">Member profile</p>
<h1 class="font-serif text-4xl uppercase leading-none tracking-[-0.06em] md:text-5xl">{titleLabel}</h1>
</div>
<!-- 未登录:引导登录 -->
<div x-show="!user" x-cloak class="border border-black/15 bg-white px-8 py-20 text-center">
<p class="mb-6 text-[10px] font-mono uppercase tracking-[0.32em] text-black/45">{pleaseLabel}</p>
<a href={loginUrl} class="inline-flex items-center justify-center border border-black bg-black px-6 py-3 text-[11px] font-bold uppercase tracking-[0.24em] text-white transition-colors hover:bg-white hover:text-black">{loginLabel}</a>
</div>
<div class="space-y-3 text-sm text-white/70">
<p class="text-[11px] font-mono uppercase tracking-[0.32em] text-white/45">Portfolio Studio</p>
<p class="leading-relaxed">Curated access to runway archives, editorial notes, and private collections.</p>
</div>
</aside>
<!-- 已登录:个人中心 -->
<div x-show="user" x-cloak class="space-y-10">
<section class="rounded-[30px] border border-black/10 bg-[#f7f5f1] p-6 shadow-[0_18px_50px_rgba(0,0,0,0.04)] md:p-8 lg:p-10">
<div x-show="!user" x-cloak class="space-y-6">
<p class="text-[10px] font-mono uppercase tracking-[0.32em] text-black/45">{pleaseLabel}</p>
<a href={href(locale, ROUTES.login) + '?redirect=' + encodeURIComponent(href(locale, ROUTES.account))}
class="inline-flex items-center justify-center border border-black bg-black px-5 py-3 text-[11px] font-bold uppercase tracking-[0.24em] text-white transition-colors hover:bg-white hover:text-black">{loginLabel}</a>
</div>
<div x-show="user" x-cloak class="space-y-8">
<div>
<p class="mb-4 text-[10px] font-mono uppercase tracking-[0.32em] text-black/45">Account</p>
<dl class="space-y-4">
<div class="flex gap-5 border-b border-black/10 pb-4">
<dt class="w-28 pt-1 text-[10px] font-mono uppercase tracking-[0.26em] text-black/45">{accountLabel}</dt>
<dd class="flex-1 font-medium" x-text="user?.username"></dd>
</div>
<div class="flex gap-5 border-b border-black/10 pb-4">
<dt class="w-28 pt-1 text-[10px] font-mono uppercase tracking-[0.26em] text-black/45">{emailLabel}</dt>
<dd class="flex-1 font-medium break-all" x-text="user?.email"></dd>
</div>
</dl>
</div>
<div class="flex flex-wrap gap-3">
<button @click="logout()" class="border border-black bg-black px-5 py-3 text-[11px] font-bold uppercase tracking-[0.24em] text-white transition-colors hover:bg-white hover:text-black">{signOutLabel}</button>
<a href={href(locale, ROUTES.runwayLooks)} class="border border-black/15 bg-transparent px-5 py-3 text-[11px] font-bold uppercase tracking-[0.24em] text-black transition-colors hover:bg-black hover:text-white">{t('View archive')}</a>
</div>
<div class="space-y-8 pt-4">
<section class="border-t border-black/10 pt-6">
<h2 class="mb-3 font-serif text-2xl uppercase tracking-[-0.04em]">{collectionsLabel}</h2>
<p class="text-sm text-black/45">{soonLabel}</p>
</section>
<section class="border-t border-black/10 pt-6">
<h2 class="mb-3 font-serif text-2xl uppercase tracking-[-0.04em]">{historyLabel}</h2>
<p class="text-sm text-black/45">{soonLabel}</p>
</section>
</div>
<!-- 报头:头像 + 用户名 -->
<section class="flex items-center gap-5 border-b border-black/15 pb-8">
<div class="flex h-16 w-16 shrink-0 items-center justify-center border border-black font-serif text-2xl tracking-[-0.04em]" x-text="(user?.username || '?').slice(0,2).toUpperCase()"></div>
<div>
<p class="mb-2 text-[10px] font-mono uppercase tracking-[0.32em] text-black/45">{t("member profile")}</p>
<h1 class="font-serif text-4xl uppercase leading-none tracking-[-0.04em] md:text-5xl" x-text="user?.username"></h1>
</div>
</section>
<!-- 主体:左侧导航 + 右侧内容 -->
<div class="grid gap-8 lg:grid-cols-[200px_1fr]">
<!-- 侧栏导航 -->
<aside class="lg:sticky lg:top-8 lg:self-start">
<nav class="flex gap-1 overflow-x-auto border-b border-black/15 lg:flex-col lg:gap-0 lg:overflow-visible lg:border-b-0">
<button @click="tab='saved'" :class="tab==='saved' ? 'text-black border-black lg:border-l-black' : 'text-black/55 hover:text-black border-transparent lg:border-l-transparent'" class="whitespace-nowrap border-b-2 px-4 py-3 text-left text-[11px] font-mono uppercase tracking-[0.22em] transition-colors lg:w-full lg:border-b lg:border-l-2 lg:border-black/10">{t("saved looks")}</button>
<button @click="tab='history'" :class="tab==='history' ? 'text-black border-black lg:border-l-black' : 'text-black/55 hover:text-black border-transparent lg:border-l-transparent'" class="whitespace-nowrap border-b-2 px-4 py-3 text-left text-[11px] font-mono uppercase tracking-[0.22em] transition-colors lg:w-full lg:border-b lg:border-l-2 lg:border-black/10">{historyLabel}</button>
</nav>
</aside>
<!-- 内容面板 -->
<section class="js-surface js-panel border border-black/15 bg-white p-6 md:p-10">
<!-- Saved looks -->
<div x-show="tab==='saved'" x-cloak>
<div class="mb-6 flex items-center justify-between">
<h2 class="font-serif text-2xl uppercase tracking-[-0.03em] md:text-3xl">{t("saved looks")}</h2>
<span class="text-[10px] font-mono uppercase tracking-[0.24em] text-black/40"><span x-text="favoritesCount"></span> {itemsLabel}</span>
</div>
<div x-show="favorites.length === 0" class="flex flex-col items-center justify-center border border-dashed border-black/20 py-20 text-center">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.25" class="mb-5 h-10 w-10 text-black/30"><path d="M6 3h12a1 1 0 0 1 1 1v17l-7-4-7 4V4a1 1 0 0 1 1-1Z"/></svg>
<p class="mb-6 text-sm text-black/45">{t("no saved looks yet")}</p>
<a href={archiveUrl} class="accent-btn inline-flex items-center justify-center border border-black bg-white px-5 py-3 text-[11px] font-bold uppercase tracking-[0.24em] text-black transition-colors hover:bg-black hover:text-white">{t("browse the archive")}</a>
</div>
<div x-show="favorites.length > 0" class="grid grid-cols-2 gap-5 sm:grid-cols-3 lg:grid-cols-4">
<template x-for="f in favorites" :key="f.id">
<div class="js-surface group relative overflow-hidden border border-black/15 bg-white hover:border-black">
<a :href="itemHref(f)" class="block aspect-[3/4] overflow-hidden bg-[#f1f1f1]">
<img :src="coverUrl(f)" :alt="f.title" loading="lazy" class="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105" />
</a>
<button @click="removeFav(f.id)" class="absolute right-2 top-2 border border-black/10 bg-white px-2.5 py-1 text-[9px] font-mono uppercase tracking-[0.18em] text-black/70 backdrop-blur transition-colors hover:bg-black hover:text-white">{removeLabel}</button>
<div class="p-3">
<a :href="itemHref(f)"><p class="truncate text-[12px] font-medium" x-text="f.title"></p></a>
<p class="mt-0.5 truncate text-[10px] font-mono uppercase tracking-[0.16em] text-black/45" x-text="f.brand || '—'"></p>
</div>
</div>
</template>
</div>
<!-- 收藏分页(服务端分页,与收藏总量解耦,10万也常数级) -->
<nav class="mt-8 flex flex-wrap items-center justify-center gap-2" x-show="favLastPage > 1" aria-label="Saved pagination">
<button type="button" class="border border-black/20 px-3 py-1.5 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :disabled="favPage <= 1" @click="goFavPage(favPage - 1)">‹ Prev</button>
<template x-for="(p, i) in favPageList" :key="i">
<span>
<span x-show="p.page === null" x-text="'…'" class="px-2 text-black/40"></span>
<button type="button" x-show="p.page !== null" class="border border-black/20 px-3 py-1.5 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :class="{ 'bg-black text-white': p.page === favPage }" :disabled="p.page === favPage" @click="goFavPage(p.page)" x-text="p.label"></button>
</span>
</template>
<button type="button" class="border border-black/20 px-3 py-1.5 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :disabled="favPage >= favLastPage" @click="goFavPage(favPage + 1)">Next ›</button>
</nav>
</div>
<!-- History -->
<div x-show="tab==='history'" x-cloak>
<div class="mb-6">
<h2 class="font-serif text-2xl uppercase tracking-[-0.03em] md:text-3xl">{historyLabel}</h2>
</div>
<div class="mb-4 flex items-center justify-between">
<span class="text-[10px] font-mono uppercase tracking-[0.24em] text-black/40"><span x-text="histTotal"></span> {itemsLabel}</span>
<button x-show="histTotal > 0" @click="clearAllHistory()" class="text-[10px] font-mono uppercase tracking-[0.22em] text-black/45 underline-offset-4 hover:text-black hover:underline">{clearHistoryLabel}</button>
</div>
<div x-show="histTotal === 0" class="flex flex-col items-center justify-center border border-dashed border-black/20 py-20 text-center">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.25" class="mb-5 h-10 w-10 text-black/30"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>
<p class="mb-6 text-sm text-black/45">{t("no history yet")}</p>
<a href={archiveUrl} class="accent-btn inline-flex items-center justify-center border border-black bg-white px-5 py-3 text-[11px] font-bold uppercase tracking-[0.24em] text-black transition-colors hover:bg-black hover:text-white">{t("browse the archive")}</a>
</div>
<div x-show="histTotal > 0" class="grid grid-cols-2 gap-5 sm:grid-cols-3 lg:grid-cols-4">
<template x-for="h in history" :key="h.id">
<div class="js-surface group relative overflow-hidden border border-black/15 bg-white hover:border-black">
<a :href="itemHref(h)" class="block aspect-[3/4] overflow-hidden bg-[#f1f1f1]">
<img :src="coverUrl(h)" :alt="h.data?.title || ''" loading="lazy" class="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105" />
</a>
<button @click="removeHist(h.id)" class="absolute right-2 top-2 border border-black/10 bg-white px-2.5 py-1 text-[9px] font-mono uppercase tracking-[0.18em] text-black/70 backdrop-blur transition-colors hover:bg-black hover:text-white">{removeLabel}</button>
<div class="p-3">
<a :href="itemHref(h)"><p class="truncate text-[12px] font-medium" x-text="h.data?.title || '…'"></p></a>
<p class="mt-0.5 truncate text-[10px] font-mono uppercase tracking-[0.16em] text-black/45" x-text="h.data?.brand || '—'"></p>
</div>
</div>
</template>
</div>
<nav class="mt-8 flex flex-wrap items-center justify-center gap-2" x-show="histLastPage > 1" aria-label="History pagination">
<button type="button" class="border border-black/20 px-3 py-1.5 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :disabled="histPage <= 1" @click="goHistPage(histPage - 1)">‹ Prev</button>
<template x-for="(p, i) in histPageList" :key="i">
<span>
<span x-show="p.page === null" x-text="'…'" class="px-2 text-black/40"></span>
<button type="button" x-show="p.page !== null" class="border border-black/20 px-3 py-1.5 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :class="{ 'bg-black text-white': p.page === histPage }" :disabled="p.page === histPage" @click="goHistPage(p.page)" x-text="p.label"></button>
</span>
</template>
<button type="button" class="border border-black/20 px-3 py-1.5 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :disabled="histPage >= histLastPage" @click="goHistPage(histPage + 1)">Next ›</button>
</nav>
</div>
</section>
</div>
</div>
</main>
</Layout>
<script>
import { getUser, logout as logoutApi } from "@/lib/api"
import { getUser, toAbs } from "@/lib/api"
import { getFavorites, removeFavorite, fetchFavoritesPage } from "@/lib/favorites"
import { fetchHistoryPage, removeHistory, clearHistory } from "@/lib/history"
import { clientHref, ROUTES } from "@/lib/routes"
const PLACEHOLDER = "data:image/svg+xml;utf8," + encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="300" height="400">' +
'<rect width="300" height="400" fill="#f1f1f1"/>' +
'<text x="150" y="200" font-family="monospace" font-size="16" letter-spacing="3" fill="#9ca3af" text-anchor="middle">LOOK</text>' +
'</svg>'
)
document.addEventListener("alpine:init", () => {
Alpine.data("accountPage", () => ({
user: null,
user: null as any,
locale: "en",
tab: "saved",
favorites: [] as any[],
favoritesCount: 0,
favPage: 1,
favPerPage: 24,
favLastPage: 1,
favTotal: 0,
history: [] as any[],
historyCount: 0,
histPage: 1,
histPerPage: 24,
histLastPage: 1,
histTotal: 0,
init() {
const loc = this.$el.dataset.locale || "en"
this.locale = this.$el.dataset.locale || "en"
const u = getUser()
if (!u) {
window.location.href = clientHref(loc, ROUTES.login) + "?redirect=" + encodeURIComponent(clientHref(loc, ROUTES.account))
window.location.href = clientHref(this.locale, ROUTES.login) + "?redirect=" + encodeURIComponent(clientHref(this.locale, ROUTES.account))
return
}
this.user = u
// 登录态:个人中心收藏以服务端权威列表为准(服务端分页拉取,失败回落本地)。
this.loadFavPage(1)
// 浏览历史:服务端分页拉取「点开过的文章」,失败回落本地。
this.loadHistPage(1)
},
// 服务端分页拉取收藏(与收藏总量解耦,10万也常数级)
async loadFavPage(page: number) {
const p = Math.max(1, page | 0)
try {
const r = await fetchFavoritesPage(p, this.favPerPage)
this.favorites = r.items
this.favTotal = r.total
this.favLastPage = r.lastPage
this.favPage = p
this.favoritesCount = r.total
} catch {
const local = getFavorites()
this.favorites = local
this.favTotal = local.length
this.favLastPage = 1
this.favPage = 1
this.favoritesCount = local.length
}
},
goFavPage(p: number) {
if (p < 1 || p > this.favLastPage || p === this.favPage) return
this.loadFavPage(p)
},
// 窗口化页码:首尾页 + 当前页 ±1,其余用 … 折叠
get favPageList() {
const cur = this.favPage, last = this.favLastPage
if (last <= 1) return []
const set = new Set([1, last, cur, cur - 1, cur + 1].filter((n) => n >= 1 && n <= last))
const arr = [...set].sort((a, b) => a - b)
const out: ({ label: string; page: number } | { label: string; page: null })[] = []
let prev = 0
for (const n of arr) {
if (n - prev > 1) out.push({ label: "…", page: null })
out.push({ label: String(n), page: n })
prev = n
}
return out
},
coverUrl(x: any): string {
const c = x.data?.cover || x.cover
return c ? toAbs(c) : PLACEHOLDER
},
itemHref(f: any): string {
return clientHref(this.locale, ROUTES.item(f.id))
},
removeFav(id: string) {
removeFavorite(id)
this.loadFavPage(this.favPage) // 重新拉当前页,保证分页计数一致
},
// ===== 浏览历史(服务端分页)=====
async loadHistPage(page: number) {
const p = Math.max(1, page | 0)
try {
const r = await fetchHistoryPage(p, this.histPerPage)
this.history = r.items
this.histTotal = r.total
this.histLastPage = r.lastPage
this.histPage = p
} catch {
this.history = []
this.histTotal = 0
this.histLastPage = 1
this.histPage = 1
}
},
goHistPage(p: number) {
if (p < 1 || p > this.histLastPage || p === this.histPage) return
this.loadHistPage(p)
},
// 窗口化页码:首尾页 + 当前页 ±1,其余用 … 折叠
get histPageList() {
const cur = this.histPage, last = this.histLastPage
if (last <= 1) return []
const set = new Set([1, last, cur, cur - 1, cur + 1].filter((n) => n >= 1 && n <= last))
const arr = [...set].sort((a, b) => a - b)
const out: ({ label: string; page: number } | { label: string; page: null })[] = []
let prev = 0
for (const n of arr) {
if (n - prev > 1) out.push({ label: "…", page: null })
out.push({ label: String(n), page: n })
prev = n
}
return out
},
removeHist(id: string) {
removeHistory(id)
this.loadHistPage(this.histPage) // 重新拉当前页,保证分页计数一致
},
clearAllHistory() {
clearHistory()
this.loadHistPage(1)
},
logout() {
logoutApi().finally(() => window.location.reload())
}
}))
})
</script>

View File

@ -3,6 +3,7 @@ import Layout from "@/layouts/Layout.astro"
import Item from "@/components/Item.astro"
import StreetSnap from "@/components/StreetSnap.astro"
import { getSsrArticle, getSsrStreetSnap } from "@/lib/api"
import { getI18n } from "@/i18n/utils"
// 按需 SSR:走秀(runway)与街拍(street snap)详情统一收口到 /item/[id]。
// 对外 id 带类型前缀(r=runway / s=snap,见后端 hashid.EncodeTyped),据此直接查对应表,无歧义、无需回落。
@ -11,6 +12,8 @@ export const prerender = false
const { id } = Astro.params
const isSnap = !!id && id[0] === "s"
const { t } = getI18n(Astro)
let article = null
let articleErr = null
let snap = null
@ -28,8 +31,93 @@ if (isSnap) {
article = data.article
articleErr = data.error
}
// 供客户端「收藏 / 历史」使用的精简元信息(服务端已拿到,直接透传,避免前端再发请求)。
const meta = isSnap
? { id: id ?? "", type: "street", title: snap?.title ?? "", cover: snap?.cover ?? "", brand: "" }
: { id: id ?? "", type: "runway", title: article?.title ?? "", cover: article?.cover ?? "", brand: article?.brand_name ?? "" }
const saveLabel = t("save")
const savedLabel = t("saved")
---
<Layout>
{article ? <Item article={article} error={articleErr} /> : <StreetSnap snap={snap} error={snapErr} />}
</Layout>
<style>
#fav-fab.is-active .fav-heart { fill: currentColor; }
</style>
{(article || snap) && (
<button id="fav-fab" type="button"
data-id={meta.id}
data-type={meta.type}
data-title={meta.title}
data-cover={meta.cover}
data-brand={meta.brand}
data-save-label={saveLabel}
data-saved-label={savedLabel}
class="fixed bottom-6 right-6 z-50 inline-flex items-center gap-2 rounded-full border border-black/40 bg-white/90 px-5 py-3 text-[11px] font-bold uppercase tracking-[0.2em] text-black shadow-sm backdrop-blur transition-colors hover:bg-black hover:text-white">
<svg class="fav-heart h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M12 21C12 21 4 14.5 4 8.8C4 6.1 6.1 4 8.7 4C10.3 4 11.5 4.8 12 6C12.5 4.8 13.7 4 15.3 4C17.9 4 20 6.1 20 8.8C20 14.5 12 21 12 21Z"/></svg>
<span id="fav-label">{saveLabel}</span>
</button>
)}
{/* 注意:不能用 define:vars 传 meta —— 它会把脚本包成内联 IIFE 并把 import 塞进函数体内,
导致 SyntaxError(import 只能在模块顶层)。故改由 data-* 属性透传,脚本自行读取。 */}
<script>
import { getUser } from "@/lib/api"
import { isFavorited, toggleFavorite, checkFavorited, hydrateFavorited } from "@/lib/favorites"
import { recordHistory } from "@/lib/history"
const btn = document.getElementById("fav-fab")
const label = document.getElementById("fav-label")
if (btn) {
const meta = {
id: btn.dataset.id || "",
type: btn.dataset.type || "",
title: btn.dataset.title || "",
cover: btn.dataset.cover || "",
brand: btn.dataset.brand || "",
}
const saveLabel = btn.dataset.saveLabel || "save"
const savedLabel = btn.dataset.savedLabel || "saved"
if (meta.id) {
// 浏览历史:点开文章详情即记一条(登录态;列表页不再记)。同一篇反复打开,后端 upsert 刷新时间。
if (getUser()) recordHistory(meta.id)
const sync = () => {
const fav = isFavorited(meta.id)
if (label) label.textContent = fav ? savedLabel : saveLabel
btn.classList.toggle("is-active", fav)
}
sync()
// 登录态:异步问服务端当前篇是否已收藏并写回本地(换浏览器/设备后,爱心状态与服务端一致)
if (getUser()) {
checkFavorited([meta.id])
.then((set) => {
if (set.has(meta.id)) hydrateFavorited(meta.id, meta.type as "runway" | "street", meta.title, meta.cover, meta.brand)
sync()
})
.catch(() => {})
}
btn.addEventListener("click", () => {
if (!getUser()) {
window.location.href = "/login"
return
}
toggleFavorite({
id: meta.id,
type: meta.type,
title: meta.title,
cover: meta.cover,
brand: meta.brand,
} as any)
sync()
})
}
}
</script>

View File

@ -23,8 +23,17 @@ const failLabel = t('login failed')
const membersLabel = t('members')
const backLabel = t('back to site')
const homeUrl = href(locale, ROUTES.runwayLooks)
const pageTitle = `${loginLabel} — Portfolio Studio`
---
<body class="min-h-screen bg-[#f4f1eb] text-black antialiased selection:bg-black selection:text-white">
<!doctype html>
<html lang={locale === 'cn' ? 'zh-CN' : 'en'}>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<meta name="generator" content={Astro.generator} />
<title>{pageTitle}</title>
</head>
<body class="min-h-screen bg-[#f4f1eb] text-black antialiased selection:bg-black selection:text-white">
<div class="absolute right-0 top-0 z-30 p-6 md:p-10" x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false">
<button type="button" @click="open = !open" @click.outside="open = false" @keydown.escape.window="open = false" class="flex items-center gap-1.5 rounded-full border border-black/10 bg-white/85 px-2.5 py-1.5 text-[10px] font-medium uppercase tracking-[0.26em] text-black backdrop-blur-sm transition-colors hover:bg-black hover:text-white" aria-label={t('Language')} aria-haspopup="listbox" :aria-expanded="open">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="h-[14px] w-[14px] transition-transform duration-200" :class="open ? 'rotate-180' : ''" aria-hidden="true">
@ -118,44 +127,45 @@ const homeUrl = href(locale, ROUTES.runwayLooks)
</div>
</section>
</main>
</body>
<script>
import { login, getUser } from "@/lib/api"
import { clientHref, ROUTES } from "@/lib/routes"
<script>
import { login, getUser } from "@/lib/api"
import { clientHref, ROUTES } from "@/lib/routes"
document.addEventListener("alpine:init", () => {
Alpine.data("loginForm", () => ({
account: "",
password: "",
error: "",
loading: false,
document.addEventListener("alpine:init", () => {
Alpine.data("loginForm", () => ({
account: "",
password: "",
error: "",
loading: false,
init() {
if (getUser()) {
const loc = this.$el.dataset.locale || "en"
window.location.href = clientHref(loc, ROUTES.account)
}
},
submit() {
this.error = ""
this.loading = true
login({ account: this.account, password: this.password })
.then(() => {
const params = new URLSearchParams(window.location.search)
const redirect = params.get("redirect")
init() {
if (getUser()) {
const loc = this.$el.dataset.locale || "en"
window.location.href = redirect || clientHref(loc, ROUTES.account)
})
.catch(() => {
this.error = this.$el.dataset.failLabel || "login failed"
})
.finally(() => {
this.loading = false
})
},
}))
})
</script>
window.location.href = clientHref(loc, ROUTES.account)
}
},
submit() {
this.error = ""
this.loading = true
login({ account: this.account, password: this.password })
.then(() => {
const params = new URLSearchParams(window.location.search)
const redirect = params.get("redirect")
const loc = this.$el.dataset.locale || "en"
window.location.href = redirect || clientHref(loc, ROUTES.account)
})
.catch(() => {
this.error = this.$el.dataset.failLabel || "login failed"
})
.finally(() => {
this.loading = false
})
},
}))
})
</script>
</body>
</html>

View File

@ -1,102 +1,277 @@
---
import Layout from '@/layouts/Layout.astro'
import { getI18n } from '@/i18n/utils'
import { href, ROUTES } from '@/lib/routes'
import Layout from "@/layouts/Layout.astro"
import { getI18n } from "@/i18n/utils"
import { href, ROUTES } from "@/lib/routes"
const { locale, t } = getI18n(Astro)
const titleLabel = t('my account')
const pleaseLabel = t('please log in')
const loginLabel = t('login')
const accountLabel = t('account')
const emailLabel = t('email')
const signOutLabel = t('sign out')
const collectionsLabel = t('my collections')
const historyLabel = t('my history')
const soonLabel = t('coming soon')
const titleLabel = t("my account")
const pleaseLabel = t("please log in")
const loginLabel = t("login")
const historyLabel = t("my history")
const loginUrl = href(locale, ROUTES.login) + "?redirect=" + encodeURIComponent(href(locale, ROUTES.account))
const archiveUrl = href(locale, ROUTES.runwayLooks)
// 预翻译常量:Alpine 表达式里没有 t()(t 仅 Astro 服务端存在),文案一律走模板插值。
const removeLabel = t("remove")
const itemsLabel = t("items")
const clearHistoryLabel = t("clear history")
---
<Layout title={titleLabel}>
<main class="mx-auto w-full max-w-6xl px-6 py-12 md:px-8 lg:px-12 lg:py-16" x-data="accountPage" data-locale={locale}>
<div class="grid gap-8 lg:grid-cols-[0.9fr_1.5fr]">
<aside class="rounded-[30px] border border-black/10 bg-black p-6 text-white shadow-[0_20px_60px_rgba(0,0,0,0.08)] md:p-8">
<div class="mb-10 flex items-center justify-between gap-3">
<span class="text-[10px] font-mono uppercase tracking-[0.38em] text-white/60">Archive</span>
<span class="border border-white/20 bg-white/5 px-2 py-1 text-[9px] font-mono uppercase tracking-[0.32em] text-white">2026</span>
</div>
<main class="account-root mx-auto w-full max-w-7xl px-6 py-10 md:px-8 md:py-14" x-data="accountPage" data-locale={locale}>
<div class="mb-10">
<p class="mb-4 text-[10px] font-mono uppercase tracking-[0.38em] text-white/50">Member profile</p>
<h1 class="font-serif text-4xl uppercase leading-none tracking-[-0.06em] md:text-5xl">{titleLabel}</h1>
</div>
<!-- 未登录:引导登录 -->
<div x-show="!user" x-cloak class="border border-black/15 bg-white px-8 py-20 text-center">
<p class="mb-6 text-[10px] uppercase tracking-[0.32em] text-black/45">{pleaseLabel}</p>
<a href={loginUrl} class="inline-flex items-center justify-center border border-black bg-black px-6 py-3 text-[11px] font-bold uppercase tracking-[0.24em] text-white transition-colors hover:bg-white hover:text-black">{loginLabel}</a>
</div>
<div class="space-y-3 text-sm text-white/70">
<p class="text-[11px] font-mono uppercase tracking-[0.32em] text-white/45">Portfolio Studio</p>
<p class="leading-relaxed">Curated access to runway archives, editorial notes, and private collections.</p>
</div>
</aside>
<!-- 已登录:个人中心 -->
<div x-show="user" x-cloak class="space-y-10">
<section class="rounded-[30px] border border-black/10 bg-[#f7f5f1] p-6 shadow-[0_18px_50px_rgba(0,0,0,0.04)] md:p-8 lg:p-10">
<div x-show="!user" x-cloak class="space-y-6">
<p class="text-[10px] font-mono uppercase tracking-[0.32em] text-black/45">{pleaseLabel}</p>
<a href={href(locale, ROUTES.login) + '?redirect=' + encodeURIComponent(href(locale, ROUTES.account))}
class="inline-flex items-center justify-center border border-black bg-black px-5 py-3 text-[11px] font-bold uppercase tracking-[0.24em] text-white transition-colors hover:bg-white hover:text-black">{loginLabel}</a>
</div>
<div x-show="user" x-cloak class="space-y-8">
<div>
<p class="mb-4 text-[10px] font-mono uppercase tracking-[0.32em] text-black/45">Account</p>
<dl class="space-y-4">
<div class="flex gap-5 border-b border-black/10 pb-4">
<dt class="w-28 pt-1 text-[10px] font-mono uppercase tracking-[0.26em] text-black/45">{accountLabel}</dt>
<dd class="flex-1 font-medium" x-text="user?.username"></dd>
</div>
<div class="flex gap-5 border-b border-black/10 pb-4">
<dt class="w-28 pt-1 text-[10px] font-mono uppercase tracking-[0.26em] text-black/45">{emailLabel}</dt>
<dd class="flex-1 font-medium break-all" x-text="user?.email"></dd>
</div>
</dl>
</div>
<div class="flex flex-wrap gap-3">
<button @click="logout()" class="border border-black bg-black px-5 py-3 text-[11px] font-bold uppercase tracking-[0.24em] text-white transition-colors hover:bg-white hover:text-black">{signOutLabel}</button>
<a href={href(locale, ROUTES.runwayLooks)} class="border border-black/15 bg-transparent px-5 py-3 text-[11px] font-bold uppercase tracking-[0.24em] text-black transition-colors hover:bg-black hover:text-white">{t('View archive')}</a>
</div>
<div class="space-y-8 pt-4">
<section class="border-t border-black/10 pt-6">
<h2 class="mb-3 font-serif text-2xl uppercase tracking-[-0.04em]">{collectionsLabel}</h2>
<p class="text-sm text-black/45">{soonLabel}</p>
</section>
<section class="border-t border-black/10 pt-6">
<h2 class="mb-3 font-serif text-2xl uppercase tracking-[-0.04em]">{historyLabel}</h2>
<p class="text-sm text-black/45">{soonLabel}</p>
</section>
</div>
<!-- 报头:头像 + 用户名 -->
<section class="flex items-center gap-5 border-b border-black/15 pb-8">
<div class="flex h-16 w-16 shrink-0 items-center justify-center border border-black text-2xl tracking-[-0.04em]" x-text="(user?.username || '?').slice(0,2).toUpperCase()"></div>
<div>
<p class="mb-2 text-[10px] uppercase tracking-[0.32em] text-black/45">{t("member profile")}</p>
<h1 class="text-4xl uppercase leading-none tracking-[-0.04em] md:text-5xl" x-text="user?.username"></h1>
</div>
</section>
<!-- 主体:左侧导航 + 右侧内容 -->
<div class="grid gap-8 lg:grid-cols-[200px_1fr]">
<!-- 侧栏导航 -->
<aside class="lg:sticky lg:top-8 lg:self-start">
<nav class="flex gap-1 overflow-x-auto border-b border-black/15 lg:flex-col lg:gap-0 lg:overflow-visible lg:border-b-0">
<button @click="tab='saved'" :class="tab==='saved' ? 'text-black border-black lg:border-l-black' : 'text-black/55 hover:text-black border-transparent lg:border-l-transparent'" class="whitespace-nowrap border-b-2 px-4 py-3 text-left text-[11px] uppercase tracking-[0.22em] transition-colors lg:w-full lg:border-b lg:border-l-2 lg:border-black/10">{t("saved looks")}</button>
<button @click="tab='history'" :class="tab==='history' ? 'text-black border-black lg:border-l-black' : 'text-black/55 hover:text-black border-transparent lg:border-l-transparent'" class="whitespace-nowrap border-b-2 px-4 py-3 text-left text-[11px] uppercase tracking-[0.22em] transition-colors lg:w-full lg:border-b lg:border-l-2 lg:border-black/10">{historyLabel}</button>
</nav>
</aside>
<!-- 内容面板 -->
<section class="js-surface js-panel border border-black/15 bg-white p-6 md:p-10">
<!-- Saved looks -->
<div x-show="tab==='saved'" x-cloak>
<div class="mb-6 flex items-center justify-between">
<h2 class="text-2xl uppercase tracking-[-0.03em] md:text-3xl">{t("saved looks")}</h2>
<span class="text-[10px] uppercase tracking-[0.24em] text-black/40"><span x-text="favoritesCount"></span> {itemsLabel}</span>
</div>
<div x-show="favorites.length === 0" class="flex flex-col items-center justify-center border border-dashed border-black/20 py-20 text-center">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.25" class="mb-5 h-10 w-10 text-black/30"><path d="M6 3h12a1 1 0 0 1 1 1v17l-7-4-7 4V4a1 1 0 0 1 1-1Z"/></svg>
<p class="mb-6 text-sm text-black/45">{t("no saved looks yet")}</p>
<a href={archiveUrl} class="accent-btn inline-flex items-center justify-center border border-black bg-white px-5 py-3 text-[11px] font-bold uppercase tracking-[0.24em] text-black transition-colors hover:bg-black hover:text-white">{t("browse the archive")}</a>
</div>
<div x-show="favorites.length > 0" class="grid grid-cols-2 gap-5 sm:grid-cols-3 lg:grid-cols-4">
<template x-for="f in favorites" :key="f.id">
<div class="js-surface group relative overflow-hidden border border-black/15 bg-white hover:border-black">
<a :href="itemHref(f)" class="block aspect-[3/4] overflow-hidden bg-[#f1f1f1]">
<img :src="coverUrl(f)" :alt="f.title" loading="lazy" class="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105" />
</a>
<button @click="removeFav(f.id)" class="absolute right-2 top-2 border border-black/10 bg-white px-2.5 py-1 text-[9px] uppercase tracking-[0.18em] text-black/70 backdrop-blur transition-colors hover:bg-black hover:text-white">{removeLabel}</button>
<div class="p-3">
<a :href="itemHref(f)"><p class="truncate text-[12px] font-medium" x-text="f.title"></p></a>
<p class="mt-0.5 truncate text-[10px] uppercase tracking-[0.16em] text-black/45" x-text="f.brand || '—'"></p>
</div>
</div>
</template>
</div>
<!-- 收藏分页(服务端分页,与收藏总量解耦,10万也常数级) -->
<nav class="mt-8 flex flex-wrap items-center justify-center gap-2" x-show="favLastPage > 1" aria-label="Saved pagination">
<button type="button" class="border border-black/20 px-3 py-1.5 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :disabled="favPage <= 1" @click="goFavPage(favPage - 1)">‹ Prev</button>
<template x-for="(p, i) in favPageList" :key="i">
<span>
<span x-show="p.page === null" x-text="'…'" class="px-2 text-black/40"></span>
<button type="button" x-show="p.page !== null" class="border border-black/20 px-3 py-1.5 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :class="{ 'bg-black text-white': p.page === favPage }" :disabled="p.page === favPage" @click="goFavPage(p.page)" x-text="p.label"></button>
</span>
</template>
<button type="button" class="border border-black/20 px-3 py-1.5 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :disabled="favPage >= favLastPage" @click="goFavPage(favPage + 1)">Next ›</button>
</nav>
</div>
<!-- History -->
<div x-show="tab==='history'" x-cloak>
<div class="mb-6">
<h2 class="text-2xl uppercase tracking-[-0.03em] md:text-3xl">{historyLabel}</h2>
</div>
<div class="mb-4 flex items-center justify-between">
<span class="text-[10px] uppercase tracking-[0.24em] text-black/40"><span x-text="histTotal"></span> {itemsLabel}</span>
<button x-show="histTotal > 0" @click="clearAllHistory()" class="text-[10px] uppercase tracking-[0.22em] text-black/45 underline-offset-4 hover:text-black hover:underline">{clearHistoryLabel}</button>
</div>
<div x-show="histTotal === 0" class="flex flex-col items-center justify-center border border-dashed border-black/20 py-20 text-center">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.25" class="mb-5 h-10 w-10 text-black/30"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>
<p class="mb-6 text-sm text-black/45">{t("no history yet")}</p>
<a href={archiveUrl} class="accent-btn inline-flex items-center justify-center border border-black bg-white px-5 py-3 text-[11px] font-bold uppercase tracking-[0.24em] text-black transition-colors hover:bg-black hover:text-white">{t("browse the archive")}</a>
</div>
<div x-show="histTotal > 0" class="grid grid-cols-2 gap-5 sm:grid-cols-3 lg:grid-cols-4">
<template x-for="h in history" :key="h.id">
<div class="js-surface group relative overflow-hidden border border-black/15 bg-white hover:border-black">
<a :href="itemHref(h)" class="block aspect-[3/4] overflow-hidden bg-[#f1f1f1]">
<img :src="coverUrl(h)" :alt="h.data?.title || ''" loading="lazy" class="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105" />
</a>
<button @click="removeHist(h.id)" class="absolute right-2 top-2 border border-black/10 bg-white px-2.5 py-1 text-[9px] uppercase tracking-[0.18em] text-black/70 backdrop-blur transition-colors hover:bg-black hover:text-white">{removeLabel}</button>
<div class="p-3">
<a :href="itemHref(h)"><p class="truncate text-[12px] font-medium" x-text="h.data?.title || '…'"></p></a>
<p class="mt-0.5 truncate text-[10px] uppercase tracking-[0.16em] text-black/45" x-text="h.data?.brand || '—'"></p>
</div>
</div>
</template>
</div>
<nav class="mt-8 flex flex-wrap items-center justify-center gap-2" x-show="histLastPage > 1" aria-label="History pagination">
<button type="button" class="border border-black/20 px-3 py-1.5 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :disabled="histPage <= 1" @click="goHistPage(histPage - 1)">‹ Prev</button>
<template x-for="(p, i) in histPageList" :key="i">
<span>
<span x-show="p.page === null" x-text="'…'" class="px-2 text-black/40"></span>
<button type="button" x-show="p.page !== null" class="border border-black/20 px-3 py-1.5 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :class="{ 'bg-black text-white': p.page === histPage }" :disabled="p.page === histPage" @click="goHistPage(p.page)" x-text="p.label"></button>
</span>
</template>
<button type="button" class="border border-black/20 px-3 py-1.5 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :disabled="histPage >= histLastPage" @click="goHistPage(histPage + 1)">Next ›</button>
</nav>
</div>
</section>
</div>
</div>
</main>
</Layout>
<script>
import { getUser, logout as logoutApi } from "@/lib/api"
import { getUser, toAbs } from "@/lib/api"
import { getFavorites, removeFavorite, fetchFavoritesPage } from "@/lib/favorites"
import { fetchHistoryPage, removeHistory, clearHistory } from "@/lib/history"
import { clientHref, ROUTES } from "@/lib/routes"
const PLACEHOLDER = "data:image/svg+xml;utf8," + encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="300" height="400">' +
'<rect width="300" height="400" fill="#f1f1f1"/>' +
'<text x="150" y="200" font-family="monospace" font-size="16" letter-spacing="3" fill="#9ca3af" text-anchor="middle">LOOK</text>' +
'</svg>'
)
document.addEventListener("alpine:init", () => {
Alpine.data("accountPage", () => ({
user: null,
user: null as any,
locale: "en",
tab: "saved",
favorites: [] as any[],
favoritesCount: 0,
favPage: 1,
favPerPage: 24,
favLastPage: 1,
favTotal: 0,
history: [] as any[],
historyCount: 0,
histPage: 1,
histPerPage: 24,
histLastPage: 1,
histTotal: 0,
init() {
const loc = this.$el.dataset.locale || "en"
this.locale = this.$el.dataset.locale || "en"
const u = getUser()
if (!u) {
window.location.href = clientHref(loc, ROUTES.login) + "?redirect=" + encodeURIComponent(clientHref(loc, ROUTES.account))
window.location.href = clientHref(this.locale, ROUTES.login) + "?redirect=" + encodeURIComponent(clientHref(this.locale, ROUTES.account))
return
}
this.user = u
// 登录态:个人中心收藏以服务端权威列表为准(服务端分页拉取,失败回落本地)。
this.loadFavPage(1)
// 浏览历史:服务端分页拉取「点开过的文章」,失败回落本地。
this.loadHistPage(1)
},
// 服务端分页拉取收藏(与收藏总量解耦,10万也常数级)
async loadFavPage(page: number) {
const p = Math.max(1, page | 0)
try {
const r = await fetchFavoritesPage(p, this.favPerPage)
this.favorites = r.items
this.favTotal = r.total
this.favLastPage = r.lastPage
this.favPage = p
this.favoritesCount = r.total
} catch {
const local = getFavorites()
this.favorites = local
this.favTotal = local.length
this.favLastPage = 1
this.favPage = 1
this.favoritesCount = local.length
}
},
goFavPage(p: number) {
if (p < 1 || p > this.favLastPage || p === this.favPage) return
this.loadFavPage(p)
},
// 窗口化页码:首尾页 + 当前页 ±1,其余用 … 折叠
get favPageList() {
const cur = this.favPage, last = this.favLastPage
if (last <= 1) return []
const set = new Set([1, last, cur, cur - 1, cur + 1].filter((n) => n >= 1 && n <= last))
const arr = [...set].sort((a, b) => a - b)
const out: ({ label: string; page: number } | { label: string; page: null })[] = []
let prev = 0
for (const n of arr) {
if (n - prev > 1) out.push({ label: "…", page: null })
out.push({ label: String(n), page: n })
prev = n
}
return out
},
coverUrl(x: any): string {
const c = x.data?.cover || x.cover
return c ? toAbs(c) : PLACEHOLDER
},
itemHref(f: any): string {
return clientHref(this.locale, ROUTES.item(f.id))
},
removeFav(id: string) {
removeFavorite(id)
this.loadFavPage(this.favPage) // 重新拉当前页,保证分页计数一致
},
// ===== 浏览历史(服务端分页)=====
async loadHistPage(page: number) {
const p = Math.max(1, page | 0)
try {
const r = await fetchHistoryPage(p, this.histPerPage)
this.history = r.items
this.histTotal = r.total
this.histLastPage = r.lastPage
this.histPage = p
} catch {
this.history = []
this.histTotal = 0
this.histLastPage = 1
this.histPage = 1
}
},
goHistPage(p: number) {
if (p < 1 || p > this.histLastPage || p === this.histPage) return
this.loadHistPage(p)
},
// 窗口化页码:首尾页 + 当前页 ±1,其余用 … 折叠
get histPageList() {
const cur = this.histPage, last = this.histLastPage
if (last <= 1) return []
const set = new Set([1, last, cur, cur - 1, cur + 1].filter((n) => n >= 1 && n <= last))
const arr = [...set].sort((a, b) => a - b)
const out: ({ label: string; page: number } | { label: string; page: null })[] = []
let prev = 0
for (const n of arr) {
if (n - prev > 1) out.push({ label: "…", page: null })
out.push({ label: String(n), page: n })
prev = n
}
return out
},
removeHist(id: string) {
removeHistory(id)
this.loadHistPage(this.histPage) // 重新拉当前页,保证分页计数一致
},
clearAllHistory() {
clearHistory()
this.loadHistPage(1)
},
logout() {
logoutApi().finally(() => window.location.reload())
}
}))
})
</script>

View File

@ -3,6 +3,7 @@ import Layout from "@/layouts/Layout.astro"
import Item from "@/components/Item.astro"
import StreetSnap from "@/components/StreetSnap.astro"
import { getSsrArticle, getSsrStreetSnap } from "@/lib/api"
import { getI18n } from "@/i18n/utils"
// 按需 SSR:走秀(runway)与街拍(street snap)详情统一收口到 /item/[id]。
// 对外 id 带类型前缀(r=runway / s=snap,见后端 hashid.EncodeTyped),据此直接查对应表,无歧义、无需回落。
@ -11,6 +12,8 @@ export const prerender = false
const { id } = Astro.params
const isSnap = !!id && id[0] === "s"
const { t } = getI18n(Astro)
let article = null
let articleErr = null
let snap = null
@ -28,8 +31,93 @@ if (isSnap) {
article = data.article
articleErr = data.error
}
// 供客户端「收藏 / 历史」使用的精简元信息(服务端已拿到,直接透传,避免前端再发请求)。
const meta = isSnap
? { id: id ?? "", type: "street", title: snap?.title ?? "", cover: snap?.cover ?? "", brand: "" }
: { id: id ?? "", type: "runway", title: article?.title ?? "", cover: article?.cover ?? "", brand: article?.brand_name ?? "" }
const saveLabel = t("save")
const savedLabel = t("saved")
---
<Layout>
{article ? <Item article={article} error={articleErr} /> : <StreetSnap snap={snap} error={snapErr} />}
</Layout>
<style>
#fav-fab.is-active .fav-heart { fill: currentColor; }
</style>
{(article || snap) && (
<button id="fav-fab" type="button"
data-id={meta.id}
data-type={meta.type}
data-title={meta.title}
data-cover={meta.cover}
data-brand={meta.brand}
data-save-label={saveLabel}
data-saved-label={savedLabel}
class="fixed bottom-6 right-6 z-50 inline-flex items-center gap-2 rounded-full border border-black/40 bg-white/90 px-5 py-3 text-[11px] font-bold uppercase tracking-[0.2em] text-black shadow-sm backdrop-blur transition-colors hover:bg-black hover:text-white">
<svg class="fav-heart h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M12 21C12 21 4 14.5 4 8.8C4 6.1 6.1 4 8.7 4C10.3 4 11.5 4.8 12 6C12.5 4.8 13.7 4 15.3 4C17.9 4 20 6.1 20 8.8C20 14.5 12 21 12 21Z"/></svg>
<span id="fav-label">{saveLabel}</span>
</button>
)}
{/* 注意:不能用 define:vars 传 meta —— 它会把脚本包成内联 IIFE 并把 import 塞进函数体内,
导致 SyntaxError(import 只能在模块顶层)。故改由 data-* 属性透传,脚本自行读取。 */}
<script>
import { getUser } from "@/lib/api"
import { isFavorited, toggleFavorite, checkFavorited, hydrateFavorited } from "@/lib/favorites"
import { recordHistory } from "@/lib/history"
const btn = document.getElementById("fav-fab")
const label = document.getElementById("fav-label")
if (btn) {
const meta = {
id: btn.dataset.id || "",
type: btn.dataset.type || "",
title: btn.dataset.title || "",
cover: btn.dataset.cover || "",
brand: btn.dataset.brand || "",
}
const saveLabel = btn.dataset.saveLabel || "save"
const savedLabel = btn.dataset.savedLabel || "saved"
if (meta.id) {
// 浏览历史:点开文章详情即记一条(登录态;列表页不再记)。同一篇反复打开,后端 upsert 刷新时间。
if (getUser()) recordHistory(meta.id)
const sync = () => {
const fav = isFavorited(meta.id)
if (label) label.textContent = fav ? savedLabel : saveLabel
btn.classList.toggle("is-active", fav)
}
sync()
// 登录态:异步问服务端当前篇是否已收藏并写回本地(换浏览器/设备后,爱心状态与服务端一致)
if (getUser()) {
checkFavorited([meta.id])
.then((set) => {
if (set.has(meta.id)) hydrateFavorited(meta.id, meta.type as "runway" | "street", meta.title, meta.cover, meta.brand)
sync()
})
.catch(() => {})
}
btn.addEventListener("click", () => {
if (!getUser()) {
window.location.href = "/login"
return
}
toggleFavorite({
id: meta.id,
type: meta.type,
title: meta.title,
cover: meta.cover,
brand: meta.brand,
} as any)
sync()
})
}
}
</script>

View File

@ -23,8 +23,17 @@ const failLabel = t('login failed')
const membersLabel = t('members')
const backLabel = t('back to site')
const homeUrl = href(locale, ROUTES.runwayLooks)
const pageTitle = `${loginLabel} — Portfolio Studio`
---
<body class="min-h-screen bg-[#f4f1eb] text-black antialiased selection:bg-black selection:text-white">
<!doctype html>
<html lang={locale === 'cn' ? 'zh-CN' : 'en'}>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<meta name="generator" content={Astro.generator} />
<title>{pageTitle}</title>
</head>
<body class="min-h-screen bg-[#f4f1eb] text-black antialiased selection:bg-black selection:text-white">
<div class="absolute right-0 top-0 z-30 p-6 md:p-10" x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false">
<button type="button" @click="open = !open" @click.outside="open = false" @keydown.escape.window="open = false" class="flex items-center gap-1.5 rounded-full border border-black/10 bg-white/85 px-2.5 py-1.5 text-[10px] font-medium uppercase tracking-[0.26em] text-black backdrop-blur-sm transition-colors hover:bg-black hover:text-white" aria-label={t('Language')} aria-haspopup="listbox" :aria-expanded="open">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="h-[14px] w-[14px] transition-transform duration-200" :class="open ? 'rotate-180' : ''" aria-hidden="true">
@ -118,44 +127,45 @@ const homeUrl = href(locale, ROUTES.runwayLooks)
</div>
</section>
</main>
</body>
<script>
import { login, getUser } from "@/lib/api"
import { clientHref, ROUTES } from "@/lib/routes"
<script>
import { login, getUser } from "@/lib/api"
import { clientHref, ROUTES } from "@/lib/routes"
document.addEventListener("alpine:init", () => {
Alpine.data("loginForm", () => ({
account: "",
password: "",
error: "",
loading: false,
document.addEventListener("alpine:init", () => {
Alpine.data("loginForm", () => ({
account: "",
password: "",
error: "",
loading: false,
init() {
if (getUser()) {
const loc = this.$el.dataset.locale || "en"
window.location.href = clientHref(loc, ROUTES.account)
}
},
submit() {
this.error = ""
this.loading = true
login({ account: this.account, password: this.password })
.then(() => {
const params = new URLSearchParams(window.location.search)
const redirect = params.get("redirect")
init() {
if (getUser()) {
const loc = this.$el.dataset.locale || "en"
window.location.href = redirect || clientHref(loc, ROUTES.account)
})
.catch(() => {
this.error = this.$el.dataset.failLabel || "login failed"
})
.finally(() => {
this.loading = false
})
},
}))
})
</script>
window.location.href = clientHref(loc, ROUTES.account)
}
},
submit() {
this.error = ""
this.loading = true
login({ account: this.account, password: this.password })
.then(() => {
const params = new URLSearchParams(window.location.search)
const redirect = params.get("redirect")
const loc = this.$el.dataset.locale || "en"
window.location.href = redirect || clientHref(loc, ROUTES.account)
})
.catch(() => {
this.error = this.$el.dataset.failLabel || "login failed"
})
.finally(() => {
this.loading = false
})
},
}))
})
</script>
</body>
</html>

View File

@ -255,3 +255,12 @@
@keyframes looksProg { 0% { left: -38%; } 100% { left: 100%; } }
@keyframes looksShim { 0%, 100% { opacity: .85; } 50% { opacity: .55; } }
/* ====================== 卡片收藏按钮(图片收藏入口) ====================== */
/* 白底细边、已收藏时仅实心黑心,尽量不使用黑底白字组件 */
.showscard-wrap { @apply relative; }
.fav-toggle {
@apply absolute right-2 top-2 z-20 grid h-9 w-9 place-items-center rounded-full border border-black/15 bg-white/85 text-black backdrop-blur-sm transition-transform duration-200 hover:scale-110 active:scale-95;
}
.fav-toggle svg { @apply h-[18px] w-[18px]; fill: none; stroke: currentColor; stroke-width: 1.6; }
.fav-toggle.is-on svg { fill: #000; stroke: #000; }