diff --git a/frontend-architecture-v3.md b/frontend-architecture-v3.md index 50f46bc..13e7a06 100644 --- a/frontend-architecture-v3.md +++ b/frontend-architecture-v3.md @@ -41,7 +41,7 @@ pages -> features -> entities -> shared `app` 只做启动和路由,不构造服务、不向下注入。 -外壳套在哪些页面上也是路由决策:`AppShellRoute` 写在 `app.tsx` 的路由表里,谁在里面谁就有顶栏,根路由留在外面。外壳组件自身不读 pathname,不判断自己该不该出现——那种写法每多一个特殊页面就多一条 `if`。外壳也不统一夹居中容器,宽度与留白由页面自己决定。 +外壳套在哪些页面上也是路由决策:`AppShellRoute` 写在 `app.tsx` 的路由表里,谁在里面谁就有顶栏。目前全部路由都在里面,根路由也是——顶栏悬浮在内容之上、不占布局高度,首屏仍是满幅,而首页同样需要通往项目资产的常驻入口。外壳组件自身不读 pathname,不判断自己该不该出现——那种写法每多一个特殊页面就多一条 `if`;顶栏内部读 pathname 只为高亮当前项,与此无关。外壳也不统一夹居中容器,宽度与留白由页面自己决定:顶栏既然悬浮,避让由页面负责,内容页统一走 `PageContainer`。 ### 依赖规则 diff --git a/frontend/src/app/app.tsx b/frontend/src/app/app.tsx index 06268ea..8c869ab 100644 --- a/frontend/src/app/app.tsx +++ b/frontend/src/app/app.tsx @@ -13,14 +13,15 @@ import { AppShellRoute } from './layout' /** * 路由表与全局外壳。 * 页面自己获取所需数据,不再由 app 层构造服务后逐层传入。 - * 外壳的边界画在这张表上:根路由是满幅首屏,自带入口卡片,不进外壳;其余页面共用常驻导航。 + * 外壳的边界画在这张表上:全部路由都在里面,包括根路由——顶栏悬浮不占高度, + * 首屏仍是满幅,同时首页也才有通往项目资产的常驻入口。 */ export function App() { return ( - } /> }> + } /> } /> } /> } /> diff --git a/frontend/src/app/layout/app-header.test.tsx b/frontend/src/app/layout/app-header.test.tsx new file mode 100644 index 0000000..33c363b --- /dev/null +++ b/frontend/src/app/layout/app-header.test.tsx @@ -0,0 +1,35 @@ +// @vitest-environment jsdom +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { MemoryRouter } from 'react-router' + +import { AppHeader } from './app-header' + +afterEach(cleanup) + +describe('AppHeader', () => { + it('保留三个产品入口,并将工作流路由归入创作', () => { + render( + + + , + ) + + expect(screen.getByRole('link', { name: '返回 Windup 首页' }).getAttribute('href')).toBe('/') + expect(screen.getByRole('link', { name: '项目资产' }).getAttribute('href')).toBe('/projects') + expect(screen.getByRole('link', { name: '创作' }).getAttribute('aria-current')).toBe('page') + expect(screen.queryByRole('link', { name: 'Playtest' })).toBeNull() + }) + + it('在首页只高亮首页一项', () => { + render( + + + , + ) + + expect(screen.getByRole('link', { name: '首页' }).getAttribute('aria-current')).toBe('page') + expect(screen.getByRole('link', { name: '项目资产' }).getAttribute('aria-current')).toBeNull() + expect(screen.getByRole('link', { name: '创作' }).getAttribute('aria-current')).toBeNull() + }) +}) diff --git a/frontend/src/app/layout/app-header.tsx b/frontend/src/app/layout/app-header.tsx new file mode 100644 index 0000000..4ad4db7 --- /dev/null +++ b/frontend/src/app/layout/app-header.tsx @@ -0,0 +1,111 @@ +import { Link, useLocation } from 'react-router' + +interface ProductNavigationItem { + to: string + label: string + compactLabel?: string + isActive: (pathname: string) => boolean +} + +/** + * 三个入口对应三种去处:回首页、看已有资产、做新东西。 + * 07-31 定稿版还有一个 Playtest 项,这里没有搬——本仓库的预览路由是 + * /playtest/:characterId/:outfitId,没有角色和造型就构造不出可用地址, + * 顶栏给不出一个恒定的链接。等预览有了落地入口再加回来。 + */ +const productNavigation: ProductNavigationItem[] = [ + { + to: '/', + label: '首页', + isActive: (pathname) => pathname === '/', + }, + { + to: '/projects', + label: '项目资产', + compactLabel: '项目', + isActive: (pathname) => pathname.startsWith('/projects'), + }, + { + to: '/quick-start', + label: '创作', + isActive: (pathname) => + pathname.startsWith('/quick-start') || pathname.startsWith('/workflow-editor'), + }, +] + +/** 左侧标牌上的第二行,随所在区域变化,让用户知道自己在哪一片。 */ +function getWorkspaceLabel(pathname: string): { title: string; detail: string } { + if (pathname.startsWith('/projects') || pathname.startsWith('/playtest')) { + return { title: '项目资产', detail: '角色、造型与动作' } + } + + if (pathname.startsWith('/quick-start') || pathname.startsWith('/workflow-editor')) { + return { title: '创作工作流', detail: '设定、生成与审核' } + } + + return { title: '角色资产工作台', detail: 'Windup' } +} + +/** + * 跨页面悬浮 Bar 知道产品路由,因此属于 app 外壳,不下沉到 shared/ui。 + * 它读 pathname 只用于高亮当前项与切换标牌文案,不据此决定自己出不出现—— + * 谁带外壳是路由表的事,见 app.tsx。 + * 悬浮不占布局高度,页面顶部留白由页面或 PageContainer 自己让出。 + */ +export function AppHeader() { + const { pathname } = useLocation() + const workspace = getWorkspaceLabel(pathname) + + return ( +
+
+ + + Windup + + + + {workspace.title} + {workspace.detail} + +
+ + +
+ ) +} diff --git a/frontend/src/app/layout/index.tsx b/frontend/src/app/layout/index.tsx index b797b33..1ebace9 100644 --- a/frontend/src/app/layout/index.tsx +++ b/frontend/src/app/layout/index.tsx @@ -1,5 +1,7 @@ import type { ReactNode } from 'react' -import { Link, Outlet } from 'react-router' +import { Outlet } from 'react-router' + +import { AppHeader } from './app-header' /** 跨页面常驻导航属于应用外壳,由 app 层统一承载。 */ @@ -12,21 +14,12 @@ export interface AppShellProps { export function AppShell({ children }: AppShellProps) { return (
- - {/* 外壳只管顶栏。页面自己决定宽度与留白,不在这里统一夹到屏幕中间。 */} + + {/* + 外壳只管顶栏。页面自己决定宽度与留白,不在这里统一夹到屏幕中间, + 也不按 pathname 分支给不同页面配不同容器。 + 顶栏悬浮不占布局高度,内容页的避让由 PageContainer 统一让出,满幅页面自己让。 + */}
{children}
) diff --git a/frontend/src/pages/home/choice-card.tsx b/frontend/src/pages/home/choice-card.tsx index d110be4..e9b99d4 100644 --- a/frontend/src/pages/home/choice-card.tsx +++ b/frontend/src/pages/home/choice-card.tsx @@ -2,6 +2,12 @@ import { Link } from 'react-router' export type HomeChoiceCardTone = 'light' | 'dark' +/** 展开后的次级入口;两个都指向真实路由,卡片本身不再是链接。 */ +export interface HomeChoiceCardAction { + to: string + label: string +} + export interface HomeChoiceCardProps { to: string eyebrow: string @@ -10,6 +16,12 @@ export interface HomeChoiceCardProps { description: string actionLabel: string tone?: HomeChoiceCardTone + /** + * 给出时,底部动作条由 actionLabel 换成这几个入口:能 hover 的设备悬停或键盘聚焦后展开, + * 触屏设备常显。卡片随之从 Link 降级为 section——链接不能嵌套链接。 + * 不给时卡片整体是一个链接,指向 to。 + */ + actions?: HomeChoiceCardAction[] } /** Home 专用入口卡;只接收显示内容与目标路由,不持有业务状态。 */ @@ -21,18 +33,19 @@ export function HomeChoiceCard({ description, actionLabel, tone = 'light', + actions, }: HomeChoiceCardProps) { const dark = tone === 'dark' + const split = actions !== undefined && actions.length > 0 - return ( - + const shell = `group relative flex min-h-56 flex-col overflow-hidden rounded-[1.35rem] border p-5 text-left transition duration-200 motion-reduce:transform-none ${ + dark + ? 'border-[#191b18] bg-[#191b18] text-white hover:-translate-y-0.5 hover:bg-[#242622]' + : 'border-[#cfd1ca] bg-[#f4f3ed] text-[#191b18] hover:-translate-y-0.5 hover:border-[#8f958b] hover:bg-white' + } focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#263f2d]` + + const body = ( + <> + + ) - - {actionLabel} + const actionBorder = dark ? 'border-white/18 text-white' : 'border-[#dfe1da] text-[#263f2d]' + + if (!split) { + return ( + + {body} - - + + ) + } + + return ( +
+ {body} + +
+ {/* 两层叠在一起换位,不用 display:none——隐藏的链接无法聚焦,键盘用户就够不到次级入口。 + any-pointer-coarse 那一份是触屏的基线:Tailwind v4 把 hover: 包在 @media (hover: hover) 里, + 触屏既没有 hover 也没有 Tab,只留 hover 展开的话这两个入口在手机上根本触发不到。 + 用 any-pointer 而非 pointer,是因为触屏笔记本、配鼠标的 iPad 主指针报 fine, + 只判主指针那类设备用手指仍然点不动;代价是它们两个分支同时命中,按钮常显。 */} +
+ {actionLabel} + +
+ +
+ {actions.map((action) => ( + + {action.label} + + ))} +
+
+
) } diff --git a/frontend/src/pages/home/index.test.tsx b/frontend/src/pages/home/index.test.tsx index 6b9655c..ce7257f 100644 --- a/frontend/src/pages/home/index.test.tsx +++ b/frontend/src/pages/home/index.test.tsx @@ -8,7 +8,7 @@ import { HomePage } from './index' afterEach(cleanup) describe('HomePage', () => { - it('提供快速开始和项目工作台两个既有入口', () => { + it('按交互形态提供快速开始与工作流画布两个入口', () => { render( @@ -17,7 +17,11 @@ describe('HomePage', () => { 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.getByText('工作流画布')).toBeTruthy() + expect(screen.getByRole('link', { name: '创建新项目' }).getAttribute('href')).toBe('/projects') + 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 b8adab9..eae909b 100644 --- a/frontend/src/pages/home/index.tsx +++ b/frontend/src/pages/home/index.tsx @@ -71,7 +71,7 @@ export function HomePage() {

- 快速开始负责连续创作,项目工作台适合逐步掌控。 + 一句话交给系统跑完,或在画布上逐个节点确认。

@@ -81,17 +81,26 @@ export function HomePage() { eyebrow="ONE COMMAND" index="01" title="快速开始" - description="用自然语言描述角色和动作,从同一套制作流程开始。" + description="用自然语言描述角色和动作,系统按同一套流程连续跑完。" actionLabel="写下角色设定" tone="dark" /> + {/* + 画布入口必须先确定角色挂在哪个项目下,所以动作条悬停后分成新建与继续两条。 + 两条现在都落在 /projects 占位页;建项目、建 workflow run 再进 + /workflow-editor/:runId 的真实链路等后端接口对齐后接上。 + */} diff --git a/frontend/src/shared/ui/page-container.tsx b/frontend/src/shared/ui/page-container.tsx index 157c8cd..6cc25a2 100644 --- a/frontend/src/shared/ui/page-container.tsx +++ b/frontend/src/shared/ui/page-container.tsx @@ -10,7 +10,10 @@ export interface PageContainerProps { * 最大宽度 1280px(`max-w-7xl`)取自应用型界面 1200–1440px 的常见区间, * 与以正文阅读为主的 640–720px 不是一档——首屏、Playtest 这类满幅页面不套本组件。 * 只提供一种尺寸:出现第二种真实需求时再加参数,不预留档位。 + * + * 顶部留白比其余三边大,是给悬浮顶栏让位:顶栏 fixed 在 top-3.5、最小高 3.625rem, + * 合计约 4.5rem,pt-24(6rem)留出余量。改顶栏尺寸时这里要一起改。 */ export function PageContainer({ children }: PageContainerProps) { - return
{children}
+ return
{children}
}