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

This commit is contained in:
toom1996
2026-08-25 21:25:20 +08:00
parent 93b6f0bef0
commit d64a046f82
9 changed files with 487 additions and 153 deletions

View File

@ -0,0 +1,14 @@
# 2026-08-25 工作日志
## 新增 /en/ 登录注册页(20:02)
- 用户要求:把 /en/ 下的 login 和 register 做出来,符合当前网站设计。
- 背景:Astro i18n `prefixDefaultLocale: true`,导航 Login/Signup 链接解析为 `/en/login`、`/en/register`,但这两个文件之前不存在(只有根 `src/pages/login.astro` 等 ComingSoon 占位),点开 404。
- 改动(加法式,不动根 ComingSoon 页、不动原 Login.astro/Register.astro 组件):
1. `src/lib/api.ts`:新增 `saveSession(token, user)`,写入 `fa_token`/`fa_user`(与 `getUser`/`fetchMe` 共用 key),供登录/注册后落地本地会话。
2. 新建 `src/components/pages/AuthForm.astro`:登录/注册通用编辑风表单(props `mode: 'login'|'register'`)。
- 设计:沿用全站黑白极简编辑风——monospace 顶边 kicker(Account / Sign In|Create Account)、`font-serif font-black` 大标题、monospace 副标题、黑底描边 input(`auth-input`)、黑底白字描边按钮(hover 反白,沿用 `cs-btn` 调性)、底部 footer 链接(注册↔登录互链)。容器用 `w-full max-w-[460px] mx-auto`(避免 m-auto shrink-to-fit,见 MEMORY Layout 陷阱)。
- 客户端 `<script>`(module,可 import):读 `form.dataset.mode/redirect`;提交 POST `/api/auth/login` 或 `/api/auth/register`(body JSON),成功 `saveSession` 后 `location.href = redirect`(优先 `?redirect=`,否则 `/en/`);失败/网络错在 `auth-error` 内联显示;注册时校验两次密码一致。后端不可用时优雅报错,不破坏设计。
3. 新建 `src/pages/en/login.astro`、`src/pages/en/register.astro`:套 `<Layout>` + `<AuthForm>`。
4. `src/layouts/Layout.astro`:原来 `<title>{`111`}</title>` 硬编码 bug,改为支持 `title` prop(`${title} — Portfolio Studio`)。属于让新页面标题正确的必要小修;导航(header)未动。
- 验证:`npm run build` BUILD_EXIT=0;AuthForm/两个 page/Layout `read_lints` 全 0;`dist/client/en/login/index.html`、`en/register/index.html` 均含 `auth-form`/`auth-input`/对应标题;`python -m http.server 8088` 托管 dist/client,`/en/login/`、`/en/register/` 均 200。
- ⚠️ 范围说明:仅做了 /en/ 版。根 `/login`、`/cn/login`(及 register 对应)仍是 ComingSoon 占位,未动。若要补齐 /cn/ 与根版,需再建 `src/pages/cn/login.astro` 等(可复用 AuthForm)。后端 `/api/auth/login|register` 是否就绪未知,未接时提交会显示内联错误。

View File

