Develop #20

Merged
sakibcc merged 19 commits from develop into main 2026-08-31 17:16:06 +08:00
39 changed files with 4618 additions and 357 deletions
Showing only changes of commit c757e96d28 - Show all commits
@@ -3,7 +3,7 @@
"name": "migrate-zhixing-b1",
"title": "迁移知行B1选股策略",
"description": "",
"status": "in_progress",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
@@ -11,7 +11,7 @@
"creator": "yuxuanhui",
"assignee": "yuxuanhui",
"createdAt": "2026-08-08",
"completedAt": null,
"completedAt": "2026-08-09",
"branch": null,
"base_branch": "main",
"worktree_path": null,
@@ -3,7 +3,7 @@
"name": "strategy-execution-results",
"title": "补充策略执行结果查询接口和前端页面",
"description": "",
"status": "in_progress",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
@@ -11,7 +11,7 @@
"creator": "yuxuanhui",
"assignee": "yuxuanhui",
"createdAt": "2026-08-08",
"completedAt": null,
"completedAt": "2026-08-09",
"branch": null,
"base_branch": "main",
"worktree_path": null,
@@ -0,0 +1,5 @@
{"file":".trellis/spec/frontend/directory-structure.md","reason":"检查原型没有污染生产 feature 边界,目标 layout 设计没有形成 shared → feature 反向依赖。"}
{"file":".trellis/spec/frontend/component-guidelines.md","reason":"检查语义 HTML、键盘操作、状态文案、focus 与触控交互是否完整。"}
{"file":".trellis/spec/frontend/state-management.md","reason":"检查 variant、分页、弹层和响应式状态的归属是否符合 URL/局部状态约定。"}
{"file":".trellis/spec/frontend/quality-guidelines.md","reason":"核对前端格式、lint、typecheck、test 和 build 的实际结果。"}
{"file":".trellis/spec/guides/code-reuse-thinking-guide.md","reason":"检查 layout/pagination 设计提供真实复用与 locality,没有为了形式增加浅层封装。"}
@@ -0,0 +1,228 @@
# 响应式前端交互体系技术设计
## 1. 设计目标
本任务先交付可交互 HTML 原型和可执行的目标架构设计,不直接重写生产页面。原型回答两个问题:
1. 暖白、炭黑、白色浮层和克制圆角能否形成适合量化研究工作台的高密度组件语言;
2. 同一套页面语义能否通过一个 layout 模块在桌面、平板和移动端形成不同但一致的交互布局。
生产实现必须等原型方案被选定后另行执行;原型代码不直接提升为生产组件。
## 2. 当前约束与证据
- `src/features/home/components/home-shell.tsx` 同时承担品牌、导航、搜索、用户入口和内容容器,并要求页面传入 `activeSection`。它位于 `home` feature,却被 `selection` feature 反向使用,说明应用壳 seam 放置不当。
- 当前窄屏只是把侧栏从 256px 缩到 80px,没有移动端主导航、底部安全区或触控密度策略。
- `SignalTable` 以 `min-width: 720px` + `overflow-x-auto` 处理窄屏,没有分页,也没有移动端信息优先级。
- Button/Input 默认 32px 高度适合桌面紧凑密度,但移动触控需单独的最小命中区域。
- 前端规范要求业务留在 feature、跨 feature 能力进入 shared/app,路由只负责映射和组合,URL 状态由 TanStack Router 承担。
- TanStack Router 官方资料确认:code-based routing 可用只有 `id`、没有 `path` 的 pathless layout route 包裹子路由,并通过 `<Outlet />` 共享应用壳;`validateSearch` 可为分页和原型 variant 提供类型化 URL 状态。
## 3. Target Architecture
```text
src/
├── app/
│ └── layout/
│ ├── app-layout.tsx # 应用品牌、响应式主导航、工具区、Outlet
│ ├── navigation.ts # 应用级导航模型与可用状态
│ └── page-layout.tsx # 页头、操作区、内容宽度与纵向节奏
├── routes/
│ └── route-tree.tsx # pathless workspace route + child routes
├── features/
│ └── selection/
│ └── components/
│ └── signal-results.tsx # 业务列定义与移动卡片呈现
└── shared/
└── ui/
└── pagination.tsx # 无业务语义的分页 primitive
```
### 3.1 Layout seam
外部 seam 位于 pathless workspace route 与所有业务 child route 之间。路由树只挂载一次 `AppLayout`,页面不再 import 应用壳,也不再传 `activeSection`。
```tsx
const workspaceRoute = createRoute({
getParentRoute: () => rootRoute,
id: "workspace",
component: AppLayout,
});
const indexRoute = createRoute({
getParentRoute: () => workspaceRoute,
path: "/",
component: HomePage,
});
```
`AppLayout` 的 interface 对页面保持极小:页面只作为 `<Outlet />` 内容进入。当前导航项由 Router Link 的 active 状态得出,不再由页面手工声明。
### 3.2 Page layout interface
页面仅通过稳定的页面语义描述页头,不知道断点、侧栏宽度和安全区:
```tsx
interface PageLayoutProps {
mode?: "document" | "bounded-workspace";
actions?: React.ReactNode;
metrics?: React.ReactNode;
children: React.ReactNode;
}
```
`PageLayout` 内部集中处理:
- 视口 gutter、最大内容宽度与内容密度;
- 操作区和指标区在窄屏的换行/堆叠;
- 页面区块的紧凑纵向节奏;
- sticky header 预留和移动底部安全区;
- `bounded-workspace` 模式下的连续高度链与唯一滚动区域;
- 跳过导航链接与语义化 `<main>`。
页面不再通过 `PageLayout` 渲染独立的大标题、眉题和说明区。当前页面名称与路由上下文由 `AppLayout` 顶栏提供;`PageLayout` 的首个内容区直接承载页面操作、指标和业务数据,避免在紧凑工作台中重复表达同一上下文。
删除该模块会使上述规则重新散落到每个页面,因此它提供真实 Depth 与 Locality,而不是 className 转发层。
`document` 是普通详情页、表单页的默认模式,由应用内容区自然滚动。`bounded-workspace` 只用于表格、树表和主从研究台:`AppLayout → main → PageLayout → business panel` 连续使用确定高度、`min-height: 0` 与 Flex 剩余空间;操作区、指标、筛选栏和分页不可收缩,纵向滚动权收敛到表格数据区或详情正文。实现不得依赖 `calc(100vh - Npx)` 或 JavaScript 测量固定区域高度。
移动断点下不强制延续桌面有界模式。主从区域转为纵向单列时,`PageLayout` 恢复自然高度和页面滚动,避免记录卡、分页与底部导航之间形成嵌套滚动。
### 3.3 Navigation model
`navigation.ts` 保存应用级、只读导航模型:`id`、`label`、`to`、`icon`、`availability`。它不读取 feature 数据,也不保存当前激活项。
- Desktop(≥1024px):232px 固定侧栏;品牌、四个主入口、底部低频入口。
- Tablet(768–1023px):64px 图标 rail;标签通过 tooltip/可访问名称提供。
- Mobile(<768px):顶部只保留当前页面标题和低频操作;底部 4 项主导航,其他入口进入“更多”sheet。
- 底部导航高度 60px,并使用 `padding-bottom: env(safe-area-inset-bottom)`;页面内容 padding 同步包含该占位。
- 响应式切换以 CSS media/container rules 为主,不在 React render 中读取 `window.innerWidth`,避免双树渲染和水合差异。
## 4. Visual and Interaction Tokens
### 4.1 视觉层级
- Canvas:`#f5f1ec`,作为应用背景。
- Surface:`#ffffff`,用于卡片、表格和浮层。
- Ink:`#111111`,作为主文本和主操作颜色。
- Muted Ink:`#626260`;Hairline:`#d3cec6`。
- 不使用阴影建立常规层级;只允许 dialog、popover、prototype switcher 使用必要浮层阴影。
- `DESIGN.md` 的 Fin Orange 不进入通用主操作,因为本产品没有 Fin 语义;涨跌、成功、警告等颜色只承担数据或语义状态。
- 字体使用 Inter/system fallback;桌面工作台不采用营销页 40–72px display 字号。
### 4.2 密度
| Token | Desktop | Touch/Mobile | Usage |
| -------------- | ------: | ---------------: | -------------------------- |
| control-height | 32px | 44px | input、select、常规 button |
| icon-target | 32px | 44px | 仅图标按钮 |
| page-gutter | 24px | 16px | 主内容边距 |
| card-padding | 16px | 14px | 默认卡片 |
| table-row | 44px | 不适用 | 桌面表格行 |
| bottom-nav | 不适用 | 60px + safe area | 移动主导航 |
触控尺寸由 layout/touch capability token 控制,不要求每个页面选择 `mobile` variant。hover 样式仅在支持 hover 的设备启用;所有交互同时提供 focus-visible 和 pressed 状态。
### 4.3 组件覆盖
原型展示并验证以下状态:
- Button:primary、secondary、ghost、destructive、disabled、loading;
- Form:input、search、select、date、focus、filled、disabled;
- Badge/Status:neutral、success、warning、error;
- Card:基础、metric、empty、error;
- Overlay:确认 dialog、移动“更多”sheet、toast;
- Data:筛选工具条、桌面表格、移动记录卡、分页;
- Feedback:skeleton、空结果、请求失败、执行中、成功提示。
## 5. Responsive Data and Pagination
### 5.1 同一数据,两种呈现
`selection` feature 持有字段优先级和业务格式化:
- Desktop:列式 table,优先显示股票、子信号、收盘价、关键详情;面板消费页面剩余高度,筛选栏与分页不可收缩,表头在数据滚动区内吸顶。
- Mobile:每条信号变为紧凑记录卡;首行显示股票与价格,第二行显示信号 badge,关键详情默认折叠后按需展开。
两种呈现消费同一页数据和同一分页状态,不复制请求或业务判断。共享层不尝试理解 `SelectionSignal`。
### 5.2 Pagination interface
```tsx
interface PaginationProps {
page: number;
pageSize: number;
total: number;
pageSizeOptions?: readonly number[];
onPageChange: (page: number) => void;
onPageSizeChange: (pageSize: number) => void;
}
```
分页 primitive 负责总数、当前范围、页码、上一页/下一页、每页数量、禁用和 aria 文案;feature 负责数据切片或 query 参数。
首版生产迁移可对当前已加载 `signals` 使用客户端分页,`page`/`pageSize` 使用 selection route 的 validated search params,使链接可复现。若真实数据规模要求服务端分页,保持 Pagination interface 不变,只替换 feature 内数据 adapter。
## 6. HTML Prototype
### 6.1 Artifact shape
- 文件:`zhixing-web/prototypes/responsive-workbench.html`。
- 单文件 HTML/CSS/JS,无后端、无 CDN、无持久化,双击即可运行。
- 通过 `?variant=a|b|c` 分享方案;底部浮动切换器支持按钮和左右方向键。
- 增加 Desktop/Mobile 预览切换,同时保留真实 media query,便于无需开发者工具评审。
- 使用内存 mock 数据演示搜索、筛选、分页、page size、dialog、sheet、toast 和折叠详情。
### 6.2 Three structural variants
1. **A — 紧凑工作台**:完整侧栏 + 轻量工具栏 + 分区卡片;默认推荐,迁移成本最低。
2. **B — 数据账本**:窄图标 rail + 表格主画布 + 吸顶筛选/操作条;信息密度最高,但首次理解成本更高。
3. **C — 主从研究台**:导航侧栏 + 结果列表/详情双栏;适合研究钻取,但在简单页面上占用更多结构。
三者必须改变信息层级与主操作位置,不能只改变颜色。移动端都遵循已确认的底部导航,但分别比较页头、筛选和记录详情的收纳方式。
### 6.3 Selected direction
用户选定 A+C 组合:保留 A 的完整工作台侧栏、顶栏和紧凑指标区,引入 C 的结果列表/研究详情双栏。原型默认打开该组合;A、B 仅保留为对比参考。
内容区不再渲染页面大标题、眉题或说明文案。操作按钮以紧凑 action row 呈现,其后立即进入指标与数据区域;移动端沿用同一信息顺序。
业务页面不展示用于设计评审的“状态语义 / 加载反馈”样例区。状态组件仍可在独立组件预览弹窗中评审;实际页面只在对应请求状态发生时就地呈现 loading、empty 或 error 反馈。
## 7. State and Data Flow
```text
URL route/search
├─ active route ──> AppLayout navigation presentation
├─ page/pageSize ─> selection feature pagination state
└─ variant ───────> prototype-only variant renderer
Query data ──> feature view model ──> current page slice
├─ desktop table
└─ mobile record cards
```
- 响应式视口不是业务状态,不进入 Zustand。
- 服务器结果继续由 TanStack Query 管理,不复制进 layout 或 UI store。
- 移动“更多”sheet、dialog、展开行属于局部瞬时状态。
- 原型 state 全部保存在内存或 URL,不写 localStorage。
## 8. Compatibility, Rollout, and Rollback
- 本任务只新增原型和规划文档,不改变现有路由、API 或生产 bundle。
- 原型选型后,生产迁移按“pathless layout → 首页 → selection 页面 → shared pagination”的顺序进行,每步保持路由 URL 不变。
- `HomeShell` 在所有页面迁移完成前保留;最终一次性删除,避免两套壳长期并存。
- 若新 layout 出现回归,可将 child routes 的 parent 恢复为 root route,并恢复页面外层 `HomeShell`,不涉及数据回滚。
## 9. Risks and Deferred Items
- `DESIGN.md` 源自营销站分析,若直接套用会造成工作台留白和字号过大;原型明确以数据密度重新校准。
- 当前 signals API 返回完整列表,客户端分页只改善渲染与浏览,不降低传输量;服务端分页在数据规模证明确有需要后单独设计。
- 生产 dark mode、表格排序/列配置、虚拟滚动、离线支持不在本任务范围。
- 原型为 throwaway code;选中方案必须按生产质量重新实现并补测试,不能直接复制粘贴上线。
## 10. Research References
- [TanStack Router routing concepts](https://github.com/tanstack/router/blob/main/docs/router/routing/routing-concepts.md)
- [TanStack Router code-based routing](https://github.com/tanstack/router/blob/main/docs/router/routing/code-based-routing.md)
- [TanStack Router search params](https://github.com/tanstack/router/blob/main/docs/router/guide/search-params.md)
@@ -0,0 +1,6 @@
{"file":".trellis/spec/frontend/index.md","reason":"实现原型前确认前端技术栈、feature 边界和完整质量入口。"}
{"file":".trellis/spec/frontend/directory-structure.md","reason":"保持 app layout、shared UI 与 feature 业务呈现的依赖方向,避免把业务逻辑放入 shared。"}
{"file":".trellis/spec/frontend/component-guidelines.md","reason":"原型和后续设计遵循语义元素、可访问性、组合与现有样式约定。"}
{"file":".trellis/spec/frontend/state-management.md","reason":"将 URL、服务器、跨页偏好与局部交互状态放在正确位置,不以 Zustand 保存视口或请求数据。"}
{"file":".trellis/spec/frontend/quality-guidelines.md","reason":"执行格式、lint、类型、测试与构建门禁,确认原型未破坏现有应用。"}
{"file":".trellis/spec/guides/code-reuse-thinking-guide.md","reason":"仅抽取有真实跨 feature 复用价值的 layout/pagination interface,避免新建浅 helper。"}
@@ -0,0 +1,96 @@
# 响应式前端交互体系执行计划
## Scope
本任务只实现可评审的单文件 HTML 原型,并完成目标 layout 架构设计;不改生产 React 页面、路由或后端。原型选型后的生产迁移另开任务。
## Ordered Checklist
### 1. 建立原型骨架
- [x] 新增 `zhixing-web/prototypes/responsive-workbench.html`,顶部注释明确标注 THROWAWAY PROTOTYPE 与验证问题。
- [x] 定义颜色、字体、间距、圆角、控件密度、触控尺寸和安全区 CSS variables。
- [x] 加入 `?variant=a|b|c` 路由状态、浮动方案切换器和 Desktop/Mobile 预览切换。
- [x] 让真实 viewport media query 与强制预览模式得到相同行为。
### 2. 实现三个结构方案
- [x] A「紧凑工作台」:完整侧栏、工具栏、分区卡片与分页表格。
- [x] B「数据账本」:图标 rail、吸顶筛选、以表格为主画布。
- [x] C「主从研究台」:结果列表与详情面板并置。
- [x] 验证三者在信息层级、主操作位置和数据钻取方式上有实质差异。
- [x] 三个方案的移动端均使用 4 项底部导航 + 更多 sheet,并留出 safe-area 空间。
### 3. 补齐组件和交互状态
- [x] 展示按钮、表单、badge、card、feedback、overlay 和 data patterns。
- [x] 使用 mock signals 实现搜索、筛选、分页、每页数量与记录范围。
- [x] 桌面呈现 table,移动端呈现优先级重排后的记录卡及折叠详情。
- [x] 实现确认 dialog、更多 sheet、toast 和 loading/disabled/empty/error 示例。
- [x] 支持键盘方向键切换 variant,且输入框聚焦时不劫持按键。
### 4. 可访问性与响应式检查
- [x] 使用 nav/main/table/button/dialog 等语义元素和清晰 aria label。
- [x] 键盘可完成导航、筛选、分页、开关弹层与关闭弹层。
- [x] 检查 1440×900、1280×800、768×1024、390×844、375×812。
- [x] 移动端无页面级水平溢出,底部导航不遮挡内容,触控目标 ≥44px。
- [x] 验证 reduced-motion 下关闭非必要动画。
### 5. 评审交付
- [x] 输出三个可分享 URL:`?variant=a`、`?variant=b`、`?variant=c`。
- [x] 截取三种方案的桌面与移动端画面作为视觉验证证据。
- [x] 记录每个方案的适用场景和明确取舍,等待用户选择或混合方案。
- [x] 不把原型代码直接迁入生产模块。
## Validation Commands
在 `zhixing-web/` 下执行:
```bash
pnpm exec prettier --check prototypes/responsive-workbench.html
pnpm check
pnpm build
```
浏览器验证:
```bash
python3 -m http.server 4173 -d zhixing-web
```
随后使用真实浏览器逐一检查三个 variant 的桌面/移动截图、URL 刷新稳定性、分页、page size、dialog、更多 sheet、键盘切换和水平溢出。
## Risky Files and Rollback Points
- `zhixing-web/prototypes/responsive-workbench.html` 是唯一产品目录新增物;删除该文件即可完全回滚运行时影响。
- `.trellis/tasks/08-09-responsive-frontend-design/` 只记录规划与验证,不进入生产 bundle。
- 本任务不得修改 `src/routes/route-tree.tsx`、`HomeShell`、shared UI 或 API 类型;若实现中发现必须修改,应回到规划并请求扩展范围。
## Review Gates
- [x] 原型三个方案均可运行且结构差异明显。
- [x] PRD 中所有验收标准均有对应验证证据。
- [x] `design.md` 的 layout seam 与现有 feature 依赖方向一致。
- [x] 检查未修改现有未提交变更或扩大到生产重构。
- [x] 用户看过最终规划摘要并在后续消息中明确批准开始实现。
## Validation Results
- `pnpm exec prettier --check prototypes/responsive-workbench.html`:通过。
- `pnpm lint`:通过。
- `pnpm typecheck`:通过。
- `pnpm test`:通过,3 个测试文件、16 项测试。
- `pnpm build`:通过。
- `pnpm check`:未完整运行;在第一步被用户新增且未格式化的 `zhixing-web/DESIGN.md` 拦截,本任务未修改该文件。其余子门禁已分别通过。
- Playwright:A/B/C 三方案在 1440×900、1280×800、768×1024、390×844、375×812 下检查通过;移动端 `body.scrollWidth === viewport width`,底部导航命中高度 50px。
- Playwright:分页 1–5 → 6–10、page size、搜索空状态、移动记录展开、更多 sheet、确认 dialog、toast、URL variant、方向键切换、Esc 关闭、Tab/Shift+Tab 焦点循环均验证通过;浏览器控制台 0 error / 0 warning。
## Selected Direction Follow-up
- [x] 将默认方案调整为 A 工作台壳 + C 研究详情面板。
- [x] 删除内容区大标题、眉题和说明模块,仅保留紧凑操作行。
- [x] 验证桌面与移动端内容均直接从操作、指标和业务数据开始。
Follow-up validation:默认无 query 时渲染 `variant-ac`;桌面 1440px 与移动 390px 均不存在 `.page-header` 或 `main h1`,内容顺序为 action row → metrics → A+C master-detail data;移动端无水平溢出,浏览器控制台 0 error / 0 warning。
@@ -0,0 +1,79 @@
# 设计响应式前端交互体系
## Goal
为知行量化建立一套可持续扩展的前端组件风格、交互风格和响应式布局体系,使高密度研究工作台在桌面端保持紧凑高效,在移动端仍可清晰导航、查询数据并完成关键操作;通过可交互 HTML 原型先验证视觉方向与布局行为,再进入生产实现。
## Background
- `zhixing-web/DESIGN.md` 给出了暖白画布、炭黑主色、白色浮层卡片、细边框、克制圆角和 Inter/Geist 替代字体等视觉方向;其原始参考偏营销页面,本任务需要将其收敛为高密度数据工作台语言。
- 当前应用为 React 19 + TypeScript + Vite,使用 TanStack Router、TanStack Query、Zustand、Tailwind CSS 4 与 Base UI;代码采用 feature 垂直切片,共享 UI 位于 `src/shared/ui/`。
- `zhixing-web/src/features/home/components/home-shell.tsx` 直接承载侧栏、顶栏和内容容器,且归属 `home` feature;窄屏仍保留 80px 侧栏,没有面向触控设备的独立导航模式。
- `zhixing-web/src/features/selection/pages/selection-results-page.tsx` 的信号表设置 `min-width: 720px` 并依赖横向滚动,当前没有分页。
- `zhixing-web/src/shared/ui/button.tsx` 与 `input.tsx` 的默认控件高度为 32px,适合紧凑桌面界面,但不足以直接作为移动端触控规格。
## Requirements
### R1. 统一视觉与组件语言
- 将 `DESIGN.md` 的视觉方向转译为适合量化研究工作台的设计 token 和组件规范。
- 覆盖按钮、输入框、选择器、标签、卡片、表格、分页、弹窗、提示反馈、空状态、加载状态与错误状态。
- 桌面布局保持紧凑,但不能以牺牲信息层级、键盘操作或可读性为代价。
- 移动端交互控件采用适合触控的命中区域,不能机械复用桌面端 32px 高度。
### R2. 响应式 Layout
- 将应用壳从 `home` feature 中抽离为跨 feature 的 layout 模块,页面不得各自实现断点和导航切换逻辑。
- layout 的 interface 只暴露页面需要声明的稳定信息,例如页面标题区、页面操作区与内容;激活导航由路由状态推导,断点、导航形态、内容宽度、滚动策略、移动安全区和触控适配留在实现内部。
- 桌面端支持高密度侧栏 + 顶部工具区 + 有最大宽度的内容区。
- 移动端使用底部导航承载四个核心入口,低频功能进入“更多”菜单;同时提供紧凑标题区、内容单列化与底部安全区处理。
- 页面内容在布局切换时保持功能与语义一致,不维护两套业务页面。
- 业务页面不重复展示大标题、眉题和说明模块;当前页面上下文由应用顶栏承担,内容区直接展示操作、指标与业务数据。
- 桌面数据工作台采用有界布局:从应用壳到业务数据区连续传递可用高度,页面外层不参与纵向滚动;移动端解除有界高度,恢复自然页面滚动。
### R3. 表格与分页
- 数据表格提供可理解的总数、当前范围、页码和每页数量交互。
- 桌面端保留列式高密度浏览;移动端为关键信息设计专门呈现策略,而非仅依赖横向滚动。
- 桌面表格消费操作区、指标区之后的全部剩余高度,仅数据行视口滚动;筛选栏、表头和分页保持可见,分页固定在数据面板底部。
- 分页控件同时支持鼠标、键盘和触控,并清楚表达禁用、当前页及加载状态。
- 首版原型使用内存数据演示分页;生产实现是否采用服务端分页由后续数据规模和接口契约决定。
### R4. HTML 交互原型
- 提供无需后端即可运行或直接打开的 HTML 原型。
- 原型至少包含桌面与移动视口切换、主导航、代表性研究页、表格分页以及关键组件状态。
- 原型用于评审设计,不作为生产代码直接合入正式路由。
- 原型要能展示布局状态和当前交互状态,使评审者看得见导航、分页、弹窗等行为变化。
### R5. 架构落点
- 应用级布局归入 `app/layout`,无业务语义的 UI primitive 归入 `shared/ui`;业务页面与业务数据呈现留在各自 feature。
- layout 模块应形成高杠杆的 interface:删除它会使响应式、导航和滚动复杂度重新散落到多个页面,而不是成为只转发 `className` 的浅模块。
- 保持现有 TanStack Router、Tailwind CSS 4、Base UI 与 feature 依赖方向,不在本迭代引入第二套组件框架。
## Acceptance Criteria
- [ ] HTML 原型可在桌面与移动视口间切换,并清晰展示两种布局形态。
- [ ] 桌面端在常见 1280px/1440px 宽度下呈现紧凑侧栏、工具区和数据内容,无不必要的大面积留白。
- [ ] 移动端在 375px/390px 宽度下无页面级水平溢出,主要操作可单手触达,内容不被导航或安全区遮挡。
- [ ] 原型包含统一的按钮、表单、标签、卡片、反馈、弹窗、表格与分页视觉和交互状态。
- [ ] 表格可实际翻页、切换每页数量,并展示总数、当前记录范围、当前页与禁用状态。
- [ ] 桌面端表格填满剩余视口高度,长列表仅滚动数据区,分页不随数据行滚出面板;页面不出现竞争或嵌套纵向滚动条。
- [ ] 移动端表格可读取并操作关键信息,不以整页横向滚动作为唯一方案。
- [ ] 技术设计明确 layout 模块的 seam、interface、内部职责、路由集成方式和页面迁移策略。
- [ ] 技术设计明确桌面密度与移动触控尺寸的 token/variant 关系,避免页面内散落设备判断。
- [ ] 规划说明 HTML 原型哪些决策会进入正式组件,哪些仅为验证用途。
## Out of Scope
- 本轮规划不修改后端接口、业务计算或数据同步流程。
- 原型阶段不实现完整生产组件库、不接入真实后端、不承诺暗色模式。
- 不照搬 `DESIGN.md` 中面向营销站的超大展示字号、定价卡片或产品截图布局。
- 未经单独确认,不提交、不推送、不发布原型。
## Product Decisions
- 移动端主导航采用“底部导航 + 更多菜单”,不使用顶部菜单按钮作为唯一主导航。该选择优先保证高频模块切换和单手可达性,并接受底部导航占用约 56–64px 视口空间的代价。
- 最终视觉结构采用 A「紧凑工作台」的应用壳与 C「主从研究台」的详情面板组合。
- 删除各页面内容区内重复的标题说明模块;桌面端由顶栏显示路由上下文,移动端由紧凑顶栏显示当前页面,内容区从按钮和核心数据开始。
@@ -0,0 +1,26 @@
{
"id": "responsive-frontend-design",
"name": "responsive-frontend-design",
"title": "设计响应式前端交互体系",
"description": "",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "yuxuanhui",
"assignee": "yuxuanhui",
"createdAt": "2026-08-09",
"completedAt": "2026-08-09",
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}
+5 -3
View File
@@ -8,8 +8,8 @@
<!-- @@@auto:current-status -->
- **Active File**: `journal-1.md`
- **Total Sessions**: 3
- **Last Active**: 2026-08-08
- **Total Sessions**: 5
- **Last Active**: 2026-08-09
<!-- @@@/auto:current-status -->
---
@@ -19,7 +19,7 @@
<!-- @@@auto:active-documents -->
| File | Lines | Status |
|------|-------|--------|
| `journal-1.md` | ~71 | Active |
| `journal-1.md` | ~142 | Active |
<!-- @@@/auto:active-documents -->
---
@@ -29,6 +29,8 @@
<!-- @@@auto:session-history -->
| # | Date | Title | Commits | Branch |
|---|------|-------|---------|--------|
| 5 | 2026-08-09 | 完成响应式前端交互原型设计 | `0511669` | `develop` |
| 4 | 2026-08-09 | 完成知行 B1 策略迁移 | `e9d06df` | `develop` |
| 3 | 2026-08-08 | 完成 00 Bootstrap Guidelines | `ee00641` | `develop` |
| 2 | 2026-08-07 | 实现 Home 市场数据概览 | `b0e846d` | `develop` |
| 1 | 2026-08-06 | 完成 TUSHARE PostgreSQL 同步任务 | `039a81f`, `11e8728` | `develop` |
+71
View File
@@ -69,3 +69,74 @@
### Status
[OK] **Completed**
## Session 4: 完成知行 B1 策略迁移
**Date**: 2026-08-09
**Task**: 完成知行 B1 策略迁移
**Branch**: `develop`
### Summary
完成 zhixing_b1 selection bounded context、公式指标、PostgreSQL qfq 历史读取、测试与后端 code-spec;通过 34 项测试,归档迁移任务。
### Main Changes
- 新增 zhixing_b1 领域策略、7 类独立信号及历史评估用例
- 新增 qfq 市场数据 reader、离线 golden fixture 和 selection 后端规格
### Git Commits
| Hash | Message |
|------|---------|
| `e9d06df` | (see git log) |
### Testing
- [OK] pytest:34 passed,1 skipped
- [OK] Ruff check、Pyright、uv lock --check 通过
### Status
[OK] **Completed**
### Next Steps
- 继续处理 08-08-strategy-execution-results
## Session 5: 完成响应式前端交互原型设计
**Date**: 2026-08-09
**Task**: 完成响应式前端交互原型设计
**Branch**: `develop`
### Summary
完成 A+C 响应式工作台 HTML 原型、移动端交互与有界表格布局设计,用户确认本版原型可作为后续生产实现基线。
### Main Changes
- 新增可交互的响应式工作台单文件 HTML 原型,保留 A/B 对比并默认采用 A+C 方案
- 桌面端建立连续 Flex 高度链,表格数据区独立滚动且分页固定底部;移动端恢复自然滚动
- 补充 layout seam、分页接口、响应式密度和生产迁移技术设计
### Git Commits
| Hash | Message |
|------|---------|
| `0511669` | (see git log) |
### Testing
- [OK] Prettier、lint、typecheck、16 项测试及 build 通过
- [OK] 1440×900、1280×800、390×844、375×812 浏览器验证通过
### Status
[OK] **Completed**
### Next Steps
- 生产实现另建任务,按 pathless layout、页面迁移和 shared pagination 顺序推进
+546
View File
@@ -0,0 +1,546 @@
---
version: alpha
name: Intercom-design-analysis
description: "An editorial customer-service marketing canvas built around a soft cream-white ground, charcoal type set in Saans (Intercom's proprietary geometric sans), and a single confident Fin Orange (#ff5600) reserved for the Fin AI brand. Cards live as floating white tiles with thin hairline borders and minimal radii (8–16px). Display headlines run Saans at weight 500 with measured negative tracking. The system reads as a careful, product-led publication: product screenshots dominate, ornament is rare, and the only place chromatic energy enters is the Fin Orange CTA."
colors:
primary: "#111111"
on-primary: "#ffffff"
ink: "#111111"
ink-muted: "#626260"
ink-subtle: "#7b7b78"
ink-tertiary: "#9c9fa5"
canvas: "#f5f1ec"
surface-1: "#ffffff"
surface-2: "#ebe7e1"
inverse-canvas: "#000000"
inverse-surface-1: "#313130"
inverse-ink: "#ffffff"
inverse-ink-muted: "#9c9fa5"
hairline: "#d3cec6"
hairline-soft: "#ebe7e1"
fin-orange: "#ff5600"
report-orange: "#fe4c02"
report-blue: "#65b5ff"
report-green: "#0bdf50"
report-pink: "#ff2067"
report-lime: "#b3e01c"
report-cyan: "#03b2cb"
brand-blue: "#0007cb"
semantic-error: "#c41c1c"
semantic-success: "#0bdf50"
typography:
display-xl:
fontFamily: Saans
fontSize: 72px
fontWeight: 500
lineHeight: 1.05
letterSpacing: -2.0px
display-lg:
fontFamily: Saans
fontSize: 56px
fontWeight: 500
lineHeight: 1.10
letterSpacing: -1.4px
display-md:
fontFamily: Saans
fontSize: 40px
fontWeight: 500
lineHeight: 1.15
letterSpacing: -0.8px
headline:
fontFamily: Saans
fontSize: 28px
fontWeight: 500
lineHeight: 1.20
letterSpacing: -0.5px
card-title:
fontFamily: Saans
fontSize: 22px
fontWeight: 500
lineHeight: 1.25
letterSpacing: -0.3px
subhead:
fontFamily: Saans
fontSize: 20px
fontWeight: 400
lineHeight: 1.40
letterSpacing: -0.2px
body-lg:
fontFamily: Saans
fontSize: 18px
fontWeight: 400
lineHeight: 1.50
letterSpacing: -0.1px
body:
fontFamily: Saans
fontSize: 16px
fontWeight: 400
lineHeight: 1.50
letterSpacing: 0
body-sm:
fontFamily: Saans
fontSize: 14px
fontWeight: 400
lineHeight: 1.50
letterSpacing: 0
caption:
fontFamily: Saans
fontSize: 12px
fontWeight: 400
lineHeight: 1.40
letterSpacing: 0
button:
fontFamily: Saans
fontSize: 15px
fontWeight: 500
lineHeight: 1.20
letterSpacing: 0
eyebrow:
fontFamily: Saans
fontSize: 14px
fontWeight: 500
lineHeight: 1.30
letterSpacing: 0
mono:
fontFamily: SaansMono
fontSize: 13px
fontWeight: 400
lineHeight: 1.50
letterSpacing: 0
rounded:
xs: 4px
sm: 6px
md: 8px
lg: 12px
xl: 16px
xxl: 24px
pill: 9999px
full: 9999px
spacing:
xxs: 4px
xs: 8px
sm: 12px
md: 16px
lg: 24px
xl: 32px
xxl: 48px
section: 96px
components:
button-primary:
backgroundColor: "{colors.ink}"
textColor: "{colors.on-primary}"
typography: "{typography.button}"
rounded: "{rounded.md}"
padding: 10px 18px
button-primary-pressed:
backgroundColor: "{colors.inverse-canvas}"
textColor: "{colors.on-primary}"
typography: "{typography.button}"
rounded: "{rounded.md}"
button-secondary:
backgroundColor: "{colors.surface-1}"
textColor: "{colors.ink}"
typography: "{typography.button}"
rounded: "{rounded.md}"
padding: 10px 18px
button-tertiary:
backgroundColor: "{colors.canvas}"
textColor: "{colors.ink}"
typography: "{typography.button}"
rounded: "{rounded.md}"
padding: 10px 18px
button-fin:
backgroundColor: "{colors.fin-orange}"
textColor: "{colors.on-primary}"
typography: "{typography.button}"
rounded: "{rounded.md}"
padding: 10px 18px
pricing-card:
backgroundColor: "{colors.surface-1}"
textColor: "{colors.ink}"
typography: "{typography.body}"
rounded: "{rounded.lg}"
padding: 24px
pricing-card-featured:
backgroundColor: "{colors.ink}"
textColor: "{colors.on-primary}"
typography: "{typography.body}"
rounded: "{rounded.lg}"
padding: 24px
feature-card:
backgroundColor: "{colors.surface-1}"
textColor: "{colors.ink}"
typography: "{typography.body}"
rounded: "{rounded.lg}"
padding: 24px
product-mockup-card:
backgroundColor: "{colors.surface-1}"
textColor: "{colors.ink}"
typography: "{typography.body}"
rounded: "{rounded.xl}"
padding: 24px
testimonial-card:
backgroundColor: "{colors.surface-1}"
textColor: "{colors.ink}"
typography: "{typography.body-lg}"
rounded: "{rounded.lg}"
padding: 32px
customer-logo-tile:
backgroundColor: "{colors.canvas}"
textColor: "{colors.ink-muted}"
typography: "{typography.caption}"
rounded: "{rounded.xs}"
padding: 16px
text-input:
backgroundColor: "{colors.surface-1}"
textColor: "{colors.ink}"
typography: "{typography.body}"
rounded: "{rounded.md}"
padding: 10px 14px
text-input-focused:
backgroundColor: "{colors.surface-1}"
textColor: "{colors.ink}"
typography: "{typography.body}"
rounded: "{rounded.md}"
padding: 10px 14px
pricing-tab-default:
backgroundColor: "{colors.canvas}"
textColor: "{colors.ink-muted}"
typography: "{typography.button}"
rounded: "{rounded.pill}"
padding: 8px 16px
pricing-tab-selected:
backgroundColor: "{colors.surface-1}"
textColor: "{colors.ink}"
typography: "{typography.button}"
rounded: "{rounded.pill}"
padding: 8px 16px
faq-row:
backgroundColor: "{colors.canvas}"
textColor: "{colors.ink}"
typography: "{typography.body}"
rounded: "{rounded.md}"
padding: 24px
cta-banner:
backgroundColor: "{colors.surface-1}"
textColor: "{colors.ink}"
typography: "{typography.headline}"
rounded: "{rounded.lg}"
padding: 48px
startup-discount-card:
backgroundColor: "{colors.surface-2}"
textColor: "{colors.ink}"
typography: "{typography.body}"
rounded: "{rounded.lg}"
padding: 32px
top-nav:
backgroundColor: "{colors.canvas}"
textColor: "{colors.ink}"
typography: "{typography.body-sm}"
rounded: "{rounded.xs}"
height: 56px
footer:
backgroundColor: "{colors.canvas}"
textColor: "{colors.ink-muted}"
typography: "{typography.caption}"
rounded: "{rounded.xs}"
padding: 64px 32px
---
## Overview
Intercom's marketing canvas is a soft cream-white ground (`{colors.canvas}` ≈ #f5f1ec) — not pure white. The warmth is the brand's signal: this is editorial, calm, and product-focused, not bright SaaS. On top of the cream canvas sit white floating cards (`{colors.surface-1}`), thin hairline dividers (`{colors.hairline}`), and charcoal type (`{colors.ink}` #111111).
Display type is **Saans** — Intercom's proprietary geometric sans — set at weight 500 with measured negative letter-spacing (-2.0px on 72px display). Body type is the same family at weight 400. The single proprietary mono is **SaansMono**, used sparingly for code snippets and product UI screenshots embedded in the marketing surface.
The single chromatic accent is **Fin Orange** (`{colors.fin-orange}` #ff5600) — Intercom's AI-product brand color. It surfaces on the Fin product CTA, the Fin badge in pricing, and a few inline emphasis moments. It is NOT a system primary; the system primary is charcoal `{colors.ink}`. Intercom also maintains a small **report palette** (`{colors.report-blue}`, `{colors.report-green}`, `{colors.report-pink}`, `{colors.report-lime}`) used inside in-product analytics surfaces shown in mockups.
The page rhythm is heavy on **product mockups**: every section's payload is a high-fidelity screenshot of Intercom's product UI, framed in white cards with `{rounded.xl}` 16px corners. The marketing chrome is intentionally quiet so the product can be the protagonist.
**Key Characteristics:**
- **Cream canvas** (`{colors.canvas}` #f5f1ec) is the brand's defining surface — neither white nor gray, deliberately warm.
- Product-screenshot-led page rhythm: every section centers a product mockup card, marketing chrome stays minimal.
- **Saans** proprietary sans-serif carries the entire hierarchy; SaansMono for code-only contexts.
- **Charcoal** `{colors.ink}` (#111111) is the system primary — buttons, headlines, body type all sit on charcoal.
- **Fin Orange** (`{colors.fin-orange}` #ff5600) is the AI product color — used on the Fin CTA and Fin badge, never decoratively.
- Display tracking pulls aggressively negative (-2.0px on 72px); body stays at 0.
- Card corners stay modest at `{rounded.lg}` 12px and `{rounded.xl}` 16px — never pill-rounded; never square.
## Colors
> Source pages: intercom.com (home), /pricing, /helpdesk, /customers, /helpdesk/inbox.
### Brand & Accent
- **Charcoal** ({colors.ink}): The system primary surface. Headlines, body type, primary CTA pill background — all charcoal.
- **White** ({colors.on-primary}): Text on charcoal CTAs; canvas of floating cards.
- **Fin Orange** ({colors.fin-orange}): The AI-product accent. Used on the Fin CTA, Fin badge, and a small set of inline emphasis moments.
- **Report Orange** ({colors.report-orange}): A slightly different orange used inside the report / analytics palette for in-product mockups.
- **Brand Blue** ({colors.brand-blue}): Saturated brand blue (#0007cb) — used on a small set of marketing illustrations.
### Surface
- **Canvas** ({colors.canvas}): Default page background — soft cream-white #f5f1ec.
- **Surface 1** ({colors.surface-1}): Pure white — used for floating cards (pricing, feature, product-mockup).
- **Surface 2** ({colors.surface-2}): Slightly darker cream — startup-discount banner, alt-row stripes.
- **Hairline** ({colors.hairline}): 1px borders on cards — soft warm gray (#d3cec6).
- **Hairline Soft** ({colors.hairline-soft}): Even softer dividers between FAQ rows and footer columns.
- **Inverse Canvas** ({colors.inverse-canvas}): Pure black — only on the testimonial / quote callout strip.
- **Inverse Surface 1** ({colors.inverse-surface-1}): One step lighter — hovered footer items in dark contexts.
### Text
- **Ink** ({colors.ink}): All headlines, body type, button labels — charcoal #111111.
- **Ink Muted** ({colors.ink-muted}): Secondary type at #626260 — meta info, deselected pricing tabs.
- **Ink Subtle** ({colors.ink-subtle}): Tertiary type at #7b7b78 — footer columns, helper text.
- **Ink Tertiary** ({colors.ink-tertiary}): Quaternary type at #9c9fa5 — disabled, footnotes.
- **Inverse Ink** ({colors.inverse-ink}): White on black — quote-strip type.
- **Inverse Ink Muted** ({colors.inverse-ink-muted}): Light gray on black — quote-strip meta.
### Semantic & Report Palette (in-product mockups)
- **Error Red** ({colors.semantic-error}): Form validation, destructive states.
- **Success Green** ({colors.semantic-success}): Positive states (also `{colors.report-green}`).
- **Report Blue** ({colors.report-blue}): Analytics chart blue.
- **Report Pink** ({colors.report-pink}): Analytics chart pink.
- **Report Lime** ({colors.report-lime}): Analytics chart lime.
- **Report Cyan** ({colors.report-cyan}): Phone country selector accent.
The report palette appears INSIDE product UI mockups — these are Intercom's in-product chart colors, not marketing surface colors.
## Typography
### Font Family
- **Saans** — Intercom's proprietary geometric sans, fallback `Saans Fallback, ui-sans-serif, system-ui`. Carries display, body, eyebrow, and button.
- **SaansMono** — Proprietary mono, fallback `SaansMono Fallback, ui-monospace`. Used inside code snippets shown in product mockups.
The same family carries the entire hierarchy. Hierarchy is carried by size + weight + tracking, not by family change.
### Hierarchy
| Token | Size | Weight | Line Height | Letter Spacing | Use |
|---|---|---|---|---|---|
| `{typography.display-xl}` | 72px | 500 | 1.05 | -2.0px | Largest hero headline |
| `{typography.display-lg}` | 56px | 500 | 1.10 | -1.4px | Section opener headlines |
| `{typography.display-md}` | 40px | 500 | 1.15 | -0.8px | Sub-section headlines |
| `{typography.headline}` | 28px | 500 | 1.20 | -0.5px | Pricing tier titles, CTA banner |
| `{typography.card-title}` | 22px | 500 | 1.25 | -0.3px | Card title, feature card |
| `{typography.subhead}` | 20px | 400 | 1.40 | -0.2px | Lead body, intro paragraphs |
| `{typography.body-lg}` | 18px | 400 | 1.50 | -0.1px | Hero subhead, lead paragraphs |
| `{typography.body}` | 16px | 400 | 1.50 | 0 | Default body |
| `{typography.body-sm}` | 14px | 400 | 1.50 | 0 | Card body, footer |
| `{typography.caption}` | 12px | 400 | 1.40 | 0 | Captions, meta |
| `{typography.button}` | 15px | 500 | 1.20 | 0 | Pill / square button labels |
| `{typography.eyebrow}` | 14px | 500 | 1.30 | 0 | Section eyebrow (sentence case) |
| `{typography.mono}` | 13px | 400 | 1.50 | 0 | SaansMono for code in mockups |
### Principles
- **Weight 500 carries display.** Saans at 500 reads as confident without bold.
- **Negative letter-spacing scales with size.** -2.0px at 72px (≈3% of size), down to 0 on body.
- **Line-heights tighten on display, relax on body.** 1.05 at display-xl, 1.50 at body.
- **No mono on chrome.** SaansMono lives in product UI; marketing chrome stays in Saans.
- **Eyebrow uses sentence case** at 14px / 500 weight — no all-caps tracking.
### Note on Font Substitutes
If implementing without Saans, suitable substitutes include **Söhne** (paid), **Inter** (free, weight 500), or **Geist Sans** (free). Inter at weight 500 is the closest free substitute; SaansMono can be approximated with **JetBrains Mono** at weight 400.
## Layout
### Spacing System
- **Base unit**: 8px.
- **Tokens (front matter)**: `{spacing.xxs}` 4px · `{spacing.xs}` 8px · `{spacing.sm}` 12px · `{spacing.md}` 16px · `{spacing.lg}` 24px · `{spacing.xl}` 32px · `{spacing.xxl}` 48px · `{spacing.section}` 96px.
- Card interior padding: `{spacing.lg}` 24px on pricing/feature cards; `{spacing.xl}` 32px on testimonial/discount cards; `{spacing.xxl}` 48px on CTA banners.
- Pill button padding: 10px vertical · 18px horizontal.
### Grid & Container
- Max content width sits around 1280px.
- Card grids are 3-up at desktop, 2-up at tablet, 1-up at mobile.
- Pricing tier grid is 3-up; comparison strip below shows checkmarks per tier.
- Product mockup cards span full content width — they're the protagonist of every section.
### Whitespace Philosophy
The cream canvas does the work white space would in another brand. Sections separate by ample vertical breathing room (`{spacing.section}` 96px) plus the lift-onto-white cards.
## Elevation & Depth
| Level | Treatment | Use |
|---|---|---|
| 0 (flat) | No shadow, no border | Default for body type, hero text, footer |
| 1 (lift on cream) | `{colors.surface-1}` white background on `{colors.canvas}` cream | Pricing cards, feature cards, product mockups |
| 2 (hairline lift) | `{colors.surface-1}` + 1px `{colors.hairline}` border | Floating tiles with extra definition |
| 3 (deep accent) | `{colors.inverse-canvas}` true black | Quote / testimonial callout strip |
Intercom resists drop shadows. Depth is communicated by the white-on-cream surface change.
### Decorative Depth
- **Product UI mockups** dominate every section's right column or center band — these are screenshots, not illustrations.
- **No atmospheric gradients, no spotlight cards, no pastel section blocks.** The cream + white system is deliberately restrained.
## Shapes
### Border Radius Scale
| Token | Value | Use |
|---|---|---|
| `{rounded.xs}` | 4px | Small chips, badges |
| `{rounded.sm}` | 6px | Inline tags |
| `{rounded.md}` | 8px | All buttons, form inputs |
| `{rounded.lg}` | 12px | Pricing cards, feature cards, FAQ rows |
| `{rounded.xl}` | 16px | Product mockup cards |
| `{rounded.xxl}` | 24px | Oversized CTA banners |
| `{rounded.pill}` | 9999px | Tab toggles |
| `{rounded.full}` | 9999px | Avatar circles |
### Photography & Illustration Geometry
- Product UI screenshots dominate the marketing surface; they sit in `{rounded.xl}` 16px tiles.
- Customer logo tiles render at small sizes (~24–32px logo height) on `{colors.canvas}` cream with no border.
- Avatar circles in testimonial cards use `{rounded.full}` at 40–48px sizes.
## Components
### Buttons
**`button-primary`** — Charcoal CTA. The default primary CTA across all pages.
- Background `{colors.ink}`, text `{colors.on-primary}`, type `{typography.button}`, padding 10px 18px, rounded `{rounded.md}`.
- Pressed state lives in `button-primary-pressed`.
**`button-secondary`** — White button on cream. Used for secondary CTAs.
- Background `{colors.surface-1}`, text `{colors.ink}`, type `{typography.button}`, padding 10px 18px, rounded `{rounded.md}`. 1px `{colors.hairline}` border.
**`button-tertiary`** — Plain text button.
- Background `{colors.canvas}`, text `{colors.ink}`, type `{typography.button}`, rounded `{rounded.md}`, padding 10px 18px.
**`button-fin`** — Fin Orange CTA — reserved for Fin AI product CTAs.
- Background `{colors.fin-orange}`, text `{colors.on-primary}`, type `{typography.button}`, rounded `{rounded.md}`, padding 10px 18px.
### Pricing Tabs
**`pricing-tab-default`** + **`pricing-tab-selected`** — Pill-toggle on `/pricing`.
- Default: `{colors.canvas}` background, `{colors.ink-muted}` text, rounded `{rounded.pill}`.
- Selected: `{colors.surface-1}` white background, `{colors.ink}` text — selected = lift onto white.
### Cards & Containers
**`pricing-card`** — Each tier on `/pricing`.
- Background `{colors.surface-1}`, text `{colors.ink}`, type `{typography.body}`, rounded `{rounded.lg}`, padding 24px.
**`pricing-card-featured`** — Featured / recommended tier — inverts to charcoal.
- Background `{colors.ink}`, text `{colors.on-primary}`, otherwise identical structure.
**`feature-card`** — Generic feature highlight.
- Background `{colors.surface-1}`, text `{colors.ink}`, type `{typography.body}`, rounded `{rounded.lg}`, padding 24px.
**`product-mockup-card`** — The dominant card type — frames a high-fidelity product UI screenshot.
- Background `{colors.surface-1}`, text `{colors.ink}`, type `{typography.body}`, rounded `{rounded.xl}`, padding 24px.
**`testimonial-card`** — Customer quote with avatar + name + company.
- Background `{colors.surface-1}`, text `{colors.ink}`, type `{typography.body-lg}`, rounded `{rounded.lg}`, padding 32px.
**`startup-discount-card`** — The "Startups get 90% off" tinted card.
- Background `{colors.surface-2}`, text `{colors.ink}`, type `{typography.body}`, rounded `{rounded.lg}`, padding 32px.
**`customer-logo-tile`** — Small tile in the customer marquee.
- Background `{colors.canvas}`, text `{colors.ink-muted}`, type `{typography.caption}`, rounded `{rounded.xs}`, padding 16px.
**`cta-banner`** — Closing CTA panel near page bottom.
- Background `{colors.surface-1}`, text `{colors.ink}`, type `{typography.headline}`, rounded `{rounded.lg}`, padding 48px.
### Inputs & Forms
**`text-input`** + **`text-input-focused`** — Form fields on contact and search overlays.
- Background `{colors.surface-1}`, text `{colors.ink}`, type `{typography.body}`, rounded `{rounded.md}`, padding 10px 14px.
### FAQ
**`faq-row`** — Expandable accordion row in the pricing FAQ.
- Background `{colors.canvas}`, text `{colors.ink}`, type `{typography.body}`, rounded `{rounded.md}`, padding 24px. 1px `{colors.hairline-soft}` bottom rule.
### Navigation
**`top-nav`** — Sticky cream bar with the Intercom wordmark left, nav links centered, log-in + sign-up pair right.
- Background `{colors.canvas}`, text `{colors.ink}`, type `{typography.body-sm}`, height 56px.
### Footer
**`footer`** — Dense link grid on `{colors.canvas}` cream with the Intercom wordmark left.
- Background `{colors.canvas}`, text `{colors.ink-muted}`, type `{typography.caption}`, padding 64px 32px.
## Do's and Don'ts
### Do
- Reserve `{colors.canvas}` cream as the system's anchor surface — never replace with pure white.
- Lift cards from cream onto white (`{colors.surface-1}`) for hierarchy.
- Use **`button-fin`** Fin Orange ONLY on Fin AI product CTAs and Fin badges.
- Pair Saans display at weight 500 with body at 400.
- Use product UI screenshots as the protagonist of every section.
- Use `{rounded.lg}` 12px for cards and `{rounded.xl}` 16px for product mockup tiles.
- Apply negative tracking proportionally to display sizes.
### Don't
- Don't use pure white as the canvas.
- Don't use Fin Orange as a section background or as a generic primary CTA.
- Don't add drop shadows to floating cards.
- Don't introduce a second display family.
- Don't pill-round CTAs.
- Don't write all-caps tracked eyebrows.
- Don't promote the report palette colors to brand-level surfaces.
- Don't combine charcoal CTAs and Fin Orange CTAs in the same viewport.
## Responsive Behavior
### Breakpoints
| Name | Width | Key Changes |
|---|---|---|
| Desktop-XL | 1440px | Default desktop layout |
| Desktop | 1280px | Card grid 3-up maintained |
| Tablet | 1024px | Card grid 3-up → 2-up |
| Mobile-Lg | 768px | Pricing comparison becomes accordion; nav hamburger |
| Mobile | 480px | Single-column; display-xl scales 72px → ~32px |
### Touch Targets
- CTAs hold ≥40px tap height across viewports.
- Pricing tab pills hold ≥40px tap height.
- Form inputs hold ≥44px tap target on touch.
### Collapsing Strategy
- **Top nav**: links collapse to hamburger below 768px; primary CTA stays visible.
- **Card grids**: 3-up → 2-up at 1024px → 1-up below 768px.
- **Pricing comparison**: collapses into per-tier accordion below 768px.
- **Display type**: `{typography.display-xl}` 72px scales toward `{typography.display-md}` 40px on mobile.
### Image Behavior
- Product UI screenshots maintain aspect ratio and never crop.
- Customer logos in the marquee may collapse from 6-up to 3-up below 768px.
## Iteration Guide
1. Focus on ONE component at a time and reference it by its `components:` token name.
2. When introducing a section, decide first whether it sits on `{colors.canvas}` cream (default) or whether it lifts onto a `{colors.surface-1}` white card.
3. Default body to `{typography.body}` at weight 400.
4. Run `npx @google/design.md lint DESIGN.md` after edits.
5. Add new variants as separate component entries.
6. Treat Fin Orange as a product accent: Fin CTA and Fin badge only.
7. Lead every section with a product screenshot.
## Known Gaps
- The **report palette** lives in product analytics dashboards rendered inside marketing mockups; they are documented for completeness but are not brand surface colors.
- Form-field error and validation styling is not visible on the inspected pages.
- Dark mode is not documented because the marketing site does not ship a dark theme.
- The helpdesk / inbox product surfaces show in-product UI states that aren't formal marketing chrome.
- Saans and SaansMono are proprietary; an open-source substitute (Inter, Söhne, Geist) is acceptable.
File diff suppressed because it is too large Load Diff
+282
View File
@@ -0,0 +1,282 @@
import { BarChart3, Search } from "lucide-react"
import { useState } from "react"
import { Link, Outlet, useMatchRoute } from "@tanstack/react-router"
import { Avatar, AvatarFallback } from "@/shared/ui/avatar"
import { Button } from "@/shared/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog"
import { Input } from "@/shared/ui/input"
import {
type NavigationItem,
mobilePrimaryNavigation,
mobileOverflowNavigation,
primaryNavigation,
routePresentation,
} from "./navigation"
function useActiveRoutePath() {
const matchRoute = useMatchRoute()
if (matchRoute({ to: "/selection", fuzzy: true })) return "/selection"
if (matchRoute({ to: "/", fuzzy: false })) return "/"
return "/"
}
export function AppLayout() {
const activePath = useActiveRoutePath()
const presentation = routePresentation[activePath] ?? routePresentation["/"]
const [moreOpen, setMoreOpen] = useState(false)
return (
<div className="flex h-svh min-h-0 flex-col overflow-hidden bg-background text-foreground md:grid md:grid-cols-[64px_minmax(0,1fr)] lg:grid-cols-[232px_minmax(0,1fr)]">
<DesktopSidebar activePath={activePath} />
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<TopBar
breadcrumb={presentation.breadcrumb}
title={presentation.title}
/>
<main
id="main-content"
className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden"
>
<Outlet />
</main>
</div>
<MobileBottomNav
activePath={activePath}
onMore={() => setMoreOpen(true)}
/>
<MoreSheet open={moreOpen} onOpenChange={setMoreOpen} />
</div>
)
}
function DesktopSidebar({ activePath }: { activePath: string }) {
return (
<aside
aria-label="主导航"
className="hidden shrink-0 flex-col border-r border-border bg-card/95 px-2 py-4 md:flex md:w-16 lg:w-[232px] lg:px-3.5"
>
<Brand />
<p className="mt-7 mb-2 hidden px-2 text-[10px] font-bold tracking-[0.09em] text-muted-foreground uppercase lg:block">
导航
</p>
<nav className="grid gap-0.5">
{primaryNavigation.map((item) => (
<DesktopNavItem
key={item.id}
item={item}
active={item.to === activePath}
/>
))}
</nav>
<div className="mt-auto border-t border-border pt-3.5">
<p className="hidden px-2 text-[11px] leading-[1.5] text-muted-foreground lg:block">
知行量化研究工作台,从市场数据概览开始。
</p>
</div>
</aside>
)
}
function Brand() {
return (
<div className="flex min-h-10 items-center justify-center gap-2.5 px-0 lg:justify-start lg:px-1.5">
<span className="grid size-8 shrink-0 place-items-center rounded-lg bg-primary text-primary-foreground">
<BarChart3 className="size-[17px]" aria-hidden="true" />
</span>
<div className="hidden min-w-0 lg:block">
<p className="truncate text-sm font-semibold tracking-[-0.01em]">
知行量化
</p>
<p className="text-[11px] text-muted-foreground">研究工作台</p>
</div>
</div>
)
}
function DesktopNavItem({
item,
active,
}: {
item: NavigationItem
active: boolean
}) {
const Icon = item.icon
if (item.to) {
return (
<Link
to={item.to}
aria-current={active ? "page" : undefined}
className={[
"flex min-h-9 w-full items-center justify-center gap-2.5 rounded-md px-0 text-left text-[13px] transition-colors outline-none focus-visible:ring-3 focus-visible:ring-ring/50 lg:justify-start lg:px-2.5",
active
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
].join(" ")}
>
<Icon className="size-4 shrink-0" aria-hidden="true" />
<span className="hidden truncate lg:inline">{item.label}</span>
</Link>
)
}
return (
<Button
disabled
variant="ghost"
className="min-h-9 w-full justify-center gap-2.5 px-0 text-[13px] text-muted-foreground lg:justify-start lg:px-2.5"
>
<Icon className="size-4 shrink-0" aria-hidden="true" />
<span className="hidden truncate lg:inline">{item.label}</span>
</Button>
)
}
function TopBar({ breadcrumb, title }: { breadcrumb: string; title: string }) {
return (
<header className="flex h-14 shrink-0 items-center gap-3 border-b border-border bg-background/90 px-4 backdrop-blur md:px-6">
<div className="min-w-0">
<p className="text-[11px] text-muted-foreground">{breadcrumb}</p>
<p className="truncate text-[13px] font-semibold">{title}</p>
</div>
<div className="ml-auto flex items-center gap-2">
<div className="relative hidden w-[min(330px,28vw)] md:block">
<Search
className="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground"
aria-hidden="true"
/>
<Input
aria-label="搜索"
className="h-8 pl-8 text-xs"
placeholder="搜索股票、数据或功能"
readOnly
/>
</div>
<Avatar aria-label="用户头像" size="sm">
<AvatarFallback>研</AvatarFallback>
</Avatar>
</div>
</header>
)
}
function MobileBottomNav({
activePath,
onMore,
}: {
activePath: string
onMore: () => void
}) {
const visible = mobilePrimaryNavigation
return (
<nav
aria-label="移动端主导航"
className="fixed inset-x-0 bottom-0 z-30 flex items-stretch gap-1 border-t border-border bg-card px-1 pb-[env(safe-area-inset-bottom)] md:hidden"
style={{ minHeight: "60px" }}
>
{visible.map((item) => {
const Icon = item.icon
const active = item.to === activePath
if (item.to) {
return (
<Link
key={item.id}
to={item.to}
aria-current={active ? "page" : undefined}
className="flex min-h-[60px] min-w-[44px] flex-1 flex-col items-center justify-center gap-0.5 rounded-md text-[10px] text-muted-foreground outline-none active:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50"
>
<Icon className="size-5" aria-hidden="true" />
<span>{item.label}</span>
</Link>
)
}
return (
<button
key={item.id}
type="button"
disabled
className="flex min-h-[60px] min-w-[44px] flex-1 flex-col items-center justify-center gap-0.5 rounded-md text-[10px] text-muted-foreground/50"
>
<Icon className="size-5" aria-hidden="true" />
<span>{item.label}</span>
</button>
)
})}
<button
type="button"
onClick={onMore}
aria-label="更多功能"
className="flex min-h-[60px] min-w-[44px] flex-1 flex-col items-center justify-center gap-0.5 rounded-md text-[10px] text-muted-foreground outline-none active:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50"
>
<span
className="grid h-5 w-5 place-items-center text-base leading-none"
aria-hidden="true"
>
···
</span>
<span>更多</span>
</button>
</nav>
)
}
function MoreSheet({
open,
onOpenChange,
}: {
open: boolean
onOpenChange: (open: boolean) => void
}) {
const overflow = mobileOverflowNavigation
if (overflow.length === 0) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent showCloseButton={false}>
<DialogHeader>
<DialogTitle>更多功能</DialogTitle>
<DialogDescription>
行情数据与同步任务尚未上线,暂无更多可用入口。
</DialogDescription>
</DialogHeader>
</DialogContent>
</Dialog>
)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent showCloseButton={false}>
<DialogHeader>
<DialogTitle>更多功能</DialogTitle>
<DialogDescription>
以下入口尚未上线,仅作占位说明。
</DialogDescription>
</DialogHeader>
<ul className="space-y-1">
{overflow.map((item) => {
const Icon = item.icon
return (
<li key={item.id}>
<Button
disabled
variant="ghost"
className="w-full justify-start gap-2.5"
>
<Icon className="size-4" aria-hidden="true" />
{item.label}
</Button>
</li>
)
})}
</ul>
</DialogContent>
</Dialog>
)
}
+74
View File
@@ -0,0 +1,74 @@
import {
BarChart3,
BrainCircuit,
ClipboardCheck,
LayoutDashboard,
type LucideIcon,
} from "lucide-react"
export type NavigationAvailability = "available" | "unavailable"
export interface NavigationItem {
id: string
label: string
to: string | null
icon: LucideIcon
availability: NavigationAvailability
}
export const primaryNavigation: readonly NavigationItem[] = [
{
id: "home",
label: "首页",
to: "/",
icon: LayoutDashboard,
availability: "available",
},
{
id: "market",
label: "行情数据",
to: null,
icon: BarChart3,
availability: "unavailable",
},
{
id: "sync",
label: "同步任务",
to: null,
icon: ClipboardCheck,
availability: "unavailable",
},
{
id: "selection",
label: "选股策略",
to: "/selection",
icon: BrainCircuit,
availability: "available",
},
] as const
export const mobileNavigationMaxItems = 4
export const mobilePrimaryNavigation: readonly NavigationItem[] =
primaryNavigation.slice(0, mobileNavigationMaxItems)
export const mobileOverflowNavigation: readonly NavigationItem[] = []
export interface RoutePresentation {
id: string
breadcrumb: string
title: string
}
export const routePresentation: Record<string, RoutePresentation> = {
"/": {
id: "home",
breadcrumb: "研究工作台",
title: "市场数据概览",
},
"/selection": {
id: "selection",
breadcrumb: "研究工作台",
title: "知行 B1 执行结果",
},
}
@@ -0,0 +1,49 @@
import type { ReactNode } from "react"
import { cn } from "@/shared/ui/utils"
interface PageLayoutProps {
mode?: "document" | "bounded-workspace"
actions?: ReactNode
metrics?: ReactNode
children: ReactNode
}
/**
* PageLayout 只承载页面内容节奏与滚动边界,不渲染页面大标题、眉题或说明。
* - document: 自然页面滚动,适合首页等普通详情页。
* - bounded-workspace: 连续高度链,只有内部数据区滚动,用于选股主从研究台。
*/
export function PageLayout({
mode = "document",
actions,
metrics,
children,
}: PageLayoutProps) {
const bounded = mode === "bounded-workspace"
return (
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
{actions ? (
<div className="shrink-0 border-b border-border px-4 py-3 md:px-6 md:py-3">
{actions}
</div>
) : null}
{metrics ? (
<div className="shrink-0 border-b border-border px-4 py-3 md:px-6 md:py-3">
{metrics}
</div>
) : null}
<div
className={cn(
"flex min-h-0 min-w-0 flex-1 flex-col",
bounded ? "overflow-y-auto md:overflow-hidden" : "overflow-y-auto",
"px-4 pb-6 pt-4 md:px-6 md:pb-8 md:pt-5",
// 移动端底部为底部导航预留安全区
"pb-[calc(5.25rem+env(safe-area-inset-bottom))] md:pb-8",
)}
>
{children}
</div>
</div>
)
}
@@ -1,124 +0,0 @@
import {
BarChart3,
BrainCircuit,
ClipboardCheck,
LayoutDashboard,
Search,
} from "lucide-react"
import { Avatar, AvatarFallback } from "@/shared/ui/avatar"
import { Button } from "@/shared/ui/button"
import { Input } from "@/shared/ui/input"
import { Separator } from "@/shared/ui/separator"
interface HomeShellProps {
children: React.ReactNode
activeSection?: "home" | "selection"
}
const navigation = [
{
label: "首页",
icon: LayoutDashboard,
section: "home" as const,
path: "/" as const,
},
{ label: "行情数据", icon: BarChart3, section: null, path: null },
{ label: "同步任务", icon: ClipboardCheck, section: null, path: null },
{
label: "选股策略",
icon: BrainCircuit,
section: "selection" as const,
path: "/selection" as const,
},
]
export function HomeShell({
activeSection = "home",
children,
}: HomeShellProps) {
return (
<div className="flex min-h-screen bg-muted/30 text-foreground">
<aside className="flex w-20 shrink-0 flex-col border-r bg-card px-3 py-5 md:w-64 md:px-5">
<div className="flex items-center gap-3 px-1 md:px-2">
<div className="flex size-9 items-center justify-center rounded-xl bg-primary text-primary-foreground shadow-sm">
<BarChart3 className="size-5" aria-hidden="true" />
</div>
<div className="hidden min-w-0 md:block">
<p className="truncate text-sm font-semibold tracking-wide">
知行量化
</p>
<p className="text-xs text-muted-foreground">研究工作台</p>
</div>
</div>
<Separator className="my-6" />
<nav aria-label="主导航" className="space-y-1">
{navigation.map((item) => {
const Icon = item.icon
const active = item.section === activeSection
const content = (
<>
<Icon className="size-4 shrink-0" aria-hidden="true" />
<span className="hidden md:inline">{item.label}</span>
</>
)
if (item.path) {
return (
<Button
key={item.label}
className="w-full justify-start gap-3 px-2 md:px-3"
render={<a href={item.path} />}
variant={active ? "secondary" : "ghost"}
aria-current={active ? "page" : undefined}
>
{content}
</Button>
)
}
return (
<Button
key={item.label}
className="w-full justify-start gap-3 px-2 md:px-3"
disabled
variant="ghost"
>
{content}
</Button>
)
})}
</nav>
<div className="mt-auto hidden rounded-xl bg-muted/60 p-4 md:block">
<p className="text-xs font-medium">数据研究空间</p>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
从市场数据概览开始,掌握当前研究基础。
</p>
</div>
</aside>
<div className="min-w-0 flex-1">
<header className="flex h-16 items-center justify-between gap-4 border-b bg-card/80 px-4 backdrop-blur md:px-8">
<div className="relative w-full max-w-md">
<Search
className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground"
aria-hidden="true"
/>
<Input
aria-label="搜索"
className="h-9 bg-muted/40 pl-9"
placeholder="搜索股票、数据或功能"
readOnly
/>
</div>
<Avatar aria-label="用户头像">
<AvatarFallback>研</AvatarFallback>
</Avatar>
</header>
<main className="mx-auto w-full max-w-7xl p-4 md:p-8">{children}</main>
</div>
</div>
)
}
@@ -51,20 +51,13 @@ describe("HomePage", () => {
})
})
it("renders the management shell and partial overview", () => {
it("renders the overview card with the partial overview", () => {
render(<HomePage />)
expect(screen.getByRole("heading", { name: "首页" })).toBeInTheDocument()
expect(screen.getByText("市场数据概览")).toBeInTheDocument()
expect(screen.getByText("部分更新")).toBeInTheDocument()
expect(screen.getByText("当前目标股票池")).toBeInTheDocument()
expect(screen.getByText("33.33%")).toBeInTheDocument()
expect(screen.getByRole("button", { name: "行情数据" })).toBeDisabled()
expect(screen.getByRole("button", { name: "同步任务" })).toBeDisabled()
expect(screen.getByRole("link", { name: "选股策略" })).toHaveAttribute(
"href",
"/selection",
)
})
it("opens and closes the accessible failed-stock dialog", () => {
@@ -142,7 +135,7 @@ describe("HomePage", () => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument()
})
it("keeps the shell and explains the no-data state", () => {
it("explains the no-data state", () => {
useHomeOverview.mockReturnValue({
data: { status: "no_data", stock_pool_count: 7, latest_update: null },
isError: false,
@@ -1,6 +1,7 @@
import { AlertTriangle, Database, RefreshCw } from "lucide-react"
import { useState } from "react"
import { PageLayout } from "@/app/layout/page-layout"
import { useHomeOverview } from "@/features/home/api/home.query"
import { Button } from "@/shared/ui/button"
import {
@@ -13,7 +14,6 @@ import {
import { Skeleton } from "@/shared/ui/skeleton"
import { FailedStocksDialog } from "../components/failed-stocks-dialog"
import { HomeShell } from "../components/home-shell"
import { MarketDataOverviewCard } from "../components/market-data-overview-card"
export function HomePage() {
@@ -22,16 +22,8 @@ export function HomePage() {
const update = overview.data?.latest_update
return (
<HomeShell activeSection="home">
<div className="space-y-8">
<section className="space-y-2">
<p className="text-sm font-medium text-primary">欢迎回到知行量化</p>
<h1 className="text-3xl font-semibold tracking-tight">首页</h1>
<p className="max-w-2xl text-muted-foreground">
从当前目标股票池和市场数据同步状态开始,快速判断研究数据是否准备就绪。
</p>
</section>
<PageLayout>
<div className="mx-auto w-full max-w-6xl space-y-6">
{overview.isPending ? <OverviewLoading /> : null}
{overview.isError ? (
@@ -60,7 +52,7 @@ export function HomePage() {
/>
) : null}
</div>
</HomeShell>
</PageLayout>
)
}
@@ -0,0 +1,55 @@
import type { SelectionSignal } from "../api/selection.types"
export const categoryLabels: Record<string, string> = {
zhixing_b1_extreme_volume: "超卖超缩量 B",
zhixing_b1_original_b1: "原始 B1",
zhixing_b1_oversold_turn: "超卖缩量拐头 B",
zhixing_b1_oversold_volume: "超卖缩量 B",
zhixing_b1_pullback_super: "回踩超级 B",
zhixing_b1_pullback_white: "回踩白线 B",
zhixing_b1_pullback_yellow: "回踩黄线 B",
}
export type SignalCategoryFilter = "all" | "pullback" | "oversold" | "original"
export const signalCategoryOptions: ReadonlyArray<{
label: string
value: SignalCategoryFilter
}> = [
{ label: "全部信号", value: "all" },
{ label: "回踩类", value: "pullback" },
{ label: "超卖类", value: "oversold" },
{ label: "原始 B1", value: "original" },
]
export function getCategoryLabel(category: string) {
return categoryLabels[category] ?? category
}
export function getSignalKey(signal: SelectionSignal) {
return `${signal.ts_code}-${signal.category}`
}
export function formatDetails(details: SelectionSignal["details"]) {
return Object.entries(details)
.map(([key, value]) => `${key}: ${value ?? "—"}`)
.join(" · ")
}
export function matchesCategory(
signal: SelectionSignal,
category: SignalCategoryFilter,
) {
if (category === "all") return true
return signal.category.includes(category)
}
export function categoryToneClass(category: string) {
if (category.includes("pullback")) {
return "border-warning/30 bg-warning/10 text-warning"
}
if (category.includes("oversold")) {
return "border-primary/15 bg-primary/5 text-primary"
}
return "border-success/30 bg-success/10 text-success"
}
@@ -0,0 +1,151 @@
import { useEffect, useMemo, useState } from "react"
import { useNavigate, useSearch } from "@tanstack/react-router"
import { Input } from "@/shared/ui/input"
import { Pagination } from "@/shared/ui/pagination"
import type { SelectionResults, SelectionSignal } from "../api/selection.types"
import { SignalDetailPanel } from "./signal-detail-panel"
import { SignalRecordList } from "./signal-record-list"
import {
getSignalKey,
matchesCategory,
signalCategoryOptions,
type SignalCategoryFilter,
} from "./selection-presentation"
import { SignalTable } from "./signal-table"
const PAGE_SIZE_OPTIONS = [5, 10, 20] as const
interface SelectionResultsWorkbenchProps {
result: SelectionResults
}
export function SelectionResultsWorkbench({
result,
}: SelectionResultsWorkbenchProps) {
const search = useSearch({ from: "/_workspace/selection" })
const navigate = useNavigate({ from: "/selection" })
const [query, setQuery] = useState("")
const [category, setCategory] = useState<SignalCategoryFilter>("all")
const [selectedKey, setSelectedKey] = useState<string | null>(null)
const [expandedKeys, setExpandedKeys] = useState<ReadonlySet<string>>(
new Set(),
)
const filteredSignals = useMemo(() => {
const normalized = query.trim().toLowerCase()
return result.signals.filter((signal) => {
const matchesQuery =
normalized.length === 0 ||
signal.name.toLowerCase().includes(normalized) ||
signal.ts_code.toLowerCase().includes(normalized)
return matchesQuery && matchesCategory(signal, category)
})
}, [category, query, result.signals])
const page = search.page
const pageSize = search.pageSize
const pageCount = Math.max(1, Math.ceil(filteredSignals.length / pageSize))
const currentPage = Math.min(page, pageCount)
const currentSignals = filteredSignals.slice(
(currentPage - 1) * pageSize,
currentPage * pageSize,
)
const selectedSignal =
currentSignals.find((signal) => getSignalKey(signal) === selectedKey) ??
currentSignals[0]
useEffect(() => {
if (page > pageCount) {
void navigate({
search: (previous) => ({ ...previous, page: pageCount }),
})
}
}, [navigate, page, pageCount])
function updateSearch(next: { page?: number; pageSize?: number }) {
void navigate({ search: (previous) => ({ ...previous, ...next }) })
}
function handleQueryChange(value: string) {
setQuery(value)
updateSearch({ page: 1 })
}
function handleCategoryChange(value: SignalCategoryFilter) {
setCategory(value)
updateSearch({ page: 1 })
}
function handleToggleExpanded(signal: SelectionSignal) {
const key = getSignalKey(signal)
setExpandedKeys((previous) => {
const next = new Set(previous)
if (next.has(key)) next.delete(key)
else next.add(key)
return next
})
}
return (
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-3 overflow-visible md:grid md:grid-cols-[minmax(0,1fr)_260px] md:overflow-hidden lg:grid-cols-[minmax(0,1.55fr)_minmax(280px,0.72fr)]">
<section className="flex min-h-[420px] min-w-0 flex-1 flex-col overflow-visible rounded-lg border border-border/80 bg-card md:min-h-0 md:overflow-hidden">
<div className="grid shrink-0 gap-2 border-b border-border/60 p-3 sm:grid-cols-[minmax(0,260px)_auto_1fr] sm:items-center">
<Input
aria-label="搜索命中信号"
className="h-11 bg-background text-sm md:h-8"
onChange={(event) => handleQueryChange(event.target.value)}
placeholder="搜索股票名称或代码"
type="search"
value={query}
/>
<select
aria-label="筛选信号分类"
className="h-11 rounded-lg border border-input bg-background px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 md:h-8"
onChange={(event) =>
handleCategoryChange(event.target.value as SignalCategoryFilter)
}
value={category}
>
{signalCategoryOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<span className="text-xs tabular-nums text-muted-foreground sm:justify-self-end">
筛选结果 {filteredSignals.length} 条
</span>
</div>
<div className="flex min-w-0 flex-col md:min-h-0 md:flex-1 md:overflow-hidden">
<SignalTable
onSelect={(signal) => setSelectedKey(getSignalKey(signal))}
selectedKey={selectedSignal ? getSignalKey(selectedSignal) : null}
signals={currentSignals}
/>
<SignalRecordList
expandedKeys={expandedKeys}
onSelect={(signal) => setSelectedKey(getSignalKey(signal))}
onToggleExpanded={handleToggleExpanded}
signals={currentSignals}
/>
</div>
<Pagination
onPageChange={(nextPage) => updateSearch({ page: nextPage })}
onPageSizeChange={(nextPageSize) =>
updateSearch({ page: 1, pageSize: nextPageSize })
}
page={currentPage}
pageSize={pageSize}
pageSizeOptions={PAGE_SIZE_OPTIONS}
total={filteredSignals.length}
/>
</section>
<SignalDetailPanel signal={selectedSignal} />
</div>
)
}
@@ -0,0 +1,92 @@
import { Database, Search } from "lucide-react"
import { Badge } from "@/shared/ui/badge"
import { Card } from "@/shared/ui/card"
import type { SelectionSignal } from "../api/selection.types"
import {
categoryToneClass,
formatDetails,
getCategoryLabel,
} from "./selection-presentation"
interface SignalDetailPanelProps {
signal: SelectionSignal | undefined
}
export function SignalDetailPanel({ signal }: SignalDetailPanelProps) {
if (!signal) {
return (
<Card
aria-label="当前股票详情"
className="hidden min-h-0 overflow-auto rounded-lg border-border/80 bg-card p-4 md:flex md:flex-col md:justify-center"
>
<div className="space-y-2 text-center">
<Search
className="mx-auto size-5 text-muted-foreground"
aria-hidden="true"
/>
<p className="font-medium">暂无选中信号</p>
<p className="text-xs leading-5 text-muted-foreground">
从左侧列表选择一条信号查看详情。
</p>
</div>
</Card>
)
}
return (
<aside
aria-label="当前股票详情"
className="hidden min-h-0 overflow-auto rounded-lg border border-border/80 bg-card p-4 md:block"
>
<p className="text-[10px] font-bold tracking-[0.08em] text-muted-foreground uppercase">
Selected signal
</p>
<div className="mt-2 flex items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="truncate text-xl font-semibold tracking-tight">
{signal.name}
</h2>
<p className="font-mono text-xs text-muted-foreground">
{signal.ts_code}
</p>
</div>
<Database
className="mt-1 size-4 shrink-0 text-muted-foreground"
aria-hidden="true"
/>
</div>
<p className="mt-5 text-3xl font-semibold tabular-nums tracking-tight">
¥ {signal.close.toFixed(2)}
</p>
<Badge
className={`mt-3 ${categoryToneClass(signal.category)}`}
variant="outline"
>
{getCategoryLabel(signal.category)}
</Badge>
<dl className="mt-5 divide-y divide-border/60 border-t border-border/60 text-xs">
<div className="flex items-center justify-between gap-3 py-2.5">
<dt className="text-muted-foreground">目标交易日</dt>
<dd className="font-medium tabular-nums">
{signal.target_trade_date}
</dd>
</div>
<div className="flex items-center justify-between gap-3 py-2.5">
<dt className="text-muted-foreground">策略</dt>
<dd className="font-medium">知行 B1</dd>
</div>
<div className="space-y-1.5 py-2.5">
<dt className="text-muted-foreground">关键依据</dt>
<dd className="leading-5 text-foreground">
{formatDetails(signal.details)}
</dd>
</div>
</dl>
</aside>
)
}
@@ -0,0 +1,90 @@
import { ChevronDown, ChevronUp } from "lucide-react"
import { Badge } from "@/shared/ui/badge"
import { Button } from "@/shared/ui/button"
import type { SelectionSignal } from "../api/selection.types"
import {
categoryToneClass,
formatDetails,
getCategoryLabel,
getSignalKey,
} from "./selection-presentation"
interface SignalRecordListProps {
signals: SelectionSignal[]
expandedKeys: ReadonlySet<string>
onSelect: (signal: SelectionSignal) => void
onToggleExpanded: (signal: SelectionSignal) => void
}
export function SignalRecordList({
signals,
expandedKeys,
onSelect,
onToggleExpanded,
}: SignalRecordListProps) {
if (signals.length === 0) {
return (
<div className="flex min-h-32 items-center justify-center px-4 text-sm text-muted-foreground md:hidden">
没有符合当前筛选条件的信号。
</div>
)
}
return (
<div className="divide-y divide-border/60 md:hidden">
{signals.map((signal) => {
const key = getSignalKey(signal)
const expanded = expandedKeys.has(key)
return (
<article key={key} className="space-y-2 px-3 py-3">
<button
type="button"
className="flex min-h-11 w-full items-center justify-between gap-3 text-left outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
onClick={() => onSelect(signal)}
>
<span className="min-w-0">
<span className="block truncate font-medium text-foreground">
{signal.name}
</span>
<span className="font-mono text-[10px] text-muted-foreground">
{signal.ts_code}
</span>
</span>
<span className="shrink-0 font-medium tabular-nums text-foreground">
¥ {signal.close.toFixed(2)}
</span>
</button>
<div className="flex items-center justify-between gap-3">
<Badge
className={categoryToneClass(signal.category)}
variant="outline"
>
{getCategoryLabel(signal.category)}
</Badge>
<Button
aria-expanded={expanded}
className="min-h-11 gap-1 px-2 text-xs text-muted-foreground"
onClick={() => onToggleExpanded(signal)}
variant="ghost"
>
{expanded ? "收起详情" : "查看详情"}
{expanded ? (
<ChevronUp aria-hidden="true" />
) : (
<ChevronDown aria-hidden="true" />
)}
</Button>
</div>
{expanded ? (
<p className="text-xs leading-5 text-muted-foreground">
{formatDetails(signal.details)}
</p>
) : null}
</article>
)
})}
</div>
)
}
@@ -0,0 +1,98 @@
import type { KeyboardEvent } from "react"
import { Badge } from "@/shared/ui/badge"
import type { SelectionSignal } from "../api/selection.types"
import {
categoryToneClass,
formatDetails,
getCategoryLabel,
getSignalKey,
} from "./selection-presentation"
interface SignalTableProps {
signals: SelectionSignal[]
selectedKey: string | null
onSelect: (signal: SelectionSignal) => void
}
export function SignalTable({
signals,
selectedKey,
onSelect,
}: SignalTableProps) {
function handleRowKeyDown(
event: KeyboardEvent<HTMLTableRowElement>,
signal: SelectionSignal,
) {
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
onSelect(signal)
}
return (
<div className="hidden min-h-0 flex-1 overflow-auto md:block">
<table className="w-full border-collapse text-left text-xs">
<caption className="sr-only">知行 B1 命中信号明细</caption>
<thead className="sticky top-0 z-10 bg-card text-[11px] text-muted-foreground">
<tr className="border-b border-border/70">
<th className="h-9 px-3 font-medium">股票</th>
<th className="h-9 px-3 font-medium">子信号</th>
<th className="h-9 px-3 font-medium">收盘价</th>
<th className="h-9 px-3 font-medium">关键详情</th>
</tr>
</thead>
<tbody>
{signals.length > 0 ? (
signals.map((signal) => {
const selected = selectedKey === getSignalKey(signal)
return (
<tr
key={getSignalKey(signal)}
aria-selected={selected}
className="cursor-pointer border-b border-border/50 outline-none transition-colors hover:bg-muted/50 focus-visible:bg-muted/60 data-[selected=true]:bg-muted/70"
data-selected={selected}
onClick={() => onSelect(signal)}
onKeyDown={(event) => handleRowKeyDown(event, signal)}
tabIndex={0}
>
<td className="h-11 px-3 align-middle">
<p className="font-medium text-foreground">{signal.name}</p>
<p className="font-mono text-[10px] text-muted-foreground">
{signal.ts_code}
</p>
</td>
<td className="px-3 align-middle">
<Badge
className={categoryToneClass(signal.category)}
variant="outline"
>
{getCategoryLabel(signal.category)}
</Badge>
</td>
<td className="px-3 align-middle font-medium tabular-nums text-foreground">
¥ {signal.close.toFixed(2)}
</td>
<td className="max-w-[360px] px-3 align-middle text-muted-foreground">
<span className="line-clamp-2">
{formatDetails(signal.details)}
</span>
</td>
</tr>
)
})
) : (
<tr>
<td
className="h-32 px-3 text-center text-sm text-muted-foreground"
colSpan={4}
>
没有符合当前筛选条件的信号。
</td>
</tr>
)}
</tbody>
</table>
</div>
)
}
@@ -5,6 +5,7 @@ import type { SelectionResults } from "../api/selection.types"
import { SelectionResultsPage } from "./selection-results-page"
const routerNavigate = vi.hoisted(() => vi.fn())
const useSelectionResults = vi.fn()
const useSelectionRun = vi.fn()
const useTriggerSelectionRun = vi.fn()
@@ -17,6 +18,11 @@ vi.mock("@/features/selection/api/selection.query", () => ({
useTriggerSelectionRun: () => useTriggerSelectionRun(),
}))
vi.mock("@tanstack/react-router", () => ({
useNavigate: () => routerNavigate,
useSearch: () => ({ page: 1, pageSize: 5 }),
}))
const selectedResult: SelectionResults = {
created_at: "2026-08-08T09:00:00+08:00",
coverage: 1,
@@ -77,18 +83,75 @@ describe("SelectionResultsPage", () => {
})
mutate.mockReset()
reset.mockReset()
routerNavigate.mockReset()
})
it("renders persisted results and keeps multiple categories visible", () => {
render(<SelectionResultsPage />)
expect(screen.getByText("执行完成")).toBeInTheDocument()
const table = screen.getByRole("table")
expect(table).toBeInTheDocument()
expect(within(table).getByText("原始 B1")).toBeInTheDocument()
expect(within(table).getByText("回踩白线 B")).toBeInTheDocument()
expect(within(table).getAllByText("平安银行")).toHaveLength(2)
})
it("filters signals, updates the detail panel, and expands mobile details", () => {
const extraSignal = {
...selectedResult.signals[0],
category: "zhixing_b1_oversold_volume",
details: { macd: 3.2 },
name: "招商银行",
ts_code: "600036.SH",
}
useSelectionResults.mockReturnValue({
data: {
...selectedResult,
signal_count: 3,
signals: [...selectedResult.signals, extraSignal],
},
isError: false,
isPending: false,
})
render(<SelectionResultsPage />)
fireEvent.change(screen.getByLabelText("搜索命中信号"), {
target: { value: "招商" },
})
const table = screen.getByRole("table")
expect(within(table).getByText("招商银行")).toBeInTheDocument()
fireEvent.click(within(table).getByText("招商银行"))
expect(
screen.getByRole("heading", { name: "选股策略" }),
within(
screen.getByRole("complementary", { name: "当前股票详情" }),
).getByText("招商银行"),
).toBeInTheDocument()
expect(screen.getByText("知行 B1 执行结果")).toBeInTheDocument()
expect(screen.getByText("原始 B1")).toBeInTheDocument()
expect(screen.getByText("回踩白线 B")).toBeInTheDocument()
expect(screen.getAllByText("平安银行")).toHaveLength(2)
const expandButton = screen.getAllByRole("button", { name: "查看详情" })[0]
fireEvent.click(expandButton)
expect(expandButton).toHaveAttribute("aria-expanded", "true")
expect(screen.getAllByText("macd: 3.2").length).toBeGreaterThan(1)
})
it("navigates to another page when the result list is paginated", () => {
const signals = Array.from({ length: 6 }, (_, index) => ({
...selectedResult.signals[0],
name: `股票${index + 1}`,
ts_code: `00000${index + 1}.SZ`,
}))
useSelectionResults.mockReturnValue({
data: { ...selectedResult, signal_count: signals.length, signals },
isError: false,
isPending: false,
})
render(<SelectionResultsPage />)
expect(screen.getByText("第 1–5 条,共 6 条")).toBeInTheDocument()
fireEvent.click(screen.getByRole("button", { name: "第 2 页" }))
expect(routerNavigate).toHaveBeenCalled()
})
it("requires confirmation before a rerun and does not trigger on cancel", () => {
@@ -7,6 +7,7 @@ import {
} from "lucide-react"
import { useState } from "react"
import { PageLayout } from "@/app/layout/page-layout"
import {
useSelectionResults,
useSelectionRun,
@@ -15,7 +16,6 @@ import {
import type {
SelectionResults,
SelectionRunStatus,
SelectionSignal,
} from "@/features/selection/api/selection.types"
import { Badge } from "@/shared/ui/badge"
import { Button } from "@/shared/ui/button"
@@ -37,7 +37,7 @@ import {
import { Input } from "@/shared/ui/input"
import { Skeleton } from "@/shared/ui/skeleton"
import { HomeShell } from "@/features/home/components/home-shell"
import { SelectionResultsWorkbench } from "../components/selection-results-workbench"
const STRATEGY = "zhixing_b1" as const
@@ -50,16 +50,6 @@ const statusPresentation: Record<
success: { label: "执行完成", variant: "default" },
}
const categoryLabels: Record<string, string> = {
zhixing_b1_extreme_volume: "超卖超缩量 B",
zhixing_b1_original_b1: "原始 B1",
zhixing_b1_oversold_turn: "超卖缩量拐头 B",
zhixing_b1_oversold_volume: "超卖缩量 B",
zhixing_b1_pullback_super: "回踩超级 B",
zhixing_b1_pullback_white: "回踩白线 B",
zhixing_b1_pullback_yellow: "回踩黄线 B",
}
export function SelectionResultsPage() {
const [targetTradeDate, setTargetTradeDate] = useState<string | null>(null)
const [activeRunId, setActiveRunId] = useState<string | null>(null)
@@ -123,78 +113,30 @@ export function SelectionResultsPage() {
trigger.reset()
}
const resultMetrics =
displayedResult &&
displayedResult.status !== "no_data" &&
displayedResult.status !== "running" ? (
<ResultMetrics result={displayedResult} />
) : null
return (
<HomeShell activeSection="selection">
<div className="space-y-8">
<section className="space-y-2">
<p className="text-sm font-medium text-primary">研究策略执行</p>
<h1 className="text-3xl font-semibold tracking-tight">选股策略</h1>
<p className="max-w-2xl text-muted-foreground">
查看知行 B1 在指定交易日的持久化执行结果,或重新执行当天策略。
</p>
</section>
<Card>
<CardHeader>
<CardTitle className="text-xl">执行条件</CardTitle>
<CardDescription>
策略只使用已完成且达到选股资格的市场数据同步批次。
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4 md:flex-row md:items-end">
<label
className="flex-1 space-y-2 text-sm font-medium"
htmlFor="target-trade-date"
>
目标交易日
<Input
id="target-trade-date"
onChange={(event) => handleDateChange(event.target.value)}
type="date"
value={selectedTargetTradeDate}
/>
</label>
<label
className="flex-1 space-y-2 text-sm font-medium"
htmlFor="strategy-name"
>
策略
<select
className="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
disabled
id="strategy-name"
value={STRATEGY}
>
<option value={STRATEGY}>知行 B1</option>
</select>
</label>
<Button
disabled={!selectedTargetTradeDate || isRunning}
onClick={handleExecute}
>
{isRunning ? (
<RefreshCw aria-hidden="true" className="animate-spin" />
) : hasExistingResult ? (
<RefreshCw aria-hidden="true" />
) : (
<Play aria-hidden="true" />
)}
{isRunning
? "执行中"
: hasExistingResult
? displayedResult?.status === "failed"
? "重试执行"
: "重新执行"
: "执行策略"}
</Button>
</CardContent>
{trigger.isError ? (
<p className="px-6 pb-6 text-sm text-destructive" role="alert">
执行请求失败,请确认市场数据状态后重试。
</p>
) : null}
</Card>
<PageLayout
actions={
<ExecutionToolbar
hasExistingResult={hasExistingResult}
isFailedResult={displayedResult?.status === "failed"}
isRunning={isRunning}
onDateChange={handleDateChange}
onExecute={handleExecute}
selectedTargetTradeDate={selectedTargetTradeDate}
triggerError={trigger.isError}
/>
}
metrics={resultMetrics}
mode="bounded-workspace"
>
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-3 md:overflow-y-auto">
{isRunning ? <RunningState /> : null}
{!isRunning &&
!hasQueryError &&
@@ -249,10 +191,128 @@ export function SelectionResultsPage() {
</DialogContent>
</Dialog>
) : null}
</HomeShell>
</PageLayout>
)
}
interface ExecutionToolbarProps {
hasExistingResult: boolean
isFailedResult: boolean
isRunning: boolean
onDateChange: (value: string) => void
onExecute: () => void
selectedTargetTradeDate: string
triggerError: boolean
}
function ExecutionToolbar({
hasExistingResult,
isFailedResult,
isRunning,
onDateChange,
onExecute,
selectedTargetTradeDate,
triggerError,
}: ExecutionToolbarProps) {
return (
<div className="w-full space-y-2">
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] sm:items-end">
<label
className="space-y-1 text-xs font-medium"
htmlFor="target-trade-date"
>
目标交易日
<Input
className="mt-1 h-11 bg-background md:h-8"
id="target-trade-date"
onChange={(event) => onDateChange(event.target.value)}
type="date"
value={selectedTargetTradeDate}
/>
</label>
<label
className="space-y-1 text-xs font-medium"
htmlFor="strategy-name"
>
策略
<select
className="mt-1 h-11 w-full rounded-lg border border-input bg-background px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:opacity-50 md:h-8"
disabled
id="strategy-name"
value={STRATEGY}
>
<option value={STRATEGY}>知行 B1</option>
</select>
</label>
<Button
className="h-11 w-full sm:w-auto md:h-8"
disabled={!selectedTargetTradeDate || isRunning}
onClick={onExecute}
>
{isRunning ? (
<RefreshCw aria-hidden="true" className="animate-spin" />
) : hasExistingResult ? (
<RefreshCw aria-hidden="true" />
) : (
<Play aria-hidden="true" />
)}
{isRunning
? "执行中"
: hasExistingResult
? isFailedResult
? "重试执行"
: "重新执行"
: "执行策略"}
</Button>
</div>
{triggerError ? (
<p className="text-sm text-destructive" role="alert">
执行请求失败,请确认市场数据状态后重试。
</p>
) : null}
</div>
)
}
function ResultMetrics({ result }: { result: SelectionResults }) {
const presentation = getStatusPresentation(result.status)
return (
<div className="grid w-full grid-cols-2 gap-x-5 gap-y-2 sm:grid-cols-3 lg:grid-cols-[repeat(5,minmax(0,1fr))_auto]">
<Metric label="目标股票数" value={result.target_count} />
<Metric label="实际参与数" value={result.eligible_count} />
<Metric label="命中股票数" value={result.selected_stock_count} />
<Metric label="信号条数" value={result.signal_count} />
<Metric
label="数据覆盖率"
value={`${(result.coverage * 100).toFixed(2)}%`}
/>
{presentation ? (
<div className="space-y-1">
<p className="text-[11px] text-muted-foreground">执行状态</p>
<Badge className="gap-1" variant={presentation.variant}>
{result.status === "success" ? (
<CheckCircle2 aria-hidden="true" />
) : null}
{presentation.label}
</Badge>
</div>
) : null}
</div>
)
}
function getStatusPresentation(status: SelectionRunStatus) {
if (
status === "failed" ||
status === "partial_success" ||
status === "success"
) {
return statusPresentation[status]
}
return null
}
function LoadingState() {
return (
<Card aria-label="正在加载策略执行结果">
@@ -355,111 +415,27 @@ function FailureState({ result }: { result: SelectionResults }) {
}
function ResultState({ result }: { result: SelectionResults }) {
const presentation =
result.status === "success" ||
result.status === "partial_success" ||
result.status === "failed"
? statusPresentation[result.status]
: null
return (
<div className="space-y-6">
<Card>
<CardHeader className="gap-4 border-b md:flex-row md:items-start md:justify-between">
<div className="space-y-1.5">
<CardTitle className="flex items-center gap-2 text-xl">
<CheckCircle2
aria-hidden="true"
className="size-5 text-primary"
/>
知行 B1 执行结果
</CardTitle>
<CardDescription>
目标交易日:{result.target_trade_date} · 市场同步批次:
{result.market_sync_batch_id}
</CardDescription>
</div>
{presentation ? (
<Badge variant={presentation.variant}>{presentation.label}</Badge>
) : null}
</CardHeader>
<CardContent className="grid gap-4 pt-6 sm:grid-cols-2 lg:grid-cols-5">
<Metric label="目标股票数" value={result.target_count} />
<Metric label="实际参与数" value={result.eligible_count} />
<Metric label="命中股票数" value={result.selected_stock_count} />
<Metric label="信号条数" value={result.signal_count} />
<Metric
label="数据覆盖率"
value={`${(result.coverage * 100).toFixed(2)}%`}
/>
</CardContent>
</Card>
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-3">
{result.signal_count === 0 ? (
<Card>
<CardHeader>
<CardTitle className="text-xl">本次没有命中信号</CardTitle>
<CardDescription>
目标交易日数据完整,但没有符合公式条件的股票。
</CardDescription>
</CardHeader>
</Card>
<NoSignalState />
) : (
<SignalTable signals={result.signals} />
<SelectionResultsWorkbench result={result} />
)}
{result.failures.length > 0 ? <FailureList result={result} /> : null}
</div>
)
}
function SignalTable({ signals }: { signals: SelectionSignal[] }) {
function NoSignalState() {
return (
<Card>
<CardHeader>
<CardTitle className="text-xl">命中信号明细</CardTitle>
<CardTitle className="text-xl">本次没有命中信号</CardTitle>
<CardDescription>
同一股票同日的多个子信号分别保留,不合并分类。
目标交易日数据完整,但没有符合公式条件的股票。
</CardDescription>
</CardHeader>
<CardContent className="overflow-x-auto">
<table className="w-full min-w-[720px] text-left text-sm">
<thead className="border-b text-muted-foreground">
<tr>
<th className="pb-3 pr-4 font-medium">股票</th>
<th className="pb-3 pr-4 font-medium">子信号</th>
<th className="pb-3 pr-4 font-medium">收盘价</th>
<th className="pb-3 font-medium">关键详情</th>
</tr>
</thead>
<tbody>
{signals.map((signal) => (
<tr
key={`${signal.ts_code}-${signal.category}`}
className="border-b last:border-0"
>
<td className="py-3 pr-4">
<p className="font-medium">{signal.name}</p>
<p className="font-mono text-xs text-muted-foreground">
{signal.ts_code}
</p>
</td>
<td className="py-3 pr-4">
<Badge variant="outline">
{categoryLabels[signal.category] ?? signal.category}
</Badge>
</td>
<td className="py-3 pr-4 tabular-nums">
{signal.close.toFixed(2)}
</td>
<td className="py-3 text-muted-foreground">
{formatDetails(signal.details)}
</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
)
}
@@ -502,15 +478,11 @@ function FailureList({ result }: { result: SelectionResults }) {
function Metric({ label, value }: { label: string; value: number | string }) {
return (
<div className="space-y-1">
<p className="text-sm text-muted-foreground">{label}</p>
<p className="text-lg font-semibold tracking-tight">{value}</p>
<div className="space-y-0.5">
<p className="text-[11px] text-muted-foreground">{label}</p>
<p className="text-base font-semibold tracking-tight tabular-nums">
{value}
</p>
</div>
)
}
function formatDetails(details: SelectionSignal["details"]) {
return Object.entries(details)
.map(([key, value]) => `${key}: ${value ?? "—"}`)
.join(" · ")
}
+20 -3
View File
@@ -1,5 +1,6 @@
import { createRootRoute, createRoute, Outlet } from "@tanstack/react-router"
import { AppLayout } from "@/app/layout/app-layout"
import { HomePage } from "@/features/home/pages/home-page"
import { SelectionResultsPage } from "@/features/selection/pages/selection-results-page"
@@ -7,16 +8,32 @@ const rootRoute = createRootRoute({
component: () => <Outlet />,
})
const indexRoute = createRoute({
const workspaceRoute = createRoute({
getParentRoute: () => rootRoute,
id: "_workspace",
component: AppLayout,
})
const indexRoute = createRoute({
getParentRoute: () => workspaceRoute,
path: "/",
component: HomePage,
})
const selectionRoute = createRoute({
getParentRoute: () => rootRoute,
getParentRoute: () => workspaceRoute,
path: "/selection",
validateSearch: (search: Record<string, unknown>) => {
const rawPage = Number(search.page)
const rawPageSize = Number(search.pageSize)
const page =
Number.isFinite(rawPage) && rawPage >= 1 ? Math.floor(rawPage) : 1
const pageSize = [5, 10, 20].includes(rawPageSize) ? rawPageSize : 10
return { page, pageSize }
},
component: SelectionResultsPage,
})
export const routeTree = rootRoute.addChildren([indexRoute, selectionRoute])
export const routeTree = rootRoute.addChildren([
workspaceRoute.addChildren([indexRoute, selectionRoute]),
])
+138
View File
@@ -0,0 +1,138 @@
import { ChevronLeft, ChevronRight } from "lucide-react"
import type { ChangeEvent } from "react"
import { Button } from "@/shared/ui/button"
import { cn } from "@/shared/ui/utils"
interface PaginationProps {
page: number
pageSize: number
total: number
pageSizeOptions?: readonly number[]
onPageChange: (page: number) => void
onPageSizeChange: (pageSize: number) => void
}
const DEFAULT_PAGE_SIZE_OPTIONS = [10, 20, 50] as const
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value))
}
function rangeLabel(page: number, pageSize: number, total: number) {
if (total <= 0) return "共 0 条"
const start = (page - 1) * pageSize + 1
const end = Math.min(page * pageSize, total)
return `第 ${start}–${end} 条,共 ${total} 条`
}
function buildPageItems(page: number, pageCount: number) {
if (pageCount <= 1) return [1]
const items: Array<number | "ellipsis"> = [1]
const start = Math.max(2, page - 1)
const end = Math.min(pageCount - 1, page + 1)
if (start > 2) items.push("ellipsis")
for (let p = start; p <= end; p += 1) items.push(p)
if (end < pageCount - 1) items.push("ellipsis")
if (pageCount > 1) items.push(pageCount)
return items
}
export function Pagination({
page,
pageSize,
total,
pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS,
onPageChange,
onPageSizeChange,
}: PaginationProps) {
const pageCount = Math.max(1, Math.ceil(total / pageSize))
const currentPage = clamp(page, 1, pageCount)
const hasPrev = currentPage > 1
const hasNext = currentPage < pageCount
const items = buildPageItems(currentPage, pageCount)
function handlePageSizeChange(event: ChangeEvent<HTMLSelectElement>) {
onPageSizeChange(Number(event.target.value))
}
return (
<footer
aria-label="表格分页"
className="flex min-h-12 flex-wrap items-center gap-3 border-t border-border/60 px-3 py-2 text-xs text-muted-foreground"
>
<span className="tabular-nums" aria-live="polite">
{rangeLabel(currentPage, pageSize, total)}
</span>
<label className="ml-auto flex items-center gap-1.5">
每页
<select
className="h-11 rounded-md border border-input bg-card px-2 text-xs text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 md:h-7"
aria-label="每页数量"
value={pageSize}
onChange={handlePageSizeChange}
>
{pageSizeOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
条
</label>
<div className="flex items-center gap-1">
<Button
aria-label="上一页"
disabled={!hasPrev}
onClick={() => onPageChange(currentPage - 1)}
className="size-11 md:size-7"
size="icon-sm"
variant="ghost"
>
<ChevronLeft aria-hidden="true" />
</Button>
{items.map((item, index) => {
if (item === "ellipsis") {
return (
<span
key={`ellipsis-${index}`}
className="px-1.5 text-muted-foreground/70"
aria-hidden="true"
>
…
</span>
)
}
const active = item === currentPage
return (
<button
key={item}
type="button"
aria-current={active ? "page" : undefined}
aria-label={`第 ${item} 页`}
onClick={() => onPageChange(item)}
className={cn(
"size-11 rounded-md border border-transparent text-xs tabular-nums transition-colors outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 md:size-7",
active
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
{item}
</button>
)
})}
<Button
aria-label="下一页"
disabled={!hasNext}
onClick={() => onPageChange(currentPage + 1)}
className="size-11 md:size-7"
size="icon-sm"
variant="ghost"
>
<ChevronRight aria-hidden="true" />
</Button>
</div>
</footer>
)
}
+41 -14
View File
@@ -9,11 +9,20 @@
--color-card-foreground: var(--card-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-success: var(--success);
--color-success-foreground: var(--success-foreground);
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
@@ -21,17 +30,26 @@
}
:root {
--background: oklch(0.985 0.002 247.839);
--foreground: oklch(0.145 0.015 252.417);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0.015 252.417);
--primary: oklch(0.488 0.243 264.376);
--primary-foreground: oklch(0.97 0.014 254.604);
--muted: oklch(0.968 0.007 247.896);
--muted-foreground: oklch(0.554 0.046 257.417);
--accent: oklch(0.932 0.032 255.585);
--accent-foreground: oklch(0.279 0.041 260.031);
--destructive: oklch(0.577 0.245 27.325);
--background: #f5f1ec;
--foreground: #111111;
--card: #ffffff;
--card-foreground: #111111;
--primary: #111111;
--primary-foreground: #ffffff;
--secondary: #ebe7e1;
--secondary-foreground: #111111;
--muted: #ebe7e1;
--muted-foreground: #626260;
--accent: #ebe7e1;
--accent-foreground: #111111;
--destructive: #b3261e;
--success: #147a46;
--success-foreground: #e5f4eb;
--warning: #9b5607;
--warning-foreground: #fff0d8;
--border: #d3cec6;
--input: #d3cec6;
--ring: #111111;
--radius: 0.75rem;
}
@@ -40,18 +58,27 @@
--foreground: oklch(0.984 0.003 247.858);
--card: oklch(0.208 0.042 265.755);
--card-foreground: oklch(0.984 0.003 247.858);
--primary: oklch(0.707 0.165 254.624);
--primary-foreground: oklch(0.208 0.042 265.755);
--primary: oklch(0.984 0.003 247.858);
--primary-foreground: oklch(0.129 0.042 264.695);
--secondary: oklch(0.279 0.041 260.031);
--secondary-foreground: oklch(0.984 0.003 247.858);
--muted: oklch(0.279 0.041 260.031);
--muted-foreground: oklch(0.704 0.04 256.788);
--accent: oklch(0.279 0.041 260.031);
--accent-foreground: oklch(0.984 0.003 247.858);
--destructive: oklch(0.704 0.191 22.216);
--success: oklch(0.6 0.13 150);
--success-foreground: oklch(0.15 0.05 150);
--warning: oklch(0.7 0.15 75);
--warning-foreground: oklch(0.2 0.08 75);
--border: oklch(0.35 0.02 260);
--input: oklch(0.35 0.02 260);
--ring: oklch(0.984 0.003 247.858);
}
@layer base {
* {
@apply border-slate-200 dark:border-slate-800;
@apply border-border;
}
body {