dsh-desktop-statusbar 2.1.1 → 2.2.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.en.md +132 -108
- package/README.md +130 -108
- package/docs/pricing.en.png +0 -0
- package/docs/pricing.png +0 -0
- package/docs/settings.en.png +0 -0
- package/docs/settings.png +0 -0
- package/lib/balance-api.js +60 -47
- package/lib/client.js +1604 -216
- package/lib/index.js +917 -303
- package/lib/release-age.js +509 -0
- package/lib/self-update.js +368 -0
- package/package.json +8 -2
- package/tools/plugin-dir.cjs +112 -0
- package/tools/shot.cjs +365 -0
- package/tools/sync-from-plugin.cjs +49 -0
- package/tools/sync-to-plugin.cjs +71 -0
- package/tools/test.cjs +99 -0
package/tools/shot.cjs
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 生成 README 截图。
|
|
3
|
+
*
|
|
4
|
+
* 做法:用插件自己的组件代码(SettingsSection / StatusBar)在 vm 里渲染出虚拟树,
|
|
5
|
+
* 序列化成 HTML,配上插件自己的 CSS 与 DSH 主题变量,再用无头 Edge/Chrome 截图。
|
|
6
|
+
* 好处是截图内容永远跟着代码走 —— 改了文案或布局,重跑一次就同步,
|
|
7
|
+
* 不需要手工开应用摆状态、也不怕忘记更新。
|
|
8
|
+
*
|
|
9
|
+
* 用法:
|
|
10
|
+
* node tools/shot.cjs 生成全部(中英 × 底栏 / 设置页 / 价格区)
|
|
11
|
+
* node tools/shot.cjs zh status 只生成某一张
|
|
12
|
+
*/
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const vm = require('vm');
|
|
16
|
+
const { execFileSync } = require('child_process');
|
|
17
|
+
|
|
18
|
+
const ROOT = path.join(__dirname, '..');
|
|
19
|
+
const PLUGIN = path.join(ROOT, 'lib', 'client.js');
|
|
20
|
+
const OUT = path.join(ROOT, 'docs');
|
|
21
|
+
const CONFIG_KEY = 'dsh.desktopStatusBar.v1';
|
|
22
|
+
|
|
23
|
+
const BROWSERS = [
|
|
24
|
+
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
|
25
|
+
'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
|
|
26
|
+
'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe'
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
/* DSH 主题变量(浅色)。取自主题包里的值,就列插件用到的那一批。 */
|
|
30
|
+
const THEME = [
|
|
31
|
+
':root{',
|
|
32
|
+
'--dsw-alias-bg-layer-1:#ffffff;',
|
|
33
|
+
'--dsw-alias-label-primary:#0f1115;',
|
|
34
|
+
'--dsw-alias-label-secondary:#4b5563;',
|
|
35
|
+
'--dsw-alias-label-tertiary:#6b7280;',
|
|
36
|
+
'--dsw-alias-separator-primary:rgba(20,24,32,.16);',
|
|
37
|
+
'--dsw-alias-border-l1:rgba(20,24,32,.14);',
|
|
38
|
+
'--dsw-alias-border-l3:rgba(20,24,32,.2);',
|
|
39
|
+
'--dsw-alias-border-l4:rgba(20,24,32,.12);',
|
|
40
|
+
'--dsw-alias-interactive-bg-hover:rgba(38,49,72,.06);',
|
|
41
|
+
'--dsw-alias-brand-primary:#4d6bfe;',
|
|
42
|
+
'--dsw-specific-menu:#f8f9fa94;',
|
|
43
|
+
'--dsw-menu-backdrop-filter:blur(40px) saturate(150%);',
|
|
44
|
+
'--dsw-radius-sm:8px;--dsw-radius-md:12px;--dsw-radius-lg:16px;',
|
|
45
|
+
'--dsw-elevation-stroke-color:var(--dsw-alias-border-l1);',
|
|
46
|
+
'--dsw-elevation-stroke:0 0 0 .5px var(--dsw-elevation-stroke-color);',
|
|
47
|
+
'--dsw-elevation-prominent:var(--dsw-elevation-stroke),0 3px 8px 0 #0000000a,0 0 20px 0 #0000000d;',
|
|
48
|
+
'--dsh-chat-content-width:760px;',
|
|
49
|
+
'--dsh-content-font-size-secondary:13px;',
|
|
50
|
+
'--dsh-content-font-delta-secondary:0px;',
|
|
51
|
+
'--dsh-composer-side-clearance:0px;',
|
|
52
|
+
'}'
|
|
53
|
+
].join('');
|
|
54
|
+
|
|
55
|
+
/* 示例会话数据:截图里显示的就是这些数字 */
|
|
56
|
+
const SAMPLE = {
|
|
57
|
+
turns: 12, steps: 268,
|
|
58
|
+
ttftMs: 22800, decodeMs: 96000, decodeTokens: 26400, ttftSteps: 12,
|
|
59
|
+
llmMs: 1512000, toolMs: 402000,
|
|
60
|
+
pressureTokens: 486000, contextWindow: 1000000,
|
|
61
|
+
breakdown: { systemTokens: 1900, toolsTokens: 7300, messageTokens: 181000 },
|
|
62
|
+
usage: { uncachedInputTokens: 29631, cacheReadTokens: 1900000, cacheWriteTokens: 0, outputTokens: 41007 },
|
|
63
|
+
calls: [
|
|
64
|
+
{ at: 1, model: 'deepseek-v4-pro', input: 9638, cacheRead: 1536, cacheWrite: 0, output: 197 },
|
|
65
|
+
{ at: 2, model: 'deepseek-v4-pro', input: 5966, cacheRead: 11264, cacheWrite: 0, output: 500 },
|
|
66
|
+
{ at: 3, model: 'deepseek-v4-pro', input: 901, cacheRead: 17664, cacheWrite: 0, output: 324 },
|
|
67
|
+
{ at: 4, model: 'deepseek-v4-pro', input: 1473, cacheRead: 18816, cacheWrite: 0, output: 476 },
|
|
68
|
+
{ at: 5, model: 'deepseek-v4-pro', input: 1553, cacheRead: 20736, cacheWrite: 0, output: 1475 },
|
|
69
|
+
{ at: 6, model: 'deepseek-v4-pro', input: 152, cacheRead: 23680, cacheWrite: 0, output: 115 }
|
|
70
|
+
],
|
|
71
|
+
timing: { samples: 12, fastestTtft: 412, slowestTtft: 4458, fastestTps: 394, slowestTps: 167 }
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
function browser() {
|
|
75
|
+
for (const one of BROWSERS) {
|
|
76
|
+
if (fs.existsSync(one)) return one;
|
|
77
|
+
}
|
|
78
|
+
throw new Error('没找到 Chrome / Edge,无法截图');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 用给定语言与配置加载 client.js,拿回组件与词典;presets 按 useState 调用序号注入初始状态 */
|
|
82
|
+
function loadPlugin(locale, config, presets) {
|
|
83
|
+
let code = fs.readFileSync(PLUGIN, 'utf8');
|
|
84
|
+
code = code.replace('\t\texports.apply = apply;',
|
|
85
|
+
'\t\texports.__shot = { zh: zh, en: en, CSS: CSS, SettingsSection: SettingsSection, StatusBar: StatusBar };\n'
|
|
86
|
+
+ '\t\texports.apply = apply;');
|
|
87
|
+
|
|
88
|
+
const store = new Map([[CONFIG_KEY, JSON.stringify(config)]]);
|
|
89
|
+
const fakeWindow = {
|
|
90
|
+
localStorage: {
|
|
91
|
+
getItem: (k) => (store.has(k) ? store.get(k) : null),
|
|
92
|
+
setItem: (k, v) => { store.set(k, v); },
|
|
93
|
+
removeItem: (k) => { store.delete(k); }
|
|
94
|
+
},
|
|
95
|
+
innerWidth: 1200, innerHeight: 900,
|
|
96
|
+
setInterval: () => 0, clearInterval: () => {}, setTimeout: () => 0, clearTimeout: () => {},
|
|
97
|
+
addEventListener: () => {}, removeEventListener: () => {},
|
|
98
|
+
fetch: () => Promise.resolve({ json: () => Promise.resolve({}) })
|
|
99
|
+
};
|
|
100
|
+
let captured = null;
|
|
101
|
+
fakeWindow.__ModuleLoader__ = { load: (entry) => { captured = entry; } };
|
|
102
|
+
|
|
103
|
+
let stateIndex = 0;
|
|
104
|
+
const reactStub = {
|
|
105
|
+
createElement: (type, props, ...children) => ({
|
|
106
|
+
type,
|
|
107
|
+
props: props === null || props === undefined ? {} : props,
|
|
108
|
+
children: children.flat(Infinity).filter((c) => c !== null && c !== undefined && c !== false)
|
|
109
|
+
}),
|
|
110
|
+
useState: (init) => {
|
|
111
|
+
const at = stateIndex;
|
|
112
|
+
stateIndex += 1;
|
|
113
|
+
/* 截图要的是"某个状态下的样子":需要展开的卡片靠预设值摆出来,省得模拟点击 */
|
|
114
|
+
if (presets !== undefined && presets[at] !== undefined) return [presets[at], () => {}];
|
|
115
|
+
return [typeof init === 'function' ? init() : init, () => {}];
|
|
116
|
+
},
|
|
117
|
+
useEffect: () => {},
|
|
118
|
+
useLayoutEffect: () => {},
|
|
119
|
+
useRef: () => ({ current: null }),
|
|
120
|
+
useMemo: (fn) => fn(),
|
|
121
|
+
useCallback: (fn) => fn,
|
|
122
|
+
useSyncExternalStore: (subscribe, snapshot) => snapshot(),
|
|
123
|
+
Fragment: Symbol('Fragment')
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const sandbox = {
|
|
127
|
+
window: fakeWindow,
|
|
128
|
+
document: {
|
|
129
|
+
documentElement: { classList: { add() {}, remove() {} } },
|
|
130
|
+
querySelector: () => null,
|
|
131
|
+
createElement: () => ({ dataset: {}, style: {}, appendChild() {}, setAttribute() {} }),
|
|
132
|
+
head: { appendChild() {} },
|
|
133
|
+
addEventListener: () => {}, removeEventListener: () => {}
|
|
134
|
+
},
|
|
135
|
+
react: reactStub,
|
|
136
|
+
console, Promise, Date, Math, JSON, Object, Array, Number, String, Boolean, Symbol, Set, Map, Error
|
|
137
|
+
};
|
|
138
|
+
sandbox.globalThis = sandbox;
|
|
139
|
+
vm.createContext(sandbox);
|
|
140
|
+
vm.runInContext(code, sandbox, { filename: 'client.js' });
|
|
141
|
+
if (captured === null || captured.factory === undefined) throw new Error('插件工厂没被捕获');
|
|
142
|
+
const api = captured.factory((name) => (name === 'react' ? reactStub : undefined));
|
|
143
|
+
return { api: api, shot: api.__shot };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function esc(text) {
|
|
147
|
+
return String(text).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** 虚拟树 → HTML */
|
|
151
|
+
function toHtml(node) {
|
|
152
|
+
if (node === null || node === undefined || node === false || node === true || node === '') return '';
|
|
153
|
+
if (typeof node === 'string' || typeof node === 'number') return esc(node);
|
|
154
|
+
if (Array.isArray(node)) return node.map(toHtml).join('');
|
|
155
|
+
if (typeof node.type === 'symbol') return (node.children || []).map(toHtml).join('');
|
|
156
|
+
const props = node.props || {};
|
|
157
|
+
let attrs = '';
|
|
158
|
+
Object.keys(props).forEach((key) => {
|
|
159
|
+
if (key === 'children' || key === 'ref' || key === 'key' || key.indexOf('on') === 0) return;
|
|
160
|
+
const value = props[key];
|
|
161
|
+
if (value === undefined || value === null || value === false || typeof value === 'function') return;
|
|
162
|
+
const name = key === 'className' ? 'class' : key;
|
|
163
|
+
attrs += ' ' + name + '="' + esc(value === true ? '' : value) + '"';
|
|
164
|
+
});
|
|
165
|
+
const cls = props.className === undefined ? '' : String(props.className);
|
|
166
|
+
const style = props.style === undefined ? '' : ' style="' + Object.keys(props.style).map((k) => k + ':' + props.style[k]).join(';') + '"';
|
|
167
|
+
const open = '<' + node.type + attrs + (style !== '' && String(attrs).indexOf(' style=') === -1 ? style : '') + '>';
|
|
168
|
+
if (node.type === 'input' || node.type === 'br' || node.type === 'img') return open;
|
|
169
|
+
return open + (node.children || []).map(toHtml).join('') + '</' + node.type + '>';
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function page(body, extraCss, pluginCss) {
|
|
173
|
+
return '<!doctype html><html lang="zh"><head><meta charset="utf-8"><style>'
|
|
174
|
+
+ THEME
|
|
175
|
+
+ '*{box-sizing:border-box}body{margin:0;padding:0;background:#fff;color:var(--dsw-alias-label-primary);'
|
|
176
|
+
+ 'font-family:"Segoe UI","Microsoft YaHei",system-ui,sans-serif;font-size:13px;line-height:1.5}'
|
|
177
|
+
+ '.shot-wrap{width:100%;padding:26px 30px}'
|
|
178
|
+
+ pluginCss
|
|
179
|
+
+ extraCss
|
|
180
|
+
+ '</style></head><body>' + body + '</body></html>';
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** 语言替身:与插件里的 t() 同语义 */
|
|
184
|
+
function translator(dict, locale) {
|
|
185
|
+
return (key, params) => {
|
|
186
|
+
const table = locale === 'en' ? dict.en : dict.zh;
|
|
187
|
+
let out = table[key] !== undefined ? table[key] : key;
|
|
188
|
+
if (params !== undefined && params !== null) {
|
|
189
|
+
out = out.replace(/\{(\w+)\}/g, (m, name) => (name in params ? String(params[name]) : m));
|
|
190
|
+
}
|
|
191
|
+
return out;
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const SETTINGS_CONFIG = {
|
|
196
|
+
version: 7, contextMeter: true, currency: 'CNY', priceConfigured: true,
|
|
197
|
+
/* 按 SEGMENTS 的声明顺序写(= 出厂默认顺序):tps 在 ttft 前面 */
|
|
198
|
+
segments: ['status', 'counts', 'cacheHit', 'tps', 'ttft', 'sessionTime', 'lastCost', 'cost', 'balance'],
|
|
199
|
+
hidden: [], modelOrder: ['deepseek-flash', 'deepseek-v4-pro'],
|
|
200
|
+
models: {
|
|
201
|
+
'deepseek-flash': { tiered: true, peak: { input: 2, cacheRead: 0.04, cacheWrite: 0, output: 8 }, offPeak: { input: 1, cacheRead: 0.02, cacheWrite: 0, output: 4 } },
|
|
202
|
+
'deepseek-v4-pro': { tiered: true, peak: { input: 9, cacheRead: 0.3, cacheWrite: 0, output: 27 }, offPeak: { input: 4.5, cacheRead: 0.15, cacheWrite: 0, output: 13.5 } }
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
/* 英文截图配美元价:币种是可配的,英文场景展示 USD 更贴切 */
|
|
207
|
+
const SETTINGS_USD = Object.assign({}, SETTINGS_CONFIG, {
|
|
208
|
+
currency: 'USD',
|
|
209
|
+
models: {
|
|
210
|
+
'deepseek-flash': { tiered: true, peak: { input: 0.3, cacheRead: 0.006, cacheWrite: 0, output: 1.2 }, offPeak: { input: 0.15, cacheRead: 0.003, cacheWrite: 0, output: 0.6 } },
|
|
211
|
+
'deepseek-v4-pro': { tiered: true, peak: { input: 1.32, cacheRead: 0.044, cacheWrite: 0, output: 3.96 }, offPeak: { input: 0.66, cacheRead: 0.022, cacheWrite: 0, output: 1.98 } }
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
/** 设置页整页 */
|
|
216
|
+
function settingsShot(locale) {
|
|
217
|
+
const loaded = loadPlugin(locale, locale === 'en' ? SETTINGS_USD : SETTINGS_CONFIG);
|
|
218
|
+
const t = translator(loaded.shot, locale);
|
|
219
|
+
const tree = loaded.shot.SettingsSection({
|
|
220
|
+
t: t,
|
|
221
|
+
useProjection: (name) => (name === 'desktopStatusbarModel' ? { model: 'deepseek-v4-pro' } : undefined)
|
|
222
|
+
});
|
|
223
|
+
return page('<div class="shot-wrap">' + toHtml(tree) + '</div>', '', loaded.shot.CSS);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** 只要价格区:前两块隐藏、展开第一张卡,并把币种下拉收起(展开会压住右边那列输入框) */
|
|
227
|
+
function pricingShot(locale) {
|
|
228
|
+
/* SettingsSection 里 useState 的顺序(组件加了 hook 就要回来重数一遍):
|
|
229
|
+
0 devOn / 1 drag / 2 newModel / 3 hostModel / 4 editing / 5 draft /
|
|
230
|
+
6 holidayBusy / 7 holidayNote / 8 currencyOpen。
|
|
231
|
+
2026-10-05 之前按下标写的是旧顺序(把 editing 当第 5 个、holidayNote 当第 6 个),
|
|
232
|
+
结果第一张卡展不开、还把草稿对象塞进了 holidayNote,卡片里冒出一句「抓取失败(undefined)」。
|
|
233
|
+
2026-10-13 又踩了一次同类的坑:新增 devOn(开发模式胶囊)插在最前面,
|
|
234
|
+
后面整体后移一位,于是 presets[3] 从 editing 变成 hostModel、
|
|
235
|
+
presets[4] 从 draft 变成 editing、presets[7] 从 currencyOpen 变成 holidayNote。
|
|
236
|
+
教训:**下标是脆的**,加 hook 必须回来重数,并用 `node tools/shot.cjs` 实际生成一次确认。 */
|
|
237
|
+
const presets = [];
|
|
238
|
+
presets[4] = 'deepseek-flash';
|
|
239
|
+
/* 浮层里的数字也要跟着币种走:预设草稿按语言挑人民币或美元那套 */
|
|
240
|
+
presets[5] = locale === 'en'
|
|
241
|
+
? {
|
|
242
|
+
tiered: true,
|
|
243
|
+
peak: { input: '0.3', cacheRead: '0.006', cacheWrite: '0', output: '1.2' },
|
|
244
|
+
offPeak: { input: '0.15', cacheRead: '0.003', cacheWrite: '0', output: '0.6' }
|
|
245
|
+
}
|
|
246
|
+
: {
|
|
247
|
+
tiered: true,
|
|
248
|
+
peak: { input: '2', cacheRead: '0.04', cacheWrite: '0', output: '8' },
|
|
249
|
+
offPeak: { input: '1', cacheRead: '0.02', cacheWrite: '0', output: '4' }
|
|
250
|
+
};
|
|
251
|
+
presets[8] = false; /* 币种下拉收起:展开会压住右边那列输入框 */
|
|
252
|
+
const loaded = loadPlugin(locale, locale === 'en' ? SETTINGS_USD : SETTINGS_CONFIG, presets);
|
|
253
|
+
const t = translator(loaded.shot, locale);
|
|
254
|
+
const tree = loaded.shot.SettingsSection({
|
|
255
|
+
t: t,
|
|
256
|
+
useProjection: (name) => (name === 'desktopStatusbarModel' ? { model: 'deepseek-v4-pro' } : undefined)
|
|
257
|
+
});
|
|
258
|
+
/* 只留第三块(价格区):head 与统计字段在前两块,按钮区在后 */
|
|
259
|
+
const extra = '.dsb-settings > *:nth-child(-n+2){display:none}'
|
|
260
|
+
+ '.dsb-settings > *:nth-child(n+4){display:none}';
|
|
261
|
+
return page('<div class="shot-wrap">' + toHtml(tree) + '</div>', extra, loaded.shot.CSS);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** 底栏:垫一层假的对话区背景,让毛玻璃看得出来 */
|
|
265
|
+
function statusbarShot(locale) {
|
|
266
|
+
/* StatusBar 里 useState 的顺序:now → balance → tipId → tipPos → tipTick。
|
|
267
|
+
余额平时靠 effect 轮询拿,静态渲染要直接喂进去。 */
|
|
268
|
+
const presets = [];
|
|
269
|
+
presets[1] = { ok: true, total: '10.32', toppedUp: '8.00', granted: '2.32', currency: 'CNY', at: Date.now() };
|
|
270
|
+
const loaded = loadPlugin(locale, SETTINGS_CONFIG, presets);
|
|
271
|
+
const t = translator(loaded.shot, locale);
|
|
272
|
+
const calls = SAMPLE.calls;
|
|
273
|
+
const tree = loaded.shot.StatusBar({
|
|
274
|
+
t: t,
|
|
275
|
+
sessionId: 'shot',
|
|
276
|
+
useProjection: (name) => {
|
|
277
|
+
if (name === 'sessionStats') return SAMPLE;
|
|
278
|
+
if (name === 'tokenUsage') return SAMPLE.usage;
|
|
279
|
+
if (name === 'contextPressure') return { pressureTokens: SAMPLE.pressureTokens, contextWindow: SAMPLE.contextWindow };
|
|
280
|
+
if (name === 'contextBreakdown') return SAMPLE.breakdown;
|
|
281
|
+
if (name === 'desktopStatusbarUsage') {
|
|
282
|
+
return { models: { 'deepseek-v4-pro': { input: 19683, cacheRead: 93696, cacheWrite: 0, output: 3087 } }, calls: calls, current: { calls: calls, input: 19683, cacheRead: 93696, cacheWrite: 0, output: 3087 }, last: null, turn: 12 };
|
|
283
|
+
}
|
|
284
|
+
if (name === 'desktopStatusbarTiming') return SAMPLE.timing;
|
|
285
|
+
if (name === 'desktopStatusbarActiveTime') return { turns: 12, steps: 268, since: Date.now() - 3152000, stepSince: null };
|
|
286
|
+
if (name === 'desktopStatusbarBalance') return { ok: true, total: '10.32', toppedUp: '8.00', granted: '2.32', currency: 'CNY', at: Date.now() };
|
|
287
|
+
if (name === 'desktopStatusbarModel') return { provider: 'deepseek', model: 'deepseek-v4-pro', at: Date.now() };
|
|
288
|
+
return undefined;
|
|
289
|
+
},
|
|
290
|
+
useSession: (selector) => (selector === undefined ? undefined : selector({ running: false, partial: null, runningCalls: [], lastAgentError: null, pendingApprovals: null })),
|
|
291
|
+
useSessions: () => undefined,
|
|
292
|
+
useChat: () => undefined
|
|
293
|
+
});
|
|
294
|
+
const extra = '.shot-wrap{padding:0}'
|
|
295
|
+
+ '.shot-chat{height:190px;padding:18px 24px;background:linear-gradient(180deg,#fff 0%,#f2f4f8 100%);'
|
|
296
|
+
+ 'color:#9aa3b2;font-size:13px;line-height:2}'
|
|
297
|
+
+ '.shot-bar{background:#eef1f6;padding:0 0 10px}';
|
|
298
|
+
const chat = '<div class="shot-chat">'
|
|
299
|
+
+ '<div>把底栏那几项做成可点的气泡,气泡风格跟官方一致。</div>'
|
|
300
|
+
+ '<div>顺带把峰谷规则挪到可配置的价格表里。</div>'
|
|
301
|
+
+ '<div>写完跑一下测试,确认占位和极值都没问题。</div>'
|
|
302
|
+
+ '</div>';
|
|
303
|
+
return page(chat + '<div class="shot-bar">' + toHtml(tree) + '</div>', extra, loaded.shot.CSS);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/* status 场景生成的底栏是**合成**的(自己摆的示例数据 + 假对话区背景)。
|
|
307
|
+
2026-10-05 起 README 里的 docs/statusbar*.png 换成了真机截图(用户自己在 DSH 里截的),
|
|
308
|
+
所以**跑全量会把它覆盖掉** —— 只想更新设置页/价格区就单跑那两张:
|
|
309
|
+
node tools/shot.cjs zh settings && node tools/shot.cjs zh pricing(en 同理)。 */
|
|
310
|
+
const SCENES = { settings: settingsShot, pricing: pricingShot, status: statusbarShot };
|
|
311
|
+
/* 窗口高度按内容量出来的(上下留白对齐到页面自己的 30px 内边距):
|
|
312
|
+
settings 内容底 1050、pricing 内容底 315.5。改了场景内容要重量一遍再调这里。 */
|
|
313
|
+
const SIZES = { settings: '940,1082', pricing: '940,346', status: '1180,230' };
|
|
314
|
+
|
|
315
|
+
function shoot(locale, scene) {
|
|
316
|
+
const html = SCENES[scene](locale);
|
|
317
|
+
const name = scene === 'settings' ? 'settings' : (scene === 'pricing' ? 'pricing' : 'statusbar');
|
|
318
|
+
const file = path.join(OUT, locale === 'en' ? name + '.en.png' : name + '.png');
|
|
319
|
+
/* SHOT_HTML_ONLY=1:只把页面写到系统临时目录、不碰浏览器 ——
|
|
320
|
+
给「当前环境起不了子进程(AI 沙箱、缺浏览器)」时用:拿这些页面自己调 chrome 截图。 */
|
|
321
|
+
if (process.env.SHOT_HTML_ONLY === '1') {
|
|
322
|
+
const out = path.join(require('os').tmpdir(), 'shot-' + locale + '-' + scene + '.html');
|
|
323
|
+
fs.writeFileSync(out, html, 'utf8');
|
|
324
|
+
console.log('HTML ' + out + ' → ' + file);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
const tmp = path.join(OUT, '_shot-' + locale + '-' + scene + '.html');
|
|
328
|
+
fs.writeFileSync(tmp, html, 'utf8');
|
|
329
|
+
const chrome = browser();
|
|
330
|
+
/* 让浏览器写到临时文件,成功了再改名顶上:
|
|
331
|
+
目标文件不能在截图前删 —— 浏览器起不来(沙箱拦子进程、缺浏览器)时会把已有截图弄丢,
|
|
332
|
+
2026-10-05 真丢过一次 docs/statusbar.png。临时名每次都是新的,也就不会有"拒绝覆盖"的问题。 */
|
|
333
|
+
const staged = file + '.tmp.png';
|
|
334
|
+
if (fs.existsSync(staged)) fs.unlinkSync(staged);
|
|
335
|
+
const args = [
|
|
336
|
+
'--headless=new', '--disable-gpu', '--hide-scrollbars', '--force-device-scale-factor=2',
|
|
337
|
+
'--default-background-color=00000000',
|
|
338
|
+
'--window-size=' + SIZES[scene],
|
|
339
|
+
'--screenshot=' + staged,
|
|
340
|
+
/* 路径里有空格(AI Project),file:// 后面必须编码,否则浏览器打不开页面、静默截不出图 */
|
|
341
|
+
'file:///' + encodeURI(tmp.replace(/\\/g, '/'))
|
|
342
|
+
];
|
|
343
|
+
const run = require('child_process').spawnSync(chrome, args, { encoding: 'utf8' });
|
|
344
|
+
if (fs.existsSync(staged) !== true) {
|
|
345
|
+
/* 调试用的页面写到系统临时目录:docs/ 是 npm 会打包的目录,失败时不能往里丢东西 */
|
|
346
|
+
const debug = path.join(require('os').tmpdir(), path.basename(tmp) + '.html');
|
|
347
|
+
fs.writeFileSync(debug, html, 'utf8');
|
|
348
|
+
fs.unlinkSync(tmp);
|
|
349
|
+
console.error('浏览器输出:', String(run.stderr).slice(0, 600));
|
|
350
|
+
console.error('保留页面: ' + debug);
|
|
351
|
+
console.error('(已有截图没被动过:' + (fs.existsSync(file) ? '仍在' : '本来就没有') + ')');
|
|
352
|
+
throw new Error('浏览器没有写出 ' + file + '(检查无头截图是否可用)');
|
|
353
|
+
}
|
|
354
|
+
fs.renameSync(staged, file);
|
|
355
|
+
fs.unlinkSync(tmp);
|
|
356
|
+
const size = fs.statSync(file).size;
|
|
357
|
+
console.log('→ ' + path.relative(ROOT, file) + ' ' + Math.round(size / 1024) + ' KB');
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const onlyLocale = process.argv[2];
|
|
361
|
+
const onlyScene = process.argv[3];
|
|
362
|
+
const locales = onlyLocale === undefined ? ['zh', 'en'] : [onlyLocale];
|
|
363
|
+
const scenes = onlyScene === undefined ? ['status', 'settings', 'pricing'] : [onlyScene];
|
|
364
|
+
locales.forEach((locale) => scenes.forEach((scene) => shoot(locale, scene)));
|
|
365
|
+
console.log('截图完成');
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 把已安装的插件源码同步进项目(改完插件跑一次,再跑 tests/)。
|
|
3
|
+
* 用法:node tools/sync-from-plugin.cjs
|
|
4
|
+
*
|
|
5
|
+
* **文件清单自动扫描**,不写死。原先只列了四个文件,插件目录里新增的模块
|
|
6
|
+
* (self-update.js / release-age.js)不会被搬回项目 —— 于是"插件里改了、项目里没有",
|
|
7
|
+
* 下次用 sync-to-plugin.cjs 推回去时又把改动覆盖掉。现在按插件目录的 `lib/*.js` 逐个搬。
|
|
8
|
+
*
|
|
9
|
+
* 只同步 lib/ 下的源码;项目的 package.json 有自己的元数据,不覆盖。
|
|
10
|
+
* 插件目录由 tools/plugin-dir.cjs 自动定位(换机器/换用户名都不必改代码):
|
|
11
|
+
* 可用环境变量 DSH_STATUS_BAR_PLUGIN_DIR 显式指定。
|
|
12
|
+
*/
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
|
|
16
|
+
const PROJECT = path.join(__dirname, '..');
|
|
17
|
+
const pluginRoot = require('./plugin-dir.cjs').pluginDir();
|
|
18
|
+
if (pluginRoot === null) {
|
|
19
|
+
console.log('× 找不到已安装的插件目录:请设置 DSH_STATUS_BAR_PLUGIN_DIR 指向它,');
|
|
20
|
+
console.log(' 或把 tools/ 放在插件目录里运行。');
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
const PLUGIN = pluginRoot;
|
|
24
|
+
|
|
25
|
+
/* 自动列出插件目录里的全部源文件,而不是写死一份清单 */
|
|
26
|
+
const sources = fs.readdirSync(path.join(PLUGIN, 'lib'))
|
|
27
|
+
.filter((name) => name.endsWith('.js'))
|
|
28
|
+
.sort()
|
|
29
|
+
.map((name) => 'lib/' + name);
|
|
30
|
+
|
|
31
|
+
let changed = 0;
|
|
32
|
+
sources.forEach((rel) => {
|
|
33
|
+
const from = path.join(PLUGIN, rel);
|
|
34
|
+
const to = path.join(PROJECT, rel);
|
|
35
|
+
const before = fs.existsSync(to) ? fs.readFileSync(to, 'utf8') : null;
|
|
36
|
+
const now = fs.readFileSync(from, 'utf8');
|
|
37
|
+
if (before === now) {
|
|
38
|
+
console.log('= ' + rel + '(一致)');
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
fs.writeFileSync(to, now, 'utf8');
|
|
42
|
+
changed += 1;
|
|
43
|
+
console.log('→ ' + rel + ' 已更新' + (before === null ? '(项目原先没有)' : ''));
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
console.log('');
|
|
47
|
+
console.log(changed === 0
|
|
48
|
+
? '项目已是最新(' + sources.length + ' 个源文件)'
|
|
49
|
+
: '同步了 ' + changed + ' / ' + sources.length + ' 个文件;接着跑:node tools/test.cjs');
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 把项目 lib/ 的源码同步到已安装的插件目录(改完项目跑一次,再重启 DSH)。
|
|
3
|
+
* 用法:node tools/sync-to-plugin.cjs
|
|
4
|
+
*
|
|
5
|
+
* **文件清单自动扫描**,不写死。原先写死 `['client.js','index.js','holiday.js','balance-api.js']`,
|
|
6
|
+
* 新增模块(self-update.js / release-age.js)不会被同步过去 —— 而 index.js 会 import 它们,
|
|
7
|
+
* 结果是插件直接加载失败,报错还发生在 DSH 启动阶段,很难联想到"同步脚本漏了一个文件"。
|
|
8
|
+
* 现在按 `lib/*.js` 逐个覆盖,项目里有什么就同步什么。
|
|
9
|
+
*
|
|
10
|
+
* 插件目录由 tools/plugin-dir.cjs 自动定位(换机器/换用户名都不必改代码):
|
|
11
|
+
* 可用环境变量 DSH_STATUS_BAR_PLUGIN_DIR 显式指定。
|
|
12
|
+
*/
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
|
|
16
|
+
const PROJECT = path.join(__dirname, '..', 'lib');
|
|
17
|
+
const pluginRoot = require('./plugin-dir.cjs').pluginDir();
|
|
18
|
+
if (pluginRoot === null) {
|
|
19
|
+
console.log('× 找不到已安装的插件目录:请设置 DSH_STATUS_BAR_PLUGIN_DIR 指向它,');
|
|
20
|
+
console.log(' 或把 tools/ 放在插件目录里运行。');
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
const PLUGIN = path.join(pluginRoot, 'lib');
|
|
24
|
+
|
|
25
|
+
let failed = 0;
|
|
26
|
+
|
|
27
|
+
function writeToPlugin(name, text) {
|
|
28
|
+
const target = path.join(PLUGIN, name);
|
|
29
|
+
const before = fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : null;
|
|
30
|
+
if (before === text) {
|
|
31
|
+
console.log('= ' + name + '(一致)');
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
/* 写入前先解除硬链接。
|
|
35
|
+
已安装目录里的文件通常是 pnpm store 的**硬链接**(同一个 inode 有两个路径)。
|
|
36
|
+
直接 writeFileSync 是"透过链接写",会同时改到 store 里那一份 —— store 是跨
|
|
37
|
+
profile、跨项目共享的,等于把别处也改了。
|
|
38
|
+
做法:先删掉这个链接(store 的数据仍在,只是少一个引用),再写新文件
|
|
39
|
+
(新文件是独立 inode,与 store 无关)。 */
|
|
40
|
+
const wasLinked = fs.existsSync(target) && fs.statSync(target).nlink > 1;
|
|
41
|
+
if (wasLinked) {
|
|
42
|
+
fs.unlinkSync(target);
|
|
43
|
+
console.log(' (' + name + ' 原本是 store 硬链接,已先解除)');
|
|
44
|
+
}
|
|
45
|
+
fs.writeFileSync(target, text, 'utf8');
|
|
46
|
+
/* 复核:新文件必须不再与 store 共享 */
|
|
47
|
+
if (fs.statSync(target).nlink > 1) {
|
|
48
|
+
throw new Error(name + ' 写入后仍有多个硬链接(nlink=' + String(fs.statSync(target).nlink) + '),可能污染 pnpm store');
|
|
49
|
+
}
|
|
50
|
+
console.log('→ ' + name + ' 已更新' + (before === null ? '(插件目录原先没有)' : ''));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/* 自动列出项目里的全部源文件,而不是写死一份清单 */
|
|
54
|
+
const sources = fs.readdirSync(PROJECT).filter((name) => name.endsWith('.js')).sort();
|
|
55
|
+
|
|
56
|
+
sources.forEach((name) => {
|
|
57
|
+
try {
|
|
58
|
+
writeToPlugin(name, fs.readFileSync(path.join(PROJECT, name), 'utf8'));
|
|
59
|
+
} catch (error) {
|
|
60
|
+
failed += 1;
|
|
61
|
+
console.log('× ' + name + ':' + (error !== null && error !== undefined && error.message ? error.message : error));
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
console.log('');
|
|
66
|
+
if (failed === 0) {
|
|
67
|
+
console.log('插件目录已同步(' + sources.length + ' 个源文件 → ' + PLUGIN + ');重启 DSH 生效');
|
|
68
|
+
} else {
|
|
69
|
+
console.log('有文件没同步成功,见上面的 ×');
|
|
70
|
+
}
|
|
71
|
+
process.exitCode = failed === 0 ? 0 : 1;
|
package/tools/test.cjs
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 跑 tests/ 下所有 verify*.js 并汇总结果。
|
|
3
|
+
* 用法:node tools/test.cjs;退出码 0 = 全部通过。
|
|
4
|
+
*
|
|
5
|
+
* 两个刻意的写法(都是踩过坑才改的):
|
|
6
|
+
*
|
|
7
|
+
* 1. 子进程用 **stdio: 'inherit'**,不用管道捕获。
|
|
8
|
+
* 原先用 `spawnSync(..., { encoding: 'utf8' })`(默认 pipe)在某些受限环境下会直接
|
|
9
|
+
* EPERM:进程根本没起来,`run.status` 是 null,于是**每个测试都被判失败**,而失败
|
|
10
|
+
* 原因那一栏是空的 —— 看起来像"40 个测试全挂了",其实一个都没跑。
|
|
11
|
+
* inherit 让子进程直接写本终端的输出,不受这条限制,失败时也能就地看到完整上下文。
|
|
12
|
+
*
|
|
13
|
+
* 2. 通配符用 `/^verify.*\.js$/`,**显式包含 verify.js**。
|
|
14
|
+
* 如果写成 `verify-*.js`(多个连字符)会漏掉 `verify.js` —— 那是最大的一个测试,
|
|
15
|
+
* 漏跑它时总数看起来"少一个"却不容易察觉。
|
|
16
|
+
*
|
|
17
|
+
* 另加超时:单个测试挂住时不能把整个汇总永久卡住。
|
|
18
|
+
*/
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const path = require('path');
|
|
21
|
+
const { spawnSync } = require('child_process');
|
|
22
|
+
|
|
23
|
+
const DIR = path.join(__dirname, '..', 'tests');
|
|
24
|
+
/* 单测超时(毫秒):正常都远小于它,只有挂住时才会触发 */
|
|
25
|
+
const TIMEOUT_MS = 300000;
|
|
26
|
+
|
|
27
|
+
const files = fs.readdirSync(DIR).filter((name) => /^verify.*\.js$/.test(name)).sort();
|
|
28
|
+
|
|
29
|
+
let failed = 0;
|
|
30
|
+
for (const name of files) {
|
|
31
|
+
const run = spawnSync(process.execPath, [path.join(DIR, name)], {
|
|
32
|
+
stdio: 'inherit',
|
|
33
|
+
timeout: TIMEOUT_MS,
|
|
34
|
+
});
|
|
35
|
+
if (run.status === 0) {
|
|
36
|
+
console.log('= ' + name + ' 通过');
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
failed += 1;
|
|
40
|
+
/* 失败原因要能一眼看出是什么:超时 / 被信号杀 / 非零退出码 / 进程都没起来。
|
|
41
|
+
原先只打 stderr 第一行,而 EPERM 那种情况 stderr 是空的,于是什么都没显示。 */
|
|
42
|
+
let why;
|
|
43
|
+
if (run.error !== undefined && run.error !== null) why = '无法启动:' + String(run.error.message);
|
|
44
|
+
else if (run.signal !== null && run.signal !== undefined) why = '被信号终止:' + String(run.signal);
|
|
45
|
+
else if (run.status === null) why = '退出码不可用(很可能超时)';
|
|
46
|
+
else why = '退出码 ' + String(run.status);
|
|
47
|
+
console.log('× ' + name + ' ' + why);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
console.log('');
|
|
51
|
+
|
|
52
|
+
/* 一致性守卫:测试跑的都是项目 lib/ 的源码,而 DSH 实际加载的是插件目录那份。
|
|
53
|
+
* 两边不一致(忘了同步)时这里直接判失败,避免"测试全绿但界面上没变化"。
|
|
54
|
+
* 插件目录由 tools/plugin-dir.cjs 自动定位(换机器/换用户名都不必改代码)。
|
|
55
|
+
*
|
|
56
|
+
* 三个修正(原有实现的问题):
|
|
57
|
+
* ① 报的是**项目**目录的文件数,文案却写"插件目录已同步" —— 数字对不上实际;
|
|
58
|
+
* 现在报两边各自的数量。
|
|
59
|
+
* ② 只检查「项目有、插件目录没有」,反向漏了:插件目录多出一个源文件不会被发现。
|
|
60
|
+
* 现在两个方向都比。
|
|
61
|
+
* ③ 原先不一致时直接 `process.exit(1)`,**在打印测试汇总之前就退出了** ——
|
|
62
|
+
* 于是"同步问题"会把"测试结果"整个吞掉,看到的只是最后那行退出码。
|
|
63
|
+
* 现在记为失败并继续往下打印汇总。 */
|
|
64
|
+
const PROJECT_LIB = path.join(__dirname, '..', 'lib');
|
|
65
|
+
const pluginRoot = require('./plugin-dir.cjs').pluginDir();
|
|
66
|
+
if (pluginRoot === null) {
|
|
67
|
+
console.log('! 找不到已安装的插件目录,跳过一致性检查。');
|
|
68
|
+
console.log(' 可用环境变量 DSH_STATUS_BAR_PLUGIN_DIR 指向它,或把 tools/ 放在插件目录里运行。');
|
|
69
|
+
} else {
|
|
70
|
+
const PLUGIN_LIB = path.join(pluginRoot, 'lib');
|
|
71
|
+
const projectFiles = fs.readdirSync(PROJECT_LIB).filter((name) => name.endsWith('.js'));
|
|
72
|
+
const pluginFiles = fs.existsSync(PLUGIN_LIB)
|
|
73
|
+
? fs.readdirSync(PLUGIN_LIB).filter((name) => name.endsWith('.js'))
|
|
74
|
+
: [];
|
|
75
|
+
|
|
76
|
+
/* 两个方向都看:项目→插件(漏同步)、插件→项目(多出来的) */
|
|
77
|
+
const missingInPlugin = projectFiles.filter((name) => !pluginFiles.includes(name));
|
|
78
|
+
const extraInPlugin = pluginFiles.filter((name) => !projectFiles.includes(name));
|
|
79
|
+
const different = projectFiles.filter((name) => {
|
|
80
|
+
if (!pluginFiles.includes(name)) return false;
|
|
81
|
+
return fs.readFileSync(path.join(PROJECT_LIB, name), 'utf8') !== fs.readFileSync(path.join(PLUGIN_LIB, name), 'utf8');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const issues = [];
|
|
85
|
+
if (missingInPlugin.length > 0) issues.push('插件目录缺:' + missingInPlugin.join(', '));
|
|
86
|
+
if (extraInPlugin.length > 0) issues.push('插件目录多出:' + extraInPlugin.join(', '));
|
|
87
|
+
if (different.length > 0) issues.push('内容不一致:' + different.join(', '));
|
|
88
|
+
|
|
89
|
+
if (issues.length > 0) {
|
|
90
|
+
failed += 1;
|
|
91
|
+
console.log('× 两边源码不一致 —— ' + issues.join(';'));
|
|
92
|
+
console.log(' 项目 → 插件:node tools/sync-to-plugin.cjs');
|
|
93
|
+
console.log(' 插件 → 项目:node tools/sync-from-plugin.cjs');
|
|
94
|
+
} else {
|
|
95
|
+
console.log('= 插件目录已同步(项目 ' + projectFiles.length + ' 个源文件 / 插件 ' + pluginFiles.length + ' 个,' + pluginRoot + ')');
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
console.log(failed === 0 ? files.length + ' 个测试全部通过' : failed + ' 项失败(含测试与同步检查)');
|
|
99
|
+
process.exit(failed === 0 ? 0 : 1);
|