Skip to content
14 changes: 8 additions & 6 deletions frontend-architecture-v3.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Windup 前端架构

本文记录当前前端的模块划分与依赖规则。2026-07-30 按当日评审意见重写:本阶段只提交模块边界与接口,实现进后续 PR。
本文记录当前前端的模块划分与依赖规则。2026-07-30 按当日评审意见重写为只提交模块边界与接口;实现按模块拆成后续 PR 陆续落地,首页是第一个

---

Expand Down Expand Up @@ -41,6 +41,8 @@ pages -> features -> entities -> shared

`app` 只做启动和路由,不构造服务、不向下注入。

外壳套在哪些页面上也是路由决策:`AppShellRoute` 写在 `app.tsx` 的路由表里,谁在里面谁就有顶栏,根路由留在外面。外壳组件自身不读 pathname,不判断自己该不该出现——那种写法每多一个特殊页面就多一条 `if`。外壳也不统一夹居中容器,宽度与留白由页面自己决定。

### 依赖规则

1. 只能向下依赖,不允许反向。
Expand Down Expand Up @@ -84,15 +86,15 @@ Controller 围绕同一份 WorkflowRun 提供推进、更新、重启和中断

---

## 5. 本次不包含
## 5. 尚未包含

- 任何实现代码(真实请求、假数据、组件内部逻辑)
- 测试文件
- 图片上传模块(体量太小,本次不单独体现
- 真实请求与数据获取,`XxxApis` 目前只有接口
- 首页之外的页面实现,其余七个路由仍是占位外壳
- 图片上传模块(体量太小,不单独体现
- 穿戴道具相关(产品侧未设计)
- 第三方登录

页面当前是占位外壳,只声明路由与模块边界
首页已按本文的分层落地:它不依赖 `entities` 与 `features`,两张入口卡片只做路由跳转。首屏那三段制作路径是 `WORKFLOW_STEP_ORDER` 八步的粗粒度概括,写死在页面文案里,改流程时要一并改

---

Expand Down
6 changes: 4 additions & 2 deletions frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 # 构建
```

Expand All @@ -25,6 +25,8 @@ CI 按上面顺序全跑一遍。

模块划分、依赖规则与命名约定见仓库根目录 `frontend-architecture-v3.md`。

**本阶段只提交模块边界与接口,不含实现。** 页面是占位外壳,各模块只有类型与 `XxxApis` 接口。实现按模块拆成后续 PR。
模块边界与接口已经落地,页面实现按模块拆成多个 PR 陆续进来。**目前只有首页是真实现,其余七个路由仍是占位外壳**,`entities` 与 `features` 也只有类型和 `XxxApis` 接口,没有真实请求。

页面自己决定宽度与留白,`AppShell` 只提供顶栏,不再统一夹一个居中容器。

与后端尚未对齐的接口见 `API_CONTRACT.md`。
16 changes: 16 additions & 0 deletions frontend/public/windup-mark.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
13 changes: 7 additions & 6 deletions frontend/src/app/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<BrowserRouter>
<AppShell>
<Routes>
<Route path="/" element={<HomePage />} />
<Routes>
<Route path="/" element={<HomePage />} />
<Route element={<AppShellRoute />}>
<Route path="/quick-start" element={<QuickStartPage />} />
<Route path="/quick-start/:runId" element={<QuickStartPage />} />
<Route path="/projects" element={<ProjectsPage />} />
Expand All @@ -29,8 +30,8 @@ export function App() {
<Route path="/workflow-editor/:runId/:stage" element={<WorkflowEditorPage />} />
<Route path="/playtest/:characterId/:outfitId" element={<PlaytestPage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</AppShell>
</Route>
</Routes>
</BrowserRouter>
)
}
22 changes: 19 additions & 3 deletions frontend/src/app/layout/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ReactNode } from 'react'
import { Link } from 'react-router'
import { Link, Outlet } from 'react-router'

/** 跨页面常驻导航属于应用外壳,由 app 层统一承载。 */

Expand All @@ -13,7 +13,10 @@ export function AppShell({ children }: AppShellProps) {
return (
<div className="min-h-screen bg-white text-slate-900">
<nav className="flex items-center justify-between border-b border-slate-200 px-6 py-3">
<span className="font-semibold tracking-tight">Windup</span>
{/* 首屏不带这条顶栏,站名是其余页面唯一的回程入口,必须是链接。 */}
<Link to="/" className="font-semibold tracking-tight hover:text-slate-600">
Windup
</Link>
<div className="flex gap-4 text-sm text-slate-600">
<Link to="/quick-start" className="hover:text-slate-900">
快速开始
Expand All @@ -23,7 +26,20 @@ export function AppShell({ children }: AppShellProps) {
</Link>
</div>
</nav>
<main className="mx-auto max-w-5xl px-6 py-8">{children}</main>
{/* 外壳只管顶栏。页面自己决定宽度与留白,不在这里统一夹到屏幕中间。 */}
<main className="w-full">{children}</main>
Comment thread
huyanxius marked this conversation as resolved.
</div>
)
}

/**
* 外壳的路由形态,套在一组子路由外面。
* 哪些页面带外壳是路由决策,写在 app 的路由表里;外壳自身不读 pathname、不判断自己该不该出现。
*/
export function AppShellRoute() {
return (
<AppShell>
<Outlet />
</AppShell>
)
}
12 changes: 8 additions & 4 deletions frontend/src/pages/asset-library/index.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { PageContainer } from '@/shared/ui'

/** 资产库。 */
export function AssetLibraryPage() {
return (
<section className="border border-dashed border-slate-300 p-6">
<h1 className="font-medium">资产库</h1>
<p className="mt-2 text-sm text-slate-500">本次只提交模块划分与接口,页面实现进后续 PR。</p>
</section>
<PageContainer>
<section className="border border-dashed border-slate-300 p-6">
<h1 className="font-medium">资产库</h1>
<p className="mt-2 text-sm text-slate-500">本次只提交模块划分与接口,页面实现进后续 PR。</p>
</section>
</PageContainer>
)
}
154 changes: 154 additions & 0 deletions frontend/src/pages/home/brand-bird.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLCanvasElement>(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 (
<canvas
ref={canvasRef}
data-testid="home-brand-bird"
aria-hidden="true"
className="pointer-events-none absolute inset-0 h-full w-full opacity-75"
/>
)
}
28 changes: 28 additions & 0 deletions frontend/src/pages/home/choice-card.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<MemoryRouter>
<HomeChoiceCard
to="/quick-start"
eyebrow="ONE COMMAND"
index="01"
title="快速开始"
description="用一句话描述角色。"
actionLabel="写下角色设定"
tone="dark"
/>
</MemoryRouter>,
)

expect(screen.getByRole('link', { name: /快速开始/ }).getAttribute('href')).toBe('/quick-start')
})
})
Loading