2026-09-08 21:52:46 +08:00
|
|
|
import { useEffect, useState } from "react";
|
2026-09-10 14:55:23 +08:00
|
|
|
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";
|
|
|
|
|
|
2026-09-08 21:52:46 +08:00
|
|
|
export type ViewContent = {
|
|
|
|
|
name: string;
|
|
|
|
|
filters: Record<string, unknown>;
|
|
|
|
|
columns: string[];
|
|
|
|
|
};
|
|
|
|
|
type View = { id: string; version: number; content: ViewContent };
|
2026-09-10 14:55:23 +08:00
|
|
|
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)),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-08 21:52:46 +08:00
|
|
|
export function SavedViews({
|
|
|
|
|
filters,
|
|
|
|
|
columns,
|
2026-09-10 14:55:23 +08:00
|
|
|
submission,
|
|
|
|
|
selectedId,
|
|
|
|
|
onSelect,
|
|
|
|
|
onSubmissionChange,
|
2026-09-08 21:52:46 +08:00
|
|
|
onRestore,
|
2026-09-10 14:55:23 +08:00
|
|
|
suspended,
|
|
|
|
|
onOverlay,
|
2026-09-08 21:52:46 +08:00
|
|
|
}: {
|
|
|
|
|
filters: Record<string, unknown>;
|
|
|
|
|
columns: string[];
|
2026-09-10 14:55:23 +08:00
|
|
|
submission: Submission;
|
|
|
|
|
selectedId: string | null;
|
|
|
|
|
onSelect: (id: string | null) => void;
|
|
|
|
|
onSubmissionChange: (submission: string) => void;
|
2026-09-08 21:52:46 +08:00
|
|
|
onRestore: (view: ViewContent) => void;
|
2026-09-10 14:55:23 +08:00
|
|
|
suspended: boolean;
|
|
|
|
|
onOverlay: () => void;
|
2026-09-08 21:52:46 +08:00
|
|
|
}) {
|
|
|
|
|
const [items, setItems] = useState<View[]>([]);
|
2026-09-10 14:55:23 +08:00
|
|
|
const [editor, setEditor] = useState<Editor | null>(null);
|
|
|
|
|
const [deleting, setDeleting] = useState<View | null>(null);
|
2026-09-08 21:52:46 +08:00
|
|
|
const [name, setName] = useState("");
|
|
|
|
|
const [busy, setBusy] = useState(false);
|
2026-09-10 14:55:23 +08:00
|
|
|
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));
|
2026-09-08 21:52:46 +08:00
|
|
|
useEffect(() => {
|
|
|
|
|
const controller = new AbortController();
|
2026-09-10 14:55:23 +08:00
|
|
|
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);
|
|
|
|
|
});
|
2026-09-08 21:52:46 +08:00
|
|
|
return () => controller.abort();
|
2026-09-10 14:55:23 +08:00
|
|
|
}, [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) {
|
2026-09-08 21:52:46 +08:00
|
|
|
setBusy(true);
|
|
|
|
|
try {
|
2026-09-10 14:55:23 +08:00
|
|
|
const value = old
|
|
|
|
|
? await api<View>(`/research/assets/${old.id}`, {
|
2026-09-08 21:52:46 +08:00
|
|
|
method: "PUT",
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
kind: "view",
|
2026-09-10 14:55:23 +08:00
|
|
|
version: old.version,
|
2026-09-08 21:52:46 +08:00
|
|
|
content,
|
|
|
|
|
}),
|
|
|
|
|
})
|
|
|
|
|
: await post<View>("/research/assets", { kind: "view", content });
|
2026-09-10 14:55:23 +08:00
|
|
|
setItems((previous) =>
|
|
|
|
|
old
|
|
|
|
|
? previous.map((item) => (item.id === value.id ? value : item))
|
|
|
|
|
: [...previous, value],
|
|
|
|
|
);
|
|
|
|
|
if (restore) {
|
|
|
|
|
onSelect(value.id);
|
|
|
|
|
onRestore(value.content);
|
|
|
|
|
}
|
|
|
|
|
setEditor(null);
|
2026-09-08 21:52:46 +08:00
|
|
|
Toast.success("筛选视图已保存");
|
2026-09-10 14:55:23 +08:00
|
|
|
} 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);
|
2026-09-08 21:52:46 +08:00
|
|
|
} finally {
|
|
|
|
|
setBusy(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return (
|
2026-09-10 14:55:23 +08:00
|
|
|
<>
|
|
|
|
|
<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",
|
|
|
|
|
);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<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);
|
2026-09-08 21:52:46 +08:00
|
|
|
}}
|
2026-09-10 14:55:23 +08:00
|
|
|
confirmLoading={busy}
|
|
|
|
|
okText="删除视图"
|
|
|
|
|
okButtonProps={{ type: "danger", "aria-label": "删除视图" }}
|
|
|
|
|
onOk={() => void remove()}
|
2026-09-08 21:52:46 +08:00
|
|
|
>
|
2026-09-10 14:55:23 +08:00
|
|
|
<p>
|
|
|
|
|
删除“{deleting?.content.name}”后,该视图将从标签栏移除,Alpha
|
|
|
|
|
数据不受影响。
|
|
|
|
|
</p>
|
|
|
|
|
</Modal>
|
|
|
|
|
</>
|
2026-09-08 21:52:46 +08:00
|
|
|
);
|
|
|
|
|
}
|