Develop #4
+1
-1
@@ -1,6 +1,6 @@
|
||||
COMPOSE_PROJECT_NAME=zhixing-system-dev
|
||||
ZHIXING_SERVER_PORT=8000
|
||||
ZHIXING_WEB_PORT=5173
|
||||
ZHIXING_WEB_PORT=5555
|
||||
ZHIXING_APP_ENV=development
|
||||
ZHIXING_LOG_LEVEL=INFO
|
||||
|
||||
|
||||
@@ -17,5 +17,6 @@ htmlcov/
|
||||
|
||||
# Node.js
|
||||
node_modules/
|
||||
.pnpm-store/
|
||||
dist/
|
||||
coverage/
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
Status: ready-for-agent
|
||||
Triage: ready-for-agent
|
||||
|
||||
# Home 市场数据概览
|
||||
|
||||
## Problem Statement
|
||||
|
||||
当前系统前端只有用于验证后端连通性的系统状态页面,用户无法在进入系统后快速了解市场数据的当前状态。
|
||||
|
||||
同步任务已经将股票主数据、日线行情、估值快照和同步批次结果写入 PostgreSQL,但这些结果尚未通过面向用户的 Home 页面聚合展示。用户需要在一个清晰、稳定的卡片中看到当前股票数量、最近更新交易日、同步完成时间、当日更新状态、数据覆盖率,以及更新成功和失败的股票情况。
|
||||
|
||||
## Solution
|
||||
|
||||
新增 Home 页面和一个面向 Home 的市场数据概览查询接口。页面沿用参考原型的管理后台布局:左侧导航、顶部搜索占位框和用户头像、主内容区的组合卡片。
|
||||
|
||||
Home 的核心内容是一个“市场数据概览”卡片,展示当前目标股票池数量、最近更新交易日、同步完成时间、更新状态、目标数量、成功数量、失败数量和数据覆盖率。失败股票通过点击卡片中的失败区域打开弹窗,查看股票代码、名称、失败类型、失败原因和目标交易日。
|
||||
|
||||
后端以现有 PostgreSQL 市场数据和同步批次为事实源,提供一次聚合读取。前端通过同源 `/api/v1` 路径访问该接口,并根据返回状态处理正常、加载中、暂无数据和异常场景。
|
||||
|
||||
## User Stories
|
||||
|
||||
1. As a 量化研究者, I want to enter the system and immediately see the Home page, so that I can understand the current market-data situation without navigating through multiple pages.
|
||||
2. As a 量化研究者, I want to see the current target stock-pool count, so that I know the size of the universe currently available to the system.
|
||||
3. As a 量化研究者, I want the stock count to exclude inactive historical stocks, so that the number reflects the current target stock pool rather than every row ever retained in the master data.
|
||||
4. As a 量化研究者, I want to see the most recent update trading date, so that I know which trading day the displayed market data reaches.
|
||||
5. As a 量化研究者, I want to see the synchronization completion time separately from the trading date, so that I can distinguish the data's business date from the job's wall-clock completion time.
|
||||
6. As a 量化研究者, I want the page to interpret “当日” as the latest open trading day, so that weekends and market holidays are not incorrectly shown as missing updates.
|
||||
7. As a 量化研究者, I want to see an updating state while a synchronization batch is running, so that I know the displayed result may still be changing.
|
||||
8. As a 量化研究者, I want to see an updated state when every target stock has both required daily market data and valuation data, so that I can trust the batch as complete.
|
||||
9. As a 量化研究者, I want to see a partial-update state when some stocks succeeded and others failed, so that I can distinguish incomplete data from a total synchronization failure.
|
||||
10. As a 量化研究者, I want to see an unavailable state when the latest batch failed or produced no valid stock data, so that I do not mistake stale or absent data for a successful update.
|
||||
11. As a 量化研究者, I want to see an empty-data state before any synchronization batch exists, so that the page explains why no update details are available.
|
||||
12. As a 量化研究者, I want to see the target stock count, successful stock count, failed stock count, and coverage rate together, so that I can assess synchronization completeness at a glance.
|
||||
13. As a 量化研究者, I want a stock to count as successful only when both its daily bar and valuation snapshot exist for the target trading day, so that partial per-stock data is not presented as complete.
|
||||
14. As a 量化研究者, I want to click the failed-stock summary, so that I can inspect the affected stocks without leaving the Home page.
|
||||
15. As a 量化研究者, I want the failure dialog to show each stock's code and name, so that I can identify the affected security quickly.
|
||||
16. As a 量化研究者, I want the failure dialog to show failure type, readable reason, and target trading day, so that I can understand whether the issue concerns market data, valuation data, or another processing boundary.
|
||||
17. As a 量化研究者, I want the failure dialog to support a long failure list through scrolling, so that every failed stock remains discoverable without making the main card unbounded.
|
||||
18. As a 量化研究者, I want batch-level errors to be distinguished from stock-level failures, so that database or retention failures are not incorrectly attributed to a particular stock.
|
||||
19. As a 量化研究者, I want a retried batch for the same trading day to be represented by the latest completed result, so that the Home page does not show duplicate competing summaries for one trading day.
|
||||
20. As a 量化研究者, I want a loading placeholder while the Home query is pending, so that the page communicates progress instead of appearing broken.
|
||||
21. As a 量化研究者, I want a retry action when the overview request fails, so that a temporary backend or network problem can be recovered from the page.
|
||||
22. As a 量化研究者, I want the page to retain its layout when data is empty or partially unavailable, so that the navigation and page context remain usable.
|
||||
23. As a 量化研究者, I want the sidebar to preserve the agreed navigation structure, so that future market-data and synchronization pages have a consistent place in the product.
|
||||
24. As a 量化研究者, I want unfinished navigation entries to be visibly disabled, so that I do not enter blank or misleading pages.
|
||||
25. As a 量化研究者, I want the search control to be visible as a visual placeholder, so that the header follows the reference layout while search behavior remains a separate feature.
|
||||
26. As a 量化研究者, I want the user avatar to remain visible in the header, so that the page retains the reference product's account area without requiring authentication behavior in this slice.
|
||||
27. As a 量化研究者, I want the main card to remain readable on desktop and tablet widths, so that the overview remains useful across the supported management-console screen sizes.
|
||||
28. As a 量化研究者, I want the failure dialog to be dismissible with normal dialog controls and keyboard interaction, so that detailed failure inspection remains accessible.
|
||||
|
||||
## Implementation Decisions
|
||||
|
||||
- Add a market-data overview read model in the backend and expose it through one aggregated HTTP endpoint, `GET /api/v1/home/overview`.
|
||||
- Use PostgreSQL as the runtime fact source, respecting the existing market-data storage ADRs and the distinction between the current target stock pool, valid selection stock pool, daily bars, valuation snapshots, and synchronization batches.
|
||||
- Calculate the current stock-pool count from active stock master records only.
|
||||
- Select the latest relevant synchronization result by trading date and batch state. A running batch for the latest target trading date takes precedence as “更新中”; once finished, the latest completed retry result for that trading date is the displayed result.
|
||||
- Keep the business date and synchronization completion timestamp as separate response values. Format both for `Asia/Shanghai` at the presentation boundary.
|
||||
- Define the overview status as a stable presentation contract with five states: `updating`, `success`, `partial_success`, `failed`, and `no_data`. The UI may render these as “更新中”“已更新”“部分更新”“未更新”“暂无数据”.
|
||||
- Treat a stock as successful only when both its daily bar and valuation snapshot exist for the target trading date. Derive failed stocks from the active target stock pool minus that complete set.
|
||||
- Return stock-level failure details with stock code, stock name, failure type, readable failure reason, and target trading date. Join stock master information for names and distinguish missing daily bars, missing valuation snapshots, and recorded per-stock failures where the data permits.
|
||||
- Return batch-level errors separately from stock-level failure details. Batch-level errors affect the overall status and explanatory message but are not fabricated into a stock failure.
|
||||
- Return target count, successful count, failed count, valid count, and coverage rate in the overview response. Coverage remains the valid stock count divided by the target count and follows the existing market-data definition.
|
||||
- Return a nullable latest-update section when no synchronization batch exists. The response must let the frontend distinguish “暂无数据” from a transport or server error.
|
||||
- Keep the endpoint read-only. This slice does not trigger synchronization, retry batches, or data repair from the Home page.
|
||||
- Keep the browser transport same-origin under `/api/v1`, as required by the existing HTTP architecture decision.
|
||||
- Add a vertical Home feature in the frontend containing the overview API adapter, typed response model, query hook, page composition, status presentation, and failure dialog behavior.
|
||||
- Replace the current root smoke page with the Home page while preserving the existing application provider, router, query client, theme behavior, and shared UI conventions.
|
||||
- Build a reusable management-console shell for this slice: sidebar navigation, top header, search placeholder, avatar area, main content region, and responsive card layout. Non-implemented navigation entries are disabled.
|
||||
- Render all requested market-data facts in one composite “市场数据概览” card rather than splitting the overview across unrelated cards.
|
||||
- Render successful and failed counts in the card. Clicking the failed summary opens a dialog containing the complete failure list; the list is scrollable and does not navigate away from Home.
|
||||
- Provide distinct UI states for loading, no data, request error with retry, updating, success, partial success, and failed synchronization.
|
||||
- Keep the search control visual-only in this slice. Stock search, search results, and search navigation are separate scope.
|
||||
- Keep the avatar visual-only in this slice. Authentication, account menus, permissions, and profile behavior are separate scope.
|
||||
- Optimize for desktop and tablet layouts. On narrow screens, stack the overview sections vertically while preserving access to the failure dialog.
|
||||
|
||||
## Testing Decisions
|
||||
|
||||
- Test external behavior at the highest practical seams. Do not assert SQL statements, repository implementation details, React Query internals, or CSS class composition.
|
||||
- Test the backend through the Home overview HTTP contract using the existing FastAPI `TestClient` style. Fixtures should cover active stock counting, complete success, partial success, failed batch, running batch, no batch, retry selection, coverage calculation, stock-level failure details, and batch-level errors.
|
||||
- Test the frontend through Home page rendering behavior using the existing Vitest, Testing Library, and query-hook mocking pattern used by the system status page.
|
||||
- Frontend behavior tests should cover the normal overview, loading skeleton, no-data message, request error and retry action, each update status, coverage and count display, opening the failed-stock dialog, dialog fields, and dismissing the dialog.
|
||||
- Add an accessibility-oriented assertion for the failure dialog's accessible name and normal close behavior where supported by the existing test setup.
|
||||
- Prefer representative fixtures with multiple active stocks, inactive historical stocks, one missing daily bar, one missing valuation snapshot, and one batch-level error so that the success/failure boundary is observable.
|
||||
- Preserve the existing system status tests and test style as prior art; the Home tests should verify user-visible text and interactions rather than implementation structure.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Triggering a new market-data synchronization from Home.
|
||||
- Retrying failed stocks from Home.
|
||||
- Building a stock search experience or search result navigation.
|
||||
- Building stock detail, synchronization history, strategy, or selection pages.
|
||||
- Adding authentication, user profiles, account menus, or permission management.
|
||||
- Adding real-time market data, intraday data, or live progress streaming.
|
||||
- Changing the six-year retention policy, Tushare synchronization workflow, or existing market-data write semantics.
|
||||
- Replacing PostgreSQL as the market-data fact source.
|
||||
- Adding a new trading-calendar product surface or a separate calendar management feature.
|
||||
- Full mobile-specific visual polish beyond a usable stacked layout.
|
||||
- Adding unrelated prototype content such as courses, memberships, career paths, or promotional panels from the reference image.
|
||||
|
||||
## Further Notes
|
||||
|
||||
- The existing domain glossary uses “当前目标股票池” for the active, eligible market universe and “最近更新交易日” for the business date shown by Home. New API and UI naming should preserve those meanings.
|
||||
- The UI should not imply that an older successful batch covers a newer trading day. When no relevant synchronization batch exists, the page must show the no-data or unavailable state implied by the response rather than inventing a current update.
|
||||
- The overview is intentionally read-only and aggregation-oriented. Any future operational controls should be designed as separate actions with their own confirmation, permissions, and audit requirements.
|
||||
- The agreed primary seam is the Home overview HTTP contract, with a frontend rendering seam for user-visible states and modal interaction.
|
||||
@@ -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": "completed",
|
||||
"dev_type": null,
|
||||
"scope": null,
|
||||
"package": null,
|
||||
"priority": "P2",
|
||||
"creator": "yuxuanhui",
|
||||
"assignee": "yuxuanhui",
|
||||
"createdAt": "2026-08-07",
|
||||
"completedAt": "2026-08-07",
|
||||
"branch": null,
|
||||
"base_branch": "main",
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"relatedFiles": [],
|
||||
"notes": "",
|
||||
"meta": {}
|
||||
}
|
||||
@@ -8,8 +8,8 @@
|
||||
|
||||
<!-- @@@auto:current-status -->
|
||||
- **Active File**: `journal-1.md`
|
||||
- **Total Sessions**: 1
|
||||
- **Last Active**: 2026-08-06
|
||||
- **Total Sessions**: 2
|
||||
- **Last Active**: 2026-08-07
|
||||
<!-- @@@/auto:current-status -->
|
||||
|
||||
---
|
||||
@@ -19,7 +19,7 @@
|
||||
<!-- @@@auto:active-documents -->
|
||||
| File | Lines | Status |
|
||||
|------|-------|--------|
|
||||
| `journal-1.md` | ~29 | Active |
|
||||
| `journal-1.md` | ~50 | Active |
|
||||
<!-- @@@/auto:active-documents -->
|
||||
|
||||
---
|
||||
@@ -29,6 +29,7 @@
|
||||
<!-- @@@auto:session-history -->
|
||||
| # | Date | Title | Commits | Branch |
|
||||
|---|------|-------|---------|--------|
|
||||
| 2 | 2026-08-07 | 实现 Home 市场数据概览 | `b0e846d` | `develop` |
|
||||
| 1 | 2026-08-06 | 完成 TUSHARE PostgreSQL 同步任务 | `039a81f`, `11e8728` | `develop` |
|
||||
<!-- @@@/auto:session-history -->
|
||||
|
||||
|
||||
@@ -27,3 +27,24 @@
|
||||
### Status
|
||||
|
||||
[OK] **Completed**
|
||||
|
||||
|
||||
## Session 2: 实现 Home 市场数据概览
|
||||
|
||||
**Date**: 2026-08-07
|
||||
**Task**: 实现 Home 市场数据概览
|
||||
**Branch**: `develop`
|
||||
|
||||
### Summary
|
||||
|
||||
新增 PostgreSQL 市场数据概览读取接口与 Home 管理后台页面;接入 shadcn base-nova primitives、失败股票 Dialog、完整状态展示和前后端测试。根级测试通过,根级 check 仍受两个既有格式问题阻断。
|
||||
|
||||
### Git Commits
|
||||
|
||||
| Hash | Message |
|
||||
|------|---------|
|
||||
| `b0e846d` | (see git log) |
|
||||
|
||||
### Status
|
||||
|
||||
[OK] **Completed**
|
||||
|
||||
+14
@@ -13,6 +13,12 @@ _Avoid_: 用当前市值回填历史、把当前目标股票池当作无幸存
|
||||
|
||||
**当前目标股票池**:同步时仍处于上市状态的沪深 A 股集合,排除 ST/风险警示股和北交所股票,也不包含退市股票的长期历史成员资格。
|
||||
|
||||
**股票数量**:当前目标股票池中的股票总数;不把已经不活跃的历史股票计入当前数量。
|
||||
|
||||
**最近更新交易日**:最近一个开市交易日中,系统尝试同步市场数据的目标日期;它描述数据覆盖到哪一天,不等同于同步任务的完成时间。
|
||||
|
||||
**日更新状态**:最近更新交易日的市场数据同步状态;“当日”按最近开市交易日理解,并区分更新中、已更新、部分更新、未更新和暂无数据。
|
||||
|
||||
**有效选股股票池**:当前目标股票池中,行情和所需估值数据均已同步到目标交易日的股票集合;数据仍停留在更早交易日的股票不参与当天选股。
|
||||
|
||||
**日线行情**:以证券和交易日为粒度记录的开盘价、最高价、最低价、收盘价和成交量等交易结果。
|
||||
@@ -25,4 +31,12 @@ _Avoid_: 用当前市值回填历史、把当前目标股票池当作无幸存
|
||||
|
||||
**同步批次**:一次面向当前目标股票池的市场数据同步运行;批次可以全部成功、部分成功或失败,并保留每只股票的处理结果。
|
||||
|
||||
**重试批次**:针对已有同步批次中失败对象再次执行的同步批次;同一交易日的展示结果以最近一次已完成的批次为准。
|
||||
|
||||
**批次级错误**:无法归属于某一只股票的同步异常,例如数据库写入或数据保留处理失败;它影响同步批次状态,但不计入某只股票的失败明细。
|
||||
|
||||
**股票更新成功**:一只股票在目标交易日同时具备日线行情和估值快照时的状态。
|
||||
|
||||
**股票更新失败**:一只股票未能在目标交易日同时具备日线行情和估值快照时的状态,并应保留可读的失败原因。
|
||||
|
||||
**数据覆盖率**:目标交易日内,有效选股股票数占当前目标股票池目标数的比例,用于判断选股结果是否具备足够完整性。
|
||||
|
||||
@@ -18,10 +18,10 @@ Zhixing System 是一个 Python + React 的量化系统基础工程。后端采
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
./dev.sh up
|
||||
./dev.sh dev
|
||||
```
|
||||
|
||||
打开 <http://localhost:5173>。前端通过 `/api/v1/system/status` 调用后端,后端健康检查位于 <http://localhost:8000/healthz>。
|
||||
打开 <http://localhost:5555>。前端通过 `/api/v1/system/status` 调用后端,后端健康检查位于 <http://localhost:8000/healthz>。
|
||||
|
||||
常用命令:
|
||||
|
||||
@@ -33,4 +33,3 @@ cp .env.example .env
|
||||
```
|
||||
|
||||
后端和前端也可分别通过 `uv` 与 `pnpm` 在本机运行,具体命令见各子项目 README。
|
||||
|
||||
|
||||
@@ -6,13 +6,13 @@ PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
COMPOSE_FILE="${PROJECT_ROOT}/docker-compose.dev.yml"
|
||||
|
||||
usage() {
|
||||
printf 'Usage: %s {up|down|logs|check|test}\n' "$0"
|
||||
printf 'Usage: %s {dev|up|down|logs|check|test}\n' "$0"
|
||||
}
|
||||
|
||||
command="${1:-}"
|
||||
|
||||
case "${command}" in
|
||||
up)
|
||||
dev|up)
|
||||
docker compose --file "${COMPOSE_FILE}" up --build --remove-orphans
|
||||
;;
|
||||
down)
|
||||
|
||||
@@ -60,7 +60,7 @@ services:
|
||||
VITE_DEV_API_TARGET: http://server:8000
|
||||
init: true
|
||||
ports:
|
||||
- "${ZHIXING_WEB_PORT:-5173}:5173"
|
||||
- "${ZHIXING_WEB_PORT:-5555}:5173"
|
||||
volumes:
|
||||
- ./zhixing-web:/app
|
||||
- web-node-modules:/app/node_modules
|
||||
|
||||
@@ -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