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
+303 -72
View File
@@ -1,113 +1,344 @@
import { useEffect, useState } from "react";
import { Button, Input, Toast } from "@douyinfe/semi-ui-19";
import { api, post } from "../api";
import { ResearchSelect } from "./ResearchSelect";
import {
Button,
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 = {
name: string;
filters: Record<string, unknown>;
columns: string[];
};
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({
filters,
columns,
submission,
selectedId,
onSelect,
onSubmissionChange,
onRestore,
suspended,
onOverlay,
}: {
filters: Record<string, unknown>;
columns: string[];
submission: Submission;
selectedId: string | null;
onSelect: (id: string | null) => void;
onSubmissionChange: (submission: string) => void;
onRestore: (view: ViewContent) => void;
suspended: boolean;
onOverlay: () => void;
}) {
const [items, setItems] = useState<View[]>([]);
const [editor, setEditor] = useState<Editor | null>(null);
const [deleting, setDeleting] = useState<View | null>(null);
const [name, setName] = useState("");
const [selected, setSelected] = useState<string>();
const [busy, setBusy] = useState(false);
const [q, setQ] = useState("");
const [version, setVersion] = useState(0);
const [revision, setRevision] = 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(() => {
const controller = new AbortController();
api<{ items: View[] }>(
`/research/assets?kind=view&q=${encodeURIComponent(q)}&limit=100`,
{ signal: controller.signal },
)
.then((r) => setItems(r.items))
.catch((e) => {
if (!controller.signal.aborted) Toast.error(e.message);
});
async function load() {
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 },
);
views.push(...result.items);
if (views.length >= result.total || result.items.length === 0) break;
}
if (!controller.signal.aborted) setItems(views);
}
void load().catch((error: Error) => {
if (!controller.signal.aborted) Toast.error(error.message);
});
return () => controller.abort();
}, [q, version]);
async function save(replace: boolean) {
}, [revision]);
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);
try {
const old = items.find((i) => i.id === selected);
if (replace && !old) return;
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}`, {
const value = old
? await api<View>(`/research/assets/${old.id}`, {
method: "PUT",
body: JSON.stringify({
kind: "view",
version: old!.version,
version: old.version,
content,
}),
})
: await post<View>("/research/assets", { kind: "view", content });
setSelected(value.id);
setName(value.content.name);
setVersion((n) => n + 1);
setItems((previous) =>
old
? previous.map((item) => (item.id === value.id ? value : item))
: [...previous, value],
);
if (restore) {
onSelect(value.id);
onRestore(value.content);
}
setEditor(null);
Toast.success("筛选视图已保存");
} catch (e) {
Toast.error((e as Error).message);
} catch (error) {
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 {
setBusy(false);
}
}
return (
<div className="research-saved-views inline-actions">
<ResearchSelect
label="保存的 Alpha 视图"
placeholder="搜索并恢复研究队列"
filter
remote
onSearch={setQ}
value={selected}
optionList={items.map((i) => ({
value: i.id,
label: `${i.content.name} · v${i.version}`,
}))}
onChange={(v) => {
const item = items.find((i) => i.id === v);
if (item) {
setSelected(item.id);
setName(item.content.name);
onRestore(item.content);
}
<>
<div className="alpha-views-header">
<Tabs
className="alpha-view-tabs"
type="card"
collapsible="auto"
activeKey={selectedId ?? submission}
onChange={(key) => {
const view = items.find((item) => item.id === key);
if (view) {
onSelect(view.id);
onRestore(view.content);
} else onSubmissionChange(key);
}}
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="有未保存的更改"
/>
)}
<span
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
>
<Dropdown
trigger="click"
position="bottomLeft"
stopPropagation
render={
<Dropdown.Menu>
<Dropdown.Item
icon={<IconSave aria-hidden="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>
</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",
);
}}
/>
<Input
aria-label="视图名称"
placeholder="视图名称"
value={name}
onChange={setName}
style={{ width: 160 }}
/>
<Button size="small" disabled={busy} onClick={() => void save(false)}>
保存为新视图
</Button>
<Button
size="small"
disabled={busy || !selected}
onClick={() => void save(true)}
>
更新当前视图
</Button>
</div>
<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>
</>
);
}