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 @@ + + Windup + 侧视机械小鸟标志 + + + + + + + + + + + + + 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 ( +