dsh-dfy-pet 2.7.0 → 2.9.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 +102 -8
- package/client.js +490 -31
- package/index.js +55 -1
- package/monitor.js +148 -0
- package/package.json +3 -2
- package/token-usage.js +367 -0
package/client.js
CHANGED
|
@@ -25,9 +25,22 @@ window.__ModuleLoader__.load({
|
|
|
25
25
|
const RELAY_NS = 'llm-pi-ai'; // 中转站供应商写入的 Host 设置命名空间
|
|
26
26
|
const ROUTE_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/; // 供应商 ID 必须能同时作为设置键与凭据前缀
|
|
27
27
|
const PROTOCOLS = [['openai-completions', 'protoChat'], ['openai-responses', 'protoResponses'], ['anthropic-messages', 'protoMessages']]; // 支持的中转站协议及其文案键
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
const OPENCODE_ROUTE = 'api/deepseek-pet/opencode'; // OpenCode
|
|
28
|
+
const MENU_GAP = 8; // 菜单与窗口边缘的最小间距
|
|
29
|
+
|
|
30
|
+
const OPENCODE_ROUTE = 'api/deepseek-pet/opencode'; // OpenCode 监控路由的文档相对地址(默认)
|
|
31
|
+
const TOKEN_USAGE_ROUTE = 'api/deepseek-pet/tokens/usage'; // Token 用量统计路由的文档相对地址
|
|
32
|
+
const hostRoute = (path) => { // 生成同源的 Host 路由地址,保证 pathname 与注册路径完全一致
|
|
33
|
+
const clean = String(path).replace(/^\/+/, ''); // 去掉可能的前导斜杠
|
|
34
|
+
const candidates = [globalThis.location?.origin, typeof document === 'object' && document !== null ? document.baseURI : null]; // 依次尝试页面来源与文档基准
|
|
35
|
+
for (const candidate of candidates) { // 逐个检查可用来源
|
|
36
|
+
if (typeof candidate !== 'string' || candidate.length === 0) continue; // 跳过空值
|
|
37
|
+
let origin = ''; // 解析出的来源
|
|
38
|
+
try { origin = new URL(candidate).origin; } catch (error) { continue; } // 解析失败换下一个
|
|
39
|
+
if (origin === 'null' || !/^https?:\/\//i.test(origin)) continue; // 自定义协议没有可直连的 /api
|
|
40
|
+
return `${origin}/${clean}`; // 同源绝对地址
|
|
41
|
+
} // 结束来源遍历
|
|
42
|
+
return path; // 非 http(s) 页面只能交给宿主按文档相对转运
|
|
43
|
+
}; // 结束地址生成
|
|
31
44
|
const OPENCODE_UNSEEN_KEY = 'dsh.deepseek-pet.opencode.unseen'; // 未查看变动标记的本地键
|
|
32
45
|
const OPENCODE_AUTO_KEY = 'dsh.deepseek-pet.opencode.autoHours'; // 自动检查间隔的本地键
|
|
33
46
|
const OPENCODE_HIDDEN_KEY = 'dsh.deepseek-pet.opencode.hiddenSeries'; // 图例隐藏状态的本地键
|
|
@@ -35,6 +48,10 @@ window.__ModuleLoader__.load({
|
|
|
35
48
|
const OPENCODE_SORT_KEY = 'dsh.deepseek-pet.opencode.sortField'; // 排序字段的本地键
|
|
36
49
|
const OPENCODE_SORT_DIR_KEY = 'dsh.deepseek-pet.opencode.sortDir'; // 排序方向的本地键
|
|
37
50
|
const OPENCODE_SORT_FIELDS = ['go', 'goPlus', 'goTeams']; // 允许的用量排序字段
|
|
51
|
+
const OPENCODE_USAGE_URL_KEY = 'dsh.deepseek-pet.opencode.usageUrl'; // 自定义用量来源的本地键
|
|
52
|
+
const OPENCODE_USAGE_FALLBACK_KEY = 'dsh.deepseek-pet.opencode.usageFallbackUrl'; // 备用用量来源的本地键
|
|
53
|
+
const OPENCODE_ORG_KEY = 'dsh.deepseek-pet.opencode.orgId'; // 团队工作区 id 的本地键
|
|
54
|
+
const OPENCODE_USAGE_WINDOWS = [['rolling', 'ocWindowRolling'], ['weekly', 'ocWindowWeekly'], ['monthly', 'ocWindowMonthly']]; // 与服务端一致的三个窗口
|
|
38
55
|
const CHAT_MODEL_KEY = 'dsh.deepseek-pet.chat.model'; // 对话面板所选模型的本地键
|
|
39
56
|
const CHAT_SESSION_KEY = 'dsh.deepseek-pet.chat.sessionId'; // 对话专用会话身份的本地键,重开页面后还能接着聊
|
|
40
57
|
const CHAT_REPLY_TIMEOUT_MS = 120000; // 单次回复最长等待时间,超时给出提示而不是一直转圈
|
|
@@ -98,9 +115,46 @@ window.__ModuleLoader__.load({
|
|
|
98
115
|
savedBubble: 'Relay ready — pick a model!', // 保存后宠物台词
|
|
99
116
|
ocMenu: 'OpenCode model monitor', // 监控一级菜单
|
|
100
117
|
ocTitle: 'OpenCode Go model monitor', // 监控面板标题
|
|
101
|
-
ocDesc: 'Diffs Included models, token prices and monthly quotas.
|
|
118
|
+
ocDesc: 'Diffs Included models, token prices and monthly quotas. Remaining allowance is read only when you press the usage button.', // 监控说明
|
|
102
119
|
ocCheck: 'Check for changes now', // 立即检查子操作
|
|
103
120
|
ocHistory: 'View recent changes', // 查看历史子操作
|
|
121
|
+
ocUsage: 'Remaining usage', // 剩余用量子操作
|
|
122
|
+
tkMenu: 'Token usage', // Token 用量菜单项
|
|
123
|
+
tkTitle: 'Token usage', // Token 用量面板标题
|
|
124
|
+
tkDesc: 'Aggregated from this machine’s DSH session logs. Cached reads count toward the total.', // 面板说明
|
|
125
|
+
tkPeriodDay: 'Today', // 本日周期
|
|
126
|
+
tkPeriodWeek: 'Week', // 本周周期
|
|
127
|
+
tkPeriodMonth: 'Month', // 本月周期
|
|
128
|
+
tkCalls: 'calls', // 调用次数单位
|
|
129
|
+
tkInput: 'Input', // 未缓存输入
|
|
130
|
+
tkCache: 'Cache read', // 缓存读取
|
|
131
|
+
tkOutput: 'Output', // 输出
|
|
132
|
+
tkModels: 'By model', // 模型用量分区标题
|
|
133
|
+
tkDaily: 'Daily usage', // 按天统计时的分区标题
|
|
134
|
+
tkHourly: 'Hourly usage', // 按小时统计时的分区标题
|
|
135
|
+
tkRefresh: 'Refresh', // 刷新按钮
|
|
136
|
+
tkLoading: 'Reading session logs…', // 统计中提示
|
|
137
|
+
tkNoData: 'No model calls in this period.', // 空周期提示
|
|
138
|
+
tkScanned: 'scanned', // 已扫描会话数
|
|
139
|
+
tkFailed: 'failed', // 读取失败会话数
|
|
140
|
+
tkTokenUnit: 'tokens', // token 单位
|
|
141
|
+
tkNoSessions: 'No session logs found on this machine.', // 找不到会话日志的提示
|
|
142
|
+
ocUsageTitle: 'OpenCode Go remaining usage', // 用量面板标题
|
|
143
|
+
ocUsageDesc: 'Rolling 5-hour, weekly and monthly allowance left on the current plan, read from the OpenCode usage endpoint.', // 用量面板说明
|
|
144
|
+
ocUsageRefresh: 'Refresh usage', // 刷新按钮
|
|
145
|
+
ocUsageRunning: 'Reading usage…', // 查询中
|
|
146
|
+
ocWindowRolling: '5 hours', // 滚动窗口
|
|
147
|
+
ocWindowWeekly: 'This week', // 每周窗口
|
|
148
|
+
ocWindowMonthly: 'This month', // 每月窗口
|
|
149
|
+
ocUsageRemaining: 'left', // 剩余百分比后置词
|
|
150
|
+
ocUsageUsed: 'used', // 已用
|
|
151
|
+
ocUsageReset: 'Resets', // 重置时间前缀
|
|
152
|
+
ocUsageLimited: 'Limit reached', // 达到上限
|
|
153
|
+
ocUsageStale: 'Showing the last saved reading; this refresh failed.', // 展示旧数据
|
|
154
|
+
ocUsageNoData: 'No allowance reading yet. Press refresh once a key is saved.', // 首次无数据
|
|
155
|
+
ocUsageOpenConsole: 'Open OpenCode Console', // 打开控制台
|
|
156
|
+
ocUsagePartial: 'Some sources failed', // 部分来源失败
|
|
157
|
+
ocUsageSource: 'Source', // 来源标签前缀
|
|
104
158
|
ocKey: 'OpenCode API Key', // Key 字段
|
|
105
159
|
ocKeyHint: 'Stored write-only; leave blank to reuse the saved key.', // Key 提示
|
|
106
160
|
ocKeySaved: 'Key saved (leave blank to reuse)', // 已保存占位
|
|
@@ -234,9 +288,46 @@ window.__ModuleLoader__.load({
|
|
|
234
288
|
savedBubble: '中转站配好啦,去选个模型吧!', // 保存后宠物台词
|
|
235
289
|
ocMenu: 'OpenCode 模型监控', // 监控一级菜单
|
|
236
290
|
ocTitle: 'OpenCode Go 模型监控', // 监控面板标题
|
|
237
|
-
ocDesc: '对比 Included models
|
|
291
|
+
ocDesc: '对比 Included models、单价与月度配额;剩余用量只在点「剩余用量」时读取。', // 监控说明
|
|
238
292
|
ocCheck: '立即检查变动', // 立即检查子操作
|
|
239
293
|
ocHistory: '查看最近变动记录', // 查看历史子操作
|
|
294
|
+
ocUsage: '剩余用量', // 剩余用量子操作
|
|
295
|
+
tkMenu: 'Token 用量', // Token 用量菜单项
|
|
296
|
+
tkTitle: 'Token 用量统计', // Token 用量面板标题
|
|
297
|
+
tkDesc: '从本机 DSH 会话日志聚合而来,缓存命中也计入总量。', // 面板说明
|
|
298
|
+
tkPeriodDay: '本日', // 本日周期
|
|
299
|
+
tkPeriodWeek: '本周', // 本周周期
|
|
300
|
+
tkPeriodMonth: '本月', // 本月周期
|
|
301
|
+
tkCalls: '次调用', // 调用次数单位
|
|
302
|
+
tkInput: '未缓存输入', // 未缓存输入
|
|
303
|
+
tkCache: '缓存读取', // 缓存读取
|
|
304
|
+
tkOutput: '输出', // 输出
|
|
305
|
+
tkModels: '模型用量', // 模型用量分区标题
|
|
306
|
+
tkDaily: '每日用量', // 按天统计时的分区标题
|
|
307
|
+
tkHourly: '每小时用量', // 按小时统计时的分区标题
|
|
308
|
+
tkRefresh: '刷新', // 刷新按钮
|
|
309
|
+
tkLoading: '正在统计会话日志…', // 统计中提示
|
|
310
|
+
tkNoData: '这个周期还没有模型调用。', // 空周期提示
|
|
311
|
+
tkScanned: '个会话已扫描', // 已扫描会话数
|
|
312
|
+
tkFailed: '个会话读取失败', // 读取失败会话数
|
|
313
|
+
tkTokenUnit: 'tokens', // token 单位
|
|
314
|
+
tkNoSessions: '本机没有找到 DSH 会话日志。', // 找不到会话日志的提示
|
|
315
|
+
ocUsageTitle: 'OpenCode Go 剩余用量', // 用量面板标题
|
|
316
|
+
ocUsageDesc: '显示当前套餐在 5 小时滚动、每周、每月三个窗口里的剩余额度,数据来自 OpenCode 用量接口。', // 用量面板说明
|
|
317
|
+
ocUsageRefresh: '刷新用量', // 刷新按钮
|
|
318
|
+
ocUsageRunning: '正在读取用量…', // 查询中
|
|
319
|
+
ocWindowRolling: '5 小时', // 滚动窗口
|
|
320
|
+
ocWindowWeekly: '本周', // 每周窗口
|
|
321
|
+
ocWindowMonthly: '本月', // 每月窗口
|
|
322
|
+
ocUsageRemaining: '剩余', // 剩余百分比后置词
|
|
323
|
+
ocUsageUsed: '已用', // 已用
|
|
324
|
+
ocUsageReset: '重置于', // 重置时间前缀
|
|
325
|
+
ocUsageLimited: '已达上限', // 达到上限
|
|
326
|
+
ocUsageStale: '这是上次保存的读数,本次刷新失败了。', // 展示旧数据
|
|
327
|
+
ocUsageNoData: '还没有额度读数;保存 Key 后点一次刷新。', // 首次无数据
|
|
328
|
+
ocUsageOpenConsole: '打开 OpenCode Console', // 打开控制台
|
|
329
|
+
ocUsagePartial: '部分来源请求失败', // 部分来源失败
|
|
330
|
+
ocUsageSource: '来源', // 来源标签前缀
|
|
240
331
|
ocKey: 'OpenCode API Key', // Key 字段
|
|
241
332
|
ocKeyHint: '只写不读回;留空沿用已保存的 Key。', // Key 提示
|
|
242
333
|
ocKeySaved: '已保存 Key(留空沿用)', // 已保存占位
|
|
@@ -459,7 +550,7 @@ window.__ModuleLoader__.load({
|
|
|
459
550
|
animation: none !important;
|
|
460
551
|
}
|
|
461
552
|
}
|
|
462
|
-
.dspet-menu { position: fixed; z-index: 60; min-width: 216px; padding: 6px; border-radius: 12px; background: var(--dsw-alias-bg-overlay, #ffffff); border: 1px solid var(--dsw-alias-border-l1, rgba(0, 0, 0, .12)); box-shadow: 0 12px 32px rgba(12, 22, 64, .22); pointer-events: auto; } /*
|
|
553
|
+
.dspet-menu { position: fixed; z-index: 60; min-width: 216px; max-height: calc(100vh - 16px); overflow-y: auto; overscroll-behavior: contain; padding: 6px; border-radius: 12px; background: var(--dsw-alias-bg-overlay, #ffffff); border: 1px solid var(--dsw-alias-border-l1, rgba(0, 0, 0, .12)); box-shadow: 0 12px 32px rgba(12, 22, 64, .22); pointer-events: auto; } /* 右键菜单卡片,超高时自身滚动而不是溢出窗口 */
|
|
463
554
|
.dspet-menu-item { display: flex; align-items: center; gap: 8px; width: 100%; padding: 9px 10px; border: 0; border-radius: 8px; background: none; color: var(--dsw-alias-label-primary, #1b1b1f); font: inherit; font-size: 13px; line-height: 20px; text-align: left; cursor: pointer; } /* 菜单项按钮 */
|
|
464
555
|
.dspet-menu-item:hover, .dspet-menu-item:focus-visible { background: var(--dsw-alias-bg-layer-2, rgba(0, 0, 0, .05)); outline: none; } /* 菜单项悬停与聚焦 */
|
|
465
556
|
.dspet-menu-icon { flex: none; width: 16px; height: 16px; color: var(--dsw-alias-label-secondary, #6b6b76); } /* 菜单项图标 */
|
|
@@ -493,8 +584,8 @@ window.__ModuleLoader__.load({
|
|
|
493
584
|
.dspet-manual { display: flex; gap: 6px; margin-top: 8px; } /* 手动添加行 */
|
|
494
585
|
.dspet-manual .dspet-input { flex: 1; } /* 手动输入占满剩余宽度 */
|
|
495
586
|
.dspet-menu-caret { margin-left: auto; width: 14px; height: 14px; color: var(--dsw-alias-label-secondary, #6b6b76); } /* 子菜单箭头 */
|
|
496
|
-
.dspet-submenu {
|
|
497
|
-
|
|
587
|
+
.dspet-submenu { position: fixed; z-index: 61; min-width: 168px; } /* 二级菜单浮在一级菜单旁边,层级更高以免被父菜单遮住 */
|
|
588
|
+
|
|
498
589
|
.dspet-badge { position: absolute; top: 6px; right: 6px; width: 10px; height: 10px; border-radius: 50%; background: var(--dsw-alias-state-warn-primary, #d97706); box-shadow: 0 0 0 3px var(--dsw-alias-bg-layer-1, #ffffff); animation: dspet-badge-pulse 2s ease-in-out infinite; pointer-events: none; } /* 未查看变动提示点 */
|
|
499
590
|
@keyframes dspet-badge-pulse { 0%, 100% { transform: scale(1); opacity: 1; } 50% { transform: scale(1.25); opacity: .75; } } /* 提示点呼吸 */
|
|
500
591
|
@media (prefers-reduced-motion: reduce) { .dspet-badge { animation: none !important; } } /* 减少动态时静止 */
|
|
@@ -568,6 +659,19 @@ window.__ModuleLoader__.load({
|
|
|
568
659
|
.dspet-status-id { color: var(--dsw-alias-label-secondary, #6b6b76); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; } /* 模型 id */
|
|
569
660
|
.dspet-status-meta { margin-top: 2px; color: var(--dsw-alias-label-secondary, #6b6b76); font-size: 11px; line-height: 1.5; } /* 价格与配额 */
|
|
570
661
|
.dspet-status-quota { display: block; } /* 配额独立一行 */
|
|
662
|
+
.dspet-usage { display: flex; flex-direction: column; gap: 10px; margin-top: 8px; } /* 三个额度窗口纵向排列 */
|
|
663
|
+
.dspet-usage-card { padding: 8px 10px; border: 1px solid var(--dsw-alias-border-l1, rgba(0, 0, 0, .12)); border-radius: 8px; background: var(--dsw-alias-bg-base, #ffffff); } /* 单个窗口卡片 */
|
|
664
|
+
.dspet-usage-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; font-size: 12px; font-weight: 600; } /* 窗口标题与百分比 */
|
|
665
|
+
.dspet-usage-value { font-size: 18px; font-weight: 700; } /* 剩余百分比数字 */
|
|
666
|
+
.dspet-usage-unit { margin-left: 2px; font-size: 11px; font-weight: 400; color: var(--dsw-alias-label-secondary, #6b6b76); } /* 百分比后缀 */
|
|
667
|
+
.dspet-usage-track { height: 8px; margin: 6px 0 4px; border-radius: 999px; background: var(--dsw-alias-bg-layer-2, rgba(0, 0, 0, .08)); overflow: hidden; } /* 进度槽 */
|
|
668
|
+
.dspet-usage-bar { height: 100%; border-radius: 999px; background: #4d6bfe; transition: width .25s ease; } /* 剩余比例条 */
|
|
669
|
+
.dspet-usage-bar[data-level="low"] { background: #f2994a; } /* 剩余低于 30% 转为橙色 */
|
|
670
|
+
.dspet-usage-bar[data-level="empty"] { background: #eb5757; } /* 剩余低于 10% 转为红色 */
|
|
671
|
+
.dspet-usage-meta { display: flex; flex-wrap: wrap; gap: 8px; color: var(--dsw-alias-label-secondary, #6b6b76); font-size: 11px; } /* 已用与重置时间 */
|
|
672
|
+
.dspet-usage-limit { color: #eb5757; font-weight: 600; } /* 达到上限提示 */
|
|
673
|
+
.dspet-usage-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; } /* 用量面板按钮行 */
|
|
674
|
+
.dspet-usage-source { width: 100%; } /* 用量来源输入占满一行 */
|
|
571
675
|
.dspet-tabs { display: flex; gap: 4px; margin: 8px 0 6px; } /* 图表切换 */
|
|
572
676
|
.dspet-tab { padding: 3px 10px; border: 1px solid var(--dsw-alias-border-l1, rgba(0, 0, 0, .12)); border-radius: 999px; background: none; color: var(--dsw-alias-label-secondary, #6b6b76); font: inherit; font-size: 11px; cursor: pointer; } /* 图表切换按钮 */
|
|
573
677
|
.dspet-tab[aria-pressed="true"] { background: var(--dsw-alias-bg-layer-2, rgba(0, 0, 0, .06)); color: var(--dsw-alias-label-primary, #1b1b1f); border-color: var(--dsw-alias-border-l2, rgba(0, 0, 0, .2)); } /* 选中态 */
|
|
@@ -597,6 +701,35 @@ window.__ModuleLoader__.load({
|
|
|
597
701
|
.dspet-sort .dspet-field-hint { margin: 0; white-space: nowrap; } /* 排序标签 */
|
|
598
702
|
.dspet-sort .dspet-select { width: auto; height: 30px; padding: 0 8px; } /* 排序下拉宽度 */
|
|
599
703
|
.dspet-sort-dir { min-width: 32px; height: 28px; padding: 0 8px; font-size: 11px; } /* 排序方向按钮 */
|
|
704
|
+
.dspet-token-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 8px; } /* 面板顶部行 */
|
|
705
|
+
.dspet-token-tabs { display: flex; gap: 4px; padding: 2px; border: 1px solid var(--dsw-alias-border-l1, rgba(0, 0, 0, .12)); border-radius: 8px; background: var(--dsw-alias-bg-layer-2, rgba(0, 0, 0, .05)); } /* 周期切换按钮组 */
|
|
706
|
+
.dspet-token-tab { min-width: 44px; height: 24px; padding: 0 8px; border: 0; border-radius: 6px; background: none; color: var(--dsw-alias-label-secondary, #6b6b76); font: inherit; font-size: 12px; cursor: pointer; } /* 周期切换按钮 */
|
|
707
|
+
.dspet-token-tab:hover { color: var(--dsw-alias-label-primary, #1b1b1f); } /* 周期按钮悬停 */
|
|
708
|
+
.dspet-token-tab[data-active="true"] { background: var(--dsw-alias-bg-base, #fff); color: var(--dsw-alias-label-primary, #1b1b1f); font-weight: 600; box-shadow: 0 1px 2px rgba(0, 0, 0, .08); } /* 选中的周期按钮 */
|
|
709
|
+
.dspet-token-summary { padding: 10px 12px; border: 1px solid var(--dsw-alias-border-l1, rgba(0, 0, 0, .12)); border-radius: 8px; background: var(--dsw-alias-bg-base, #fff); } /* 汇总卡片 */
|
|
710
|
+
.dspet-token-total { font-size: 22px; font-weight: 700; line-height: 1.2; } /* 总量大数字 */
|
|
711
|
+
.dspet-token-total-unit { margin-left: 3px; font-size: 12px; font-weight: 400; color: var(--dsw-alias-label-secondary, #6b6b76); } /* 总量单位 */
|
|
712
|
+
.dspet-token-split { display: flex; flex-wrap: wrap; gap: 6px 14px; margin-top: 8px; } /* 输入/缓存/输出分项行 */
|
|
713
|
+
.dspet-token-split-item { display: flex; align-items: baseline; gap: 5px; font-size: 11px; color: var(--dsw-alias-label-secondary, #6b6b76); } /* 单个分项 */
|
|
714
|
+
.dspet-token-split-value { font-size: 12px; font-weight: 600; color: var(--dsw-alias-label-primary, #1b1b1f); } /* 分项数值 */
|
|
715
|
+
.dspet-token-section { margin-top: 12px; } /* 分区间距 */
|
|
716
|
+
.dspet-token-section-title { margin: 0 0 6px; font-size: 12px; font-weight: 600; color: var(--dsw-alias-label-primary, #1b1b1f); } /* 分区标题 */
|
|
717
|
+
.dspet-token-models { display: flex; flex-direction: column; gap: 8px; } /* 模型列表 */
|
|
718
|
+
.dspet-token-model { padding: 8px 10px; border: 1px solid var(--dsw-alias-border-l1, rgba(0, 0, 0, .12)); border-radius: 8px; background: var(--dsw-alias-bg-base, #fff); } /* 单个模型卡片 */
|
|
719
|
+
.dspet-token-model-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; } /* 模型名与占比 */
|
|
720
|
+
.dspet-token-model-name { font-size: 12px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } /* 模型名 */
|
|
721
|
+
.dspet-token-model-provider { margin-left: 6px; font-size: 10px; font-weight: 400; color: var(--dsw-alias-label-secondary, #6b6b76); } /* 供应商标识 */
|
|
722
|
+
.dspet-token-model-share { font-size: 12px; font-weight: 700; color: var(--dsw-alias-label-primary, #1b1b1f); } /* 占比数字 */
|
|
723
|
+
.dspet-token-track { height: 6px; margin: 6px 0 4px; border-radius: 999px; background: var(--dsw-alias-bg-layer-2, rgba(0, 0, 0, .08)); overflow: hidden; } /* 占比条槽 */
|
|
724
|
+
.dspet-token-bar { height: 100%; border-radius: 999px; background: #4d6bfe; } /* 占比条 */
|
|
725
|
+
.dspet-token-bar[data-level="low"] { background: #f2994a; } /* 占比低于 5% 转橙色 */
|
|
726
|
+
.dspet-token-model-meta { display: flex; flex-wrap: wrap; gap: 4px 12px; color: var(--dsw-alias-label-secondary, #6b6b76); font-size: 10px; } /* 模型分项明细 */
|
|
727
|
+
.dspet-token-daily { display: flex; align-items: flex-end; gap: 3px; height: 46px; padding-top: 4px; } /* 时间序列柱状图 */
|
|
728
|
+
.dspet-token-daily-col { flex: 1 1 0; min-width: 4px; border-radius: 2px 2px 0 0; background: #4d6bfe; opacity: .75; } /* 单根柱子 */
|
|
729
|
+
.dspet-token-daily-col[data-empty="true"] { background: var(--dsw-alias-bg-layer-2, rgba(0, 0, 0, .08)); opacity: 1; } /* 没有用量的时段留空 */
|
|
730
|
+
.dspet-token-axis { display: flex; gap: 3px; margin-top: 3px; } /* 柱状图下方的坐标标签行 */
|
|
731
|
+
.dspet-token-axis-label { flex: 1 1 0; min-width: 4px; overflow: hidden; text-align: center; font-size: 9px; line-height: 12px; color: var(--dsw-alias-label-secondary, #6b6b76); } /* 单个坐标标签 */
|
|
732
|
+
.dspet-token-foot { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; margin-top: 12px; color: var(--dsw-alias-label-secondary, #6b6b76); font-size: 10px; } /* 面板底部说明 */
|
|
600
733
|
`;
|
|
601
734
|
|
|
602
735
|
function clampPosition(right, bottom) {
|
|
@@ -894,6 +1027,22 @@ window.__ModuleLoader__.load({
|
|
|
894
1027
|
apply(ctx) {
|
|
895
1028
|
const translate = ctx.locale.bind(NS);
|
|
896
1029
|
|
|
1030
|
+
// 把文档相对路由换算成浏览器地址:默认保持文档相对写法(与 DSH 自身接口一致);
|
|
1031
|
+
// 桌面端页面跑在 dsh-app:// 自定义协议下,文档相对地址会解析成 /web/api/... 而 404,
|
|
1032
|
+
// 因此只要能拿到 http(s) 同源地址就用绝对地址;拿不到才退回 DSH 的文档相对约定。
|
|
1033
|
+
const routeUrl = (path) => { // 生成一次请求地址
|
|
1034
|
+
const clean = path.replace(/^\/+/, ''); // 去掉可能的前导斜杠,统一后面拼接
|
|
1035
|
+
const candidates = [globalThis.location?.origin, typeof document === 'object' && document !== null ? document.baseURI : null]; // 依次尝试页面来源与文档基准
|
|
1036
|
+
for (const candidate of candidates) { // 逐个检查可用来源
|
|
1037
|
+
if (typeof candidate !== 'string' || candidate.length === 0) continue; // 跳过空值
|
|
1038
|
+
let origin = ''; // 解析出的来源
|
|
1039
|
+
try { origin = new URL(candidate).origin; } catch (error) { continue; } // 解析失败换下一个
|
|
1040
|
+
if (origin === 'null' || !/^https?:\/\//i.test(origin)) continue; // 自定义协议没有可直连的 /api
|
|
1041
|
+
return `${origin}/${clean}`; // 同源绝对地址,保证 pathname 与注册路径完全一致
|
|
1042
|
+
} // 结束来源遍历
|
|
1043
|
+
return path; // 非 http(s) 页面只能交给宿主按文档相对转运
|
|
1044
|
+
}; // 结束地址生成
|
|
1045
|
+
|
|
897
1046
|
ctx.effect(
|
|
898
1047
|
() => ctx.locale.register(NS, 'en', DICTS.en),
|
|
899
1048
|
'deepseek-pet: en dictionary',
|
|
@@ -1122,9 +1271,9 @@ window.__ModuleLoader__.load({
|
|
|
1122
1271
|
const loadHistory = async () => { // 加载历史
|
|
1123
1272
|
setHistoryBusy(true); setHistoryError(''); // 进入加载
|
|
1124
1273
|
try { // 捕获失败
|
|
1125
|
-
const response = await fetch(`${OPENCODE_ROUTE}/history?limit=20
|
|
1274
|
+
const response = await fetch(routeUrl(`${OPENCODE_ROUTE}/history?limit=20`), { headers: { accept: 'application/json' } }); // 请求历史
|
|
1126
1275
|
const payload = await response.json().catch(() => ({ ok: false, error: `HTTP ${response.status}` })); // 解析响应
|
|
1127
|
-
if (!response.ok || !payload.ok) throw new Error(payload.error || `HTTP ${response.status}`); //
|
|
1276
|
+
if (!response.ok || !payload.ok) throw new Error(payload.error || `HTTP ${response.status}(请求地址 ${response.url || routeUrl(`${OPENCODE_ROUTE}/history?limit=20`)})`); // 检查状态并带上实际地址便于排查
|
|
1128
1277
|
setHistory(payload.checks ?? []); setHasKey(payload.hasKey === true); props.onHistoryViewed(); // 保存并清除标记
|
|
1129
1278
|
} catch (cause) { // 加载失败
|
|
1130
1279
|
setHistoryError(cause instanceof Error ? cause.message : String(cause)); // 显示错误
|
|
@@ -1135,9 +1284,9 @@ window.__ModuleLoader__.load({
|
|
|
1135
1284
|
|
|
1136
1285
|
const loadStatus = async () => { // 读取最近状态
|
|
1137
1286
|
try { // 捕获失败
|
|
1138
|
-
const response = await fetch(`${OPENCODE_ROUTE}/status
|
|
1287
|
+
const response = await fetch(routeUrl(`${OPENCODE_ROUTE}/status`), { headers: { accept: 'application/json' } }); // 请求状态
|
|
1139
1288
|
const payload = await response.json().catch(() => ({ ok: false, error: `HTTP ${response.status}` })); // 解析响应
|
|
1140
|
-
if (!response.ok || !payload.ok) throw new Error(payload.error || `HTTP ${response.status}`); //
|
|
1289
|
+
if (!response.ok || !payload.ok) throw new Error(payload.error || `HTTP ${response.status}(请求地址 ${response.url || routeUrl(`${OPENCODE_ROUTE}/status`)})`); // 检查状态并带上实际地址便于排查
|
|
1141
1290
|
if (payload.status) { setCurrent(payload.status); setHasKey(payload.hasKey === true); } // 保存状态
|
|
1142
1291
|
} catch (cause) { // 状态读取失败
|
|
1143
1292
|
setError((previous) => previous.length > 0 ? previous : (cause instanceof Error ? cause.message : String(cause))); // 不覆盖检查错误
|
|
@@ -1298,6 +1447,231 @@ window.__ModuleLoader__.load({
|
|
|
1298
1447
|
); // 结束遮罩
|
|
1299
1448
|
} // 结束监控面板
|
|
1300
1449
|
|
|
1450
|
+
function OpenCodeUsage(props) { // OpenCode Go 剩余用量面板
|
|
1451
|
+
const t = props.t; // 翻译函数
|
|
1452
|
+
const [apiKey, setApiKey] = React.useState(''); // 本次输入的 Key,留空沿用已保存的
|
|
1453
|
+
const [busy, setBusy] = React.useState(false); // 是否正在查询
|
|
1454
|
+
const [error, setError] = React.useState(''); // 查询错误
|
|
1455
|
+
const [data, setData] = React.useState(null); // 最近读数
|
|
1456
|
+
const [urlDraft, setUrlDraft] = React.useState(() => { try { return window.localStorage.getItem(OPENCODE_USAGE_URL_KEY) ?? ''; } catch (error) { return ''; } }); // 自定义来源
|
|
1457
|
+
const [fallbackDraft, setFallbackDraft] = React.useState(() => { try { return window.localStorage.getItem(OPENCODE_USAGE_FALLBACK_KEY) ?? ''; } catch (error) { return ''; } }); // 备用来源
|
|
1458
|
+
const [orgDraft, setOrgDraft] = React.useState(() => { try { return window.localStorage.getItem(OPENCODE_ORG_KEY) ?? ''; } catch (error) { return ''; } }); // 团队工作区 id
|
|
1459
|
+
const [showAdvanced, setShowAdvanced] = React.useState(false); // 高级选项是否展开
|
|
1460
|
+
const started = React.useRef(false); // 防止重复自动查询
|
|
1461
|
+
|
|
1462
|
+
const load = async () => { // 查询一次用量
|
|
1463
|
+
setBusy(true); setError(''); // 进入查询
|
|
1464
|
+
try { // 捕获失败
|
|
1465
|
+
const payload = await props.runUsage({ apiKey: apiKey.trim(), usageUrl: urlDraft.trim(), fallbackUrl: fallbackDraft.trim(), orgId: orgDraft.trim() }); // 交给 Host 路由
|
|
1466
|
+
setData(payload); setApiKey(''); // 保存读数并清空 Key
|
|
1467
|
+
if (Array.isArray(payload.errors) && payload.errors.length > 0) setError(payload.errors.join(';')); // 部分来源失败也要告知
|
|
1468
|
+
} catch (cause) { // 查询失败
|
|
1469
|
+
setError(cause instanceof Error ? cause.message : String(cause)); // 显示原因
|
|
1470
|
+
} finally { // 结束查询
|
|
1471
|
+
setBusy(false); // 恢复按钮
|
|
1472
|
+
} // 结束查询
|
|
1473
|
+
}; // 结束查询函数
|
|
1474
|
+
const loadRef = React.useRef(load); loadRef.current = load; // 自动查询使用最新闭包
|
|
1475
|
+
|
|
1476
|
+
React.useEffect(() => { // 打开时自动查一次
|
|
1477
|
+
if (started.current) return; // 只执行一次
|
|
1478
|
+
started.current = true; // 标记已执行
|
|
1479
|
+
loadRef.current(); // 触发查询
|
|
1480
|
+
}, []); // 只依赖首次挂载
|
|
1481
|
+
|
|
1482
|
+
React.useEffect(() => { // Escape 关闭
|
|
1483
|
+
const onKeyDown = (event) => { if (event.key === 'Escape') props.onClose(); }; // 随时可关闭
|
|
1484
|
+
window.addEventListener('keydown', onKeyDown); // 监听
|
|
1485
|
+
return () => window.removeEventListener('keydown', onKeyDown); // 清理
|
|
1486
|
+
}, [props.onClose]); // 依赖关闭回调
|
|
1487
|
+
|
|
1488
|
+
React.useEffect(() => { try { window.localStorage.setItem(OPENCODE_USAGE_URL_KEY, urlDraft.trim()); } catch (error) { /* 忽略存储失败 */ } }, [urlDraft]); // 持久化自定义来源
|
|
1489
|
+
React.useEffect(() => { try { window.localStorage.setItem(OPENCODE_USAGE_FALLBACK_KEY, fallbackDraft.trim()); } catch (error) { /* 忽略存储失败 */ } }, [fallbackDraft]); // 持久化备用来源
|
|
1490
|
+
React.useEffect(() => { try { window.localStorage.setItem(OPENCODE_ORG_KEY, orgDraft.trim()); } catch (error) { /* 忽略存储失败 */ } }, [orgDraft]); // 持久化组织 id
|
|
1491
|
+
|
|
1492
|
+
const maskClose = (event) => { if (event.target === event.currentTarget) props.onClose(); }; // 点遮罩关闭
|
|
1493
|
+
const timeLabel = (value) => { const date = new Date(value); return Number.isNaN(date.getTime()) ? value : date.toLocaleString(); }; // 时间格式
|
|
1494
|
+
const level = (remaining) => (remaining < 10 ? 'empty' : remaining < 30 ? 'low' : 'normal'); // 剩余比例对应的颜色档
|
|
1495
|
+
const card = ([key, labelKey]) => { // 单个额度窗口卡片
|
|
1496
|
+
const item = data?.windows?.[key]; // 该窗口的读数
|
|
1497
|
+
return h('div', { key, className: 'dspet-usage-card' }, // 卡片容器
|
|
1498
|
+
h('div', { className: 'dspet-usage-head' }, // 标题行
|
|
1499
|
+
h('span', null, t(labelKey)), // 窗口名称
|
|
1500
|
+
h('span', { className: 'dspet-usage-value' }, item ? `${item.remaining}%` : '—', h('span', { className: 'dspet-usage-unit' }, t('ocUsageRemaining'))), // 剩余百分比
|
|
1501
|
+
), // 结束标题行
|
|
1502
|
+
h('div', { className: 'dspet-usage-track', role: 'progressbar', 'aria-valuemin': 0, 'aria-valuemax': 100, 'aria-valuenow': item?.remaining ?? 0, 'aria-label': `${t(labelKey)} ${t('ocUsageRemaining')}` }, // 进度槽
|
|
1503
|
+
h('div', { className: 'dspet-usage-bar', 'data-level': item ? level(item.remaining) : 'normal', style: { width: `${item?.remaining ?? 0}%` } }), // 剩余比例条
|
|
1504
|
+
), // 结束进度槽
|
|
1505
|
+
h('div', { className: 'dspet-usage-meta' }, // 细节行
|
|
1506
|
+
h('span', null, `${t('ocUsageUsed')} ${item ? item.percent : '—'}%`), // 已用百分比
|
|
1507
|
+
h('span', null, item?.resetsAt ? `${t('ocUsageReset')} ${timeLabel(item.resetsAt)}` : `${t('ocUsageReset')} —`), // 重置时间
|
|
1508
|
+
item?.limited ? h('span', { className: 'dspet-usage-limit' }, t('ocUsageLimited')) : null, // 达到上限
|
|
1509
|
+
), // 结束细节行
|
|
1510
|
+
); // 结束卡片
|
|
1511
|
+
}; // 结束卡片函数
|
|
1512
|
+
const sourceLabel = data?.source === 'official' ? 'opencode.ai' : data?.source === 'fallback' ? 'fallback' : data?.source === 'custom' ? 'custom' : '—'; // 来源标签
|
|
1513
|
+
const summary = h('div', { className: 'dspet-monitor-summary' }, // 顶部摘要
|
|
1514
|
+
data?.stale ? h('span', { className: 'dspet-chip' }, t('ocUsageStale')) : null, // 旧读数提示
|
|
1515
|
+
h('span', { className: 'dspet-chip' }, `${t('ocUsageSource')} ${sourceLabel}`), // 数据来源
|
|
1516
|
+
data?.fetchedAt ? h('span', { className: 'dspet-chip' }, timeLabel(data.fetchedAt)) : null, // 读数的抓取时间
|
|
1517
|
+
); // 结束摘要
|
|
1518
|
+
|
|
1519
|
+
return h('div', { className: 'dspet-modal', onPointerDown: maskClose }, // 遮罩
|
|
1520
|
+
h('div', { className: 'dspet-dialog', role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'dspet-usage-title' }, // 面板
|
|
1521
|
+
h('h2', { id: 'dspet-usage-title', className: 'dspet-dialog-title' }, t('ocUsageTitle')), // 标题
|
|
1522
|
+
h('p', { className: 'dspet-dialog-desc' }, t('ocUsageDesc')), // 说明
|
|
1523
|
+
h('label', { className: 'dspet-field' }, // Key 字段
|
|
1524
|
+
h('span', { className: 'dspet-field-label' }, t('ocKey')), // 字段名
|
|
1525
|
+
h('input', { className: 'dspet-input', type: 'password', value: apiKey, autoComplete: 'new-password', placeholder: t('ocKeySaved'), 'aria-label': t('ocKey'), disabled: busy, onChange: (event) => setApiKey(event.target.value) }), // Key 输入
|
|
1526
|
+
h('span', { className: 'dspet-field-hint' }, t('ocKeyHint')), // Key 提示
|
|
1527
|
+
), // 结束 Key 字段
|
|
1528
|
+
h('div', { className: 'dspet-monitor-actions' }, // 操作行
|
|
1529
|
+
h('button', { type: 'button', className: 'dspet-btn dspet-btn-primary', onClick: load, disabled: busy }, busy ? t('ocUsageRunning') : t('ocUsageRefresh')), // 刷新
|
|
1530
|
+
h('button', { type: 'button', className: 'dspet-btn', onClick: () => setShowAdvanced((value) => !value), 'aria-expanded': showAdvanced }, t('ocUsageSource')), // 展开或收起来源选项
|
|
1531
|
+
h('button', { type: 'button', className: 'dspet-btn', onClick: () => { if (data?.pageUrl) window.open(data.pageUrl, '_blank', 'noopener,noreferrer'); else window.open('https://opencode.ai/console', '_blank', 'noopener,noreferrer'); } }, t('ocUsageOpenConsole')), // 打开控制台
|
|
1532
|
+
), // 结束操作行
|
|
1533
|
+
showAdvanced ? h('div', { className: 'dspet-usage-source' }, // 高级选项
|
|
1534
|
+
h('label', { className: 'dspet-field' }, h('span', { className: 'dspet-field-label' }, 'usage URL'), h('input', { className: 'dspet-input', type: 'url', value: urlDraft, placeholder: 'https://opencode.ai/zen/go/v1/usage', 'aria-label': 'usage URL', disabled: busy, onChange: (event) => setUrlDraft(event.target.value) })), // 自定义来源
|
|
1535
|
+
h('label', { className: 'dspet-field' }, h('span', { className: 'dspet-field-label' }, 'fallback URL'), h('input', { className: 'dspet-input', type: 'url', value: fallbackDraft, placeholder: 'https://…/zen/go/v1/usage', 'aria-label': 'fallback URL', disabled: busy, onChange: (event) => setFallbackDraft(event.target.value) })), // 备用来源
|
|
1536
|
+
h('label', { className: 'dspet-field' }, h('span', { className: 'dspet-field-label' }, 'org id'), h('input', { className: 'dspet-input', type: 'text', value: orgDraft, placeholder: 'org_…', 'aria-label': 'org id', disabled: busy, spellCheck: false, onChange: (event) => setOrgDraft(event.target.value) })), // 团队工作区 id
|
|
1537
|
+
) : null, // 结束高级选项
|
|
1538
|
+
error.length > 0 ? h('p', { className: 'dspet-error' }, `${t('ocError')}:${error}`) : null, // 错误
|
|
1539
|
+
data === null && busy ? h('p', { className: 'dspet-field-hint' }, t('ocUsageRunning')) : null, // 首次加载提示
|
|
1540
|
+
data === null || !data.windows || data.windows.rolling === null && data.windows.weekly === null && data.windows.monthly === null ? (busy ? null : h('p', { className: 'dspet-field-hint' }, t('ocUsageNoData'))) : h('div', null, summary, h('div', { className: 'dspet-usage' }, OPENCODE_USAGE_WINDOWS.map(card))), // 读数或空提示
|
|
1541
|
+
h('div', { className: 'dspet-dialog-footer' }, h('button', { type: 'button', className: 'dspet-btn', onClick: props.onClose }, t('ocClose'))), // 关闭
|
|
1542
|
+
), // 结束面板
|
|
1543
|
+
); // 结束遮罩
|
|
1544
|
+
} // 结束用量面板
|
|
1545
|
+
|
|
1546
|
+
function TokenUsage(props) { // Token 用量面板:本日/本周/本月三个周期,每个周期内按模型拆分
|
|
1547
|
+
const t = props.t; // 翻译函数
|
|
1548
|
+
const [period, setPeriod] = React.useState('week'); // 当前选中的周期
|
|
1549
|
+
const [data, setData] = React.useState(null); // Host 聚合回来的统计结果
|
|
1550
|
+
const [busy, setBusy] = React.useState(false); // 统计中
|
|
1551
|
+
const [error, setError] = React.useState(''); // 错误文案
|
|
1552
|
+
const started = React.useRef(false); // 防止重复自动加载
|
|
1553
|
+
|
|
1554
|
+
const load = async (force = false) => { // 拉一次统计
|
|
1555
|
+
setBusy(true); setError(''); // 进入加载态
|
|
1556
|
+
try { // 捕获失败
|
|
1557
|
+
const zone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'Asia/Shanghai'; // 浏览器所在时区,周期边界按它算
|
|
1558
|
+
const query = `timeZone=${encodeURIComponent(zone)}${force ? '&force=1' : ''}`; // 查询串
|
|
1559
|
+
const response = await fetch(hostRoute(`${TOKEN_USAGE_ROUTE}?${query}`), { headers: { accept: 'application/json' } }); // 请求统计路由
|
|
1560
|
+
const payload = await response.json().catch(() => ({ ok: false, error: `HTTP ${response.status}` })); // 解析响应
|
|
1561
|
+
if (!response.ok || !payload.ok) throw new Error(payload.error || `HTTP ${response.status}`); // 检查状态并带上原因
|
|
1562
|
+
setData(payload); // 保存结果
|
|
1563
|
+
} catch (cause) { // 统计失败
|
|
1564
|
+
setError(cause instanceof Error ? cause.message : String(cause)); // 显示原因
|
|
1565
|
+
} finally { // 结束
|
|
1566
|
+
setBusy(false); // 恢复按钮
|
|
1567
|
+
} // 结束异常
|
|
1568
|
+
}; // 结束加载
|
|
1569
|
+
|
|
1570
|
+
React.useEffect(() => { // 打开时统计一次
|
|
1571
|
+
if (started.current) return; // 只执行一次
|
|
1572
|
+
started.current = true; // 标记已执行
|
|
1573
|
+
load(); // 触发统计
|
|
1574
|
+
}, []); // 只依赖首次挂载
|
|
1575
|
+
|
|
1576
|
+
React.useEffect(() => { // Escape 关闭
|
|
1577
|
+
const onKeyDown = (event) => { if (event.key === 'Escape') props.onClose(); }; // 随时可关闭
|
|
1578
|
+
window.addEventListener('keydown', onKeyDown); // 监听
|
|
1579
|
+
return () => window.removeEventListener('keydown', onKeyDown); // 清理
|
|
1580
|
+
}, [props.onClose]); // 依赖关闭回调
|
|
1581
|
+
|
|
1582
|
+
const maskClose = (event) => { if (event.target === event.currentTarget) props.onClose(); }; // 点遮罩关闭
|
|
1583
|
+
const current = data?.periods?.[period] ?? null; // 当前周期的汇总
|
|
1584
|
+
const totals = current?.totals ?? null; // 当前周期的合计
|
|
1585
|
+
const number = (value) => (typeof value === 'number' ? value.toLocaleString('en-US') : '—'); // 千分位格式
|
|
1586
|
+
const timeLabel = (value) => { const date = new Date(value); return Number.isNaN(date.getTime()) ? '' : date.toLocaleString(); }; // 时间格式
|
|
1587
|
+
const tabs = [['day', 'tkPeriodDay'], ['week', 'tkPeriodWeek'], ['month', 'tkPeriodMonth']]; // 三个周期按钮
|
|
1588
|
+
|
|
1589
|
+
const split = h('div', { className: 'dspet-token-split' }, // 输入/缓存/输出分项
|
|
1590
|
+
h('span', { className: 'dspet-token-split-item' }, h('span', null, t('tkInput')), h('span', { className: 'dspet-token-split-value' }, number(totals?.input))), // 未缓存输入
|
|
1591
|
+
h('span', { className: 'dspet-token-split-item' }, h('span', null, t('tkCache')), h('span', { className: 'dspet-token-split-value' }, number(totals?.cacheRead))), // 缓存读取
|
|
1592
|
+
h('span', { className: 'dspet-token-split-item' }, h('span', null, t('tkOutput')), h('span', { className: 'dspet-token-split-value' }, number(totals?.output))), // 输出
|
|
1593
|
+
); // 结束分项
|
|
1594
|
+
|
|
1595
|
+
const summary = h('div', { className: 'dspet-token-summary' }, // 汇总卡片
|
|
1596
|
+
h('div', { className: 'dspet-token-total' }, number(totals?.total ?? 0), h('span', { className: 'dspet-token-total-unit' }, t('tkTokenUnit'))), // 总量
|
|
1597
|
+
h('div', { className: 'dspet-token-model-meta' }, h('span', null, `${number(totals?.calls ?? 0)} ${t('tkCalls')}`)), // 调用次数
|
|
1598
|
+
split, // 分项
|
|
1599
|
+
); // 结束汇总卡片
|
|
1600
|
+
|
|
1601
|
+
const models = Array.isArray(current?.models) ? current.models : []; // 当前周期的模型列表
|
|
1602
|
+
const modelList = h('div', { className: 'dspet-token-models' }, models.map((model) => h( // 模型卡片列表
|
|
1603
|
+
'div', { key: `${model.provider}::${model.model}`, className: 'dspet-token-model' }, // 单个模型
|
|
1604
|
+
h('div', { className: 'dspet-token-model-head' }, // 模型名与占比
|
|
1605
|
+
h('span', { className: 'dspet-token-model-name', title: `${model.provider} / ${model.model}` }, model.model, h('span', { className: 'dspet-token-model-provider' }, model.provider)), // 模型名与供应商
|
|
1606
|
+
h('span', { className: 'dspet-token-model-share' }, `${(model.share * 100).toFixed(1)}%`), // 占比
|
|
1607
|
+
), // 结束模型名行
|
|
1608
|
+
h('div', { className: 'dspet-token-track' }, h('div', { className: 'dspet-token-bar', 'data-level': model.share < 0.05 ? 'low' : 'normal', style: { width: `${Math.max(model.share * 100, model.total > 0 ? 1 : 0)}%` } })), // 占比条
|
|
1609
|
+
h('div', { className: 'dspet-token-model-meta' }, // 分项明细
|
|
1610
|
+
h('span', null, `${number(model.calls)} ${t('tkCalls')}`), // 调用次数
|
|
1611
|
+
h('span', null, `${t('tkInput')} ${number(model.input)}`), // 未缓存输入
|
|
1612
|
+
h('span', null, `${t('tkCache')} ${number(model.cacheRead)}`), // 缓存读取
|
|
1613
|
+
h('span', null, `${t('tkOutput')} ${number(model.output)}`), // 输出
|
|
1614
|
+
h('span', null, `${number(model.total)} ${t('tkTokenUnit')}`), // 该模型合计
|
|
1615
|
+
), // 结束分项明细
|
|
1616
|
+
), // 结束模型卡片
|
|
1617
|
+
)); // 结束模型列表
|
|
1618
|
+
|
|
1619
|
+
const series = current?.series ?? null; // 当前周期自带的时间序列(本日按小时,本周与本月按天)
|
|
1620
|
+
const points = Array.isArray(series?.points) ? series.points : []; // 序列点位
|
|
1621
|
+
const maxPoint = points.reduce((max, point) => Math.max(max, point.total), 0); // 序列最大值,用来定柱高
|
|
1622
|
+
// 坐标标签按点位数量抽稀,避免柱子太密时文字挤在一起
|
|
1623
|
+
const labelStep = points.length <= 8 ? 1 : points.length <= 16 ? 2 : 5; // 每隔几个点显示一个标签
|
|
1624
|
+
const weekdayLabel = (key) => { // 把 YYYY-MM-DD 转成本地化的星期几
|
|
1625
|
+
const parsed = new Date(`${key}T00:00:00`); // 按本地时间解析该日期
|
|
1626
|
+
if (Number.isNaN(parsed.getTime())) return ''; // 解析失败就没有标签
|
|
1627
|
+
return parsed.toLocaleDateString(undefined, { weekday: 'narrow' }); // 窄版星期几
|
|
1628
|
+
}; // 结束星期标签
|
|
1629
|
+
const pointLabel = (point, index) => { // 决定一个点位显示什么标签
|
|
1630
|
+
if (series?.unit === 'hour') return String(point.key); // 本日按小时:直接显示小时数
|
|
1631
|
+
if (period === 'week') return weekdayLabel(point.key); // 本周按天:显示星期几
|
|
1632
|
+
const parsed = new Date(`${point.key}T00:00:00`); // 按本地时间解析该日期
|
|
1633
|
+
return Number.isNaN(parsed.getTime()) ? '' : String(parsed.getDate()); // 本月按天:显示几号
|
|
1634
|
+
}; // 结束标签计算
|
|
1635
|
+
const trend = h('div', null, // 趋势区
|
|
1636
|
+
h('div', { className: 'dspet-token-daily' }, points.map((point) => h( // 柱状趋势
|
|
1637
|
+
'div', { key: point.key, className: 'dspet-token-daily-col', 'data-empty': point.total === 0 ? 'true' : 'false', title: `${point.label} · ${number(point.total)} ${t('tkTokenUnit')}`, style: { height: maxPoint > 0 ? `${Math.max((point.total / maxPoint) * 100, point.total > 0 ? 8 : 3)}%` : '3%' } }, // 单根柱子
|
|
1638
|
+
))), // 结束柱状图
|
|
1639
|
+
h('div', { className: 'dspet-token-axis' }, points.map((point, index) => h( // 坐标标签行
|
|
1640
|
+
'span', { key: point.key, className: 'dspet-token-axis-label' }, (index % labelStep === 0 || index === points.length - 1) ? pointLabel(point, index) : ''), // 单个标签
|
|
1641
|
+
)), // 结束坐标标签行
|
|
1642
|
+
); // 结束趋势区
|
|
1643
|
+
|
|
1644
|
+
const foot = h('div', { className: 'dspet-token-foot' }, // 底部说明
|
|
1645
|
+
h('span', null, `${number(data?.sessions?.scanned ?? 0)} ${t('tkScanned')}${data?.sessions?.failed > 0 ? ` · ${number(data.sessions.failed)} ${t('tkFailed')}` : ''}`), // 会话读取情况
|
|
1646
|
+
h('span', null, data?.fetchedAt ? timeLabel(data.fetchedAt) : ''), // 统计时间
|
|
1647
|
+
); // 结束底部说明
|
|
1648
|
+
|
|
1649
|
+
return h('div', { className: 'dspet-modal', onPointerDown: maskClose }, // 遮罩
|
|
1650
|
+
h('div', { className: 'dspet-dialog', role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'dspet-token-title' }, // 面板
|
|
1651
|
+
h('div', { className: 'dspet-token-head' }, // 标题与周期切换
|
|
1652
|
+
h('h2', { id: 'dspet-token-title', className: 'dspet-dialog-title' }, t('tkTitle')), // 标题
|
|
1653
|
+
h('div', { className: 'dspet-token-tabs', role: 'tablist' }, tabs.map(([key, labelKey]) => h( // 周期按钮组
|
|
1654
|
+
'button', { key, type: 'button', role: 'tab', className: 'dspet-token-tab', 'aria-selected': period === key, 'data-active': period === key ? 'true' : 'false', onClick: () => setPeriod(key) }, t(labelKey)), // 单个周期按钮
|
|
1655
|
+
)), // 结束周期按钮组
|
|
1656
|
+
), // 结束标题行
|
|
1657
|
+
h('p', { className: 'dspet-dialog-desc' }, t('tkDesc')), // 说明
|
|
1658
|
+
h('div', { className: 'dspet-monitor-actions' }, // 操作行
|
|
1659
|
+
h('button', { type: 'button', className: 'dspet-btn dspet-btn-primary', onClick: () => load(true), disabled: busy }, busy ? t('tkLoading') : t('tkRefresh')), // 刷新
|
|
1660
|
+
), // 结束操作行
|
|
1661
|
+
error.length > 0 ? h('p', { className: 'dspet-error' }, error) : null, // 错误
|
|
1662
|
+
data === null && busy ? h('p', { className: 'dspet-field-hint' }, t('tkLoading')) : null, // 首次加载提示
|
|
1663
|
+
data !== null && (totals?.calls ?? 0) === 0 ? h('p', { className: 'dspet-field-hint' }, data?.sessions?.scanned === 0 ? t('tkNoSessions') : t('tkNoData')) : null, // 空周期提示
|
|
1664
|
+
data !== null && (totals?.calls ?? 0) > 0 ? h('div', null, // 有数据时展示全部内容
|
|
1665
|
+
summary, // 汇总
|
|
1666
|
+
h('div', { className: 'dspet-token-section' }, h('p', { className: 'dspet-token-section-title' }, t('tkModels')), modelList), // 模型用量
|
|
1667
|
+
points.length > 1 ? h('div', { className: 'dspet-token-section' }, h('p', { className: 'dspet-token-section-title' }, series?.unit === 'hour' ? t('tkHourly') : t('tkDaily')), trend) : null, // 与当前周期匹配的趋势
|
|
1668
|
+
foot, // 底部说明
|
|
1669
|
+
) : null, // 结束内容区
|
|
1670
|
+
h('div', { className: 'dspet-dialog-footer' }, h('button', { type: 'button', className: 'dspet-btn', onClick: props.onClose }, t('close'))), // 关闭
|
|
1671
|
+
), // 结束面板
|
|
1672
|
+
); // 结束遮罩
|
|
1673
|
+
} // 结束 Token 用量面板
|
|
1674
|
+
|
|
1301
1675
|
function Pet(props) {
|
|
1302
1676
|
const [, setLocaleRevision] = React.useState(0);
|
|
1303
1677
|
const [position, setPosition] = React.useState(loadPosition);
|
|
@@ -1313,10 +1687,15 @@ window.__ModuleLoader__.load({
|
|
|
1313
1687
|
const [menu, setMenu] = React.useState(null); // 右键菜单坐标,null 表示关闭
|
|
1314
1688
|
const [relayOpen, setRelayOpen] = React.useState(false); // 中转站配置框是否打开
|
|
1315
1689
|
const menuRef = React.useRef(null); // 用于判断点击是否落在菜单内
|
|
1690
|
+
const submenuRef = React.useRef(null); // 二级菜单容器,用于独立测量尺寸
|
|
1691
|
+
const submenuAnchorRef = React.useRef(null); // 触发二级菜单的一级项,作为定位锚点
|
|
1316
1692
|
const [monitorOpen, setMonitorOpen] = React.useState(false); // OpenCode 监控面板是否打开
|
|
1317
1693
|
const [monitorView, setMonitorView] = React.useState('check'); // 监控初始视图
|
|
1318
1694
|
const [monitorAutoCheck, setMonitorAutoCheck] = React.useState(false); // 打开监控时是否自动检查
|
|
1319
|
-
const [monitorMenuOpen, setMonitorMenuOpen] = React.useState(false); //
|
|
1695
|
+
const [monitorMenuOpen, setMonitorMenuOpen] = React.useState(false); // 监控二级菜单是否展开
|
|
1696
|
+
const [submenuPos, setSubmenuPos] = React.useState({ x: 0, y: 0, ready: false }); // 二级菜单的坐标,独立测量
|
|
1697
|
+
const [usageOpen, setUsageOpen] = React.useState(false); // 剩余用量面板是否打开
|
|
1698
|
+
const [tokenOpen, setTokenOpen] = React.useState(false); // Token 用量面板是否打开
|
|
1320
1699
|
const [unseen, setUnseen] = React.useState(() => { try { return window.localStorage.getItem(OPENCODE_UNSEEN_KEY) === '1'; } catch (error) { return false; } }); // 未查看变动标记
|
|
1321
1700
|
const [autoHours, setAutoHours] = React.useState(() => { try { const value = Number(window.localStorage.getItem(OPENCODE_AUTO_KEY)); return value === 6 || value === 24 ? value : 0; } catch (error) { return 0; } }); // 自动检查间隔
|
|
1322
1701
|
const [chatOpen, setChatOpen] = React.useState(false); // 对话面板是否打开
|
|
@@ -1387,15 +1766,27 @@ window.__ModuleLoader__.load({
|
|
|
1387
1766
|
|
|
1388
1767
|
const runOpenCodeCheck = async (apiKey, silent = false) => { // 调用 Host 检查路由
|
|
1389
1768
|
const trimmed = typeof apiKey === 'string' ? apiKey.trim() : ''; // 统一 Key 输入
|
|
1390
|
-
const response = await fetch(`${OPENCODE_ROUTE}/check
|
|
1769
|
+
const response = await fetch(routeUrl(`${OPENCODE_ROUTE}/check`), { method: 'POST', headers: { 'content-type': 'application/json', accept: 'application/json' }, body: JSON.stringify(trimmed.length > 0 ? { apiKey: trimmed } : {}) }); // 发起检查
|
|
1391
1770
|
const payload = await response.json().catch(() => ({ ok: false, error: `HTTP ${response.status}` })); // 解析响应
|
|
1392
|
-
if (!response.ok || !payload.ok) throw new Error(payload.error || `HTTP ${response.status}`); //
|
|
1771
|
+
if (!response.ok || !payload.ok) throw new Error(payload.error || `HTTP ${response.status}(请求地址 ${response.url || routeUrl(`${OPENCODE_ROUTE}/check`)})`); // 检查状态并带上实际地址便于排查
|
|
1393
1772
|
if (!silent) notifyOpenCode(payload); // 后台检查才弹气泡
|
|
1394
1773
|
return payload; // 返回结果给面板
|
|
1395
1774
|
}; // 结束检查调用
|
|
1396
1775
|
const runCheckRef = React.useRef(runOpenCodeCheck); // 自动检查使用最新函数
|
|
1397
1776
|
runCheckRef.current = runOpenCodeCheck; // 每次渲染更新引用
|
|
1398
1777
|
|
|
1778
|
+
const runOpenCodeUsage = async (options = {}) => { // 调用 Host 额度路由
|
|
1779
|
+
const body = {}; // 只带非空字段,避免覆盖已保存的 Key 与来源
|
|
1780
|
+
if (typeof options.apiKey === 'string' && options.apiKey.trim().length > 0) body.apiKey = options.apiKey.trim(); // 本次输入的 Key
|
|
1781
|
+
if (typeof options.usageUrl === 'string' && options.usageUrl.trim().length > 0) body.usageUrl = options.usageUrl.trim(); // 自定义来源
|
|
1782
|
+
if (typeof options.fallbackUrl === 'string' && options.fallbackUrl.trim().length > 0) body.fallbackUrl = options.fallbackUrl.trim(); // 备用来源
|
|
1783
|
+
if (typeof options.orgId === 'string' && options.orgId.trim().length > 0) body.orgId = options.orgId.trim(); // 团队工作区 id
|
|
1784
|
+
const response = await fetch(routeUrl(`${OPENCODE_ROUTE}/usage`), { method: 'POST', headers: { 'content-type': 'application/json', accept: 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout ? AbortSignal.timeout(30000) : undefined }); // 发起额度查询
|
|
1785
|
+
const payload = await response.json().catch(() => ({ ok: false, error: `HTTP ${response.status}` })); // 解析响应
|
|
1786
|
+
if (!response.ok || !payload.ok) throw new Error(payload.error || `HTTP ${response.status}(请求地址 ${response.url || routeUrl(`${OPENCODE_ROUTE}/usage`)})`); // 检查状态并带上实际地址便于排查
|
|
1787
|
+
return payload; // 返回给面板
|
|
1788
|
+
}; // 结束额度调用
|
|
1789
|
+
|
|
1399
1790
|
const sendChat = async (text, modelKey) => { // 发送一条对话:先把用户消息落到列表,再等回复
|
|
1400
1791
|
const id = 'chat-' + (chatSeq.current += 1); // 递增的稳定消息 id
|
|
1401
1792
|
const next = [...chatMessagesRef.current, { id, role: 'user', text }]; // 含本条的最新记录
|
|
@@ -1511,6 +1902,11 @@ window.__ModuleLoader__.load({
|
|
|
1511
1902
|
}; // 结束模型目录读取
|
|
1512
1903
|
|
|
1513
1904
|
|
|
1905
|
+
// 展开二级菜单:它浮在一级菜单右侧(右侧放不下时翻到左侧),不再把父菜单拉长
|
|
1906
|
+
const toggleMonitorMenu = () => {
|
|
1907
|
+
setMonitorMenuOpen((open) => !open); // 切换展开状态,位置由下方 effect 测量
|
|
1908
|
+
}; // 结束二级菜单切换
|
|
1909
|
+
|
|
1514
1910
|
const openMonitor = (view, autoCheck) => { // 打开监控面板
|
|
1515
1911
|
setMenu(null); // 关闭右键菜单
|
|
1516
1912
|
setMonitorMenuOpen(false); // 收起子菜单
|
|
@@ -1519,6 +1915,16 @@ window.__ModuleLoader__.load({
|
|
|
1519
1915
|
setMonitorOpen(true); // 打开面板
|
|
1520
1916
|
clearUnseen(); // 视为已查看
|
|
1521
1917
|
}; // 结束打开监控
|
|
1918
|
+
const openUsage = () => { // 打开剩余用量面板
|
|
1919
|
+
setMenu(null); // 关闭右键菜单
|
|
1920
|
+
setMonitorMenuOpen(false); // 收起子菜单
|
|
1921
|
+
setUsageOpen(true); // 打开面板
|
|
1922
|
+
}; // 结束打开用量
|
|
1923
|
+
const openTokens = () => { // 打开 Token 用量面板
|
|
1924
|
+
setMenu(null); // 关闭右键菜单
|
|
1925
|
+
setMonitorMenuOpen(false); // 收起子菜单
|
|
1926
|
+
setTokenOpen(true); // 打开面板
|
|
1927
|
+
}; // 结束打开 Token 用量
|
|
1522
1928
|
const onAutoHours = (value) => { // 保存自动检查频率
|
|
1523
1929
|
setAutoHours(value); // 更新状态
|
|
1524
1930
|
try { window.localStorage.setItem(OPENCODE_AUTO_KEY, String(value)); } catch (error) { /* 忽略存储失败 */ } // 持久化
|
|
@@ -1650,19 +2056,62 @@ window.__ModuleLoader__.load({
|
|
|
1650
2056
|
return () => clearInterval(timer); // 清理定时器
|
|
1651
2057
|
}, [autoHours]); // 频率变化时重设
|
|
1652
2058
|
|
|
2059
|
+
// 方案一:菜单渲染出真实尺寸后再定位。位置未就绪时菜单不可见,量到尺寸才显示,
|
|
2060
|
+
// 因此菜单再长也只会被收进视口,不依赖任何高度估算常量。
|
|
2061
|
+
const useMenuLayout = typeof React.useLayoutEffect === 'function' ? React.useLayoutEffect : React.useEffect; // 兼容没有 useLayoutEffect 的宿主
|
|
2062
|
+
useMenuLayout(() => { // 菜单打开后测量并修正位置
|
|
2063
|
+
if (menu === null || menu.ready === true || menuRef.current === null) return; // 已定位或未渲染则不处理
|
|
2064
|
+
const rect = menuRef.current.getBoundingClientRect(); // 菜单真实尺寸
|
|
2065
|
+
const viewportWidth = window.innerWidth; // 当前窗口宽度,缩放或分屏后会变
|
|
2066
|
+
const viewportHeight = window.innerHeight; // 当前窗口高度
|
|
2067
|
+
const left = Math.max(MENU_GAP, Math.min(menu.x, viewportWidth - rect.width - MENU_GAP)); // 右边界超出时向左收
|
|
2068
|
+
const top = Math.max(MENU_GAP, Math.min(menu.y, viewportHeight - rect.height - MENU_GAP)); // 下边界超出时向上收
|
|
2069
|
+
setMenu({ ...menu, x: left, y: top, ready: true }); // 写入真实位置并标记可显示
|
|
2070
|
+
}, [menu]); // 菜单状态变化时重新测量
|
|
2071
|
+
|
|
2072
|
+
// 二级菜单独立定位:贴着锚点右侧展开,右侧放不下翻到左侧,纵向超出则收进视口
|
|
2073
|
+
useMenuLayout(() => { // 二级菜单挂载后测量
|
|
2074
|
+
if (monitorMenuOpen === false || submenuRef.current === null || submenuAnchorRef.current === null) return; // 未展开或未渲染不处理
|
|
2075
|
+
if (submenuPos.ready === true) return; // 已定位则跳过,避免反复设置
|
|
2076
|
+
const anchor = submenuAnchorRef.current.getBoundingClientRect(); // 锚点位置
|
|
2077
|
+
const rect = submenuRef.current.getBoundingClientRect(); // 二级菜单真实尺寸
|
|
2078
|
+
const viewportWidth = window.innerWidth; // 当前窗口宽度
|
|
2079
|
+
const viewportHeight = window.innerHeight; // 当前窗口高度
|
|
2080
|
+
const rightSide = anchor.right + 4; // 首选贴右侧
|
|
2081
|
+
const leftSide = anchor.left - rect.width - 4; // 右侧放不下时翻到左侧
|
|
2082
|
+
const x = rightSide + rect.width <= viewportWidth - MENU_GAP ? rightSide : Math.max(MENU_GAP, leftSide); // 横向落点
|
|
2083
|
+
const y = Math.max(MENU_GAP, Math.min(anchor.top, viewportHeight - rect.height - MENU_GAP)); // 与锚点顶部对齐后收边
|
|
2084
|
+
setSubmenuPos({ x, y, ready: true }); // 写入真实位置
|
|
2085
|
+
}, [monitorMenuOpen, submenuPos]); // 展开状态变化时重新测量
|
|
2086
|
+
|
|
2087
|
+
React.useEffect(() => { // 窗口尺寸变化时重新收边
|
|
2088
|
+
if (menu === null || menu.ready !== true) return undefined; // 只在可见时处理
|
|
2089
|
+
const onResize = () => setMenu((current) => (current === null ? current : { ...current, x: current.x, y: current.y, ready: false })); // 标记重新测量
|
|
2090
|
+
window.addEventListener('resize', onResize); // 监听窗口尺寸
|
|
2091
|
+
return () => window.removeEventListener('resize', onResize); // 清理监听
|
|
2092
|
+
}, [menu === null ? null : menu.ready]); // 打开状态变化时重挂
|
|
2093
|
+
|
|
1653
2094
|
React.useEffect(() => { // 菜单打开时监听外部点击与 Escape
|
|
1654
2095
|
if (menu === null) return undefined; // 关闭状态不装监听
|
|
1655
2096
|
const closeMenu = (event) => { // 点击菜单外时收起
|
|
1656
|
-
|
|
2097
|
+
const insideMain = menuRef.current !== null && menuRef.current.contains(event.target); // 是否点在原型菜单内
|
|
2098
|
+
const insideSub = submenuRef.current !== null && submenuRef.current.contains(event.target); // 是否点在二级菜单内
|
|
2099
|
+
if (!insideMain && !insideSub) { setMenu(null); setMonitorMenuOpen(false); } // 点外部时两个面板一起收起,避免二级菜单残留在屏幕上
|
|
1657
2100
|
}; // 结束点击处理
|
|
1658
|
-
const onEscape = (event) => {
|
|
2101
|
+
const onEscape = (event) => { // Escape 逐级收起
|
|
2102
|
+
if (event.key !== 'Escape') return; // 只处理 Escape
|
|
2103
|
+
if (monitorMenuOpen) { setMonitorMenuOpen(false); return; } // 先收二级菜单
|
|
2104
|
+
setMenu(null); setMonitorMenuOpen(false); // 收起主菜单并确保二级也收起
|
|
2105
|
+
}; // Escape 收起菜单
|
|
1659
2106
|
window.addEventListener('pointerdown', closeMenu, true); // 捕获阶段先于宠物自身点击
|
|
1660
2107
|
window.addEventListener('keydown', onEscape); // 监听全局按键
|
|
1661
2108
|
return () => { // 清理监听
|
|
1662
2109
|
window.removeEventListener('pointerdown', closeMenu, true); // 移除点击监听
|
|
1663
2110
|
window.removeEventListener('keydown', onEscape); // 移除按键监听
|
|
1664
2111
|
}; // 结束清理
|
|
1665
|
-
}, [menu]); // 菜单开关变化时重装监听
|
|
2112
|
+
}, [menu, monitorMenuOpen]); // 菜单开关变化时重装监听
|
|
2113
|
+
|
|
2114
|
+
React.useEffect(() => { if (monitorMenuOpen === false) setSubmenuPos({ x: 0, y: 0, ready: false }); }, [monitorMenuOpen]); // 收起后清掉旧坐标,下次重新测量
|
|
1666
2115
|
|
|
1667
2116
|
const onPointerDown = (event) => {
|
|
1668
2117
|
if (event.button !== undefined && event.button !== 0) return;
|
|
@@ -1734,11 +2183,10 @@ window.__ModuleLoader__.load({
|
|
|
1734
2183
|
event.stopPropagation(); // 不影响上层浮层
|
|
1735
2184
|
setMonitorMenuOpen(false); // 每次右键先收起监控子菜单
|
|
1736
2185
|
drag.current = null; // 取消可能已开始的拖拽,避免随后 pointerup 触发摸头
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
setMenu({ x: Math.max(8, x), y: Math.max(8, y) }); // 记录最终位置
|
|
2186
|
+
// 先按点击位置落点,等真实渲染出尺寸后由菜单自己的 effect 收进视口
|
|
2187
|
+
setMenu({ x: event.clientX, y: event.clientY, ready: false }); // ready=false 时菜单隐形测量
|
|
1740
2188
|
}; // 结束右键处理
|
|
1741
|
-
const openRelay = () => { setMenu(null); setRelayOpen(true); }; //
|
|
2189
|
+
const openRelay = () => { setMenu(null); setMonitorMenuOpen(false); setRelayOpen(true); }; // 打开配置框时两个菜单都收起
|
|
1742
2190
|
const closeRelay = () => setRelayOpen(false); // 关闭配置框
|
|
1743
2191
|
const onRelaySaved = () => { showBubble('savedBubble', 3200); }; // 保存成功后说一句
|
|
1744
2192
|
|
|
@@ -1811,7 +2259,7 @@ window.__ModuleLoader__.load({
|
|
|
1811
2259
|
),
|
|
1812
2260
|
menu === null ? null : h( // 右键菜单
|
|
1813
2261
|
'div', // 菜单容器
|
|
1814
|
-
{ ref: menuRef, className: 'dspet-menu', style: { left: menu.x + 'px', top: menu.y + 'px' }, role: 'menu' }, //
|
|
2262
|
+
{ ref: menuRef, className: 'dspet-menu', style: { left: menu.x + 'px', top: menu.y + 'px', visibility: menu.ready === true ? 'visible' : 'hidden' }, role: 'menu' }, // 定位与语义,测量阶段先隐形避免闪跳
|
|
1815
2263
|
h( // 供应商配置菜单项
|
|
1816
2264
|
'button', // 可聚焦按钮
|
|
1817
2265
|
{ type: 'button', className: 'dspet-menu-item', role: 'menuitem', onClick: openRelay, autoFocus: true }, // 点击打开配置
|
|
@@ -1822,7 +2270,7 @@ window.__ModuleLoader__.load({
|
|
|
1822
2270
|
), // 结束供应商菜单项
|
|
1823
2271
|
h( // OpenCode 监控一级项
|
|
1824
2272
|
'button', // 可聚焦按钮
|
|
1825
|
-
{ type: 'button', className: 'dspet-menu-item', role: 'menuitem', 'aria-expanded': monitorMenuOpen,
|
|
2273
|
+
{ type: 'button', className: 'dspet-menu-item', role: 'menuitem', 'aria-haspopup': 'menu', 'aria-expanded': monitorMenuOpen, ref: submenuAnchorRef, onClick: toggleMonitorMenu }, // 打开独立的二级菜单
|
|
1826
2274
|
h('svg', { className: 'dspet-menu-icon', viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', 'aria-hidden': true }, // 活动图标
|
|
1827
2275
|
h('path', { d: 'M3 12h4l3-8 4 16 3-8h4' }), // 活动折线路径
|
|
1828
2276
|
), // 结束图标
|
|
@@ -1831,16 +2279,27 @@ window.__ModuleLoader__.load({
|
|
|
1831
2279
|
h('path', { d: 'M9 6l6 6-6 6' }), // 箭头路径
|
|
1832
2280
|
), // 结束箭头
|
|
1833
2281
|
), // 结束监控一级项
|
|
1834
|
-
|
|
1835
|
-
'
|
|
1836
|
-
{ className: 'dspet-
|
|
1837
|
-
h('
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
2282
|
+
h( // Token 用量一级项
|
|
2283
|
+
'button', // 可聚焦按钮
|
|
2284
|
+
{ type: 'button', className: 'dspet-menu-item', role: 'menuitem', onClick: openTokens }, // 点击打开统计面板
|
|
2285
|
+
h('svg', { className: 'dspet-menu-icon', viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', 'aria-hidden': true }, // 计量图标
|
|
2286
|
+
h('path', { d: 'M3 3v18h18M7 15l4-5 3 3 5-7' }), // 折线与坐标轴路径
|
|
2287
|
+
), // 结束图标
|
|
2288
|
+
h('span', null, t('tkMenu')), // 本地化菜单文案
|
|
2289
|
+
), // 结束 Token 用量一级项
|
|
2290
|
+
), // 结束一级菜单
|
|
2291
|
+
monitorMenuOpen ? h( // 二级菜单:浮在一级菜单旁边,不撑高父菜单
|
|
2292
|
+
'div', // 二级菜单容器
|
|
2293
|
+
{ ref: submenuRef, className: 'dspet-menu dspet-submenu', style: { left: submenuPos.x + 'px', top: submenuPos.y + 'px', visibility: submenuPos.ready === true ? 'visible' : 'hidden' }, role: 'menu', 'aria-label': t('ocMenu') }, // 定位与语义,测量阶段先隐形
|
|
2294
|
+
h('button', { type: 'button', className: 'dspet-menu-item', role: 'menuitem', onClick: () => openMonitor('check', true) }, t('ocCheck')), // 立即检查
|
|
2295
|
+
h('button', { type: 'button', className: 'dspet-menu-item', role: 'menuitem', onClick: () => openMonitor('history', false) }, t('ocHistory')), // 查看记录
|
|
2296
|
+
h('button', { type: 'button', className: 'dspet-menu-item', role: 'menuitem', onClick: openUsage }, t('ocUsage')), // 剩余用量
|
|
2297
|
+
) : null, // 结束二级菜单
|
|
1841
2298
|
relayOpen ? h(RelayDialog, { t, onClose: closeRelay, onSaved: onRelaySaved }) : null, // 配置对话框
|
|
1842
2299
|
monitorOpen ? h(OpenCodeMonitor, { t, view: monitorView, autoCheck: monitorAutoCheck, autoHours, onAutoHours, runCheck: runOpenCodeCheck, onClose: () => setMonitorOpen(false), onHistoryViewed: clearUnseen }) : null, // OpenCode 监控面板
|
|
1843
2300
|
chatOpen ? h(ChatThread, { t, anchor: chatAnchor, messages: chatMessages, onSend: sendChat, onClose: closeChat, onClear: clearChat, loadModels: loadChatModels }) : null, // 双击弹出的对话面板,模型目录与清空由宠物侧处理
|
|
2301
|
+
usageOpen ? h(OpenCodeUsage, { t, runUsage: runOpenCodeUsage, onClose: () => setUsageOpen(false) }) : null, // 剩余用量面板
|
|
2302
|
+
tokenOpen ? h(TokenUsage, { t, onClose: () => setTokenOpen(false) }) : null, // Token 用量面板
|
|
1844
2303
|
);
|
|
1845
2304
|
}
|
|
1846
2305
|
|