@whyshu/ba 0.0.0-stage → 0.1.0
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 +84 -2
- package/cordis.patch.yml +6 -0
- package/dist/client.js +548 -0
- package/icon.svg +6 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +46 -4
- package/src/binance.mjs +385 -0
- package/src/index.js +84 -0
package/README.md
CHANGED
|
@@ -1,3 +1,85 @@
|
|
|
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
|
+
## 配置 API Key
|
|
35
|
+
|
|
36
|
+
1. 币安 → 账户 → **API 管理** → 新建 API,权限只勾 **「启用读取」**(务必禁用提现、交易)。
|
|
37
|
+
2. 建议同时开启 **IP 白名单**。
|
|
38
|
+
3. 写入本机文件(权限 600,不入仓库、不进日志):
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
mkdir -p ~/.dsh/argent && chmod 700 ~/.dsh/argent
|
|
42
|
+
cat > ~/.dsh/argent/ba.env <<'EOF'
|
|
43
|
+
BINANCE_API_KEY=你的只读 Key
|
|
44
|
+
BINANCE_API_SECRET=你的 Secret
|
|
45
|
+
BINANCE_PROXY=http://127.0.0.1:7890
|
|
46
|
+
BINANCE_SYMBOLS=BTCUSDT,ETHUSDT,SOLUSDT,BNBUSDT
|
|
47
|
+
EOF
|
|
48
|
+
chmod 600 ~/.dsh/argent/ba.env
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
- `BINANCE_PROXY` 可选;本机直连 `api.binance.com` 不通时必须配(本机 ClashX 为 `http://127.0.0.1:7890`)。
|
|
52
|
+
- `BINANCE_SYMBOLS` 可选,决定行情看板的币对。
|
|
53
|
+
- 插件**每次请求都重读该文件**:改 Key / 代理后点面板里的「刷新」即可,无需重启 dsh。
|
|
54
|
+
|
|
55
|
+
## 端点(宿主侧,只读)
|
|
56
|
+
|
|
57
|
+
| 端点 | 说明 |
|
|
58
|
+
| --- | --- |
|
|
59
|
+
| `GET /api/whyshu-ba/health` | 版本、是否已配置 Key、是否启用代理、自选币对 |
|
|
60
|
+
| `GET /api/whyshu-ba/ping` | 客户端 bundle 自检信标 |
|
|
61
|
+
| `GET /api/whyshu-ba/overview` | 面板总览(行情 + 现货 + 合约;各段独立失败) |
|
|
62
|
+
|
|
63
|
+
## 文件
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
src/index.js 宿主插件:注册 /api/whyshu-ba/* 端点
|
|
67
|
+
src/binance.mjs 币安只读数据层:签名、代理隧道、现货/合约/行情
|
|
68
|
+
dist/client.js 浏览器 bundle(手写维护,无需构建):main + sidebar.panellist
|
|
69
|
+
locale/*.json 插件管理器的标题/描述(zh/en)
|
|
70
|
+
icon.svg 插件图标
|
|
71
|
+
cordis.patch.yml bundle patch(insert id=whyshu-ba)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## 排查
|
|
75
|
+
|
|
76
|
+
- **面板报「请求超时 / fetch failed」**:直连被墙 → 配 `BINANCE_PROXY`。
|
|
77
|
+
- **报 401 / -2015**:Key 无效、未开现货读取权限,或本机时间偏差过大。
|
|
78
|
+
- **面板显示「还没有配置币安 API Key」**:宿主没读到 `~/.dsh/argent/ba.env`,检查路径与权限。
|
|
79
|
+
- **左侧栏没有图标**:确认 bundle 已安装并启用;`/api/whyshu-ba/health` 的 `clientPingAt` 为空说明浏览器端 bundle 没加载,刷新页面。
|
|
80
|
+
|
|
81
|
+
## 安全
|
|
82
|
+
|
|
83
|
+
- 只读:不调用下单 / 划转 / 提现接口。
|
|
84
|
+
- Key 仅在宿主进程读取与签名;`/overview` 响应只含计算结果,不含 Key/Secret。
|
|
85
|
+
- 不要把 `ba.env` 提交进任何仓库。
|
package/cordis.patch.yml
ADDED
package/dist/client.js
ADDED
|
@@ -0,0 +1,548 @@
|
|
|
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
|
+
function apply(ctx) {
|
|
517
|
+
try {
|
|
518
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'whyshu-ba: dictionaries')
|
|
519
|
+
} catch (e) {
|
|
520
|
+
console.warn('[whyshu-ba] 语言字典注册失败:', (e && e.message) || e)
|
|
521
|
+
}
|
|
522
|
+
const t = ctx.locale.bind(NS)
|
|
523
|
+
injectCss()
|
|
524
|
+
// 自检信标:宿主 /health 会回报 clientPingAt ⇒ 能区分"bundle 没加载"与"加载了但没显示"
|
|
525
|
+
try { fetch(API + '/ping?from=client').catch(() => {}) } catch { /* noop */ }
|
|
526
|
+
|
|
527
|
+
// ⛔ 永不抛出:注册失败只影响这个面板,不能让 web boot 报 "entry did not activate"
|
|
528
|
+
try {
|
|
529
|
+
ctx.slots.inject('main', () => ctx.slots.register({
|
|
530
|
+
name: 'main',
|
|
531
|
+
key: PANEL_ID,
|
|
532
|
+
locale: NS,
|
|
533
|
+
}, BinancePanelSafe))
|
|
534
|
+
ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({
|
|
535
|
+
name: 'sidebar.panellist',
|
|
536
|
+
id: PANEL_ID,
|
|
537
|
+
order: 20,
|
|
538
|
+
locale: NS,
|
|
539
|
+
label: () => t('panel'),
|
|
540
|
+
}, BinanceIcon))
|
|
541
|
+
} catch (e) {
|
|
542
|
+
console.warn('[whyshu-ba] 面板注册失败(不影响 dsh):', (e && e.message) || e)
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
return { name, inject, apply }
|
|
547
|
+
},
|
|
548
|
+
})
|
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>
|
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,48 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@whyshu/ba",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "DSH 左侧栏币安面板:主视图展示现货资产、合约持仓与行情看板(只读,API Key 仅宿主侧)",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=24.2.0"
|
|
8
|
+
},
|
|
9
|
+
"keywords": [
|
|
10
|
+
"dsh",
|
|
11
|
+
"deepseek-harness",
|
|
12
|
+
"dsh-plugin",
|
|
13
|
+
"cordis",
|
|
14
|
+
"binance",
|
|
15
|
+
"spot",
|
|
16
|
+
"futures",
|
|
17
|
+
"portfolio"
|
|
18
|
+
],
|
|
19
|
+
"exports": {
|
|
20
|
+
".": "./src/index.js",
|
|
21
|
+
"./client": "./dist/client.js",
|
|
22
|
+
"./locale/*.json": "./locale/*.json",
|
|
23
|
+
"./icon": "./icon.svg"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"src/",
|
|
27
|
+
"dist/",
|
|
28
|
+
"locale/",
|
|
29
|
+
"icon.svg",
|
|
30
|
+
"cordis.patch.yml",
|
|
31
|
+
"README.md"
|
|
32
|
+
],
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"dsh": {
|
|
37
|
+
"bundle": {
|
|
38
|
+
"patch": "./cordis.patch.yml"
|
|
39
|
+
},
|
|
40
|
+
"client": {
|
|
41
|
+
"platform": "web",
|
|
42
|
+
"immediately": true
|
|
43
|
+
},
|
|
44
|
+
"plugin": {
|
|
45
|
+
"displayName": "币安账户面板"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
package/src/binance.mjs
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* whyshu-ba · 币安只读数据层(宿主侧)
|
|
3
|
+
*
|
|
4
|
+
* 设计红线:
|
|
5
|
+
* - API Key / Secret **只在宿主进程**读取与签名,永不回传浏览器;
|
|
6
|
+
* - 只读:只调用 GET 行情/账户/持仓接口,不调用任何下单/划转/提现接口;
|
|
7
|
+
* - 每次请求都重新读 `~/.dsh/argent/ba.env`,改 Key / 代理无需重启 dsh;
|
|
8
|
+
* - 代理可配(默认读 BINANCE_PROXY,其次 HTTPS_PROXY),未配置时直连。
|
|
9
|
+
*
|
|
10
|
+
* 配置(~/.dsh/argent/ba.env,权限 600):
|
|
11
|
+
* BINANCE_API_KEY=...
|
|
12
|
+
* BINANCE_API_SECRET=...
|
|
13
|
+
* BINANCE_PROXY=http://127.0.0.1:7890 # 可选;本机直连 api.binance.com 不通时必须配
|
|
14
|
+
* BINANCE_SYMBOLS=BTCUSDT,ETHUSDT,SOLUSDT # 可选,行情看板自选币对
|
|
15
|
+
*/
|
|
16
|
+
import { createHmac } from 'node:crypto'
|
|
17
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
18
|
+
import { homedir } from 'node:os'
|
|
19
|
+
import { join } from 'node:path'
|
|
20
|
+
import http from 'node:http'
|
|
21
|
+
import https from 'node:https'
|
|
22
|
+
import tls from 'node:tls'
|
|
23
|
+
|
|
24
|
+
export const SPOT_BASE = 'https://api.binance.com'
|
|
25
|
+
export const FUTURES_BASE = 'https://fapi.binance.com'
|
|
26
|
+
export const ENV_FILE = join(homedir(), '.dsh', 'argent', 'ba.env')
|
|
27
|
+
export const DEFAULT_SYMBOLS = ['BTCUSDT', 'ETHUSDT', 'SOLUSDT', 'BNBUSDT']
|
|
28
|
+
const TIMEOUT_MS = 12000
|
|
29
|
+
const RECV_WINDOW = 5000
|
|
30
|
+
|
|
31
|
+
/** 币安接口返回的业务错误(code/msg);与网络错误分开,便于前端提示。 */
|
|
32
|
+
export class BinanceError extends Error {
|
|
33
|
+
constructor(message, { status = 0, code = 0 } = {}) {
|
|
34
|
+
super(message)
|
|
35
|
+
this.name = 'BinanceError'
|
|
36
|
+
this.status = status
|
|
37
|
+
this.code = code
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 极简 KEY=VALUE 解析(只支持注释、空行、单/双引号),不引依赖。 */
|
|
42
|
+
export function parseEnvText(text) {
|
|
43
|
+
const out = {}
|
|
44
|
+
for (const raw of String(text || '').split(/\r?\n/)) {
|
|
45
|
+
const line = raw.trim()
|
|
46
|
+
if (!line || line.startsWith('#')) continue
|
|
47
|
+
const eq = line.indexOf('=')
|
|
48
|
+
if (eq <= 0) continue
|
|
49
|
+
const key = line.slice(0, eq).trim()
|
|
50
|
+
let value = line.slice(eq + 1).trim()
|
|
51
|
+
if (value.length >= 2) {
|
|
52
|
+
const q = value[0]
|
|
53
|
+
if ((q === '"' || q === "'") && value[value.length - 1] === q) value = value.slice(1, -1)
|
|
54
|
+
}
|
|
55
|
+
if (key) out[key] = value
|
|
56
|
+
}
|
|
57
|
+
return out
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 读设置:进程环境变量优先,其次 env 文件。envFile 可传(测试用隔离路径)。 */
|
|
61
|
+
export function loadSettings(envFile = ENV_FILE) {
|
|
62
|
+
let file = {}
|
|
63
|
+
try {
|
|
64
|
+
if (existsSync(envFile)) file = parseEnvText(readFileSync(envFile, 'utf8'))
|
|
65
|
+
} catch { /* 读不到就按未配置处理 */ }
|
|
66
|
+
const pick = (name) => {
|
|
67
|
+
const raw = process.env[name] !== undefined ? process.env[name] : file[name]
|
|
68
|
+
return typeof raw === 'string' && raw.trim() ? raw.trim() : ''
|
|
69
|
+
}
|
|
70
|
+
const symbols = pick('BINANCE_SYMBOLS')
|
|
71
|
+
.split(',')
|
|
72
|
+
.map((s) => s.trim().toUpperCase())
|
|
73
|
+
.filter((s) => /^[A-Z0-9]{5,20}$/.test(s))
|
|
74
|
+
const proxy = pick('BINANCE_PROXY')
|
|
75
|
+
|| process.env.HTTPS_PROXY || process.env.https_proxy
|
|
76
|
+
|| process.env.HTTP_PROXY || process.env.http_proxy
|
|
77
|
+
|| ''
|
|
78
|
+
return {
|
|
79
|
+
apiKey: pick('BINANCE_API_KEY'),
|
|
80
|
+
apiSecret: pick('BINANCE_API_SECRET'),
|
|
81
|
+
proxy,
|
|
82
|
+
symbols: symbols.length ? symbols : DEFAULT_SYMBOLS,
|
|
83
|
+
envFile,
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function hasCredentials(settings) {
|
|
88
|
+
return Boolean(settings && settings.apiKey && settings.apiSecret)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 通过 HTTP 代理建立到目标主机的 CONNECT 隧道。
|
|
93
|
+
* @param {string} proxy 代理 URL(http://host:port)
|
|
94
|
+
* @param {URL} target 目标 URL
|
|
95
|
+
* @param {number} timeoutMs 超时
|
|
96
|
+
* @returns {Promise<import('node:net').Socket>} 已连通的裸 TCP socket
|
|
97
|
+
*/
|
|
98
|
+
function openTunnel(proxy, target, timeoutMs) {
|
|
99
|
+
return new Promise((resolve, reject) => {
|
|
100
|
+
let proxyUrl
|
|
101
|
+
try { proxyUrl = new URL(proxy) } catch { reject(new Error('BINANCE_PROXY 不是合法 URL: ' + proxy)); return }
|
|
102
|
+
if (proxyUrl.protocol !== 'http:') { reject(new Error('只支持 http:// 代理,收到: ' + proxyUrl.protocol)); return }
|
|
103
|
+
const port = target.port || '443'
|
|
104
|
+
const host = target.hostname
|
|
105
|
+
const req = http.request({
|
|
106
|
+
host: proxyUrl.hostname,
|
|
107
|
+
port: proxyUrl.port || 80,
|
|
108
|
+
method: 'CONNECT',
|
|
109
|
+
path: host + ':' + port,
|
|
110
|
+
headers: { host: host + ':' + port },
|
|
111
|
+
agent: false,
|
|
112
|
+
timeout: timeoutMs,
|
|
113
|
+
})
|
|
114
|
+
const fail = (e) => { try { req.destroy(e) } catch { reject(e) } }
|
|
115
|
+
req.on('timeout', () => fail(new Error('代理连接超时: ' + proxyUrl.host)))
|
|
116
|
+
req.on('error', (e) => reject(new Error('代理连接失败 ' + proxyUrl.host + ': ' + e.message)))
|
|
117
|
+
req.on('connect', (res, socket) => {
|
|
118
|
+
if (res.statusCode !== 200) {
|
|
119
|
+
socket.destroy()
|
|
120
|
+
reject(new Error('代理 CONNECT 返回 ' + res.statusCode))
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
resolve(socket)
|
|
124
|
+
})
|
|
125
|
+
req.end()
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* GET 一个 JSON(带代理/超时);HTTP 非 2xx 或币安 {code,msg} 抛 BinanceError。
|
|
131
|
+
* 超时是**整段硬超时**:DNS / CONNECT / TLS / 响应任一环节卡住都会按时失败。
|
|
132
|
+
* @param {string} url
|
|
133
|
+
* @param {{headers?: Record<string,string>, proxy?: string, timeoutMs?: number}} [opts]
|
|
134
|
+
*/
|
|
135
|
+
export async function requestJson(url, opts = {}) {
|
|
136
|
+
const target = new URL(url)
|
|
137
|
+
const timeoutMs = opts.timeoutMs || TIMEOUT_MS
|
|
138
|
+
const headers = { accept: 'application/json', 'user-agent': 'whyshu-ba/0.1 (dsh plugin)', ...(opts.headers || {}) }
|
|
139
|
+
|
|
140
|
+
let tunnel = null
|
|
141
|
+
let agent = null
|
|
142
|
+
let req = null
|
|
143
|
+
let timer = null
|
|
144
|
+
let settled = false
|
|
145
|
+
|
|
146
|
+
const cleanup = () => {
|
|
147
|
+
if (timer) { clearTimeout(timer); timer = null }
|
|
148
|
+
if (agent) { try { agent.destroy() } catch { /* noop */ } }
|
|
149
|
+
if (tunnel && tunnel.destroyed === false) { try { tunnel.destroy() } catch { /* noop */ } }
|
|
150
|
+
if (req && req.destroyed === false) { try { req.destroy() } catch { /* noop */ } }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return new Promise((resolve, reject) => {
|
|
154
|
+
const finish = (fn, value) => {
|
|
155
|
+
if (settled) return
|
|
156
|
+
settled = true
|
|
157
|
+
cleanup()
|
|
158
|
+
fn(value)
|
|
159
|
+
}
|
|
160
|
+
const fail = (e) => finish(reject, e instanceof BinanceError ? e : new Error((e && e.message) || String(e)))
|
|
161
|
+
|
|
162
|
+
timer = setTimeout(() => {
|
|
163
|
+
fail(new Error('请求超时(' + timeoutMs + 'ms)' + (opts.proxy ? ',代理 ' + opts.proxy : '')))
|
|
164
|
+
}, timeoutMs)
|
|
165
|
+
|
|
166
|
+
;(async () => {
|
|
167
|
+
try {
|
|
168
|
+
if (opts.proxy) tunnel = await openTunnel(opts.proxy, target, timeoutMs)
|
|
169
|
+
if (settled) return
|
|
170
|
+
if (tunnel) {
|
|
171
|
+
// 走代理:自定义 Agent 把 CONNECT 后的裸 socket 包成 TLS。
|
|
172
|
+
agent = new https.Agent({ keepAlive: false, maxSockets: 1 })
|
|
173
|
+
agent.createConnection = (options) => tls.connect({ socket: tunnel, servername: options.host })
|
|
174
|
+
}
|
|
175
|
+
if (settled) return
|
|
176
|
+
req = https.request({
|
|
177
|
+
host: target.hostname,
|
|
178
|
+
port: target.port || 443,
|
|
179
|
+
path: target.pathname + target.search,
|
|
180
|
+
method: 'GET',
|
|
181
|
+
headers: { ...headers, host: target.hostname, connection: 'close' },
|
|
182
|
+
agent: agent || false,
|
|
183
|
+
}, (res) => {
|
|
184
|
+
const chunks = []
|
|
185
|
+
res.on('data', (c) => chunks.push(c))
|
|
186
|
+
res.on('end', () => {
|
|
187
|
+
const text = Buffer.concat(chunks).toString('utf8')
|
|
188
|
+
let body
|
|
189
|
+
try { body = text ? JSON.parse(text) : {} } catch { body = undefined }
|
|
190
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
191
|
+
finish(resolve, body)
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
const msg = body && (body.msg || body.message) ? String(body.msg || body.message) : text.slice(0, 200)
|
|
195
|
+
finish(reject, new BinanceError('HTTP ' + res.statusCode + ': ' + msg, { status: res.statusCode, code: body && typeof body.code === 'number' ? body.code : 0 }))
|
|
196
|
+
})
|
|
197
|
+
res.on('error', fail)
|
|
198
|
+
})
|
|
199
|
+
req.on('error', fail)
|
|
200
|
+
req.end()
|
|
201
|
+
} catch (e) {
|
|
202
|
+
fail(e)
|
|
203
|
+
}
|
|
204
|
+
})()
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* 批量 symbols 参数:币安**要求原样**的 JSON 数组(`["A","B"]`),
|
|
210
|
+
* 百分号编码会被 `Illegal characters found in parameter 'symbols'` 拒绝。
|
|
211
|
+
*/
|
|
212
|
+
function symbolsParam(symbols) {
|
|
213
|
+
return JSON.stringify([...new Set(symbols)])
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** 公开行情:24h ticker 批量查询(无需鉴权);整批失败时逐个降级。 */
|
|
217
|
+
export async function fetchTickers24h(symbols, settings) {
|
|
218
|
+
if (!symbols || !symbols.length) return []
|
|
219
|
+
const normalize = (r) => ({
|
|
220
|
+
symbol: String(r.symbol || ''),
|
|
221
|
+
lastPrice: Number(r.lastPrice || 0),
|
|
222
|
+
priceChangePercent: Number(r.priceChangePercent || 0),
|
|
223
|
+
highPrice: Number(r.highPrice || 0),
|
|
224
|
+
lowPrice: Number(r.lowPrice || 0),
|
|
225
|
+
quoteVolume: Number(r.quoteVolume || 0),
|
|
226
|
+
})
|
|
227
|
+
try {
|
|
228
|
+
const rows = await requestJson(SPOT_BASE + '/api/v3/ticker/24hr?symbols=' + symbolsParam(symbols), { proxy: settings.proxy })
|
|
229
|
+
if (!Array.isArray(rows)) throw new BinanceError('行情返回格式异常')
|
|
230
|
+
return rows.map(normalize)
|
|
231
|
+
} catch (batchError) {
|
|
232
|
+
// 自选里混了不存在的币对时整批会失败:逐个取,成功的留下。
|
|
233
|
+
const settled = await Promise.all(symbols.map(async (symbol) => {
|
|
234
|
+
try {
|
|
235
|
+
const one = await requestJson(SPOT_BASE + '/api/v3/ticker/24hr?symbol=' + encodeURIComponent(symbol), { proxy: settings.proxy })
|
|
236
|
+
return one && one.symbol ? normalize(one) : null
|
|
237
|
+
} catch { return null }
|
|
238
|
+
}))
|
|
239
|
+
const rows = settled.filter(Boolean)
|
|
240
|
+
if (!rows.length) throw batchError
|
|
241
|
+
return rows
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** 公开行情:批量现价(用于资产折 USDT);整批失败时逐个降级。 */
|
|
246
|
+
export async function fetchPrices(symbols, settings) {
|
|
247
|
+
if (!symbols || !symbols.length) return new Map()
|
|
248
|
+
try {
|
|
249
|
+
const rows = await requestJson(SPOT_BASE + '/api/v3/ticker/price?symbols=' + symbolsParam(symbols), { proxy: settings.proxy })
|
|
250
|
+
const map = new Map()
|
|
251
|
+
if (Array.isArray(rows)) for (const r of rows) map.set(String(r.symbol || ''), Number(r.price || 0))
|
|
252
|
+
return map
|
|
253
|
+
} catch {
|
|
254
|
+
// 某些资产没有 USDT 现货对(或已下线)会让整批失败:逐个取,失败的跳过。
|
|
255
|
+
const map = new Map()
|
|
256
|
+
await Promise.all([...new Set(symbols)].map(async (symbol) => {
|
|
257
|
+
try {
|
|
258
|
+
const one = await requestJson(SPOT_BASE + '/api/v3/ticker/price?symbol=' + encodeURIComponent(symbol), { proxy: settings.proxy })
|
|
259
|
+
if (one && one.price) map.set(String(one.symbol || symbol), Number(one.price))
|
|
260
|
+
} catch { /* 无对应交易对:该资产估值留空 */ }
|
|
261
|
+
}))
|
|
262
|
+
return map
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** 签名 GET(现货/合约共用)。 */
|
|
267
|
+
async function signedGet(base, path, params, settings) {
|
|
268
|
+
const search = new URLSearchParams({
|
|
269
|
+
...params,
|
|
270
|
+
recvWindow: String(RECV_WINDOW),
|
|
271
|
+
timestamp: String(Date.now()),
|
|
272
|
+
}).toString()
|
|
273
|
+
const signature = createHmac('sha256', settings.apiSecret).update(search).digest('hex')
|
|
274
|
+
return requestJson(base + path + '?' + search + '&signature=' + signature, {
|
|
275
|
+
proxy: settings.proxy,
|
|
276
|
+
headers: { 'X-MBX-APIKEY': settings.apiKey },
|
|
277
|
+
})
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** 现货账户:余额 + 现价折 USDT。 */
|
|
281
|
+
export async function fetchSpot(settings) {
|
|
282
|
+
const account = await signedGet(SPOT_BASE, '/api/v3/account', {}, settings)
|
|
283
|
+
const balances = Array.isArray(account.balances) ? account.balances : []
|
|
284
|
+
const rows = balances
|
|
285
|
+
.map((b) => {
|
|
286
|
+
const free = Number(b.free || 0)
|
|
287
|
+
const locked = Number(b.locked || 0)
|
|
288
|
+
return { asset: String(b.asset || ''), free, locked, total: free + locked }
|
|
289
|
+
})
|
|
290
|
+
.filter((b) => b.asset && b.total > 0 && b.total > 1e-12)
|
|
291
|
+
|
|
292
|
+
const pairs = rows
|
|
293
|
+
.filter((b) => b.asset !== 'USDT')
|
|
294
|
+
.map((b) => b.asset + 'USDT')
|
|
295
|
+
let prices = new Map()
|
|
296
|
+
if (pairs.length) {
|
|
297
|
+
try { prices = await fetchPrices(pairs, settings) } catch { /* 无对应现货对的币种不估值 */ }
|
|
298
|
+
}
|
|
299
|
+
const valued = rows.map((b) => {
|
|
300
|
+
const priceUSDT = b.asset === 'USDT' ? 1 : (prices.get(b.asset + 'USDT') || 0)
|
|
301
|
+
return { ...b, priceUSDT, valueUSDT: priceUSDT ? b.total * priceUSDT : null }
|
|
302
|
+
}).sort((a, b) => (b.valueUSDT || 0) - (a.valueUSDT || 0))
|
|
303
|
+
const totalUSDT = valued.reduce((sum, b) => sum + (b.valueUSDT || 0), 0)
|
|
304
|
+
return {
|
|
305
|
+
totalUSDT,
|
|
306
|
+
canTrade: Boolean(account.canTrade),
|
|
307
|
+
accountType: String(account.accountType || ''),
|
|
308
|
+
rows: valued,
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** U 本位合约:账户余额 + 非零持仓。 */
|
|
313
|
+
export async function fetchFutures(settings) {
|
|
314
|
+
const [account, positions] = await Promise.all([
|
|
315
|
+
signedGet(FUTURES_BASE, '/fapi/v2/account', {}, settings),
|
|
316
|
+
signedGet(FUTURES_BASE, '/fapi/v2/positionRisk', {}, settings),
|
|
317
|
+
])
|
|
318
|
+
const rows = (Array.isArray(positions) ? positions : [])
|
|
319
|
+
.map((p) => {
|
|
320
|
+
const amt = Number(p.positionAmt || 0)
|
|
321
|
+
return {
|
|
322
|
+
symbol: String(p.symbol || ''),
|
|
323
|
+
side: amt > 0 ? 'LONG' : amt < 0 ? 'SHORT' : 'FLAT',
|
|
324
|
+
positionAmt: amt,
|
|
325
|
+
entryPrice: Number(p.entryPrice || 0),
|
|
326
|
+
markPrice: Number(p.markPrice || 0),
|
|
327
|
+
unrealizedProfit: Number(p.unRealizedProfit || p.unrealizedProfit || 0),
|
|
328
|
+
leverage: Number(p.leverage || 0),
|
|
329
|
+
liquidationPrice: Number(p.liquidationPrice || 0),
|
|
330
|
+
marginType: String(p.marginType || ''),
|
|
331
|
+
}
|
|
332
|
+
})
|
|
333
|
+
.filter((p) => p.symbol && p.positionAmt !== 0)
|
|
334
|
+
return {
|
|
335
|
+
totalWalletBalance: Number(account.totalWalletBalance || 0),
|
|
336
|
+
totalUnrealizedProfit: Number(account.totalUnrealizedProfit || 0),
|
|
337
|
+
totalMarginBalance: Number(account.totalMarginBalance || 0),
|
|
338
|
+
availableBalance: Number(account.availableBalance || 0),
|
|
339
|
+
positions: rows,
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* 面板总览:行情总是有(公开接口);账户/持仓在配好 Key 后才有。
|
|
345
|
+
* 各段独立失败:一段报错不影响其他段。
|
|
346
|
+
* @param {ReturnType<typeof loadSettings>} [settingsIn]
|
|
347
|
+
*/
|
|
348
|
+
export async function fetchOverview(settingsIn) {
|
|
349
|
+
const settings = settingsIn || loadSettings()
|
|
350
|
+
const configured = hasCredentials(settings)
|
|
351
|
+
const errors = []
|
|
352
|
+
const mark = (section, e) => {
|
|
353
|
+
const message = e instanceof BinanceError ? e.message : String((e && e.message) || e)
|
|
354
|
+
errors.push({ section, message })
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
let tickers = []
|
|
358
|
+
try {
|
|
359
|
+
tickers = await fetchTickers24h(settings.symbols, settings)
|
|
360
|
+
} catch (e) {
|
|
361
|
+
mark('market', e)
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
let spot = null
|
|
365
|
+
let futures = null
|
|
366
|
+
if (configured) {
|
|
367
|
+
try { spot = await fetchSpot(settings) } catch (e) { mark('spot', e) }
|
|
368
|
+
try { futures = await fetchFutures(settings) } catch (e) { mark('futures', e) }
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// 行情按自选顺序返回(币安接口不保证顺序)
|
|
372
|
+
const bySymbol = new Map(tickers.map((k) => [k.symbol, k]))
|
|
373
|
+
const ordered = settings.symbols.map((s) => bySymbol.get(s)).filter(Boolean)
|
|
374
|
+
|
|
375
|
+
return {
|
|
376
|
+
at: new Date().toISOString(),
|
|
377
|
+
configured,
|
|
378
|
+
proxy: Boolean(settings.proxy),
|
|
379
|
+
symbols: settings.symbols,
|
|
380
|
+
tickers: ordered.length ? ordered : tickers,
|
|
381
|
+
spot,
|
|
382
|
+
futures,
|
|
383
|
+
errors,
|
|
384
|
+
}
|
|
385
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* whyshu-ba · 宿主侧插件
|
|
3
|
+
*
|
|
4
|
+
* 只做两件事:
|
|
5
|
+
* 1. 注册本地只读端点 `/api/whyshu-ba/*`(币安 Key 只在宿主进程使用,不下发浏览器);
|
|
6
|
+
* 2. 由 bundle 的客户端部分(dist/client.js)在左侧栏注册一个面板入口(id=binance),
|
|
7
|
+
* 点击后在主视图渲染 `main` slot 的币安面板。
|
|
8
|
+
*
|
|
9
|
+
* 红线:只读(只打行情/账户/持仓 GET 接口);接口异常只返回错误 JSON,不阻塞 dsh 启动。
|
|
10
|
+
*/
|
|
11
|
+
import { readFileSync } from 'node:fs'
|
|
12
|
+
import { dirname, join } from 'node:path'
|
|
13
|
+
import { fileURLToPath } from 'node:url'
|
|
14
|
+
|
|
15
|
+
import { ENV_FILE, fetchOverview, hasCredentials, loadSettings } from './binance.mjs'
|
|
16
|
+
|
|
17
|
+
/** 每次都读盘:重装后 version 仍能反映当前磁盘版本 */
|
|
18
|
+
function pkgVersion() {
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(readFileSync(join(dirname(dirname(fileURLToPath(import.meta.url))), 'package.json'), 'utf8')).version || '0.0.0'
|
|
21
|
+
} catch { return '0.0.0' }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 客户端 bundle 是否真的被加载过(区分"bundle 没加载" vs "加载了但没显示") */
|
|
25
|
+
const CLIENT_PING = { at: null, ua: '' }
|
|
26
|
+
|
|
27
|
+
const json = (res, code, body) => {
|
|
28
|
+
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' })
|
|
29
|
+
res.end(JSON.stringify(body))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function handle(req, res) {
|
|
33
|
+
const url = new URL(req.url || '/', 'http://localhost')
|
|
34
|
+
if (req.method !== 'GET') return json(res, 405, { ok: false, error: 'method_not_allowed' })
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
if (url.pathname === '/api/whyshu-ba/health') {
|
|
38
|
+
const settings = loadSettings()
|
|
39
|
+
return json(res, 200, {
|
|
40
|
+
ok: true,
|
|
41
|
+
version: pkgVersion(),
|
|
42
|
+
configured: hasCredentials(settings),
|
|
43
|
+
proxy: Boolean(settings.proxy),
|
|
44
|
+
symbols: settings.symbols,
|
|
45
|
+
envFile: ENV_FILE,
|
|
46
|
+
clientPingAt: CLIENT_PING.at,
|
|
47
|
+
clientUa: CLIENT_PING.ua,
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
if (url.pathname === '/api/whyshu-ba/ping') {
|
|
51
|
+
const at = new Date().toISOString()
|
|
52
|
+
const ua = String((req.headers && req.headers['user-agent']) || '').slice(0, 80)
|
|
53
|
+
Object.assign(CLIENT_PING, { at, ua })
|
|
54
|
+
return json(res, 200, { ok: true })
|
|
55
|
+
}
|
|
56
|
+
if (url.pathname === '/api/whyshu-ba/overview') {
|
|
57
|
+
return json(res, 200, { ok: true, data: await fetchOverview() })
|
|
58
|
+
}
|
|
59
|
+
return json(res, 404, { ok: false, error: 'not_found' })
|
|
60
|
+
} catch (e) {
|
|
61
|
+
return json(res, 502, {
|
|
62
|
+
ok: false,
|
|
63
|
+
error: 'upstream_error',
|
|
64
|
+
message: String((e && e.message) || e).slice(0, 300),
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const name = 'whyshu-ba'
|
|
70
|
+
export const inject = ['webServer']
|
|
71
|
+
|
|
72
|
+
export function apply(ctx) {
|
|
73
|
+
// ⛔ 永不抛出:插件激活失败会让整个 web boot 报 "entry did not activate"。
|
|
74
|
+
try {
|
|
75
|
+
if (!ctx || !ctx.webServer || typeof ctx.webServer.register !== 'function') {
|
|
76
|
+
console.error('[whyshu-ba] 未拿到 webServer 服务,跳过端点注册(面板会显示 404,但 dsh 正常启动)')
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: '/api/whyshu-ba', handler: handle }), 'whyshu-ba: read-only binance endpoints')
|
|
80
|
+
console.log(`[whyshu-ba] 已注册 /api/whyshu-ba/*(v${pkgVersion()},只读,币安 Key 仅在宿主侧使用)`)
|
|
81
|
+
} catch (e) {
|
|
82
|
+
console.error('[whyshu-ba] 端点注册失败(不影响 dsh 其他功能):', (e && e.message) || e)
|
|
83
|
+
}
|
|
84
|
+
}
|