@ -5,8 +5,8 @@
- **所有走秀页 `/shows`**(新建 2026-08-08):全局走秀档案(区别于 `/articles` 的品牌档案视图 ?brand_id)。基于 articles.astro,标题「All Runway」,保留系列/季节/品牌筛选 + 三视图(Grid/Magazine/Timeline),新增分页 Load More(page/total/busy,append 模式,后端 `?page=&size=30&with_images=5`)。首页 sec-more「全部走秀 →」指向 /shows。 - **所有走秀页 `/shows`**(新建 2026-08-08):全局走秀档案(区别于 `/articles` 的品牌档案视图 ?brand_id)。基于 articles.astro,标题「All Runway」,保留系列/季节/品牌筛选 + 三视图(Grid/Magazine/Timeline),新增分页 Load More(page/total/busy,append 模式,后端 `?page=&size=30&with_images=5`)。首页 sec-more「全部走秀 →」指向 /shows。
- 其余模块(Portfolio / Latest Projects / About / Contact / 登录注册)为「暂未上线」占位页:直接访问旧 URL 或点击导航旧链接均渲染 ComingSoon 占位;**导航(header)保持原样未改动**(用户 2026-08-07 明确「header 别动」),门禁完全靠占位页实现。 - 其余模块(Portfolio / Latest Projects / About / Contact / 登录注册)为「暂未上线」占位页:直接访问旧 URL 或点击导航旧链接均渲染 ComingSoon 占位;**导航(header)保持原样未改动**(用户 2026-08-07 明确「header 别动」),门禁完全靠占位页实现。
- **首页 tabbar**(index.astro, id="tabbar"):仅「发布会(backstage)」上线(默认激活 + Live 徽标,承载 getShows(8) SSR 真实卡片);「灵感(shows)/社媒(events)/街拍(street)」标 `data-soon="true"` + Soon 灰徽标,点击仅显示 ComingSoon 占位、不再请求后端;「品牌」tab 仍为指向 /brands 的有效链接(属已上线模块,未屏蔽)。 - **首页 tabbar**(index.astro, id="tabbar"):仅「发布会(backstage)」上线(默认激活 + Live 徽标,承载 getShows(8) SSR 真实卡片);「灵感(shows)/社媒(events)/街拍(street)」标 `data-soon="true"` + Soon 灰徽标,点击仅显示 ComingSoon 占位、不再请求后端;「品牌」tab 仍为指向 /brands 的有效链接(属已上线模块,未屏蔽)。
- 占位实现:src/components/ComingSoon.astro(编辑风,props: module / moduleZh / note;两个按钮「浏览走秀档案 →」「返回首页」)。6 个页面(portfolio / latest-projects / about / contact / login / register)改为 `<Layout><ComingSoon/></Layout>`。 - 占位实现:src/components/ComingSoon.astro(编辑风,props: module / moduleZh / note;两个按钮「浏览走秀档案 →」「返回首页」)。portfolio / latest-projects / about / contact 仍为 ComingSoon 占位页。
- login/register 原 standalone 页(未 import global.css、字体变量未定义),已改为套 Layout + ComingSoon 占位页,顺带修复字体一致性;导航中 Login/Signup 链接保留,点击即见占位。 - **登录/注册(2026-08-25 更新)**:`/en/login`、`/en/register` 已做成真实编辑风页面(套 Layout + 新建 `src/components/pages/AuthForm.astro` 通用表单组件,`mode:'login'|'register'`,客户端 POST `/api/auth/login|register`,用 `saveSession` 写 `fa_token`/`fa_user`)。根 `/login`(src/pages/login.astro) 与 `/cn/login` 等**仍是 ComingSoon 占位**(未动,属当时范围外);导航 Login/Signup 在 en 语境下现指向真实的 /en/login、/en/register。
## 架构总览 ## 架构总览
- 前端:/d/project/test/test — Astro SSG + Tailwind v4。构建 `PUBLIC_API_BASE=http://localhost:8090 npm run build`;预览 `python -m http.server` 托管 dist/。 - 前端:/d/project/test/test — Astro SSG + Tailwind v4。构建 `PUBLIC_API_BASE=http://localhost:8090 npm run build`;预览 `python -m http.server` 托管 dist/。

View File

