Improve desktop settings and trades list UX.

Fold backup/WeCom help into rules; backup list one-per-line (5 visible); trades table with 10/page; hide funds bar on mobile.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-29 19:31:30 +08:00
parent 0ee1d8be5d
commit e1da830eac
4 changed files with 311 additions and 73 deletions
+11
View File
@@ -5,6 +5,17 @@
---
## 2026-07-29 — 桌面端:备份规则说明、交易记录表格翻页
### 变更
1. 备份恢复:说明文字收入「规则说明」;服务器备份列表一行一项,桌面最多显示约 5 行(可滚动)。
2. 运行模式:企业微信说明收入「规则说明」。
3. 交易记录(仅电脑端):表格列(序号/组名/方向/持仓状态/开平时间/时长/盈亏/平仓方式)+ 每页 10 行翻页;手机端列表不变。
4. 手机端继续隐藏顶部资金条。
---
## 2026-07-24 — 策略设置:固定方向开关
### 变更
+40 -40
View File
@@ -51,6 +51,7 @@ export default function SettingsPage() {
const [stratSub, setStratSub] = useState<StratSub>("position");
const [stratRulesOpen, setStratRulesOpen] = useState(false);
const [runtimeRulesOpen, setRuntimeRulesOpen] = useState(false);
const [backupRulesOpen, setBackupRulesOpen] = useState(false);
const [newUsername, setNewUsername] = useState(getUsername() || "admin");
const [currentPassword, setCurrentPassword] = useState("");
@@ -1000,18 +1001,6 @@ export default function SettingsPage() {
<section className="settings-section">
<h3></h3>
<p style={{ color: "var(--muted)", marginTop: 0 }}>
Webhook MarkdownOPEN/CLOSE/START/PAUSE/FAULT
<span className="mono">
{" "}
{wecomMeta?.venue_label ||
(runtime?.mode === "LIVE"
? `实盘·${(runtime.exchange || "okx").toUpperCase()}`
: "模拟盘")}
</span>
////·
</p>
<div className="settings-fields">
<div className="field">
<label htmlFor="wecomEn"></label>
@@ -1082,6 +1071,17 @@ export default function SettingsPage() {
= LIVE
Passphrase
</li>
<li>
Webhook MarkdownOPEN/CLOSE/START/PAUSE/FAULT
<span className="mono">
{wecomMeta?.venue_label ||
(runtime?.mode === "LIVE"
? `实盘·${(runtime.exchange || "okx").toUpperCase()}`
: "模拟盘")}
</span>
////·
</li>
<li>
Webhook
URL 5
@@ -1336,14 +1336,6 @@ export default function SettingsPage() {
<form onSubmit={onSaveBackupSettings}>
<section className="settings-section">
<h3></h3>
<p style={{ color: "var(--muted)", marginTop: 0 }}>
SQLite +{" "}
<span className="mono">.env</span>
<span className="mono">
{" "}
{backup?.backup_dir || "/root/eth_hedge_backups"}
</span>
</p>
<div className="settings-fields">
<div className="field">
<label htmlFor="bakAuto"></label>
@@ -1401,21 +1393,12 @@ export default function SettingsPage() {
<section className="settings-section">
<h3></h3>
{backup?.items && backup.items.length > 0 ? (
<div className="settings-fields">
<div className="backup-list" role="list">
{backup.items.map((it) => (
<div
key={it.name}
className="field"
style={{
display: "flex",
flexWrap: "wrap",
gap: 8,
alignItems: "center",
}}
>
<span className="mono" style={{ flex: "1 1 220px" }}>
<div key={it.name} className="backup-list-row" role="listitem">
<span className="mono backup-list-name">
{it.name}
<span style={{ color: "var(--muted)" }}>
<span className="backup-list-meta">
{" "}
· {(it.size_bytes / 1024).toFixed(1)} KB ·{" "}
{new Date(it.mtime_ms).toLocaleString()}
@@ -1439,13 +1422,6 @@ export default function SettingsPage() {
<section className="settings-section">
<h3></h3>
<p style={{ color: "var(--muted)", marginTop: 0 }}>
zip{" "}
<span className="mono">.env</span>
{" "}
<span className="mono">RESTORE</span>
</p>
<div className="settings-fields">
<div className="field">
<label htmlFor="bakFile"> zip</label>
@@ -1480,6 +1456,30 @@ export default function SettingsPage() {
</button>
</div>
</section>
<RulesFold
open={backupRulesOpen}
onToggle={() => setBackupRulesOpen((v) => !v)}
>
<ul className="settings-rules-list">
<li>
SQLite +{" "}
<span className="mono">.env</span>
<span className="mono">
{backup?.backup_dir || "/root/eth_hedge_backups"}
</span>
</li>
<li>
zip{" "}
<span className="mono">.env</span>
{" "}
<span className="mono">RESTORE</span>
</li>
</ul>
</RulesFold>
</div>
) : null}
</div>
+134 -33
View File
@@ -1,4 +1,4 @@
import { useEffect, useId, useState } from "react";
import { useEffect, useId, useMemo, useState } from "react";
import { apiFetch } from "../api/client";
import {
closeReasonZh,
@@ -59,6 +59,8 @@ type Fill = {
qty_eth: number;
};
const PAGE_SIZE = 10;
function pnlClass(n: number | null | undefined) {
if (n == null || Number.isNaN(n)) return "";
return n > 0 ? "pos-pnl-profit" : n < 0 ? "pos-pnl-loss" : "";
@@ -96,9 +98,14 @@ function fmtHold(ms: number | null | undefined) {
return `${s}`;
}
function groupPnl(g: Group) {
return g.net_pnl ?? g.pnl_summary?.net_pnl ?? g.realized_pnl;
}
export default function TradesPage() {
const titleId = useId();
const [groups, setGroups] = useState<Group[]>([]);
const [page, setPage] = useState(1);
const [selected, setSelected] = useState<string | null>(null);
const [selectedGroup, setSelectedGroup] = useState<Group | null>(null);
const [fills, setFills] = useState<Fill[]>([]);
@@ -109,7 +116,10 @@ export default function TradesPage() {
useEffect(() => {
apiFetch<{ groups: Group[] }>("/api/trades/groups")
.then((r) => setGroups(r.groups))
.then((r) => {
setGroups(r.groups);
setPage(1);
})
.catch((e) => setErr(e instanceof Error ? e.message : String(e)));
}, []);
@@ -127,6 +137,13 @@ export default function TradesPage() {
};
}, [selected]);
const totalPages = Math.max(1, Math.ceil(groups.length / PAGE_SIZE));
const safePage = Math.min(page, totalPages);
const pageGroups = useMemo(() => {
const start = (safePage - 1) * PAGE_SIZE;
return groups.slice(start, start + PAGE_SIZE);
}, [groups, safePage]);
function closeDetail() {
setSelected(null);
setSelectedGroup(null);
@@ -159,6 +176,40 @@ export default function TradesPage() {
}
}
function renderMobileRow(g: Group) {
const listPnl = groupPnl(g);
const openMs = g.hold_open_at_ms ?? g.open_at_ms;
const closeMs = g.hold_close_at_ms ?? g.close_at_ms;
return (
<button
key={g.group_id}
type="button"
className="trade-row"
onClick={() => openGroup(g.group_id)}
>
<div className="trade-row-main mono">
<span className="trade-row-id">{g.group_id}</span>
<span className="trade-row-meta">
{statusZh(g.status)} ·{" "}
{positionSidesZh(g.perp_side, g.option_side)}
</span>
<span className="trade-row-times">
{fmtTime(openMs)} · {fmtTime(closeMs)} · {" "}
{fmtHold(g.hold_ms)}
</span>
</div>
<div className={`trade-row-pnl mono ${pnlClass(listPnl)}`}>
<span> {fmt(listPnl)}</span>
{g.close_reason ? (
<span className="trade-row-reason">
{closeReasonZh(g.close_reason)}
</span>
) : null}
</div>
</button>
);
}
return (
<div className="trades-page">
<h2 style={{ marginTop: 0 }}></h2>
@@ -166,44 +217,94 @@ export default function TradesPage() {
</p>
{err ? <div className="err">{err}</div> : null}
<div className="card trade-list" style={{ marginBottom: 12 }}>
{/* 手机端:保持原卡片列表 */}
<div className="card trade-list trade-list-mobile" style={{ marginBottom: 12 }}>
{groups.length === 0 ? (
<p style={{ color: "var(--muted)" }}></p>
) : (
groups.map((g) => {
const listPnl =
g.net_pnl ?? g.pnl_summary?.net_pnl ?? g.realized_pnl;
const openMs = g.hold_open_at_ms ?? g.open_at_ms;
const closeMs = g.hold_close_at_ms ?? g.close_at_ms;
return (
groups.map((g) => renderMobileRow(g))
)}
</div>
{/* 电脑端:表格 + 翻页 */}
<div className="card trade-table-card trade-list-desktop">
{groups.length === 0 ? (
<p style={{ color: "var(--muted)" }}></p>
) : (
<>
<div className="trade-table-wrap">
<table className="trade-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{pageGroups.map((g, i) => {
const listPnl = groupPnl(g);
const openMs = g.hold_open_at_ms ?? g.open_at_ms;
const closeMs = g.hold_close_at_ms ?? g.close_at_ms;
const seq = (safePage - 1) * PAGE_SIZE + i + 1;
return (
<tr
key={g.group_id}
className="trade-table-row"
onClick={() => openGroup(g.group_id)}
>
<td className="mono">{seq}</td>
<td className="mono trade-table-id">{g.group_id}</td>
<td className="mono">
{positionSidesZh(g.perp_side, g.option_side)}
</td>
<td className="mono">{statusZh(g.status)}</td>
<td className="mono">{fmtTime(openMs)}</td>
<td className="mono">{fmtTime(closeMs)}</td>
<td className="mono">{fmtHold(g.hold_ms)}</td>
<td className={`mono ${pnlClass(listPnl)}`}>
{fmt(listPnl)}
</td>
<td className="mono">
{g.close_reason
? closeReasonZh(g.close_reason)
: "—"}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="trade-pager">
<button
key={g.group_id}
type="button"
className="trade-row"
onClick={() => openGroup(g.group_id)}
className="btn ghost"
disabled={safePage <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<div className="trade-row-main mono">
<span className="trade-row-id">{g.group_id}</span>
<span className="trade-row-meta">
{statusZh(g.status)} ·{" "}
{positionSidesZh(g.perp_side, g.option_side)}
</span>
<span className="trade-row-times">
{fmtTime(openMs)} · {fmtTime(closeMs)} · {" "}
{fmtHold(g.hold_ms)}
</span>
</div>
<div className={`trade-row-pnl mono ${pnlClass(listPnl)}`}>
<span> {fmt(listPnl)}</span>
{g.close_reason ? (
<span className="trade-row-reason">
{closeReasonZh(g.close_reason)}
</span>
) : null}
</div>
</button>
);
})
<span className="mono trade-pager-info">
{safePage} / {totalPages} · {groups.length}
</span>
<button
type="button"
className="btn ghost"
disabled={safePage >= totalPages}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
>
</button>
</div>
</>
)}
</div>
+126
View File
@@ -177,6 +177,12 @@ input {
margin: 0 0 12px;
}
@media (max-width: 900px) {
.funds-bar-shell {
display: none;
}
}
.funds-bar-inner {
width: 100%;
max-width: none;
@@ -766,6 +772,81 @@ input {
padding-bottom: 4px;
}
/* 电脑端表格;手机端保持卡片列表 */
.trade-list-desktop {
display: none;
}
.trade-list-mobile {
display: block;
}
.trade-table-card {
padding: 12px 14px 10px;
margin-bottom: 12px;
}
.trade-table-wrap {
width: 100%;
overflow-x: auto;
}
.trade-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.trade-table th,
.trade-table td {
padding: 10px 8px;
text-align: left;
border-bottom: 1px solid var(--line);
white-space: nowrap;
vertical-align: middle;
}
.trade-table th {
color: var(--muted);
font-weight: 600;
font-size: 12px;
}
.trade-table-row {
cursor: pointer;
}
.trade-table-row:hover td {
background: rgba(240, 185, 11, 0.06);
}
.trade-table-id {
font-weight: 600;
}
.trade-pager {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
padding: 12px 0 4px;
}
.trade-pager-info {
color: var(--muted);
font-size: 13px;
}
@media (min-width: 901px) {
.trade-list-desktop {
display: block;
}
.trade-list-mobile {
display: none;
}
}
.trade-row {
display: flex;
justify-content: space-between;
@@ -1306,6 +1387,51 @@ input {
gap: 10px;
}
.backup-list {
display: flex;
flex-direction: column;
gap: 0;
border: 1px solid var(--line);
border-radius: 8px;
overflow: hidden;
}
.backup-list-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border-bottom: 1px solid var(--line);
width: 100%;
box-sizing: border-box;
}
.backup-list-row:last-child {
border-bottom: none;
}
.backup-list-name {
flex: 1 1 auto;
min-width: 0;
word-break: break-all;
font-size: 13px;
line-height: 1.4;
}
.backup-list-meta {
color: var(--muted);
font-weight: 400;
}
/* 电脑端:一屏最多约 5 行,超出滚动 */
@media (min-width: 901px) {
.backup-list {
max-height: calc(5 * 48px);
overflow-y: auto;
}
}
@media (min-width: 700px) {
.settings-fields {
grid-template-columns: repeat(2, minmax(0, 1fr));