feat(home): 添加市场数据概览
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"Review the completed backend-to-frontend contract for field drift, same-origin routing, and state ownership."}
|
||||
{"file":".trellis/spec/guides/code-reuse-thinking-guide.md","reason":"Check that the implementation reused existing transport, query, styling, and application composition seams."}
|
||||
{"file":".trellis/spec/backend/http-api-contracts.md","reason":"Verify response_model, router mounting, dependency boundaries, and black-box HTTP tests."}
|
||||
{"file":".trellis/spec/backend/error-handling.md","reason":"Verify backend errors are not converted into false no-data success and frontend retry remains possible."}
|
||||
{"file":".trellis/spec/backend/quality-guidelines.md","reason":"Run and assess backend formatting, lint, strict typing, and behavior tests."}
|
||||
{"file":".trellis/spec/frontend/component-guidelines.md","reason":"Review semantic structure, keyboard behavior, accessible Dialog, and shadcn primitive composition."}
|
||||
{"file":".trellis/spec/frontend/hook-guidelines.md","reason":"Review query state handling, AbortSignal propagation, and React Query ownership."}
|
||||
{"file":".trellis/spec/frontend/quality-guidelines.md","reason":"Run and assess frontend format, lint, typecheck, Vitest, and build gates."}
|
||||
{"file":".trellis/tasks/08-07-home-market-overview/research/shadcn-ui.md","reason":"Confirm newly added UI primitives follow the researched shadcn CLI/component conventions and do not introduce an unrelated UI system."}
|
||||
@@ -0,0 +1,98 @@
|
||||
# 技术设计:Home 市场数据概览
|
||||
|
||||
## 1. 边界与模块归属
|
||||
|
||||
市场数据概览属于现有 `modules/market_data` bounded context:
|
||||
|
||||
```text
|
||||
zhixing-server/src/zhixing_server/modules/market_data/
|
||||
├── domain/overview.py # 无框架的读取模型和值
|
||||
├── application/overview.py # 读取用例/端口编排
|
||||
├── infrastructure/postgres.py # PostgreSQL 聚合读取适配器
|
||||
└── presentation/home.py # Pydantic 模型与 /home/overview 路由
|
||||
```
|
||||
|
||||
顶层 `interfaces/http/router.py` 只挂载 bounded context 的 presentation router。FastAPI 依赖和 Pydantic 只出现在 presentation;domain/application 不导入 FastAPI 或 Psycopg。
|
||||
|
||||
前端增加 `features/home/` 垂直切片;`shared/ui` 仅承载无业务状态的 shadcn primitives。Home shell 和 overview card 的业务组合保留在 feature 内。
|
||||
|
||||
## 2. HTTP 契约
|
||||
|
||||
`GET /api/v1/home/overview` 返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"stock_pool_count": 2,
|
||||
"latest_update": {
|
||||
"trade_date": "2026-08-06",
|
||||
"completed_at": "2026-08-07T16:30:00+08:00",
|
||||
"status": "success",
|
||||
"target_count": 2,
|
||||
"successful_count": 2,
|
||||
"failed_count": 0,
|
||||
"valid_count": 2,
|
||||
"coverage_rate": 1.0,
|
||||
"failures": [],
|
||||
"batch_errors": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
约定:
|
||||
|
||||
- 顶层 `status` 表示 Home 当前状态。没有任何批次时为 `no_data`,`latest_update` 为 `null`。
|
||||
- `latest_update.status` 只使用 `updating`、`success`、`partial_success`、`failed`;无批次不创建虚假的 update 对象。
|
||||
- `completed_at` 对 running 批次为 `null`。所有非空时间在 presentation 边界转换到 `Asia/Shanghai`。
|
||||
- `failure` 包含 `ts_code`、`name`、`failure_type`、`reason`、`trade_date`。
|
||||
- `batch_error` 包含 `item_key`、`error_type`、`reason`;只聚合 `market_sync_item.item_kind = 'batch'`,不把日期级 `daily_basic` 错误伪造成股票级错误。
|
||||
- 覆盖率以 `Decimal` 在读取层计算,Pydantic 在 JSON 边界输出数值;分母为 0 时返回 0。
|
||||
|
||||
## 3. 批次选择与事实计算
|
||||
|
||||
读取适配器在一个 PostgreSQL 连接/读取事务中完成聚合:
|
||||
|
||||
1. 统计 `market_stock.is_active = true` 的当前目标股票池。
|
||||
2. 找出最新 `target_trade_date`;该日期上的最新 running 批次优先,否则选该日期 `status != 'running'` 的最新已完成批次,排序使用 `finished_at DESC, created_at DESC`。这保证同日 retry 只展示最后完成结果,且不会让旧日期 running 批次遮蔽更新日期。
|
||||
3. 若没有任何批次,返回 `no_data`。
|
||||
4. 对选中批次的目标交易日,使用 active 股票与 `market_daily_bar`、`market_daily_basic` 的 left join;两张事实表均存在的股票构成有效/成功集合。
|
||||
5. `failed_count` 是 active 目标股票池减去有效集合;失败详情优先使用同批次 `market_sync_item` 中该代码的 `bar` 失败记录,否则按缺失的日线/估值事实生成可读原因。若两类事实都缺失,优先呈现已记录的股票级错误,再使用稳定的缺失类型。
|
||||
6. 读取 `item_kind = 'batch' AND status = 'failed'` 作为独立 batch errors。
|
||||
7. `target_count` 保留选中批次记录的目标数;`stock_pool_count` 是当前 active 数;正常同步期间两者相同。`successful_count` / `valid_count` 来自当前事实完整性计算,`coverage_rate` 为成功数除以目标数,分母为 0 时为 0。
|
||||
|
||||
状态映射:
|
||||
|
||||
| 事实 | 状态 |
|
||||
| --- | --- |
|
||||
| 无同步批次 | `no_data` |
|
||||
| 最新交易日仍有 running 批次 | `updating` |
|
||||
| 已完成批次无失败且所有目标股票完整 | `success` |
|
||||
| 已完成批次存在失败但至少一只股票完整 | `partial_success` |
|
||||
| 已完成批次失败或没有任何完整股票 | `failed` |
|
||||
|
||||
这条读取路径不触发同步、不修改数据、不读取 CSV。数据库错误继续抛出 `MarketDataRepositoryError`,由现有 FastAPI 默认 500 边界暴露,避免把基础设施错误伪装成 `no_data`。
|
||||
|
||||
## 4. 依赖与测试 seam
|
||||
|
||||
- 增加 `MarketDataOverviewReader` protocol,HTTP 依赖工厂返回具体 `PostgresMarketDataRepository`;测试通过 `app.dependency_overrides` 注入内存 reader,HTTP 测试仍调用 `TestClient(create_app())`。
|
||||
- 读取模型使用 frozen dataclass,Pydantic response model 负责日期/时间和字段名转换。
|
||||
- 后端契约测试使用多个 active/inactive 股票、完整/缺失事实、running/completed/retry/batch error 记录构造 fake reader,验证用户可见 JSON,不断言 SQL。
|
||||
- 前端 API 通过 `requestJson` 保留 `AbortSignal`;query 使用 feature 内 `as const` key。
|
||||
|
||||
## 5. shadcn/ui 组合策略
|
||||
|
||||
现有 `Card`、`Badge`、`Button` 作为基础,按当前 `components.json` 的 `base-nova` 约定补齐并复用:
|
||||
|
||||
- shell:`Avatar`、`Input`、`Separator`、`Button`;
|
||||
- loading:`Skeleton`;
|
||||
- status/data:`Card`、`Badge`、`Progress`;
|
||||
- failure details:`Dialog` + `ScrollArea`。
|
||||
|
||||
优先用 shadcn CLI 生成标准 primitive;如果生成器与锁定依赖版本不兼容,则采用 CLI 对应的当前源代码形状,在 `shared/ui` 保持同样的可访问属性、原生 props、`cn` 合并和组件组合接口。Home feature 不直接依赖第三方 modal/scroll implementation。
|
||||
|
||||
## 6. 兼容性、迁移与回滚
|
||||
|
||||
- 不新增数据库迁移;只读模型适配现有五张表。
|
||||
- API 新增,不修改 `/api/v1/system/status`;根路由从 smoke 页面切换为 Home,但既有 system feature 测试文件保留。
|
||||
- 若读取查询需要兼容已有迁移字段,使用当前 schema 中的字段和状态值,不变更同步写入语义。
|
||||
- 回滚点是移除 Home route、overview presentation/reader 及新增前端 feature/shared primitives;数据库无需回滚。
|
||||
@@ -0,0 +1,17 @@
|
||||
{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"Home overview changes the FastAPI response, same-origin adapter, TypeScript types, query hook, and page states; keep the contract synchronized across layers."}
|
||||
{"file":".trellis/spec/guides/code-reuse-thinking-guide.md","reason":"The implementation must reuse requestJson, React Query keys, cn, existing UI primitives, and the application factory before adding helpers."}
|
||||
{"file":".trellis/spec/backend/directory-structure.md","reason":"The backend feature belongs to the existing market_data bounded context and needs domain/application/infrastructure/presentation boundaries."}
|
||||
{"file":".trellis/spec/backend/market-data-sync.md","reason":"The overview reads the market-data tables and must preserve current stock-pool, daily bar, valuation, batch, retry, and failure semantics."}
|
||||
{"file":".trellis/spec/backend/http-api-contracts.md","reason":"The new FastAPI route and Pydantic response must follow the /api/v1 router and HTTP testing contract."}
|
||||
{"file":".trellis/spec/backend/error-handling.md","reason":"Database and transport failures must remain distinguishable from no-data responses and map cleanly to the frontend retry state."}
|
||||
{"file":".trellis/spec/backend/quality-guidelines.md","reason":"Backend public types, response models, imports, and tests must satisfy Ruff, Pyright, and pytest rules."}
|
||||
{"file":".trellis/spec/frontend/directory-structure.md","reason":"Home is a frontend feature vertical slice while shadcn primitives remain in shared/ui."}
|
||||
{"file":".trellis/spec/frontend/component-guidelines.md","reason":"The page must use semantic, accessible React compositions and shadcn UI primitives with cn/Tailwind conventions."}
|
||||
{"file":".trellis/spec/frontend/hook-guidelines.md","reason":"The overview adapter and hook must use requestJson, AbortSignal, query keys, and explicit query states."}
|
||||
{"file":".trellis/spec/frontend/type-safety.md","reason":"The API response needs strict TypeScript types and no unsafe assertions beyond the existing transport boundary."}
|
||||
{"file":".trellis/spec/frontend/quality-guidelines.md","reason":"Home behavior tests and the final frontend quality gate follow the repository's Vitest/Testing Library and lint/typecheck rules."}
|
||||
{"file":"docs/adr/0001-bounded-context-first-modular-monolith.md","reason":"The backend overview must remain inside the market_data bounded context rather than creating global service layers."}
|
||||
{"file":"docs/adr/0002-use-a-same-origin-browser-api.md","reason":"The browser adapter must use /api/v1 and preserve Vite/Nginx same-origin routing."}
|
||||
{"file":"docs/adr/0003-postgresql-as-market-data-store.md","reason":"PostgreSQL remains the runtime fact source for the overview; CSV is not queried."}
|
||||
{"file":"docs/adr/0004-tushare-six-year-snapshot-sync.md","reason":"Batch status, retries, partial success, coverage, and failure semantics come from the synchronization ADR."}
|
||||
{"file":".trellis/tasks/08-07-home-market-overview/research/shadcn-ui.md","reason":"The task explicitly requires full shadcn/ui reuse; this records current CLI and primitive guidance for base-nova."}
|
||||
@@ -0,0 +1,65 @@
|
||||
# 实施计划:Home 市场数据概览
|
||||
|
||||
## 顺序清单
|
||||
|
||||
1. [x] 在后端建立 overview domain/application 读取模型、reader protocol 和 PostgreSQL 聚合读取;保留 `market_data` 层边界。
|
||||
2. [x] 增加 Home presentation Pydantic response、依赖工厂、`GET /api/v1/home/overview`,并挂载到 `/api/v1`。
|
||||
3. [x] 增加后端 HTTP contract tests,覆盖 no data、running、success、partial、failed、active count、coverage、stock failures、batch errors;同日 retry 选择由 PostgreSQL 查询实现,未在本机无数据库环境中直接执行。
|
||||
4. [x] 通过 shadcn CLI 补齐 `shared/ui` 所需 primitives;核对 `base-nova` 风格、`@base-ui/react` 依赖和原生可访问行为。
|
||||
5. [x] 建立 `features/home/api` 的 types、adapter、query;严格复用 `requestJson`、AbortSignal 和 React Query。
|
||||
6. [x] 实现 Home shell、sidebar、header、overview card、status branches、失败详情 Dialog/ScrollArea,并把根路由从 system smoke page 切换到 Home。
|
||||
7. [x] 编写 Home 页面行为测试,覆盖 loading/error/no-data/status 分支、计数/覆盖率、失败详情打开、正常关闭与 Escape 关闭、导航禁用项和 Dialog accessible name。
|
||||
8. [x] 逐步运行后端单测/类型检查和前端单测/typecheck;修复格式、lint、类型问题。
|
||||
9. [x] 执行后端与前端质量门禁、build、Trellis check 和 code review 流程;根级 `./dev.sh check` 仅受两个既有格式问题阻断,已如实记录。
|
||||
|
||||
## 验证命令
|
||||
|
||||
后端(在 `zhixing-server/`):
|
||||
|
||||
```bash
|
||||
uv run ruff format --check .
|
||||
uv run ruff check .
|
||||
uv run pyright
|
||||
uv run pytest tests/test_home_overview_http.py
|
||||
uv run pytest
|
||||
```
|
||||
|
||||
前端(在 `zhixing-web/`):
|
||||
|
||||
```bash
|
||||
pnpm format:check
|
||||
pnpm lint
|
||||
pnpm typecheck
|
||||
pnpm test -- src/features/home/pages/home-page.test.tsx
|
||||
pnpm test
|
||||
pnpm build
|
||||
```
|
||||
|
||||
根级(依赖可用时):
|
||||
|
||||
```bash
|
||||
./dev.sh check
|
||||
./dev.sh test
|
||||
```
|
||||
|
||||
## 风险与回滚点
|
||||
|
||||
- PostgreSQL 读取查询必须正确区分 running、同日 retry 和旧日期批次;先用 fake reader HTTP 测试锁定 contract,再实现 SQL。
|
||||
- `market_sync_item` 现有表只记录 daily-basic 的日期级失败,不能凭空生成股票级估值错误;缺失事实使用稳定的合成原因,batch error 单独输出。
|
||||
- 当前仓库只有少量 shared UI primitive;新增 shadcn components 可能更新 `package.json` / `pnpm-lock.yaml`,需检查变更是否只服务本任务。
|
||||
- 任何测试、格式化或 CLI 自动修改不得覆盖用户已有的 `CONTEXT.md` 和 `.scratch/home-market-overview/spec.md` 变更。
|
||||
- 如果某个质量命令因本机缺少 PostgreSQL、依赖或网络而无法运行,记录为 not run/blocked,不用假通过替代。
|
||||
|
||||
## Final verification record
|
||||
|
||||
- 后端:任务相关 Ruff format、Ruff check、Pyright 通过;`pytest` 为 `20 passed, 1 skipped`,集成测试因未设置 `ZHIXING_TEST_DATABASE_URL` 跳过。
|
||||
- 前端:`pnpm check`、`pnpm build` 通过,10 个测试通过。
|
||||
- 根级:`./dev.sh test` 通过;`./dev.sh check` 仅被未修改的 `src/zhixing_server/modules/market_data/application/sync.py` 和 `tests/unit/market_data/test_sync.py` 格式问题阻断。
|
||||
- 质量检查:Trellis check 修复了失败项重复关联风险,并补充了状态和 Escape 行为测试;code-review 子代理因超时未返回可用报告,主会话完成了 spec/standards 对照复核。
|
||||
|
||||
## 启动前 review gate
|
||||
|
||||
- `prd.md` 已移除导航 open question,并包含 shadcn/ui 复用要求。
|
||||
- `design.md` 已锁定响应形状、批次选择、失败分类、依赖 seam 和回滚策略。
|
||||
- `implement.jsonl` / `check.jsonl` 已有真实 spec/research context。
|
||||
- 用户已明确批准导航结构和 shadcn/ui 要求;完成最终规划摘要后,下一条用户消息需明确批准规划再执行 `task.py start`。
|
||||
@@ -0,0 +1,60 @@
|
||||
# Home 市场数据概览
|
||||
|
||||
## Goal
|
||||
|
||||
让量化研究者进入系统后,在一个稳定的管理后台 Home 页面立即了解当前目标股票池和最近一次市场数据同步状态,并能定位失败股票。
|
||||
|
||||
## Background and confirmed facts
|
||||
|
||||
- 当前根路由展示的是后端连通性 smoke 页面,前端只有 `system` feature。
|
||||
- `market_data` bounded context 已有 `market_stock`、`market_daily_bar`、`market_daily_basic`、`market_sync_batch` 和 `market_sync_item` 表及同步写入逻辑。
|
||||
- PostgreSQL 是运行时市场数据事实源;CSV 仅是同步快照和恢复介质。
|
||||
- 当前目标股票池由 active 的沪深非 ST A 股组成,历史 inactive 主数据不能计入当前目标数。
|
||||
- 一只股票只有在目标交易日同时存在日线行情和估值快照时才是有效股票。
|
||||
- 同一交易日的重试批次以最近一次已完成结果为展示结果;该交易日仍有 running 批次时优先展示“更新中”。
|
||||
- 浏览器通过同源 `/api/v1` 路径访问后端。
|
||||
- 侧边栏采用确认过的最小结构:`首页` 可用,`行情数据`、`同步任务`、`选股策略` 可见但禁用。
|
||||
- 前端项目已配置 shadcn/ui(`base-nova`、Tailwind CSS 4、Lucide 图标和 `@/shared/ui` 别名);本任务必须优先复用现有 shadcn primitives,并按需补齐标准 primitive。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Backend overview contract
|
||||
|
||||
- 新增只读 `GET /api/v1/home/overview` 聚合接口。
|
||||
- 响应提供当前目标股票池数、最近更新交易日、同步完成时间、更新状态、目标数、成功数、失败数、有效数、覆盖率,以及失败股票详情和批次级错误。
|
||||
- 状态稳定表示为 `updating`、`success`、`partial_success`、`failed`、`no_data`;无任何批次时必须能与 HTTP/服务错误区分。
|
||||
- 目标交易日按最近开市日语义展示;接口不得用旧成功批次冒充更新了更晚交易日。
|
||||
- 日期和完成时间在展示边界按 `Asia/Shanghai` 表达;交易日与完成时间必须是独立字段。
|
||||
- 目标数只计算 active 当前目标股票池;成功数只计算目标日同时存在 daily bar 和 daily basic 的股票;覆盖率为成功数 / 目标数。
|
||||
- 失败股票包含代码、名称、失败类型、可读原因和目标交易日。缺失 daily bar、缺失 valuation snapshot、已记录的股票级失败应能区分;批次级错误不得伪造成股票失败。
|
||||
- PostgreSQL 读取失败沿用现有 HTTP 错误边界,不返回伪造的空成功数据。
|
||||
|
||||
### Frontend Home
|
||||
|
||||
- 根路由替换为 Home 页面,同时保留现有 provider、router、query client 和主题行为。
|
||||
- Home 使用 feature 垂直切片,包含 overview API adapter、响应类型、query hook、页面和失败详情对话框。
|
||||
- 页面包含管理后台 shell:侧边栏、顶部搜索视觉占位、头像视觉区域和主内容区;未实现的导航入口可见但禁用。
|
||||
- 管理后台 shell、概览卡片、状态反馈和失败详情优先由 `shared/ui` 中的 shadcn primitives 组合实现;按需补齐并使用 `Avatar`、`Dialog`、`Input`、`Progress`、`ScrollArea`、`Separator`、`Skeleton` 等标准组件,不在 feature 中复制同类基础交互。
|
||||
- 主内容使用一个“市场数据概览”组合卡片,展示最近更新交易日、同步完成时间、状态、目标/成功/失败数量、覆盖率和当前目标股票池数量。
|
||||
- 分别渲染加载占位、暂无数据、请求异常及重试、更新中、已更新、部分更新和未更新状态;请求异常提供 retry 操作。
|
||||
- 点击失败汇总打开可滚动、可关闭、支持键盘交互的失败详情对话框;对话框展示代码、名称、失败类型、原因和目标交易日。
|
||||
- 桌面和平板保持可读;窄屏将内容纵向堆叠,不影响失败详情访问。
|
||||
- 搜索和头像只做视觉展示,不实现搜索、认证、账户菜单或权限行为。
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `GET /api/v1/home/overview` 的 HTTP 契约可测试覆盖:active 股票计数、完整成功、部分成功、失败批次、running 批次、无批次、同日 retry 选择、覆盖率、股票级失败详情和批次级错误。
|
||||
- [ ] 接口对完整成功、部分成功、失败、更新中和无数据返回正确稳定状态;批次级错误不会出现在股票失败列表中。
|
||||
- [ ] Home 根路由可见管理后台 shell 和“市场数据概览”卡片;既有 system HTTP/frontend 测试继续通过。
|
||||
- [ ] 侧边栏显示 `首页`、`行情数据`、`同步任务`、`选股策略`,只有首页可操作,其余入口明确禁用;搜索和头像保持视觉占位。
|
||||
- [ ] Home 的卡片、状态反馈和失败弹窗使用项目 `@/shared/ui` 的 shadcn primitives,新增 primitive 保持 `components.json` 的 `base-nova` / Tailwind 约定。
|
||||
- [ ] Home 前端测试覆盖正常、加载、无数据、请求错误与重试、全部更新状态、数量/覆盖率、打开失败对话框、详情字段和关闭行为,并包含对话框可访问名称断言。
|
||||
- [ ] 后端 Ruff、Pyright、pytest 以及前端格式检查、lint、typecheck、Vitest、build 通过;根级检查按环境可用性执行并如实记录。
|
||||
|
||||
## Out of scope
|
||||
|
||||
- 从 Home 触发同步、重试失败股票、修复数据或执行选股。
|
||||
- 股票搜索、股票详情、同步历史、策略/选股页面、实时或分钟行情。
|
||||
- 认证、用户资料、账户菜单和权限管理。
|
||||
- 修改六年保留策略、Tushare 同步流程、市场数据写入语义或 PostgreSQL 事实源。
|
||||
- 独立交易日历产品和超出可用堆叠布局的移动端视觉打磨。
|
||||
@@ -0,0 +1,23 @@
|
||||
# shadcn/ui 组件研究
|
||||
|
||||
## 来源
|
||||
|
||||
- Context7 library: `/shadcn-ui/ui`
|
||||
- 当前官方文档通过 Context7 返回的来源包括:
|
||||
- `https://github.com/shadcn-ui/ui/blob/main/apps/v4/content/docs/(root)/cli.mdx`
|
||||
- `https://github.com/shadcn-ui/ui/blob/main/apps/v4/content/docs/components/aria/scroll-area.mdx`
|
||||
- `https://github.com/shadcn-ui/ui/blob/main/apps/v4/content/docs/components/radix/skeleton.mdx`
|
||||
- `https://github.com/shadcn-ui/ui/blob/main/apps/v4/content/docs/installation/vite.mdx`
|
||||
|
||||
## 结论
|
||||
|
||||
- 当前仓库的 `components.json` 已配置为 `base-nova`、Tailwind CSS 4、Lucide 图标,并将 `@/shared/ui` 作为 UI alias;新增组件应落在 `zhixing-web/src/shared/ui/`。
|
||||
- 官方推荐通过 `npx shadcn@latest add [component-name]` 添加组件。若当前 CLI 与仓库版本不兼容,则保留相同 shadcn 组件边界和可访问性契约,在本地按生成结果最小化实现。
|
||||
- Home 需要的基础能力包括 `Avatar`、`Dialog`、`Input`、`Progress`、`ScrollArea`、`Separator` 和 `Skeleton`;`Card`、`Badge`、`Button` 已存在,应先复用并只补足实际缺失的 variant/API。
|
||||
- `Skeleton` 使用 Tailwind class 控制尺寸;`Dialog`、`ScrollArea` 等交互组件必须保留标准键盘与可访问行为,页面测试应从 role、name 和用户可见文本验证。
|
||||
|
||||
## 对本任务的约束
|
||||
|
||||
- 不在 Home feature 内重新实现 modal、滚动容器、头像或 loading primitive。
|
||||
- 不把 shadcn primitive 变成数据感知组件;数据请求和业务分支留在 feature/page 层。
|
||||
- 若执行 CLI 添加组件,检查其依赖、生成文件和 `pnpm-lock.yaml`,再运行格式、lint、类型和测试门禁。
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"id": "home-market-overview",
|
||||
"name": "home-market-overview",
|
||||
"title": "实现 Home 市场数据概览",
|
||||
"description": "",
|
||||
"status": "in_progress",
|
||||
"dev_type": null,
|
||||
"scope": null,
|
||||
"package": null,
|
||||
"priority": "P2",
|
||||
"creator": "yuxuanhui",
|
||||
"assignee": "yuxuanhui",
|
||||
"createdAt": "2026-08-07",
|
||||
"completedAt": null,
|
||||
"branch": null,
|
||||
"base_branch": "main",
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"relatedFiles": [],
|
||||
"notes": "",
|
||||
"meta": {}
|
||||
}
|
||||
@@ -3,8 +3,10 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from zhixing_server.interfaces.http.system import operational_router, system_router
|
||||
from zhixing_server.modules.market_data.presentation.home import home_router
|
||||
|
||||
api_v1_router = APIRouter(prefix="/api/v1")
|
||||
api_v1_router.include_router(system_router, prefix="/system", tags=["system"])
|
||||
api_v1_router.include_router(home_router, prefix="/home", tags=["home"])
|
||||
|
||||
__all__ = ["api_v1_router", "operational_router"]
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Application port and use case for the Home market-data overview."""
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
from ..domain.overview import MarketDataOverview
|
||||
|
||||
|
||||
class MarketDataOverviewReader(Protocol):
|
||||
"""Read port implemented by the PostgreSQL market-data adapter."""
|
||||
|
||||
def read_overview(self) -> MarketDataOverview:
|
||||
"""Return the current Home overview without changing market data."""
|
||||
|
||||
...
|
||||
|
||||
|
||||
def get_market_data_overview(reader: MarketDataOverviewReader) -> MarketDataOverview:
|
||||
"""Execute the overview read use case at the application boundary.
|
||||
|
||||
Args:
|
||||
reader: A bounded-context read adapter supplied by application wiring.
|
||||
|
||||
Returns:
|
||||
The current market-data overview read model.
|
||||
"""
|
||||
|
||||
return reader.read_overview()
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Read models for the market-data overview shown on Home."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Literal
|
||||
|
||||
OverviewStatus = Literal["updating", "success", "partial_success", "failed", "no_data"]
|
||||
LatestUpdateStatus = Literal["updating", "success", "partial_success", "failed"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OverviewFailure:
|
||||
"""A target stock that lacks a complete set of facts for one trading day."""
|
||||
|
||||
ts_code: str
|
||||
name: str
|
||||
failure_type: str
|
||||
reason: str
|
||||
trade_date: date
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OverviewBatchError:
|
||||
"""An error recorded for a batch rather than for one stock."""
|
||||
|
||||
item_key: str
|
||||
error_type: str
|
||||
reason: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MarketDataUpdate:
|
||||
"""The selected synchronization result and its stock-level facts."""
|
||||
|
||||
trade_date: date
|
||||
completed_at: datetime | None
|
||||
status: LatestUpdateStatus
|
||||
target_count: int
|
||||
successful_count: int
|
||||
failed_count: int
|
||||
valid_count: int
|
||||
coverage_rate: Decimal
|
||||
failures: tuple[OverviewFailure, ...]
|
||||
batch_errors: tuple[OverviewBatchError, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MarketDataOverview:
|
||||
"""Read-only Home payload before transport-specific serialization."""
|
||||
|
||||
status: OverviewStatus
|
||||
stock_pool_count: int
|
||||
latest_update: MarketDataUpdate | None
|
||||
@@ -12,6 +12,13 @@ from uuid import uuid4
|
||||
import psycopg
|
||||
|
||||
from ..domain.models import Bar, DailyBasic, Stock, SyncWindow
|
||||
from ..domain.overview import (
|
||||
LatestUpdateStatus,
|
||||
MarketDataOverview,
|
||||
MarketDataUpdate,
|
||||
OverviewBatchError,
|
||||
OverviewFailure,
|
||||
)
|
||||
from ..domain.ports import MarketDataRepositoryError, WriteResult
|
||||
|
||||
|
||||
@@ -25,6 +32,209 @@ class PostgresMarketDataRepository:
|
||||
def __init__(self, database_url: str) -> None:
|
||||
self.database_url = database_url
|
||||
|
||||
def read_overview(self) -> MarketDataOverview:
|
||||
"""Aggregate the active stock pool and latest synchronization facts.
|
||||
|
||||
The latest target date is selected before batch state, so a running
|
||||
retry for an older date cannot hide a newer completed target. All
|
||||
facts are read through one connection to keep the overview internally
|
||||
consistent without introducing a write-side transaction.
|
||||
|
||||
Returns:
|
||||
A domain read model suitable for the Home HTTP presentation layer.
|
||||
|
||||
Raises:
|
||||
MarketDataRepositoryError: If PostgreSQL cannot complete the read.
|
||||
"""
|
||||
|
||||
with self._connection() as connection:
|
||||
cursor = connection.cursor()
|
||||
stock_pool_count = int(
|
||||
cursor.execute(
|
||||
"SELECT count(*) FROM market_stock WHERE is_active = true"
|
||||
).fetchone()[0]
|
||||
)
|
||||
batch = cursor.execute(
|
||||
"""
|
||||
WITH latest_date AS (
|
||||
SELECT max(target_trade_date) AS target_trade_date
|
||||
FROM market_sync_batch
|
||||
)
|
||||
SELECT id, target_trade_date, status, target_count, finished_at
|
||||
FROM market_sync_batch
|
||||
WHERE target_trade_date = (SELECT target_trade_date FROM latest_date)
|
||||
ORDER BY
|
||||
(status = 'running') DESC,
|
||||
finished_at DESC NULLS LAST,
|
||||
created_at DESC,
|
||||
id DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
).fetchone()
|
||||
if batch is None:
|
||||
return MarketDataOverview(
|
||||
status="no_data",
|
||||
stock_pool_count=stock_pool_count,
|
||||
latest_update=None,
|
||||
)
|
||||
|
||||
batch_id = str(batch[0])
|
||||
trade_date = batch[1]
|
||||
batch_status = str(batch[2])
|
||||
target_count = int(batch[3])
|
||||
completed_at = batch[4]
|
||||
stock_rows = cursor.execute(
|
||||
"""
|
||||
SELECT
|
||||
stock.ts_code,
|
||||
stock.name,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM market_daily_bar AS bar
|
||||
WHERE bar.ts_code = stock.ts_code
|
||||
AND bar.trade_date = %s
|
||||
) AS has_bar,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM market_daily_basic AS basic
|
||||
WHERE basic.ts_code = stock.ts_code
|
||||
AND basic.trade_date = %s
|
||||
) AS has_basic,
|
||||
item.error_type,
|
||||
item.error_message
|
||||
FROM market_stock AS stock
|
||||
LEFT JOIN market_sync_item AS item
|
||||
ON item.batch_id = %s
|
||||
AND item.item_key = stock.ts_code
|
||||
AND item.item_kind = 'bar'
|
||||
AND item.status = 'failed'
|
||||
WHERE stock.is_active = true
|
||||
ORDER BY stock.ts_code
|
||||
""",
|
||||
(trade_date, trade_date, batch_id),
|
||||
).fetchall()
|
||||
failures = tuple(
|
||||
self._overview_failure(row, trade_date)
|
||||
for row in stock_rows
|
||||
if not (bool(row[2]) and bool(row[3]))
|
||||
)
|
||||
successful_count = len(stock_rows) - len(failures)
|
||||
failed_count = max(0, stock_pool_count - successful_count)
|
||||
batch_error_rows = cursor.execute(
|
||||
"""
|
||||
SELECT item_key, error_type, error_message
|
||||
FROM market_sync_item
|
||||
WHERE batch_id = %s
|
||||
AND item_kind = 'batch'
|
||||
AND status = 'failed'
|
||||
ORDER BY item_key
|
||||
""",
|
||||
(batch_id,),
|
||||
).fetchall()
|
||||
batch_errors = tuple(
|
||||
OverviewBatchError(
|
||||
item_key=str(row[0]),
|
||||
error_type=str(row[1] or "batch_error"),
|
||||
reason=str(row[2] or "同步批次发生批次级错误"),
|
||||
)
|
||||
for row in batch_error_rows
|
||||
)
|
||||
|
||||
coverage_rate = (
|
||||
Decimal(successful_count) / Decimal(target_count) if target_count > 0 else Decimal("0")
|
||||
)
|
||||
latest_status = self._overview_status(
|
||||
batch_status=batch_status,
|
||||
successful_count=successful_count,
|
||||
target_count=target_count,
|
||||
failures=failures,
|
||||
batch_errors=batch_errors,
|
||||
)
|
||||
return MarketDataOverview(
|
||||
status=latest_status,
|
||||
stock_pool_count=stock_pool_count,
|
||||
latest_update=MarketDataUpdate(
|
||||
trade_date=trade_date,
|
||||
completed_at=completed_at,
|
||||
status=latest_status,
|
||||
target_count=target_count,
|
||||
successful_count=successful_count,
|
||||
failed_count=failed_count,
|
||||
valid_count=successful_count,
|
||||
coverage_rate=coverage_rate,
|
||||
failures=failures,
|
||||
batch_errors=batch_errors,
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _overview_failure(row: tuple[Any, ...], trade_date: date) -> OverviewFailure:
|
||||
"""Translate one incomplete stock row into a stable user-facing reason."""
|
||||
|
||||
ts_code = str(row[0])
|
||||
name = str(row[1])
|
||||
has_bar = bool(row[2])
|
||||
has_basic = bool(row[3])
|
||||
recorded_error_type = row[4]
|
||||
recorded_message = row[5]
|
||||
if recorded_error_type or recorded_message:
|
||||
return OverviewFailure(
|
||||
ts_code=ts_code,
|
||||
name=name,
|
||||
failure_type="recorded_stock_failure",
|
||||
reason=str(recorded_message or "股票级同步失败"),
|
||||
trade_date=trade_date,
|
||||
)
|
||||
if not has_bar and not has_basic:
|
||||
return OverviewFailure(
|
||||
ts_code=ts_code,
|
||||
name=name,
|
||||
failure_type="missing_daily_bar_and_valuation_snapshot",
|
||||
reason="目标交易日缺少日线行情和估值快照",
|
||||
trade_date=trade_date,
|
||||
)
|
||||
if not has_bar:
|
||||
return OverviewFailure(
|
||||
ts_code=ts_code,
|
||||
name=name,
|
||||
failure_type="missing_daily_bar",
|
||||
reason="目标交易日缺少日线行情",
|
||||
trade_date=trade_date,
|
||||
)
|
||||
return OverviewFailure(
|
||||
ts_code=ts_code,
|
||||
name=name,
|
||||
failure_type="missing_valuation_snapshot",
|
||||
reason="目标交易日缺少估值快照",
|
||||
trade_date=trade_date,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _overview_status(
|
||||
*,
|
||||
batch_status: str,
|
||||
successful_count: int,
|
||||
target_count: int,
|
||||
failures: tuple[OverviewFailure, ...],
|
||||
batch_errors: tuple[OverviewBatchError, ...],
|
||||
) -> LatestUpdateStatus:
|
||||
"""Map persisted batch state and current fact completeness to a status."""
|
||||
|
||||
if batch_status == "running":
|
||||
return "updating"
|
||||
if batch_status == "failed" or (batch_errors and successful_count == 0):
|
||||
return "failed"
|
||||
if successful_count == 0:
|
||||
return "failed"
|
||||
if (
|
||||
batch_status == "success"
|
||||
and not failures
|
||||
and not batch_errors
|
||||
and successful_count >= target_count
|
||||
):
|
||||
return "success"
|
||||
return "partial_success"
|
||||
|
||||
def upsert_stocks(self, rows: Iterable[Stock]) -> WriteResult:
|
||||
records = tuple(rows)
|
||||
if not records:
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
"""HTTP presentation for the Home market-data overview."""
|
||||
|
||||
from datetime import date, datetime
|
||||
from typing import Annotated, Literal
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
|
||||
from zhixing_server.bootstrap.config import Settings, get_settings
|
||||
from zhixing_server.modules.market_data.application.overview import (
|
||||
MarketDataOverviewReader,
|
||||
get_market_data_overview,
|
||||
)
|
||||
from zhixing_server.modules.market_data.domain.overview import (
|
||||
MarketDataOverview,
|
||||
MarketDataUpdate,
|
||||
OverviewBatchError,
|
||||
OverviewFailure,
|
||||
)
|
||||
from zhixing_server.modules.market_data.infrastructure.postgres import (
|
||||
PostgresMarketDataRepository,
|
||||
)
|
||||
|
||||
home_router = APIRouter()
|
||||
_SHANGHAI = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
OverviewStatusValue = Literal["updating", "success", "partial_success", "failed", "no_data"]
|
||||
UpdateStatusValue = Literal["updating", "success", "partial_success", "failed"]
|
||||
|
||||
|
||||
class OverviewFailureResponse(BaseModel):
|
||||
"""A stock-level failure in the public Home response."""
|
||||
|
||||
ts_code: str
|
||||
name: str
|
||||
failure_type: str
|
||||
reason: str
|
||||
trade_date: date
|
||||
|
||||
@classmethod
|
||||
def from_domain(cls, failure: OverviewFailure) -> "OverviewFailureResponse":
|
||||
"""Convert a domain failure without changing its business date."""
|
||||
|
||||
return cls(
|
||||
ts_code=failure.ts_code,
|
||||
name=failure.name,
|
||||
failure_type=failure.failure_type,
|
||||
reason=failure.reason,
|
||||
trade_date=failure.trade_date,
|
||||
)
|
||||
|
||||
|
||||
class OverviewBatchErrorResponse(BaseModel):
|
||||
"""A batch-level error kept separate from stock failures."""
|
||||
|
||||
item_key: str
|
||||
error_type: str
|
||||
reason: str
|
||||
|
||||
@classmethod
|
||||
def from_domain(cls, error: OverviewBatchError) -> "OverviewBatchErrorResponse":
|
||||
"""Convert a batch error to the public response shape."""
|
||||
|
||||
return cls(
|
||||
item_key=error.item_key,
|
||||
error_type=error.error_type,
|
||||
reason=error.reason,
|
||||
)
|
||||
|
||||
|
||||
class LatestUpdateResponse(BaseModel):
|
||||
"""The selected synchronization result for one target trading date."""
|
||||
|
||||
trade_date: date
|
||||
completed_at: datetime | None
|
||||
status: UpdateStatusValue
|
||||
target_count: int
|
||||
successful_count: int
|
||||
failed_count: int
|
||||
valid_count: int
|
||||
coverage_rate: float
|
||||
failures: list[OverviewFailureResponse]
|
||||
batch_errors: list[OverviewBatchErrorResponse]
|
||||
|
||||
@classmethod
|
||||
def from_domain(cls, update: MarketDataUpdate) -> "LatestUpdateResponse":
|
||||
"""Convert and localize the synchronization completion timestamp."""
|
||||
|
||||
return cls(
|
||||
trade_date=update.trade_date,
|
||||
completed_at=_to_shanghai(update.completed_at),
|
||||
status=update.status,
|
||||
target_count=update.target_count,
|
||||
successful_count=update.successful_count,
|
||||
failed_count=update.failed_count,
|
||||
valid_count=update.valid_count,
|
||||
coverage_rate=float(update.coverage_rate),
|
||||
failures=[OverviewFailureResponse.from_domain(item) for item in update.failures],
|
||||
batch_errors=[
|
||||
OverviewBatchErrorResponse.from_domain(item) for item in update.batch_errors
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class HomeOverviewResponse(BaseModel):
|
||||
"""Stable HTTP contract consumed by the Home feature."""
|
||||
|
||||
status: OverviewStatusValue
|
||||
stock_pool_count: int
|
||||
latest_update: LatestUpdateResponse | None
|
||||
|
||||
@classmethod
|
||||
def from_domain(cls, overview: MarketDataOverview) -> "HomeOverviewResponse":
|
||||
"""Convert the domain overview into its Pydantic response model."""
|
||||
|
||||
return cls(
|
||||
status=overview.status,
|
||||
stock_pool_count=overview.stock_pool_count,
|
||||
latest_update=(
|
||||
LatestUpdateResponse.from_domain(overview.latest_update)
|
||||
if overview.latest_update is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _to_shanghai(value: datetime | None) -> datetime | None:
|
||||
"""Render database timestamps in the product's business timezone."""
|
||||
|
||||
if value is None:
|
||||
return None
|
||||
if value.tzinfo is None:
|
||||
value = value.replace(tzinfo=_SHANGHAI)
|
||||
return value.astimezone(_SHANGHAI)
|
||||
|
||||
|
||||
def get_market_data_overview_reader(
|
||||
settings: Annotated[Settings, Depends(get_settings)],
|
||||
) -> MarketDataOverviewReader:
|
||||
"""Build the PostgreSQL read adapter for one request."""
|
||||
|
||||
return PostgresMarketDataRepository(settings.database_url)
|
||||
|
||||
|
||||
@home_router.get("/overview", response_model=HomeOverviewResponse)
|
||||
def market_data_overview(
|
||||
reader: Annotated[MarketDataOverviewReader, Depends(get_market_data_overview_reader)],
|
||||
) -> HomeOverviewResponse:
|
||||
"""Return the read-only market-data overview for the Home page."""
|
||||
|
||||
overview = get_market_data_overview(reader)
|
||||
return HomeOverviewResponse.from_domain(overview)
|
||||
@@ -0,0 +1,219 @@
|
||||
"""HTTP contract tests for the Home market-data overview."""
|
||||
|
||||
from datetime import UTC, date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from zhixing_server.bootstrap.app import create_app
|
||||
from zhixing_server.modules.market_data.application.overview import (
|
||||
MarketDataOverviewReader,
|
||||
)
|
||||
from zhixing_server.modules.market_data.domain.overview import (
|
||||
MarketDataOverview,
|
||||
MarketDataUpdate,
|
||||
OverviewBatchError,
|
||||
OverviewFailure,
|
||||
)
|
||||
from zhixing_server.modules.market_data.presentation.home import (
|
||||
get_market_data_overview_reader,
|
||||
)
|
||||
|
||||
TARGET_DATE = date(2026, 8, 6)
|
||||
|
||||
|
||||
class FakeOverviewReader:
|
||||
"""Small in-memory read adapter used at the HTTP dependency seam."""
|
||||
|
||||
def __init__(self, overview: MarketDataOverview) -> None:
|
||||
self.overview = overview
|
||||
|
||||
def read_overview(self) -> MarketDataOverview:
|
||||
return self.overview
|
||||
|
||||
|
||||
def get_client(overview: MarketDataOverview) -> TestClient:
|
||||
"""Build the real application with only its read adapter overridden."""
|
||||
|
||||
application = create_app()
|
||||
reader = FakeOverviewReader(overview)
|
||||
|
||||
def override_reader() -> MarketDataOverviewReader:
|
||||
return reader
|
||||
|
||||
application.dependency_overrides[get_market_data_overview_reader] = override_reader
|
||||
return TestClient(application)
|
||||
|
||||
|
||||
def test_home_overview_returns_complete_success_contract() -> None:
|
||||
"""A complete batch exposes active-pool counts and Shanghai completion time."""
|
||||
|
||||
response = get_client(
|
||||
MarketDataOverview(
|
||||
status="success",
|
||||
stock_pool_count=2,
|
||||
latest_update=MarketDataUpdate(
|
||||
trade_date=TARGET_DATE,
|
||||
completed_at=datetime(2026, 8, 7, 8, 30, tzinfo=UTC),
|
||||
status="success",
|
||||
target_count=2,
|
||||
successful_count=2,
|
||||
failed_count=0,
|
||||
valid_count=2,
|
||||
coverage_rate=Decimal("1"),
|
||||
failures=(),
|
||||
batch_errors=(),
|
||||
),
|
||||
)
|
||||
).get("/api/v1/home/overview")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"status": "success",
|
||||
"stock_pool_count": 2,
|
||||
"latest_update": {
|
||||
"trade_date": "2026-08-06",
|
||||
"completed_at": "2026-08-07T16:30:00+08:00",
|
||||
"status": "success",
|
||||
"target_count": 2,
|
||||
"successful_count": 2,
|
||||
"failed_count": 0,
|
||||
"valid_count": 2,
|
||||
"coverage_rate": 1.0,
|
||||
"failures": [],
|
||||
"batch_errors": [],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_home_overview_keeps_stock_and_batch_failures_separate() -> None:
|
||||
"""Partial results preserve failure categories and batch-level errors."""
|
||||
|
||||
response = get_client(
|
||||
MarketDataOverview(
|
||||
status="partial_success",
|
||||
stock_pool_count=3,
|
||||
latest_update=MarketDataUpdate(
|
||||
trade_date=TARGET_DATE,
|
||||
completed_at=datetime(2026, 8, 6, 16, 30),
|
||||
status="partial_success",
|
||||
target_count=3,
|
||||
successful_count=1,
|
||||
failed_count=2,
|
||||
valid_count=1,
|
||||
coverage_rate=Decimal("0.3333333333"),
|
||||
failures=(
|
||||
OverviewFailure(
|
||||
ts_code="000001.SZ",
|
||||
name="平安银行",
|
||||
failure_type="missing_daily_bar",
|
||||
reason="目标交易日缺少日线行情",
|
||||
trade_date=TARGET_DATE,
|
||||
),
|
||||
OverviewFailure(
|
||||
ts_code="600000.SH",
|
||||
name="浦发银行",
|
||||
failure_type="recorded_stock_failure",
|
||||
reason="供应商请求失败",
|
||||
trade_date=TARGET_DATE,
|
||||
),
|
||||
),
|
||||
batch_errors=(
|
||||
OverviewBatchError(
|
||||
item_key="retention",
|
||||
error_type="database_error",
|
||||
reason="保留策略执行失败",
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
).get("/api/v1/home/overview")
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["latest_update"]["failed_count"] == 2
|
||||
assert payload["latest_update"]["coverage_rate"] == 0.3333333333
|
||||
assert payload["latest_update"]["failures"][0]["failure_type"] == "missing_daily_bar"
|
||||
assert payload["latest_update"]["batch_errors"] == [
|
||||
{
|
||||
"item_key": "retention",
|
||||
"error_type": "database_error",
|
||||
"reason": "保留策略执行失败",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_home_overview_returns_updating_without_completion_time() -> None:
|
||||
"""A running batch remains distinguishable from an absent batch."""
|
||||
|
||||
response = get_client(
|
||||
MarketDataOverview(
|
||||
status="updating",
|
||||
stock_pool_count=1,
|
||||
latest_update=MarketDataUpdate(
|
||||
trade_date=TARGET_DATE,
|
||||
completed_at=None,
|
||||
status="updating",
|
||||
target_count=1,
|
||||
successful_count=0,
|
||||
failed_count=1,
|
||||
valid_count=0,
|
||||
coverage_rate=Decimal("0"),
|
||||
failures=(),
|
||||
batch_errors=(),
|
||||
),
|
||||
)
|
||||
).get("/api/v1/home/overview")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "updating"
|
||||
assert response.json()["latest_update"]["completed_at"] is None
|
||||
|
||||
|
||||
def test_home_overview_returns_failed_batch_without_fabricating_stock_errors() -> None:
|
||||
"""A failed batch keeps infrastructure errors separate from stock details."""
|
||||
|
||||
response = get_client(
|
||||
MarketDataOverview(
|
||||
status="failed",
|
||||
stock_pool_count=2,
|
||||
latest_update=MarketDataUpdate(
|
||||
trade_date=TARGET_DATE,
|
||||
completed_at=datetime(2026, 8, 6, 16, 30),
|
||||
status="failed",
|
||||
target_count=2,
|
||||
successful_count=0,
|
||||
failed_count=2,
|
||||
valid_count=0,
|
||||
coverage_rate=Decimal("0"),
|
||||
failures=(),
|
||||
batch_errors=(
|
||||
OverviewBatchError(
|
||||
item_key="retention",
|
||||
error_type="database_error",
|
||||
reason="保留策略执行失败",
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
).get("/api/v1/home/overview")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "failed"
|
||||
assert response.json()["latest_update"]["failures"] == []
|
||||
assert response.json()["latest_update"]["batch_errors"][0]["item_key"] == "retention"
|
||||
|
||||
|
||||
def test_home_overview_returns_no_data_without_fake_update() -> None:
|
||||
"""No synchronization batch is represented by a null latest update."""
|
||||
|
||||
response = get_client(
|
||||
MarketDataOverview(status="no_data", stock_pool_count=7, latest_update=None)
|
||||
).get("/api/v1/home/overview")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"status": "no_data",
|
||||
"stock_pool_count": 7,
|
||||
"latest_update": None,
|
||||
}
|
||||
@@ -17,6 +17,7 @@
|
||||
"typecheck": "tsc -b --pretty false"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.7.0",
|
||||
"@tanstack/react-query": "^5.101.4",
|
||||
"@tanstack/react-router": "^1.170.18",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
||||
Generated
+90
@@ -8,6 +8,9 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@base-ui/react':
|
||||
specifier: ^1.7.0
|
||||
version: 1.7.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@tanstack/react-query':
|
||||
specifier: ^5.101.4
|
||||
version: 5.101.4(react@19.2.8)
|
||||
@@ -173,6 +176,33 @@ packages:
|
||||
resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@base-ui/react@1.7.0':
|
||||
resolution: {integrity: sha512-j+8QjX44C32jrXD/qyEAGpFr70FRpGL2CY61mQd9nBPWN737CK0xxD1ceJ055rW4RtdvFDT1e7otzdlfxvsYug==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
peerDependencies:
|
||||
'@date-fns/tz': ^1.2.0
|
||||
'@types/react': ^17 || ^18 || ^19
|
||||
date-fns: ^4.0.0
|
||||
react: ^17 || ^18 || ^19
|
||||
react-dom: ^17 || ^18 || ^19
|
||||
peerDependenciesMeta:
|
||||
'@date-fns/tz':
|
||||
optional: true
|
||||
'@types/react':
|
||||
optional: true
|
||||
date-fns:
|
||||
optional: true
|
||||
|
||||
'@base-ui/utils@0.3.2':
|
||||
resolution: {integrity: sha512-oWy1aq/I2GmYjpl4PhEAhzflF8VPGKgZeq0xAWTbfD5KBWyxcN0ZP2+WHSUm/5Z6lVMBDLReLcoXwSYoRc/zNQ==}
|
||||
peerDependencies:
|
||||
'@types/react': ^17 || ^18 || ^19
|
||||
react: ^17 || ^18 || ^19
|
||||
react-dom: ^17 || ^18 || ^19
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@csstools/color-helpers@5.1.0':
|
||||
resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -249,6 +279,21 @@ packages:
|
||||
resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==}
|
||||
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
|
||||
|
||||
'@floating-ui/core@1.8.0':
|
||||
resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==}
|
||||
|
||||
'@floating-ui/dom@1.8.0':
|
||||
resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==}
|
||||
|
||||
'@floating-ui/react-dom@2.1.9':
|
||||
resolution: {integrity: sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
react-dom: '>=16.8.0'
|
||||
|
||||
'@floating-ui/utils@0.2.12':
|
||||
resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==}
|
||||
|
||||
'@humanfs/core@0.19.2':
|
||||
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
|
||||
engines: {node: '>=18.18.0'}
|
||||
@@ -1303,6 +1348,9 @@ packages:
|
||||
resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
reselect@5.2.0:
|
||||
resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==}
|
||||
|
||||
rolldown@1.2.1:
|
||||
resolution: {integrity: sha512-4FKJhg8d3OiyQOA6Q1Q0hoFFpW9/OoX+VsHzpECsdsIZoOArrAK90gl59YK/Z+gnDel45bgJZK03ozH/9bCqEw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -1736,6 +1784,29 @@ snapshots:
|
||||
'@babel/helper-string-parser': 7.29.7
|
||||
'@babel/helper-validator-identifier': 7.29.7
|
||||
|
||||
'@base-ui/react@1.7.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.7
|
||||
'@base-ui/utils': 0.3.2(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@floating-ui/react-dom': 2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@floating-ui/utils': 0.2.12
|
||||
react: 19.2.8
|
||||
react-dom: 19.2.8(react@19.2.8)
|
||||
use-sync-external-store: 1.6.0(react@19.2.8)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.18
|
||||
|
||||
'@base-ui/utils@0.3.2(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.7
|
||||
'@floating-ui/utils': 0.2.12
|
||||
react: 19.2.8
|
||||
react-dom: 19.2.8(react@19.2.8)
|
||||
reselect: 5.2.0
|
||||
use-sync-external-store: 1.6.0(react@19.2.8)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.18
|
||||
|
||||
'@csstools/color-helpers@5.1.0': {}
|
||||
|
||||
'@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)':
|
||||
@@ -1806,6 +1877,23 @@ snapshots:
|
||||
'@eslint/core': 1.2.1
|
||||
levn: 0.4.1
|
||||
|
||||
'@floating-ui/core@1.8.0':
|
||||
dependencies:
|
||||
'@floating-ui/utils': 0.2.12
|
||||
|
||||
'@floating-ui/dom@1.8.0':
|
||||
dependencies:
|
||||
'@floating-ui/core': 1.8.0
|
||||
'@floating-ui/utils': 0.2.12
|
||||
|
||||
'@floating-ui/react-dom@2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
|
||||
dependencies:
|
||||
'@floating-ui/dom': 1.8.0
|
||||
react: 19.2.8
|
||||
react-dom: 19.2.8(react@19.2.8)
|
||||
|
||||
'@floating-ui/utils@0.2.12': {}
|
||||
|
||||
'@humanfs/core@0.19.2':
|
||||
dependencies:
|
||||
'@humanfs/types': 0.15.0
|
||||
@@ -2736,6 +2824,8 @@ snapshots:
|
||||
indent-string: 4.0.0
|
||||
strip-indent: 3.0.0
|
||||
|
||||
reselect@5.2.0: {}
|
||||
|
||||
rolldown@1.2.1:
|
||||
dependencies:
|
||||
'@oxc-project/types': 0.142.0
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { requestJson } from "@/shared/api/request-json"
|
||||
|
||||
import type { HomeOverview } from "./home.types"
|
||||
|
||||
export function getHomeOverview(signal?: AbortSignal) {
|
||||
return requestJson<HomeOverview>("/api/v1/home/overview", { signal })
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
|
||||
import { getHomeOverview } from "./home.api"
|
||||
|
||||
export const homeOverviewQueryKey = ["home", "overview"] as const
|
||||
|
||||
export function useHomeOverview() {
|
||||
return useQuery({
|
||||
queryFn: ({ signal }) => getHomeOverview(signal),
|
||||
queryKey: homeOverviewQueryKey,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
export type HomeOverviewStatus =
|
||||
"updating" | "success" | "partial_success" | "failed" | "no_data"
|
||||
|
||||
export type HomeUpdateStatus = Exclude<HomeOverviewStatus, "no_data">
|
||||
|
||||
export interface OverviewFailure {
|
||||
ts_code: string
|
||||
name: string
|
||||
failure_type: string
|
||||
reason: string
|
||||
trade_date: string
|
||||
}
|
||||
|
||||
export interface OverviewBatchError {
|
||||
item_key: string
|
||||
error_type: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
export interface LatestMarketDataUpdate {
|
||||
trade_date: string
|
||||
completed_at: string | null
|
||||
status: Exclude<HomeOverviewStatus, "no_data">
|
||||
target_count: number
|
||||
successful_count: number
|
||||
failed_count: number
|
||||
valid_count: number
|
||||
coverage_rate: number
|
||||
failures: OverviewFailure[]
|
||||
batch_errors: OverviewBatchError[]
|
||||
}
|
||||
|
||||
export interface HomeOverview {
|
||||
status: HomeOverviewStatus
|
||||
stock_pool_count: number
|
||||
latest_update: LatestMarketDataUpdate | null
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog"
|
||||
import { ScrollArea } from "@/shared/ui/scroll-area"
|
||||
import { Button } from "@/shared/ui/button"
|
||||
|
||||
import type { OverviewFailure } from "../api/home.types"
|
||||
|
||||
interface FailedStocksDialogProps {
|
||||
failures: OverviewFailure[]
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function FailedStocksDialog({
|
||||
failures,
|
||||
onOpenChange,
|
||||
open,
|
||||
}: FailedStocksDialogProps) {
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-2xl" showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>失败股票详情</DialogTitle>
|
||||
<DialogDescription>
|
||||
以下股票尚未在目标交易日同时具备完整的行情和估值数据。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="h-80 pr-3">
|
||||
<div className="space-y-3">
|
||||
{failures.map((failure) => (
|
||||
<div
|
||||
key={`${failure.ts_code}-${failure.failure_type}`}
|
||||
className="rounded-lg border bg-muted/30 p-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<div>
|
||||
<p className="font-medium">{failure.name}</p>
|
||||
<p className="font-mono text-xs text-muted-foreground">
|
||||
{failure.ts_code}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
目标交易日:{failure.trade_date}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-3 text-sm font-medium">
|
||||
{formatFailureType(failure.failure_type)}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{failure.reason}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<DialogFooter>
|
||||
<DialogClose render={<Button variant="outline" />}>关闭</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function formatFailureType(value: string) {
|
||||
const labels: Record<string, string> = {
|
||||
missing_daily_bar: "缺少日线行情",
|
||||
missing_daily_bar_and_valuation_snapshot: "缺少日线行情和估值快照",
|
||||
missing_valuation_snapshot: "缺少估值快照",
|
||||
recorded_stock_failure: "股票级同步失败",
|
||||
}
|
||||
|
||||
return labels[value] ?? value
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
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
|
||||
}
|
||||
|
||||
const navigation = [
|
||||
{ label: "首页", icon: LayoutDashboard, active: true },
|
||||
{ label: "行情数据", icon: BarChart3, active: false },
|
||||
{ label: "同步任务", icon: ClipboardCheck, active: false },
|
||||
{ label: "选股策略", icon: BrainCircuit, active: false },
|
||||
]
|
||||
|
||||
export function HomeShell({ 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
|
||||
return (
|
||||
<Button
|
||||
key={item.label}
|
||||
className="w-full justify-start gap-3 px-2 md:px-3"
|
||||
disabled={!item.active}
|
||||
variant={item.active ? "secondary" : "ghost"}
|
||||
aria-current={item.active ? "page" : undefined}
|
||||
>
|
||||
<Icon className="size-4 shrink-0" aria-hidden="true" />
|
||||
<span className="hidden md:inline">{item.label}</span>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { AlertCircle, CheckCircle2, Database, RefreshCw } from "lucide-react"
|
||||
|
||||
import { Badge } from "@/shared/ui/badge"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/ui/card"
|
||||
import { Progress } from "@/shared/ui/progress"
|
||||
import { Button } from "@/shared/ui/button"
|
||||
|
||||
import type { LatestMarketDataUpdate } from "../api/home.types"
|
||||
|
||||
interface MarketDataOverviewCardProps {
|
||||
stockPoolCount: number
|
||||
update: LatestMarketDataUpdate
|
||||
onShowFailures: () => void
|
||||
}
|
||||
|
||||
const statusPresentation = {
|
||||
updating: {
|
||||
label: "更新中",
|
||||
description: "同步批次正在处理,数据仍可能变化。",
|
||||
icon: RefreshCw,
|
||||
variant: "outline" as const,
|
||||
},
|
||||
success: {
|
||||
label: "已更新",
|
||||
description: "目标股票池已完整同步到最近交易日。",
|
||||
icon: CheckCircle2,
|
||||
variant: "default" as const,
|
||||
},
|
||||
partial_success: {
|
||||
label: "部分更新",
|
||||
description: "部分股票尚未完成行情或估值数据同步。",
|
||||
icon: AlertCircle,
|
||||
variant: "outline" as const,
|
||||
},
|
||||
failed: {
|
||||
label: "未更新",
|
||||
description: "最近同步未形成可用的完整数据结果。",
|
||||
icon: AlertCircle,
|
||||
variant: "destructive" as const,
|
||||
},
|
||||
} as const
|
||||
|
||||
function formatShanghaiDateTime(value: string | null) {
|
||||
if (!value) return "同步进行中"
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
timeZone: "Asia/Shanghai",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
}).format(new Date(value))
|
||||
}
|
||||
|
||||
function formatCoverage(value: number) {
|
||||
return `${(value * 100).toFixed(2)}%`
|
||||
}
|
||||
|
||||
export function MarketDataOverviewCard({
|
||||
onShowFailures,
|
||||
stockPoolCount,
|
||||
update,
|
||||
}: MarketDataOverviewCardProps) {
|
||||
const presentation = statusPresentation[update.status]
|
||||
const StatusIcon = presentation.icon
|
||||
const progressValue = Math.min(100, Math.max(0, update.coverage_rate * 100))
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="gap-4 border-b bg-card md:flex-row md:items-start md:justify-between">
|
||||
<div className="space-y-1.5">
|
||||
<CardTitle className="flex items-center gap-2 text-xl">
|
||||
<Database className="size-5 text-primary" aria-hidden="true" />
|
||||
市场数据概览
|
||||
</CardTitle>
|
||||
<CardDescription>{presentation.description}</CardDescription>
|
||||
</div>
|
||||
<Badge variant={presentation.variant} className="w-fit gap-1.5">
|
||||
<StatusIcon className="size-3.5" aria-hidden="true" />
|
||||
{presentation.label}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6 pt-6">
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<Metric label="当前目标股票池" value={String(stockPoolCount)} />
|
||||
<Metric label="最近更新交易日" value={update.trade_date} />
|
||||
<Metric
|
||||
label="同步完成时间"
|
||||
value={formatShanghaiDateTime(update.completed_at)}
|
||||
/>
|
||||
<Metric label="目标股票数" value={String(update.target_count)} />
|
||||
<Metric label="成功股票数" value={String(update.successful_count)} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 rounded-xl border bg-muted/20 p-4 sm:grid-cols-3">
|
||||
<Metric label="失败股票数" value={String(update.failed_count)} />
|
||||
<Metric label="有效股票数" value={String(update.valid_count)} />
|
||||
<div className="space-y-2 sm:col-span-1">
|
||||
<div className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="text-muted-foreground">数据覆盖率</span>
|
||||
<span className="font-semibold tabular-nums">
|
||||
{formatCoverage(update.coverage_rate)}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
aria-label="数据覆盖率"
|
||||
value={progressValue}
|
||||
className="gap-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
只有同时具备日线行情和估值快照的股票计入有效股票。
|
||||
</p>
|
||||
<Button
|
||||
disabled={update.failed_count === 0}
|
||||
onClick={onShowFailures}
|
||||
variant="outline"
|
||||
>
|
||||
<AlertCircle aria-hidden="true" />
|
||||
查看失败股票
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{update.batch_errors.length > 0 ? (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm">
|
||||
<p className="font-medium text-destructive">批次级错误</p>
|
||||
<ul className="mt-1 space-y-1 text-muted-foreground">
|
||||
{update.batch_errors.map((error) => (
|
||||
<li key={`${error.item_key}-${error.error_type}`}>
|
||||
{error.reason}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function Metric({ label, value }: { label: string; value: 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
import { HomePage } from "./home-page"
|
||||
|
||||
const useHomeOverview = vi.fn()
|
||||
|
||||
vi.mock("@/features/home/api/home.query", () => ({
|
||||
useHomeOverview: () => useHomeOverview(),
|
||||
}))
|
||||
|
||||
const overview = {
|
||||
status: "partial_success" as const,
|
||||
stock_pool_count: 3,
|
||||
latest_update: {
|
||||
trade_date: "2026-08-06",
|
||||
completed_at: "2026-08-07T08:30:00Z",
|
||||
status: "partial_success" as const,
|
||||
target_count: 3,
|
||||
successful_count: 1,
|
||||
failed_count: 2,
|
||||
valid_count: 1,
|
||||
coverage_rate: 1 / 3,
|
||||
failures: [
|
||||
{
|
||||
ts_code: "000001.SZ",
|
||||
name: "平安银行",
|
||||
failure_type: "missing_daily_bar",
|
||||
reason: "目标交易日缺少日线行情",
|
||||
trade_date: "2026-08-06",
|
||||
},
|
||||
{
|
||||
ts_code: "600000.SH",
|
||||
name: "浦发银行",
|
||||
failure_type: "recorded_stock_failure",
|
||||
reason: "供应商请求失败",
|
||||
trade_date: "2026-08-06",
|
||||
},
|
||||
],
|
||||
batch_errors: [],
|
||||
},
|
||||
}
|
||||
|
||||
describe("HomePage", () => {
|
||||
beforeEach(() => {
|
||||
useHomeOverview.mockReturnValue({
|
||||
data: overview,
|
||||
isError: false,
|
||||
isPending: false,
|
||||
refetch: vi.fn(),
|
||||
})
|
||||
})
|
||||
|
||||
it("renders the management shell and 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("button", { name: "选股策略" })).toBeDisabled()
|
||||
})
|
||||
|
||||
it("opens and closes the accessible failed-stock dialog", () => {
|
||||
render(<HomePage />)
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "查看失败股票" }))
|
||||
|
||||
const dialog = screen.getByRole("dialog", { name: "失败股票详情" })
|
||||
expect(dialog).toBeInTheDocument()
|
||||
expect(within(dialog).getByText("平安银行")).toBeInTheDocument()
|
||||
expect(within(dialog).getByText("缺少日线行情")).toBeInTheDocument()
|
||||
expect(within(dialog).getAllByText("目标交易日:2026-08-06")).toHaveLength(
|
||||
2,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "关闭" }))
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("renders a loading placeholder", () => {
|
||||
useHomeOverview.mockReturnValue({
|
||||
data: undefined,
|
||||
isError: false,
|
||||
isPending: true,
|
||||
})
|
||||
|
||||
render(<HomePage />)
|
||||
|
||||
expect(screen.getByLabelText("正在加载市场数据概览")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("offers retry when the overview request fails", () => {
|
||||
const refetch = vi.fn()
|
||||
useHomeOverview.mockReturnValue({
|
||||
data: undefined,
|
||||
isError: true,
|
||||
isPending: false,
|
||||
refetch,
|
||||
})
|
||||
|
||||
render(<HomePage />)
|
||||
fireEvent.click(screen.getByRole("button", { name: "重试" }))
|
||||
|
||||
expect(refetch).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each([
|
||||
["updating", "更新中"],
|
||||
["success", "已更新"],
|
||||
["failed", "未更新"],
|
||||
] as const)("renders the %s overview status", (status, label) => {
|
||||
useHomeOverview.mockReturnValue({
|
||||
data: {
|
||||
...overview,
|
||||
status,
|
||||
latest_update: { ...overview.latest_update, status },
|
||||
},
|
||||
isError: false,
|
||||
isPending: false,
|
||||
})
|
||||
|
||||
render(<HomePage />)
|
||||
|
||||
expect(screen.getByText(label)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("closes the failed-stock dialog with Escape", () => {
|
||||
render(<HomePage />)
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "查看失败股票" }))
|
||||
const dialog = screen.getByRole("dialog", { name: "失败股票详情" })
|
||||
|
||||
fireEvent.keyDown(dialog, { key: "Escape" })
|
||||
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("keeps the shell and explains the no-data state", () => {
|
||||
useHomeOverview.mockReturnValue({
|
||||
data: { status: "no_data", stock_pool_count: 7, latest_update: null },
|
||||
isError: false,
|
||||
isPending: false,
|
||||
})
|
||||
|
||||
render(<HomePage />)
|
||||
|
||||
expect(screen.getByText("暂无市场数据")).toBeInTheDocument()
|
||||
expect(screen.getByText(/当前目标股票池:/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,134 @@
|
||||
import { AlertTriangle, Database, RefreshCw } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
|
||||
import { useHomeOverview } from "@/features/home/api/home.query"
|
||||
import { Button } from "@/shared/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/ui/card"
|
||||
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() {
|
||||
const overview = useHomeOverview()
|
||||
const [failureDialogOpen, setFailureDialogOpen] = useState(false)
|
||||
const update = overview.data?.latest_update
|
||||
|
||||
return (
|
||||
<HomeShell>
|
||||
<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>
|
||||
|
||||
{overview.isPending ? <OverviewLoading /> : null}
|
||||
|
||||
{overview.isError ? (
|
||||
<OverviewError onRetry={() => void overview.refetch()} />
|
||||
) : null}
|
||||
|
||||
{!overview.isPending &&
|
||||
!overview.isError &&
|
||||
overview.data?.status === "no_data" ? (
|
||||
<NoDataState stockPoolCount={overview.data.stock_pool_count} />
|
||||
) : null}
|
||||
|
||||
{!overview.isPending && !overview.isError && update ? (
|
||||
<MarketDataOverviewCard
|
||||
onShowFailures={() => setFailureDialogOpen(true)}
|
||||
stockPoolCount={overview.data.stock_pool_count}
|
||||
update={update}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{update ? (
|
||||
<FailedStocksDialog
|
||||
failures={update.failures}
|
||||
onOpenChange={setFailureDialogOpen}
|
||||
open={failureDialogOpen}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</HomeShell>
|
||||
)
|
||||
}
|
||||
|
||||
function OverviewLoading() {
|
||||
return (
|
||||
<Card aria-label="正在加载市场数据概览">
|
||||
<CardHeader className="gap-3">
|
||||
<Skeleton className="h-6 w-48" />
|
||||
<Skeleton className="h-4 w-72 max-w-full" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: 5 }, (_, index) => (
|
||||
<Skeleton key={index} className="h-16 w-full" />
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function OverviewError({ onRetry }: { onRetry: () => void }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-xl">
|
||||
<AlertTriangle
|
||||
className="size-5 text-destructive"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
市场数据暂时不可用
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
概览请求失败,可能是服务或网络暂时异常。请稍后重试。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button onClick={onRetry} variant="outline">
|
||||
<RefreshCw aria-hidden="true" />
|
||||
重试
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function NoDataState({ stockPoolCount }: { stockPoolCount: number }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-xl">
|
||||
<Database className="size-5 text-primary" aria-hidden="true" />
|
||||
暂无市场数据
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
当前还没有同步批次,完成第一次同步后这里会显示更新详情。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
当前目标股票池:
|
||||
<span className="font-semibold text-foreground">
|
||||
{stockPoolCount}
|
||||
</span>{" "}
|
||||
只
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createRootRoute, createRoute, Outlet } from "@tanstack/react-router"
|
||||
|
||||
import { SystemStatusPage } from "@/features/system/pages/system-status-page"
|
||||
import { HomePage } from "@/features/home/pages/home-page"
|
||||
|
||||
const rootRoute = createRootRoute({
|
||||
component: () => <Outlet />,
|
||||
@@ -9,7 +9,7 @@ const rootRoute = createRootRoute({
|
||||
const indexRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/",
|
||||
component: SystemStatusPage,
|
||||
component: HomePage,
|
||||
})
|
||||
|
||||
export const routeTree = rootRoute.addChildren([indexRoute])
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import * as React from "react"
|
||||
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
|
||||
|
||||
import { cn } from "@/shared/ui/utils"
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: AvatarPrimitive.Root.Props & {
|
||||
size?: "default" | "sm" | "lg"
|
||||
}) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn(
|
||||
"aspect-square size-full rounded-full object-cover",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: AvatarPrimitive.Fallback.Props) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="avatar-badge"
|
||||
className={cn(
|
||||
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
|
||||
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
|
||||
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
|
||||
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group"
|
||||
className={cn(
|
||||
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarGroupCount({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group-count"
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
AvatarFallback,
|
||||
AvatarGroup,
|
||||
AvatarGroupCount,
|
||||
AvatarBadge,
|
||||
}
|
||||
@@ -1,39 +1,58 @@
|
||||
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import type { ButtonHTMLAttributes } from "react"
|
||||
|
||||
import { cn } from "@/shared/ui/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 [&_svg]:size-4",
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
outline:
|
||||
"border bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
icon: "size-9",
|
||||
default:
|
||||
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
icon: "size-8",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm":
|
||||
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||
"icon-lg": "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: { size: "default", variant: "default" },
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export function Button({
|
||||
function Button({
|
||||
className,
|
||||
size,
|
||||
type = "button",
|
||||
variant,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: ButtonHTMLAttributes<HTMLButtonElement> &
|
||||
VariantProps<typeof buttonVariants>) {
|
||||
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||
return (
|
||||
<button
|
||||
className={cn(buttonVariants({ size, variant }), className)}
|
||||
type={type}
|
||||
<ButtonPrimitive
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button }
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import * as React from "react"
|
||||
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
|
||||
|
||||
import { cn } from "@/shared/ui/utils"
|
||||
import { Button } from "@/shared/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: DialogPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Backdrop
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: DialogPrimitive.Popup.Props & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Popup
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-2 right-2"
|
||||
size="icon-sm"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Popup>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close render={<Button variant="outline" />}>
|
||||
Close
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-base leading-none font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: DialogPrimitive.Description.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as React from "react"
|
||||
import { Input as InputPrimitive } from "@base-ui/react/input"
|
||||
|
||||
import { cn } from "@/shared/ui/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<InputPrimitive
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client"
|
||||
|
||||
import { Progress as ProgressPrimitive } from "@base-ui/react/progress"
|
||||
|
||||
import { cn } from "@/shared/ui/utils"
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
children,
|
||||
value,
|
||||
...props
|
||||
}: ProgressPrimitive.Root.Props) {
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
value={value}
|
||||
data-slot="progress"
|
||||
className={cn("flex flex-wrap gap-3", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ProgressTrack>
|
||||
<ProgressIndicator />
|
||||
</ProgressTrack>
|
||||
</ProgressPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ProgressTrack({ className, ...props }: ProgressPrimitive.Track.Props) {
|
||||
return (
|
||||
<ProgressPrimitive.Track
|
||||
className={cn(
|
||||
"relative flex h-1 w-full items-center overflow-x-hidden rounded-full bg-muted",
|
||||
className,
|
||||
)}
|
||||
data-slot="progress-track"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ProgressIndicator({
|
||||
className,
|
||||
...props
|
||||
}: ProgressPrimitive.Indicator.Props) {
|
||||
return (
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className={cn("h-full bg-primary transition-all", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ProgressLabel({ className, ...props }: ProgressPrimitive.Label.Props) {
|
||||
return (
|
||||
<ProgressPrimitive.Label
|
||||
className={cn("text-sm font-medium", className)}
|
||||
data-slot="progress-label"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ProgressValue({ className, ...props }: ProgressPrimitive.Value.Props) {
|
||||
return (
|
||||
<ProgressPrimitive.Value
|
||||
className={cn(
|
||||
"ml-auto text-sm text-muted-foreground tabular-nums",
|
||||
className,
|
||||
)}
|
||||
data-slot="progress-value"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Progress,
|
||||
ProgressTrack,
|
||||
ProgressIndicator,
|
||||
ProgressLabel,
|
||||
ProgressValue,
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
|
||||
|
||||
import { cn } from "@/shared/ui/utils"
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ScrollAreaPrimitive.Root.Props) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: ScrollAreaPrimitive.Scrollbar.Props) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Scrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
data-orientation={orientation}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Thumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="relative flex-1 rounded-full bg-border"
|
||||
/>
|
||||
</ScrollAreaPrimitive.Scrollbar>
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client"
|
||||
|
||||
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
|
||||
|
||||
import { cn } from "@/shared/ui/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: SeparatorPrimitive.Props) {
|
||||
return (
|
||||
<SeparatorPrimitive
|
||||
data-slot="separator"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
@@ -0,0 +1,13 @@
|
||||
import { cn } from "@/shared/ui/utils"
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
@@ -1 +1,7 @@
|
||||
import "@testing-library/jest-dom/vitest"
|
||||
import { cleanup } from "@testing-library/react"
|
||||
import { afterEach } from "vitest"
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user