@ -93,129 +93,128 @@ const seasonOptions: { value: string; label: string }[] = [
<div class="showsbody"> <div class="showsbody">
<!-- LEFT SIDEBAR --> <!-- LEFT SIDEBAR -->
<aside class="showsaside"> <aside class="showsaside">
<div class="showsaside-head"> <div class="showsaside-head">
<h2 class="showsaside-h">REFINE</h2> <h2 class="showsaside-h">REFINE</h2>
</div> </div>
<div class="showsaside-body"> <div class="showsaside-body">
<!-- Designer 多选 --> <!-- Designer 多选 -->
<section class="showsg"> <section class="showsg">
<header class="showsg-h" @click="toggleGroup('designers')"> <header class="showsg-h" @click="toggleGroup('designers')">
<span>DESIGNERS</span> <span>{t('brands')}</span>
<span class="showsg-right"> <span class="showsg-right">
<span class="showsg-count" x-text="selectedBrands.length">0</span> <span class="showsg-chev" :class="{ 'is-collapsed': collapsedGroups.designers }">
<span class="showsg-chev" :class="{ 'is-collapsed': collapsedGroups.designers }"> <svg viewBox="0 0 12 12" width="11" height="11"><path d="M2.5 4.5l3.5 3 3.5-3" stroke="currentColor" fill="none" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
<svg viewBox="0 0 12 12" width="11" height="11"><path d="M2.5 4.5l3.5 3 3.5-3" stroke="currentColor" fill="none" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg> </span>
</span> </span>
</span> </header>
</header> <div class="showsg-b" x-show="!collapsedGroups.designers">
<div class="showsg-b" x-show="!collapsedGroups.designers"> <ul class="showschecks">
<ul class="showschecks"> <template x-for="b in hotBrands.slice(0, 10)" :key="b.id">
<template x-for="b in hotBrands.slice(0, 10)" :key="b.id"> <li>
<li> <label class="flex items-center gap-2 w-full cursor-pointer">
<label class="flex items-center gap-2 w-full cursor-pointer"> <input type="checkbox" :value="b.id" :checked="selectedBrands.includes(b.id)" @change="toggleBrand(b.id)" />
<input type="checkbox" :value="b.id" :checked="selectedBrands.includes(b.id)" @change="toggleBrand(b.id)" /> <span x-text="b.name"></span>
<span x-text="b.name"></span> <span class="ct" x-text="b.article_count ? b.article_count : ''"></span>
<span class="ct" x-text="b.article_count ? b.article_count : ''"></span> </label>
</label> </li>
</li> </template>
</template>
</ul>
<a class="showsmore" @click="openBrandModal()" x-text="'ALL BRANDS +'" role="button" tabindex="0"></a>
</div>
</section>
<!-- Collection type -->
<section class="showsg">
<header class="showsg-h" @click="toggleGroup('collection')">
<span>COLLECTION</span>
<span class="showsg-chev" :class="{ 'is-collapsed': collapsedGroups.collection }">
<svg viewBox="0 0 12 12" width="11" height="11"><path d="M2.5 4.5l3.5 3 3.5-3" stroke="currentColor" fill="none" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
</span>
</header>
<div class="showsg-b" x-show="!collapsedGroups.collection">
<ul class="showschecks showschecks--flat">
{collectionOptions.map((o) => (
<li><label><input type="checkbox" value={o.value} x-model="selectedCt" @change="refreshFilter()" /> <span>{o.label}</span></label></li>
))}
</ul> </ul>
</div> <a class="showsmore" @click="openBrandModal()" x-text="'ALL BRANDS +'" role="button" tabindex="0"></a>
</section> </div>
</section>
<!-- Season --> <!-- Collection type -->
<section class="showsg"> <section class="showsg">
<header class="showsg-h" @click="toggleGroup('season')"> <header class="showsg-h" @click="toggleGroup('collection')">
<span>SEASON</span> <span>COLLECTION</span>
<span class="showsg-chev" :class="{ 'is-collapsed': collapsedGroups.season }"> <span class="showsg-chev" :class="{ 'is-collapsed': collapsedGroups.collection }">
<svg viewBox="0 0 12 12" width="11" height="11"><path d="M2.5 4.5l3.5 3 3.5-3" stroke="currentColor" fill="none" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg> <svg viewBox="0 0 12 12" width="11" height="11"><path d="M2.5 4.5l3.5 3 3.5-3" stroke="currentColor" fill="none" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
</span> </span>
</header> </header>
<div class="showsg-b" x-show="!collapsedGroups.season"> <div class="showsg-b" x-show="!collapsedGroups.collection">
<ul class="showschecks showschecks--flat"> <ul class="showschecks showschecks--flat">
{seasonOptions.map((o) => ( {collectionOptions.map((o) => (
<li><label><input type="checkbox" value={o.value} x-model="selectedSeason" @change="refreshFilter()" /> <span>{o.label}</span></label></li> <li><label><input type="checkbox" value={o.value} x-model="selectedCt" @change="refreshFilter()" /> <span>{o.label}</span></label></li>
))} ))}
</ul> </ul>
</div> </div>
</section> </section>
<!-- Year --> <!-- Season -->
<section class="showsg"> <section class="showsg">
<header class="showsg-h" @click="toggleGroup('year')"> <header class="showsg-h" @click="toggleGroup('season')">
<span>YEAR</span> <span>SEASON</span>
<span class="showsg-chev" :class="{ 'is-collapsed': collapsedGroups.year }"> <span class="showsg-chev" :class="{ 'is-collapsed': collapsedGroups.season }">
<svg viewBox="0 0 12 12" width="11" height="11"><path d="M2.5 4.5l3.5 3 3.5-3" stroke="currentColor" fill="none" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg> <svg viewBox="0 0 12 12" width="11" height="11"><path d="M2.5 4.5l3.5 3 3.5-3" stroke="currentColor" fill="none" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
</span> </span>
</header> </header>
<div class="showsg-b" x-show="!collapsedGroups.year"> <div class="showsg-b" x-show="!collapsedGroups.season">
<ul class="showschecks showschecks--flat"> <ul class="showschecks showschecks--flat">
{yearOptions.map((o) => ( {seasonOptions.map((o) => (
<li><label><input type="checkbox" value={String(o.value)} x-model="selectedYear" @change="refreshFilter()" /> <span>{o.label}</span></label></li> <li><label><input type="checkbox" value={o.value} x-model="selectedSeason" @change="refreshFilter()" /> <span>{o.label}</span></label></li>
))} ))}
</ul> </ul>
</div> </div>
</section> </section>
<button class="showsclear" type="button" @click="clearAll()" x-show="pills.length > 0" x-cloak>Clear all ×</button> <!-- Year -->
</div> <section class="showsg">
<header class="showsg-h" @click="toggleGroup('year')">
<span>YEAR</span>
<span class="showsg-chev" :class="{ 'is-collapsed': collapsedGroups.year }">
<svg viewBox="0 0 12 12" width="11" height="11"><path d="M2.5 4.5l3.5 3 3.5-3" stroke="currentColor" fill="none" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
</span>
</header>
<div class="showsg-b" x-show="!collapsedGroups.year">
<ul class="showschecks showschecks--flat">
{yearOptions.map((o) => (
<li><label><input type="checkbox" value={String(o.value)} x-model="selectedYear" @change="refreshFilter()" /> <span>{o.label}</span></label></li>
))}
</ul>
</div>
</section>
<button class="showsclear" type="button" @click="clearAll()" x-show="pills.length > 0" x-cloak>Clear all ×</button>
</div>
</aside> </aside>
<!-- MAIN GRID --> <!-- MAIN GRID -->
<main class="showsmain"> <main class="showsmain">
<div class="showsgrid" :class="{ 'is-loading': loading }"> <div class="showsgrid" :class="{ 'is-loading': loading }">
<!-- 骨架屏:结构与真实卡片完全一致,loading 期间占位,杜绝 加载中→数据完成 的尺寸抖动 --> <!-- 骨架屏:结构与真实卡片完全一致,loading 期间占位,杜绝 加载中→数据完成 的尺寸抖动 -->
<template x-if="loading && items.length === 0"> <template x-if="loading && items.length === 0">
<template x-for="i in skeletonCount" :key="'skel-'+i"> <template x-for="i in skeletonCount" :key="'skel-'+i">
<div class="showscard showscard--skel" aria-hidden="true"> <div class="showscard showscard--skel" aria-hidden="true">
<div class="showscard-left"> <div class="showscard-left">
<div class="showscard-img"></div> <div class="showscard-img"></div>
</div> </div>
<div class="showscard-right"> <div class="showscard-right">
<div class="showscard-info"> <div class="showscard-info">
<div class="showsline h-[41px] mb-[10px] rounded-[2px]"></div> <div class="showsline h-[41px] mb-[10px] rounded-[2px]"></div>
<div class="showsline h-[29px] mb-[14px] rounded-[2px]"></div> <div class="showsline h-[29px] mb-[14px] rounded-[2px]"></div>
<div class="showsline h-[38px] rounded-[2px]"></div> <div class="showsline h-[38px] rounded-[2px]"></div>
</div> </div>
<div class="showscard-strip"> <div class="showscard-strip">
<div class="showscard-thumb"></div> <div class="showscard-thumb"></div>
<div class="showscard-thumb"></div> <div class="showscard-thumb"></div>
<div class="showscard-thumb"></div> <div class="showscard-thumb"></div>
<div class="showscard-thumb"></div> <div class="showscard-thumb"></div>
<div class="showscard-thumb"></div> <div class="showscard-thumb"></div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
</template> </template>
<!-- Empty State --> <!-- Empty State -->
<template x-if="!loading && items.length === 0"> <template x-if="!loading && items.length === 0">
<div class="showsempty">No shows match the current filters</div> <div class="showsempty">No shows match the current filters</div>
</template> </template>
<!-- Card List --> <!-- Card List -->
<template x-for="it in items" :key="it.id"> <template x-for="it in items" :key="it.id">
<a class="showscard" :href="itemHref(it)" :aria-label="`${it.brand_name} ${it.title}`"> <a class="showscard" :href="itemHref(it)" :aria-label="`${it.brand_name} ${it.title}`">
<div class="showscard-left"> <div class="showscard-left">
<div class="showscard-img" :class="{ 'is-loaded': it._imgLoaded }"> <div class="showscard-img" :class="{ 'is-loaded': it._imgLoaded }">
@ -238,12 +237,12 @@ const seasonOptions: { value: string; label: string }[] = [
</div> </div>
</div> </div>
</a> </a>
</template> </template>
</div> </div>
<div class="showsloadmore-wrap" x-show="items.length > 0"> <div class="showsloadmore-wrap" x-show="items.length > 0">
<button class="showsloadmore" type="button" :disabled="loadingMore || isEnd" @click="loadPage(page + 1, true)" x-text="loadBtnText"></button> <button class="showsloadmore" type="button" :disabled="loadingMore || isEnd" @click="loadPage(page + 1, true)" x-text="loadBtnText"></button>
</div> </div>
</main> </main>
</div> </div>

View File

@ -0,0 +1,262 @@
---
// 账号表单(登录 / 注册通用),编辑风黑白设计,匹配全站视觉。
// - mode: "login" | "register"
// - 提交:POST /api/auth/login 或 /api/auth/register,成功后写入本地会话并跳转。
// - 后端不可用时也能优雅显示错误(不破坏设计)。
import { getI18n } from "@/i18n/utils"
import { href, ROUTES } from "@/lib/routes"
interface Props {
mode?: "login" | "register"
}
const { mode = "login" } = Astro.props
const { locale } = getI18n(Astro)
const isLogin = mode === "login"
const title = isLogin ? "Sign In" : "Create Account"
const kickerRight = isLogin ? "Sign In" : "Create Account"
const subtitle = isLogin
? "Sign in to your account to access the runway archive."
: "Create an account to save shows and build your own archive."
const submitLabel = isLogin ? "Sign In" : "Create Account"
// 登录/注册成功后回跳目标:优先 ?redirect=,否则回 /en/
const redirect = Astro.url.searchParams.get("redirect") || href(locale, ROUTES.home)
const loginHref = href(locale, ROUTES.login)
const registerHref = href(locale, ROUTES.register)
---
<section class="auth-wrap">
<div class="auth-kicker">
<span>Account</span>
<span>{kickerRight}</span>
</div>
<h1 class="auth-title">{title}</h1>
<p class="auth-sub">{subtitle}</p>
<form id="auth-form" class="auth-form" data-mode={mode} data-redirect={redirect} novalidate>
{!isLogin && (
<label class="auth-field">
<span class="auth-label">Username</span>
<input
class="auth-input"
type="text"
name="username"
autocomplete="username"
placeholder="yourname"
required
/>
</label>
)}
<label class="auth-field">
<span class="auth-label">Email</span>
<input
class="auth-input"
type="email"
name="email"
autocomplete="email"
placeholder="you@example.com"
required
/>
</label>
<label class="auth-field">
<span class="auth-label">Password</span>
<input
class="auth-input"
type="password"
name="password"
autocomplete={isLogin ? "current-password" : "new-password"}
placeholder="••••••••"
required
/>
</label>
{!isLogin && (
<label class="auth-field">
<span class="auth-label">Confirm Password</span>
<input
class="auth-input"
type="password"
name="confirm"
autocomplete="new-password"
placeholder="••••••••"
required
/>
</label>
)}
<p id="auth-error" class="auth-error" hidden></p>
<button type="submit" class="auth-submit">{submitLabel}</button>
</form>
<div class="auth-foot">
{isLogin ? (
<>
<a class="auth-link" href={registerHref}>Don&rsquo;t have an account? Sign up &rarr;</a>
<a class="auth-link muted" href="#">Forgot your password?</a>
</>
) : (
<a class="auth-link" href={loginHref}>Already have an account? Sign in &rarr;</a>
)}
</div>
</section>
<style>
@reference "tailwindcss";
.auth-wrap {
@apply w-full max-w-[460px] mx-auto;
padding-top: clamp(40px, 7vw, 90px);
padding-bottom: 40px;
}
.auth-kicker {
display: flex;
justify-content: space-between;
border-top: 1px solid #000;
padding-top: 18px;
@apply font-mono text-[11px] tracking-[0.25em] uppercase text-[#666];
}
.auth-title {
@apply font-serif font-black tracking-[-0.02em] text-black;
line-height: 0.9;
font-size: clamp(44px, 9vw, 88px);
margin: 0.16em 0 0.18em;
}
.auth-sub {
@apply font-mono text-[12px] text-[#666];
letter-spacing: 0.04em;
margin: 0 0 34px;
max-width: 42ch;
}
.auth-form {
@apply flex flex-col;
}
.auth-field {
@apply block mb-[22px];
}
.auth-label {
display: block;
@apply font-mono text-[11px] uppercase tracking-[0.2em] text-black mb-[8px];
}
.auth-input {
@apply w-full border border-black bg-white px-[14px] py-[12px] font-sans text-[14px] text-black;
outline: none;
transition: background-color 0.15s ease;
}
.auth-input::placeholder {
@apply text-black/30;
}
.auth-input:focus {
background-color: rgba(0, 0, 0, 0.03);
}
.auth-error {
@apply font-mono text-[12px] text-black;
border: 1px solid #000;
background-color: rgba(0, 0, 0, 0.04);
padding: 10px 12px;
margin: 0 0 18px;
}
.auth-submit {
@apply w-full bg-black text-white border border-black px-[18px] py-[14px] text-[12px] uppercase tracking-[0.18em] font-bold cursor-pointer;
transition: background-color 0.2s ease, color 0.2s ease;
}
.auth-submit:hover {
@apply bg-white text-black;
}
.auth-submit:disabled {
@apply opacity-50 cursor-not-allowed;
}
.auth-foot {
@apply mt-[26px] pt-[22px] border-t border-zinc-200 flex flex-col gap-[10px];
@apply font-mono text-[12px] text-[#666];
}
.auth-link {
@apply text-black no-underline;
}
.auth-link:hover {
text-decoration: underline;
}
.auth-link.muted {
@apply text-[#666];
}
</style>
<script>
import { saveSession } from "@/lib/api"
const form = document.getElementById("auth-form") as HTMLFormElement | null
if (form) {
const errEl = document.getElementById("auth-error") as HTMLParagraphElement | null
const btn = form.querySelector('button[type="submit"]') as HTMLButtonElement | null
const mode = (form.dataset.mode || "login") as "login" | "register"
const redirect = form.dataset.redirect || "/"
const showErr = (msg: string) => {
if (!errEl) return
errEl.textContent = msg
errEl.hidden = false
}
form.addEventListener("submit", async (e) => {
e.preventDefault()
if (errEl) errEl.hidden = true
const data = Object.fromEntries(new FormData(form).entries()) as Record<string, string>
if (mode === "register" && data.password !== data.confirm) {
showErr("Passwords do not match.")
return
}
const label = mode === "login" ? "Sign In" : "Create Account"
const orig = btn?.textContent ?? label
if (btn) {
btn.disabled = true
btn.textContent = "…"
}
try {
const endpoint = mode === "login" ? "/api/auth/login" : "/api/auth/register"
const res = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
})
const json: any = await res.json().catch(() => ({}))
if (!res.ok) {
showErr(json?.message || json?.error || `Unable to ${label.toLowerCase()}. Please try again.`)
return
}
const token = json?.data?.token ?? json?.token
const user = json?.data?.user ?? json?.user
if (token && user) {
saveSession(token, user)
location.href = redirect
return
}
showErr("Unexpected response. Please try again.")
} catch {
showErr("Network error. Please try again.")
} finally {
if (btn) {
btn.disabled = false
btn.textContent = orig
}
}
})
}
</script>

View File

@ -18,53 +18,86 @@ type OtherLocale = Exclude<Locale, typeof DEFAULT_LOCALE>;
type TranslationEntry = Partial<Record<OtherLocale, string>>; type TranslationEntry = Partial<Record<OtherLocale, string>>;
export const dict: Record<string, TranslationEntry> = { export const dict: Record<string, TranslationEntry> = {
// ── 导航 / Navigation ── // ── 导航 / Navigation ──
'About': { cn: '关于' },
'全部的 runway': { cn: '全部的 runway' }, 'about': { cn: '关于' },
'Contact': { cn: '联系' },
'Contact Us': { cn: '联系我们' }, '全部的 runway': { cn: '全部的 runway' },
'Home': { cn: '首页' },
'Latest Projects': { cn: '最新项目' }, 'contact': { cn: '联系' },
'Login': { cn: '登录' },
'Portfolio': { cn: '作品集' }, 'contact us': { cn: '联系我们' },
'Runway Archive': { cn: '走秀档案' },
'Signup': { cn: '注册' }, 'home': { cn: '首页' },
'latest projects': { cn: '最新项目' },
'login': { cn: '登录' },
'portfolio': { cn: '作品集' },
'runway archive': { cn: '走秀档案' },
'signup': { cn: '注册' },
// ── 控件 / Controls ── // ── 控件 / Controls ──
'Back to Index': { cn: '返回首页' },
'Back to top': { cn: '回到顶部' }, 'back to index': { cn: '返回首页' },
'Close': { cn: '关闭' },
'Previous image': { cn: '上一张' }, 'back to top': { cn: '回到顶部' },
'Next image': { cn: '下一张' },
'Previous': { cn: '上一组' }, 'close': { cn: '关闭' },
'Next': { cn: '下一组' },
'previous image': { cn: '上一张' },
'next image': { cn: '下一张' },
'previous': { cn: '上一组' },
'next': { cn: '下一组' },
// ── 错误 / Errors ── // ── 错误 / Errors ──
'No article ID specified': { cn: '未指定文章 ID' },
'Failed to load article. Please refresh or try later.': { cn: '加载文章失败,请刷新或稍后再试' }, 'no article id specified': { cn: '未指定文章 ID' },
'Click image to zoom · Scroll to view · Esc to close': { cn: '点击图片放大 · 上下滚动查看 · Esc 关闭' },
'failed to load article. please refresh or try later.': { cn: '加载文章失败,请刷新或稍后再试' },
'click image to zoom · scroll to view · esc to close': { cn: '点击图片放大 · 上下滚动查看 · Esc 关闭' },
// ── 页脚 / Footer ── // ── 页脚 / Footer ──
'© 2026 PORTFOLIO STUDIO. ALL RIGHTS RESERVED.': { cn: '© 2026 PORTFOLIO STUDIO. 保留所有权利。' },
'© 2026 portfolio studio. all rights reserved.': { cn: '© 2026 PORTFOLIO STUDIO. 保留所有权利。' },
// ── 页脚工具 / Footer tools ── // ── 页脚工具 / Footer tools ──
'Color Studio Tools': { cn: '色彩工作室工具' },
'AI Color Trend Generator': { cn: 'AI 色彩趋势生成器' }, 'color studio tools': { cn: '色彩工作室工具' },
'Photo Swatch Extractor': { cn: '图片色板提取器' },
'Upload': { cn: '上传' }, 'ai color trend generator': { cn: 'AI 色彩趋势生成器' },
'Reset B&W Theme': { cn: '重置黑白主题' },
'Default': { cn: '默认' }, 'photo swatch extractor': { cn: '图片色板提取器' },
'upload': { cn: '上传' },
'reset b&w theme': { cn: '重置黑白主题' },
'default': { cn: '默认' },
// ── 页脚简介 / Manifesto ── // ── 页脚简介 / Manifesto ──
'Minimalist editorial portfolio documenting haute couture shows, backstage fittings, and street culture. Integrated with 2026 color forecast intelligence and AI palette generator.':
'minimalist editorial portfolio documenting haute couture shows, backstage fittings, and street culture. integrated with 2026 color forecast intelligence and ai palette generator.':
{ cn: '极简编辑风格作品集,记录高级定制秀场、后台试装与街头文化。集成 2026 色彩预测情报与 AI 调色板生成器。' }, { cn: '极简编辑风格作品集,记录高级定制秀场、后台试装与街头文化。集成 2026 色彩预测情报与 AI 调色板生成器。' },
// ── 登录态 / Auth ── // ── 登录态 / Auth ──
'Hi': { cn: '你好' },
'Logout': { cn: '退出' },
'Language': { cn: '语言' },
'Runway looks': {cn: '秀场造型'} 'hi': { cn: '你好' },
'logout': { cn: '退出' },
'language': { cn: '语言' },
'runway looks': { cn: '秀场造型' }
}; };
/** 把 {name} 形式的占位符替换成参数值。 */ /** 把 {name} 形式的占位符替换成参数值。 */
@ -81,7 +114,7 @@ function interpolate(text: string, params?: Record<string, string | number>): st
export function translate(key: string, locale: Locale, params?: Record<string, string | number>): string { export function translate(key: string, locale: Locale, params?: Record<string, string | number>): string {
if (locale === DEFAULT_LOCALE) return interpolate(key, params); if (locale === DEFAULT_LOCALE) return interpolate(key, params);
const localized = dict[key]?.[locale as OtherLocale]; const localized = dict[key.toLowerCase()]?.[locale as OtherLocale];
if (localized) return interpolate(localized, params); if (localized) return interpolate(localized, params);
if (import.meta.env.DEV) { if (import.meta.env.DEV) {

View File

@ -8,6 +8,9 @@ import { toCodes } from 'astro:i18n';
import LanguageDetector from '@/components/LanguageDetector.astro' import LanguageDetector from '@/components/LanguageDetector.astro'
const { title } = Astro.props as { title?: string }
const siteTitle = title ? `${title} — Portfolio Studio` : "Portfolio Studio"
const { locale, t, getRelativeLocaleUrl } = getI18n(Astro) const { locale, t, getRelativeLocaleUrl } = getI18n(Astro)
// 经 data-* 属性把已翻译字符串传入打包型 <script>;define:vars 会让脚本变内联 classic,导致 import 报错,故改用 data 属性 // 经 data-* 属性把已翻译字符串传入打包型 <script>;define:vars 会让脚本变内联 classic,导致 import 报错,故改用 data 属性
@ -48,7 +51,7 @@ const headers = {
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
<meta name="generator" content={Astro.generator} /> <meta name="generator" content={Astro.generator} />
<title>{`111`}</title> <title>{siteTitle}</title>
</head> </head>
<body class="bg-white text-black min-h-screen flex flex-col p-[30px_50px] font-sans"> <body class="bg-white text-black min-h-screen flex flex-col p-[30px_50px] font-sans">

View File

@ -358,6 +358,13 @@ export async function fetchMe(): Promise<AuthUser | null> {
} }
} }
// 登录/注册成功后写入本地会话(token + user),供 Layout 顶部已登录态与 fetchMe 读取。
export function saveSession(token: string, user: AuthUser): void {
if (typeof localStorage === "undefined") return
localStorage.setItem(TOKEN_KEY, token)
localStorage.setItem(USER_KEY, JSON.stringify(user))
}
// ===================== SSG 构建专用(/api/ssg/*,仅 Node 构建期) ===================== // ===================== SSG 构建专用(/api/ssg/*,仅 Node 构建期) =====================
function ssgEnv(name: string): string | undefined { function ssgEnv(name: string): string | undefined {

8
src/pages/en/login.astro Normal file
View File

@ -0,0 +1,8 @@
---
import Layout from "@/layouts/Layout.astro"
import AuthForm from "@/components/pages/AuthForm.astro"
---
<Layout title="Sign In">
<AuthForm mode="login" />
</Layout>

View File

@ -0,0 +1,8 @@
---
import Layout from "@/layouts/Layout.astro"
import AuthForm from "@/components/pages/AuthForm.astro"
---
<Layout title="Create Account">
<AuthForm mode="register" />
</Layout>