From b9975e58e96e2340dedfa89b69025f42b68f85e6 Mon Sep 17 00:00:00 2001 From: yuxuanhui Date: Sat, 12 Sep 2026 02:13:48 +0800 Subject: [PATCH] feat: enhance table layout and interaction across various components - Introduced a new `WorkspaceTable` component for consistent table styling and behavior. - Updated `BacktestPage`, `DatasetPage`, and `DataPreparationPage` to utilize the new `WorkspaceTable`. - Implemented compact row heights and ellipsis for long text in table cells. - Added validation for online field queries to ensure at least one filter is provided. - Enhanced error handling and user feedback for invalid input scenarios. - Improved pagination controls with dynamic page size options. - Documented table display specifications in `ui-tables.md`. - Added tests to verify table layout, interaction, and error handling. --- .../issues/01-implementation.md | 20 +++ AGENTS.md | 2 + README.md | 2 + backend/app/backtests/contracts.py | 15 ++- backend/app/preparations/service.py | 10 ++ backend/tests/test_backtests.py | 27 ++++ backend/tests/test_preparations.py | 17 ++- docs/ui-tables.md | 29 +++++ frontend/src/backtests/BacktestPage.tsx | 37 +++++- frontend/src/backtests/style.css | 5 + frontend/src/components/WorkspaceTable.tsx | 35 ++++++ frontend/src/components/workspace-table.css | 117 ++++++++++++++++++ frontend/src/pages/DatasetPage.tsx | 31 +++-- .../src/preparations/DataPreparationPage.tsx | 65 +++++++--- .../preparations/DataPreparationPicker.tsx | 15 +-- frontend/src/preparations/FieldDirectory.tsx | 81 ++++++++---- frontend/src/preparations/FieldTable.tsx | 19 ++- frontend/src/preparations/style.css | 32 +++-- frontend/tests/dataset.spec.ts | 108 ++++++++++++++++ 19 files changed, 586 insertions(+), 81 deletions(-) create mode 100644 .scratch/table-ui-consistency/issues/01-implementation.md create mode 100644 docs/ui-tables.md create mode 100644 frontend/src/components/WorkspaceTable.tsx create mode 100644 frontend/src/components/workspace-table.css diff --git a/.scratch/table-ui-consistency/issues/01-implementation.md b/.scratch/table-ui-consistency/issues/01-implementation.md new file mode 100644 index 0000000..3563b06 --- /dev/null +++ b/.scratch/table-ui-consistency/issues/01-implementation.md @@ -0,0 +1,20 @@ +# 统一表格规范与回测 500 排查 + +Type: task +Status: resolved + +按 Alpha 管理统一数据目录、字段目录、数据准备的 Semi Table 行高、Tab、分页、状态 Tag、单元格截断和紧凑筛选区;在线查询改为第二个 Tab,明确输入并提交条件才请求。用户授权将规范固定为项目文档。回测 500 先复现并核对失败接口,再修复验证。 + +## Comments + +- 开始处理;工作区干净。 + +- Semi 2.103.0 Table、Tabs、Tag 文档已核对;新建 WorkspaceTable 统一 40px 行高、截断、表体滚动和底部分页。三个目录及准备选择器已接入,状态使用 Tag,筛选栏上下 padding 4px。 +- 在线目录移到第二个卡片 Tab;默认及仅输入时不查询,重置清空结果。前后端均要求额外查询条件。 +- 用户提供运行列表 URL 和 ResponseValidationError / extra_forbidden 日志。使用含额外来源字段的持久化记录复现同类响应错误;新增 SourceOutput 仅投影当前字段,输入 Source 保持严格校验,不恢复旧输入字段或进行快照转换。 + +## Answer + +本地修改完成。项目展示规范见 docs/ui-tables.md,AGENTS.md 与 README 已接入入口。 +验证:后端 Ruff 通过;回测与准备模块 pytest 41 项通过,新增复现测试修复前失败、修复后通过;前端构建通过(保留既有打包体积及 lottie eval 提示);Playwright 9 项通过,最后列宽与 Tag 调整后另复核 2 项通过。浏览器实测 40px 行高、长名称不溢出、桌面及 900px 窄屏底栏固定,截图已检查。 +仅本地代码与模拟上游验证,未部署或修改线上数据;部署后需要确认线上运行列表恢复。 diff --git a/AGENTS.md b/AGENTS.md index a366b39..c5b2c3b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,6 +14,8 @@ ### Semi MCP +新增表格、调整表格布局或交互、评审表格 UI 时,先阅读 `docs/ui-tables.md`,使用项目统一的行高、Tab、筛选区、内容截断及分页规范。 + 新增或调整 Semi UI 组件用法(属性、布局、对齐、状态展示、交互、主题样式),排查组件专属行为或进行版本迁移时,优先使用 `semi-mcp` 核对文档与示例。纯业务逻辑修改不触发此查询。 - 先从项目依赖及锁文件确认实际版本,发现当前可用的 Semi MCP 工具,再按该版本调用 `get_semi_document`;示例被折叠时使用 `get_semi_code_block`。 diff --git a/README.md b/README.md index b5f62b8..cfae9da 100644 --- a/README.md +++ b/README.md @@ -266,3 +266,5 @@ AI 模型兼容性由模拟 Chat Completions/Responses HTTP 流与真实 SDK 适 首页提供平台消息、顾问日度排名、已参加比赛与协议、未来活动,独立刷新并保存上次成功快照。消息逐页浏览,活动读取平台分页后按结束时间过滤;日期或排名缺失保持未知。刷新失败保留缓存并提示过期。 在大模型服务设置“基础信息处理模型”(沿用 description_model 字段),与 alpha研究使用模型共享连接配置。该模型继续生成 Alpha Description,并用于手动生成首页摘要及比赛规则解读;未配置时仍可查看原始信息,不回退到研究模型。解读保存来源版本及所用模型,来源或连接配置变化后提示待更新,不自动调用模型。比赛解读附原文依据,仅提供信息建议,不执行报名、接受协议或安排任务。 + +项目表格的布局、行高、Tab、状态标签、内容截断与分页交互遵循[表格展示规范](docs/ui-tables.md)。 diff --git a/backend/app/backtests/contracts.py b/backend/app/backtests/contracts.py index 70bef83..5b5e0e9 100644 --- a/backend/app/backtests/contracts.py +++ b/backend/app/backtests/contracts.py @@ -4,7 +4,7 @@ import hashlib import json from typing import Literal -from pydantic import Field, field_validator, model_validator +from pydantic import ConfigDict, Field, field_validator, model_validator from ..preparations.contracts import PreparationReference from ..schemas import Contract @@ -56,6 +56,15 @@ class Source(Contract): hypothesis: str | None = Field(default=None, max_length=2000) +class SourceOutput(Source): + """Project stored metadata onto the public source fields; input remains strict. + + Removed or internal keys are never returned or converted to snapshot references. + Known fields still undergo normal validation. + """ + + model_config = ConfigDict(extra="ignore") + class DraftInput(Contract): @@ -145,7 +154,7 @@ class PreviewOutput(Contract): preview_id: str version: int name: str - source: Source + source: SourceOutput digest: str total: int batch_count: int @@ -163,7 +172,7 @@ class RunOutput(Contract): backtest_run_id: str preview_id: str name: str - source: Source + source: SourceOutput ai_context: dict control: Literal["active", "paused", "stopped"] status: str diff --git a/backend/app/preparations/service.py b/backend/app/preparations/service.py index 69c0926..5bcd4ad 100644 --- a/backend/app/preparations/service.py +++ b/backend/app/preparations/service.py @@ -136,6 +136,16 @@ class Preparations: ) async def online_fields(self, filters): + text_filters = (filters.q, filters.dataset_id, filters.field_type) + numeric_filters = ( + filters.coverage_min, filters.coverage_max, + filters.user_count_min, filters.user_count_max, + filters.alpha_count_min, filters.alpha_count_max, + ) + if not any(value and value.strip() for value in text_filters) and not any( + value is not None for value in numeric_filters + ): + raise HTTPException(422, "在线查询至少需要关键词、数据集、类型或数值筛选条件") if self.client is None: raise HTTPException(409, "请先连接 WorldQuant") params = dict( diff --git a/backend/tests/test_backtests.py b/backend/tests/test_backtests.py index 1a236f4..b7f70f3 100644 --- a/backend/tests/test_backtests.py +++ b/backend/tests/test_backtests.py @@ -411,3 +411,30 @@ async def test_terminal_detail_failure_releases_slot_but_keeps_platform_success( await tick(lane) assert len(platform.posts) == 2 await lane.interrupt() + + +async def test_run_list_projects_source_metadata_without_accepting_unknown_input(app, logged_in): + """Persisted source metadata must not turn the entire run list into a 500.""" + await setup(app) + created = await start(logged_in, await preview(logged_in)) + async with app.state.sessions.begin() as db: + row = await db.get(BacktestRun, created["backtest_run_id"]) + row.source = {**row.source, "template_input_id": None, "extra_metadata": "not-an-input"} + response = await logged_in.get(f"{PREFIX}/runs?offset=0&source=") + assert response.status_code == 200 + source = response.json()["items"][0]["source"] + assert source["kind"] == "test" + assert "template_input_id" not in source and "extra_metadata" not in source + assert source["input_snapshot_ids"] == [] + detail = await logged_in.get(f"{PREFIX}/runs/{created['backtest_run_id']}") + assert detail.status_code == 200 + assert detail.json()["source"] == source + rejected = await logged_in.post( + f"{PREFIX}/drafts", + json={ + "name": "invalid", + "source": {"kind": "manual", "extra_metadata": "invalid"}, + "candidates": [candidate()], + }, + ) + assert rejected.status_code == 422 diff --git a/backend/tests/test_preparations.py b/backend/tests/test_preparations.py index f815537..9156cfc 100644 --- a/backend/tests/test_preparations.py +++ b/backend/tests/test_preparations.py @@ -88,12 +88,13 @@ async def test_scope_dimensions_rejected_without_creating_collection(catalog, ke async def test_online_fields_without_sync_and_local_search_before_pagination(catalog): client, _, _ = catalog - response = await client.get("/api/v1/catalog/worldquant/fields", params={**SCOPE, "limit": 100}) + params = {**SCOPE, "dataset_id": "TEST_FIN", "limit": 100} + response = await client.get("/api/v1/catalog/worldquant/fields", params=params) assert response.status_code == 200, response.text first = response.json() assert len(first["items"]) == 100 and first["has_more"] second = ( - await client.get("/api/v1/catalog/worldquant/fields", params={**SCOPE, "limit": 100, "offset": 100}) + await client.get("/api/v1/catalog/worldquant/fields", params={**params, "offset": 100}) ).json() assert len(second["items"]) > 0 assert (await client.get("/api/v1/catalog/fields", params=SCOPE)).json()["total"] == 0 @@ -298,7 +299,8 @@ async def test_full_sync_restart_keeps_page_and_auth_pauses_all_datasets(catalog async def test_online_instrument_type_is_verified(catalog): client, _, state = catalog state["fields"][0]["instrumentType"] = "FUTURE" - assert (await client.get("/api/v1/catalog/worldquant/fields", params=SCOPE)).status_code == 502 + response = await client.get("/api/v1/catalog/worldquant/fields", params={**SCOPE, "q": "TEST"}) + assert response.status_code == 502 async def test_mcp_collection_reads_and_versioned_submit_contract(catalog): @@ -350,3 +352,12 @@ async def test_retry_waiting_full_job_requeues_its_checkpoint(catalog): async with runner.sessions.begin() as db: result = await Business(db).retry_job(job.id) assert result["status"] == "queued" and result["checkpoint"]["offset"] == 100 + + +async def test_online_fields_reject_empty_criteria_before_requesting_platform(catalog): + client, _, state = catalog + before = list(state["calls"]) + for params in (SCOPE, {**SCOPE, "q": " ", "dataset_id": ""}): + response = await client.get("/api/v1/catalog/worldquant/fields", params=params) + assert response.status_code == 422 + assert state["calls"] == before diff --git a/docs/ui-tables.md b/docs/ui-tables.md new file mode 100644 index 0000000..be90d9c --- /dev/null +++ b/docs/ui-tables.md @@ -0,0 +1,29 @@ +# 表格展示规范 + +以 Alpha 管理为视觉基准。数据目录、字段目录、数据准备及其选择器使用 `frontend/src/components/WorkspaceTable.tsx`,底层为项目锁定版本的 Semi Design Table。新表格及涉及布局的改动复用此边界;业务页面负责服务端查询、排序、选择和分页状态。 + +## 布局与尺寸 + +| 部位 | 规范 | +| --- | --- | +| 页面结构 | 可选 Tab → 筛选及操作栏 → 表格 → 底部分页 | +| Tab | Semi `Tabs type="card"`,浅灰栏底色,`tabPaneMotion={false}`;单视图无需空 Tab | +| 表头、数据行 | 40px 高,单元格上下 8px、左右 16px;正文 13px,内容高不超过 23px(含边框后整行 40px) | +| 筛选及操作栏 | 每行上下 padding 4px、左右 12px,margin 0,控件间距 8px;更多条件按需展开 | +| 表体 | 列宽明确、固定表格布局;宽表仅在表体内横向滚动,主列表纵向占满剩余空间 | +| 分页 | 表格外底栏,左侧总数及更新时间,右侧页码和每页条数;主列表支持 25/50/100 条 | + +主列表使用 `WorkspaceTable fill`、`scroll.y="100%"`,父级 flex 链保持 `min-height: 0`。Tab 的内容及 motion overlay 同样参与高度约束,使表头和底栏可见、表体独立滚动。弹窗中的预览表格可使用限定高度。 + +## 单元格与交互 + +- 每列提供合适宽度,长名称、ID、描述保持单行省略。使用 Semi `ellipsis` 和共用 `.workspace-cell` 边界;完整内容可通过标题提示或详情查看。 +- 状态使用小尺寸、浅色 Semi `Tag`,文字明确表达状态,颜色仅作辅助:成功绿色、进行中蓝色、等待灰色、警告橙色、失败红色。缺失值显示“未提供”。 +- 名称入口使用 borderless Button,宽度限定在单元格内。行操作使用 `.workspace-cell-actions`,不复用筛选栏样式;按钮、Tag 和任何子元素均不得侵入相邻列。 +- 多选使用 Semi `rowSelection` 和稳定的业务键;表头全选只作用于当前页,跨页保留选择,切换数据范围清空选择。批量操作展示选择数量,空选择时禁用。 +- 筛选或每页条数改变时回到第一页。服务端分页场景由后端排序,保留受控分页;只有接口支持的排序条件才展示为可用。 +- 字段目录先“本地同步”,后“worldquant接口”。在线字段查询只有填写关键词、数据集、类型或数值筛选条件并点击查询后才执行;范围默认值本身不触发查询。重置及切换范围清空在线结果和勾选。 + +## 验证 + +使用长名称、长描述、状态、多页数据及空结果检查。浏览器回归需验证 40px 行高、单元格内截断、Tab 切换、分页及选择保留;在桌面与窄视口检查表体滚动和底栏可达。在线目录检查首次打开、仅输入未提交及重置均不会调用字段查询接口。 diff --git a/frontend/src/backtests/BacktestPage.tsx b/frontend/src/backtests/BacktestPage.tsx index 99cf8b7..de8587b 100644 --- a/frontend/src/backtests/BacktestPage.tsx +++ b/frontend/src/backtests/BacktestPage.tsx @@ -35,6 +35,29 @@ import type { import "./style.css"; import { SourceDetails, sourceLabel } from "../research/SourceDetails"; +function BacktestStatusTag({ status }: { status: string }) { + const color = ["completed", "complete", "saved"].includes(status) + ? "green" + : ["failed", "collection_failed"].includes(status) + ? "red" + : ["completed_with_errors", "needs_review", "unknown"].includes(status) + ? "orange" + : [ + "running", + "submitting", + "submitted", + "collecting", + "stopping", + ].includes(status) + ? "blue" + : "grey"; + return ( + + {labels[status] || status} + + ); +} + export function BacktestPage({ version = 0, active, @@ -395,7 +418,7 @@ export function BacktestPage({ { title: "状态", width: 160, - render: (_, r) => labels[r!.status] || r!.status, + render: (_, r) => , }, { title: "已保存 / 总数", @@ -895,17 +918,23 @@ export function BacktestPage({ { title: "平台", width: 120, - render: (_, i) => labels[i!.platform_status], + render: (_, i) => ( + + ), }, { title: "收集", width: 100, - render: (_, i) => labels[i!.collection_status], + render: (_, i) => ( + + ), }, { title: "保存", width: 100, - render: (_, i) => labels[i!.persistence_status], + render: (_, i) => ( + + ), }, { title: "Sharpe", diff --git a/frontend/src/backtests/style.css b/frontend/src/backtests/style.css index 789de23..6f43c6b 100644 --- a/frontend/src/backtests/style.css +++ b/frontend/src/backtests/style.css @@ -99,3 +99,8 @@ white-space: nowrap; text-overflow: ellipsis; } + +.backtest-page .semi-table-row-cell .semi-tag, +.backtest-sheet .semi-table-row-cell .semi-tag { + max-width: 100%; +} diff --git a/frontend/src/components/WorkspaceTable.tsx b/frontend/src/components/WorkspaceTable.tsx new file mode 100644 index 0000000..a5c24cc --- /dev/null +++ b/frontend/src/components/WorkspaceTable.tsx @@ -0,0 +1,35 @@ +import { Table } from "@douyinfe/semi-ui-19"; +import type { TableProps } from "@douyinfe/semi-ui-19/lib/es/table/interface"; +import "./workspace-table.css"; + +/** Shared compact table boundary. Business pages retain selection, sorting and paging state. */ +export function WorkspaceTable>({ + columns = [], + className = "", + fill = false, + ...props +}: TableProps & { fill?: boolean }) { + return ( + + {...props} + size="small" + pagination={false} + className={`workspace-table ${fill ? "workspace-table-fill" : ""} ${className}`} + columns={columns.map((column) => ({ + width: 140, + ellipsis: true, + ...column, + render: (value, record, index, options) => ( +
+ {column.render + ? column.render(value, record, index, options) + : (value ?? "未提供")} +
+ ), + }))} + /> + ); +} diff --git a/frontend/src/components/workspace-table.css b/frontend/src/components/workspace-table.css new file mode 100644 index 0000000..319b09c --- /dev/null +++ b/frontend/src/components/workspace-table.css @@ -0,0 +1,117 @@ +/* Canonical table metrics; see docs/ui-tables.md. Values match Alpha management. */ +.workspace-table { + min-width: 0; + width: 100%; + --workspace-row-height: 40px; +} +.workspace-table table { + table-layout: fixed; +} +.workspace-table .semi-table-row-cell, +.workspace-table .semi-table-row-head { + box-sizing: border-box; + height: var(--workspace-row-height); + padding: 8px 16px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} +.workspace-cell { + width: 100%; + min-width: 0; + max-height: 23px; + line-height: 23px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-size: 13px; +} +.workspace-cell > .semi-button { + max-width: 100%; + padding: 0; + text-align: left; +} +.workspace-cell .semi-button { + height: 23px; + font-size: 13px; +} +.workspace-cell .semi-button-content { + display: block; + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} +.workspace-cell-actions { + display: flex; + align-items: center; + gap: 4px; + height: 23px; + margin: 0; + padding: 0; + flex-wrap: nowrap; +} +.workspace-cell-actions > .semi-button { + padding: 0 8px; + flex-shrink: 0; +} +.workspace-cell .semi-tag { + max-width: 100%; + vertical-align: middle; +} +.workspace-table-fill, +.workspace-table-fill > .semi-spin, +.workspace-table-fill .semi-spin-children, +.workspace-table-fill .semi-table-small, +.workspace-table-fill .semi-table-container { + display: flex; + flex: 1; + flex-direction: column; + min-height: 0; + overflow: hidden; +} +.workspace-table-fill .semi-table-header { + flex-shrink: 0; +} +.workspace-table-fill .semi-table-body { + flex: 1; + min-height: 0; + overflow: auto; + overscroll-behavior: contain; +} +.workspace-tabs { + min-height: 0; + display: flex; + flex-direction: column; + flex: 1; +} +.workspace-tabs > .semi-tabs-bar { + background: var(--semi-color-fill-0); + flex-shrink: 0; +} +.workspace-tabs > .semi-tabs-content { + min-height: 0; + padding: 0; + flex: 1; + display: flex; + flex-direction: column; +} +.workspace-tabs .semi-tabs-pane-active, +.workspace-tabs .semi-tabs-pane-active > .semi-tabs-pane-motion-overlay { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} +.workspace-table-footer { + min-height: 44px; + flex-shrink: 0; +} +.workspace-table-footer > .semi-page { + margin-left: auto; +} +@media (max-width: 700px) { + .workspace-table-footer { + flex-wrap: wrap; + } +} diff --git a/frontend/src/pages/DatasetPage.tsx b/frontend/src/pages/DatasetPage.tsx index 332f73f..5352a0d 100644 --- a/frontend/src/pages/DatasetPage.tsx +++ b/frontend/src/pages/DatasetPage.tsx @@ -1,3 +1,4 @@ +import { WorkspaceTable as Table } from "../components/WorkspaceTable"; import { useEffect, useState } from "react"; import { Banner, @@ -5,9 +6,9 @@ import { Input, Modal, Pagination, - Table, TextArea, Toast, + Tag, } from "@douyinfe/semi-ui-19"; import { api, @@ -78,6 +79,7 @@ export function DatasetPage({ const [sort, setSort] = useState("name"), [direction, setDirection] = useState("asc"), [page, setPage] = useState(1), + [pageSize, setPageSize] = useState(25), [rev, setRev] = useState(0); const [data, setData] = useState({ items: [], @@ -129,7 +131,7 @@ export function DatasetPage({ const c = new AbortController(); setLoading(true); api( - `/catalog/datasets?${queryString({ ...scope, q, category, subcategory, sort, direction, offset: (page - 1) * 25 })}`, + `/catalog/datasets?${queryString({ ...scope, q, category, subcategory, sort, direction, limit: pageSize, offset: (page - 1) * pageSize })}`, { signal: c.signal }, ) .then(setData) @@ -149,6 +151,7 @@ export function DatasetPage({ sort, direction, page, + pageSize, version, rev, ]); @@ -304,18 +307,21 @@ export function DatasetPage({ + fill rowKey="id" size="small" dataSource={data.items} pagination={false} loading={loading} - scroll={{ x: 1050, y: "100%" }} + scroll={{ x: 1180, y: "100%" }} columns={[ { title: "数据集", + width: 280, render: (_, r) => ( ), }, - { title: "范围", render: (_, r) => scopeLabel(r.scope) }, - { title: "字段数", dataIndex: "field_count" }, - { title: "数据集数", dataIndex: "dataset_count" }, - { title: "更新时间", render: (_, r) => formatTime(r.updated_at) }, + { + title: "范围", + width: 240, + render: (_, r) => ( + {scopeLabel(r.scope)} + ), + }, + { title: "字段数", dataIndex: "field_count", width: 100 }, + { title: "数据集数", dataIndex: "dataset_count", width: 100 }, + { + title: "更新时间", + width: 180, + render: (_, r) => formatTime(r.updated_at), + }, { title: "操作", + width: 200, render: (_, r) => ( -
+
@@ -233,12 +247,21 @@ export function DataPreparationPage({ active }: { active: boolean }) { }, ]} /> - +
+ 共 {data.total} 个集合 + { + setPageSize(size); + setPage(1); + }} + onPageChange={setPage} + /> +
setAdding(false)} > {error && } - + {(["local", "worldquant"] as const).map((source) => ( rowKey="id" + scroll={{ x: 900, y: 320 }} dataSource={data.items} size="small" pagination={false} @@ -160,9 +155,11 @@ export function DataPreparationPicker({ columns={[ { title: "集合名称", + width: 260, render: (_, r) => ( ), }, - { title: "范围", render: (_, r) => scopeLabel(r.scope) }, + { title: "范围", width: 280, render: (_, r) => scopeLabel(r.scope) }, { title: "字段数", dataIndex: "field_count" }, { title: "数据集数", dataIndex: "dataset_count" }, ...(!multiple diff --git a/frontend/src/preparations/FieldDirectory.tsx b/frontend/src/preparations/FieldDirectory.tsx index a302d3b..ae7045b 100644 --- a/frontend/src/preparations/FieldDirectory.tsx +++ b/frontend/src/preparations/FieldDirectory.tsx @@ -114,7 +114,8 @@ export function FieldBrowser({ }) { const [scope, setScope] = useState(fixedScope ?? defaultScope), [draft, setDraft] = useState>({}), - [filters, setFilters] = useState>({}); + [filters, setFilters] = useState>({}), + [queriedScope, setQueriedScope] = useState(null); const [page, setPage] = useState(1), [size, setSize] = useState(25), [refresh, setRefresh] = useState(0), @@ -131,13 +132,30 @@ export function FieldBrowser({ [adding, setAdding] = useState(false); const actual = fixedScope ?? scope, key = scopeKey(actual); + const hasCriteria = Object.entries(draft).some( + ([k, v]) => + [ + "q", + "dataset_id", + "field_type", + "coverage_min", + "coverage_max", + "user_count_min", + "user_count_max", + "alpha_count_min", + "alpha_count_max", + ].includes(k) && v.trim() !== "", + ); + const canRead = source === "local" || queriedScope === key; useEffect(() => { + setQueriedScope(null); + setLoading(false); setSelected({}); setData({ items: [], total: 0, limit: 25, offset: 0 }); setPage(1); }, [key]); useEffect(() => { - if (!active) return; + if (!active || !canRead) return; const c = new AbortController(); setLoading(true); setError(""); @@ -163,7 +181,7 @@ export function FieldBrowser({ if (!c.signal.aborted) setLoading(false); }); return () => c.abort(); - }, [active, source, key, filters, page, size, refresh, revision]); + }, [active, source, key, filters, page, size, refresh, revision, canRead]); const update = (k: string, v: string) => setDraft((old) => ({ ...old, [k]: v })); return ( @@ -202,7 +220,9 @@ export function FieldBrowser({ onChange={(v) => update("field_type", String(v ?? ""))} />
)} {error && } + {source === "worldquant" && !canRead && ( +

+ 请输入关键词、数据集或其他筛选条件,然后点击查询。 +

+ )}
) : ( @@ -44,7 +47,7 @@ export function FieldTable({ title: "类型", dataIndex: "field_type", width: 100, - render: displayValue, + render: (value) => (value ? {value} : "未提供"), }, { title: "覆盖率", @@ -67,11 +70,17 @@ export function FieldTable({ { title: "同步时间", width: 170, - render: (_, r) => (r.synced_at ? formatTime(r.synced_at) : "在线查询"), + render: (_, r) => + r.synced_at ? ( + formatTime(r.synced_at) + ) : ( + 在线查询 + ), }, ]; return ( + fill={fill} size="small" className="preparation-table" rowKey={(r) => `${r?.dataset_id}|${r?.id}`} @@ -79,7 +88,7 @@ export function FieldTable({ columns={columns} loading={loading} pagination={false} - scroll={{ x: 1420, y: 400 }} + scroll={{ x: 1420, y: fill ? "100%" : 400 }} rowSelection={ onSelect ? { diff --git a/frontend/src/preparations/style.css b/frontend/src/preparations/style.css index be086a7..b57728d 100644 --- a/frontend/src/preparations/style.css +++ b/frontend/src/preparations/style.css @@ -1,11 +1,11 @@ .preparation-page { display: flex; flex-direction: column; - gap: 12px; + gap: 0; height: 100%; min-height: 0; - padding: 16px; - overflow: auto; + padding: 0; + overflow: hidden; background: var(--semi-color-bg-0); } .preparation-tools { @@ -13,7 +13,9 @@ align-items: center; gap: 8px; flex-wrap: wrap; - margin: 8px 0; + margin: 0; + padding: 4px 12px; + flex-shrink: 0; } .preparation-tools > .semi-input-wrapper { width: 240px; @@ -31,14 +33,17 @@ .preparation-fields { display: flex; flex-direction: column; - gap: 8px; + gap: 0; + flex: 1; + overflow: hidden; min-height: 0; } .preparation-filters { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 10px; - margin: 10px 0; + margin: 0; + padding: 4px 12px; } .preparation-filters label { display: flex; @@ -60,9 +65,22 @@ } @media (max-width: 700px) { .preparation-page { - padding: 8px; + padding: 0; } .preparation-tools > .semi-input-wrapper { width: 100%; } } + +.preparation-detail .preparation-tools { + padding: 0; +} +.preparation-page > .semi-banner { + margin: 4px 12px; +} +.preparation-query-hint { + padding: 8px 12px; + margin: 0; + color: var(--semi-color-text-2); + font-size: 13px; +} diff --git a/frontend/tests/dataset.spec.ts b/frontend/tests/dataset.spec.ts index 8e13002..2be0b72 100644 --- a/frontend/tests/dataset.spec.ts +++ b/frontend/tests/dataset.spec.ts @@ -94,6 +94,14 @@ test("数据集完整同步、使用、集合编辑与研究选择", async ({ pa await prep.getByRole("button", { name: "移除所选字段" }).click(); await expect(prep).toContainText("122 字段"); await page.keyboard.press("Escape"); + const preparationRow = page + .getByRole("row") + .filter({ hasText: "浏览器准备集合" }); + await expect(preparationRow).toBeVisible(); + expect( + await preparationRow.evaluate((row) => row.getBoundingClientRect().height), + ).toBe(40); + await page.screenshot({ path: "test-results/preparation-table-layout.png" }); await page .getByRole("navigation", { name: "主导航" }) .getByRole("button", { name: "特征工程", exact: true }) @@ -111,7 +119,26 @@ test("在线字段跨页多选直接准备,本地只展示完整同步字段", .getByRole("navigation", { name: "主导航" }) .getByRole("button", { name: "字段目录", exact: true }) .click(); + const queries: string[] = []; + page.on("request", (request) => { + if (request.url().includes("/catalog/worldquant/fields")) + queries.push(request.url()); + }); + await expect( + page.getByRole("tab", { name: "本地同步", exact: true }), + ).toHaveAttribute("aria-selected", "true"); + await page.getByRole("tab", { name: "worldquant接口", exact: true }).click(); const online = page.getByRole("tabpanel", { name: "worldquant接口" }); + await expect( + online.getByRole("button", { name: "查询", exact: true }), + ).toBeDisabled(); + await expect( + online.getByText("请输入关键词、数据集或其他筛选条件,然后点击查询。"), + ).toBeVisible(); + expect(queries).toEqual([]); + await online.getByLabel("字段数据集").fill("TEST_FIN"); + expect(queries).toEqual([]); + await online.getByRole("button", { name: "查询", exact: true }).click(); await expect(online.getByText("TEST_FIN_001", { exact: true })).toBeVisible(); await online .getByRole("row") @@ -147,3 +174,84 @@ test("在线字段跨页多选直接准备,本地只展示完整同步字段", fullPage: true, }); }); + +test("目录表格保持紧凑行高、截断和固定分页", async ({ page }) => { + await setup(page); + await sync(page, null); + await sync(page, "TEST_FIN"); + const longName = "超长数据集名称与描述".repeat(20); + await page.route("**/api/v1/catalog/datasets?*", async (route) => { + const response = await route.fetch(); + const body = await response.json(); + body.items = body.items.map((item: Record) => ({ + ...item, + name: longName, + })); + await route.fulfill({ response, json: body }); + }); + await page.reload(); + const table = page.locator(".workspace-table:visible").first(); + await expect( + table.getByRole("button", { name: longName }).first(), + ).toBeVisible(); + const metrics = await table + .locator("tbody .workspace-cell") + .first() + .evaluate((el) => ({ + rowHeight: el.closest("tr")!.getBoundingClientRect().height, + overflow: getComputedStyle(el).overflow, + cellWidth: el.getBoundingClientRect().width, + buttonWidth: el.querySelector("button")!.getBoundingClientRect().width, + })); + expect(metrics.rowHeight).toBe(40); + expect(metrics.overflow).toBe("hidden"); + expect(metrics.buttonWidth).toBeLessThanOrEqual(metrics.cellWidth); + await expect( + table.locator(".semi-tag").filter({ hasText: "已同步字段" }), + ).toBeVisible(); + await page.screenshot({ path: "test-results/catalog-table-layout.png" }); + await page + .getByRole("navigation", { name: "主导航" }) + .getByRole("button", { name: "字段目录", exact: true }) + .click(); + const local = page.getByRole("tabpanel", { name: "本地同步" }); + await expect(local.getByText("TEST_FIN_001", { exact: true })).toBeVisible(); + const checkLayout = async () => { + const box = await local.locator(".workspace-table-footer").boundingBox(); + expect(box!.y + box!.height).toBeGreaterThan( + page.viewportSize()!.height - 40, + ); + expect(box!.y + box!.height).toBeLessThanOrEqual( + page.viewportSize()!.height, + ); + const rows = await local + .locator("tbody tr.semi-table-row") + .evaluateAll((rows) => + rows.map((row) => row.getBoundingClientRect().height), + ); + expect(rows.length).toBe(25); + expect(rows.every((height) => height === 40)).toBe(true); + }; + await checkLayout(); + await page.screenshot({ path: "test-results/field-table-layout.png" }); + await page.setViewportSize({ width: 900, height: 760 }); + await checkLayout(); + await page.screenshot({ path: "test-results/field-table-layout-narrow.png" }); + await page.getByRole("tab", { name: "worldquant接口", exact: true }).click(); + const online = page.getByRole("tabpanel", { name: "worldquant接口" }); + await online.getByLabel("字段数据集").fill("TEST_FIN"); + await online.getByRole("button", { name: "查询", exact: true }).click(); + await expect(online.getByText("TEST_FIN_001", { exact: true })).toBeVisible(); + let queries = 0; + page.on("request", (req) => { + if (req.url().includes("/catalog/worldquant/fields")) queries++; + }); + await online.getByRole("button", { name: "重置", exact: true }).click(); + await expect( + online.getByRole("button", { name: "查询", exact: true }), + ).toBeDisabled(); + await expect(online.getByText("TEST_FIN_001", { exact: true })).toHaveCount( + 0, + ); + expect(queries).toBe(0); +});