feat: enhance table layout and interaction across various components
Deploy production / deploy (push) Successful in 53s
Deploy production / deploy (push) Successful in 53s
- 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.
This commit is contained in:
@@ -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 窄屏底栏固定,截图已检查。
|
||||||
|
仅本地代码与模拟上游验证,未部署或修改线上数据;部署后需要确认线上运行列表恢复。
|
||||||
@@ -14,6 +14,8 @@
|
|||||||
|
|
||||||
### Semi MCP
|
### Semi MCP
|
||||||
|
|
||||||
|
新增表格、调整表格布局或交互、评审表格 UI 时,先阅读 `docs/ui-tables.md`,使用项目统一的行高、Tab、筛选区、内容截断及分页规范。
|
||||||
|
|
||||||
新增或调整 Semi UI 组件用法(属性、布局、对齐、状态展示、交互、主题样式),排查组件专属行为或进行版本迁移时,优先使用 `semi-mcp` 核对文档与示例。纯业务逻辑修改不触发此查询。
|
新增或调整 Semi UI 组件用法(属性、布局、对齐、状态展示、交互、主题样式),排查组件专属行为或进行版本迁移时,优先使用 `semi-mcp` 核对文档与示例。纯业务逻辑修改不触发此查询。
|
||||||
|
|
||||||
- 先从项目依赖及锁文件确认实际版本,发现当前可用的 Semi MCP 工具,再按该版本调用 `get_semi_document`;示例被折叠时使用 `get_semi_code_block`。
|
- 先从项目依赖及锁文件确认实际版本,发现当前可用的 Semi MCP 工具,再按该版本调用 `get_semi_document`;示例被折叠时使用 `get_semi_code_block`。
|
||||||
|
|||||||
@@ -266,3 +266,5 @@ AI 模型兼容性由模拟 Chat Completions/Responses HTTP 流与真实 SDK 适
|
|||||||
首页提供平台消息、顾问日度排名、已参加比赛与协议、未来活动,独立刷新并保存上次成功快照。消息逐页浏览,活动读取平台分页后按结束时间过滤;日期或排名缺失保持未知。刷新失败保留缓存并提示过期。
|
首页提供平台消息、顾问日度排名、已参加比赛与协议、未来活动,独立刷新并保存上次成功快照。消息逐页浏览,活动读取平台分页后按结束时间过滤;日期或排名缺失保持未知。刷新失败保留缓存并提示过期。
|
||||||
|
|
||||||
在大模型服务设置“基础信息处理模型”(沿用 description_model 字段),与 alpha研究使用模型共享连接配置。该模型继续生成 Alpha Description,并用于手动生成首页摘要及比赛规则解读;未配置时仍可查看原始信息,不回退到研究模型。解读保存来源版本及所用模型,来源或连接配置变化后提示待更新,不自动调用模型。比赛解读附原文依据,仅提供信息建议,不执行报名、接受协议或安排任务。
|
在大模型服务设置“基础信息处理模型”(沿用 description_model 字段),与 alpha研究使用模型共享连接配置。该模型继续生成 Alpha Description,并用于手动生成首页摘要及比赛规则解读;未配置时仍可查看原始信息,不回退到研究模型。解读保存来源版本及所用模型,来源或连接配置变化后提示待更新,不自动调用模型。比赛解读附原文依据,仅提供信息建议,不执行报名、接受协议或安排任务。
|
||||||
|
|
||||||
|
项目表格的布局、行高、Tab、状态标签、内容截断与分页交互遵循[表格展示规范](docs/ui-tables.md)。
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import hashlib
|
|||||||
import json
|
import json
|
||||||
from typing import Literal
|
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 ..preparations.contracts import PreparationReference
|
||||||
from ..schemas import Contract
|
from ..schemas import Contract
|
||||||
@@ -56,6 +56,15 @@ class Source(Contract):
|
|||||||
hypothesis: str | None = Field(default=None, max_length=2000)
|
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):
|
class DraftInput(Contract):
|
||||||
@@ -145,7 +154,7 @@ class PreviewOutput(Contract):
|
|||||||
preview_id: str
|
preview_id: str
|
||||||
version: int
|
version: int
|
||||||
name: str
|
name: str
|
||||||
source: Source
|
source: SourceOutput
|
||||||
digest: str
|
digest: str
|
||||||
total: int
|
total: int
|
||||||
batch_count: int
|
batch_count: int
|
||||||
@@ -163,7 +172,7 @@ class RunOutput(Contract):
|
|||||||
backtest_run_id: str
|
backtest_run_id: str
|
||||||
preview_id: str
|
preview_id: str
|
||||||
name: str
|
name: str
|
||||||
source: Source
|
source: SourceOutput
|
||||||
ai_context: dict
|
ai_context: dict
|
||||||
control: Literal["active", "paused", "stopped"]
|
control: Literal["active", "paused", "stopped"]
|
||||||
status: str
|
status: str
|
||||||
|
|||||||
@@ -136,6 +136,16 @@ class Preparations:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def online_fields(self, filters):
|
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:
|
if self.client is None:
|
||||||
raise HTTPException(409, "请先连接 WorldQuant")
|
raise HTTPException(409, "请先连接 WorldQuant")
|
||||||
params = dict(
|
params = dict(
|
||||||
|
|||||||
@@ -411,3 +411,30 @@ async def test_terminal_detail_failure_releases_slot_but_keeps_platform_success(
|
|||||||
await tick(lane)
|
await tick(lane)
|
||||||
assert len(platform.posts) == 2
|
assert len(platform.posts) == 2
|
||||||
await lane.interrupt()
|
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
|
||||||
|
|||||||
@@ -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):
|
async def test_online_fields_without_sync_and_local_search_before_pagination(catalog):
|
||||||
client, _, _ = 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
|
assert response.status_code == 200, response.text
|
||||||
first = response.json()
|
first = response.json()
|
||||||
assert len(first["items"]) == 100 and first["has_more"]
|
assert len(first["items"]) == 100 and first["has_more"]
|
||||||
second = (
|
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()
|
).json()
|
||||||
assert len(second["items"]) > 0
|
assert len(second["items"]) > 0
|
||||||
assert (await client.get("/api/v1/catalog/fields", params=SCOPE)).json()["total"] == 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):
|
async def test_online_instrument_type_is_verified(catalog):
|
||||||
client, _, state = catalog
|
client, _, state = catalog
|
||||||
state["fields"][0]["instrumentType"] = "FUTURE"
|
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):
|
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:
|
async with runner.sessions.begin() as db:
|
||||||
result = await Business(db).retry_job(job.id)
|
result = await Business(db).retry_job(job.id)
|
||||||
assert result["status"] == "queued" and result["checkpoint"]["offset"] == 100
|
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
|
||||||
|
|||||||
@@ -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 切换、分页及选择保留;在桌面与窄视口检查表体滚动和底栏可达。在线目录检查首次打开、仅输入未提交及重置均不会调用字段查询接口。
|
||||||
@@ -35,6 +35,29 @@ import type {
|
|||||||
import "./style.css";
|
import "./style.css";
|
||||||
import { SourceDetails, sourceLabel } from "../research/SourceDetails";
|
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 (
|
||||||
|
<Tag color={color} size="small">
|
||||||
|
<span title={labels[status] || status}>{labels[status] || status}</span>
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function BacktestPage({
|
export function BacktestPage({
|
||||||
version = 0,
|
version = 0,
|
||||||
active,
|
active,
|
||||||
@@ -395,7 +418,7 @@ export function BacktestPage({
|
|||||||
{
|
{
|
||||||
title: "状态",
|
title: "状态",
|
||||||
width: 160,
|
width: 160,
|
||||||
render: (_, r) => labels[r!.status] || r!.status,
|
render: (_, r) => <BacktestStatusTag status={r!.status} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "已保存 / 总数",
|
title: "已保存 / 总数",
|
||||||
@@ -895,17 +918,23 @@ export function BacktestPage({
|
|||||||
{
|
{
|
||||||
title: "平台",
|
title: "平台",
|
||||||
width: 120,
|
width: 120,
|
||||||
render: (_, i) => labels[i!.platform_status],
|
render: (_, i) => (
|
||||||
|
<BacktestStatusTag status={i!.platform_status} />
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "收集",
|
title: "收集",
|
||||||
width: 100,
|
width: 100,
|
||||||
render: (_, i) => labels[i!.collection_status],
|
render: (_, i) => (
|
||||||
|
<BacktestStatusTag status={i!.collection_status} />
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "保存",
|
title: "保存",
|
||||||
width: 100,
|
width: 100,
|
||||||
render: (_, i) => labels[i!.persistence_status],
|
render: (_, i) => (
|
||||||
|
<BacktestStatusTag status={i!.persistence_status} />
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Sharpe",
|
title: "Sharpe",
|
||||||
|
|||||||
@@ -99,3 +99,8 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.backtest-page .semi-table-row-cell .semi-tag,
|
||||||
|
.backtest-sheet .semi-table-row-cell .semi-tag {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<T extends Record<string, unknown>>({
|
||||||
|
columns = [],
|
||||||
|
className = "",
|
||||||
|
fill = false,
|
||||||
|
...props
|
||||||
|
}: TableProps<T> & { fill?: boolean }) {
|
||||||
|
return (
|
||||||
|
<Table<T>
|
||||||
|
{...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) => (
|
||||||
|
<div
|
||||||
|
className="workspace-cell"
|
||||||
|
title={typeof value === "string" ? value : undefined}
|
||||||
|
>
|
||||||
|
{column.render
|
||||||
|
? column.render(value, record, index, options)
|
||||||
|
: (value ?? "未提供")}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { WorkspaceTable as Table } from "../components/WorkspaceTable";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Banner,
|
Banner,
|
||||||
@@ -5,9 +6,9 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
Modal,
|
Modal,
|
||||||
Pagination,
|
Pagination,
|
||||||
Table,
|
|
||||||
TextArea,
|
TextArea,
|
||||||
Toast,
|
Toast,
|
||||||
|
Tag,
|
||||||
} from "@douyinfe/semi-ui-19";
|
} from "@douyinfe/semi-ui-19";
|
||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
@@ -78,6 +79,7 @@ export function DatasetPage({
|
|||||||
const [sort, setSort] = useState("name"),
|
const [sort, setSort] = useState("name"),
|
||||||
[direction, setDirection] = useState("asc"),
|
[direction, setDirection] = useState("asc"),
|
||||||
[page, setPage] = useState(1),
|
[page, setPage] = useState(1),
|
||||||
|
[pageSize, setPageSize] = useState(25),
|
||||||
[rev, setRev] = useState(0);
|
[rev, setRev] = useState(0);
|
||||||
const [data, setData] = useState<Result>({
|
const [data, setData] = useState<Result>({
|
||||||
items: [],
|
items: [],
|
||||||
@@ -129,7 +131,7 @@ export function DatasetPage({
|
|||||||
const c = new AbortController();
|
const c = new AbortController();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
api<Result>(
|
api<Result>(
|
||||||
`/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 },
|
{ signal: c.signal },
|
||||||
)
|
)
|
||||||
.then(setData)
|
.then(setData)
|
||||||
@@ -149,6 +151,7 @@ export function DatasetPage({
|
|||||||
sort,
|
sort,
|
||||||
direction,
|
direction,
|
||||||
page,
|
page,
|
||||||
|
pageSize,
|
||||||
version,
|
version,
|
||||||
rev,
|
rev,
|
||||||
]);
|
]);
|
||||||
@@ -304,18 +307,21 @@ export function DatasetPage({
|
|||||||
<Button onClick={onChat}>AI 助手</Button>
|
<Button onClick={onChat}>AI 助手</Button>
|
||||||
</div>
|
</div>
|
||||||
<Table<Entry>
|
<Table<Entry>
|
||||||
|
fill
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
size="small"
|
size="small"
|
||||||
dataSource={data.items}
|
dataSource={data.items}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
scroll={{ x: 1050, y: "100%" }}
|
scroll={{ x: 1180, y: "100%" }}
|
||||||
columns={[
|
columns={[
|
||||||
{
|
{
|
||||||
title: "数据集",
|
title: "数据集",
|
||||||
|
width: 280,
|
||||||
render: (_, r) => (
|
render: (_, r) => (
|
||||||
<Button
|
<Button
|
||||||
theme="borderless"
|
theme="borderless"
|
||||||
|
title={r.name || r.id}
|
||||||
onClick={() => void work(() => view(r))}
|
onClick={() => void work(() => view(r))}
|
||||||
>
|
>
|
||||||
{r.name || r.id}
|
{r.name || r.id}
|
||||||
@@ -330,14 +336,17 @@ export function DatasetPage({
|
|||||||
{ title: "价值评分", dataIndex: "value_score", render: displayValue },
|
{ title: "价值评分", dataIndex: "value_score", render: displayValue },
|
||||||
{
|
{
|
||||||
title: "同步状态",
|
title: "同步状态",
|
||||||
render: (_, r) =>
|
render: (_, r) => (
|
||||||
r.collection_version ? "已同步字段" : "待同步字段",
|
<Tag color={r.collection_version ? "green" : "grey"}>
|
||||||
|
{r.collection_version ? "已同步字段" : "待同步字段"}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "操作",
|
title: "操作",
|
||||||
width: 240,
|
width: 240,
|
||||||
render: (_, r) => (
|
render: (_, r) => (
|
||||||
<div className="preparation-tools">
|
<div className="workspace-cell-actions">
|
||||||
<Button
|
<Button
|
||||||
theme="borderless"
|
theme="borderless"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
@@ -365,7 +374,7 @@ export function DatasetPage({
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<div className="preparation-tools">
|
<div className="table-pagination workspace-table-footer">
|
||||||
<span>
|
<span>
|
||||||
{data.total} 个数据集 ·{" "}
|
{data.total} 个数据集 ·{" "}
|
||||||
{formatTime(data.synced_at, account?.timezone)}
|
{formatTime(data.synced_at, account?.timezone)}
|
||||||
@@ -373,7 +382,13 @@ export function DatasetPage({
|
|||||||
<Pagination
|
<Pagination
|
||||||
currentPage={page}
|
currentPage={page}
|
||||||
total={data.total}
|
total={data.total}
|
||||||
pageSize={25}
|
pageSize={pageSize}
|
||||||
|
showSizeChanger
|
||||||
|
pageSizeOpts={[25, 50, 100]}
|
||||||
|
onPageSizeChange={(size) => {
|
||||||
|
setPageSize(size);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
onPageChange={setPage}
|
onPageChange={setPage}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { WorkspaceTable as Table } from "../components/WorkspaceTable";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Banner,
|
Banner,
|
||||||
@@ -5,7 +6,6 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
Modal,
|
Modal,
|
||||||
Pagination,
|
Pagination,
|
||||||
Table,
|
|
||||||
TextArea,
|
TextArea,
|
||||||
Toast,
|
Toast,
|
||||||
Tabs,
|
Tabs,
|
||||||
@@ -34,6 +34,7 @@ export function DataPreparationPage({ active }: { active: boolean }) {
|
|||||||
}),
|
}),
|
||||||
[q, setQ] = useState(""),
|
[q, setQ] = useState(""),
|
||||||
[page, setPage] = useState(1),
|
[page, setPage] = useState(1),
|
||||||
|
[pageSize, setPageSize] = useState(25),
|
||||||
[rev, setRev] = useState(0);
|
[rev, setRev] = useState(0);
|
||||||
const [selected, setSelected] = useState<Record<string, Preparation>>({}),
|
const [selected, setSelected] = useState<Record<string, Preparation>>({}),
|
||||||
[scope, setScope] = useState(defaultScope),
|
[scope, setScope] = useState(defaultScope),
|
||||||
@@ -60,18 +61,18 @@ export function DataPreparationPage({ active }: { active: boolean }) {
|
|||||||
if (!active) return;
|
if (!active) return;
|
||||||
const c = new AbortController();
|
const c = new AbortController();
|
||||||
api<Page<Preparation>>(
|
api<Page<Preparation>>(
|
||||||
`/data-preparations?${queryString({ q, offset: (page - 1) * 25, scope_key: scopeFilter ? scopeKey(scope) : undefined })}`,
|
`/data-preparations?${queryString({ q, limit: pageSize, offset: (page - 1) * pageSize, scope_key: scopeFilter ? scopeKey(scope) : undefined })}`,
|
||||||
{ signal: c.signal },
|
{ signal: c.signal },
|
||||||
)
|
)
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
setData(r);
|
setData(r);
|
||||||
setPage((p) => Math.min(p, Math.max(1, Math.ceil(r.total / 25))));
|
setPage((p) => Math.min(p, Math.max(1, Math.ceil(r.total / pageSize))));
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
if (!c.signal.aborted) setError(e.message);
|
if (!c.signal.aborted) setError(e.message);
|
||||||
});
|
});
|
||||||
return () => c.abort();
|
return () => c.abort();
|
||||||
}, [active, q, page, rev, scopeFilter, scopeKey(scope)]);
|
}, [active, q, page, pageSize, rev, scopeFilter, scopeKey(scope)]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!detail) return;
|
if (!detail) return;
|
||||||
const c = new AbortController();
|
const c = new AbortController();
|
||||||
@@ -169,11 +170,12 @@ export function DataPreparationPage({ active }: { active: boolean }) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Table<Preparation>
|
<Table<Preparation>
|
||||||
|
fill
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
size="small"
|
size="small"
|
||||||
dataSource={data.items}
|
dataSource={data.items}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
scroll={{ x: 1000, y: "100%" }}
|
scroll={{ x: 1140, y: "100%" }}
|
||||||
rowSelection={{
|
rowSelection={{
|
||||||
selectedRowKeys: Object.keys(selected),
|
selectedRowKeys: Object.keys(selected),
|
||||||
onChange: (keys) =>
|
onChange: (keys) =>
|
||||||
@@ -189,20 +191,32 @@ export function DataPreparationPage({ active }: { active: boolean }) {
|
|||||||
columns={[
|
columns={[
|
||||||
{
|
{
|
||||||
title: "集合名称",
|
title: "集合名称",
|
||||||
|
width: 260,
|
||||||
render: (_, r) => (
|
render: (_, r) => (
|
||||||
<Button theme="borderless" onClick={() => open(r)}>
|
<Button theme="borderless" title={r.name} onClick={() => open(r)}>
|
||||||
{r.name}
|
{r.name}
|
||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ title: "范围", render: (_, r) => scopeLabel(r.scope) },
|
{
|
||||||
{ title: "字段数", dataIndex: "field_count" },
|
title: "范围",
|
||||||
{ title: "数据集数", dataIndex: "dataset_count" },
|
width: 240,
|
||||||
{ title: "更新时间", render: (_, r) => formatTime(r.updated_at) },
|
render: (_, r) => (
|
||||||
|
<span title={scopeLabel(r.scope)}>{scopeLabel(r.scope)}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: "字段数", dataIndex: "field_count", width: 100 },
|
||||||
|
{ title: "数据集数", dataIndex: "dataset_count", width: 100 },
|
||||||
|
{
|
||||||
|
title: "更新时间",
|
||||||
|
width: 180,
|
||||||
|
render: (_, r) => formatTime(r.updated_at),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "操作",
|
title: "操作",
|
||||||
|
width: 200,
|
||||||
render: (_, r) => (
|
render: (_, r) => (
|
||||||
<div className="preparation-tools">
|
<div className="workspace-cell-actions">
|
||||||
<Button theme="borderless" onClick={() => open(r)}>
|
<Button theme="borderless" onClick={() => open(r)}>
|
||||||
查看
|
查看
|
||||||
</Button>
|
</Button>
|
||||||
@@ -233,12 +247,21 @@ export function DataPreparationPage({ active }: { active: boolean }) {
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<Pagination
|
<footer className="table-pagination workspace-table-footer">
|
||||||
currentPage={page}
|
<span>共 {data.total} 个集合</span>
|
||||||
total={data.total}
|
<Pagination
|
||||||
pageSize={25}
|
currentPage={page}
|
||||||
onPageChange={setPage}
|
total={data.total}
|
||||||
/>
|
pageSize={pageSize}
|
||||||
|
showSizeChanger
|
||||||
|
pageSizeOpts={[25, 50, 100]}
|
||||||
|
onPageSizeChange={(size) => {
|
||||||
|
setPageSize(size);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
onPageChange={setPage}
|
||||||
|
/>
|
||||||
|
</footer>
|
||||||
<Modal
|
<Modal
|
||||||
visible={creating && active}
|
visible={creating && active}
|
||||||
title="新建数据准备集合"
|
title="新建数据准备集合"
|
||||||
@@ -363,7 +386,13 @@ export function DataPreparationPage({ active }: { active: boolean }) {
|
|||||||
onCancel={() => setAdding(false)}
|
onCancel={() => setAdding(false)}
|
||||||
>
|
>
|
||||||
{error && <Banner type="danger" description={error} />}
|
{error && <Banner type="danger" description={error} />}
|
||||||
<Tabs activeKey={tab} onChange={setTab} tabPaneMotion={false}>
|
<Tabs
|
||||||
|
className="workspace-tabs"
|
||||||
|
type="card"
|
||||||
|
activeKey={tab}
|
||||||
|
onChange={setTab}
|
||||||
|
tabPaneMotion={false}
|
||||||
|
>
|
||||||
{(["local", "worldquant"] as const).map((source) => (
|
{(["local", "worldquant"] as const).map((source) => (
|
||||||
<TabPane
|
<TabPane
|
||||||
key={source}
|
key={source}
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
|
import { WorkspaceTable as Table } from "../components/WorkspaceTable";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import { Banner, Button, Input, Modal, Pagination } from "@douyinfe/semi-ui-19";
|
||||||
Banner,
|
|
||||||
Button,
|
|
||||||
Input,
|
|
||||||
Modal,
|
|
||||||
Pagination,
|
|
||||||
Table,
|
|
||||||
} from "@douyinfe/semi-ui-19";
|
|
||||||
import { api, queryString } from "../api";
|
import { api, queryString } from "../api";
|
||||||
import { FieldTable } from "./FieldTable";
|
import { FieldTable } from "./FieldTable";
|
||||||
import {
|
import {
|
||||||
@@ -134,6 +128,7 @@ export function DataPreparationPicker({
|
|||||||
</div>
|
</div>
|
||||||
<Table<Preparation>
|
<Table<Preparation>
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
|
scroll={{ x: 900, y: 320 }}
|
||||||
dataSource={data.items}
|
dataSource={data.items}
|
||||||
size="small"
|
size="small"
|
||||||
pagination={false}
|
pagination={false}
|
||||||
@@ -160,9 +155,11 @@ export function DataPreparationPicker({
|
|||||||
columns={[
|
columns={[
|
||||||
{
|
{
|
||||||
title: "集合名称",
|
title: "集合名称",
|
||||||
|
width: 260,
|
||||||
render: (_, r) => (
|
render: (_, r) => (
|
||||||
<Button
|
<Button
|
||||||
theme="borderless"
|
theme="borderless"
|
||||||
|
title={r.name}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setPreview(r);
|
setPreview(r);
|
||||||
setFieldPage(1);
|
setFieldPage(1);
|
||||||
@@ -172,7 +169,7 @@ export function DataPreparationPicker({
|
|||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ title: "范围", render: (_, r) => scopeLabel(r.scope) },
|
{ title: "范围", width: 280, render: (_, r) => scopeLabel(r.scope) },
|
||||||
{ title: "字段数", dataIndex: "field_count" },
|
{ title: "字段数", dataIndex: "field_count" },
|
||||||
{ title: "数据集数", dataIndex: "dataset_count" },
|
{ title: "数据集数", dataIndex: "dataset_count" },
|
||||||
...(!multiple
|
...(!multiple
|
||||||
|
|||||||
@@ -114,7 +114,8 @@ export function FieldBrowser({
|
|||||||
}) {
|
}) {
|
||||||
const [scope, setScope] = useState<Scope>(fixedScope ?? defaultScope),
|
const [scope, setScope] = useState<Scope>(fixedScope ?? defaultScope),
|
||||||
[draft, setDraft] = useState<Record<string, string>>({}),
|
[draft, setDraft] = useState<Record<string, string>>({}),
|
||||||
[filters, setFilters] = useState<Record<string, string>>({});
|
[filters, setFilters] = useState<Record<string, string>>({}),
|
||||||
|
[queriedScope, setQueriedScope] = useState<string | null>(null);
|
||||||
const [page, setPage] = useState(1),
|
const [page, setPage] = useState(1),
|
||||||
[size, setSize] = useState(25),
|
[size, setSize] = useState(25),
|
||||||
[refresh, setRefresh] = useState(0),
|
[refresh, setRefresh] = useState(0),
|
||||||
@@ -131,13 +132,30 @@ export function FieldBrowser({
|
|||||||
[adding, setAdding] = useState(false);
|
[adding, setAdding] = useState(false);
|
||||||
const actual = fixedScope ?? scope,
|
const actual = fixedScope ?? scope,
|
||||||
key = scopeKey(actual);
|
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(() => {
|
useEffect(() => {
|
||||||
|
setQueriedScope(null);
|
||||||
|
setLoading(false);
|
||||||
setSelected({});
|
setSelected({});
|
||||||
setData({ items: [], total: 0, limit: 25, offset: 0 });
|
setData({ items: [], total: 0, limit: 25, offset: 0 });
|
||||||
setPage(1);
|
setPage(1);
|
||||||
}, [key]);
|
}, [key]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!active) return;
|
if (!active || !canRead) return;
|
||||||
const c = new AbortController();
|
const c = new AbortController();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
@@ -163,7 +181,7 @@ export function FieldBrowser({
|
|||||||
if (!c.signal.aborted) setLoading(false);
|
if (!c.signal.aborted) setLoading(false);
|
||||||
});
|
});
|
||||||
return () => c.abort();
|
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) =>
|
const update = (k: string, v: string) =>
|
||||||
setDraft((old) => ({ ...old, [k]: v }));
|
setDraft((old) => ({ ...old, [k]: v }));
|
||||||
return (
|
return (
|
||||||
@@ -202,7 +220,9 @@ export function FieldBrowser({
|
|||||||
onChange={(v) => update("field_type", String(v ?? ""))}
|
onChange={(v) => update("field_type", String(v ?? ""))}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
|
disabled={source === "worldquant" && (!hasCriteria || loading)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
setQueriedScope(key);
|
||||||
setFilters({
|
setFilters({
|
||||||
...draft,
|
...draft,
|
||||||
synced_from: draft.synced_from
|
synced_from: draft.synced_from
|
||||||
@@ -223,6 +243,12 @@ export function FieldBrowser({
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setDraft({});
|
setDraft({});
|
||||||
setFilters({});
|
setFilters({});
|
||||||
|
setQueriedScope(null);
|
||||||
|
setSelected({});
|
||||||
|
setLoading(false);
|
||||||
|
setError("");
|
||||||
|
if (source === "worldquant")
|
||||||
|
setData({ items: [], total: 0, limit: size, offset: 0 });
|
||||||
setPage(1);
|
setPage(1);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -312,6 +338,11 @@ export function FieldBrowser({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{error && <Banner type="danger" description={error} />}
|
{error && <Banner type="danger" description={error} />}
|
||||||
|
{source === "worldquant" && !canRead && (
|
||||||
|
<p className="preparation-query-hint">
|
||||||
|
请输入关键词、数据集或其他筛选条件,然后点击查询。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<div className="preparation-tools">
|
<div className="preparation-tools">
|
||||||
<Button
|
<Button
|
||||||
theme="solid"
|
theme="solid"
|
||||||
@@ -328,6 +359,7 @@ export function FieldBrowser({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<FieldTable
|
<FieldTable
|
||||||
|
fill={!fixedScope}
|
||||||
items={data.items}
|
items={data.items}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
selected={Object.keys(selected)}
|
selected={Object.keys(selected)}
|
||||||
@@ -344,7 +376,7 @@ export function FieldBrowser({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<div className="preparation-tools">
|
<div className="table-pagination workspace-table-footer">
|
||||||
<span>
|
<span>
|
||||||
{data.total_known === false
|
{data.total_known === false
|
||||||
? "平台未提供总数"
|
? "平台未提供总数"
|
||||||
@@ -353,20 +385,14 @@ export function FieldBrowser({
|
|||||||
<Pagination
|
<Pagination
|
||||||
currentPage={page}
|
currentPage={page}
|
||||||
pageSize={size}
|
pageSize={size}
|
||||||
total={data.total}
|
showSizeChanger
|
||||||
onPageChange={setPage}
|
pageSizeOpts={[25, 50, 100]}
|
||||||
/>
|
onPageSizeChange={(next) => {
|
||||||
<ResearchSelect
|
setSize(next);
|
||||||
label="字段每页条数"
|
|
||||||
value={size}
|
|
||||||
optionList={[25, 50, 100].map((v) => ({
|
|
||||||
value: v,
|
|
||||||
label: `${v} 条/页`,
|
|
||||||
}))}
|
|
||||||
onChange={(v) => {
|
|
||||||
setSize(Number(v));
|
|
||||||
setPage(1);
|
setPage(1);
|
||||||
}}
|
}}
|
||||||
|
total={data.total}
|
||||||
|
onPageChange={setPage}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{adding && (
|
{adding && (
|
||||||
@@ -386,10 +412,23 @@ export function FieldDirectory({
|
|||||||
active: boolean;
|
active: boolean;
|
||||||
revision?: string;
|
revision?: string;
|
||||||
}) {
|
}) {
|
||||||
const [tab, setTab] = useState("worldquant");
|
const [tab, setTab] = useState("local");
|
||||||
return (
|
return (
|
||||||
<section className="preparation-page">
|
<section className="preparation-page">
|
||||||
<Tabs activeKey={tab} onChange={setTab} keepDOM tabPaneMotion={false}>
|
<Tabs
|
||||||
|
className="workspace-tabs"
|
||||||
|
type="card"
|
||||||
|
activeKey={tab}
|
||||||
|
onChange={setTab}
|
||||||
|
keepDOM
|
||||||
|
tabPaneMotion={false}
|
||||||
|
>
|
||||||
|
<TabPane tab="本地同步" itemKey="local">
|
||||||
|
<FieldBrowser
|
||||||
|
active={active && tab === "local"}
|
||||||
|
revision={revision}
|
||||||
|
/>
|
||||||
|
</TabPane>
|
||||||
<TabPane tab="worldquant接口" itemKey="worldquant">
|
<TabPane tab="worldquant接口" itemKey="worldquant">
|
||||||
<FieldBrowser
|
<FieldBrowser
|
||||||
active={active && tab === "worldquant"}
|
active={active && tab === "worldquant"}
|
||||||
@@ -397,12 +436,6 @@ export function FieldDirectory({
|
|||||||
revision={revision}
|
revision={revision}
|
||||||
/>
|
/>
|
||||||
</TabPane>
|
</TabPane>
|
||||||
<TabPane tab="本地同步" itemKey="local">
|
|
||||||
<FieldBrowser
|
|
||||||
active={active && tab === "local"}
|
|
||||||
revision={revision}
|
|
||||||
/>
|
|
||||||
</TabPane>
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
import { Button, Table } from "@douyinfe/semi-ui-19";
|
import { WorkspaceTable as Table } from "../components/WorkspaceTable";
|
||||||
|
import { Button, Tag } from "@douyinfe/semi-ui-19";
|
||||||
import type { ColumnProps } from "@douyinfe/semi-ui-19/lib/es/table/interface";
|
import type { ColumnProps } from "@douyinfe/semi-ui-19/lib/es/table/interface";
|
||||||
import { displayValue, formatTime } from "../api";
|
import { displayValue, formatTime } from "../api";
|
||||||
import type { FieldRecord } from "./types";
|
import type { FieldRecord } from "./types";
|
||||||
export function FieldTable({
|
export function FieldTable({
|
||||||
items,
|
items,
|
||||||
loading = false,
|
loading = false,
|
||||||
|
fill = false,
|
||||||
selected,
|
selected,
|
||||||
onSelect,
|
onSelect,
|
||||||
onOpen,
|
onOpen,
|
||||||
}: {
|
}: {
|
||||||
items: FieldRecord[];
|
items: FieldRecord[];
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
|
fill?: boolean;
|
||||||
onOpen?: (field: FieldRecord) => void;
|
onOpen?: (field: FieldRecord) => void;
|
||||||
selected?: string[];
|
selected?: string[];
|
||||||
onSelect?: (ids: string[]) => void;
|
onSelect?: (ids: string[]) => void;
|
||||||
@@ -22,7 +25,7 @@ export function FieldTable({
|
|||||||
width: 220,
|
width: 220,
|
||||||
render: (_, r) =>
|
render: (_, r) =>
|
||||||
onOpen ? (
|
onOpen ? (
|
||||||
<Button theme="borderless" onClick={() => onOpen(r)}>
|
<Button theme="borderless" title={r.id} onClick={() => onOpen(r)}>
|
||||||
{r.id}
|
{r.id}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
@@ -44,7 +47,7 @@ export function FieldTable({
|
|||||||
title: "类型",
|
title: "类型",
|
||||||
dataIndex: "field_type",
|
dataIndex: "field_type",
|
||||||
width: 100,
|
width: 100,
|
||||||
render: displayValue,
|
render: (value) => (value ? <Tag color="blue">{value}</Tag> : "未提供"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "覆盖率",
|
title: "覆盖率",
|
||||||
@@ -67,11 +70,17 @@ export function FieldTable({
|
|||||||
{
|
{
|
||||||
title: "同步时间",
|
title: "同步时间",
|
||||||
width: 170,
|
width: 170,
|
||||||
render: (_, r) => (r.synced_at ? formatTime(r.synced_at) : "在线查询"),
|
render: (_, r) =>
|
||||||
|
r.synced_at ? (
|
||||||
|
formatTime(r.synced_at)
|
||||||
|
) : (
|
||||||
|
<Tag color="blue">在线查询</Tag>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
return (
|
return (
|
||||||
<Table<FieldRecord>
|
<Table<FieldRecord>
|
||||||
|
fill={fill}
|
||||||
size="small"
|
size="small"
|
||||||
className="preparation-table"
|
className="preparation-table"
|
||||||
rowKey={(r) => `${r?.dataset_id}|${r?.id}`}
|
rowKey={(r) => `${r?.dataset_id}|${r?.id}`}
|
||||||
@@ -79,7 +88,7 @@ export function FieldTable({
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
scroll={{ x: 1420, y: 400 }}
|
scroll={{ x: 1420, y: fill ? "100%" : 400 }}
|
||||||
rowSelection={
|
rowSelection={
|
||||||
onSelect
|
onSelect
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
.preparation-page {
|
.preparation-page {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding: 16px;
|
padding: 0;
|
||||||
overflow: auto;
|
overflow: hidden;
|
||||||
background: var(--semi-color-bg-0);
|
background: var(--semi-color-bg-0);
|
||||||
}
|
}
|
||||||
.preparation-tools {
|
.preparation-tools {
|
||||||
@@ -13,7 +13,9 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
margin: 8px 0;
|
margin: 0;
|
||||||
|
padding: 4px 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.preparation-tools > .semi-input-wrapper {
|
.preparation-tools > .semi-input-wrapper {
|
||||||
width: 240px;
|
width: 240px;
|
||||||
@@ -31,14 +33,17 @@
|
|||||||
.preparation-fields {
|
.preparation-fields {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 0;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
.preparation-filters {
|
.preparation-filters {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
margin: 10px 0;
|
margin: 0;
|
||||||
|
padding: 4px 12px;
|
||||||
}
|
}
|
||||||
.preparation-filters label {
|
.preparation-filters label {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -60,9 +65,22 @@
|
|||||||
}
|
}
|
||||||
@media (max-width: 700px) {
|
@media (max-width: 700px) {
|
||||||
.preparation-page {
|
.preparation-page {
|
||||||
padding: 8px;
|
padding: 0;
|
||||||
}
|
}
|
||||||
.preparation-tools > .semi-input-wrapper {
|
.preparation-tools > .semi-input-wrapper {
|
||||||
width: 100%;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -94,6 +94,14 @@ test("数据集完整同步、使用、集合编辑与研究选择", async ({ pa
|
|||||||
await prep.getByRole("button", { name: "移除所选字段" }).click();
|
await prep.getByRole("button", { name: "移除所选字段" }).click();
|
||||||
await expect(prep).toContainText("122 字段");
|
await expect(prep).toContainText("122 字段");
|
||||||
await page.keyboard.press("Escape");
|
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
|
await page
|
||||||
.getByRole("navigation", { name: "主导航" })
|
.getByRole("navigation", { name: "主导航" })
|
||||||
.getByRole("button", { name: "特征工程", exact: true })
|
.getByRole("button", { name: "特征工程", exact: true })
|
||||||
@@ -111,7 +119,26 @@ test("在线字段跨页多选直接准备,本地只展示完整同步字段",
|
|||||||
.getByRole("navigation", { name: "主导航" })
|
.getByRole("navigation", { name: "主导航" })
|
||||||
.getByRole("button", { name: "字段目录", exact: true })
|
.getByRole("button", { name: "字段目录", exact: true })
|
||||||
.click();
|
.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接口" });
|
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 expect(online.getByText("TEST_FIN_001", { exact: true })).toBeVisible();
|
||||||
await online
|
await online
|
||||||
.getByRole("row")
|
.getByRole("row")
|
||||||
@@ -147,3 +174,84 @@ test("在线字段跨页多选直接准备,本地只展示完整同步字段",
|
|||||||
fullPage: true,
|
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<string, unknown>) => ({
|
||||||
|
...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);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user