feat: 调整 Alpha 视图标签与筛选工具栏交互
Deploy production / deploy (push) Successful in 36s

This commit is contained in:
yuxuanhui
2026-09-10 14:55:23 +08:00
parent 6ce33eb572
commit 1e67f43012
9 changed files with 995 additions and 414 deletions
@@ -0,0 +1,18 @@
# Alpha 视图标签与筛选弹层
Type: task
Status: ready-for-agent
按 ../spec.md 实现并验证。
## Comments
开始实现。使用现有 Semi 组件和研究素材接口;主代理负责修改及最终验证。
完成实现。生产构建、类型检查、Prettier 与 diff 空白检查通过。受影响的 6 项浏览器回归全部通过(分批运行):Alpha 管理、工作空间同步/导出、批量标签/列/分页、保存视图与研究评估、AI 筛选上下文及多宽度布局。
通过 Playwright CLI 在独立模拟服务进一步核验:搜索+地区+Turnover 组合筛选、筛选数量与导出参数、视图创建/重命名/更新/复制/删除及恢复、排序更改标记;390px 宽度无横向溢出,长筛选面板内滚动且底部操作固定可见。浏览器最后一次导航后无控制台错误。
截图:output/playwright/alpha-view-toolbar.png、alpha-filter-panel.png、alpha-view-menu.png、alpha-filter-mobile.png、alpha-views-mobile.png。所有验证使用隔离临时数据库和模拟平台,未修改真实 Alpha 或部署。构建仅保留依赖已有的 lottie eval 与包体积警告。
Completion: completed
+9
View File
@@ -0,0 +1,9 @@
# Alpha 视图与筛选工具栏
按用户截图调整交互,保留 Semi Design 2.103.0。
- 待提交、已提交及保存的视图统一为标签栏;新增视图通过命名弹窗完成,标签菜单支持更新、重命名、复制及删除。
- 工具栏集中筛选、排序、列设置、导入导出、同步及表格批量操作。搜索仅在筛选弹层内显示。
- 保留全部现有筛选字段及查询/重置行为;应用后关闭弹层,入口显示已应用数量;切换视图清理草稿与行选择。
- 复用研究素材视图接口及版本锁,不改后端数据模型。没有后端支持的保护视图/设为首项不在范围内。
- 本地生产构建、受影响的既有浏览器回归,以及隔离模拟环境中的视图保存恢复、菜单、筛选弹层、窄屏视觉验收。
+190
View File
@@ -0,0 +1,190 @@
.alpha-views-header {
display: flex;
align-items: center;
flex-shrink: 0;
min-width: 0;
background: var(--semi-color-fill-0);
border-bottom: 1px solid var(--semi-color-border);
}
.alpha-view-tabs {
flex: 1;
min-width: 0;
}
.alpha-view-tabs > .semi-tabs-content {
display: none;
}
.alpha-view-tabs .semi-tabs-bar {
border-bottom: 0;
}
.alpha-view-label {
display: inline-flex;
align-items: center;
gap: 8px;
height: 24px;
}
.alpha-view-name {
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.alpha-view-modified {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--semi-color-primary);
flex-shrink: 0;
}
.alpha-add-view {
flex-shrink: 0;
margin: 0 8px;
}
.alpha-toolbar {
gap: 8px 16px;
padding: 10px 12px;
border-bottom: 1px solid var(--semi-color-border);
}
.alpha-view-tools,
.alpha-table-actions {
gap: 4px;
}
.alpha-table-actions {
margin-left: auto;
}
.alpha-filter-count {
margin-left: 6px;
font-variant-numeric: tabular-nums;
}
.alpha-selection-actions {
padding: 8px 12px;
flex-shrink: 0;
background: var(--semi-color-primary-light-default);
border-bottom: 1px solid var(--semi-color-border);
}
.alpha-selection-actions .selection-text {
margin-left: 0;
margin-right: 8px;
}
.alpha-filter-panel {
display: flex;
flex-direction: column;
width: min(640px, calc(100vw - 32px));
max-height: min(700px, calc(100dvh - 180px));
color: var(--semi-color-text-0);
}
.alpha-filter-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 20px 20px 16px;
flex-shrink: 0;
}
.alpha-filter-header p {
margin-top: 4px;
}
.alpha-filter-body {
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
padding: 0 20px 20px;
}
.alpha-filter-fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.alpha-filter-fields > label {
margin: 0;
gap: 6px;
}
.alpha-filter-fields .semi-select {
width: 100%;
}
.alpha-filter-search {
grid-column: 1 / -1;
}
.alpha-filter-panel .advanced-filters {
margin-top: 20px;
}
.alpha-filter-panel .advanced-filters > summary {
font-size: 13px;
color: var(--semi-color-text-1);
}
.alpha-filter-panel .advanced-filters .alpha-filter-fields {
padding-top: 16px;
}
.alpha-filter-panel .range-inputs {
min-width: 0;
}
.alpha-filter-panel .range-inputs .semi-input-wrapper {
min-width: 0;
}
.alpha-filter-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 20px;
border-top: 1px solid var(--semi-color-border);
flex-shrink: 0;
}
.alpha-filter-footer > :first-child {
margin-right: auto;
}
.alpha-sort-panel {
display: grid;
grid-template-columns: minmax(0, 1fr) 96px;
gap: 12px;
padding: 16px;
width: min(360px, calc(100vw - 32px));
}
.alpha-sort-panel > h2 {
grid-column: 1 / -1;
}
.alpha-sort-panel .semi-select {
width: 100%;
}
.alpha-table-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px 16px;
}
.alpha-table-meta .alpha-last-sync {
margin: 0;
padding: 0;
}
.alpha-table-meta .count-pill {
margin: 0;
}
@media (max-width: 760px) {
.alpha-toolbar {
padding: 8px;
}
.alpha-table-actions {
margin-left: 0;
}
.alpha-view-name {
max-width: 120px;
}
.alpha-add-view {
margin: 0 4px;
}
.alpha-table-meta .alpha-last-sync {
display: none;
}
.alpha-filter-header {
padding: 16px;
}
.alpha-filter-body {
padding: 0 16px 16px;
}
.alpha-filter-fields {
gap: 12px;
}
}
@media (max-width: 420px) {
.alpha-filter-fields {
grid-template-columns: minmax(0, 1fr);
}
}
+289 -174
View File
@@ -11,12 +11,22 @@ import {
Pagination, Pagination,
Select, Select,
Table, Table,
Tabs,
Tag, Tag,
TextArea, TextArea,
Toast, Toast,
Typography, Typography,
} from "@douyinfe/semi-ui-19"; } from "@douyinfe/semi-ui-19";
import {
IconFilter,
IconSetting,
IconSort,
IconSearch,
IconClose,
IconDownload,
IconUpload,
IconRefresh,
} from "@douyinfe/semi-icons";
import "./AlphaPage.css";
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 { import {
api, api,
@@ -144,6 +154,8 @@ export function AlphaPage({
const [facets, setFacets] = useState<Facets>({}); const [facets, setFacets] = useState<Facets>({});
const [draft, setDraft] = useState<Record<string, string>>({}); const [draft, setDraft] = useState<Record<string, string>>({});
const [filters, setFilters] = useState<Record<string, string>>({}); const [filters, setFilters] = useState<Record<string, string>>({});
const [filterOpen, setFilterOpen] = useState(false);
const [selectedViewId, setSelectedViewId] = useState<string | null>(null);
const [submission, setSubmission] = useState<Submission>("UNSUBMITTED"); const [submission, setSubmission] = useState<Submission>("UNSUBMITTED");
const [syncingScope, setSyncingScope] = useState<Submission | null>(null); const [syncingScope, setSyncingScope] = useState<Submission | null>(null);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
@@ -238,6 +250,8 @@ export function AlphaPage({
setDetailId(action.alpha_id); setDetailId(action.alpha_id);
onOverlay(); onOverlay();
} else { } else {
setSelectedViewId(null);
setFilterOpen(false);
const { const {
sort: nextSort, sort: nextSort,
direction: nextDirection, direction: nextDirection,
@@ -296,15 +310,20 @@ export function AlphaPage({
})); }));
} }
function applyFilters() { function applyFilters() {
setFilterOpen(false);
setFilters({ ...draft }); setFilters({ ...draft });
setPage(1); setPage(1);
} }
function clearFilters() { function clearFilters() {
setFilterOpen(false);
setDraft({}); setDraft({});
setFilters({}); setFilters({});
setPage(1); setPage(1);
} }
function changeSubmission(value: string) { function changeSubmission(value: string) {
setSelectedViewId(null);
setFilterOpen(false);
setDirection("desc");
setSubmission(value as Submission); setSubmission(value as Submission);
setDraft({}); setDraft({});
setFilters({}); setFilters({});
@@ -610,58 +629,43 @@ export function AlphaPage({
const connected = const connected =
account?.connection_status === "connected" || account?.connection_status === "connected" ||
account?.connection_status === "expired"; account?.connection_status === "expired";
return ( const filterPanel = (
<>
{!connected && (
<Banner
type="info"
description={
<span>
连接 WorldQuant 后即可同步平台数据。
<button className="text-link" onClick={onAccount}>
前往个人信息
</button>
</span>
}
/>
)}
{error && <Banner type="danger" description={error} />}
<section className="library-panel" ref={tablePanel}>
<div className="alpha-tabs-header">
<Tabs
className="alpha-submission-tabs"
activeKey={submission}
onChange={changeSubmission}
tabList={[
{ itemKey: "UNSUBMITTED", tab: "待提交" },
{ itemKey: "SUBMITTED", tab: "已提交" },
]}
/>
<span className="alpha-last-sync">
最近数据更新{" "}
<span>
{formatTime(
typeof facets.last_sync === "string" ? facets.last_sync : null,
account?.timezone,
)}
</span>
</span>
</div>
<form <form
className="filter-panel" className="alpha-filter-panel"
aria-label="Alpha 筛选条件"
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
applyFilters(); applyFilters();
}} }}
> >
<div className="primary-filters"> <header className="alpha-filter-header">
<div>
<h2>筛选 Alpha</h2>
<p className="muted">设置搜索和筛选条件,查询后应用到当前视图。</p>
</div>
<Button
icon={<IconClose aria-hidden="true" />}
theme="borderless"
type="tertiary"
aria-label="关闭筛选"
onClick={() => setFilterOpen(false)}
/>
</header>
<div className="alpha-filter-body">
<div className="alpha-filter-fields">
<label className="alpha-filter-search">
搜索
<Input <Input
prefix={<IconSearch aria-hidden="true" />}
aria-label="搜索 Alpha" aria-label="搜索 Alpha"
placeholder="搜索 ID、名称或表达式" placeholder="搜索 ID、名称或表达式"
value={draft.q ?? ""} value={draft.q ?? ""}
onChange={(v) => updateDraft("q", v)} onChange={(v) => updateDraft("q", v)}
showClear showClear
/> />
</label>
<label>
地区
<Select <Select
aria-label="地区" aria-label="地区"
placeholder="全部地区" placeholder="全部地区"
@@ -670,6 +674,9 @@ export function AlphaPage({
optionList={options("region")} optionList={options("region")}
onChange={(v) => updateDraft("region", v)} onChange={(v) => updateDraft("region", v)}
/> />
</label>
<label>
平台状态
<Select <Select
aria-label="平台状态" aria-label="平台状态"
placeholder="全部平台状态" placeholder="全部平台状态"
@@ -678,6 +685,9 @@ export function AlphaPage({
optionList={options("status")} optionList={options("status")}
onChange={(v) => updateDraft("status", v)} onChange={(v) => updateDraft("status", v)}
/> />
</label>
<label>
研究状态
<Select <Select
aria-label="研究状态筛选" aria-label="研究状态筛选"
placeholder="全部研究状态" placeholder="全部研究状态"
@@ -686,6 +696,9 @@ export function AlphaPage({
optionList={stateOptions} optionList={stateOptions}
onChange={(v) => updateDraft("research_state", v)} onChange={(v) => updateDraft("research_state", v)}
/> />
</label>
<label>
检查结果
<Select <Select
aria-label="检查结果筛选" aria-label="检查结果筛选"
placeholder="全部检查结果" placeholder="全部检查结果"
@@ -697,6 +710,9 @@ export function AlphaPage({
}))} }))}
onChange={(v) => updateDraft("check_type", v)} onChange={(v) => updateDraft("check_type", v)}
/> />
</label>
<label>
自定义标签
<Select <Select
aria-label="自定义标签筛选" aria-label="自定义标签筛选"
showClear showClear
@@ -706,13 +722,11 @@ export function AlphaPage({
placeholder="全部标签" placeholder="全部标签"
onChange={(v) => updateDraft("tag", v)} onChange={(v) => updateDraft("tag", v)}
/> />
</label>
</div> </div>
<details className="advanced-filters"> <details className="advanced-filters">
<summary> <summary>更多筛选</summary>
更多筛选{" "} <div className="alpha-filter-fields">
{activeFilterCount ? `· ${activeFilterCount} 项已应用` : ""}
</summary>
<div className="advanced-grid">
<label> <label>
研究来源 研究来源
<Select <Select
@@ -831,19 +845,45 @@ export function AlphaPage({
检查结果仅汇总已同步的平台检查;预检通过不代表可以提交,活动资格可用自定义标签记录。 检查结果仅汇总已同步的平台检查;预检通过不代表可以提交,活动资格可用自定义标签记录。
</p> </p>
</details> </details>
<div className="inline-actions filter-actions"> </div>
<Button htmlType="submit" theme="solid"> <footer className="alpha-filter-footer">
查询
</Button>
<Button type="tertiary" theme="borderless" onClick={clearFilters}> <Button type="tertiary" theme="borderless" onClick={clearFilters}>
重置 重置
</Button> </Button>
</div> <Button htmlType="submit" theme="solid">
查询
</Button>
</footer>
</form> </form>
);
return (
<>
{!connected && (
<Banner
type="info"
description={
<span>
连接 WorldQuant 后即可同步平台数据。
<button className="text-link" onClick={onAccount}>
前往个人信息
</button>
</span>
}
/>
)}
{error && <Banner type="danger" description={error} />}
<section className="library-panel" ref={tablePanel}>
<SavedViews <SavedViews
submission={submission}
selectedId={selectedViewId}
onSelect={setSelectedViewId}
onSubmissionChange={changeSubmission}
suspended={overlaySuspended || !active}
onOverlay={onOverlay}
filters={{ ...filters, submission, sort, direction, limit: pageSize }} filters={{ ...filters, submission, sort, direction, limit: pageSize }}
columns={visibleColumns} columns={visibleColumns}
onRestore={(view) => { onRestore={(view) => {
setFilterOpen(false);
const { const {
submission: savedSubmission, submission: savedSubmission,
sort: savedSort, sort: savedSort,
@@ -861,11 +901,9 @@ export function AlphaPage({
setDraft(values); setDraft(values);
setPage(1); setPage(1);
setSelected([]); setSelected([]);
if ( setSubmission(
savedSubmission === "SUBMITTED" || savedSubmission === "SUBMITTED" ? "SUBMITTED" : "UNSUBMITTED",
savedSubmission === "UNSUBMITTED" );
)
setSubmission(savedSubmission);
setSort(String(savedSort || "date_created")); setSort(String(savedSort || "date_created"));
setDirection(String(savedDirection || "desc")); setDirection(String(savedDirection || "desc"));
if (typeof limit === "number") setPageSize(limit); if (typeof limit === "number") setPageSize(limit);
@@ -879,22 +917,183 @@ export function AlphaPage({
localStorage.setItem("alpha-columns", JSON.stringify(next)); localStorage.setItem("alpha-columns", JSON.stringify(next));
}} }}
/> />
<div
<div className="table-toolbar"> className="table-toolbar alpha-toolbar"
<div> aria-label="Alpha 表格工具栏"
<strong> >
{submission === "UNSUBMITTED" ? "待提交" : "已提交"} Alpha <div className="inline-actions alpha-view-tools">
</strong> <Popover
<span className="count-pill">{data.total.toLocaleString()}</span> trigger="custom"
{selected.length > 0 && ( position="bottomLeft"
<span className="selection-text"> visible={filterOpen && active && !overlaySuspended}
已选择 {selected.length} 条 onClickOutSide={() => setFilterOpen(false)}
onEscKeyDown={() => setFilterOpen(false)}
content={filterPanel}
>
<Button
icon={<IconFilter aria-hidden="true" />}
type={filterOpen || activeFilterCount ? "primary" : "tertiary"}
theme={filterOpen || activeFilterCount ? "light" : "borderless"}
aria-label="筛选 Alpha"
onClick={() => {
setFilterOpen((open) => !open);
onOverlay();
}}
>
筛选
{activeFilterCount > 0 && (
<span className="alpha-filter-count">
{activeFilterCount}
</span> </span>
)} )}
</Button>
</Popover>
<Popover
trigger="click"
position="bottomRight"
content={
<div className="columns-picker">
<strong>显示列</strong>
<CheckboxGroup
direction="vertical"
value={visibleColumns}
options={Object.entries(columnLabels).map(
([value, label]) => ({
value,
label,
disabled: value === "name",
}),
)}
onChange={(values) => {
const next = values.map(String);
setVisibleColumns(next);
localStorage.setItem(
"alpha-columns",
JSON.stringify(next),
);
}}
/>
</div>
}
>
<Button
icon={<IconSetting aria-hidden="true" />}
theme="borderless"
type="tertiary"
aria-label="显示列设置"
>
列设置
</Button>
</Popover>
<Popover
trigger="click"
position="bottomLeft"
content={
<div className="alpha-sort-panel">
<h2>排序</h2>
<Select
aria-label="排序字段"
size="small"
value={sort}
optionList={[
{ value: "date_created", label: "创建时间" },
{ value: "date_submitted", label: "提交时间" },
{ value: "synced_at", label: "同步时间" },
...Object.entries(metricLabels).map(([value, label]) => ({
value,
label,
})),
{ value: "name", label: "名称" },
]}
onChange={(v) => {
setSort(String(v));
setPage(1);
}}
/>
<Select
aria-label="排序方向"
size="small"
value={direction}
optionList={[
{ value: "desc", label: "降序" },
{ value: "asc", label: "升序" },
]}
onChange={(v) => {
setDirection(String(v));
setPage(1);
}}
/>
</div>
}
>
<Button
icon={<IconSort aria-hidden="true" />}
theme="borderless"
type="tertiary"
aria-label="排序设置"
>
排序
</Button>
</Popover>
</div>
<div className="inline-actions alpha-table-actions">
<a href={`/api/v1/alphas/export?${query}`}>
<Button
icon={<IconDownload aria-hidden="true" />}
theme="borderless"
type="tertiary"
>
导出
</Button>
</a>
<Button
icon={<IconUpload aria-hidden="true" />}
theme="borderless"
type="tertiary"
onClick={() => {
setImporting(true);
onOverlay();
}}
>
导入 Alpha ID
</Button>
<Button
icon={<IconRefresh aria-hidden="true" />}
theme="solid"
disabled={!connected}
onClick={() => {
setSyncingScope(submission);
onOverlay();
}}
>
按天同步
</Button>
{submission === "SUBMITTED" && (
<Button
size="small"
loading={busy === "pnl_backfill"}
disabled={!connected || Boolean(busy)}
title="补取全部本地已提交 Alpha 缺失的 PnL,不受筛选和分页影响"
onClick={() => void newTask("pnl_backfill")}
>
检查pnl
</Button>
)}
{submission === "SUBMITTED" && (
<Button
size="small"
loading={busy === "full_sync"}
disabled={!connected}
onClick={() => void newTask("full_sync")}
>
全量同步已提交
</Button>
)}
</div>
</div> </div>
<div className="inline-actions">
{selected.length > 0 && ( {selected.length > 0 && (
<> <div className="alpha-selection-actions inline-actions">
<span className="selection-text">已选择 {selected.length} 条</span>
<Button <Button
type="tertiary" type="tertiary"
size="small" size="small"
@@ -929,118 +1128,17 @@ export function AlphaPage({
> >
刷新选中 刷新选中
</Button> </Button>
</>
)}
<Select
aria-label="排序字段"
size="small"
value={sort}
optionList={[
{ value: "date_created", label: "创建时间" },
{ value: "date_submitted", label: "提交时间" },
{ value: "synced_at", label: "同步时间" },
...Object.entries(metricLabels).map(([value, label]) => ({
value,
label,
})),
{ value: "name", label: "名称" },
]}
onChange={(v) => {
setSort(String(v));
setPage(1);
}}
/>
<Select
aria-label="排序方向"
size="small"
value={direction}
optionList={[
{ value: "desc", label: "降序" },
{ value: "asc", label: "升序" },
]}
onChange={(v) => {
setDirection(String(v));
setPage(1);
}}
/>
<a href={`/api/v1/alphas/export?${query}`}>
<Button type="tertiary" size="small">
导出
</Button>
</a>
<Button <Button
size="small" size="small"
theme="borderless"
type="tertiary" type="tertiary"
onClick={() => setImporting(true)} onClick={() => setSelected([])}
> >
导入 Alpha ID 取消选择
</Button> </Button>
<Button
size="small"
theme="solid"
disabled={!connected}
onClick={() => {
setSyncingScope(submission);
onOverlay();
}}
>
按天同步
</Button>
{submission === "SUBMITTED" && (
<Button
size="small"
loading={busy === "pnl_backfill"}
disabled={!connected || Boolean(busy)}
title="补取全部本地已提交 Alpha 缺失的 PnL,不受筛选和分页影响"
onClick={() => void newTask("pnl_backfill")}
>
检查pnl
</Button>
)}
{submission === "SUBMITTED" && (
<Button
size="small"
loading={busy === "full_sync"}
disabled={!connected}
onClick={() => void newTask("full_sync")}
>
全量同步已提交
</Button>
)}
<Popover
trigger="click"
position="bottomRight"
content={
<div className="columns-picker">
<strong>显示列</strong>
<CheckboxGroup
direction="vertical"
value={visibleColumns}
options={Object.entries(columnLabels).map(
([value, label]) => ({
value,
label,
disabled: value === "name",
}),
)}
onChange={(values) => {
const next = values.map(String);
setVisibleColumns(next);
localStorage.setItem(
"alpha-columns",
JSON.stringify(next),
);
}}
/>
</div>
}
>
<Button type="tertiary" aria-label="显示列设置" size="small">
列设置
</Button>
</Popover>
</div>
</div> </div>
)}
<Table<Alpha> <Table<Alpha>
columns={columns} columns={columns}
dataSource={data.items} dataSource={data.items}
@@ -1075,7 +1173,24 @@ export function AlphaPage({
} }
/> />
<footer className="table-pagination" aria-label="Alpha 分页"> <footer className="table-pagination" aria-label="Alpha 分页">
<span className="muted">共 {data.total.toLocaleString()} 条</span> <div className="alpha-table-meta">
<span className="muted">
{submission === "UNSUBMITTED" ? "待提交" : "已提交"} Alpha{" "}
<span className="count-pill">{data.total.toLocaleString()}</span>{" "}
条
</span>
<span className="alpha-last-sync">
最近数据更新{" "}
<span>
{formatTime(
typeof facets.last_sync === "string"
? facets.last_sync
: null,
account?.timezone,
)}
</span>
</span>
</div>
<Pagination <Pagination
size={compact ? "small" : "default"} size={compact ? "small" : "default"}
currentPage={page} currentPage={page}
+297 -66
View File
@@ -1,113 +1,344 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Button, Input, Toast } from "@douyinfe/semi-ui-19"; import {
import { api, post } from "../api"; Button,
import { ResearchSelect } from "./ResearchSelect"; Dropdown,
Input,
Modal,
Tabs,
Toast,
} from "@douyinfe/semi-ui-19";
import {
IconCopy,
IconDelete,
IconEdit,
IconMore,
IconPlus,
IconSave,
IconList,
} from "@douyinfe/semi-icons";
import { api, ApiError, post } from "../api";
import type { Submission } from "../types";
export type ViewContent = { export type ViewContent = {
name: string; name: string;
filters: Record<string, unknown>; filters: Record<string, unknown>;
columns: string[]; columns: string[];
}; };
type View = { id: string; version: number; content: ViewContent }; type View = { id: string; version: number; content: ViewContent };
type Editor = {
mode: "create" | "rename" | "copy";
content: ViewContent;
view?: View;
};
// The API normalizes numbers and adds offset; neither changes the saved view.
function filterSignature(filters: Record<string, unknown>) {
return JSON.stringify(
Object.entries(filters)
.filter(
([key, value]) => key !== "offset" && value !== "" && value != null,
)
.map(([key, value]) => [key, String(value)])
.sort(([a], [b]) => a.localeCompare(b)),
);
}
export function SavedViews({ export function SavedViews({
filters, filters,
columns, columns,
submission,
selectedId,
onSelect,
onSubmissionChange,
onRestore, onRestore,
suspended,
onOverlay,
}: { }: {
filters: Record<string, unknown>; filters: Record<string, unknown>;
columns: string[]; columns: string[];
submission: Submission;
selectedId: string | null;
onSelect: (id: string | null) => void;
onSubmissionChange: (submission: string) => void;
onRestore: (view: ViewContent) => void; onRestore: (view: ViewContent) => void;
suspended: boolean;
onOverlay: () => void;
}) { }) {
const [items, setItems] = useState<View[]>([]); const [items, setItems] = useState<View[]>([]);
const [editor, setEditor] = useState<Editor | null>(null);
const [deleting, setDeleting] = useState<View | null>(null);
const [name, setName] = useState(""); const [name, setName] = useState("");
const [selected, setSelected] = useState<string>();
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [q, setQ] = useState(""); const [revision, setRevision] = useState(0);
const [version, setVersion] = useState(0); const current = items.find((item) => item.id === selectedId);
const modified =
current &&
(filterSignature(filters) !== filterSignature(current.content.filters) ||
JSON.stringify(columns) !== JSON.stringify(current.content.columns));
useEffect(() => { useEffect(() => {
const controller = new AbortController(); const controller = new AbortController();
api<{ items: View[] }>( async function load() {
`/research/assets?kind=view&q=${encodeURIComponent(q)}&limit=100`, const views: View[] = [];
// Tabs replace remote search, so retain access to views beyond the first page.
while (!controller.signal.aborted) {
const result = await api<{ items: View[]; total: number }>(
`/research/assets?kind=view&limit=100&offset=${views.length}`,
{ signal: controller.signal }, { signal: controller.signal },
) );
.then((r) => setItems(r.items)) views.push(...result.items);
.catch((e) => { if (views.length >= result.total || result.items.length === 0) break;
if (!controller.signal.aborted) Toast.error(e.message); }
if (!controller.signal.aborted) setItems(views);
}
void load().catch((error: Error) => {
if (!controller.signal.aborted) Toast.error(error.message);
}); });
return () => controller.abort(); return () => controller.abort();
}, [q, version]); }, [revision]);
async function save(replace: boolean) {
function openEditor(mode: Editor["mode"], view?: View) {
const content = view?.content ?? { name: "", filters, columns };
setEditor({ mode, content, view });
setName(
mode === "copy" ? `${content.name} 副本`.slice(0, 200) : content.name,
);
onOverlay();
}
function reportError(error: unknown) {
Toast.error((error as Error).message);
if (error instanceof ApiError && error.status === 409) {
setRevision((value) => value + 1);
setEditor(null);
setDeleting(null);
}
}
async function save(content: ViewContent, old?: View, restore = false) {
setBusy(true); setBusy(true);
try { try {
const old = items.find((i) => i.id === selected); const value = old
if (replace && !old) return; ? await api<View>(`/research/assets/${old.id}`, {
const content = {
name: name.trim() || old?.content.name || "研究队列",
filters: Object.fromEntries(
Object.entries(filters).filter(
([, v]) => v !== "" && v !== null && v !== undefined,
),
),
columns,
};
const value = replace
? await api<View>(`/research/assets/${old!.id}`, {
method: "PUT", method: "PUT",
body: JSON.stringify({ body: JSON.stringify({
kind: "view", kind: "view",
version: old!.version, version: old.version,
content, content,
}), }),
}) })
: await post<View>("/research/assets", { kind: "view", content }); : await post<View>("/research/assets", { kind: "view", content });
setSelected(value.id); setItems((previous) =>
setName(value.content.name); old
setVersion((n) => n + 1); ? previous.map((item) => (item.id === value.id ? value : item))
: [...previous, value],
);
if (restore) {
onSelect(value.id);
onRestore(value.content);
}
setEditor(null);
Toast.success("筛选视图已保存"); Toast.success("筛选视图已保存");
} catch (e) { } catch (error) {
Toast.error((e as Error).message); reportError(error);
} finally {
setBusy(false);
}
}
async function remove() {
if (!deleting) return;
setBusy(true);
try {
await api(`/research/assets/${deleting.id}?version=${deleting.version}`, {
method: "DELETE",
});
setItems((previous) =>
previous.filter((item) => item.id !== deleting.id),
);
if (selectedId === deleting.id) onSubmissionChange(submission);
setDeleting(null);
Toast.success("视图已删除");
} catch (error) {
reportError(error);
} finally { } finally {
setBusy(false); setBusy(false);
} }
} }
return ( return (
<div className="research-saved-views inline-actions"> <>
<ResearchSelect <div className="alpha-views-header">
label="保存的 Alpha 视图" <Tabs
placeholder="搜索并恢复研究队列" className="alpha-view-tabs"
filter type="card"
remote collapsible="auto"
onSearch={setQ} activeKey={selectedId ?? submission}
value={selected} onChange={(key) => {
optionList={items.map((i) => ({ const view = items.find((item) => item.id === key);
value: i.id, if (view) {
label: `${i.content.name} · v${i.version}`, onSelect(view.id);
}))} onRestore(view.content);
onChange={(v) => { } else onSubmissionChange(key);
const item = items.find((i) => i.id === v);
if (item) {
setSelected(item.id);
setName(item.content.name);
onRestore(item.content);
}
}} }}
tabList={[
{
itemKey: "UNSUBMITTED",
tab: "待提交",
icon: <IconList aria-hidden="true" />,
},
{
itemKey: "SUBMITTED",
tab: "已提交",
icon: <IconList aria-hidden="true" />,
},
...items.map((view) => ({
itemKey: view.id,
icon: <IconList aria-hidden="true" />,
tab: (
<span className="alpha-view-label">
<span className="alpha-view-name" title={view.content.name}>
{view.content.name}
</span>
{view.id === selectedId && modified && (
<span
className="alpha-view-modified"
title="视图有未保存的更改"
aria-label="有未保存的更改"
/> />
<Input )}
aria-label="视图名称" <span
placeholder="视图名称" onClick={(event) => event.stopPropagation()}
value={name} onKeyDown={(event) => event.stopPropagation()}
onChange={setName} >
style={{ width: 160 }} <Dropdown
/> trigger="click"
<Button size="small" disabled={busy} onClick={() => void save(false)}> position="bottomLeft"
保存为新视图 stopPropagation
</Button> render={
<Button <Dropdown.Menu>
size="small" <Dropdown.Item
disabled={busy || !selected} icon={<IconSave aria-hidden="true" />}
onClick={() => void save(true)} disabled={
busy || view.id !== selectedId || !modified
}
onClick={() =>
void save(
{ name: view.content.name, filters, columns },
view,
)
}
> >
更新当前视图 更新当前视图
</Dropdown.Item>
<Dropdown.Item
icon={<IconEdit aria-hidden="true" />}
disabled={busy}
onClick={() => openEditor("rename", view)}
>
重命名
</Dropdown.Item>
<Dropdown.Item
icon={<IconCopy aria-hidden="true" />}
disabled={busy}
onClick={() => openEditor("copy", view)}
>
复制视图
</Dropdown.Item>
<Dropdown.Divider />
<Dropdown.Item
icon={<IconDelete aria-hidden="true" />}
type="danger"
disabled={busy}
onClick={() => {
setDeleting(view);
onOverlay();
}}
>
删除视图
</Dropdown.Item>
</Dropdown.Menu>
}
>
<Button
icon={<IconMore aria-hidden="true" />}
size="small"
theme="borderless"
type="tertiary"
aria-label={`${view.content.name} 视图操作`}
/>
</Dropdown>
</span>
</span>
),
})),
]}
/>
<Button
className="alpha-add-view"
icon={<IconPlus aria-hidden="true" />}
theme="borderless"
type="tertiary"
disabled={busy}
aria-label="保存为新视图"
title="将当前筛选、排序和显示列保存为新视图"
onClick={() => openEditor("create")}
>
新建视图
</Button> </Button>
</div> </div>
<Modal
title={
editor?.mode === "rename"
? "重命名视图"
: editor?.mode === "copy"
? "复制视图"
: "保存为新视图"
}
visible={Boolean(editor) && !suspended}
onCancel={() => {
if (!busy) setEditor(null);
}}
confirmLoading={busy}
okText="保存视图"
okButtonProps={{ disabled: !name.trim(), "aria-label": "保存视图" }}
onOk={() => {
if (editor && name.trim())
void save(
{ ...editor.content, name: name.trim() },
editor.mode === "rename" ? editor.view : undefined,
editor.mode !== "rename",
);
}}
>
<label>
视图名称
<Input
aria-label="视图名称"
placeholder="例如:低换手候选"
value={name}
onChange={setName}
maxLength={200}
autoFocus
/>
</label>
{editor?.mode !== "rename" && (
<p className="muted">
保存筛选条件、排序、每页条数和显示列,之后可从标签栏一键切换。
</p>
)}
</Modal>
<Modal
title="删除视图"
visible={Boolean(deleting) && !suspended}
onCancel={() => {
if (!busy) setDeleting(null);
}}
confirmLoading={busy}
okText="删除视图"
okButtonProps={{ type: "danger", "aria-label": "删除视图" }}
onOk={() => void remove()}
>
<p>
删除“{deleting?.content.name}”后,该视图将从标签栏移除,Alpha
数据不受影响。
</p>
</Modal>
</>
); );
} }
+3
View File
@@ -82,6 +82,7 @@ test("model settings, text stream, contextual query, confirmation and preserved
await page.locator(".settings-drawer .semi-sidesheet-close").click(); await page.locator(".settings-drawer .semi-sidesheet-close").click();
await configure(page); await configure(page);
await page.getByRole("button", { name: "Alpha 管理", exact: true }).click(); await page.getByRole("button", { name: "Alpha 管理", exact: true }).click();
await page.getByRole("button", { name: "筛选 Alpha", exact: true }).click();
await page.getByRole("textbox", { name: "搜索 Alpha" }).fill("TEST0001"); await page.getByRole("textbox", { name: "搜索 Alpha" }).fill("TEST0001");
await page.getByRole("button", { name: "查询", exact: true }).click(); await page.getByRole("button", { name: "查询", exact: true }).click();
// Global tools sit below drawers; open the assistant before the Alpha detail. // Global tools sit below drawers; open the assistant before the Alpha detail.
@@ -138,9 +139,11 @@ test("model settings, text stream, contextual query, confirmation and preserved
chat.getByText("筛选换手率不超过 15% 的 Alpha", { exact: true }), chat.getByText("筛选换手率不超过 15% 的 Alpha", { exact: true }),
).toBeVisible(); ).toBeVisible();
await chat.getByRole("button", { name: "应用到列表" }).click(); await chat.getByRole("button", { name: "应用到列表" }).click();
await page.getByRole("button", { name: "筛选 Alpha", exact: true }).click();
await expect(page.getByRole("textbox", { name: "搜索 Alpha" })).toHaveValue( await expect(page.getByRole("textbox", { name: "搜索 Alpha" })).toHaveValue(
"", "",
); );
await page.getByRole("button", { name: "关闭筛选", exact: true }).click();
await page.screenshot({ await page.screenshot({
path: "../output/playwright/ai-query.png", path: "../output/playwright/ai-query.png",
fullPage: true, fullPage: true,
+9 -1
View File
@@ -81,6 +81,7 @@ test("submission tabs, day selection, local correlation and reload", async ({
expect(complete.processed).toBe(103); expect(complete.processed).toBe(103);
await expect(page.locator(".job-panel")).toContainText("2025-02-02"); await expect(page.locator(".job-panel")).toContainText("2025-02-02");
await page.keyboard.press("Escape"); await page.keyboard.press("Escape");
await page.getByRole("button", { name: "筛选 Alpha", exact: true }).click();
await page.getByRole("textbox", { name: "搜索 Alpha" }).fill("TEST0003"); await page.getByRole("textbox", { name: "搜索 Alpha" }).fill("TEST0003");
await page.getByRole("button", { name: "查询", exact: true }).click(); await page.getByRole("button", { name: "查询", exact: true }).click();
await expect(page.locator(".alpha-link")).toHaveCount(1); await expect(page.locator(".alpha-link")).toHaveCount(1);
@@ -96,11 +97,15 @@ test("submission tabs, day selection, local correlation and reload", async ({
await page.getByRole("tab", { name: "待提交", exact: true }).hover(); await page.getByRole("tab", { name: "待提交", exact: true }).hover();
await page.getByRole("tab", { name: "待提交", exact: true }).click(); await page.getByRole("tab", { name: "待提交", exact: true }).click();
await page.getByRole("button", { name: "筛选 Alpha", exact: true }).click();
await expect(page.getByRole("textbox", { name: "搜索 Alpha" })).toHaveValue( await expect(page.getByRole("textbox", { name: "搜索 Alpha" })).toHaveValue(
"", "",
); );
await page.getByRole("button", { name: "关闭筛选", exact: true }).click();
await page.getByRole("button", { name: "按天同步", exact: true }).click(); await page.getByRole("button", { name: "按天同步", exact: true }).click();
await expect(page.getByRole("dialog")).toContainText("创建日期"); await expect(
page.getByRole("dialog", { name: "按天同步待提交 Alpha" }),
).toContainText("创建日期");
await page.getByLabel("同步开始日期").fill("2025-01-01"); await page.getByLabel("同步开始日期").fill("2025-01-01");
await page.getByLabel("同步结束日期").fill("2025-01-01"); await page.getByLabel("同步结束日期").fill("2025-01-01");
await page.getByRole("button", { name: "开始同步", exact: true }).click(); await page.getByRole("button", { name: "开始同步", exact: true }).click();
@@ -115,6 +120,7 @@ test("submission tabs, day selection, local correlation and reload", async ({
) )
.toBe(413); .toBe(413);
await page.keyboard.press("Escape"); await page.keyboard.press("Escape");
await page.getByRole("button", { name: "筛选 Alpha", exact: true }).click();
await page.getByRole("textbox", { name: "搜索 Alpha" }).fill("TEST0004"); await page.getByRole("textbox", { name: "搜索 Alpha" }).fill("TEST0004");
await page.getByRole("button", { name: "查询", exact: true }).click(); await page.getByRole("button", { name: "查询", exact: true }).click();
await page.route("**/api/v1/alphas/TEST0004", async (route) => { await page.route("**/api/v1/alphas/TEST0004", async (route) => {
@@ -147,6 +153,7 @@ test("submission tabs, day selection, local correlation and reload", async ({
detail.is_metrics.unavailable = null; detail.is_metrics.unavailable = null;
await route.fulfill({ response, json: detail }); await route.fulfill({ response, json: detail });
}); });
await expect(page.locator(".alpha-link")).toHaveCount(1);
await page.locator(".alpha-link").click(); await page.locator(".alpha-link").click();
const sheet = page.locator(".alpha-detail"); const sheet = page.locator(".alpha-detail");
await expect(sheet.getByRole("tab")).toHaveText([ await expect(sheet.getByRole("tab")).toHaveText([
@@ -290,6 +297,7 @@ test("submission tabs, day selection, local correlation and reload", async ({
}); });
await page.keyboard.press("Escape"); await page.keyboard.press("Escape");
await page.reload(); await page.reload();
await page.getByRole("button", { name: "筛选 Alpha", exact: true }).click();
await page.getByRole("textbox", { name: "搜索 Alpha" }).fill("TEST0004"); await page.getByRole("textbox", { name: "搜索 Alpha" }).fill("TEST0004");
await page.getByRole("button", { name: "查询", exact: true }).click(); await page.getByRole("button", { name: "查询", exact: true }).click();
await expect(page.locator(".alpha-table:visible")).toContainText( await expect(page.locator(".alpha-table:visible")).toContainText(
+3 -1
View File
@@ -100,8 +100,9 @@ test("feature conversion, saved views and immutable evaluations", async ({
]); ]);
await page.keyboard.press("Escape"); await page.keyboard.press("Escape");
await page.getByRole("button", { name: "Alpha 管理", exact: true }).click(); await page.getByRole("button", { name: "Alpha 管理", exact: true }).click();
await page.getByLabel("视图名称", { exact: true }).fill("浏览器常用队列");
await page.getByRole("button", { name: "保存为新视图" }).click(); await page.getByRole("button", { name: "保存为新视图" }).click();
await page.getByLabel("视图名称", { exact: true }).fill("浏览器常用队列");
await page.getByRole("button", { name: "保存视图", exact: true }).click();
await expect(page.getByText("筛选视图已保存", { exact: true })).toBeVisible(); await expect(page.getByText("筛选视图已保存", { exact: true })).toBeVisible();
const views = await ( const views = await (
await page.request.get("/api/v1/research/assets?kind=view&q=浏览器常用队列") await page.request.get("/api/v1/research/assets?kind=view&q=浏览器常用队列")
@@ -137,6 +138,7 @@ test("feature conversion, saved views and immutable evaluations", async ({
) )
.toBe("completed"); .toBe("completed");
await page.reload(); await page.reload();
await page.getByRole("button", { name: "筛选 Alpha", exact: true }).click();
await page.getByRole("textbox", { name: "搜索 Alpha" }).fill("TEST0001"); await page.getByRole("textbox", { name: "搜索 Alpha" }).fill("TEST0001");
await page.getByRole("button", { name: "查询", exact: true }).click(); await page.getByRole("button", { name: "查询", exact: true }).click();
await expect(page.locator(".alpha-link")).toHaveCount(1); await expect(page.locator(".alpha-link")).toHaveCount(1);
+6 -1
View File
@@ -84,11 +84,12 @@ test("account → scoped sync → research → resync → PnL → filtered expor
await page.keyboard.press("Escape"); await page.keyboard.press("Escape");
await expect(page.locator(".job-panel")).not.toBeVisible(); await expect(page.locator(".job-panel")).not.toBeVisible();
await page.getByRole("tab", { name: "待提交", exact: true }).click(); await page.getByRole("tab", { name: "待提交", exact: true }).click();
await expect(page.locator(".table-toolbar .count-pill")).toHaveText("413"); await expect(page.locator(".alpha-table-meta .count-pill")).toHaveText("413");
await page.screenshot({ await page.screenshot({
path: "../output/playwright/alpha-library.png", path: "../output/playwright/alpha-library.png",
fullPage: true, fullPage: true,
}); });
await page.getByRole("button", { name: "筛选 Alpha", exact: true }).click();
await page.getByRole("textbox", { name: "搜索 Alpha" }).fill("TEST0007"); await page.getByRole("textbox", { name: "搜索 Alpha" }).fill("TEST0007");
await page.getByRole("button", { name: "查询", exact: true }).click(); await page.getByRole("button", { name: "查询", exact: true }).click();
await expect(page.locator(".alpha-link")).toHaveCount(1); await expect(page.locator(".alpha-link")).toHaveCount(1);
@@ -141,6 +142,7 @@ test("account → scoped sync → research → resync → PnL → filtered expor
) )
.toBe("completed"); .toBe("completed");
await page.reload(); await page.reload();
await page.getByRole("button", { name: "筛选 Alpha", exact: true }).click();
await page.getByRole("textbox", { name: "搜索 Alpha" }).fill("TEST0007"); await page.getByRole("textbox", { name: "搜索 Alpha" }).fill("TEST0007");
await page.getByRole("button", { name: "查询", exact: true }).click(); await page.getByRole("button", { name: "查询", exact: true }).click();
await expect(page.locator(".alpha-link")).toHaveCount(1); await expect(page.locator(".alpha-link")).toHaveCount(1);
@@ -153,6 +155,7 @@ test("account → scoped sync → research → resync → PnL → filtered expor
page.getByRole("checkbox", { name: "收藏此 Alpha" }), page.getByRole("checkbox", { name: "收藏此 Alpha" }),
).toBeChecked(); ).toBeChecked();
await page.keyboard.press("Escape"); await page.keyboard.press("Escape");
await page.getByRole("button", { name: "筛选 Alpha", exact: true }).click();
await page.getByRole("button", { name: "重置", exact: true }).click(); await page.getByRole("button", { name: "重置", exact: true }).click();
await expect(page.locator(".alpha-link")).toHaveCount(25); await expect(page.locator(".alpha-link")).toHaveCount(25);
const downloading = page.waitForEvent("download"); const downloading = page.waitForEvent("download");
@@ -217,6 +220,7 @@ test("batch tags, column visibility, server pagination and saved preferences", a
) )
.toBe(620); .toBe(620);
} }
await page.getByRole("button", { name: "筛选 Alpha", exact: true }).click();
await page.getByRole("textbox", { name: "搜索 Alpha" }).fill("TEST006"); await page.getByRole("textbox", { name: "搜索 Alpha" }).fill("TEST006");
await page.getByRole("button", { name: "查询", exact: true }).click(); await page.getByRole("button", { name: "查询", exact: true }).click();
await expect(page.locator(".alpha-link")).toHaveCount(6); await expect(page.locator(".alpha-link")).toHaveCount(6);
@@ -258,6 +262,7 @@ test("batch tags, column visibility, server pagination and saved preferences", a
page.getByRole("columnheader", { name: "自定义标签", exact: true }), page.getByRole("columnheader", { name: "自定义标签", exact: true }),
).toBeVisible(); ).toBeVisible();
await page.getByRole("button", { name: "显示列设置" }).click(); await page.getByRole("button", { name: "显示列设置" }).click();
await page.getByRole("button", { name: "筛选 Alpha", exact: true }).click();
await page.getByRole("button", { name: "重置", exact: true }).click(); await page.getByRole("button", { name: "重置", exact: true }).click();
await expect(page.locator(".alpha-link")).toHaveCount(25); await expect(page.locator(".alpha-link")).toHaveCount(25);
const firstId = await page.locator(".alpha-link").first().innerText(); const firstId = await page.locator(".alpha-link").first().innerText();