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:
@@ -5,6 +5,17 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-29 — 桌面端:备份规则说明、交易记录表格翻页
|
||||
|
||||
### 变更
|
||||
|
||||
1. 备份恢复:说明文字收入「规则说明」;服务器备份列表一行一项,桌面最多显示约 5 行(可滚动)。
|
||||
2. 运行模式:企业微信说明收入「规则说明」。
|
||||
3. 交易记录(仅电脑端):表格列(序号/组名/方向/持仓状态/开平时间/时长/盈亏/平仓方式)+ 每页 10 行翻页;手机端列表不变。
|
||||
4. 手机端继续隐藏顶部资金条。
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-24 — 策略设置:固定方向开关
|
||||
|
||||
### 变更
|
||||
|
||||
@@ -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。推送 Markdown,带标识(OPEN/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,推送 Markdown,带标识(OPEN/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
@@ -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>
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user