feat: unify backtest table controls and add refresh action
Deploy production / deploy (push) Successful in 53s

This commit is contained in:
yuxuanhui
2026-09-12 13:29:00 +08:00
parent ed349693db
commit 8206b36cd2
8 changed files with 453 additions and 142 deletions
@@ -0,0 +1,12 @@
# 实现与验证
Type: task
Status: ready-for-agent
- [x] 共用表格、工具栏、图标刷新
- [x] 服务端名称筛选与排序
- [x] 后端与浏览器回归、构建
## Comments
用户授权本地实现,未要求本次提交或推送。
本地实现完成。后端 20 项测试及 ruff 检查通过,前端构建通过;4 项浏览器用例通过,轮询慢请求保护修改后工具栏用例再次通过。验证了名称/来源筛选、排序回到第一页、手动刷新、列显隐、40px 行高、桌面与窄屏固定分页,以及原有草稿/预览/运行和 MCP 来源流程。使用模拟平台,无真实回测请求。构建保留已有依赖 eval 与包体积警告。
@@ -0,0 +1,2 @@
# 回测研究表格交互
运行记录与候选草稿复用目录工具栏和 WorkspaceTable:筛选浮层、列显隐持久化、服务端名称/时间排序、图标刷新与调度设置、固定分页及 25/50/100 条容量。保留自动刷新和现有候选/运行详情操作;手动刷新当前视图,避免旧请求覆盖新筛选。
+17 -3
View File
@@ -1,5 +1,7 @@
"""Authenticated adapters; every mutation is committed before the execution lane wakes.""" """Authenticated adapters; every mutation is committed before the execution lane wakes."""
from typing import Literal
from fastapi import APIRouter, Depends, Query, Request from fastapi import APIRouter, Depends, Query, Request
from ..business import Business from ..business import Business
@@ -48,9 +50,16 @@ async def configure(body: SchedulerInput, request: Request):
@router.get("/drafts") @router.get("/drafts")
async def drafts(request: Request, limit: int = Query(25, ge=1, le=100), offset: int = Query(0, ge=0)): async def drafts(
request: Request,
limit: int = Query(25, ge=1, le=100),
offset: int = Query(0, ge=0),
q: str = Query("", max_length=200),
sort: Literal["name", "updated_at"] = "updated_at",
direction: Literal["asc", "desc"] = "desc",
):
async with request.app.state.sessions() as db: async with request.app.state.sessions() as db:
return await Business(db).backtests.drafts(limit, offset) return await Business(db).backtests.drafts(limit, offset, q, sort, direction)
@router.post("/drafts", status_code=201) @router.post("/drafts", status_code=201)
@@ -108,9 +117,14 @@ async def runs(
source: str | None = Query(None, max_length=100), source: str | None = Query(None, max_length=100),
reference: str | None = Query(None, max_length=200), reference: str | None = Query(None, max_length=200),
research_id: str | None = Query(None, max_length=200), research_id: str | None = Query(None, max_length=200),
q: str = Query("", max_length=200),
sort: Literal["name", "created_at"] = "created_at",
direction: Literal["asc", "desc"] = "desc",
): ):
async with request.app.state.sessions() as db: async with request.app.state.sessions() as db:
return await Business(db).backtests.runs(limit, offset, source, reference, research_id) return await Business(db).backtests.runs(
limit, offset, source, reference, research_id, q, sort, direction
)
@router.get("/sources", response_model=list[str]) @router.get("/sources", response_model=list[str])
+15 -6
View File
@@ -151,11 +151,16 @@ class Backtests:
await self.db.flush() await self.db.flush()
return await self.draft(draft_id) return await self.draft(draft_id)
async def drafts(self, limit=25, offset=0): async def drafts(self, limit=25, offset=0, q="", sort="updated_at", direction="desc"):
query = select(BacktestDraft)
if q:
query = query.where(BacktestDraft.name.contains(q, autoescape=True))
column = {"name": BacktestDraft.name, "updated_at": BacktestDraft.updated_at}[sort]
order = column.asc() if direction == "asc" else column.desc()
rows = ( rows = (
await self.db.scalars( await self.db.scalars(
select(BacktestDraft) query
.order_by(BacktestDraft.updated_at.desc(), BacktestDraft.id) .order_by(order, BacktestDraft.id)
.limit(limit) .limit(limit)
.offset(offset) .offset(offset)
) )
@@ -173,7 +178,7 @@ class Backtests:
) )
for r in rows for r in rows
], ],
"total": await self.db.scalar(select(func.count()).select_from(BacktestDraft)), "total": await self.db.scalar(select(func.count()).select_from(query.subquery())),
"limit": limit, "limit": limit,
"offset": offset, "offset": offset,
} }
@@ -355,15 +360,19 @@ class Backtests:
await self.db.flush() await self.db.flush()
return await self.run(run.id) return await self.run(run.id)
async def runs(self, limit=25, offset=0, source=None, reference=None, research_id=None): async def runs(self, limit=25, offset=0, source=None, reference=None, research_id=None, q="", sort="created_at", direction="desc"):
query = select(BacktestRun) query = select(BacktestRun)
if q:
query = query.where(BacktestRun.name.contains(q, autoescape=True))
column = {"name": BacktestRun.name, "created_at": BacktestRun.created_at}[sort]
order = column.asc() if direction == "asc" else column.desc()
for key, value in (("kind", source), ("reference", reference), ("research_id", research_id)): for key, value in (("kind", source), ("reference", reference), ("research_id", research_id)):
if value: if value:
query = query.where(BacktestRun.source[key].as_string() == value) query = query.where(BacktestRun.source[key].as_string() == value)
total = await self.db.scalar(select(func.count()).select_from(query.subquery())) total = await self.db.scalar(select(func.count()).select_from(query.subquery()))
rows = ( rows = (
await self.db.scalars( await self.db.scalars(
query.order_by(BacktestRun.created_at.desc(), BacktestRun.id).limit(limit).offset(offset) query.order_by(order, BacktestRun.id).limit(limit).offset(offset)
) )
).all() ).all()
return { return {
+40
View File
@@ -0,0 +1,40 @@
"""List filtering and ordering happen before pagination, without platform execution."""
from tests.test_backtests import PREFIX, candidate, setup, start
async def test_list_sorting(app, logged_in):
await setup(app)
for name in ["sort-C", "sort-A", "sort-B", "unrelated"]:
body = {"name": name, "source": {"kind": "test"}, "candidates": [candidate()]}
assert (await logged_in.post(f"{PREFIX}/drafts", json=body)).status_code == 201
preview = await logged_in.post(f"{PREFIX}/previews", json={"inline": body})
assert preview.status_code == 201
await start(logged_in, preview.json(), key=name)
for path in ["runs", "drafts"]:
for direction, expected in [
("asc", ["sort-A", "sort-B", "sort-C"]),
("desc", ["sort-C", "sort-B", "sort-A"]),
]:
names = []
for offset in range(3):
r = await logged_in.get(
f"{PREFIX}/{path}",
params={
"q": "sort-",
"sort": "name",
"direction": direction,
"limit": 1,
"offset": offset,
},
)
assert r.status_code == 200
assert r.json()["total"] == 3
names.append(r.json()["items"][0]["name"])
assert names == expected
for params in [{"sort": "unknown"}, {"direction": "unknown"}]:
assert (await logged_in.get(f"{PREFIX}/{path}", params=params)).status_code == 422
assert (await logged_in.get(f"{PREFIX}/{path}", params={"q": "%"})).json()["total"] == 0
assert (await logged_in.get(f"{PREFIX}/runs", params={"q": "sort-", "source": "missing"})).json()[
"total"
] == 0
+226 -93
View File
@@ -1,3 +1,11 @@
import {
CatalogTableToolbar,
CatalogIconAction,
useCatalogColumns,
} from "../components/CatalogTableToolbar";
import { WorkspaceTable } from "../components/WorkspaceTable";
import { IconRefresh, IconSetting, IconPlus } from "@douyinfe/semi-icons";
import type { ColumnProps } from "@douyinfe/semi-ui-19/lib/es/table/interface";
import { SimulationSettingsEditor } from "../settings/SimulationSettingsEditor"; import { SimulationSettingsEditor } from "../settings/SimulationSettingsEditor";
import { useSettingsOptions } from "../settings/useSettingsOptions"; import { useSettingsOptions } from "../settings/useSettingsOptions";
import { settingsErrors } from "../settings/model"; import { settingsErrors } from "../settings/model";
@@ -6,7 +14,7 @@ import {
researchSelection, researchSelection,
} from "../preparations/ResearchDataInput"; } from "../preparations/ResearchDataInput";
import type { InputSnapshot } from "../research/workspaceTypes"; import type { InputSnapshot } from "../research/workspaceTypes";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { import {
Banner, Banner,
Button, Button,
@@ -19,7 +27,7 @@ import {
Tag, Tag,
TextArea, TextArea,
} from "@douyinfe/semi-ui-19"; } from "@douyinfe/semi-ui-19";
import { api, formatNumber, formatTime, post } from "../api"; import { api, formatNumber, formatTime, post, queryString } from "../api";
import type { PageContext, UIAction } from "../ai/types"; import type { PageContext, UIAction } from "../ai/types";
import { controlLabels, initialSettings, labels } from "./types"; import { controlLabels, initialSettings, labels } from "./types";
import type { import type {
@@ -82,6 +90,30 @@ export function BacktestPage({
}) { }) {
const [view, setView] = useState("runs"); const [view, setView] = useState("runs");
const [sourceFilter, setSourceFilter] = useState(""); const [sourceFilter, setSourceFilter] = useState("");
const [q, setQ] = useState("");
const [filterDraft, setFilterDraft] = useState({ q: "", source: "" });
const [pageSize, setPageSize] = useState(25);
const [loading, setLoading] = useState(false);
const requestSequence = useRef(0);
const [sorting, setSorting] = useState({
runs: { value: "created_at", direction: "desc" },
drafts: { value: "updated_at", direction: "desc" },
});
const sort = sorting[view === "runs" ? "runs" : "drafts"];
const runColumns = useCatalogColumns("backtest-run-columns", [
{ key: "name", label: "运行", required: true },
{ key: "source", label: "来源" },
{ key: "status", label: "状态" },
{ key: "saved", label: "已保存 / 总数" },
{ key: "failed", label: "失败" },
{ key: "created_at", label: "创建时间" },
]);
const draftColumns = useCatalogColumns("backtest-draft-columns", [
{ key: "name", label: "候选草稿", required: true },
{ key: "total", label: "候选数" },
{ key: "version", label: "版本" },
{ key: "updated_at", label: "更新时间" },
]);
const [sourceKinds, setSourceKinds] = useState<string[]>([]); const [sourceKinds, setSourceKinds] = useState<string[]>([]);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [runs, setRuns] = useState<Page<Run>>({ const [runs, setRuns] = useState<Page<Run>>({
@@ -139,30 +171,56 @@ export function BacktestPage({
const [itemDetail, setItemDetail] = useState<Item | null>(null); const [itemDetail, setItemDetail] = useState<Item | null>(null);
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
const [r, d, c, kinds] = await Promise.all([ const sequence = ++requestSequence.current;
api<Page<Run>>( setLoading(true);
`/backtests/runs?offset=${(page - 1) * 25}&source=${encodeURIComponent(sourceFilter)}`, try {
), const params = queryString({
api<Page<DraftSummary>>(`/backtests/drafts?offset=${(page - 1) * 25}`), q,
limit: pageSize,
offset: (page - 1) * pageSize,
sort: sort.value,
direction: sort.direction,
...(view === "runs" ? { source: sourceFilter } : {}),
});
const [data, c, kinds] = await Promise.all([
api<Page<Run> | Page<DraftSummary>>(`/backtests/${view}?${params}`),
api<Scheduler>("/backtests/config"), api<Scheduler>("/backtests/config"),
api<string[]>("/backtests/sources"), api<string[]>("/backtests/sources"),
]); ]);
setRuns(r); if (sequence !== requestSequence.current) return;
setDrafts(d); if (view === "runs") setRuns(data as Page<Run>);
else setDrafts(data as Page<DraftSummary>);
setPage((p) =>
Math.min(p, Math.max(1, Math.ceil(data.total / pageSize))),
);
setConfig(c); setConfig(c);
setSourceKinds(kinds); setSourceKinds(kinds);
}, [page, sourceFilter]); setError("");
} finally {
if (sequence === requestSequence.current) setLoading(false);
}
}, [page, pageSize, sourceFilter, q, view, sort.value, sort.direction]);
useEffect(() => { useEffect(() => {
if (!active) return; if (!active) return;
let alive = true; let alive = true;
const load = () => let pending = false;
refresh().catch((e) => { const load = () => {
// Slow responses must finish before the next polling request supersedes them.
if (pending) return;
pending = true;
void refresh()
.catch((e) => {
if (alive) setError(e.message); if (alive) setError(e.message);
})
.finally(() => {
pending = false;
}); });
};
void load(); void load();
const timer = window.setInterval(load, 3000); const timer = window.setInterval(load, 3000);
return () => { return () => {
alive = false; alive = false;
requestSequence.current++;
clearInterval(timer); clearInterval(timer);
}; };
}, [active, refresh, version]); }, [active, refresh, version]);
@@ -345,71 +403,7 @@ export function BacktestPage({
setRun(r); setRun(r);
await refresh(); await refresh();
} }
const drawerVisible = active && !suspended; const runTableColumns: ColumnProps<Run>[] = [
return (
<section className="backtest-page">
<div className="backtest-toolbar">
<Select
aria-label="回测视图"
value={view}
onChange={(v) => {
setView(String(v));
setPage(1);
}}
optionList={[
{ value: "runs", label: "运行记录" },
{ value: "drafts", label: "候选草稿" },
]}
/>
<span className="muted">REGULAR · FASTEXPR</span>
{view === "runs" && (
<Select
aria-label="回测研究来源筛选"
value={sourceFilter || undefined}
placeholder="全部研究来源"
showClear
optionList={sourceKinds.map((kind) => ({
value: kind,
label: sourceLabel(kind),
}))}
onChange={(value) => {
setSourceFilter(String(value ?? ""));
setPage(1);
}}
/>
)}
</div>
<div className="backtest-toolbar">
<Button
type="tertiary"
onClick={() => {
setSettingsDraft(config);
setRunId("");
setEditor(false);
}}
>
调度设置
</Button>
<Button theme="solid" onClick={newDraft}>
新建回测
</Button>
</div>
{error && !editor && !runId && !settingsDraft && (
<Banner type="danger" description={error} />
)}
{config?.blocked_reason && (
<Banner type="warning" description={config.blocked_reason} />
)}
{view === "runs" ? (
<Table<Run>
className="alpha-table"
size="small"
rowKey="backtest_run_id"
pagination={false}
scroll={{ x: 900, y: "100%" }}
dataSource={runs.items}
empty="尚无回测运行,准备候选并预览后启动。"
columns={[
{ {
title: "运行", title: "运行",
dataIndex: "name", dataIndex: "name",
@@ -438,8 +432,7 @@ export function BacktestPage({
{ {
title: "已保存 / 总数", title: "已保存 / 总数",
width: 120, width: 120,
render: (_, r) => render: (_, r) => `${r!.counts.persistence.saved || 0} / ${r!.total}`,
`${r!.counts.persistence.saved || 0} / ${r!.total}`,
}, },
{ {
title: "失败", title: "失败",
@@ -451,18 +444,8 @@ export function BacktestPage({
width: 180, width: 180,
render: (_, r) => formatTime(r!.created_at, timezone), render: (_, r) => formatTime(r!.created_at, timezone),
}, },
]} ];
/> const draftTableColumns: ColumnProps<DraftSummary>[] = [
) : (
<Table<DraftSummary>
className="alpha-table"
size="small"
rowKey="id"
pagination={false}
scroll={{ x: 700, y: "100%" }}
dataSource={drafts.items}
empty="尚无已保存草稿。"
columns={[
{ {
title: "候选草稿", title: "候选草稿",
dataIndex: "name", dataIndex: "name",
@@ -485,16 +468,166 @@ export function BacktestPage({
width: 180, width: 180,
render: (_, d) => formatTime(d!.updated_at, timezone), render: (_, d) => formatTime(d!.updated_at, timezone),
}, },
];
const drawerVisible = active && !suspended;
return (
<section className="backtest-page">
<CatalogTableToolbar
label="回测研究"
active={active && !suspended && !editor && !runId && !settingsDraft}
scope={
<Select
aria-label="回测视图"
value={view}
onChange={(v) => {
setView(String(v));
setPage(1);
setQ("");
setSourceFilter("");
setFilterDraft({ q: "", source: "" });
}}
optionList={[
{ value: "runs", label: "运行记录" },
{ value: "drafts", label: "候选草稿" },
]} ]}
/> />
}
columns={view === "runs" ? runColumns : draftColumns}
filterCount={
Number(Boolean(q)) + Number(view === "runs" && Boolean(sourceFilter))
}
filters={
<div className="catalog-filter-grid">
<label>
名称
<Input
aria-label="回测名称筛选"
value={filterDraft.q}
maxLength={200}
onChange={(q) => setFilterDraft({ ...filterDraft, q })}
/>
</label>
{view === "runs" && (
<label>
研究来源
<Select
aria-label="回测研究来源筛选"
value={filterDraft.source || undefined}
placeholder="全部研究来源"
showClear
optionList={sourceKinds.map((kind) => ({
value: kind,
label: sourceLabel(kind),
}))}
onChange={(v) =>
setFilterDraft({ ...filterDraft, source: String(v ?? "") })
}
/>
</label>
)} )}
<footer className="table-pagination"> </div>
}
onApply={() => {
setQ(filterDraft.q);
setSourceFilter(filterDraft.source);
setPage(1);
}}
onReset={() => {
setQ("");
setSourceFilter("");
setFilterDraft({ q: "", source: "" });
setPage(1);
}}
sort={{
...sort,
options: [
{ value: "name", label: "名称" },
{
value: view === "runs" ? "created_at" : "updated_at",
label: view === "runs" ? "创建时间" : "更新时间",
},
],
onChange: (value, direction) => {
setSorting((old) => ({ ...old, [view]: { value, direction } }));
setPage(1);
},
}}
actions={
<>
<CatalogIconAction
label="刷新回测"
icon={<IconRefresh aria-hidden="true" />}
loading={loading}
onClick={() => void perform(refresh)}
/>
<CatalogIconAction
label="调度设置"
icon={<IconSetting aria-hidden="true" />}
disabled={!config}
onClick={() => {
setSettingsDraft(config);
setRunId("");
setEditor(false);
}}
/>
<Button
theme="solid"
icon={<IconPlus aria-hidden="true" />}
onClick={newDraft}
>
新建回测
</Button>
</>
}
/>
{error && !editor && !runId && !settingsDraft && (
<Banner type="danger" description={error} />
)}
{config?.blocked_reason && (
<Banner type="warning" description={config.blocked_reason} />
)}
{view === "runs" ? (
<WorkspaceTable<Run>
fill
className="alpha-table"
size="small"
rowKey="backtest_run_id"
pagination={false}
scroll={{ x: 900, y: "100%" }}
dataSource={runs.items}
empty="尚无回测运行,准备候选并预览后启动。"
columns={runTableColumns.filter((_, i) =>
runColumns.visible.includes(runColumns.choices[i].key),
)}
/>
) : (
<WorkspaceTable<DraftSummary>
fill
className="alpha-table"
size="small"
rowKey="id"
pagination={false}
scroll={{ x: 700, y: "100%" }}
dataSource={drafts.items}
empty="尚无已保存草稿。"
columns={draftTableColumns.filter((_, i) =>
draftColumns.visible.includes(draftColumns.choices[i].key),
)}
/>
)}
<footer className="table-pagination workspace-table-footer">
<span className="muted"> <span className="muted">
共 {view === "runs" ? runs.total : drafts.total} 条 共 {view === "runs" ? runs.total : drafts.total} 条
</span> </span>
<Pagination <Pagination
currentPage={page} currentPage={page}
pageSize={25} pageSize={pageSize}
showSizeChanger
pageSizeOpts={[25, 50, 100]}
onPageSizeChange={(size) => {
setPageSize(size);
setPage(1);
}}
total={view === "runs" ? runs.total : drafts.total} total={view === "runs" ? runs.total : drafts.total}
onPageChange={setPage} onPageChange={setPage}
/> />
+1 -1
View File
@@ -4,7 +4,7 @@
height: 100%; height: 100%;
min-height: 0; min-height: 0;
min-width: 0; min-width: 0;
gap: 12px; gap: 0;
} }
.backtest-toolbar { .backtest-toolbar {
flex-shrink: 0; flex-shrink: 0;
+101
View File
@@ -188,9 +188,110 @@ test("MCP direct batch appears through its run link and source filter", async ({
.getByRole("heading") .getByRole("heading")
.getByRole("button") .getByRole("button")
.click(); .click();
await page.getByRole("button", { name: "筛选回测研究", exact: true }).click();
await page.getByText("全部研究来源", { exact: true }).click(); await page.getByText("全部研究来源", { exact: true }).click();
await page.getByRole("option", { name: /MCP 研究/ }).click(); await page.getByRole("option", { name: /MCP 研究/ }).click();
await page.getByRole("button", { name: "查询", exact: true }).click();
await expect( await expect(
page.getByRole("button", { name: "MCP 浏览器留痕", exact: true }), page.getByRole("button", { name: "MCP 浏览器留痕", exact: true }),
).toBeVisible(); ).toBeVisible();
}); });
test("backtest toolbar filters, sorts, refreshes and preserves compact layout", async ({
page,
}) => {
await login(page);
let revision = 1;
const requests: URL[] = [];
await page.route("**/api/v1/backtests/runs?*", async (route) => {
const url = new URL(route.request().url());
requests.push(url);
const offset = Number(url.searchParams.get("offset") || 0);
const limit = Number(url.searchParams.get("limit") || 25);
await route.fulfill({
json: {
total: 60,
limit,
offset,
items: Array.from({ length: Math.min(limit, 60 - offset) }, (_, i) => ({
backtest_run_id: `run-${offset + i}`,
name: `工具栏验收-${revision}-${offset + i} ` + "长名称".repeat(20),
source: { kind: "manual" },
status: "running",
total: 8,
counts: { persistence: { saved: 2 }, platform: { failed: 0 } },
created_at: "2026-09-12T10:00:00Z",
})),
},
});
});
await page.reload();
const table = page.locator(".backtest-page .workspace-table");
await expect(
table.getByRole("row").filter({ hasText: "工具栏验收-1-0" }),
).toBeVisible();
revision = 2;
await page.getByRole("button", { name: "刷新回测", exact: true }).click();
await expect(
table.getByRole("row").filter({ hasText: "工具栏验收-2-0" }),
).toBeVisible();
await page.getByRole("button", { name: "筛选回测研究", exact: true }).click();
await page.getByLabel("回测名称筛选").fill("test-filter");
expect(
requests.some((url) => url.searchParams.get("q") === "test-filter"),
).toBe(false);
await page.getByRole("button", { name: "查询", exact: true }).click();
await expect
.poll(() =>
requests.some((url) => url.searchParams.get("q") === "test-filter"),
)
.toBe(true);
await page.getByRole("button", { name: "显示列设置", exact: true }).click();
await page
.locator(".catalog-column-panel .semi-checkbox")
.filter({ hasText: "失败" })
.click();
await page.keyboard.press("Escape");
await expect(
table.getByRole("columnheader", { name: "失败", exact: true }),
).toHaveCount(0);
await page
.locator(".backtest-page .semi-page-item")
.filter({ hasText: /^2$/ })
.click();
await expect
.poll(() => requests.some((url) => url.searchParams.get("offset") === "25"))
.toBe(true);
await page.getByRole("button", { name: "排序设置", exact: true }).click();
await page.getByRole("combobox", { name: "排序字段", exact: true }).click();
await page
.getByRole("option")
.filter({ hasText: /^名称$/ })
.click();
await expect
.poll(() =>
requests.some(
(url) =>
url.searchParams.get("sort") === "name" &&
url.searchParams.get("offset") === "0",
),
)
.toBe(true);
await page.keyboard.press("Escape");
for (const width of [1440, 850]) {
await page.setViewportSize({ width, height: 900 });
const row = table.locator("tbody tr.semi-table-row").first();
await expect(row).toBeVisible();
expect(await row.evaluate((el) => el.getBoundingClientRect().height)).toBe(
40,
);
const footer = await page
.locator(".backtest-page .workspace-table-footer")
.boundingBox();
expect(footer!.y + footer!.height).toBeLessThanOrEqual(900);
expect(footer!.y + footer!.height).toBeGreaterThan(860);
await page.screenshot({
path: `../output/playwright/backtest-toolbar-${width}.png`,
});
}
});