diff --git a/frontend-architecture-v3.md b/frontend-architecture-v3.md
index ed137ec..50f46bc 100644
--- a/frontend-architecture-v3.md
+++ b/frontend-architecture-v3.md
@@ -1,6 +1,6 @@
# Windup 前端架构
-本文记录当前前端的模块划分与依赖规则。2026-07-30 按当日评审意见重写:本阶段只提交模块边界与接口,实现进后续 PR。
+本文记录当前前端的模块划分与依赖规则。2026-07-30 按当日评审意见重写为只提交模块边界与接口;实现按模块拆成后续 PR 陆续落地,首页是第一个。
---
@@ -41,6 +41,8 @@ pages -> features -> entities -> shared
`app` 只做启动和路由,不构造服务、不向下注入。
+外壳套在哪些页面上也是路由决策:`AppShellRoute` 写在 `app.tsx` 的路由表里,谁在里面谁就有顶栏,根路由留在外面。外壳组件自身不读 pathname,不判断自己该不该出现——那种写法每多一个特殊页面就多一条 `if`。外壳也不统一夹居中容器,宽度与留白由页面自己决定。
+
### 依赖规则
1. 只能向下依赖,不允许反向。
@@ -84,15 +86,15 @@ Controller 围绕同一份 WorkflowRun 提供推进、更新、重启和中断
---
-## 5. 本次不包含
+## 5. 尚未包含
-- 任何实现代码(真实请求、假数据、组件内部逻辑)
-- 测试文件
-- 图片上传模块(体量太小,本次不单独体现)
+- 真实请求与数据获取,`XxxApis` 目前只有接口
+- 首页之外的页面实现,其余七个路由仍是占位外壳
+- 图片上传模块(体量太小,不单独体现)
- 穿戴道具相关(产品侧未设计)
- 第三方登录
-页面当前是占位外壳,只声明路由与模块边界。
+首页已按本文的分层落地:它不依赖 `entities` 与 `features`,两张入口卡片只做路由跳转。首屏那三段制作路径是 `WORKFLOW_STEP_ORDER` 八步的粗粒度概括,写死在页面文案里,改流程时要一并改。
---
diff --git a/frontend/README.md b/frontend/README.md
index 6e01fa3..2976fd5 100644
--- a/frontend/README.md
+++ b/frontend/README.md
@@ -15,7 +15,7 @@ npm run dev
npm run format:check # 格式
npm run lint # 静态检查
npm run typecheck # 类型
-npm run test # 测试(本阶段无测试文件)
+npm run test # 测试
npm run build # 构建
```
@@ -25,6 +25,8 @@ CI 按上面顺序全跑一遍。
模块划分、依赖规则与命名约定见仓库根目录 `frontend-architecture-v3.md`。
-**本阶段只提交模块边界与接口,不含实现。** 页面是占位外壳,各模块只有类型与 `XxxApis` 接口。实现按模块拆成后续 PR。
+模块边界与接口已经落地,页面实现按模块拆成多个 PR 陆续进来。**目前只有首页是真实现,其余七个路由仍是占位外壳**,`entities` 与 `features` 也只有类型和 `XxxApis` 接口,没有真实请求。
+
+页面自己决定宽度与留白,`AppShell` 只提供顶栏,不再统一夹一个居中容器。
与后端尚未对齐的接口见 `API_CONTRACT.md`。
diff --git a/frontend/public/windup-mark.svg b/frontend/public/windup-mark.svg
new file mode 100644
index 0000000..5b16f25
--- /dev/null
+++ b/frontend/public/windup-mark.svg
@@ -0,0 +1,16 @@
+
diff --git a/frontend/src/app/app.tsx b/frontend/src/app/app.tsx
index b46ae87..06268ea 100644
--- a/frontend/src/app/app.tsx
+++ b/frontend/src/app/app.tsx
@@ -8,18 +8,19 @@ import { ProjectDetailPage } from '@/pages/project-detail'
import { ProjectsPage } from '@/pages/projects'
import { QuickStartPage } from '@/pages/quick-start'
import { WorkflowEditorPage } from '@/pages/workflow-editor'
-import { AppShell } from './layout'
+import { AppShellRoute } from './layout'
/**
* 路由表与全局外壳。
* 页面自己获取所需数据,不再由 app 层构造服务后逐层传入。
+ * 外壳的边界画在这张表上:根路由是满幅首屏,自带入口卡片,不进外壳;其余页面共用常驻导航。
*/
export function App() {
return (
-
-
- } />
+
+ } />
+ }>
} />
} />
} />
@@ -29,8 +30,8 @@ export function App() {
} />
} />
} />
-
-
+
+
)
}
diff --git a/frontend/src/app/layout/index.tsx b/frontend/src/app/layout/index.tsx
index aa21b82..b797b33 100644
--- a/frontend/src/app/layout/index.tsx
+++ b/frontend/src/app/layout/index.tsx
@@ -1,5 +1,5 @@
import type { ReactNode } from 'react'
-import { Link } from 'react-router'
+import { Link, Outlet } from 'react-router'
/** 跨页面常驻导航属于应用外壳,由 app 层统一承载。 */
@@ -13,7 +13,10 @@ export function AppShell({ children }: AppShellProps) {
return (
-
{children}
+ {/* 外壳只管顶栏。页面自己决定宽度与留白,不在这里统一夹到屏幕中间。 */}
+
{children}
)
}
+
+/**
+ * 外壳的路由形态,套在一组子路由外面。
+ * 哪些页面带外壳是路由决策,写在 app 的路由表里;外壳自身不读 pathname、不判断自己该不该出现。
+ */
+export function AppShellRoute() {
+ return (
+
+
+
+ )
+}
diff --git a/frontend/src/pages/asset-library/index.tsx b/frontend/src/pages/asset-library/index.tsx
index 016328b..b90da65 100644
--- a/frontend/src/pages/asset-library/index.tsx
+++ b/frontend/src/pages/asset-library/index.tsx
@@ -1,9 +1,13 @@
+import { PageContainer } from '@/shared/ui'
+
/** 资产库。 */
export function AssetLibraryPage() {
return (
-
- 资产库
- 本次只提交模块划分与接口,页面实现进后续 PR。
-
+
+
+ 资产库
+ 本次只提交模块划分与接口,页面实现进后续 PR。
+
+
)
}
diff --git a/frontend/src/pages/home/brand-bird.tsx b/frontend/src/pages/home/brand-bird.tsx
new file mode 100644
index 0000000..0fc9ef3
--- /dev/null
+++ b/frontend/src/pages/home/brand-bird.tsx
@@ -0,0 +1,154 @@
+import { useEffect, useRef } from 'react'
+
+const SAMPLE_SIZE = 48
+const MAX_DPR = 2
+const WAVE_DURATION = 7200
+
+interface BrandPoint {
+ accent: boolean
+ luminance: number
+ x: number
+ y: number
+}
+
+function smoothStep(value: number) {
+ return value * value * (3 - 2 * value)
+}
+
+function sampleMark(image: HTMLImageElement): BrandPoint[] {
+ const sample = document.createElement('canvas')
+ sample.width = SAMPLE_SIZE
+ sample.height = SAMPLE_SIZE
+ const context = sample.getContext('2d', { willReadFrequently: true })
+ if (!context) return []
+
+ context.drawImage(image, 0, 0, SAMPLE_SIZE, SAMPLE_SIZE)
+ const pixels = context.getImageData(0, 0, SAMPLE_SIZE, SAMPLE_SIZE).data
+ const points: BrandPoint[] = []
+
+ for (let y = 0; y < SAMPLE_SIZE; y += 1) {
+ for (let x = 0; x < SAMPLE_SIZE; x += 1) {
+ if (pixels[(y * SAMPLE_SIZE + x) * 4 + 3] < 80) continue
+ const centerLight = Math.max(
+ 0,
+ 1 - Math.hypot(x / SAMPLE_SIZE - 0.5, y / SAMPLE_SIZE - 0.5) / 0.7,
+ )
+ points.push({
+ accent: Math.hypot(x - SAMPLE_SIZE * 0.44, y - SAMPLE_SIZE * 0.5) < SAMPLE_SIZE * 0.055,
+ luminance: 0.34 + centerLight * 0.3,
+ x: x / (SAMPLE_SIZE - 1),
+ y: y / (SAMPLE_SIZE - 1),
+ })
+ }
+ }
+
+ return points
+}
+
+/** livedemo 主屏点阵小鸟;仅作为主页背景,不接入页面业务。 */
+export function HomeBrandBird() {
+ const canvasRef = useRef(null)
+
+ useEffect(() => {
+ const canvas = canvasRef.current
+ if (!canvas || typeof ResizeObserver === 'undefined') return
+ const context = canvas.getContext('2d')
+ if (!context) return
+
+ const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
+ const image = new Image()
+ image.decoding = 'async'
+ image.src = '/windup-mark.svg'
+
+ let animationFrame = 0
+ let points: BrandPoint[] = []
+ let width = 1
+ let height = 1
+
+ function draw(time = 0) {
+ context!.clearRect(0, 0, width, height)
+ if (!points.length) return
+
+ const markSize = Math.min(width * 0.36, height * 0.66, 576)
+ const originX = Math.max(18, width * 0.02)
+ const originY = height - markSize * 0.9
+ const dotRadius = Math.max(1.15, Math.min(3.6, markSize / 155))
+ const waveProgress = reduceMotion ? 0.5 : (time % WAVE_DURATION) / WAVE_DURATION
+ const primaryCenter = originX + (waveProgress * 1.42 - 0.2) * markSize
+ const echoProgress = (waveProgress + 0.52) % 1
+ const echoCenter = originX + (echoProgress * 1.42 - 0.2) * markSize
+ const spread = markSize * 0.15
+
+ for (const point of points) {
+ const baseX = originX + point.x * markSize
+ const baseY = originY + point.y * markSize
+ const primaryDistance = baseX - primaryCenter
+ const echoDistance = baseX - echoCenter
+ const primaryWave = reduceMotion
+ ? 0
+ : Math.sin(primaryDistance / 14) * Math.exp(-((primaryDistance / spread) ** 2))
+ const echoWave = reduceMotion
+ ? 0
+ : Math.sin(echoDistance / 17) * Math.exp(-((echoDistance / spread) ** 2)) * 0.42
+ const displacement = (primaryWave + echoWave) * 4.2 * (0.38 + smoothStep(point.x) * 0.62)
+ const lightBand = primaryWave * 0.2 + echoWave * 0.1
+ const opacity = Math.max(0.16, Math.min(0.92, point.luminance + lightBand))
+
+ context!.beginPath()
+ context!.arc(
+ baseX + displacement * 0.18,
+ baseY + displacement,
+ dotRadius + Math.max(0, lightBand) * 1.35,
+ 0,
+ Math.PI * 2,
+ )
+ context!.fillStyle = point.accent
+ ? `rgba(176, 119, 47, ${Math.min(0.76, opacity + 0.12)})`
+ : `rgba(38, 63, 45, ${opacity * 0.38})`
+ context!.fill()
+ }
+ }
+
+ function resize() {
+ const bounds = canvas!.getBoundingClientRect()
+ const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR)
+ width = Math.max(1, bounds.width)
+ height = Math.max(1, bounds.height)
+ canvas!.width = Math.round(width * dpr)
+ canvas!.height = Math.round(height * dpr)
+ context!.setTransform(dpr, 0, 0, dpr, 0, 0)
+ draw()
+ }
+
+ function animate(time: number) {
+ draw(time)
+ animationFrame = window.requestAnimationFrame(animate)
+ }
+
+ function load() {
+ points = sampleMark(image)
+ draw()
+ if (!reduceMotion) animationFrame = window.requestAnimationFrame(animate)
+ }
+
+ const observer = new ResizeObserver(resize)
+ observer.observe(canvas)
+ image.addEventListener('load', load, { once: true })
+ resize()
+
+ return () => {
+ window.cancelAnimationFrame(animationFrame)
+ observer.disconnect()
+ image.removeEventListener('load', load)
+ }
+ }, [])
+
+ return (
+
+ )
+}
diff --git a/frontend/src/pages/home/choice-card.test.tsx b/frontend/src/pages/home/choice-card.test.tsx
new file mode 100644
index 0000000..011d8b2
--- /dev/null
+++ b/frontend/src/pages/home/choice-card.test.tsx
@@ -0,0 +1,28 @@
+// @vitest-environment jsdom
+import { cleanup, render, screen } from '@testing-library/react'
+import { afterEach, describe, expect, it } from 'vitest'
+import { MemoryRouter } from 'react-router'
+
+import { HomeChoiceCard } from './choice-card'
+
+afterEach(cleanup)
+
+describe('HomeChoiceCard', () => {
+ it('只根据输入渲染一个可导航入口', () => {
+ render(
+
+
+ ,
+ )
+
+ expect(screen.getByRole('link', { name: /快速开始/ }).getAttribute('href')).toBe('/quick-start')
+ })
+})
diff --git a/frontend/src/pages/home/choice-card.tsx b/frontend/src/pages/home/choice-card.tsx
new file mode 100644
index 0000000..d110be4
--- /dev/null
+++ b/frontend/src/pages/home/choice-card.tsx
@@ -0,0 +1,90 @@
+import { Link } from 'react-router'
+
+export type HomeChoiceCardTone = 'light' | 'dark'
+
+export interface HomeChoiceCardProps {
+ to: string
+ eyebrow: string
+ index: string
+ title: string
+ description: string
+ actionLabel: string
+ tone?: HomeChoiceCardTone
+}
+
+/** Home 专用入口卡;只接收显示内容与目标路由,不持有业务状态。 */
+export function HomeChoiceCard({
+ to,
+ eyebrow,
+ index,
+ title,
+ description,
+ actionLabel,
+ tone = 'light',
+}: HomeChoiceCardProps) {
+ const dark = tone === 'dark'
+
+ return (
+
+
+
+
+
+ {eyebrow}
+
+
+ {index}
+
+
+
+
+
+ {title}
+
+
+ {description}
+
+
+
+
+ {actionLabel}
+
+ →
+
+
+
+ )
+}
diff --git a/frontend/src/pages/home/index.test.tsx b/frontend/src/pages/home/index.test.tsx
new file mode 100644
index 0000000..6b9655c
--- /dev/null
+++ b/frontend/src/pages/home/index.test.tsx
@@ -0,0 +1,23 @@
+// @vitest-environment jsdom
+import { cleanup, render, screen } from '@testing-library/react'
+import { afterEach, describe, expect, it } from 'vitest'
+import { MemoryRouter } from 'react-router'
+
+import { HomePage } from './index'
+
+afterEach(cleanup)
+
+describe('HomePage', () => {
+ it('提供快速开始和项目工作台两个既有入口', () => {
+ render(
+
+
+ ,
+ )
+
+ expect(screen.getByRole('heading', { name: /真正登场/ })).toBeTruthy()
+ expect(screen.getByRole('link', { name: /快速开始/ }).getAttribute('href')).toBe('/quick-start')
+ expect(screen.getByRole('link', { name: /从项目开始/ }).getAttribute('href')).toBe('/projects')
+ expect(screen.getByTestId('home-brand-bird').tagName).toBe('CANVAS')
+ })
+})
diff --git a/frontend/src/pages/home/index.tsx b/frontend/src/pages/home/index.tsx
index 7a153ad..b8adab9 100644
--- a/frontend/src/pages/home/index.tsx
+++ b/frontend/src/pages/home/index.tsx
@@ -1,13 +1,102 @@
-/** 首页:入口与项目概览。 */
-export function HomePage() {
- return
-}
+import { HomeChoiceCard } from './choice-card'
+import { HomeBrandBird } from './brand-bird'
-function PagePlaceholder({ title }: { title: string }) {
+/** 根入口只负责提供两种制作入口,不持有工作流业务状态。 */
+export function HomePage() {
return (
-
- {title}
- 本次只提交模块划分与接口,页面实现进后续 PR。
-
+
+
+
+
+
+
+
+
+ 2D CHARACTER PRODUCTION WORKFLOW
+
+
+ 让你的角色,
+
+ 真正登场。
+
+
+ 从一句设定,到可检查、可修正、能进入游戏项目的 2D 动作资产。Windup
+ 把角色创作收拢在同一条制作线上。
+
+
+
+ {/*
+ 首屏用的三段式说法,是 entities/workflow-run 那八步 WORKFLOW_STEP_ORDER 的粗粒度概括:
+ 确认角色 = character-setup + character-template,生成动作 = first-frame + complete-animation,
+ 检查交付 = review + export;template-candidate 与 action-setup 是流程内部环节,首屏不提。
+ 这份对应关系目前只写在这里,八步一变这段文案不会跟着变,改流程时要一并改。
+ */}
+
+ {[
+ ['01', '确认角色', '设定与母版'],
+ ['02', '生成动作', '首帧与动画'],
+ ['03', '检查交付', '审核与核验'],
+ ].map(([index, label, detail]) => (
+ -
+ {index}
+ {label}
+ {detail}
+
+ ))}
+
+
+
+
+
+
)
}
diff --git a/frontend/src/pages/not-found/index.tsx b/frontend/src/pages/not-found/index.tsx
index d35181b..4fbd653 100644
--- a/frontend/src/pages/not-found/index.tsx
+++ b/frontend/src/pages/not-found/index.tsx
@@ -1,9 +1,13 @@
+import { PageContainer } from '@/shared/ui'
+
/** 页面不存在。 */
export function NotFoundPage() {
return (
-
- 页面不存在
- 本次只提交模块划分与接口,页面实现进后续 PR。
-
+
+
+ 页面不存在
+ 本次只提交模块划分与接口,页面实现进后续 PR。
+
+
)
}
diff --git a/frontend/src/pages/playtest/index.tsx b/frontend/src/pages/playtest/index.tsx
index 9796e86..8e5827d 100644
--- a/frontend/src/pages/playtest/index.tsx
+++ b/frontend/src/pages/playtest/index.tsx
@@ -1,9 +1,13 @@
+import { PageContainer } from '@/shared/ui'
+
/** 核验台。 */
export function PlaytestPage() {
return (
-
- 核验台
- 本次只提交模块划分与接口,页面实现进后续 PR。
-
+
+
+ 核验台
+ 本次只提交模块划分与接口,页面实现进后续 PR。
+
+
)
}
diff --git a/frontend/src/pages/project-detail/index.tsx b/frontend/src/pages/project-detail/index.tsx
index cc6ff5c..911b1e4 100644
--- a/frontend/src/pages/project-detail/index.tsx
+++ b/frontend/src/pages/project-detail/index.tsx
@@ -1,9 +1,13 @@
+import { PageContainer } from '@/shared/ui'
+
/** 项目详情。 */
export function ProjectDetailPage() {
return (
-
- 项目详情
- 本次只提交模块划分与接口,页面实现进后续 PR。
-
+
+
+ 项目详情
+ 本次只提交模块划分与接口,页面实现进后续 PR。
+
+
)
}
diff --git a/frontend/src/pages/projects/index.tsx b/frontend/src/pages/projects/index.tsx
index 8f807f7..6c252ea 100644
--- a/frontend/src/pages/projects/index.tsx
+++ b/frontend/src/pages/projects/index.tsx
@@ -1,9 +1,13 @@
+import { PageContainer } from '@/shared/ui'
+
/** 项目列表。 */
export function ProjectsPage() {
return (
-
- 项目列表
- 本次只提交模块划分与接口,页面实现进后续 PR。
-
+
+
+ 项目列表
+ 本次只提交模块划分与接口,页面实现进后续 PR。
+
+
)
}
diff --git a/frontend/src/pages/quick-start/index.tsx b/frontend/src/pages/quick-start/index.tsx
index 1ffe9fe..10e1be5 100644
--- a/frontend/src/pages/quick-start/index.tsx
+++ b/frontend/src/pages/quick-start/index.tsx
@@ -1,9 +1,13 @@
+import { PageContainer } from '@/shared/ui'
+
/** 快速开始。 */
export function QuickStartPage() {
return (
-
- 快速开始
- 本次只提交模块划分与接口,页面实现进后续 PR。
-
+
+
+ 快速开始
+ 本次只提交模块划分与接口,页面实现进后续 PR。
+
+
)
}
diff --git a/frontend/src/pages/workflow-editor/index.tsx b/frontend/src/pages/workflow-editor/index.tsx
index 59df779..9c5afec 100644
--- a/frontend/src/pages/workflow-editor/index.tsx
+++ b/frontend/src/pages/workflow-editor/index.tsx
@@ -1,9 +1,13 @@
+import { PageContainer } from '@/shared/ui'
+
/** 工作流画布。 */
export function WorkflowEditorPage() {
return (
-
- 工作流画布
- 本次只提交模块划分与接口,页面实现进后续 PR。
-
+
+
+ 工作流画布
+ 本次只提交模块划分与接口,页面实现进后续 PR。
+
+
)
}
diff --git a/frontend/src/shared/ui/index.ts b/frontend/src/shared/ui/index.ts
new file mode 100644
index 0000000..dab9f90
--- /dev/null
+++ b/frontend/src/shared/ui/index.ts
@@ -0,0 +1,2 @@
+export { PageContainer } from './page-container'
+export type { PageContainerProps } from './page-container'
diff --git a/frontend/src/shared/ui/page-container.tsx b/frontend/src/shared/ui/page-container.tsx
new file mode 100644
index 0000000..157c8cd
--- /dev/null
+++ b/frontend/src/shared/ui/page-container.tsx
@@ -0,0 +1,16 @@
+import type { ReactNode } from 'react'
+
+export interface PageContainerProps {
+ children: ReactNode
+}
+
+/**
+ * 内容页的居中容器,页面按需套用;外壳不再统一施加宽度与留白。
+ *
+ * 最大宽度 1280px(`max-w-7xl`)取自应用型界面 1200–1440px 的常见区间,
+ * 与以正文阅读为主的 640–720px 不是一档——首屏、Playtest 这类满幅页面不套本组件。
+ * 只提供一种尺寸:出现第二种真实需求时再加参数,不预留档位。
+ */
+export function PageContainer({ children }: PageContainerProps) {
+ return {children}
+}