@whyshu/ba 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +93 -2
- package/cordis.patch.yml +6 -0
- package/dist/client.js +559 -0
- package/icon.svg +6 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +51 -4
- package/src/binance.mjs +385 -0
- package/src/compat.mjs +305 -0
- package/src/index.js +115 -0
package/README.md
CHANGED
|
@@ -1,3 +1,94 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @whyshu/ba · DSH 币安账户面板
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
DSH(DeepSeek Harness)插件:在**左侧栏**增加一个「币安账户」入口,点击后由**主视图**展示:
|
|
4
|
+
|
|
5
|
+
- 现货资产:非零余额、可用/锁定、现价、USDT 估值、总估值
|
|
6
|
+
- 合约持仓:钱包余额、未实现盈亏、可用余额,以及每个非零持仓(方向/数量/开仓价/标记价/未实现盈亏/杠杆)
|
|
7
|
+
- 行情看板:自选币对最新价、24h 涨跌、24h 成交额(无需 API Key)
|
|
8
|
+
|
|
9
|
+
全部**只读**。API Key 只保存在 dsh 宿主进程(Node 侧),浏览器拿不到。
|
|
10
|
+
|
|
11
|
+
## 安装
|
|
12
|
+
|
|
13
|
+
**从 npm 安装(推荐;npx dsh / 其他机器)**——在 dsh 的 Plugins 面板安装包名:
|
|
14
|
+
|
|
15
|
+
```text
|
|
16
|
+
@whyshu/ba
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
或使用 `plugin_manager`:
|
|
20
|
+
|
|
21
|
+
```text
|
|
22
|
+
install_bundle target = @whyshu/ba
|
|
23
|
+
registry = https://registry.npmjs.org
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
**本地开发安装**(改 `src/` / `dist/` 即时生效,宿主侧改动仍需重启 dsh):
|
|
27
|
+
|
|
28
|
+
```text
|
|
29
|
+
install_bundle target = /Users/Chester/Whyshu/whyshu-ba
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
安装后左侧栏出现图标;点击进入面板。首次打开若未配置 Key,会显示配置引导。
|
|
33
|
+
|
|
34
|
+
## dsh 兼容范围(双层门禁)
|
|
35
|
+
|
|
36
|
+
支持 `dsh >=0.2.0-0 <0.3.0-0`(`package.json#engines.dsh`,含 0.2.x 的 rc 预发布)。
|
|
37
|
+
|
|
38
|
+
1. **宿主侧**:dsh 在导入插件代码前校验 `engines.dsh`;不匹配的 bundle 会被 skip(宿主与客户端 bundle 都不会挂载),只在 stderr 打一行原因,不阻断 `dsh web` 启动。
|
|
39
|
+
2. **插件侧(兜底)**:`apply()` 顶部再自行评估版本;未适配时只打一行
|
|
40
|
+
`[@whyshu/ba] 插件已自我禁用(dsh x.y.z 未适配)` 后 `return`,**不注册任何宿主 API、不抛**。
|
|
41
|
+
排障可用环境变量 `WHYSHU_BA_DSH_VERSION` 显式指定宿主版本。
|
|
42
|
+
|
|
43
|
+
## 配置 API Key
|
|
44
|
+
|
|
45
|
+
1. 币安 → 账户 → **API 管理** → 新建 API,权限只勾 **「启用读取」**(务必禁用提现、交易)。
|
|
46
|
+
2. 建议同时开启 **IP 白名单**。
|
|
47
|
+
3. 写入本机文件(权限 600,不入仓库、不进日志):
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
mkdir -p ~/.dsh/argent && chmod 700 ~/.dsh/argent
|
|
51
|
+
cat > ~/.dsh/argent/ba.env <<'EOF'
|
|
52
|
+
BINANCE_API_KEY=你的只读 Key
|
|
53
|
+
BINANCE_API_SECRET=你的 Secret
|
|
54
|
+
BINANCE_PROXY=http://127.0.0.1:7890
|
|
55
|
+
BINANCE_SYMBOLS=BTCUSDT,ETHUSDT,SOLUSDT,BNBUSDT
|
|
56
|
+
EOF
|
|
57
|
+
chmod 600 ~/.dsh/argent/ba.env
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
- `BINANCE_PROXY` 可选;本机直连 `api.binance.com` 不通时必须配(本机 ClashX 为 `http://127.0.0.1:7890`)。
|
|
61
|
+
- `BINANCE_SYMBOLS` 可选,决定行情看板的币对。
|
|
62
|
+
- 插件**每次请求都重读该文件**:改 Key / 代理后点面板里的「刷新」即可,无需重启 dsh。
|
|
63
|
+
|
|
64
|
+
## 端点(宿主侧,只读)
|
|
65
|
+
|
|
66
|
+
| 端点 | 说明 |
|
|
67
|
+
| --- | --- |
|
|
68
|
+
| `GET /api/whyshu-ba/health` | 版本、是否已配置 Key、是否启用代理、自选币对 |
|
|
69
|
+
| `GET /api/whyshu-ba/ping` | 客户端 bundle 自检信标 |
|
|
70
|
+
| `GET /api/whyshu-ba/overview` | 面板总览(行情 + 现货 + 合约;各段独立失败) |
|
|
71
|
+
|
|
72
|
+
## 文件
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
src/index.js 宿主插件:注册 /api/whyshu-ba/* 端点
|
|
76
|
+
src/binance.mjs 币安只读数据层:签名、代理隧道、现货/合约/行情
|
|
77
|
+
dist/client.js 浏览器 bundle(手写维护,无需构建):main + sidebar.panellist
|
|
78
|
+
locale/*.json 插件管理器的标题/描述(zh/en)
|
|
79
|
+
icon.svg 插件图标
|
|
80
|
+
cordis.patch.yml bundle patch(insert id=whyshu-ba)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## 排查
|
|
84
|
+
|
|
85
|
+
- **面板报「请求超时 / fetch failed」**:直连被墙 → 配 `BINANCE_PROXY`。
|
|
86
|
+
- **报 401 / -2015**:Key 无效、未开现货读取权限,或本机时间偏差过大。
|
|
87
|
+
- **面板显示「还没有配置币安 API Key」**:宿主没读到 `~/.dsh/argent/ba.env`,检查路径与权限。
|
|
88
|
+
- **左侧栏没有图标**:确认 bundle 已安装并启用;`/api/whyshu-ba/health` 的 `clientPingAt` 为空说明浏览器端 bundle 没加载,刷新页面。
|
|
89
|
+
|
|
90
|
+
## 安全
|
|
91
|
+
|
|
92
|
+
- 只读:不调用下单 / 划转 / 提现接口。
|
|
93
|
+
- Key 仅在宿主进程读取与签名;`/overview` 响应只含计算结果,不含 Key/Secret。
|
|
94
|
+
- 不要把 `ba.env` 提交进任何仓库。
|
package/cordis.patch.yml
ADDED
package/dist/client.js
ADDED
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* whyshu-ba · 客户端(dsh browser bundle 格式,手写维护,无需构建)
|
|
3
|
+
*
|
|
4
|
+
* 形态:左侧栏一个面板入口(sidebar.panellist, id=binance),
|
|
5
|
+
* 点击后由主视图(main slot, key=binance)渲染币安面板:
|
|
6
|
+
* 现货资产 / 合约持仓 / 行情看板 —— 全部只读。
|
|
7
|
+
*
|
|
8
|
+
* 数据来自宿主端点 `/api/whyshu-ba/*`(API Key 只在宿主进程,浏览器拿不到)。
|
|
9
|
+
* id 必须是整包名(与 package.json#name 一致),否则客户端拒绝加载。
|
|
10
|
+
*/
|
|
11
|
+
window.__ModuleLoader__.load({
|
|
12
|
+
id: '@whyshu/ba',
|
|
13
|
+
factory(require) {
|
|
14
|
+
const React = require('react')
|
|
15
|
+
const h = React.createElement
|
|
16
|
+
const { useCallback, useEffect, useRef, useState } = React
|
|
17
|
+
|
|
18
|
+
const NS = 'whyshu-ba'
|
|
19
|
+
const PANEL_ID = 'binance'
|
|
20
|
+
const API = '/api/whyshu-ba'
|
|
21
|
+
const REFRESH_MS = 30000
|
|
22
|
+
const ENV_PATH = '~/.dsh/argent/ba.env'
|
|
23
|
+
|
|
24
|
+
/* ───────────────────────── 文案(zh 必填 + en 基线) ───────────────────────── */
|
|
25
|
+
|
|
26
|
+
const zh = {
|
|
27
|
+
panel: '币安账户',
|
|
28
|
+
title: '币安账户',
|
|
29
|
+
subtitle: '现货资产 · 合约持仓 · 行情看板(只读)',
|
|
30
|
+
readOnly: '只读',
|
|
31
|
+
refresh: '刷新',
|
|
32
|
+
refreshing: '刷新中…',
|
|
33
|
+
updated: '更新于',
|
|
34
|
+
autoRefresh: '每 30 秒自动刷新',
|
|
35
|
+
loading: '正在读取币安数据…',
|
|
36
|
+
retry: '重试',
|
|
37
|
+
kpiSpot: '现货估值',
|
|
38
|
+
kpiWallet: '合约钱包余额',
|
|
39
|
+
kpiUnrealized: '未实现盈亏',
|
|
40
|
+
kpiAvailable: '合约可用余额',
|
|
41
|
+
spotTitle: '现货资产',
|
|
42
|
+
spotHint: '按现价折算 USDT,仅显示非零余额',
|
|
43
|
+
spotAsset: '资产',
|
|
44
|
+
spotTotal: '数量',
|
|
45
|
+
spotFree: '可用',
|
|
46
|
+
spotLocked: '锁定',
|
|
47
|
+
spotPrice: '现价',
|
|
48
|
+
spotValue: '估值 (USDT)',
|
|
49
|
+
futuresTitle: '合约持仓',
|
|
50
|
+
futuresPositions: '持仓明细',
|
|
51
|
+
posSymbol: '合约',
|
|
52
|
+
posSide: '方向',
|
|
53
|
+
posSize: '数量',
|
|
54
|
+
posEntry: '开仓价',
|
|
55
|
+
posMark: '标记价',
|
|
56
|
+
posPnl: '未实现盈亏',
|
|
57
|
+
posLeverage: '杠杆',
|
|
58
|
+
marketTitle: '行情看板',
|
|
59
|
+
marketSymbol: '币对',
|
|
60
|
+
marketPrice: '最新价',
|
|
61
|
+
marketChange: '24h 涨跌',
|
|
62
|
+
marketVolume: '24h 成交额',
|
|
63
|
+
long: '多',
|
|
64
|
+
short: '空',
|
|
65
|
+
emptySpot: '没有非零现货余额',
|
|
66
|
+
emptyPositions: '没有持仓',
|
|
67
|
+
emptyMarket: '暂无行情数据',
|
|
68
|
+
setupTitle: '还没有配置币安 API Key',
|
|
69
|
+
setupIntro: '行情看板已在工作;配置只读 Key 后即可显示现货资产与合约持仓。',
|
|
70
|
+
setupStep1: '币安 → 账户 → API 管理 → 新建 API,只勾选「启用读取」,务必禁用提现与交易。',
|
|
71
|
+
setupStep2: '把 Key / Secret 写入本机文件(权限 600,不入仓库、不进日志):',
|
|
72
|
+
setupStep3: '保存后回到这里点「刷新」,无需重启 dsh。',
|
|
73
|
+
setupHint: '建议单独建一个只读 Key,并开启 IP 白名单。代理仅在直连不通时需要。',
|
|
74
|
+
setupPath: '文件位置',
|
|
75
|
+
errTitle: '数据读取失败',
|
|
76
|
+
errProxyHint: '网络不通:在 ba.env 里配置 BINANCE_PROXY(本机可用 http://127.0.0.1:7890)后重试。',
|
|
77
|
+
errKeyHint: 'Key 无效或无读取权限:确认只读 Key、现货读取权限,并检查服务器时间。',
|
|
78
|
+
errRegionHint: '当前地区被限制访问,请检查代理节点。',
|
|
79
|
+
footnote: '只读面板 · 每 30 秒自动刷新 · API Key 只在 dsh 宿主进程使用',
|
|
80
|
+
proxyOn: '代理已启用',
|
|
81
|
+
proxyOff: '直连',
|
|
82
|
+
noValue: '—',
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const en = {
|
|
86
|
+
panel: 'Binance account',
|
|
87
|
+
title: 'Binance account',
|
|
88
|
+
subtitle: 'Spot assets · futures positions · market board (read-only)',
|
|
89
|
+
readOnly: 'Read-only',
|
|
90
|
+
refresh: 'Refresh',
|
|
91
|
+
refreshing: 'Refreshing…',
|
|
92
|
+
updated: 'Updated',
|
|
93
|
+
autoRefresh: 'Auto-refresh every 30s',
|
|
94
|
+
loading: 'Loading Binance data…',
|
|
95
|
+
retry: 'Retry',
|
|
96
|
+
kpiSpot: 'Spot value',
|
|
97
|
+
kpiWallet: 'Futures wallet',
|
|
98
|
+
kpiUnrealized: 'Unrealized PnL',
|
|
99
|
+
kpiAvailable: 'Futures available',
|
|
100
|
+
spotTitle: 'Spot assets',
|
|
101
|
+
spotHint: 'Valued in USDT at the latest price; zero balances hidden',
|
|
102
|
+
spotAsset: 'Asset',
|
|
103
|
+
spotTotal: 'Amount',
|
|
104
|
+
spotFree: 'Free',
|
|
105
|
+
spotLocked: 'Locked',
|
|
106
|
+
spotPrice: 'Price',
|
|
107
|
+
spotValue: 'Value (USDT)',
|
|
108
|
+
futuresTitle: 'Futures positions',
|
|
109
|
+
futuresPositions: 'Open positions',
|
|
110
|
+
posSymbol: 'Symbol',
|
|
111
|
+
posSide: 'Side',
|
|
112
|
+
posSize: 'Size',
|
|
113
|
+
posEntry: 'Entry',
|
|
114
|
+
posMark: 'Mark',
|
|
115
|
+
posPnl: 'Unrealized PnL',
|
|
116
|
+
posLeverage: 'Lev.',
|
|
117
|
+
marketTitle: 'Market board',
|
|
118
|
+
marketSymbol: 'Pair',
|
|
119
|
+
marketPrice: 'Last',
|
|
120
|
+
marketChange: '24h change',
|
|
121
|
+
marketVolume: '24h volume',
|
|
122
|
+
long: 'Long',
|
|
123
|
+
short: 'Short',
|
|
124
|
+
emptySpot: 'No non-zero spot balances',
|
|
125
|
+
emptyPositions: 'No open positions',
|
|
126
|
+
emptyMarket: 'No market data',
|
|
127
|
+
setupTitle: 'Binance API key is not configured yet',
|
|
128
|
+
setupIntro: 'The market board already works. Add a read-only key to show spot assets and futures positions.',
|
|
129
|
+
setupStep1: 'Binance → Account → API Management → Create API, enable only "Enable Reading"; disable withdrawals and trading.',
|
|
130
|
+
setupStep2: 'Write the key and secret into a local file (mode 600, never committed or logged):',
|
|
131
|
+
setupStep3: 'Save, then click Refresh here. No dsh restart is needed.',
|
|
132
|
+
setupHint: 'Prefer a dedicated read-only key with an IP allowlist. A proxy is needed only when direct access is blocked.',
|
|
133
|
+
setupPath: 'File',
|
|
134
|
+
errTitle: 'Could not load data',
|
|
135
|
+
errProxyHint: 'Network unreachable: set BINANCE_PROXY in ba.env (this machine can use http://127.0.0.1:7890) and retry.',
|
|
136
|
+
errKeyHint: 'Invalid key or missing read permission: verify the read-only key, spot reading permission, and host time.',
|
|
137
|
+
errRegionHint: 'This region is restricted; try another proxy node.',
|
|
138
|
+
footnote: 'Read-only · auto-refresh every 30s · API key stays in the dsh host process',
|
|
139
|
+
proxyOn: 'proxy on',
|
|
140
|
+
proxyOff: 'direct',
|
|
141
|
+
noValue: '—',
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/* ───────────────────────── 格式化 ───────────────────────── */
|
|
145
|
+
|
|
146
|
+
const pad2 = (n) => (n < 10 ? '0' + n : String(n))
|
|
147
|
+
|
|
148
|
+
function fmtNum(value, maxFrac = 8) {
|
|
149
|
+
const n = Number(value)
|
|
150
|
+
if (!Number.isFinite(n)) return '—'
|
|
151
|
+
if (n === 0) return '0'
|
|
152
|
+
const abs = Math.abs(n)
|
|
153
|
+
const frac = abs >= 1000 ? 2 : abs >= 1 ? Math.min(maxFrac, 4) : Math.min(maxFrac, 6)
|
|
154
|
+
return n.toLocaleString('en-US', { maximumFractionDigits: frac })
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function fmtPrice(value) {
|
|
158
|
+
const n = Number(value)
|
|
159
|
+
if (!Number.isFinite(n) || n <= 0) return '—'
|
|
160
|
+
const frac = n >= 1000 ? 2 : n >= 1 ? 4 : 6
|
|
161
|
+
return n.toLocaleString('en-US', { maximumFractionDigits: frac })
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function fmtUsd(value) {
|
|
165
|
+
const n = Number(value)
|
|
166
|
+
if (!Number.isFinite(n)) return '—'
|
|
167
|
+
return '$' + n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function fmtPct(value) {
|
|
171
|
+
const n = Number(value)
|
|
172
|
+
if (!Number.isFinite(n)) return '—'
|
|
173
|
+
return (n > 0 ? '+' : '') + n.toFixed(2) + '%'
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function fmtCompact(value) {
|
|
177
|
+
const n = Number(value)
|
|
178
|
+
if (!Number.isFinite(n)) return '—'
|
|
179
|
+
if (Math.abs(n) >= 1e9) return (n / 1e9).toFixed(2) + 'B'
|
|
180
|
+
if (Math.abs(n) >= 1e6) return (n / 1e6).toFixed(2) + 'M'
|
|
181
|
+
if (Math.abs(n) >= 1e3) return (n / 1e3).toFixed(2) + 'K'
|
|
182
|
+
return n.toFixed(2)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** 24 小时制:14:05:09 */
|
|
186
|
+
function fmtTime(iso) {
|
|
187
|
+
const d = iso instanceof Date ? iso : new Date(iso || Date.now())
|
|
188
|
+
if (Number.isNaN(d.getTime())) return ''
|
|
189
|
+
return pad2(d.getHours()) + ':' + pad2(d.getMinutes()) + ':' + pad2(d.getSeconds())
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function toneClass(value) {
|
|
193
|
+
const n = Number(value)
|
|
194
|
+
if (!Number.isFinite(n) || n === 0) return ''
|
|
195
|
+
return n > 0 ? ' wba-pos' : ' wba-neg'
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function errorHint(message, t) {
|
|
199
|
+
const text = String(message || '')
|
|
200
|
+
if (/timeout|超时|ECONN|ENOTFOUND|EAI_AGAIN|socket hang up|fetch failed/i.test(text)) return t('errProxyHint')
|
|
201
|
+
if (/401|403|451|-2015|invalid api-key|signature|permission|apikey/i.test(text)) return t('errKeyHint')
|
|
202
|
+
if (/restricted|region|451/i.test(text)) return t('errRegionHint')
|
|
203
|
+
return ''
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/* ───────────────────────── 样式(只注入一次;全部用 dsh 主题 token) ───────────────────────── */
|
|
207
|
+
|
|
208
|
+
const CSS = `
|
|
209
|
+
.wba-root{display:flex;flex-direction:column;height:100%;min-height:0;color:var(--dsw-alias-label-primary);
|
|
210
|
+
font:14px/1.55 var(--dsw-font-family,-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif)}
|
|
211
|
+
.wba-head{display:flex;align-items:flex-start;gap:12px;padding:16px 22px 10px;flex-wrap:wrap}
|
|
212
|
+
.wba-head-main{display:flex;align-items:center;gap:10px;min-width:0}
|
|
213
|
+
.wba-head-actions{display:flex;align-items:center;gap:10px;margin-left:auto;flex-wrap:wrap}
|
|
214
|
+
.wba-title{font-size:16px;font-weight:600;line-height:1.3}
|
|
215
|
+
.wba-sub{font-size:12px;color:var(--dsw-alias-label-secondary);margin-top:1px}
|
|
216
|
+
.wba-chip{border:1px solid var(--dsw-alias-border-l2);border-radius:999px;padding:1px 8px;font-size:11px;
|
|
217
|
+
color:var(--dsw-alias-label-secondary);white-space:nowrap}
|
|
218
|
+
.wba-time{font-size:12px;color:var(--dsw-alias-label-secondary);white-space:nowrap}
|
|
219
|
+
.wba-btn{border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary);
|
|
220
|
+
border-radius:8px;padding:4px 12px;font:12px/1.4 inherit;cursor:pointer;white-space:nowrap}
|
|
221
|
+
.wba-btn:hover{background:rgba(127,127,127,.10)}
|
|
222
|
+
.wba-btn:disabled{opacity:.55;cursor:default}
|
|
223
|
+
.wba-body{flex:1 1 auto;min-height:0;overflow:auto;padding:6px 22px 28px;display:flex;flex-direction:column;gap:12px}
|
|
224
|
+
.wba-card{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:12px;padding:12px 14px;min-width:0}
|
|
225
|
+
.wba-card-head{display:flex;align-items:baseline;gap:8px;margin-bottom:8px;flex-wrap:wrap}
|
|
226
|
+
.wba-card-title{margin:0;font-size:13px;font-weight:600}
|
|
227
|
+
.wba-card-sub{font-size:11px;color:var(--dsw-alias-label-secondary)}
|
|
228
|
+
.wba-kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px}
|
|
229
|
+
.wba-kpi{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:12px;padding:10px 14px}
|
|
230
|
+
.wba-kpi b{display:block;font-size:22px;line-height:1.2;font-weight:600;font-variant-numeric:tabular-nums}
|
|
231
|
+
.wba-kpi span{display:block;font-size:11px;color:var(--dsw-alias-label-secondary);margin-top:2px}
|
|
232
|
+
.wba-table{display:flex;flex-direction:column;min-width:0}
|
|
233
|
+
.wba-tr{display:grid;gap:10px;align-items:center;padding:6px 2px;border-bottom:1px dashed var(--dsw-alias-border-l1)}
|
|
234
|
+
.wba-tr:last-child{border-bottom:0}
|
|
235
|
+
.wba-th{font-size:11px;color:var(--dsw-alias-label-secondary);padding-bottom:4px;border-bottom:1px solid var(--dsw-alias-border-l1)}
|
|
236
|
+
.wba-cell{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-variant-numeric:tabular-nums}
|
|
237
|
+
.wba-cell.wba-name{font-weight:600}
|
|
238
|
+
.wba-cell.wba-num{text-align:right}
|
|
239
|
+
.wba-cell.wba-total{font-weight:600}
|
|
240
|
+
.wba-pos{color:var(--dsw-alias-state-success-primary)}
|
|
241
|
+
.wba-neg{color:var(--dsw-alias-state-error-primary)}
|
|
242
|
+
.wba-dim{color:var(--dsw-alias-label-secondary)}
|
|
243
|
+
.wba-note{font-size:12px;color:var(--dsw-alias-label-secondary);background:rgba(127,127,127,.07);border-radius:10px;padding:10px 12px}
|
|
244
|
+
.wba-note b{color:var(--dsw-alias-label-primary)}
|
|
245
|
+
.wba-empty{padding:16px 2px;text-align:center;color:var(--dsw-alias-label-secondary);font-size:12px}
|
|
246
|
+
.wba-loading{padding:40px 0;text-align:center;color:var(--dsw-alias-label-secondary);font-size:13px}
|
|
247
|
+
.wba-err{margin:0;padding:12px 14px;border-radius:12px;border:1px solid var(--dsw-alias-state-error-primary);
|
|
248
|
+
background:rgba(248,113,113,.10);font-size:13px;white-space:pre-wrap;word-break:break-word}
|
|
249
|
+
.wba-err-hint{margin-top:6px;font-size:12px;color:var(--dsw-alias-label-secondary)}
|
|
250
|
+
.wba-setup h3{margin:0 0 6px;font-size:14px}
|
|
251
|
+
.wba-setup p{margin:6px 0;font-size:13px}
|
|
252
|
+
.wba-setup ol{margin:8px 0 8px 18px;padding:0;font-size:13px}
|
|
253
|
+
.wba-setup li{margin:4px 0}
|
|
254
|
+
.wba-code{background:var(--dsw-alias-bg-base);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;
|
|
255
|
+
padding:8px 10px;font:12px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace;white-space:pre-wrap;word-break:break-all}
|
|
256
|
+
.wba-foot{font-size:11px;color:var(--dsw-alias-label-secondary);padding:0 2px 4px;display:flex;gap:10px;flex-wrap:wrap}
|
|
257
|
+
`
|
|
258
|
+
|
|
259
|
+
function injectCss() {
|
|
260
|
+
try {
|
|
261
|
+
if (typeof document === 'undefined' || !document.head) return
|
|
262
|
+
if (document.getElementById('whyshu-ba-css')) return
|
|
263
|
+
const style = document.createElement('style')
|
|
264
|
+
style.id = 'whyshu-ba-css'
|
|
265
|
+
style.textContent = CSS
|
|
266
|
+
document.head.appendChild(style)
|
|
267
|
+
} catch { /* noop */ }
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/* ───────────────────────── 小部件 ───────────────────────── */
|
|
271
|
+
|
|
272
|
+
/** 左侧栏图标:原创「三根向上蜡烛」图形,跟随主题色。 */
|
|
273
|
+
function BinanceIcon(props) {
|
|
274
|
+
const size = (props && props.size) || 20
|
|
275
|
+
return h('svg', {
|
|
276
|
+
width: size, height: size, viewBox: '0 0 24 24', fill: 'none',
|
|
277
|
+
'aria-hidden': true, style: { display: 'block', flex: '0 0 auto' },
|
|
278
|
+
},
|
|
279
|
+
h('rect', { x: 4, y: 11, width: 3.2, height: 7, rx: 1, fill: 'currentColor', opacity: 0.55 }),
|
|
280
|
+
h('rect', { x: 10.4, y: 7, width: 3.2, height: 11, rx: 1, fill: 'currentColor', opacity: 0.8 }),
|
|
281
|
+
h('rect', { x: 16.8, y: 3.5, width: 3.2, height: 14.5, rx: 1, fill: 'currentColor' }),
|
|
282
|
+
h('path', { d: 'M4.5 20.5h15', stroke: 'currentColor', strokeWidth: 1.4, strokeLinecap: 'round' }))
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function Kpi(props) {
|
|
286
|
+
return h('div', { className: 'wba-kpi' },
|
|
287
|
+
h('b', { className: props.tone ? toneClass(props.tone).trim() : '' }, props.value),
|
|
288
|
+
h('span', null, props.label))
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** 通用表格:cols 为 grid-template-columns;rows 为二维数组;首列左对齐,其余右对齐。 */
|
|
292
|
+
function Table(props) {
|
|
293
|
+
const rows = props.rows || []
|
|
294
|
+
if (!rows.length) return h('div', { className: 'wba-empty' }, props.empty || '')
|
|
295
|
+
const cellClass = (index, extra) => 'wba-cell ' + (index === 0 ? 'wba-name' : 'wba-num') + (extra || '')
|
|
296
|
+
return h('div', { className: 'wba-table' },
|
|
297
|
+
h('div', { className: 'wba-tr wba-th', style: { gridTemplateColumns: props.cols } },
|
|
298
|
+
props.head.map((label, i) => h('span', { key: i, className: cellClass(i) }, label))),
|
|
299
|
+
rows.map((row, ri) => h('div', { key: ri, className: 'wba-tr', style: { gridTemplateColumns: props.cols } },
|
|
300
|
+
row.map((cell, ci) => h('span', { key: ci, className: cellClass(ci, cell && cell.toneClass ? ' ' + cell.toneClass : '') },
|
|
301
|
+
cell && cell.node !== undefined ? cell.node : String(cell === undefined || cell === null ? '—' : cell))))))
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function toneCell(value, formatter) {
|
|
305
|
+
return { toneClass: toneClass(value).trim(), node: h('span', null, formatter(value)) }
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function SetupCard(props) {
|
|
309
|
+
const t = props.t
|
|
310
|
+
return h('div', { className: 'wba-card wba-setup' },
|
|
311
|
+
h('h3', null, t('setupTitle')),
|
|
312
|
+
h('p', null, t('setupIntro')),
|
|
313
|
+
h('ol', null,
|
|
314
|
+
h('li', null, t('setupStep1')),
|
|
315
|
+
h('li', null, t('setupStep2'))),
|
|
316
|
+
h('div', { className: 'wba-note' }, t('setupPath') + ': ', h('b', null, ENV_PATH)),
|
|
317
|
+
h('div', { className: 'wba-code' },
|
|
318
|
+
'BINANCE_API_KEY=...\nBINANCE_API_SECRET=...\nBINANCE_PROXY=http://127.0.0.1:7890'),
|
|
319
|
+
h('p', { className: 'wba-dim' }, t('setupStep3')),
|
|
320
|
+
h('p', { className: 'wba-dim' }, t('setupHint')))
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function ErrorsCard(props) {
|
|
324
|
+
const t = props.t
|
|
325
|
+
return h('div', { className: 'wba-card' },
|
|
326
|
+
h('div', { className: 'wba-card-head' }, h('h3', { className: 'wba-card-title' }, t('errTitle'))),
|
|
327
|
+
props.errors.map((e, i) => {
|
|
328
|
+
const hint = errorHint(e.message, t)
|
|
329
|
+
return h('div', { key: i, className: 'wba-err', style: i ? { marginTop: 8 } : undefined },
|
|
330
|
+
'[' + e.section + '] ' + e.message,
|
|
331
|
+
hint ? h('div', { className: 'wba-err-hint' }, hint) : null)
|
|
332
|
+
}))
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/* ───────────────────────── 主面板 ───────────────────────── */
|
|
336
|
+
|
|
337
|
+
function BinancePanel(props) {
|
|
338
|
+
const t = (props && props.t) || ((key) => key)
|
|
339
|
+
const [data, setData] = useState(null)
|
|
340
|
+
const [error, setError] = useState(null)
|
|
341
|
+
const [loading, setLoading] = useState(true)
|
|
342
|
+
const [fetchedAt, setFetchedAt] = useState(null)
|
|
343
|
+
const alive = useRef(true)
|
|
344
|
+
|
|
345
|
+
const load = useCallback(async () => {
|
|
346
|
+
setLoading(true)
|
|
347
|
+
try {
|
|
348
|
+
const res = await fetch(API + '/overview', { cache: 'no-store' })
|
|
349
|
+
const body = await res.json().catch(() => null)
|
|
350
|
+
if (!alive.current) return
|
|
351
|
+
if (!res.ok || !body || body.ok !== true) {
|
|
352
|
+
const message = (body && (body.message || body.error)) || ('HTTP ' + res.status)
|
|
353
|
+
setError(String(message))
|
|
354
|
+
return
|
|
355
|
+
}
|
|
356
|
+
setData(body.data)
|
|
357
|
+
setError(null)
|
|
358
|
+
setFetchedAt(new Date())
|
|
359
|
+
} catch (e) {
|
|
360
|
+
if (alive.current) setError(String((e && e.message) || e))
|
|
361
|
+
} finally {
|
|
362
|
+
if (alive.current) setLoading(false)
|
|
363
|
+
}
|
|
364
|
+
}, [])
|
|
365
|
+
|
|
366
|
+
useEffect(() => {
|
|
367
|
+
alive.current = true
|
|
368
|
+
load()
|
|
369
|
+
const timer = setInterval(load, REFRESH_MS)
|
|
370
|
+
return () => { alive.current = false; clearInterval(timer) }
|
|
371
|
+
}, [load])
|
|
372
|
+
|
|
373
|
+
const head = h('div', { className: 'wba-head' },
|
|
374
|
+
h('div', { className: 'wba-head-main' },
|
|
375
|
+
h(BinanceIcon, { size: 24, active: true }),
|
|
376
|
+
h('div', null,
|
|
377
|
+
h('div', { className: 'wba-title' }, t('title')),
|
|
378
|
+
h('div', { className: 'wba-sub' }, t('subtitle')))),
|
|
379
|
+
h('div', { className: 'wba-head-actions' },
|
|
380
|
+
h('span', { className: 'wba-chip' }, t('readOnly')),
|
|
381
|
+
fetchedAt ? h('span', { className: 'wba-time' }, t('updated') + ' ' + fmtTime(fetchedAt)) : null,
|
|
382
|
+
h('span', { className: 'wba-time' }, t('autoRefresh')),
|
|
383
|
+
h('button', { type: 'button', className: 'wba-btn', onClick: load, disabled: loading },
|
|
384
|
+
loading ? t('refreshing') : t('refresh'))))
|
|
385
|
+
|
|
386
|
+
if (!data && loading) {
|
|
387
|
+
return h('div', { className: 'wba-root' }, head, h('div', { className: 'wba-loading' }, t('loading')))
|
|
388
|
+
}
|
|
389
|
+
if (!data && error) {
|
|
390
|
+
return h('div', { className: 'wba-root' }, head,
|
|
391
|
+
h('div', { className: 'wba-body' },
|
|
392
|
+
h('div', { className: 'wba-err' }, error,
|
|
393
|
+
errorHint(error, t) ? h('div', { className: 'wba-err-hint' }, errorHint(error, t)) : null),
|
|
394
|
+
h('div', null, h('button', { type: 'button', className: 'wba-btn', onClick: load }, t('retry')))))
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const body = []
|
|
398
|
+
if (error) {
|
|
399
|
+
body.push(h('div', { key: 'soft-err', className: 'wba-err' }, error))
|
|
400
|
+
}
|
|
401
|
+
if (data && !data.configured) body.push(h(SetupCard, { key: 'setup', t }))
|
|
402
|
+
if (data && data.errors && data.errors.length) body.push(h(ErrorsCard, { key: 'errors', t, errors: data.errors }))
|
|
403
|
+
|
|
404
|
+
const spot = data && data.spot
|
|
405
|
+
const futures = data && data.futures
|
|
406
|
+
if (spot || futures) {
|
|
407
|
+
const kpis = []
|
|
408
|
+
if (spot) kpis.push(h(Kpi, { key: 'spot', label: t('kpiSpot'), value: fmtUsd(spot.totalUSDT) }))
|
|
409
|
+
if (futures) {
|
|
410
|
+
kpis.push(h(Kpi, { key: 'wallet', label: t('kpiWallet'), value: fmtUsd(futures.totalWalletBalance) }))
|
|
411
|
+
kpis.push(h(Kpi, { key: 'upnl', label: t('kpiUnrealized'), value: fmtUsd(futures.totalUnrealizedProfit), tone: futures.totalUnrealizedProfit }))
|
|
412
|
+
kpis.push(h(Kpi, { key: 'avail', label: t('kpiAvailable'), value: fmtUsd(futures.availableBalance) }))
|
|
413
|
+
}
|
|
414
|
+
body.push(h('div', { key: 'kpis', className: 'wba-kpis' }, kpis))
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (spot) {
|
|
418
|
+
const rows = (spot.rows || []).map((r) => [
|
|
419
|
+
r.asset,
|
|
420
|
+
fmtNum(r.total),
|
|
421
|
+
fmtNum(r.free),
|
|
422
|
+
fmtNum(r.locked),
|
|
423
|
+
r.priceUSDT ? fmtPrice(r.priceUSDT) : '—',
|
|
424
|
+
r.valueUSDT === null ? '—' : fmtUsd(r.valueUSDT),
|
|
425
|
+
])
|
|
426
|
+
body.push(h('div', { key: 'spot', className: 'wba-card' },
|
|
427
|
+
h('div', { className: 'wba-card-head' },
|
|
428
|
+
h('h3', { className: 'wba-card-title' }, t('spotTitle')),
|
|
429
|
+
h('span', { className: 'wba-card-sub' }, t('spotHint')),
|
|
430
|
+
h('span', { className: 'wba-card-sub', style: { marginLeft: 'auto' } }, t('kpiSpot') + ' ' + fmtUsd(spot.totalUSDT))),
|
|
431
|
+
h(Table, {
|
|
432
|
+
cols: '1.1fr .9fr .9fr .9fr .9fr 1.1fr',
|
|
433
|
+
head: [t('spotAsset'), t('spotTotal'), t('spotFree'), t('spotLocked'), t('spotPrice'), t('spotValue')],
|
|
434
|
+
rows: rows,
|
|
435
|
+
empty: t('emptySpot'),
|
|
436
|
+
})))
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (futures) {
|
|
440
|
+
const posRows = (futures.positions || []).map((p) => [
|
|
441
|
+
p.symbol,
|
|
442
|
+
p.side === 'LONG' ? t('long') : t('short'),
|
|
443
|
+
fmtNum(Math.abs(p.positionAmt)),
|
|
444
|
+
fmtPrice(p.entryPrice),
|
|
445
|
+
fmtPrice(p.markPrice),
|
|
446
|
+
toneCell(p.unrealizedProfit, (v) => (v > 0 ? '+' : '') + fmtUsd(v)),
|
|
447
|
+
p.leverage ? p.leverage + '×' : '—',
|
|
448
|
+
])
|
|
449
|
+
body.push(h('div', { key: 'futures', className: 'wba-card' },
|
|
450
|
+
h('div', { className: 'wba-card-head' },
|
|
451
|
+
h('h3', { className: 'wba-card-title' }, t('futuresTitle')),
|
|
452
|
+
h('span', { className: 'wba-card-sub' }, t('futuresPositions')),
|
|
453
|
+
h('span', { className: 'wba-card-sub', style: { marginLeft: 'auto' } },
|
|
454
|
+
t('kpiWallet') + ' ' + fmtUsd(futures.totalWalletBalance))),
|
|
455
|
+
h(Table, {
|
|
456
|
+
cols: '1fr .55fr .9fr .9fr .9fr 1.1fr .6fr',
|
|
457
|
+
head: [t('posSymbol'), t('posSide'), t('posSize'), t('posEntry'), t('posMark'), t('posPnl'), t('posLeverage')],
|
|
458
|
+
rows: posRows,
|
|
459
|
+
empty: t('emptyPositions'),
|
|
460
|
+
})))
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (data && data.tickers && data.tickers.length) {
|
|
464
|
+
const tickerRows = data.tickers.map((k) => [
|
|
465
|
+
k.symbol,
|
|
466
|
+
fmtPrice(k.lastPrice),
|
|
467
|
+
toneCell(k.priceChangePercent, fmtPct),
|
|
468
|
+
fmtCompact(k.quoteVolume) + ' USDT',
|
|
469
|
+
])
|
|
470
|
+
body.push(h('div', { key: 'market', className: 'wba-card' },
|
|
471
|
+
h('div', { className: 'wba-card-head' }, h('h3', { className: 'wba-card-title' }, t('marketTitle'))),
|
|
472
|
+
h(Table, {
|
|
473
|
+
cols: '1fr 1fr 1fr 1fr',
|
|
474
|
+
head: [t('marketSymbol'), t('marketPrice'), t('marketChange'), t('marketVolume')],
|
|
475
|
+
rows: tickerRows,
|
|
476
|
+
empty: t('emptyMarket'),
|
|
477
|
+
})))
|
|
478
|
+
} else if (data && !(data.errors || []).some((e) => e.section === 'market')) {
|
|
479
|
+
body.push(h('div', { key: 'market-empty', className: 'wba-card' },
|
|
480
|
+
h('div', { className: 'wba-card-head' }, h('h3', { className: 'wba-card-title' }, t('marketTitle'))),
|
|
481
|
+
h('div', { className: 'wba-empty' }, t('emptyMarket'))))
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
if (data) {
|
|
485
|
+
body.push(h('div', { key: 'foot', className: 'wba-foot' },
|
|
486
|
+
h('span', null, t('footnote')),
|
|
487
|
+
h('span', null, data.proxy ? t('proxyOn') : t('proxyOff'))))
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
return h('div', { className: 'wba-root' }, head, h('div', { className: 'wba-body' }, body))
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** 兜底错误边界:数据形状异常时显示错误卡片,而不是把整个 slot 弄崩。 */
|
|
494
|
+
class PanelBoundary extends React.Component {
|
|
495
|
+
constructor(props) { super(props); this.state = { error: null } }
|
|
496
|
+
static getDerivedStateFromError(error) { return { error } }
|
|
497
|
+
render() {
|
|
498
|
+
if (this.state.error) {
|
|
499
|
+
const message = String((this.state.error && this.state.error.message) || this.state.error)
|
|
500
|
+
return h('div', { className: 'wba-root' },
|
|
501
|
+
h('div', { className: 'wba-body' },
|
|
502
|
+
h('div', { className: 'wba-err' }, '面板渲染失败 / panel render failed: ' + message)))
|
|
503
|
+
}
|
|
504
|
+
return this.props.children
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
function BinancePanelSafe(props) {
|
|
508
|
+
return h(PanelBoundary, null, h(BinancePanel, props))
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/* ───────────────────────── 注册 ───────────────────────── */
|
|
512
|
+
|
|
513
|
+
const name = 'whyshu-ba-client'
|
|
514
|
+
const inject = ['slots', 'locale']
|
|
515
|
+
|
|
516
|
+
/** 安全执行一次注册:任何异常只记日志,绝不让客户端 apply 抛出(抛了会空白槽位/页面)。 */
|
|
517
|
+
function safe(label, fn) {
|
|
518
|
+
try {
|
|
519
|
+
return fn()
|
|
520
|
+
} catch (e) {
|
|
521
|
+
try {
|
|
522
|
+
console.warn('[whyshu-ba] ' + label + ' 失败(已忽略,不影响 dsh 页面):', (e && e.message) || e)
|
|
523
|
+
} catch { /* 日志本身失败也不能抛 */ }
|
|
524
|
+
return undefined
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function apply(ctx) {
|
|
529
|
+
// 整体 try/catch:客户端 apply 抛异常会让整个页面/槽位崩溃。
|
|
530
|
+
try {
|
|
531
|
+
safe('语言字典', () => ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'whyshu-ba: dictionaries'))
|
|
532
|
+
const t = safe('locale.bind', () => ctx.locale.bind(NS)) || ((key) => key)
|
|
533
|
+
safe('样式注入', injectCss)
|
|
534
|
+
// 自检信标:宿主 /health 会回报 clientPingAt ⇒ 能区分"bundle 没加载"与"加载了但没显示"
|
|
535
|
+
safe('ping', () => { fetch(API + '/ping?from=client').catch(() => {}) })
|
|
536
|
+
|
|
537
|
+
// ⛔ 永不抛出:注册失败只影响这个面板,不能让 web boot 报 "entry did not activate"
|
|
538
|
+
safe('main 面板', () => ctx.slots.inject('main', () => ctx.slots.register({
|
|
539
|
+
name: 'main',
|
|
540
|
+
key: PANEL_ID,
|
|
541
|
+
locale: NS,
|
|
542
|
+
}, BinancePanelSafe)))
|
|
543
|
+
safe('侧栏入口', () => ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({
|
|
544
|
+
name: 'sidebar.panellist',
|
|
545
|
+
id: PANEL_ID,
|
|
546
|
+
order: 20,
|
|
547
|
+
locale: NS,
|
|
548
|
+
label: () => t('panel'),
|
|
549
|
+
}, BinanceIcon)))
|
|
550
|
+
} catch (e) {
|
|
551
|
+
try {
|
|
552
|
+
console.warn('[whyshu-ba] 客户端 apply 异常已忽略(不影响 dsh 页面):', (e && e.message) || e)
|
|
553
|
+
} catch { /* noop */ }
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
return { name, inject, apply }
|
|
558
|
+
},
|
|
559
|
+
})
|
package/icon.svg
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Binance account panel">
|
|
2
|
+
<rect x="8" y="30" width="10" height="20" rx="3" fill="currentColor" opacity="0.55"/>
|
|
3
|
+
<rect x="26" y="18" width="10" height="32" rx="3" fill="currentColor" opacity="0.8"/>
|
|
4
|
+
<rect x="44" y="8" width="10" height="42" rx="3" fill="currentColor"/>
|
|
5
|
+
<path d="M10 58h44" stroke="currentColor" stroke-width="4" stroke-linecap="round"/>
|
|
6
|
+
</svg>
|