14
.workbuddy/memory/2026-08-25.md
Normal file
14
.workbuddy/memory/2026-08-25.md
Normal 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` 是否就绪未知,未接时提交会显示内联错误。
|
||||||
@ -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/。
|
||||||
|
|||||||
@ -101,9 +101,8 @@ const seasonOptions: { value: string; label: string }[] = [
|
|||||||
<!-- 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>
|
||||||
|
|||||||
262
src/components/pages/AuthForm.astro
Normal file
262
src/components/pages/AuthForm.astro
Normal 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’t have an account? Sign up →</a>
|
||||||
|
<a class="auth-link muted" href="#">Forgot your password?</a>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<a class="auth-link" href={loginHref}>Already have an account? Sign in →</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>
|
||||||
@ -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: '关于' },
|
|
||||||
|
'about': { cn: '关于' },
|
||||||
|
|
||||||
'全部的 runway': { cn: '全部的 runway' },
|
'全部的 runway': { cn: '全部的 runway' },
|
||||||
'Contact': { cn: '联系' },
|
|
||||||
'Contact Us': { cn: '联系我们' },
|
'contact': { cn: '联系' },
|
||||||
'Home': { cn: '首页' },
|
|
||||||
'Latest Projects': { cn: '最新项目' },
|
'contact us': { cn: '联系我们' },
|
||||||
'Login': { cn: '登录' },
|
|
||||||
'Portfolio': { cn: '作品集' },
|
'home': { cn: '首页' },
|
||||||
'Runway Archive': { cn: '走秀档案' },
|
|
||||||
'Signup': { 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) {
|
||||||
|
|||||||
@ -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">
|
||||||
|
|
||||||
|
|||||||
@ -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
8
src/pages/en/login.astro
Normal 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>
|
||||||
8
src/pages/en/register.astro
Normal file
8
src/pages/en/register.astro
Normal 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>
|
||||||
Reference in New Issue
Block a user