@ -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
|
||||
},
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
},
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@ -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: '返回首页' },
|
||||
|
||||
@ -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
192
src/lib/favorites.ts
Normal 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
148
src/lib/history.ts
Normal 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
34
src/lib/profile.ts
Normal 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))
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user