dsh-whale-widget 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 +121 -0
- package/assets/DSniang02.png +0 -0
- package/cordis.patch.yml +15 -0
- package/lib/index.js +627 -0
- package/package.json +26 -0
package/README.md
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# DSH 小鲸鱼余额挂件(DeepSeek Balance Whale Widget)
|
|
2
|
+
|
|
3
|
+
DeepSeek Harness(DSH)Web 界面右下角的常驻余额挂件:本地小鲸鱼气泡图 + DeepSeek API 余额,每次打开界面自动启用。本项目是标准 DSH 插件包,可通过 `dsh plugin` 安装/卸载。
|
|
4
|
+
|
|
5
|
+
## 特性
|
|
6
|
+
|
|
7
|
+
- 🐋 常驻自启:随 DSH Web 界面每次打开自动出现(标准 DSH bundle 插件)
|
|
8
|
+
- 💰 余额:60 秒自动刷新 + 点击手动刷新;余额变化时数字**滚动动画**;瞬时网络抖动自动沿用最近余额不报错
|
|
9
|
+
- 🖱️ 拖拽 + **四边四分之一吸附**(左/右/上/下,角落可组合)
|
|
10
|
+
- 🔄 左吸附时整体**水平镜像翻转**(文字同步反向、带动画)
|
|
11
|
+
- 🧸 **按压 Q 弹**玩偶效果(按压时底部坐标不变)
|
|
12
|
+
- 🎚️ 悬停显示大小调节按钮(0.6–1.4 倍,尺寸记忆)
|
|
13
|
+
- 📐 随浏览器窗口自动缩放;文字位置/字号与图片联动
|
|
14
|
+
|
|
15
|
+
## 目录结构
|
|
16
|
+
|
|
17
|
+
```text
|
|
18
|
+
dsh-whale-widget/
|
|
19
|
+
├── package.json # DSH bundle 插件元数据
|
|
20
|
+
├── README.md # 本文件
|
|
21
|
+
├── cordis.patch.yml # 插件挂载声明
|
|
22
|
+
├── lib/
|
|
23
|
+
│ └── index.js # 宿主侧插件本体
|
|
24
|
+
├── assets/
|
|
25
|
+
│ └── DSniang02.png # 小鲸鱼气泡图
|
|
26
|
+
└── whale-widget-prompt.md # 完整规格/维护提示词
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## 安装
|
|
30
|
+
|
|
31
|
+
### 方式 A:本地开发安装(当前项目)
|
|
32
|
+
|
|
33
|
+
在项目根目录(`DeepSeek-Balance-Whale-Widget-main`)执行:
|
|
34
|
+
|
|
35
|
+
```powershell
|
|
36
|
+
dsh plugin --profile web add link:.\dsh-whale-widget
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
说明:
|
|
40
|
+
|
|
41
|
+
- `dsh plugin` 会把参数转发给 pnpm,并在成功后自动把 `dsh-whale-widget` 加入 `dsh.profile.bundles`
|
|
42
|
+
- 使用 `link:` 会在 profile 的 `node_modules` 里链接到当前源码目录,方便继续改代码
|
|
43
|
+
- 安装完成后重启 `dsh web`,再 F5 刷新浏览器
|
|
44
|
+
- **如果之后移动了源码目录**,必须重新到新的项目根目录执行一次:
|
|
45
|
+
```powershell
|
|
46
|
+
dsh plugin --profile web add link:.\dsh-whale-widget
|
|
47
|
+
```
|
|
48
|
+
因为 `link:` 记录的是源目录的绝对路径;移动后旧链接会失效。若提示已存在/冲突,可先 `dsh plugin --profile web remove dsh-whale-widget` 再重新 add。
|
|
49
|
+
|
|
50
|
+
### 方式 B:发布到 npm 后安装
|
|
51
|
+
|
|
52
|
+
如果你把这个包发布到 npm:
|
|
53
|
+
|
|
54
|
+
```powershell
|
|
55
|
+
cd dsh-whale-widget
|
|
56
|
+
npm publish
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
然后任意机器上安装:
|
|
60
|
+
|
|
61
|
+
```powershell
|
|
62
|
+
dsh plugin --profile web add dsh-whale-widget
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## 卸载
|
|
66
|
+
|
|
67
|
+
```powershell
|
|
68
|
+
dsh plugin --profile web remove dsh-whale-widget
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## 从旧手动安装升级
|
|
72
|
+
|
|
73
|
+
如果你之前按旧方式手动安装过(复制 `whale-balance.mjs` + 改 `cordis.patch.yml`),先清理:
|
|
74
|
+
|
|
75
|
+
```powershell
|
|
76
|
+
$web = "$env:USERPROFILE\.dsh\profiles\web"
|
|
77
|
+
|
|
78
|
+
Remove-Item "$web\whale-balance.mjs" -ErrorAction SilentlyContinue
|
|
79
|
+
Remove-Item "$web\DSniang02.png" -ErrorAction SilentlyContinue
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
然后编辑 `$web\cordis.patch.yml`,删除这段旧补丁:
|
|
83
|
+
|
|
84
|
+
```yaml
|
|
85
|
+
- insert:
|
|
86
|
+
- id: whale-balance-widget
|
|
87
|
+
name: ./whale-balance.mjs?v=1
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
如果里面只有这段,直接改成:
|
|
91
|
+
|
|
92
|
+
```yaml
|
|
93
|
+
[]
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
清理后再执行上面的安装命令。
|
|
97
|
+
|
|
98
|
+
## 验证
|
|
99
|
+
|
|
100
|
+
```powershell
|
|
101
|
+
dsh --profile web --dump-config | Select-String -Pattern "whale"
|
|
102
|
+
|
|
103
|
+
curl http://127.0.0.1:3080/dsh-whale/image.png
|
|
104
|
+
curl http://127.0.0.1:3080/dsh-whale/balance.json
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
- `/dsh-whale/image.png` → 200 `image/png`
|
|
108
|
+
- `/dsh-whale/balance.json` → 200,含 `{"ok":true,"totalBalance":...,"currency":"CNY"}`
|
|
109
|
+
- 浏览器 F5 后右下角出现挂件
|
|
110
|
+
|
|
111
|
+
## 常见问题
|
|
112
|
+
|
|
113
|
+
- **挂件不出现**:确认 `dsh plugin add` 成功;`dsh --profile web --dump-config` 里能看到 `dsh-whale-widget`;重启 `dsh web` 后 F5。
|
|
114
|
+
- **图片不显示**:确认 `assets/DSniang02.png` 在插件包内,且没有把旧文件放在 profile 里占用了同名路由。
|
|
115
|
+
- **余额报「未配置 DEEPSEEK_API_KEY」**:去 DSH 配置凭据。
|
|
116
|
+
- **本地开发改了代码不生效**:使用 `link:` 安装时,修改源码后重启 `dsh web`(ESM 模块缓存);如果用已发布版本,需要 `npm publish` 新版本后 `dsh plugin --profile web update dsh-whale-widget`。
|
|
117
|
+
- **自定义图片**:必须是 1026×1026、气泡几何一致(中心 455,247、长轴 710、纵轴 430),否则按 `whale-widget-prompt.md` 调整文字定位参数。
|
|
118
|
+
|
|
119
|
+
## 开发与维护
|
|
120
|
+
|
|
121
|
+
完整规格、视觉参数、架构结论和生成提示词见 `whale-widget-prompt.md`。修改文字位置、颜色、动画、吸附逻辑时参考该文件。
|
|
Binary file
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# ============================================================================
|
|
2
|
+
# dsh-whale-widget —— DSH bundle patch
|
|
3
|
+
# ============================================================================
|
|
4
|
+
# 这是插件的"挂载声明"。DSH 启动时按 bundle 层栈叠加各插件 patch,
|
|
5
|
+
# 本文件会把鲸鱼余额挂件插入 Web profile 的配置树。
|
|
6
|
+
#
|
|
7
|
+
# 安装方式(不再需要手工复制/手工改 profile patch):
|
|
8
|
+
# dsh plugin --profile web add dsh-whale-widget
|
|
9
|
+
# 或本地开发安装:
|
|
10
|
+
# dsh plugin --profile web add link:<本目录绝对路径>
|
|
11
|
+
# ============================================================================
|
|
12
|
+
|
|
13
|
+
- insert:
|
|
14
|
+
- id: dsh-whale-widget
|
|
15
|
+
name: dsh-whale-widget
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
|
|
6
|
+
// Package root: lib/index.js -> package root. This keeps the bundle relocatable
|
|
7
|
+
// when installed as a normal DSH npm plugin (node_modules or a local link).
|
|
8
|
+
const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
9
|
+
|
|
10
|
+
// DSH home: used for the widget size memory file, since node_modules may be
|
|
11
|
+
// read-only or cleaned on update.
|
|
12
|
+
const DSH_HOME = process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
|
|
13
|
+
|
|
14
|
+
// Resolve the whale image relative to the package first, so the bundle works
|
|
15
|
+
// no matter where it is installed. Legacy absolute paths remain as harmless
|
|
16
|
+
// fallbacks for older manual installs.
|
|
17
|
+
const IMAGE_CANDIDATES = [
|
|
18
|
+
path.join(PACKAGE_ROOT, 'assets', 'DSniang02.png'),
|
|
19
|
+
path.join(PACKAGE_ROOT, 'DSniang02.png'),
|
|
20
|
+
'D:/TestBox/deepseek/DSniang02.png',
|
|
21
|
+
'D:/TestBox/deepseek/skin/DSniang02.png',
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
// Size memory file: prefer writable DSH home locations, then legacy fallbacks.
|
|
25
|
+
const SIZE_FILE_CANDIDATES = [
|
|
26
|
+
path.join(DSH_HOME, '.dshw-size.json'),
|
|
27
|
+
path.join(DSH_HOME, 'profiles', 'web', '.dshw-size.json'),
|
|
28
|
+
'D:/TestBox/deepseek/.dshw-size.json',
|
|
29
|
+
'D:/TestBox/deepseek/skin/.dshw-size.json',
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
const BALANCE_URL = 'https://api.deepseek.com/user/balance'
|
|
33
|
+
const BALANCE_TTL_MS = 25000
|
|
34
|
+
|
|
35
|
+
const JSON_HEADERS = {
|
|
36
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
37
|
+
'Access-Control-Allow-Origin': '*',
|
|
38
|
+
'Cache-Control': 'no-store',
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const WIDGET_JS = `(function () {
|
|
42
|
+
if (window.__dshWhaleWidget) return
|
|
43
|
+
window.__dshWhaleWidget = true
|
|
44
|
+
|
|
45
|
+
var MIN_SCALE = 0.6
|
|
46
|
+
var MAX_SCALE = 1.4
|
|
47
|
+
var STEP = 0.1
|
|
48
|
+
var CLICK_SQ = 9
|
|
49
|
+
var REFRESH_MS = 60000
|
|
50
|
+
var CHANGE_MS = 900
|
|
51
|
+
var ANIM_MS = 700
|
|
52
|
+
var FETCH_TIMEOUT_MS = 25000
|
|
53
|
+
var BALANCE_URL = '/dsh-whale/balance.json'
|
|
54
|
+
var SIZE_URL = '/dsh-whale/size.json'
|
|
55
|
+
var IMG_URL = '/dsh-whale/image.png'
|
|
56
|
+
|
|
57
|
+
var css = [
|
|
58
|
+
'.dshwv-root{position:fixed;right:0;bottom:0;--dshw-scale:1;--dshw-base:clamp(96px,calc(min(196px,min(100vw,100vh) * 0.22) * var(--dshw-scale)),292px);width:var(--dshw-base);height:var(--dshw-base);cursor:grab;touch-action:none;user-select:none;-webkit-user-select:none;z-index:9999;font-family:inherit;transition:left .16s ease,top .16s ease,transform .3s ease}',
|
|
59
|
+
'.dshwv-root.dshwv-left{transform:scaleX(-1)}',
|
|
60
|
+
'.dshwv-root.dshwv-dragging{cursor:grabbing;transition:none}',
|
|
61
|
+
'.dshwv-body{position:absolute;left:0;top:0;width:100%;height:100%;transform-origin:50% 100%;transition:transform .22s cubic-bezier(.34,1.56,.64,1)}',
|
|
62
|
+
'.dshwv-img{width:var(--dshw-base);height:var(--dshw-base);display:block;pointer-events:none;-webkit-user-drag:none;user-select:none}',
|
|
63
|
+
'.dshwv-text{position:absolute;left:44.346%;top:25.5%;transform:translate(-50%,-50%);text-align:center;color:#536ba9;line-height:1.18;white-space:nowrap;--dshw-u:calc(var(--dshw-base) / 1026);pointer-events:none;transition:transform .3s ease}',
|
|
64
|
+
'.dshwv-root.dshwv-left .dshwv-text{transform:translate(-50%,-50%) scaleX(-1)}',
|
|
65
|
+
'.dshwv-label{font-size:calc(var(--dshw-u) * 68);font-weight:600;letter-spacing:.06em}',
|
|
66
|
+
'.dshwv-amount{font-size:calc(var(--dshw-u) * 119);font-weight:800;line-height:1.05}',
|
|
67
|
+
'.dshwv-hint{font-size:calc(var(--dshw-u) * 54);color:#9fb0d9;letter-spacing:.02em}',
|
|
68
|
+
'.dshwv-size{position:absolute;top:4px;right:4px;display:flex;gap:4px;opacity:0;transition:opacity .15s ease;z-index:2}',
|
|
69
|
+
'.dshwv-root:hover .dshwv-size{opacity:1}',
|
|
70
|
+
'.dshwv-size button{width:20px;height:20px;border:none;border-radius:50%;background:rgba(83,107,169,.85);color:#fff;font-size:13px;line-height:1;padding:0;cursor:pointer;display:flex;align-items:center;justify-content:center;user-select:none}',
|
|
71
|
+
'.dshwv-size button:hover{background:#536ba9}'
|
|
72
|
+
].join('\\n')
|
|
73
|
+
|
|
74
|
+
var styleEl = document.createElement('style')
|
|
75
|
+
styleEl.textContent = css
|
|
76
|
+
document.head.appendChild(styleEl)
|
|
77
|
+
|
|
78
|
+
var root = document.createElement('div')
|
|
79
|
+
root.className = 'dshwv-root'
|
|
80
|
+
|
|
81
|
+
var img = document.createElement('img')
|
|
82
|
+
img.className = 'dshwv-img'
|
|
83
|
+
img.src = IMG_URL
|
|
84
|
+
img.alt = 'DeepSeek 余额'
|
|
85
|
+
img.draggable = false
|
|
86
|
+
|
|
87
|
+
var sizeBox = document.createElement('div')
|
|
88
|
+
sizeBox.className = 'dshwv-size'
|
|
89
|
+
function makeBtn(text, title, delta) {
|
|
90
|
+
var b = document.createElement('button')
|
|
91
|
+
b.type = 'button'
|
|
92
|
+
b.textContent = text
|
|
93
|
+
b.title = title
|
|
94
|
+
b.addEventListener('pointerdown', function (e) { e.stopPropagation() })
|
|
95
|
+
b.addEventListener('click', function (e) { e.stopPropagation(); adjust(delta) })
|
|
96
|
+
return b
|
|
97
|
+
}
|
|
98
|
+
sizeBox.appendChild(makeBtn('-', '缩小', -STEP))
|
|
99
|
+
sizeBox.appendChild(makeBtn('+', '放大', STEP))
|
|
100
|
+
|
|
101
|
+
var textBox = document.createElement('div')
|
|
102
|
+
textBox.className = 'dshwv-text'
|
|
103
|
+
var labelEl = document.createElement('div')
|
|
104
|
+
labelEl.className = 'dshwv-label'
|
|
105
|
+
labelEl.textContent = 'DeepSeek 余额'
|
|
106
|
+
var amountEl = document.createElement('div')
|
|
107
|
+
amountEl.className = 'dshwv-amount'
|
|
108
|
+
var hintEl = document.createElement('div')
|
|
109
|
+
hintEl.className = 'dshwv-hint'
|
|
110
|
+
textBox.appendChild(labelEl)
|
|
111
|
+
textBox.appendChild(amountEl)
|
|
112
|
+
textBox.appendChild(hintEl)
|
|
113
|
+
|
|
114
|
+
var body = document.createElement('div')
|
|
115
|
+
body.className = 'dshwv-body'
|
|
116
|
+
body.appendChild(img)
|
|
117
|
+
body.appendChild(sizeBox)
|
|
118
|
+
body.appendChild(textBox)
|
|
119
|
+
root.appendChild(body)
|
|
120
|
+
document.body.appendChild(root)
|
|
121
|
+
|
|
122
|
+
// Position model: the widget is ALWAYS expressed in left/top px (so edge snaps
|
|
123
|
+
// animate smoothly via the CSS transition on both sides — switching to
|
|
124
|
+
// right/auto cannot transition and flashes). The anchor info (h/v + offsets)
|
|
125
|
+
// lives in state and is used by settle() to recompute coordinates on window
|
|
126
|
+
// resize and size changes, keeping the widget glued to its anchored edge.
|
|
127
|
+
var state = {
|
|
128
|
+
scale: 1,
|
|
129
|
+
h: 'right',
|
|
130
|
+
hOff: 0,
|
|
131
|
+
v: 'bottom',
|
|
132
|
+
vOff: 0,
|
|
133
|
+
left: 0,
|
|
134
|
+
top: 0,
|
|
135
|
+
balance: null,
|
|
136
|
+
currency: null,
|
|
137
|
+
status: 'loading',
|
|
138
|
+
message: ''
|
|
139
|
+
}
|
|
140
|
+
var busy = false
|
|
141
|
+
var settleTimer = null
|
|
142
|
+
var drag = null
|
|
143
|
+
var shown = null
|
|
144
|
+
var animId = null
|
|
145
|
+
|
|
146
|
+
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v) }
|
|
147
|
+
function viewport() {
|
|
148
|
+
return {
|
|
149
|
+
w: window.innerWidth || document.documentElement.clientWidth || 1280,
|
|
150
|
+
h: window.innerHeight || document.documentElement.clientHeight || 800
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
function fmt(balance, currency) {
|
|
154
|
+
var num = Number(balance)
|
|
155
|
+
var fixed = isFinite(num) ? num.toFixed(2) : '--'
|
|
156
|
+
return currency === 'CNY' ? '¥ ' + fixed : fixed + ' ' + currency
|
|
157
|
+
}
|
|
158
|
+
function animateAmount(from, to, currency, duration) {
|
|
159
|
+
if (animId) cancelAnimationFrame(animId)
|
|
160
|
+
if (from === null || !isFinite(from)) from = to
|
|
161
|
+
if (from === to) {
|
|
162
|
+
shown = to
|
|
163
|
+
amountEl.textContent = fmt(to, currency)
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
var startTime = null
|
|
167
|
+
function step(ts) {
|
|
168
|
+
if (startTime === null) startTime = ts
|
|
169
|
+
var t = Math.min(1, (ts - startTime) / duration)
|
|
170
|
+
var eased = 1 - Math.pow(1 - t, 3)
|
|
171
|
+
var val = from + (to - from) * eased
|
|
172
|
+
amountEl.textContent = fmt(val, currency)
|
|
173
|
+
if (t < 1) {
|
|
174
|
+
animId = requestAnimationFrame(step)
|
|
175
|
+
} else {
|
|
176
|
+
animId = null
|
|
177
|
+
shown = to
|
|
178
|
+
amountEl.textContent = fmt(to, currency)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
animId = requestAnimationFrame(step)
|
|
182
|
+
}
|
|
183
|
+
function render() {
|
|
184
|
+
var amount, hint
|
|
185
|
+
if (state.status === 'loading') {
|
|
186
|
+
amount = shown !== null ? fmt(shown, state.currency) : '…'
|
|
187
|
+
hint = '加载中…'
|
|
188
|
+
} else if (state.status === 'error') {
|
|
189
|
+
amount = shown !== null ? fmt(shown, state.currency) : '--'
|
|
190
|
+
hint = state.message ? state.message.slice(0, 14) : '获取失败 · 点击重试'
|
|
191
|
+
} else {
|
|
192
|
+
amount = shown !== null ? fmt(shown, state.currency) : (state.balance !== null ? fmt(state.balance, state.currency) : '--')
|
|
193
|
+
hint = state.status === 'changing' ? '加载中…' : '点击刷新'
|
|
194
|
+
}
|
|
195
|
+
amountEl.textContent = amount
|
|
196
|
+
hintEl.textContent = hint
|
|
197
|
+
}
|
|
198
|
+
function express() {
|
|
199
|
+
root.style.right = 'auto'
|
|
200
|
+
root.style.bottom = 'auto'
|
|
201
|
+
root.style.left = state.left + 'px'
|
|
202
|
+
root.style.top = state.top + 'px'
|
|
203
|
+
root.classList.toggle('dshwv-left', state.h === 'left')
|
|
204
|
+
}
|
|
205
|
+
function settle() {
|
|
206
|
+
var vp = viewport()
|
|
207
|
+
var w = root.offsetWidth || root.getBoundingClientRect().width || 0
|
|
208
|
+
var h = root.offsetHeight || root.getBoundingClientRect().height || 0
|
|
209
|
+
if (drag && drag.active) {
|
|
210
|
+
// mid-drag resize: keep the pointer-follow position, just clamp into view
|
|
211
|
+
state.left = clamp(state.left, 0, Math.max(0, vp.w - w))
|
|
212
|
+
state.top = clamp(state.top, 0, Math.max(0, vp.h - h))
|
|
213
|
+
express()
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
if (state.h === 'right') {
|
|
217
|
+
state.left = Math.max(0, vp.w - w - state.hOff)
|
|
218
|
+
} else if (state.h === 'left') {
|
|
219
|
+
state.left = state.hOff
|
|
220
|
+
} else {
|
|
221
|
+
state.left = clamp(state.left, 0, Math.max(0, vp.w - w))
|
|
222
|
+
}
|
|
223
|
+
if (state.v === 'bottom') {
|
|
224
|
+
state.top = Math.max(0, vp.h - h - state.vOff)
|
|
225
|
+
} else if (state.v === 'top') {
|
|
226
|
+
state.top = state.vOff
|
|
227
|
+
} else {
|
|
228
|
+
state.top = clamp(state.top, 0, Math.max(0, vp.h - h))
|
|
229
|
+
}
|
|
230
|
+
express()
|
|
231
|
+
}
|
|
232
|
+
function refresh(manual) {
|
|
233
|
+
if (busy) return
|
|
234
|
+
busy = true
|
|
235
|
+
if (manual || state.balance === null) { state.status = 'loading'; render() }
|
|
236
|
+
var ctrl = null
|
|
237
|
+
var timer = null
|
|
238
|
+
try {
|
|
239
|
+
ctrl = new AbortController()
|
|
240
|
+
timer = setTimeout(function () { try { ctrl.abort() } catch (err) {} }, FETCH_TIMEOUT_MS)
|
|
241
|
+
} catch (err) {}
|
|
242
|
+
fetch(BALANCE_URL, { cache: 'no-store', signal: ctrl ? ctrl.signal : undefined })
|
|
243
|
+
.then(function (r) { return r.json() })
|
|
244
|
+
.then(function (data) {
|
|
245
|
+
if (data && data.ok) {
|
|
246
|
+
var nb = Number(data.totalBalance)
|
|
247
|
+
var nc = String(data.currency || 'CNY')
|
|
248
|
+
var changed = state.balance !== null && (nb !== state.balance || nc !== state.currency)
|
|
249
|
+
var currencyChanged = state.currency !== null && nc !== state.currency
|
|
250
|
+
state.balance = nb
|
|
251
|
+
state.currency = nc
|
|
252
|
+
state.message = ''
|
|
253
|
+
if (changed && !currencyChanged) {
|
|
254
|
+
if (!manual) {
|
|
255
|
+
state.status = 'changing'
|
|
256
|
+
animateAmount(shown, nb, nc, ANIM_MS)
|
|
257
|
+
if (settleTimer) clearTimeout(settleTimer)
|
|
258
|
+
settleTimer = setTimeout(function () {
|
|
259
|
+
settleTimer = null
|
|
260
|
+
if (state.status === 'changing') { state.status = 'ok'; render() }
|
|
261
|
+
}, CHANGE_MS)
|
|
262
|
+
} else {
|
|
263
|
+
animateAmount(shown, nb, nc, ANIM_MS)
|
|
264
|
+
state.status = 'ok'
|
|
265
|
+
render()
|
|
266
|
+
}
|
|
267
|
+
} else {
|
|
268
|
+
if (animId === null) shown = nb
|
|
269
|
+
state.status = 'ok'
|
|
270
|
+
render()
|
|
271
|
+
}
|
|
272
|
+
} else {
|
|
273
|
+
state.status = 'error'
|
|
274
|
+
state.message = (data && data.error) ? String(data.error) : '获取失败'
|
|
275
|
+
render()
|
|
276
|
+
}
|
|
277
|
+
})
|
|
278
|
+
.catch(function () {
|
|
279
|
+
state.status = 'error'
|
|
280
|
+
state.message = '获取失败'
|
|
281
|
+
render()
|
|
282
|
+
})
|
|
283
|
+
.finally(function () {
|
|
284
|
+
busy = false
|
|
285
|
+
if (timer) clearTimeout(timer)
|
|
286
|
+
})
|
|
287
|
+
}
|
|
288
|
+
function adjust(delta) {
|
|
289
|
+
var next = Math.round(Math.min(MAX_SCALE, Math.max(MIN_SCALE, state.scale + delta)) * 10) / 10
|
|
290
|
+
state.scale = next
|
|
291
|
+
root.style.setProperty('--dshw-scale', String(next))
|
|
292
|
+
try {
|
|
293
|
+
fetch(SIZE_URL, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ scale: next }) })
|
|
294
|
+
} catch (err) {}
|
|
295
|
+
settle()
|
|
296
|
+
}
|
|
297
|
+
var SQUISH = 'scaleY(0.88) scaleX(1.05)'
|
|
298
|
+
function pressDown() {
|
|
299
|
+
body.style.transform = SQUISH
|
|
300
|
+
}
|
|
301
|
+
function pressUp() {
|
|
302
|
+
body.style.transform = 'scaleY(1) scaleX(1)'
|
|
303
|
+
}
|
|
304
|
+
function onPointerDown(e) {
|
|
305
|
+
if (e.button !== 0) return
|
|
306
|
+
try { root.setPointerCapture(e.pointerId) } catch (err) {}
|
|
307
|
+
var vp = viewport()
|
|
308
|
+
var rect = root.getBoundingClientRect()
|
|
309
|
+
drag = { active: true, startX: e.clientX, startY: e.clientY, origLeft: rect.left, origTop: rect.top, w: rect.width, h: rect.height, moved: false, vp: vp }
|
|
310
|
+
root.classList.add('dshwv-dragging')
|
|
311
|
+
pressDown()
|
|
312
|
+
}
|
|
313
|
+
function onPointerMove(e) {
|
|
314
|
+
if (!drag || !drag.active) return
|
|
315
|
+
var dx = e.clientX - drag.startX
|
|
316
|
+
var dy = e.clientY - drag.startY
|
|
317
|
+
if (dx * dx + dy * dy >= CLICK_SQ) drag.moved = true
|
|
318
|
+
// Keep the pre-drag flip orientation while dragging (state.h/v stay as they
|
|
319
|
+
// were); on release endDrag() recomputes the anchors and settle() flips the
|
|
320
|
+
// class with a smooth transition instead of reverting instantly.
|
|
321
|
+
state.left = clamp(drag.origLeft + dx, 0, Math.max(0, drag.vp.w - drag.w))
|
|
322
|
+
state.top = clamp(drag.origTop + dy, 0, Math.max(0, drag.vp.h - drag.h))
|
|
323
|
+
express()
|
|
324
|
+
}
|
|
325
|
+
function endDrag(e, clickAllowed) {
|
|
326
|
+
if (!drag || !drag.active) return
|
|
327
|
+
drag.active = false
|
|
328
|
+
pressUp()
|
|
329
|
+
root.classList.remove('dshwv-dragging')
|
|
330
|
+
try {
|
|
331
|
+
if (root.hasPointerCapture && root.hasPointerCapture(e.pointerId)) root.releasePointerCapture(e.pointerId)
|
|
332
|
+
} catch (err) {}
|
|
333
|
+
if (clickAllowed && !drag.moved) { refresh(true); return }
|
|
334
|
+
var dx = e.clientX - drag.startX
|
|
335
|
+
var dy = e.clientY - drag.startY
|
|
336
|
+
var left = clamp(drag.origLeft + dx, 0, Math.max(0, drag.vp.w - drag.w))
|
|
337
|
+
var top = clamp(drag.origTop + dy, 0, Math.max(0, drag.vp.h - drag.h))
|
|
338
|
+
var centerX = left + drag.w / 2
|
|
339
|
+
var centerY = top + drag.h / 2
|
|
340
|
+
if (centerX < drag.vp.w / 4) {
|
|
341
|
+
state.h = 'left'
|
|
342
|
+
state.hOff = 0
|
|
343
|
+
} else if (centerX > drag.vp.w * 3 / 4) {
|
|
344
|
+
state.h = 'right'
|
|
345
|
+
state.hOff = 0
|
|
346
|
+
} else {
|
|
347
|
+
state.h = null
|
|
348
|
+
state.hOff = left
|
|
349
|
+
}
|
|
350
|
+
if (centerY < drag.vp.h / 4) {
|
|
351
|
+
state.v = 'top'
|
|
352
|
+
state.vOff = 0
|
|
353
|
+
} else if (centerY > drag.vp.h * 3 / 4) {
|
|
354
|
+
state.v = 'bottom'
|
|
355
|
+
state.vOff = 0
|
|
356
|
+
} else {
|
|
357
|
+
state.v = null
|
|
358
|
+
state.vOff = top
|
|
359
|
+
}
|
|
360
|
+
state.left = left
|
|
361
|
+
state.top = top
|
|
362
|
+
settle()
|
|
363
|
+
}
|
|
364
|
+
root.addEventListener('pointerdown', onPointerDown)
|
|
365
|
+
root.addEventListener('pointermove', onPointerMove)
|
|
366
|
+
root.addEventListener('pointerup', function (e) { endDrag(e, true) })
|
|
367
|
+
root.addEventListener('pointercancel', function (e) { endDrag(e, false) })
|
|
368
|
+
window.addEventListener('resize', function () {
|
|
369
|
+
settle()
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
var rect0 = root.getBoundingClientRect()
|
|
373
|
+
state.left = rect0.left
|
|
374
|
+
state.top = rect0.top
|
|
375
|
+
express()
|
|
376
|
+
render()
|
|
377
|
+
fetch(SIZE_URL, { cache: 'no-store' })
|
|
378
|
+
.then(function (r) { return r.json() })
|
|
379
|
+
.then(function (d) {
|
|
380
|
+
if (d && typeof d.scale === 'number' && d.scale >= MIN_SCALE - 0.1 && d.scale <= MAX_SCALE + 0.1) {
|
|
381
|
+
state.scale = d.scale
|
|
382
|
+
root.style.setProperty('--dshw-scale', String(d.scale))
|
|
383
|
+
settle()
|
|
384
|
+
}
|
|
385
|
+
refresh(false)
|
|
386
|
+
})
|
|
387
|
+
.catch(function () { refresh(false) })
|
|
388
|
+
setInterval(function () { refresh(false) }, REFRESH_MS)
|
|
389
|
+
})()`
|
|
390
|
+
|
|
391
|
+
const name = 'dsh-whale-widget'
|
|
392
|
+
const inject = ['webServer', 'credentials']
|
|
393
|
+
|
|
394
|
+
function apply(ctx) {
|
|
395
|
+
let imageBytes = null
|
|
396
|
+
let balanceCache = null
|
|
397
|
+
let balanceInFlight = null
|
|
398
|
+
|
|
399
|
+
function loadImage() {
|
|
400
|
+
if (imageBytes) return imageBytes
|
|
401
|
+
for (const p of IMAGE_CANDIDATES) {
|
|
402
|
+
try {
|
|
403
|
+
const bytes = fs.readFileSync(p)
|
|
404
|
+
if (bytes && bytes.length > 0) {
|
|
405
|
+
imageBytes = bytes
|
|
406
|
+
return bytes
|
|
407
|
+
}
|
|
408
|
+
} catch (err) {}
|
|
409
|
+
}
|
|
410
|
+
throw new Error('whale image not found')
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
async function fetchBalance() {
|
|
414
|
+
let cred
|
|
415
|
+
try {
|
|
416
|
+
cred = await ctx.credentials.resolve('DEEPSEEK_API_KEY')
|
|
417
|
+
} catch (err) {
|
|
418
|
+
return { ok: false, code: 'NO_KEY', error: '凭据读取失败: ' + String((err && err.message) || err).slice(0, 160) }
|
|
419
|
+
}
|
|
420
|
+
if (!cred) {
|
|
421
|
+
return { ok: false, code: 'NO_KEY', error: '未配置 DEEPSEEK_API_KEY' }
|
|
422
|
+
}
|
|
423
|
+
let lastErr = null
|
|
424
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
425
|
+
let res
|
|
426
|
+
try {
|
|
427
|
+
res = await fetch(BALANCE_URL, {
|
|
428
|
+
headers: { Authorization: 'Bearer ' + cred.value },
|
|
429
|
+
signal: AbortSignal.timeout(20000),
|
|
430
|
+
})
|
|
431
|
+
} catch (err) {
|
|
432
|
+
lastErr = err
|
|
433
|
+
if (attempt === 0) await new Promise((r) => setTimeout(r, 500))
|
|
434
|
+
continue
|
|
435
|
+
}
|
|
436
|
+
if (!res.ok) {
|
|
437
|
+
lastErr = new Error('HTTP ' + res.status)
|
|
438
|
+
if (res.status < 500) break
|
|
439
|
+
if (attempt === 0) await new Promise((r) => setTimeout(r, 500))
|
|
440
|
+
continue
|
|
441
|
+
}
|
|
442
|
+
let data
|
|
443
|
+
try {
|
|
444
|
+
data = await res.json()
|
|
445
|
+
} catch (err) {
|
|
446
|
+
return { ok: false, code: 'PARSE', error: '余额接口返回不是合法 JSON' }
|
|
447
|
+
}
|
|
448
|
+
const info = data && Array.isArray(data.balance_infos) ? data.balance_infos[0] : null
|
|
449
|
+
if (!info || info.total_balance === undefined) {
|
|
450
|
+
return { ok: false, code: 'SHAPE', error: '余额接口返回结构异常' }
|
|
451
|
+
}
|
|
452
|
+
return {
|
|
453
|
+
ok: true,
|
|
454
|
+
totalBalance: Number(info.total_balance),
|
|
455
|
+
currency: String(info.currency || 'CNY'),
|
|
456
|
+
updatedAt: new Date().toISOString(),
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
const transient = !(lastErr && /^HTTP 4\d\d/.test(lastErr.message))
|
|
460
|
+
return {
|
|
461
|
+
ok: false,
|
|
462
|
+
code: 'HTTP',
|
|
463
|
+
transient: transient,
|
|
464
|
+
error: '余额接口请求失败: ' + String((lastErr && lastErr.message) || lastErr).slice(0, 200),
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function getBalance() {
|
|
469
|
+
const now = Date.now()
|
|
470
|
+
if (balanceCache && now - balanceCache.at < BALANCE_TTL_MS) {
|
|
471
|
+
return Promise.resolve(balanceCache.payload)
|
|
472
|
+
}
|
|
473
|
+
if (balanceInFlight) return balanceInFlight
|
|
474
|
+
balanceInFlight = fetchBalance()
|
|
475
|
+
.then((payload) => {
|
|
476
|
+
if (payload.ok) {
|
|
477
|
+
balanceCache = { at: now, payload }
|
|
478
|
+
return payload
|
|
479
|
+
}
|
|
480
|
+
if (payload.transient && balanceCache) {
|
|
481
|
+
// transient network/API blip: keep serving the last known balance
|
|
482
|
+
return { ...balanceCache.payload, stale: true, error: payload.error }
|
|
483
|
+
}
|
|
484
|
+
if (!payload.transient) console.error('[whale-balance]', payload.code, payload.error)
|
|
485
|
+
return payload
|
|
486
|
+
})
|
|
487
|
+
.catch((err) => ({
|
|
488
|
+
ok: false,
|
|
489
|
+
code: 'ERROR',
|
|
490
|
+
error: '余额服务异常: ' + String((err && err.message) || err).slice(0, 200),
|
|
491
|
+
}))
|
|
492
|
+
.finally(() => {
|
|
493
|
+
balanceInFlight = null
|
|
494
|
+
})
|
|
495
|
+
return balanceInFlight
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function readSizeConfig() {
|
|
499
|
+
for (const p of SIZE_FILE_CANDIDATES) {
|
|
500
|
+
try {
|
|
501
|
+
const parsed = JSON.parse(fs.readFileSync(p, 'utf8'))
|
|
502
|
+
if (parsed && typeof parsed.scale === 'number') return { scale: parsed.scale }
|
|
503
|
+
} catch (err) {}
|
|
504
|
+
}
|
|
505
|
+
return null
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function writeSizeConfig(scale) {
|
|
509
|
+
const body = JSON.stringify({ scale: scale, updatedAt: new Date().toISOString() })
|
|
510
|
+
for (const p of SIZE_FILE_CANDIDATES) {
|
|
511
|
+
try {
|
|
512
|
+
fs.writeFileSync(p, body, 'utf8')
|
|
513
|
+
return { ok: true, scale: scale }
|
|
514
|
+
} catch (err) {}
|
|
515
|
+
}
|
|
516
|
+
return { ok: false, error: '无法持久化挂件尺寸' }
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function readBody(req) {
|
|
520
|
+
return new Promise((resolve, reject) => {
|
|
521
|
+
const chunks = []
|
|
522
|
+
let size = 0
|
|
523
|
+
req.on('data', (c) => {
|
|
524
|
+
size += c.length
|
|
525
|
+
if (size > 8192) {
|
|
526
|
+
reject(new Error('body too large'))
|
|
527
|
+
req.destroy()
|
|
528
|
+
return
|
|
529
|
+
}
|
|
530
|
+
chunks.push(c)
|
|
531
|
+
})
|
|
532
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
|
|
533
|
+
req.on('error', reject)
|
|
534
|
+
})
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const disposers = []
|
|
538
|
+
|
|
539
|
+
disposers.push(ctx.webServer.register({
|
|
540
|
+
kind: 'exact',
|
|
541
|
+
path: '/dsh-whale/image.png',
|
|
542
|
+
handler: (req, res) => {
|
|
543
|
+
try {
|
|
544
|
+
const bytes = loadImage()
|
|
545
|
+
res.writeHead(200, {
|
|
546
|
+
'Content-Type': 'image/png',
|
|
547
|
+
'Cache-Control': 'public, max-age=3600',
|
|
548
|
+
'Content-Length': String(bytes.length),
|
|
549
|
+
})
|
|
550
|
+
res.end(bytes)
|
|
551
|
+
} catch (err) {
|
|
552
|
+
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' })
|
|
553
|
+
res.end('whale image unavailable: ' + String((err && err.message) || err))
|
|
554
|
+
}
|
|
555
|
+
},
|
|
556
|
+
}))
|
|
557
|
+
|
|
558
|
+
disposers.push(ctx.webServer.register({
|
|
559
|
+
kind: 'exact',
|
|
560
|
+
path: '/dsh-whale/balance.json',
|
|
561
|
+
handler: async (req, res) => {
|
|
562
|
+
try {
|
|
563
|
+
const payload = await getBalance()
|
|
564
|
+
res.writeHead(200, JSON_HEADERS)
|
|
565
|
+
res.end(JSON.stringify(payload))
|
|
566
|
+
} catch (err) {
|
|
567
|
+
res.writeHead(200, JSON_HEADERS)
|
|
568
|
+
res.end(JSON.stringify({ ok: false, code: 'ERROR', error: String((err && err.message) || err).slice(0, 200) }))
|
|
569
|
+
}
|
|
570
|
+
},
|
|
571
|
+
}))
|
|
572
|
+
|
|
573
|
+
disposers.push(ctx.webServer.register({
|
|
574
|
+
kind: 'exact',
|
|
575
|
+
path: '/dsh-whale/size.json',
|
|
576
|
+
handler: async (req, res) => {
|
|
577
|
+
if (req.method === 'PUT' || req.method === 'POST') {
|
|
578
|
+
try {
|
|
579
|
+
const body = await readBody(req)
|
|
580
|
+
const parsed = JSON.parse(body)
|
|
581
|
+
const scale = typeof parsed.scale === 'number' ? parsed.scale : null
|
|
582
|
+
if (scale === null) {
|
|
583
|
+
res.writeHead(400, JSON_HEADERS)
|
|
584
|
+
res.end(JSON.stringify({ ok: false, error: 'missing scale' }))
|
|
585
|
+
return
|
|
586
|
+
}
|
|
587
|
+
const result = writeSizeConfig(scale)
|
|
588
|
+
res.writeHead(result.ok ? 200 : 500, JSON_HEADERS)
|
|
589
|
+
res.end(JSON.stringify(result))
|
|
590
|
+
} catch (err) {
|
|
591
|
+
res.writeHead(400, JSON_HEADERS)
|
|
592
|
+
res.end(JSON.stringify({ ok: false, error: String((err && err.message) || err) }))
|
|
593
|
+
}
|
|
594
|
+
return
|
|
595
|
+
}
|
|
596
|
+
res.writeHead(200, JSON_HEADERS)
|
|
597
|
+
res.end(JSON.stringify(readSizeConfig() || {}))
|
|
598
|
+
},
|
|
599
|
+
}))
|
|
600
|
+
|
|
601
|
+
disposers.push(ctx.webServer.register({
|
|
602
|
+
kind: 'exact',
|
|
603
|
+
path: '/dsh-whale/widget.js',
|
|
604
|
+
handler: (req, res) => {
|
|
605
|
+
res.writeHead(200, {
|
|
606
|
+
'Content-Type': 'application/javascript; charset=utf-8',
|
|
607
|
+
'Cache-Control': 'no-store',
|
|
608
|
+
})
|
|
609
|
+
res.end(WIDGET_JS)
|
|
610
|
+
},
|
|
611
|
+
}))
|
|
612
|
+
|
|
613
|
+
disposers.push(ctx.webServer.tapIndex((html) => {
|
|
614
|
+
if (html.indexOf('/dsh-whale/widget.js') !== -1) return html
|
|
615
|
+
const tag = '<script defer src="/dsh-whale/widget.js"></script>'
|
|
616
|
+
if (html.indexOf('</body>') !== -1) return html.replace('</body>', tag + '</body>')
|
|
617
|
+
return html + tag
|
|
618
|
+
}))
|
|
619
|
+
|
|
620
|
+
ctx.effect(() => () => {
|
|
621
|
+
for (const d of disposers) {
|
|
622
|
+
try { d() } catch (err) {}
|
|
623
|
+
}
|
|
624
|
+
})
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
export { name, inject, apply }
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-whale-widget",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "DSH Web 界面右下角的 DeepSeek 余额小鲸鱼挂件",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"dsh",
|
|
7
|
+
"dsh-plugin",
|
|
8
|
+
"deepseek-harness",
|
|
9
|
+
"balance",
|
|
10
|
+
"widget"
|
|
11
|
+
],
|
|
12
|
+
"type": "module",
|
|
13
|
+
"main": "lib/index.js",
|
|
14
|
+
"files": [
|
|
15
|
+
"lib",
|
|
16
|
+
"assets",
|
|
17
|
+
"cordis.patch.yml",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"dsh": {
|
|
21
|
+
"bundle": {
|
|
22
|
+
"patch": "./cordis.patch.yml"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"license": "MIT"
|
|
26
|
+
}
|