diff --git a/_tmp_build.log b/_tmp_build.log
new file mode 100644
index 0000000..8bf35fe
--- /dev/null
+++ b/_tmp_build.log
@@ -0,0 +1,11 @@
+
+> test@0.0.1 build
+> astro build
+
+[safe-delete] 操作失败: ERROR D:\project\test\test\node_modules\.vite\deps: Error during a `trash` operation: Unknown { description: "Some operations were aborted" }
+ [1mStack trace:[22m
+[2m at trashViaBinary (D:\apps\workbuddy\resources\app.asar.unpacked\cli\vendor\shim\genie-safe-delete.cjs:270:15)
+ at tryTrash (D:\apps\workbuddy\resources\app.asar.unpacked\cli\vendor\shim\genie-safe-delete.cjs:547:5)
+ at Object.wrappedPromisesRm [as rm] (D:\apps\workbuddy\resources\app.asar.unpacked\cli\vendor\shim\genie-safe-delete.cjs:766:15)
+ at async optimizeExplicitEnvironmentDeps (file:///D:/project/test/test/node_modules/vite/dist/node/chunks/node.js:31978:25)
+ at async Promise.all (index 1)[22m
diff --git a/astro.config.mjs b/astro.config.mjs
index 1def40c..fe1dd1d 100644
--- a/astro.config.mjs
+++ b/astro.config.mjs
@@ -11,6 +11,13 @@ export default defineConfig({
vite: {
plugins: [tailwindcss()]
},
+ i18n: {
+ defaultLocale: 'en',
+ locales: ['en', 'cn'],
+ routing: {
+ prefixDefaultLocale: false, // 英文在根路径 /shows,中文在 /cn/shows
+ },
+ },
build: {
// 示例:在构建过程中生 成`page.html` 而不是 `page/index.html`。
format: 'file'
diff --git a/src/components/pages/About.astro b/src/components/pages/About.astro
new file mode 100644
index 0000000..3f4ac53
--- /dev/null
+++ b/src/components/pages/About.astro
@@ -0,0 +1,6 @@
+---
+// 页面主体(与语言无关的内容),由根页面与 [locale] 页面共用
+import ComingSoon from "../../components/ComingSoon.astro"
+---
+
+
diff --git a/src/components/pages/Contact.astro b/src/components/pages/Contact.astro
new file mode 100644
index 0000000..cf241e7
--- /dev/null
+++ b/src/components/pages/Contact.astro
@@ -0,0 +1,6 @@
+---
+// 页面主体(与语言无关的内容),由根页面与 [locale] 页面共用
+import ComingSoon from "../../components/ComingSoon.astro"
+---
+
+
diff --git a/src/components/pages/LatestProjects.astro b/src/components/pages/LatestProjects.astro
new file mode 100644
index 0000000..abd52e2
--- /dev/null
+++ b/src/components/pages/LatestProjects.astro
@@ -0,0 +1,6 @@
+---
+// 页面主体(与语言无关的内容),由根页面与 [locale] 页面共用
+import ComingSoon from "../../components/ComingSoon.astro"
+---
+
+
diff --git a/src/components/pages/Login.astro b/src/components/pages/Login.astro
new file mode 100644
index 0000000..f27463e
--- /dev/null
+++ b/src/components/pages/Login.astro
@@ -0,0 +1,6 @@
+---
+// 页面主体(与语言无关的内容),由根页面与 [locale] 页面共用
+import ComingSoon from "../../components/ComingSoon.astro"
+---
+
+
diff --git a/src/components/pages/Portfolio.astro b/src/components/pages/Portfolio.astro
new file mode 100644
index 0000000..e96a16a
--- /dev/null
+++ b/src/components/pages/Portfolio.astro
@@ -0,0 +1,6 @@
+---
+// 页面主体(与语言无关的内容),由根页面与 [locale] 页面共用
+import ComingSoon from "../../components/ComingSoon.astro"
+---
+
+
diff --git a/src/components/pages/Register.astro b/src/components/pages/Register.astro
new file mode 100644
index 0000000..da59b7c
--- /dev/null
+++ b/src/components/pages/Register.astro
@@ -0,0 +1,6 @@
+---
+// 页面主体(与语言无关的内容),由根页面与 [locale] 页面共用
+import ComingSoon from "../../components/ComingSoon.astro"
+---
+
+
diff --git a/src/components/pages/article.astro b/src/components/pages/article.astro
new file mode 100644
index 0000000..875075c
--- /dev/null
+++ b/src/components/pages/article.astro
@@ -0,0 +1,460 @@
+---
+
+// 示例数据:字段严格对齐后端 BrandRunway 模型 + 图片集接口
+const article = {
+ id: 1,
+ title: "Maison Lumen — Paper Light",
+ description:
+ "巴黎,2026 秋冬。Maison Lumen 以“Paper Light(纸光)”为题,将薄纱、宣纸与压褶工艺叠合成一种近乎透明的体积感。\n\n系列延续了工作室对“轻”的执念:肩线被刻意放低,廓形在行走中随风摆动,像被光穿透的纸。色彩仅保留骨白、炭灰与一抹雾蓝。\n\n这不是关于廓形的宣言,而是关于 material 的克制——用最少的手段,制造最大的呼吸感。",
+ created_at: 1711948800,
+ updated_at: 1711948800,
+ year: 2026,
+ source_url: "https://example.com/runway/maison-lumen-paper-light",
+ brand_id: 12,
+ brand_name: "Maison Lumen",
+ images: [
+ { id: 1, image: "https://picsum.photos/seed/art1/1200/1500", name: "Look 01", sort_order: 1 },
+ { id: 2, image: "https://picsum.photos/seed/art2/1200/1500", name: "Look 02", sort_order: 2 },
+ { id: 3, image: "https://picsum.photos/seed/art3/1200/1500", name: "Look 03", sort_order: 3 },
+ { id: 4, image: "https://picsum.photos/seed/art4/1200/1500", name: "Look 04", sort_order: 4 },
+ ],
+}
+
+const fmt = (ts: number) => {
+ const d = new Date(ts * 1000)
+ const y = d.getFullYear()
+ const m = String(d.getMonth() + 1).padStart(2, "0")
+ const day = String(d.getDate()).padStart(2, "0")
+ return `${y}.${m}.${day}`
+}
+
+const body = article.description.split("\n\n")
+---
+
+
+
+
+
← Index
+
Runway · {article.brand_name}
+
+
+
+
+ {article.title}
+
+
+
+
+
{fmt(article.created_at)}
+
{article.year}
+
Source ↗
+
+
+
+
+
+ Report
+
+
+ {body.map((p) =>
{p}
)}
+
+
+
+
+
+
+
+
+
+ {article.images.map((im) => (
+
+
+ {im.name}
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
![]()
+
+
+
+
点击图片放大 · 上下滚动查看 · Esc 关闭
+
+
+
+
+
+
+
diff --git a/src/components/pages/articles.astro b/src/components/pages/articles.astro
new file mode 100644
index 0000000..37ae6ef
--- /dev/null
+++ b/src/components/pages/articles.astro
@@ -0,0 +1,471 @@
+---
+import { getReports, fmtDate } from "../../lib/data"
+
+// 文章展示创意布局页:复用你现有的 Layout 外壳。
+// 数据由数据层 getReports() 统一从后端公开接口拉取(见 src/lib/data.ts);
+// 后端不可达时回落到内置示例,非对称创意布局结构完全不变。
+
+const reports = await getReports(30)
+const fmt = fmtDate
+---
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/pages/brands.astro b/src/components/pages/brands.astro
new file mode 100644
index 0000000..4d9aee8
--- /dev/null
+++ b/src/components/pages/brands.astro
@@ -0,0 +1,255 @@
+---
+
+// 品牌索引页(HBX 风格:每个字母一行,品牌名横向展开)
+// - 数据:一次 SSR 拉完全部精选集合(?size=300&only_with_articles=1&featured=1),
+// 前端按 name 首字母分桶渲染;198 个品牌数据量小,无需懒加载。
+// - 风格:极简编辑风(黑白 + monospace + 细线)。
+// · 顶部单行 BRANDS 标题 + 总数
+// · sticky A-Z 字母条(点击平滑滚动)
+// · 每个字母一行:左侧字母标签 + 右侧该字母下品牌名横向铺成多列
+// · 无副标题、无数字 badge、无大字标题、无悬停黑底、无搜索框
+// 构建期 SSR 拉数据用 API_SSR(容器内部后端地址,如 http://backend:8090);
+// 运行期浏览器走同源 /api(由 nginx 反代),PUBLIC_API_BASE 置空即可。
+const API = (import.meta.env.API_SSR || import.meta.env.PUBLIC_API_BASE || "http://localhost:8090").replace(/\/$/, "")
+
+interface Brand {
+ id: number
+ name: string
+ show_name: string
+ article_count: number
+}
+
+let brands: Brand[] = []
+let loadError = false
+try {
+ const res = await fetch(`${API}/api/public/brands?size=300&only_with_articles=1&featured=1`)
+ if (res.ok) {
+ const j = await res.json()
+ brands = (j?.data ?? []) as Brand[]
+ } else {
+ loadError = true
+ }
+} catch {
+ loadError = true
+}
+
+const LETTERS = Array.from("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
+
+// 主显示名:拉丁/英文名优先(用于 A-Z 归类与展示),无拉丁名时回退中文
+const primaryName = (b: Brand): string => {
+ const name = (b.name || "").trim()
+ const show = (b.show_name || "").trim()
+ if (/^[A-Za-z]/.test(name)) return name
+ if (show) {
+ const segs = show.split("/")
+ const lat = segs.find((s) => /^[A-Za-z]/.test(s.trim()))
+ if (lat) return lat.trim()
+ }
+ return name || show || "Unnamed"
+}
+
+// 按首字母归类到 A-Z(中文/符号开头的品牌不再单独成桶,直接忽略)
+type Bucket = Record
+const buckets: Bucket = {}
+LETTERS.forEach((l) => (buckets[l] = []))
+
+for (const b of brands) {
+ const p = primaryName(b)
+ const first = (p[0] || "").toUpperCase()
+ if (first >= "A" && first <= "Z") buckets[first].push(b)
+}
+
+// 按字母顺序输出行
+const orderedColumns: { key: string; label: string; items: Brand[] }[] = []
+LETTERS.forEach((l) => {
+ if (buckets[l].length > 0) orderedColumns.push({ key: l, label: l, items: buckets[l] })
+})
+
+const total = orderedColumns.reduce((s, c) => s + c.items.length, 0)
+---
+
+
+
+
+
+ Brands
+ {total.toLocaleString()}
+
+
+
+
+
+
+
+
+ {loadError ? (
+ Failed to load brands. Please try again later.
+ ) : total === 0 ? (
+ No brands indexed yet.
+ ) : (
+
+ {orderedColumns.map((col) => (
+
+ ))}
+
+ )}
+
+
+
+
+
+
diff --git a/src/i18n/config.ts b/src/i18n/config.ts
new file mode 100644
index 0000000..cea96cf
--- /dev/null
+++ b/src/i18n/config.ts
@@ -0,0 +1,36 @@
+// i18n 核心配置:语言列表、默认语言、URL 前缀规则
+// 改这里即可增减语言;新增语言记得同步 src/i18n/dictionary.ts 里的每个 key(单文件聚合多语言)。
+
+/** 默认语言(无前缀时使用的语言,如 /shows)。 */
+export const DEFAULT_LOCALE = 'en' as const;
+
+/** 支持的语言列表。URL 前缀用的就是这些值,例如 /cn/shows 的 `cn`。 */
+export const LOCALES = ['en', 'cn'] as const;
+export type Locale = (typeof LOCALES)[number];
+
+/**
+ * 默认语言是否带 URL 前缀:
+ * - false → 英文在根路径(/shows),中文在 /cn/shows(本项目采用)
+ * - true → 所有语言都带前缀(/en/shows、/cn/shows)
+ */
+export const PREFIX_DEFAULT_LOCALE = false;
+
+/** 语言显示名(用于语言切换器等 UI)。 */
+export const LOCALE_NAMES: Record = {
+ en: 'English',
+ cn: '中文',
+};
+
+/** 浏览器 Accept-Language / navigator.language → 本项目支持的语言。 */
+export const BROWSER_LOCALE_MAP: Record = {
+ 'zh': 'cn',
+ 'zh-cn': 'cn',
+ 'zh-tw': 'cn',
+ 'zh-hk': 'cn',
+ 'en': 'en',
+};
+
+/** 判断一个路径段是不是已支持的语言前缀。 */
+export function isLocaleSegment(seg: string | undefined): seg is Locale {
+ return !!seg && (LOCALES as readonly string[]).includes(seg);
+}
diff --git a/src/i18n/dictionary.ts b/src/i18n/dictionary.ts
new file mode 100644
index 0000000..4175b15
--- /dev/null
+++ b/src/i18n/dictionary.ts
@@ -0,0 +1,56 @@
+import { DEFAULT_LOCALE, type Locale } from './config';
+
+/**
+ * 单文件翻译字典。
+ *
+ * 设计(按你的要求):
+ * - key 就是「英文原文」,直接当文案用,无需额外维护英文副本;
+ * - value 只存「非默认语言」的翻译(本项目默认语言是 en,所以只存 cn 等);
+ * - 英文环境调用 t(key) 直接返回 key 本身;
+ * - 其它语言从 value 里取对应翻译,取不到就回退英文(开发环境会告警,方便发现漏翻)。
+ *
+ * 新增一条:直接加一行 `'英文原文': { cn: '中文' }` 即可;
+ * 当前是英文时根本不需要在字典里登记,写了 t('Nice day!') 就能渲染 "Nice day!"。
+ */
+
+// 除默认语言外的所有语言(如 'cn')。加新语言(ja 等)时这里会自动包含。
+type OtherLocale = Exclude;
+type TranslationEntry = Partial>;
+
+export const dict: Record = {
+ Portfolio: { cn: '作品集' },
+ 'All Shows': { cn: '全部走秀' },
+ 'Latest Projects': { cn: '最新项目' },
+ About: { cn: '关于' },
+ 'Contact Us': { cn: '联系我们' },
+ Login: { cn: '登录' },
+ Signup: { cn: '注册' },
+ Contact: { cn: '联系' },
+ 'Back to top': { cn: '回到顶部' },
+ '© 2026 PORTFOLIO STUDIO. ALL RIGHTS RESERVED.': { cn: '© 2026 PORTFOLIO STUDIO. 保留所有权利。' },
+ Previous: { cn: '上一组' },
+ Next: { cn: '下一组' },
+};
+
+/** 把 {name} 形式的占位符替换成参数值。 */
+function interpolate(text: string, params?: Record): string {
+ if (!params) return text;
+ return text.replace(/\{(\w+)\}/g, (_, k) => (k in params ? String(params[k]) : `{${k}}`));
+}
+
+/**
+ * 取翻译:
+ * - 当前是默认语言(en)→ 直接返回 key(英文原文);
+ * - 其它语言 → 优先取字典里的翻译,缺失则回退英文并返回 key(不会白屏)。
+ */
+export function translate(key: string, locale: Locale, params?: Record): string {
+ if (locale === DEFAULT_LOCALE) return interpolate(key, params);
+
+ const localized = dict[key]?.[locale as OtherLocale];
+ if (localized) return interpolate(localized, params);
+
+ if (import.meta.env.DEV) {
+ console.warn(`[i18n] 缺少 "${locale}" 翻译:"${key}",已回退英文`);
+ }
+ return interpolate(key, params);
+}
diff --git a/src/i18n/utils.ts b/src/i18n/utils.ts
new file mode 100644
index 0000000..0148e69
--- /dev/null
+++ b/src/i18n/utils.ts
@@ -0,0 +1,49 @@
+import {
+ DEFAULT_LOCALE,
+ LOCALES,
+ PREFIX_DEFAULT_LOCALE,
+ isLocaleSegment,
+ type Locale,
+} from './config';
+import { translate } from './dictionary';
+
+/**
+ * 从当前 URL 解析语言:
+ * - 路径首段若是支持的语言前缀(如 /cn/shows → cn)则用之;
+ * - 否则回退到默认语言(en)。
+ */
+export function getLocaleFromUrl(url: URL | string): Locale {
+ const pathname = typeof url === 'string' ? url : url.pathname;
+ const seg = pathname.split('/').filter(Boolean)[0];
+ return isLocaleSegment(seg) ? seg : DEFAULT_LOCALE;
+}
+
+/**
+ * 生成带语言前缀的链接。
+ * - en(默认且 PREFIX_DEFAULT_LOCALE=false)→ /shows
+ * - cn → /cn/shows
+ * 第二个参数可指定目标语言,方便做语言切换器。
+ */
+export function useTranslatedPath(locale: Locale) {
+ return (path: string, targetLocale: Locale = locale): string => {
+ const clean = path.startsWith('/') ? path : `/${path}`;
+ if (targetLocale === DEFAULT_LOCALE && !PREFIX_DEFAULT_LOCALE) return clean;
+ return `/${targetLocale}${clean}`;
+ };
+}
+
+/**
+ * 取翻译函数 t():key 即英文原文,按当前语言自动匹配翻译。
+ * - 英文环境:t('All Shows') 直接返回 'All Shows'(key 本身)
+ * - 中文环境:t('All Shows') 返回字典里的 '全部走秀'(取不到则回退英文)
+ * - 支持 {name} 形式的插值参数。
+ */
+export function useTranslations(locale: Locale) {
+ function t(key: string, params?: Record): string {
+ return translate(key, locale, params);
+ }
+ return { t, locale };
+}
+
+export { LOCALES, DEFAULT_LOCALE };
+export type { Locale };
diff --git a/src/layouts/Layout.astro b/src/layouts/Layout.astro
index ef5620b..15556a0 100644
--- a/src/layouts/Layout.astro
+++ b/src/layouts/Layout.astro
@@ -1,5 +1,7 @@
---
import '../styles/global.css'
+import { getLocaleFromUrl, useTranslations, useTranslatedPath } from '../i18n/utils'
+import { LOCALES, LOCALE_NAMES, isLocaleSegment } from '../i18n/config'
const {
title = 'Portfolio Studio',
@@ -8,6 +10,17 @@ const {
activeThemePalette = null,
} = Astro.props;
+const locale = getLocaleFromUrl(Astro.url)
+const { t } = useTranslations(locale)
+const translatePath = useTranslatedPath(locale)
+
+// 当前路径去掉语言前缀,用于语言切换器拼出「同页面、另一语言」的链接
+const _segs = Astro.url.pathname.split('/').filter(Boolean);
+const _stripped = _segs.length > 0 && isLocaleSegment(_segs[0])
+ ? '/' + _segs.slice(1).join('/')
+ : Astro.url.pathname;
+const currentPath = _stripped === '' ? '/' : _stripped;
+
const categories: { label: string; value: PortfolioCategory | 'Trending Colors' }[] = [
{ label: 'x', value: 'x' },
{ label: 'Backstage', value: 'Backstage' },
@@ -17,7 +30,7 @@ const categories: { label: string; value: PortfolioCategory | 'Trending Colors'
];
---
-
+
@@ -25,25 +38,55 @@ const categories: { label: string; value: PortfolioCategory | 'Trending Colors'
{title}
+
+