dsh-deepmoyu 0.0.0-stage → 2.3.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/AGENTS.md +153 -0
- package/CONTRIBUTORS.md +18 -0
- package/LICENSE +21 -0
- package/NOTICE.md +18 -0
- package/README.md +128 -2
- package/assets/avatar/bust.png +0 -0
- package/assets/avatar/face.png +0 -0
- package/assets/avatar/fish.png +0 -0
- package/assets/avatar/wide.png +0 -0
- package/assets/pet/pet.png +0 -0
- package/client.js +3557 -0
- package/cordis.patch.yml +20 -0
- package/index.js +6791 -0
- package/lib/__pycache__/pv.cpython-310.pyc +0 -0
- package/lib/__pycache__/pv_cover.cpython-310.pyc +0 -0
- package/lib/asr.py +141 -0
- package/lib/automation.mjs +70 -0
- package/lib/background-mcp.mjs +32 -0
- package/lib/background-output.mjs +49 -0
- package/lib/background-tools.mjs +40 -0
- package/lib/comment.mjs +429 -0
- package/lib/dm.mjs +172 -0
- package/lib/dup.mjs +227 -0
- package/lib/favops.py +87 -0
- package/lib/hostdeps.mjs +141 -0
- package/lib/ids.mjs +35 -0
- package/lib/learn-review-mcp.mjs +56 -0
- package/lib/learn-review.mjs +69 -0
- package/lib/learn-store.mjs +19 -0
- package/lib/learn.mjs +384 -0
- package/lib/ledger.mjs +190 -0
- package/lib/login.mjs +115 -0
- package/lib/mcp.mjs +143 -0
- package/lib/mirror.mjs +120 -0
- package/lib/mode.mjs +70 -0
- package/lib/model-relay-adapter.mjs +75 -0
- package/lib/model-relay.mjs +154 -0
- package/lib/pace.mjs +477 -0
- package/lib/persona.mjs +294 -0
- package/lib/pet-session.mjs +108 -0
- package/lib/prompts.mjs +49 -0
- package/lib/pv.mjs +456 -0
- package/lib/pv.py +2509 -0
- package/lib/pv_cover.py +362 -0
- package/lib/pv_gsv.py +235 -0
- package/lib/pv_plan.py +435 -0
- package/lib/record-privacy.mjs +49 -0
- package/lib/record-storage.mjs +125 -0
- package/lib/records.mjs +325 -0
- package/lib/reply.mjs +106 -0
- package/lib/run-settings.mjs +110 -0
- package/lib/state.mjs +183 -0
- package/lib/sticker-automation.mjs +166 -0
- package/lib/sticker-download.mjs +159 -0
- package/lib/sticker-image.mjs +245 -0
- package/lib/sticker.mjs +232 -0
- package/lib/taste.mjs +624 -0
- package/lib/tokens.mjs +211 -0
- package/lib/uvfetch.mjs +179 -0
- package/lib/verify.mjs +184 -0
- package/package.json +81 -4
- package/presets/deepmoyu.patch.yml +129 -0
- package/prompts/duty.md +135 -0
- package/selfcheck.mjs +157 -0
- package/tests/automation-routes.test.mjs +183 -0
- package/tests/automation.test.mjs +79 -0
- package/tests/background-tools.test.mjs +182 -0
- package/tests/background-window.test.mjs +75 -0
- package/tests/dup-cross-channel.test.mjs +158 -0
- package/tests/manual-browsing.test.mjs +671 -0
- package/tests/mcp-restart.test.mjs +131 -0
- package/tests/memory-chain.test.mjs +413 -0
- package/tests/model-relay.test.mjs +116 -0
- package/tests/persona.test.mjs +67 -0
- package/tests/pet-run-routes.test.mjs +127 -0
- package/tests/pet-run-settings.test.mjs +38 -0
- package/tests/pet-session.test.mjs +125 -0
- package/tests/pv-stage1.test.mjs +203 -0
- package/tests/sticker-routes.test.mjs +141 -0
- package/tests/sticker-upload-routes.test.mjs +97 -0
- package/tests/sticker-upload.test.mjs +122 -0
- package/tests/sticker.test.mjs +169 -0
- package/tests/test_guard_cross_channel.py +141 -0
- package/tests/test_pv_cover.py +95 -0
- package/tests/test_pv_stage1.py +250 -0
- package/tests/test_reply_pictures.py +255 -0
- package/tests/token-ledger.test.mjs +78 -0
- package/tests/verify_pv_sample.py +91 -0
- package/vendor/bilibili-mcp/LICENSE +21 -0
- package/vendor/bilibili-mcp/README.md +176 -0
- package/vendor/bilibili-mcp/mcp_server.py +2708 -0
- package/vendor/bilibili-mcp/requirements.txt +11 -0
- package/vendor/bilibili-mcp/runtime_guard.py +676 -0
package/client.js
ADDED
|
@@ -0,0 +1,3557 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-deepmoyu —— 浏览器半端(**设置全在桌宠那只鱼身上,DSH 的「设置」里不再有刷B站**)
|
|
3
|
+
* ============================================================================
|
|
4
|
+
* ⭐ **client bundle 不是 ESM,是自注册脚本**(2026-10-14 从官方包里挖到的约定):
|
|
5
|
+
*
|
|
6
|
+
* window.__ModuleLoader__.load({
|
|
7
|
+
* id: '<包名>', // 必须等于 package.json 的 name
|
|
8
|
+
* factory: (require) => { ... return { inject: [...], apply(ctx) {...} } },
|
|
9
|
+
* })
|
|
10
|
+
*
|
|
11
|
+
* ⭐ **2026-10-26 使用者改口(m00717):「当前鲸鱼摸鱼的设置不要在 dsh 的默认设置里」**
|
|
12
|
+
* ⇒ 原来那段(抄 `dsh-meme`)的注册已经**整段删掉**:
|
|
13
|
+
*
|
|
14
|
+
* slots.inject('settings.section', () => slots.register(
|
|
15
|
+
* { name: 'settings.section', id: 'dafeiyu', order: 30, label: '刷B站' }, …)) // ✗ 已移除
|
|
16
|
+
*
|
|
17
|
+
* 现在的入口只有一条:**点桌宠 → 弹菜单 → ⚙️ 设置 → 弹窗**(PetSettingsModal)。
|
|
18
|
+
* 弹窗九项侧栏:偏好与桌宠 / 运行方式 / 互动与投递 / 表情收藏 / 学习与记忆 /
|
|
19
|
+
* token 账本 / 运行日志 / 日报视频 / 账号与环境;仅构建当前页,账本独立并支持截图。
|
|
20
|
+
* ⇒ `inject` 也跟着清空成 `[]`,apply() 只做两件事:挂样式、挂桌宠。
|
|
21
|
+
* ⚠️ 别再加回 settings.section —— selfcheck.mjs 会直接判红。
|
|
22
|
+
*
|
|
23
|
+
* 这一页要满足使用者 2026-10-14 说的形状(现在这些都长在弹窗里):
|
|
24
|
+
* · **总开关 → 打开之后下面才展开子开关**(峰谷自动刷 → 中午 / 晚上)
|
|
25
|
+
* · **数字项要能自己填**(定量刷:每天几条)
|
|
26
|
+
* · **账号绑定必须在设置里**(扫码登录 + 二维码直接显示在页面里)
|
|
27
|
+
* · 会发东西出去的(自动评论)、要下大模型的(本地转写)**默认关**
|
|
28
|
+
*
|
|
29
|
+
* ⭐ 桌宠:本文件在 document.body 上挂一只常驻贴图鱼(不走 slots —— 自建 DOM +
|
|
30
|
+
* react-dom root 渲染,见文件尾部「🐾 桌宠」那一大段)。单击鱼=弹菜单,双击=直接
|
|
31
|
+
* 「新开会话刷」;藏起来之后右下角留一个小圆点,点它把鱼叫回来。
|
|
32
|
+
* (原来的唤回入口是「设置 → 刷B站 → 显示桌宠」,设置页没了,所以补了这个圆点。)
|
|
33
|
+
* ============================================================================
|
|
34
|
+
*/
|
|
35
|
+
window.__ModuleLoader__.load({
|
|
36
|
+
id: 'dsh-deepmoyu',
|
|
37
|
+
factory: (require) => {
|
|
38
|
+
const React = require('react')
|
|
39
|
+
const h = React.createElement
|
|
40
|
+
|
|
41
|
+
/* 私信「晾太久就不回了」的天数 —— 跟宿主 `lib/dm.mjs` 的 `DM_STALE_DAYS` 必须一致。
|
|
42
|
+
* client bundle 不是 ESM(见文件头),没法 import 那个模块,所以在这里再钉一个常量;
|
|
43
|
+
* 断言脚本(devtools/render-panel.cjs)会核对文案里出现的数字,改一处漏一处会被抓出来。 */
|
|
44
|
+
const DM_STALE_DAYS = 3
|
|
45
|
+
|
|
46
|
+
/* 🪞 她自己那本账的四节(跟宿主 `lib/persona.mjs` 的 SOUL_SECTIONS 一一对应)。
|
|
47
|
+
* client bundle 不是 ESM,import 不了,所以在这里再钉一份;只用来显示与清空。 */
|
|
48
|
+
const SOUL_SECTION_LABELS = [['mood', '最近的心情'], ['crave', '惦记的事'], ['self', '对自己的认识'], ['next', '下次想试试']]
|
|
49
|
+
|
|
50
|
+
const API = '/api/bili-duty'
|
|
51
|
+
/* 🐟 桌宠本体贴图:后端 `GET /api/bili-duty/pet/image?name=pet` → `assets/pet/pet.png`
|
|
52
|
+
*(554×561 透明 PNG,247KB)。不塞 base64 进 bundle:白膨胀 330KB,且这样能走浏览器缓存。
|
|
53
|
+
* 路由不在 / 文件丢了时 <img> 会 onError,PetFish 自动退回原来那条纯 CSS 胖鱼。 */
|
|
54
|
+
const PET_IMG_URL = API + '/pet/image?name=pet'
|
|
55
|
+
/* 🪞 弹窗头像位用的是角色立绘的**脸部特写**(assets/avatar/face.png,512×512 圆图)——
|
|
56
|
+
* 比抽象小图标有人味。图挂了 onError 退回原来的图标,不会留一个破图框。 */
|
|
57
|
+
const PET_AVATAR_URL = API + '/pet/image?name=face'
|
|
58
|
+
/* ⚠️ 原来 jget/jpost 直接 .json()、从不看 res.ok ⇒ HTTP 500 的 {ok:false,error}
|
|
59
|
+
* 会被调用方当成「已保存 / 成功」,绿色提示把故障盖掉。(2026-10-16 审查修) */
|
|
60
|
+
const asJson = async (res) => {
|
|
61
|
+
if (res.ok) return res.json()
|
|
62
|
+
const why = res.status === 401 ? '(未登录 / 没权限)' : res.status === 403 ? '(被宿主拦了)' : ''
|
|
63
|
+
let detail
|
|
64
|
+
try { detail = await res.json() } catch { /* Some older hosts return a plain 404 page. */ }
|
|
65
|
+
const message = typeof detail?.error === 'string' ? detail.error.slice(0, 500) : ''
|
|
66
|
+
const error = new Error('HTTP ' + res.status + why + (message ? ':' + message : ''))
|
|
67
|
+
error.status = res.status
|
|
68
|
+
error.code = detail?.code
|
|
69
|
+
throw error
|
|
70
|
+
}
|
|
71
|
+
const jget = async (p) => asJson(await fetch(API + p, { credentials: 'same-origin' }))
|
|
72
|
+
const jpost = async (p, body) => asJson(await fetch(API + p, {
|
|
73
|
+
method: 'POST',
|
|
74
|
+
credentials: 'same-origin',
|
|
75
|
+
headers: { 'content-type': 'application/json' },
|
|
76
|
+
body: JSON.stringify(body || {}),
|
|
77
|
+
}))
|
|
78
|
+
|
|
79
|
+
const CSS = `
|
|
80
|
+
[data-bd-page]{display:flex;flex-direction:column;gap:14px;padding:2px 2px 8px;max-width:680px;}
|
|
81
|
+
/* ⭐ 卡片:七段内容原来只靠 gap 分隔,视觉上糊成一片 ⇒ 每段一张卡,边界一眼可辨 */
|
|
82
|
+
[data-bd-sec]{display:flex;flex-direction:column;gap:8px;padding:13px 14px;border-radius:10px;
|
|
83
|
+
border:1px solid var(--dsw-alias-border-l2,rgba(255,255,255,.1));
|
|
84
|
+
background:var(--dsw-alias-bg-overlay,rgba(128,128,128,.07));}
|
|
85
|
+
[data-bd-sec][data-flat]{padding:0;border:0;background:none;}
|
|
86
|
+
[data-bd-h]{display:flex;align-items:center;gap:6px;font-size:11px;font-weight:600;letter-spacing:.04em;
|
|
87
|
+
color:var(--dsw-alias-label-tertiary,#9aa0a6);}
|
|
88
|
+
[data-bd-h] b{font-size:12px;font-weight:600;color:var(--dsw-alias-label-primary,inherit);letter-spacing:0;}
|
|
89
|
+
/* ⚠️ 原来没有 flex-wrap ⇒ 顶部那一排 badge + 按钮会溢出被裁掉(2026-10-20 使用者截图指出的"布局有问题") */
|
|
90
|
+
[data-bd-row]{display:flex;align-items:center;gap:10px;flex-wrap:wrap;}
|
|
91
|
+
[data-bd-row][data-nowrap]{flex-wrap:nowrap;}
|
|
92
|
+
[data-bd-grow]{flex:1 1 140px;min-width:0;}
|
|
93
|
+
[data-bd-label]{font-size:13px;}
|
|
94
|
+
[data-bd-note]{font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary,#9aa0a6);}
|
|
95
|
+
[data-bd-sw]{flex:none;width:38px;height:22px;border-radius:11px;border:0;cursor:pointer;position:relative;
|
|
96
|
+
background:var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.35));transition:background .15s;padding:0;}
|
|
97
|
+
[data-bd-sw][data-on]{background:#38bdf8;}
|
|
98
|
+
[data-bd-sw] i{position:absolute;top:3px;left:3px;width:16px;height:16px;border-radius:50%;background:#fff;
|
|
99
|
+
box-shadow:0 1px 2px rgba(6,40,64,.25);
|
|
100
|
+
transition:left .2s cubic-bezier(.34,1.56,.64,1);display:block;}
|
|
101
|
+
[data-bd-sw][data-on] i{left:19px;}
|
|
102
|
+
[data-bd-sw][disabled]{opacity:.45;cursor:default;}
|
|
103
|
+
[data-bd-sub]{margin-left:14px;padding-left:12px;border-left:2px solid var(--dsw-alias-border-l2,rgba(255,255,255,.1));
|
|
104
|
+
display:flex;flex-direction:column;gap:10px;}
|
|
105
|
+
/* ⭐ 表单行:标签在左、控件在右(这是设置页的常规排版),但光有间距时控件像"飘在右边"。
|
|
106
|
+
* 给每行加一条很淡的分隔线,一列下来就读成"表单"而不是"撒了一地控件"。
|
|
107
|
+
* (2026-10-20 截图复查:数字框全贴右边缘、中间空一大片,看着像布局坏了) */
|
|
108
|
+
[data-bd-sub] > [data-bd-row]{padding:6px 0;gap:12px;
|
|
109
|
+
border-bottom:1px solid var(--dsw-alias-border-l2,rgba(255,255,255,.055));}
|
|
110
|
+
[data-bd-sub] > [data-bd-row]:last-child{border-bottom:0;}
|
|
111
|
+
[data-bd-sub] > [data-bd-note]{margin-top:-4px;}
|
|
112
|
+
[data-bd-input]{width:100%;box-sizing:border-box;padding:7px 9px;border-radius:8px;font-size:12px;
|
|
113
|
+
border:1px solid var(--dsw-alias-border-l2,rgba(255,255,255,.14));
|
|
114
|
+
background:var(--dsw-alias-bg-overlay,rgba(128,128,128,.12));color:inherit;font-family:inherit;}
|
|
115
|
+
[data-bd-input]:focus{outline:none;border-color:#3b82f6;}
|
|
116
|
+
[data-bd-num]{width:88px;text-align:center;flex:none;}
|
|
117
|
+
[data-bd-btn]{padding:7px 12px;border-radius:8px;font-size:12px;cursor:pointer;font-family:inherit;
|
|
118
|
+
border:1px solid var(--dsw-alias-border-l2,rgba(255,255,255,.16));
|
|
119
|
+
background:var(--dsw-alias-bg-overlay,rgba(128,128,128,.14));color:inherit;
|
|
120
|
+
transition:background .15s ease,border-color .15s ease,box-shadow .15s ease,transform .15s ease;}
|
|
121
|
+
[data-bd-btn]:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.24));}
|
|
122
|
+
[data-bd-btn]:active:not(:disabled){transform:translateY(1px) scale(.99);}
|
|
123
|
+
[data-bd-btn][data-primary]{background:#3b82f6;border-color:#3b82f6;color:#fff;}
|
|
124
|
+
[data-bd-btn][data-sm]{padding:4px 9px;font-size:11px;}
|
|
125
|
+
[data-bd-btn][disabled]{opacity:.5;cursor:default;}
|
|
126
|
+
[data-bd-msg]{font-size:11px;line-height:16px;padding:7px 9px;border-radius:8px;word-break:break-word;
|
|
127
|
+
background:var(--dsw-alias-bg-overlay,rgba(128,128,128,.12));color:var(--dsw-alias-label-tertiary,#9aa0a6);}
|
|
128
|
+
[data-bd-msg][data-warn]{color:#f59e0b;}
|
|
129
|
+
[data-bd-msg][data-good]{color:#22c55e;}
|
|
130
|
+
[data-bd-qr]{display:flex;gap:14px;align-items:flex-start;flex-wrap:wrap;}
|
|
131
|
+
[data-bd-qr] img{width:150px;height:150px;border-radius:8px;background:#fff;padding:6px;}
|
|
132
|
+
[data-bd-pre]{margin:6px 0 0;padding:8px 9px;border-radius:8px;max-height:170px;overflow:auto;font-size:11px;
|
|
133
|
+
line-height:15px;white-space:pre-wrap;word-break:break-all;
|
|
134
|
+
background:var(--dsw-alias-bg-overlay,rgba(0,0,0,.25));color:var(--dsw-alias-label-secondary,#c9ccd1);}
|
|
135
|
+
[data-bd-kv]{font-size:11px;line-height:18px;color:var(--dsw-alias-label-tertiary,#9aa0a6);word-break:break-all;}
|
|
136
|
+
[data-bd-badge]{font-size:11px;padding:2px 7px;border-radius:6px;white-space:nowrap;
|
|
137
|
+
background:var(--dsw-alias-bg-overlay,rgba(128,128,128,.14));color:var(--dsw-alias-label-secondary,#c9ccd1);}
|
|
138
|
+
[data-bd-badge][data-live]{background:rgba(34,197,94,.16);color:#22c55e;}
|
|
139
|
+
[data-bd-badge][data-warn]{background:rgba(245,158,11,.16);color:#f59e0b;}
|
|
140
|
+
/* ⭐ 活动流水:它刚才干了什么,一条一行,时间在左、动作在右 */
|
|
141
|
+
[data-bd-act]{display:flex;flex-direction:column;gap:0;max-height:280px;overflow:auto;
|
|
142
|
+
border-radius:8px;border:1px solid var(--dsw-alias-border-l2,rgba(255,255,255,.08));}
|
|
143
|
+
[data-bd-actline]{display:flex;gap:8px;padding:6px 9px;font-size:11px;line-height:16px;align-items:flex-start;
|
|
144
|
+
border-bottom:1px solid var(--dsw-alias-border-l2,rgba(255,255,255,.05));}
|
|
145
|
+
[data-bd-actline]:last-child{border-bottom:0;}
|
|
146
|
+
[data-bd-actline] time{flex:none;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-tertiary,#9aa0a6);}
|
|
147
|
+
/* ⚠️ 长句会撑成六七行(实测一条 200 字的旁白把流水糊掉半屏)⇒ 正常 2 行、悬停看全。
|
|
148
|
+
* 用 -webkit-line-clamp 而不是 max-height:行高随字号变,固定高度会切到半行字。 */
|
|
149
|
+
[data-bd-actline] span{flex:1;min-width:0;word-break:break-word;
|
|
150
|
+
display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;}
|
|
151
|
+
[data-bd-actline]:hover span{-webkit-line-clamp:8;}
|
|
152
|
+
[data-bd-actline] em{flex:none;font-style:normal;font-size:10px;padding:1px 5px;border-radius:4px;
|
|
153
|
+
background:rgba(96,165,250,.16);color:#60a5fa;animation:bdPulse 1.4s ease-in-out infinite;}
|
|
154
|
+
@keyframes bdPulse{0%,100%{opacity:1}50%{opacity:.45}}
|
|
155
|
+
[data-bd-actline][data-kind="call"] span{color:var(--dsw-alias-label-secondary,#c9ccd1);}
|
|
156
|
+
[data-bd-actline][data-kind="done"] span{color:var(--dsw-alias-label-primary,inherit);}
|
|
157
|
+
[data-bd-actline][data-kind="fail"] span,[data-bd-actline][data-kind="budget"] span{color:#f87171;}
|
|
158
|
+
[data-bd-actline][data-kind="start"] span,[data-bd-actline][data-kind="resume"] span{color:#22c55e;}
|
|
159
|
+
[data-bd-actline][data-kind="round"] span{color:#60a5fa;font-weight:600;}
|
|
160
|
+
[data-bd-empty]{padding:14px;text-align:center;font-size:11px;color:var(--dsw-alias-label-tertiary,#9aa0a6);}
|
|
161
|
+
/* ⭐ 「正在想」呼吸条:模型不调工具的那几十秒,界面不能一声不吭 */
|
|
162
|
+
[data-bd-thinking]{display:flex;align-items:center;gap:8px;padding:7px 10px;border-radius:8px;font-size:11px;
|
|
163
|
+
line-height:16px;background:rgba(96,165,250,.1);color:#93c5fd;
|
|
164
|
+
border:1px solid rgba(96,165,250,.22);}
|
|
165
|
+
[data-bd-thinking] time{flex:none;font-variant-numeric:tabular-nums;color:rgba(147,197,253,.75);}
|
|
166
|
+
[data-bd-dot]{flex:none;width:7px;height:7px;border-radius:50%;background:#60a5fa;
|
|
167
|
+
animation:bdBreathe 1.2s ease-in-out infinite;}
|
|
168
|
+
@keyframes bdBreathe{0%,100%{opacity:.35;transform:scale(.8)}50%{opacity:1;transform:scale(1.15)}}
|
|
169
|
+
/* ⭐ KPI 一排:数字大、标签小。三个格子等宽,眼睛能横向对齐
|
|
170
|
+
* (原来 4 个挤一起时,长模型名把后面全推乱了) */
|
|
171
|
+
[data-bd-kpi]{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;}
|
|
172
|
+
[data-bd-kpi] div{display:flex;flex-direction:column;gap:1px;min-width:0;}
|
|
173
|
+
[data-bd-kpi] b{font-size:15px;font-variant-numeric:tabular-nums;font-weight:600;
|
|
174
|
+
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
|
175
|
+
[data-bd-kpi] span{font-size:10px;letter-spacing:.03em;color:var(--dsw-alias-label-tertiary,#9aa0a6);
|
|
176
|
+
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
|
177
|
+
/* ⭐ 模型名单独一行:38 个字符的 provider/model 不可能塞进 1/4 宽度里 */
|
|
178
|
+
[data-bd-model]{display:flex;align-items:baseline;gap:8px;min-width:0;}
|
|
179
|
+
[data-bd-model] b{font-size:12px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;}
|
|
180
|
+
/* ⭐ 带说明的字段块:标签一行 + 说明跟在后面,比纯 note 更像表单 */
|
|
181
|
+
[data-bd-field]{display:flex;flex-direction:column;gap:1px;margin-top:2px;}
|
|
182
|
+
/* ⭐ 开关行:label 和开关之间要有虚线引导,否则开关飘在最右边像脱缰 */
|
|
183
|
+
[data-bd-row][data-bd-toggle]{padding:2px 0;}
|
|
184
|
+
[data-bd-toggle] [data-bd-sw]{margin-left:auto;}
|
|
185
|
+
[data-bd-learn-list]{display:flex;flex-direction:column;gap:10px;}
|
|
186
|
+
[data-bd-example]{margin:0;padding:8px 10px;border-left:2px solid #60a5fa;border-radius:0 7px 7px 0;
|
|
187
|
+
background:rgba(96,165,250,.07);font-size:12px;line-height:1.6;white-space:pre-wrap;overflow-wrap:anywhere;}
|
|
188
|
+
[data-bd-source]{overflow-wrap:anywhere;}
|
|
189
|
+
[data-bd-settings-log]{display:flex;flex-direction:column;gap:0;max-height:260px;overflow:auto;border:1px solid var(--dsw-alias-border-l2,rgba(255,255,255,.08));border-radius:8px;}
|
|
190
|
+
[data-bd-logrow]{padding:6px 8px;border-bottom:1px solid var(--dsw-alias-border-l2,rgba(255,255,255,.06));font-size:11px;line-height:16px;}
|
|
191
|
+
[data-bd-logrow]:last-child{border-bottom:0;}
|
|
192
|
+
[data-bd-logrow] time{flex:none;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-tertiary,#9aa0a6);}
|
|
193
|
+
[data-bd-logevent]{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
|
194
|
+
[data-bd-logrow] [data-bd-pre]{max-height:120px;margin-top:4px;}
|
|
195
|
+
|
|
196
|
+
/* ═══════════ 🐾 桌宠(2026-10-26 桌宠化改造)═══════════
|
|
197
|
+
* 挂在自建 DOM(document.body)上的常驻浮层,选择器全部带 [data-bd-pet-*] 前缀,
|
|
198
|
+
* 跟设置页那套 [data-bd-*] 互不污染;控件类(按钮/输入框/开关)继续复用上面的
|
|
199
|
+
* [data-bd-btn]/[data-bd-input]/[data-bd-sw],视觉语言才是同一套。
|
|
200
|
+
* 配色:柔和蓝青做鱼身(海洋),珊瑚橙只给尾鳍和强调点 —— 跟插件整体一个脾气。 */
|
|
201
|
+
/* ⭐ 2026-10-26 换成本体贴图后放大了一档:原图 554×561,角色本体只占下半部(脸在 ~70% 处),
|
|
202
|
+
76px 的人格里五官糊成一团;128 格 + 120 贴图,桌面上一眼认得出是谁,图里那个
|
|
203
|
+
「哦鲸鲸…」的小气泡也勉强能看清字。 */
|
|
204
|
+
/* ⚠️ 卡片/弹窗/toast 都是 [data-bd-pet] 的后代(菜单要拿 128px 的鱼盒当定位参照),
|
|
205
|
+
所以凡是"手感"类的规则(grab 光标、缩放、禁选)都必须锁在 [data-bd-pet-fish] 上,
|
|
206
|
+
不能落在 [data-bd-pet] 上 —— 否则菜单里的文字会被一起禁选、鼠标停在设置弹窗上鱼还在放大。 */
|
|
207
|
+
[data-bd-pet]{position:absolute;width:128px;height:128px;}
|
|
208
|
+
/* touch-action 也要锁在鱼身上:放在 [data-bd-pet] 上会连设置弹窗里的触摸滚动一起禁掉
|
|
209
|
+
(沿祖先链找,卡片是它的后代) */
|
|
210
|
+
[data-bd-pet-fish]{position:absolute;left:4px;top:4px;width:120px;height:120px;cursor:grab;touch-action:none;
|
|
211
|
+
user-select:none;-webkit-user-select:none;
|
|
212
|
+
filter:drop-shadow(0 10px 20px rgba(4,24,40,.45));
|
|
213
|
+
animation:bdPetFloat 3.8s ease-in-out infinite;}
|
|
214
|
+
[data-bd-pet][data-dragging] [data-bd-pet-fish]{cursor:grabbing;
|
|
215
|
+
filter:drop-shadow(0 18px 30px rgba(4,24,40,.55));}
|
|
216
|
+
/* 拖动时冻住所有动画(浮沉/摆尾/光环):一条"被抓着"的鱼不该还在装游泳 */
|
|
217
|
+
[data-bd-pet][data-dragging] *{animation-play-state:paused !important;}
|
|
218
|
+
@keyframes bdPetFloat{0%,100%{transform:translateY(0) rotate(-2.5deg)}50%{transform:translateY(-7px) rotate(2.5deg)}}
|
|
219
|
+
/* ⭐ 2026-10-26 换成本体贴图(554×561 的透明 PNG):object-fit contain + 圆角阴影,
|
|
220
|
+
浮沉/摆尾的动画沿用上面那条 bdPetFloat,贴图不用为动画再切图层。 */
|
|
221
|
+
[data-bd-pet-img]{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;
|
|
222
|
+
-webkit-user-drag:none;user-select:none;-webkit-user-select:none;pointer-events:none;
|
|
223
|
+
transition:transform .18s ease;
|
|
224
|
+
filter:drop-shadow(0 6px 12px rgba(4,24,40,.45));}
|
|
225
|
+
/* ⭐ 摸一下要有反应:悬停微微放大("我在这儿"),按下回缩("收到"),拖起来侧一点身("被拎住了")。
|
|
226
|
+
贴图本身没别的 transform 动画,这条 transition 不和 bdPetFloat(在外层 fish 上)打架。 */
|
|
227
|
+
[data-bd-pet-fish]:hover [data-bd-pet-img]{transform:scale(1.045);}
|
|
228
|
+
[data-bd-pet-fish]:active [data-bd-pet-img]{transform:scale(.965);}
|
|
229
|
+
/* 🎨 心情/需要-你小灯(2026-10-28,纯显示层):attention 急促琥珀 > mood 四色(soul 最后一行
|
|
230
|
+
* 关键词推导)。别加第二次发光 —— 鱼本体的 hover 缩放已经够戏了。 */
|
|
231
|
+
[data-bd-pet-mood]{position:absolute;right:10px;top:6px;width:11px;height:11px;border-radius:50%;
|
|
232
|
+
border:1.5px solid rgba(255,255,255,.9);background:#9db8c8;box-shadow:0 1px 4px rgba(2,12,22,.35);
|
|
233
|
+
pointer-events:none;z-index:2;}
|
|
234
|
+
[data-bd-pet-mood][data-mood=curious]{background:#56c8d2;box-shadow:0 0 8px rgba(86,200,210,.8);animation:bdMoodBreathe 3.2s ease-in-out infinite;}
|
|
235
|
+
[data-bd-pet-mood][data-mood=content]{background:#8fd08f;box-shadow:0 0 8px rgba(143,208,143,.7);}
|
|
236
|
+
[data-bd-pet-mood][data-mood=glum]{background:#a39ad9;box-shadow:0 0 6px rgba(163,154,217,.55);}
|
|
237
|
+
[data-bd-pet-mood][data-mood=attention]{background:#ffb14d;box-shadow:0 0 10px rgba(255,177,77,.9);animation:bdMoodPulse 1.6s ease-in-out infinite;}
|
|
238
|
+
@keyframes bdMoodBreathe{0%,100%{transform:scale(1);}50%{transform:scale(1.2);}}
|
|
239
|
+
@keyframes bdMoodPulse{0%,100%{transform:scale(1);}50%{transform:scale(1.35);}}
|
|
240
|
+
[data-bd-pet][data-dragging] [data-bd-pet-img]{transform:scale(1.02) rotate(-1.5deg);}
|
|
241
|
+
/* 系统里关了动效的人:浮沉/光环/泡泡全部停掉,只留静态贴图(不然会晕) */
|
|
242
|
+
@media (prefers-reduced-motion:reduce){
|
|
243
|
+
[data-bd-pet], [data-bd-pet] *{animation:none !important;}
|
|
244
|
+
[data-bd-pet-img]{transition:none;}
|
|
245
|
+
}
|
|
246
|
+
/* 状态通过菜单文字与状态卡表达,不给角色叠呼吸光圈或发光滤镜。 */
|
|
247
|
+
/* 🙈 藏起来之后留的那个小圆点:静态入口,不用呼吸提示打扰使用者。 */
|
|
248
|
+
[data-bd-pet-dot]{position:fixed;width:20px;height:20px;border-radius:50%;cursor:pointer;border:1px solid #8cb6cc;padding:0;
|
|
249
|
+
background:#d7e9f2;box-shadow:0 2px 8px rgba(2,12,22,.2);opacity:.8;z-index:2147483000;}
|
|
250
|
+
[data-bd-pet-dot]:hover{opacity:1;background:#eef7fb;}
|
|
251
|
+
/* ⭐ 鱼身:渐变 + 浅肚皮;尾/背鳍/胸鳍是珊瑚橙的点缀,眼睛会眨(keyframes)
|
|
252
|
+
* ⚠️ 别写成 [data-bd-pet-body] —— 那个名字已经被弹窗正文([data-bd-pet-body]{flex:1;overflow:auto})占了,
|
|
253
|
+
* 两条规则会叠到同一个元素上:鱼身变 overflow:auto,弹窗正文反过来被 position:absolute + 49x44
|
|
254
|
+
* 按扁,整个设置弹窗只剩顶上一条 tab 栏(2026-10-26 真踩过,截图验证时才发现)。 */
|
|
255
|
+
[data-bd-pet-torso]{position:absolute;left:13px;top:2px;width:49px;height:44px;
|
|
256
|
+
border-radius:52% 62% 62% 52% / 58% 68% 68% 58%;
|
|
257
|
+
background:linear-gradient(135deg,#b9e9f8 0%,#63bade 36%,#2f89bb 72%,#1d6494 100%);
|
|
258
|
+
box-shadow:inset -5px -8px 11px rgba(9,50,80,.38),inset 4px 6px 9px rgba(255,255,255,.4),0 4px 10px rgba(15,60,95,.35);}
|
|
259
|
+
[data-bd-pet-belly]{position:absolute;left:19px;top:23px;width:36px;height:21px;border-radius:50%;pointer-events:none;
|
|
260
|
+
background:linear-gradient(180deg,rgba(255,255,255,0) 0%,rgba(233,248,255,.45) 55%,rgba(233,248,255,.78) 100%);}
|
|
261
|
+
[data-bd-pet-tail]{position:absolute;left:-3px;top:13px;width:20px;height:24px;transform-origin:100% 50%;
|
|
262
|
+
clip-path:polygon(100% 50%,4% 0,24% 50%,4% 100%);
|
|
263
|
+
background:linear-gradient(90deg,#f2955c,#f97316 68%,#fb923c);
|
|
264
|
+
animation:bdPetWag 1.15s ease-in-out infinite;}
|
|
265
|
+
@keyframes bdPetWag{0%,100%{transform:rotate(-11deg) scaleX(.9)}50%{transform:rotate(12deg) scaleX(1.1)}}
|
|
266
|
+
[data-bd-pet-dorsal]{position:absolute;left:31px;top:-6px;width:20px;height:13px;
|
|
267
|
+
clip-path:polygon(16% 100%,52% 0,100% 100%);
|
|
268
|
+
background:linear-gradient(180deg,#ffc27a,#f59e0b);}
|
|
269
|
+
[data-bd-pet-fin]{position:absolute;left:31px;top:39px;width:17px;height:12px;transform-origin:50% 0;
|
|
270
|
+
clip-path:polygon(0 0,100% 0,50% 100%);
|
|
271
|
+
background:linear-gradient(180deg,rgba(249,115,22,.85),rgba(249,115,22,.4));
|
|
272
|
+
animation:bdPetFin 1.5s ease-in-out infinite;}
|
|
273
|
+
@keyframes bdPetFin{0%,100%{transform:rotate(-9deg)}50%{transform:rotate(11deg)}}
|
|
274
|
+
[data-bd-pet-eye]{position:absolute;left:45px;top:13px;width:12px;height:12px;border-radius:50%;background:#fff;
|
|
275
|
+
box-shadow:0 1px 2px rgba(6,40,64,.45);animation:bdPetBlink 4.6s infinite;}
|
|
276
|
+
[data-bd-pet-eye] i{position:absolute;left:5px;top:3px;width:6px;height:6px;border-radius:50%;background:#132635;
|
|
277
|
+
box-shadow:inset 1px 1px 1px rgba(255,255,255,.55);}
|
|
278
|
+
@keyframes bdPetBlink{0%,91%,95%,100%{transform:scaleY(1)}93%{transform:scaleY(.05)}}
|
|
279
|
+
[data-bd-pet-mouth]{position:absolute;left:55px;top:31px;width:7px;height:4px;
|
|
280
|
+
border:1.5px solid rgba(10,50,80,.55);border-top:0;border-radius:0 0 7px 7px;}
|
|
281
|
+
/* ⭐ 泡泡:上浮 + 消散;尺寸/偏移/时长由 React 用 CSS 变量注入,一条 keyframes 全包 */
|
|
282
|
+
[data-bd-pet-bubble]{position:absolute;left:57px;top:12px;width:var(--s,6px);height:var(--s,6px);border-radius:50%;
|
|
283
|
+
opacity:0;pointer-events:none;
|
|
284
|
+
border:1.5px solid rgba(190,230,250,.85);
|
|
285
|
+
background:radial-gradient(circle at 32% 30%,rgba(255,255,255,.95),rgba(190,230,250,.25) 62%);
|
|
286
|
+
animation:bdPetBubble var(--t,3.4s) ease-out var(--d,0s) forwards;}
|
|
287
|
+
@keyframes bdPetBubble{0%{opacity:0;transform:translate(0,0) scale(.5)}12%{opacity:.95}
|
|
288
|
+
100%{opacity:0;transform:translate(var(--dx,10px),-48px) scale(1.3)}}
|
|
289
|
+
/* ⭐ 弹出卡片(菜单/子面板共用):毛玻璃、圆角、阴影、缩放进场;默认在鱼上方,
|
|
290
|
+
* 鱼贴顶时翻到下方(data-flip)、贴右缘时右对齐(data-align)—— 都是 JS 算好挂属性。 */
|
|
291
|
+
[data-bd-pet-card]{position:absolute;left:-4px;bottom:calc(100% + 14px);width:252px;border-radius:14px;overflow:hidden;
|
|
292
|
+
background:rgba(16,28,40,.85);backdrop-filter:blur(16px) saturate(1.4);-webkit-backdrop-filter:blur(16px) saturate(1.4);
|
|
293
|
+
border:1px solid rgba(255,255,255,.14);box-shadow:0 14px 44px rgba(2,12,22,.55),0 2px 8px rgba(2,12,22,.4);
|
|
294
|
+
color:#e8f1f8;transform-origin:38px calc(100% + 14px);
|
|
295
|
+
/* ⭐ 从鱼身上"鼓"出来:先轻微过冲再回弹(cubic-bezier 的第二段 >1 = 弹一下),
|
|
296
|
+
比匀减速的 pop 更像一个气泡冒出来 —— 这是使用者要的"点击触发小气泡"的手感。 */
|
|
297
|
+
animation:bdPetPop .22s cubic-bezier(.34,1.56,.64,1);}
|
|
298
|
+
[data-bd-pet-card][data-wide]{width:316px;}
|
|
299
|
+
/* ⭐ right:-4(2026-10-28):鱼贴屏幕右缘时(box 右缘 = vw-6),right:-8 会让卡片静止时
|
|
300
|
+
* 戳出视口 2px(右键菜单回归闸 top-right 场景真红过)——收成 -4 后贴缘也完整在屏内。 */
|
|
301
|
+
[data-bd-pet][data-align="right"] [data-bd-pet-card]{left:auto;right:-4px;transform-origin:calc(100% - 38px) calc(100% + 14px);}
|
|
302
|
+
[data-bd-pet][data-flip] [data-bd-pet-card]{bottom:auto;top:calc(100% + 14px);transform-origin:38px -14px;}
|
|
303
|
+
[data-bd-pet][data-align="right"][data-flip] [data-bd-pet-card]{transform-origin:calc(100% - 38px) -14px;}
|
|
304
|
+
/* 长出一小截再回弹:from 缩小 + 下移,中途 overshoot 交给 cubic-bezier 的 .34,1.56 那条曲线 */
|
|
305
|
+
@keyframes bdPetPop{from{opacity:0;transform:scale(.88) translateY(9px)}to{opacity:1;transform:none}}
|
|
306
|
+
[data-bd-pet-cardhead]{display:flex;align-items:center;gap:7px;padding:9px 12px;font-size:11px;color:#9db8c8;
|
|
307
|
+
border-bottom:1px solid rgba(255,255,255,.09);}
|
|
308
|
+
[data-bd-pet-cardhead] b{color:#e8f1f8;font-size:12px;}
|
|
309
|
+
[data-bd-pet-cardhead] span{margin-left:auto;font-size:10.5px;color:#7fa0b5;}
|
|
310
|
+
[data-bd-pet-item]{display:flex;align-items:center;gap:9px;width:100%;padding:9px 12px;border:0;background:none;
|
|
311
|
+
color:inherit;font:inherit;font-size:12.5px;cursor:pointer;text-align:left;}
|
|
312
|
+
[data-bd-pet-item]:hover:not(:disabled){background:rgba(125,211,252,.13);}
|
|
313
|
+
[data-bd-pet-item]:disabled{opacity:.55;cursor:default;}
|
|
314
|
+
[data-bd-pet-item] b{flex:none;font-size:15px;width:20px;text-align:center;font-weight:400;}
|
|
315
|
+
[data-bd-pet-item] span{flex:1;min-width:0;display:flex;flex-direction:column;gap:1px;}
|
|
316
|
+
[data-bd-pet-item] span i{font-style:normal;font-size:10.5px;color:#8fabbf;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
|
317
|
+
[data-bd-pet-sep]{height:1px;margin:3px 8px;border:0;background:rgba(255,255,255,.09);}
|
|
318
|
+
[data-bd-pet-spin]{flex:none;width:12px;height:12px;border-radius:50%;display:inline-block;
|
|
319
|
+
border:2px solid rgba(125,211,252,.3);border-top-color:#7dd3fc;animation:bdPetSpin .7s linear infinite;}
|
|
320
|
+
@keyframes bdPetSpin{to{transform:rotate(360deg)}}
|
|
321
|
+
[data-bd-pet-pad]{padding:10px 12px;display:flex;flex-direction:column;gap:9px;}
|
|
322
|
+
[data-bd-pet-badge]{font-size:10.5px;padding:2px 7px;border-radius:6px;white-space:nowrap;align-self:flex-start;
|
|
323
|
+
background:rgba(128,128,128,.18);color:#c9ccd1;}
|
|
324
|
+
[data-bd-pet-badge][data-warn]{background:rgba(245,158,11,.16);color:#f59e0b;}
|
|
325
|
+
[data-bd-pet-badge][data-live]{background:rgba(34,197,94,.16);color:#22c55e;}
|
|
326
|
+
/* 📜 日志面板:按 type 着色(start 绿 / stop 灰 / error 红 / note 蓝) */
|
|
327
|
+
[data-bd-pet-log]{max-height:264px;overflow:auto;display:flex;flex-direction:column;}
|
|
328
|
+
[data-bd-pet-logline]{display:flex;gap:7px;padding:4px 12px;font-size:11px;line-height:16px;}
|
|
329
|
+
[data-bd-pet-logline] time{flex:none;font-variant-numeric:tabular-nums;color:#7f9cb2;}
|
|
330
|
+
[data-bd-pet-logline] span{flex:1;min-width:0;word-break:break-word;}
|
|
331
|
+
[data-bd-pet-logline][data-t="start"] span{color:#4ade80;}
|
|
332
|
+
[data-bd-pet-logline][data-t="stop"] span{color:#9fb2c0;}
|
|
333
|
+
[data-bd-pet-logline][data-t="error"] span{color:#f87171;}
|
|
334
|
+
[data-bd-pet-logline][data-t="note"] span{color:#60a5fa;}
|
|
335
|
+
/* 🐟 状态卡 */
|
|
336
|
+
[data-bd-pet-status]{display:flex;flex-direction:column;padding:6px 0;}
|
|
337
|
+
[data-bd-pet-kv]{display:flex;gap:10px;padding:6px 12px;font-size:11.5px;align-items:baseline;}
|
|
338
|
+
[data-bd-pet-kv] + [data-bd-pet-kv]{border-top:1px solid rgba(255,255,255,.06);}
|
|
339
|
+
[data-bd-pet-kv] span{flex:none;width:92px;color:#8fabbf;}
|
|
340
|
+
[data-bd-pet-kv] b{flex:1;min-width:0;font-weight:600;word-break:break-all;color:#e8f1f8;}
|
|
341
|
+
/* ⚙️ 设置大弹窗:居中、毛玻璃、顶部 tab;卡片网格里的卡沿用 [data-bd-sec] */
|
|
342
|
+
[data-bd-pet-mask]{position:fixed;inset:0;background:rgba(4,12,20,.48);backdrop-filter:blur(3px);
|
|
343
|
+
display:flex;align-items:center;justify-content:center;animation:bdPetFade .16s ease-out;}
|
|
344
|
+
@keyframes bdPetFade{from{opacity:0}to{opacity:1}}
|
|
345
|
+
[data-bd-pet-modal]{width:min(880px,92vw);height:min(640px,86vh);display:flex;flex-direction:column;overflow:hidden;
|
|
346
|
+
border-radius:18px;background:rgba(15,27,39,.94);backdrop-filter:blur(20px) saturate(1.4);
|
|
347
|
+
border:1px solid rgba(255,255,255,.15);box-shadow:0 26px 80px rgba(0,0,0,.55);color:#e8f1f8;
|
|
348
|
+
animation:bdPetPop .18s ease-out;transform-origin:50% 50%;}
|
|
349
|
+
[data-bd-pet-tabs]{display:flex;align-items:center;gap:2px;padding:8px 10px 0;
|
|
350
|
+
border-bottom:1px solid rgba(255,255,255,.1);background:rgba(255,255,255,.02);}
|
|
351
|
+
[data-bd-pet-tab]{padding:8px 14px;border:0;background:none;color:#9db8c8;font:inherit;font-size:12.5px;cursor:pointer;
|
|
352
|
+
border-radius:9px 9px 0 0;}
|
|
353
|
+
[data-bd-pet-tab]:hover{color:#e8f1f8;}
|
|
354
|
+
[data-bd-pet-tab][data-on]{color:#fff;background:rgba(125,211,252,.15);box-shadow:inset 0 -2px 0 #38bdf8;}
|
|
355
|
+
[data-bd-pet-close]{margin-left:auto;width:30px;height:30px;border-radius:8px;border:0;cursor:pointer;
|
|
356
|
+
background:rgba(255,255,255,.06);color:#cfe3f0;font-size:13px;}
|
|
357
|
+
[data-bd-pet-close]:hover{background:rgba(248,113,113,.2);color:#fca5a5;}
|
|
358
|
+
[data-bd-pet-body]{flex:1;overflow:auto;padding:14px 16px;}
|
|
359
|
+
[data-bd-pet-grid]{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:13px;align-items:start;}
|
|
360
|
+
[data-bd-pet-grid] > [data-bd-sec]{background:rgba(128,128,128,.07);}
|
|
361
|
+
/* tab⑤「全部设置」:整页内容塞进弹窗。收掉它自己的外框宽度上限与左右内边距,
|
|
362
|
+
让卡片沿用弹窗那套 auto-fill 栅格(两块卡并排,窄屏自动单列)。 */
|
|
363
|
+
[data-bd-allpage]{max-width:none;}
|
|
364
|
+
[data-bd-allpage] > [data-bd-page]{max-width:none;padding:0;gap:13px;
|
|
365
|
+
display:grid;grid-template-columns:repeat(auto-fill,minmax(310px,1fr));align-items:start;}
|
|
366
|
+
[data-bd-allpage] > [data-bd-page] > [data-bd-sec],
|
|
367
|
+
[data-bd-allpage] > [data-bd-page] > [data-bd-row],
|
|
368
|
+
[data-bd-allpage] > [data-bd-page] > [data-bd-toggle]{margin:0;}
|
|
369
|
+
/* 整行宽的那些(横幅/流水/视频卡/它的地盘)别被栅格切成半块 */
|
|
370
|
+
[data-bd-allpage] > [data-bd-page] > [data-bd-msg],
|
|
371
|
+
[data-bd-allpage] > [data-bd-page] > [data-bd-pre]{grid-column:1/-1;}
|
|
372
|
+
/* ⭐ toast:桌宠的动作回执(设置页有 msg 那条常驻,浮层说完自己走) */
|
|
373
|
+
[data-bd-pet-toast]{position:fixed;left:50%;bottom:30px;transform:translateX(-50%);display:flex;flex-direction:column;
|
|
374
|
+
gap:8px;pointer-events:none;}
|
|
375
|
+
[data-bd-pet-toast] div{max-width:min(520px,86vw);padding:8px 14px;border-radius:10px;font-size:12px;line-height:17px;
|
|
376
|
+
word-break:break-word;color:#e8f1f8;background:rgba(16,28,40,.92);border:1px solid rgba(125,211,252,.25);
|
|
377
|
+
box-shadow:0 10px 30px rgba(2,12,22,.5);animation:bdPetPop .18s ease-out;}
|
|
378
|
+
[data-bd-pet-toast] div[data-tone="warn"]{border-color:rgba(245,158,11,.5);color:#fbd38d;}
|
|
379
|
+
[data-bd-pet-toast] div[data-tone="good"]{border-color:rgba(34,197,94,.5);color:#a7f3d0;}
|
|
380
|
+
|
|
381
|
+
/* ══════════════════════════════════════════════════════════════════════════
|
|
382
|
+
⭐ 天蓝主题(2026-10-27,使用者:右键设置里是黑的、很多字看不清 → 改天蓝)
|
|
383
|
+
病因:上面那套规则把文字色写成了 var(--dsw-alias-label-*, 浅色兜底)。
|
|
384
|
+
宿主若注入浅色主题变量,兜底不生效 —— 弹窗是固定深底(#0f1b27),
|
|
385
|
+
于是变成"深底 + 深字"。这里一律改成**显式浅色**,不再跟随宿主主题。
|
|
386
|
+
注意:不加 !important 也能生效(同优先级、后来者胜),且不删上面任何规则 ——
|
|
387
|
+
devtools 的断言与变异([data-bd-sec] 的 border/border-radius、
|
|
388
|
+
gap:10px;flex-wrap:wrap; 等)盯的是上面那些行,这里只叠加颜色。
|
|
389
|
+
══════════════════════════════════════════════════════════════════════════ */
|
|
390
|
+
[data-bd-page],[data-bd-allpage]{--bd-fg:#eaf6ff;--bd-fg-2:#c2d9e9;--bd-fg-3:#8fb0c7;
|
|
391
|
+
--bd-line:rgba(125,211,252,.16);--bd-line-soft:rgba(125,211,252,.09);
|
|
392
|
+
--bd-card:rgba(125,211,252,.055);--bd-card-2:rgba(125,211,252,.1);
|
|
393
|
+
--bd-accent:#38bdf8;--bd-accent-2:#7dd3fc;color:var(--bd-fg);}
|
|
394
|
+
[data-bd-page] [data-bd-h],[data-bd-allpage] [data-bd-h]{color:var(--bd-fg-3);}
|
|
395
|
+
[data-bd-page] [data-bd-h] b,[data-bd-allpage] [data-bd-h] b{color:var(--bd-fg);}
|
|
396
|
+
[data-bd-page] [data-bd-label],[data-bd-allpage] [data-bd-label]{color:var(--bd-fg);}
|
|
397
|
+
[data-bd-page] [data-bd-note],[data-bd-allpage] [data-bd-note]{color:var(--bd-fg-3);}
|
|
398
|
+
[data-bd-page] [data-bd-kv],[data-bd-allpage] [data-bd-kv]{color:var(--bd-fg-2);}
|
|
399
|
+
[data-bd-page] [data-bd-empty],[data-bd-allpage] [data-bd-empty]{color:var(--bd-fg-3);}
|
|
400
|
+
[data-bd-page] [data-bd-sec],[data-bd-allpage] [data-bd-sec]{background:var(--bd-card);border-color:var(--bd-line);}
|
|
401
|
+
[data-bd-page] [data-bd-sub],[data-bd-allpage] [data-bd-sub]{border-left-color:var(--bd-line);}
|
|
402
|
+
[data-bd-page] [data-bd-sub] > [data-bd-row],[data-bd-allpage] [data-bd-sub] > [data-bd-row]{border-bottom-color:var(--bd-line-soft);}
|
|
403
|
+
[data-bd-page] [data-bd-input],[data-bd-allpage] [data-bd-input]{color:var(--bd-fg);border-color:var(--bd-line);background:rgba(8,22,34,.55);}
|
|
404
|
+
[data-bd-page] [data-bd-input]::placeholder,[data-bd-allpage] [data-bd-input]::placeholder{color:#6f8fa6;}
|
|
405
|
+
[data-bd-page] [data-bd-input]:focus,[data-bd-allpage] [data-bd-input]:focus{border-color:#38bdf8;
|
|
406
|
+
box-shadow:0 0 0 3px rgba(56,189,248,.16);}
|
|
407
|
+
[data-bd-page] [data-bd-btn],[data-bd-allpage] [data-bd-btn]{color:var(--bd-fg);border-color:var(--bd-line);
|
|
408
|
+
background:rgba(125,211,252,.08);}
|
|
409
|
+
[data-bd-page] [data-bd-btn]:hover,[data-bd-allpage] [data-bd-btn]:hover{background:rgba(125,211,252,.17);border-color:rgba(125,211,252,.3);}
|
|
410
|
+
[data-bd-page] [data-bd-btn][data-primary],[data-bd-allpage] [data-bd-btn][data-primary]{
|
|
411
|
+
background:#38bdf8;border-color:#38bdf8;color:#04202e;font-weight:600;}
|
|
412
|
+
[data-bd-page] [data-bd-btn][data-primary]:hover,[data-bd-allpage] [data-bd-btn][data-primary]:hover{background:#5cc9f9;border-color:#5cc9f9;}
|
|
413
|
+
[data-bd-page] [data-bd-msg],[data-bd-allpage] [data-bd-msg]{color:var(--bd-fg-2);background:rgba(125,211,252,.08);}
|
|
414
|
+
[data-bd-page] [data-bd-badge],[data-bd-allpage] [data-bd-badge]{color:var(--bd-fg-2);background:rgba(125,211,252,.13);}
|
|
415
|
+
[data-bd-page] [data-bd-pre],[data-bd-allpage] [data-bd-pre]{color:var(--bd-fg-2);background:rgba(6,18,28,.6);}
|
|
416
|
+
[data-bd-page] [data-bd-sw],[data-bd-allpage] [data-bd-sw]{background:rgba(143,176,199,.3);}
|
|
417
|
+
[data-bd-page] [data-bd-sw][data-on],[data-bd-allpage] [data-bd-sw][data-on]{background:#38bdf8;}
|
|
418
|
+
[data-bd-page] [data-bd-actline] time,[data-bd-allpage] [data-bd-actline] time{color:var(--bd-fg-3);}
|
|
419
|
+
[data-bd-page] [data-bd-actline][data-kind="call"] span,[data-bd-allpage] [data-bd-actline][data-kind="call"] span{color:var(--bd-fg-2);}
|
|
420
|
+
[data-bd-page] [data-bd-kpi] span,[data-bd-allpage] [data-bd-kpi] span{color:var(--bd-fg-3);}
|
|
421
|
+
[data-bd-page] [data-bd-kpi] b,[data-bd-allpage] [data-bd-kpi] b{color:var(--bd-fg);}
|
|
422
|
+
/* 弹窗壳:底色压深一档、边框挑成天蓝,跟卡片同一族 */
|
|
423
|
+
[data-bd-pet-modal]{background:linear-gradient(175deg,rgba(11,26,38,.97),rgba(7,18,28,.98));
|
|
424
|
+
border-color:rgba(125,211,252,.22);color:#eaf6ff;}
|
|
425
|
+
[data-bd-pet-tabs]{background:rgba(125,211,252,.05);border-bottom-color:rgba(125,211,252,.16);}
|
|
426
|
+
[data-bd-pet-tab]{color:#9ec0d4;}
|
|
427
|
+
[data-bd-pet-tab]:hover{color:#eaf6ff;background:rgba(125,211,252,.08);}
|
|
428
|
+
[data-bd-pet-tab][data-on]{color:#eaf6ff;background:rgba(125,211,252,.16);box-shadow:inset 0 -2px 0 #38bdf8;}
|
|
429
|
+
[data-bd-pet-close]{background:rgba(125,211,252,.1);color:#bfdceb;}
|
|
430
|
+
[data-bd-pet-close]:hover{background:rgba(248,113,113,.24);color:#ffc9c9;}
|
|
431
|
+
/* 分页标题(新五页每页顶上那一行:小图标 + 页名 + 一句说明) */
|
|
432
|
+
[data-bd-pghead]{display:flex;align-items:center;gap:9px;margin:0 0 2px;padding:0 2px;}
|
|
433
|
+
[data-bd-pghead] b{font-size:13.5px;font-weight:600;color:#eaf6ff;letter-spacing:.01em;}
|
|
434
|
+
[data-bd-pghead] span{font-size:11px;color:#8fb0c7;}
|
|
435
|
+
[data-bd-grid2]{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:13px;align-items:start;}
|
|
436
|
+
/* 「这一项现在是什么」的读数条:模型 / 轮数 / 预算,一眼扫完 */
|
|
437
|
+
[data-bd-readout]{display:grid;grid-template-columns:repeat(auto-fit,minmax(96px,1fr));gap:8px;
|
|
438
|
+
padding:9px 10px;border-radius:9px;background:rgba(56,189,248,.08);border:1px solid rgba(56,189,248,.2);}
|
|
439
|
+
[data-bd-readout] div{display:flex;flex-direction:column;gap:2px;min-width:0;}
|
|
440
|
+
[data-bd-readout] b{font-size:13px;font-weight:600;color:#eaf6ff;font-variant-numeric:tabular-nums;
|
|
441
|
+
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
|
442
|
+
[data-bd-readout] span{font-size:10px;letter-spacing:.03em;color:#8fb0c7;}
|
|
443
|
+
|
|
444
|
+
[data-bd-ico]{display:inline-block;flex:none;width:18px;height:18px;color:currentColor;vertical-align:middle;}
|
|
445
|
+
[data-bd-pet-item] [data-bd-ico]{align-self:center;}
|
|
446
|
+
/* 桌宠浮层自带颜色变量,宿主切换主题不会改变文字对比度。 */
|
|
447
|
+
[data-bd-pet-modal],[data-bd-pet-card]{
|
|
448
|
+
--bd-fg:#163b55;--bd-fg-2:#365b75;--bd-fg-3:#4b6c83;
|
|
449
|
+
--bd-line:#b8d7e9;--bd-line-soft:#d5e6f0;--bd-accent:#0879b1;
|
|
450
|
+
--dsw-alias-label-primary:#163b55;--dsw-alias-label-secondary:#365b75;--dsw-alias-label-tertiary:#4b6c83;
|
|
451
|
+
--dsw-alias-border-l2:#c3ddea;--dsw-alias-bg-overlay:rgba(255,255,255,.7);
|
|
452
|
+
--dsw-alias-interactive-bg-hover:#d5edf9;color:var(--bd-fg);color-scheme:light;
|
|
453
|
+
font-family:'Microsoft YaHei UI','Microsoft YaHei','Segoe UI',sans-serif;
|
|
454
|
+
}
|
|
455
|
+
[data-bd-pet-mask]{background:rgba(24,56,76,.28);backdrop-filter:none;padding:14px;box-sizing:border-box;}
|
|
456
|
+
[data-bd-pet-modal]{width:min(1000px,100%);height:min(820px,calc(100dvh - 40px));max-height:100%;
|
|
457
|
+
background:#fafbf8;border:1px solid #c5d8e0;
|
|
458
|
+
box-shadow:0 24px 80px rgba(16,58,87,.2);
|
|
459
|
+
border-radius:18px;animation:bdSettingsIn .2s ease-out;box-sizing:border-box;}
|
|
460
|
+
@keyframes bdSettingsIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
|
|
461
|
+
[data-bd-settings-head]{display:flex;align-items:center;gap:13px;padding:21px 28px 16px;flex:none;}
|
|
462
|
+
[data-bd-settings-mark]{display:grid;place-items:center;width:38px;height:38px;flex:none;border-radius:50%;background:#e8f0f3;border:1px solid #d3e2e8;}
|
|
463
|
+
[data-bd-settings-title]{min-width:0;flex:1;}
|
|
464
|
+
[data-bd-settings-title] b{font-size:16px;letter-spacing:.03em;color:#163b55;}
|
|
465
|
+
[data-bd-settings-title] p{margin:2px 0 0;font-size:10.5px;color:#4b6c83;line-height:1.45;}
|
|
466
|
+
[data-bd-pet-close]{margin-left:auto;flex:none;width:32px;height:32px;display:grid;place-items:center;
|
|
467
|
+
color:#365b75;background:#d7edf9;border:1px solid #bedceb;font-size:19px;}
|
|
468
|
+
[data-bd-pet-close]:hover{background:#fae1df;color:#973e39;}
|
|
469
|
+
[data-bd-pet-tabs]{padding:0 28px;gap:12px;background:transparent;flex:none;overflow-x:auto;scrollbar-width:thin;
|
|
470
|
+
border-bottom:1px solid #d3e0e5;}
|
|
471
|
+
[data-bd-pet-tab]{display:flex;align-items:center;justify-content:center;white-space:nowrap;
|
|
472
|
+
flex:none;padding:12px 4px;color:#657e89;font-size:12px;border-radius:0;
|
|
473
|
+
transition:color .15s ease,background .15s ease;}
|
|
474
|
+
[data-bd-pet-tab]:hover{color:#1a607d;background:#f1f6f7;}
|
|
475
|
+
[data-bd-pet-tab][data-on]{color:#1a607d;background:transparent;box-shadow:inset 0 -2px 0 #367b96;font-weight:600;}
|
|
476
|
+
[data-bd-pet-body]{min-height:0;padding:25px 28px 28px;overscroll-behavior:contain;scrollbar-width:thin;scrollbar-color:#b8cdd7 transparent;}
|
|
477
|
+
[data-bd-page-content]{display:flex;flex-direction:column;gap:22px;min-width:0;}
|
|
478
|
+
[data-bd-pghead]{padding:0 1px 2px;align-items:flex-start;gap:8px;}
|
|
479
|
+
[data-bd-pghead] b{color:#224b5d;font-size:17px;font-weight:600;}
|
|
480
|
+
[data-bd-pghead] p{color:#66808d;font-size:11px;line-height:1.7;margin:6px 0 0;}
|
|
481
|
+
[data-bd-pet-modal] [data-bd-ico]{display:none;}
|
|
482
|
+
[data-bd-pet-grid],[data-bd-grid2]{grid-template-columns:repeat(2,minmax(0,1fr));gap:18px;min-width:0;}
|
|
483
|
+
[data-bd-page-content]>[data-bd-pet-grid],[data-bd-pet-body]>[data-bd-pet-grid]{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px;align-items:start;}
|
|
484
|
+
[data-bd-stack]{display:flex;flex-direction:column;gap:18px;min-width:0;}
|
|
485
|
+
[data-bd-pet-modal] [data-bd-sec]{min-width:0;padding:21px 22px;gap:14px;border:1px solid #d8e4e8;border-radius:12px;
|
|
486
|
+
background:#fffefb;box-shadow:none;color:#244a5e;}
|
|
487
|
+
[data-bd-pet-modal] [data-bd-sec]:hover{border-color:#bfd5df;box-shadow:none;}
|
|
488
|
+
[data-bd-pet-modal] [data-bd-sec][data-bd-span]{grid-column:1/-1;}
|
|
489
|
+
[data-bd-pet-modal] [data-bd-h]{color:#4b6c83;gap:8px;letter-spacing:.02em;flex-wrap:wrap;font-size:12px;}
|
|
490
|
+
[data-bd-pet-modal] [data-bd-h] b{color:#163b55;font-size:13px;}
|
|
491
|
+
[data-bd-pet-modal] [data-bd-label]{color:#21485f;font-size:12px;line-height:1.5;}
|
|
492
|
+
[data-bd-pet-modal] [data-bd-note],[data-bd-pet-modal] [data-bd-kv],
|
|
493
|
+
[data-bd-pet-modal] [data-bd-empty],[data-bd-pet-modal] [data-bd-kpi] span{color:#4b6c83;line-height:1.55;font-size:11px;}
|
|
494
|
+
[data-bd-pet-modal] [data-bd-input],[data-bd-pet-card] [data-bd-input]{color:#244a5e;background:#fffefc;border:1px solid #c4d7df;
|
|
495
|
+
padding:8px 10px;max-width:100%;min-width:0;font-size:12px;line-height:1.6;border-radius:8px;
|
|
496
|
+
transition:border-color .15s ease,box-shadow .15s ease;}
|
|
497
|
+
[data-bd-pet-modal] [data-bd-input]::placeholder{color:#5a778b;}
|
|
498
|
+
[data-bd-pet-modal] [data-bd-input]:focus,[data-bd-pet-card] [data-bd-input]:focus{border-color:#238fc5;outline:2px solid rgba(35,143,197,.16);outline-offset:1px;}
|
|
499
|
+
[data-bd-pet-modal] [data-bd-field]{gap:4px;}
|
|
500
|
+
[data-bd-pet-modal] textarea{resize:vertical;}
|
|
501
|
+
[data-bd-pet-modal] [data-bd-btn],[data-bd-pet-card] [data-bd-btn]{display:inline-flex;align-items:center;justify-content:center;gap:6px;
|
|
502
|
+
color:#345e71;background:#f7faf9;border:1px solid #c5d8df;line-height:1.5;font-size:12px;max-width:100%;
|
|
503
|
+
border-radius:8px;padding:8px 13px;
|
|
504
|
+
transition:background .16s ease,border-color .16s ease;}
|
|
505
|
+
[data-bd-pet-modal] [data-bd-btn]:hover:not(:disabled),[data-bd-pet-card] [data-bd-btn]:hover:not(:disabled){
|
|
506
|
+
background:#eaf2f5;border-color:#94bacb;box-shadow:none;transform:none;}
|
|
507
|
+
[data-bd-pet-modal] [data-bd-btn]:active:not(:disabled),[data-bd-pet-card] [data-bd-btn]:active:not(:disabled){
|
|
508
|
+
transform:translateY(1px);box-shadow:none;}
|
|
509
|
+
[data-bd-pet-modal] [data-bd-btn][data-primary],[data-bd-pet-card] [data-bd-btn][data-primary]{
|
|
510
|
+
background:#276f8b;color:#fff;border-color:#276f8b;font-weight:600;box-shadow:none;}
|
|
511
|
+
[data-bd-pet-modal] [data-bd-btn][data-primary]:hover:not(:disabled),[data-bd-pet-card] [data-bd-btn][data-primary]:hover:not(:disabled){
|
|
512
|
+
background:#1c5c77;border-color:#1c5c77;box-shadow:none;transform:none;}
|
|
513
|
+
[data-bd-pet-modal] [data-bd-btn][data-primary]:active:not(:disabled),[data-bd-pet-card] [data-bd-btn][data-primary]:active:not(:disabled){
|
|
514
|
+
transform:translateY(1px);box-shadow:none;}
|
|
515
|
+
[data-bd-pet-modal] [data-bd-btn][data-primary] [data-bd-ico]{color:#fff;background:transparent;}
|
|
516
|
+
[data-bd-pet-modal] [data-bd-msg]{background:#dceffb;color:#24536e;border:1px solid #b9d8eb;}
|
|
517
|
+
[data-bd-pet-modal] [data-bd-msg][data-warn]{background:#fff2da;border-color:#e8c98f;color:#805311;}
|
|
518
|
+
[data-bd-pet-modal] [data-bd-msg][data-good]{background:#e4f5ed;border-color:#b6dbcb;color:#256447;}
|
|
519
|
+
[data-bd-pet-modal] [data-bd-badge],[data-bd-pet-card] [data-bd-pet-badge]{background:#e1f0f9;color:#365b75;}
|
|
520
|
+
[data-bd-pet-modal] [data-bd-badge][data-live],[data-bd-pet-card] [data-bd-pet-badge][data-live]{background:#d9f0e5;color:#256447;}
|
|
521
|
+
[data-bd-pet-modal] [data-bd-badge][data-warn],[data-bd-pet-card] [data-bd-pet-badge][data-warn]{background:#fff0d6;color:#805311;}
|
|
522
|
+
[data-bd-pet-modal] [data-bd-pre]{color:#365b75;background:#edf6fc;border:1px solid #d2e5f0;}
|
|
523
|
+
[data-bd-pet-modal] [data-bd-sub]{border-color:#cbdde4;margin-left:3px;padding-left:14px;gap:12px;}
|
|
524
|
+
[data-bd-pet-modal] [data-bd-sub]>[data-bd-row]{border-bottom-color:#e0e9ec;padding:7px 0;}
|
|
525
|
+
[data-bd-pet-modal] [data-bd-sw]{background:#b3c8d1;transition:background .18s ease;}
|
|
526
|
+
[data-bd-pet-modal] [data-bd-sw][data-on]{background:#347c98;box-shadow:none;}
|
|
527
|
+
[data-bd-pet-modal] [data-bd-readout]{background:#e4f3fc;border-color:#bcddee;}
|
|
528
|
+
[data-bd-pet-modal] [data-bd-readout] b,[data-bd-pet-modal] [data-bd-kpi] b{color:#163b55;}
|
|
529
|
+
[data-bd-pet-modal] [data-bd-readout] span{color:#4b6c83;}
|
|
530
|
+
[data-bd-pet-modal] [data-bd-source] a{color:#086eaa;}
|
|
531
|
+
[data-bd-pet-modal] [data-bd-example]{background:#eef8fd;color:#365b75;border-color:#328ec2;}
|
|
532
|
+
[data-bd-pet-modal] [data-bd-qr] img{box-sizing:border-box;width:160px;height:160px;object-fit:contain;}
|
|
533
|
+
[data-bd-pet-modal] [data-bd-kv]{overflow-wrap:anywhere;}
|
|
534
|
+
[data-bd-pet-modal] [data-bd-actline] time{color:#4b6c83;}
|
|
535
|
+
[data-bd-pet-modal] [data-bd-actline][data-kind="round"] span{color:#0879b1;}
|
|
536
|
+
[data-bd-pet-modal] [data-bd-actline][data-kind="start"] span{color:#256447;}
|
|
537
|
+
[data-bd-pet-modal] [data-bd-actline][data-kind="fail"] span{color:#a63838;}
|
|
538
|
+
[data-bd-pet-modal] [data-bd-logrow][data-t="error"] [data-bd-logevent]{color:#a63838;}
|
|
539
|
+
[data-bd-pet-modal] [data-bd-logrow][data-t="start"] [data-bd-logevent]{color:#256447;}
|
|
540
|
+
[data-bd-pet-modal] [data-bd-logrow][data-t="note"] [data-bd-logevent]{color:#276f8b;}
|
|
541
|
+
[data-bd-pet-modal] [data-bd-logevent]{white-space:normal;overflow-wrap:anywhere;line-height:1.7;}
|
|
542
|
+
[data-bd-pet-modal] :focus-visible,[data-bd-pet-card] :focus-visible{outline:2px solid #1685bd;outline-offset:3px;}
|
|
543
|
+
[data-bd-pet-card]{background:#edf8ff;border-color:#b3d5e9;box-shadow:0 16px 48px rgba(18,62,91,.24);}
|
|
544
|
+
[data-bd-pet-cardhead]{color:#4b6c83;border-bottom-color:#c9e0ef;}
|
|
545
|
+
[data-bd-pet-cardhead] b,[data-bd-pet-kv] b{color:#163b55;}
|
|
546
|
+
[data-bd-pet-cardhead] span,[data-bd-pet-item] span i,[data-bd-pet-kv] span,[data-bd-pet-logline] time{color:#4b6c83;}
|
|
547
|
+
[data-bd-pet-item]:hover:not(:disabled){background:#d7eefb;}
|
|
548
|
+
[data-bd-pet-sep]{background:#c5deed;}
|
|
549
|
+
[data-bd-pet-logline][data-t="start"] span{color:#256447;}
|
|
550
|
+
[data-bd-pet-logline][data-t="stop"] span{color:#4b6c83;}
|
|
551
|
+
[data-bd-pet-logline][data-t="error"] span{color:#a63838;}
|
|
552
|
+
[data-bd-pet-logline][data-t="note"] span{color:#0879b1;}
|
|
553
|
+
[data-bd-pet-item] [data-bd-ico]{color:#4d788a;background:transparent;}
|
|
554
|
+
/* ═══════════ 独立 token 账本:海盐蓝 / 暖白 / 编辑式数字排版 ═══════════ */
|
|
555
|
+
[data-bd-ledger]{gap:20px;}
|
|
556
|
+
[data-bd-ledger-heading]{display:flex;align-items:center;justify-content:space-between;gap:16px;}
|
|
557
|
+
[data-bd-eyebrow]{font-family:'Cascadia Code','Consolas',monospace;font-size:9px;letter-spacing:.16em;color:#66818e;}
|
|
558
|
+
[data-bd-ledger-heading] h2{font-family:'Noto Serif SC','Source Han Serif SC','SimSun',serif;font-size:27px;line-height:1.35;font-weight:600;letter-spacing:.035em;color:#173f52;margin:7px 0 5px;}
|
|
559
|
+
[data-bd-ledger-heading] p{font-size:12px;color:#617882;margin:0;}
|
|
560
|
+
[data-bd-ledger-summary]{padding:25px 28px 18px;border:1px solid #cadde3;border-top:3px solid #216b87;border-radius:3px 3px 12px 12px;background:#fffefb;
|
|
561
|
+
display:grid;grid-template-columns:minmax(0,1.1fr) minmax(0,1fr);column-gap:28px;row-gap:22px;}
|
|
562
|
+
[data-bd-ledger-hero]{min-width:0;}
|
|
563
|
+
[data-bd-ledger-label]{font-size:12px;font-weight:600;letter-spacing:.06em;color:#506f7d;}
|
|
564
|
+
[data-bd-ledger-number]{display:flex;align-items:baseline;gap:9px;flex-wrap:wrap;min-width:0;margin:9px 0 13px;}
|
|
565
|
+
[data-bd-ledger-number] b{font:600 clamp(32px,4.3vw,48px)/1.04 'Bahnschrift','DIN Alternate','Cascadia Code',sans-serif;letter-spacing:-.035em;color:#184d65;font-variant-numeric:tabular-nums;overflow-wrap:anywhere;}
|
|
566
|
+
[data-bd-ledger-number]>span{font:10px/1.5 'Cascadia Code','Consolas',monospace;color:#708790;}
|
|
567
|
+
[data-bd-ledger-meta]{display:flex;align-items:center;flex-wrap:wrap;gap:9px;font-size:10px;color:#5c7783;}
|
|
568
|
+
[data-bd-ledger-hero] p{font-size:10px;color:#6b8089;margin:11px 0 0;}
|
|
569
|
+
[data-bd-tok-kpis]{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px;align-self:center;border-left:1px solid #e0e9eb;padding-left:28px;}
|
|
570
|
+
[data-bd-tok-kpis]>div{min-width:0;}
|
|
571
|
+
[data-bd-tok-kpis] span{display:block;font-size:11px;color:#587381;}
|
|
572
|
+
[data-bd-tok-kpis] b{display:block;font:600 21px/1.35 'Bahnschrift','DIN Alternate','Cascadia Code',sans-serif;color:#254f62;font-variant-numeric:tabular-nums;overflow-wrap:anywhere;margin:7px 0 4px;}
|
|
573
|
+
[data-bd-tok-kpis] small{display:block;font-size:9px;color:#75868d;line-height:1.5;}
|
|
574
|
+
[data-bd-ledger-budget]{grid-column:1/-1;border-top:1px solid #e3ebed;padding-top:13px;}
|
|
575
|
+
[data-bd-ledger-budget] [data-bd-row]{font-size:10px;color:#627b87;margin-bottom:7px;}
|
|
576
|
+
[data-bd-ledger-budget] b{font-variant-numeric:tabular-nums;color:#32596b;}
|
|
577
|
+
[data-bd-tok-bar]{height:5px;border-radius:99px;background:#e7eff2;overflow:hidden;}
|
|
578
|
+
[data-bd-tok-bar] i{display:block;height:100%;border-radius:99px;background:#367b96;transition:width .4s ease;}
|
|
579
|
+
[data-bd-ledger-budget] p{font-size:10px;color:#6d818b;line-height:1.5;margin:8px 0 0;}
|
|
580
|
+
[data-bd-ledger-sectionhead]{display:flex;align-items:baseline;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-bottom:12px;}
|
|
581
|
+
[data-bd-ledger-sectionhead] h3{font-size:13px;font-weight:600;margin:0;color:#224e63;letter-spacing:.03em;}
|
|
582
|
+
[data-bd-ledger-sectionhead]>span{font-size:10px;color:#6a828d;}
|
|
583
|
+
[data-bd-ledger-trend]{padding:0 4px;}
|
|
584
|
+
[data-bd-tok-days]{display:flex;gap:10px;height:76px;}
|
|
585
|
+
[data-bd-tok-days]>div{flex:1;min-width:0;display:flex;flex-direction:column;align-items:center;gap:7px;height:100%;}
|
|
586
|
+
[data-bd-tok-daytrack]{width:100%;height:54px;display:flex;align-items:flex-end;justify-content:center;}
|
|
587
|
+
[data-bd-tok-days] i{display:block;width:100%;max-width:28px;border-radius:3px 3px 0 0;background:#568fa5;min-height:2px;}
|
|
588
|
+
[data-bd-tok-days]>div[data-zero] i{background:#dbe7eb;}
|
|
589
|
+
[data-bd-tok-days] span{font:9px/1 'Bahnschrift','Consolas',sans-serif;color:#718994;white-space:nowrap;}
|
|
590
|
+
[data-bd-tok-list]{border-top:1px solid #cbdde4;border-bottom:1px solid #cbdde4;max-height:265px;overflow:auto;scrollbar-width:thin;}
|
|
591
|
+
[data-bd-tokrow]{display:flex;align-items:center;gap:14px;padding:12px 4px;font-size:11px;line-height:1.5;border-bottom:1px solid #e0e9ec;}
|
|
592
|
+
[data-bd-tokrow]:last-child{border-bottom:0;}
|
|
593
|
+
[data-bd-tokrow]:hover{background:#f0f6f7;}
|
|
594
|
+
[data-bd-tokrow] time{flex:none;width:54px;font:10px/1.5 'Bahnschrift','Consolas',sans-serif;font-variant-numeric:tabular-nums;color:#78909a;}
|
|
595
|
+
[data-bd-tok-task]{flex:1;min-width:0;}
|
|
596
|
+
[data-bd-tok-task] strong{display:block;font-weight:500;color:#305265;}
|
|
597
|
+
[data-bd-tok-task]>span{display:block;margin-top:3px;font-size:9px;color:#758994;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
|
598
|
+
[data-bd-tok-amount]{text-align:right;flex:none;min-width:126px;}
|
|
599
|
+
[data-bd-tok-amount] em{display:block;font:600 16px/1.4 'Bahnschrift','Consolas',sans-serif;font-variant-numeric:tabular-nums;color:#225b73;font-style:normal;}
|
|
600
|
+
[data-bd-tok-amount] em[data-unknown]{font-family:inherit;font-size:12px;color:#8c734e;}
|
|
601
|
+
[data-bd-tok-amount] small{font-size:9px;color:#7b8d95;}
|
|
602
|
+
[data-bd-tok-chip]{flex:none;font-size:10px;font-style:normal;padding:3px 8px;border-radius:5px;background:#e9f2f5;color:#4b7485;white-space:nowrap;}
|
|
603
|
+
[data-bd-tok-chip][data-warn]{background:#f7efdf;color:#8a6b38;}
|
|
604
|
+
[data-bd-tok-empty]{padding:24px 12px;text-align:center;font-size:11px;color:#69818c;line-height:1.6;}
|
|
605
|
+
[data-bd-ledger-footnote]{font-size:10px;color:#758892;line-height:1.7;margin:-6px 0 0;}
|
|
606
|
+
[data-bd-ledger-controls]{border:1px solid #d6e3e7;border-radius:10px;background:#f1f6f6;padding:17px 18px;}
|
|
607
|
+
[data-bd-ledger-controls] [data-bd-row]{align-items:flex-end;}
|
|
608
|
+
[data-bd-ledger-controls] label{max-width:360px;}
|
|
609
|
+
[data-bd-pet-modal] [data-bd-danger]{background:transparent;color:#97665b;border-color:#d9c3bb;}
|
|
610
|
+
[data-bd-ledger-focus]{gap:9px;}
|
|
611
|
+
[data-bd-ledger-focus] [data-bd-ledger-heading] h2{font-size:24px;margin:4px 0;}
|
|
612
|
+
[data-bd-ledger-focus] [data-bd-ledger-summary]{padding:18px 24px 12px;row-gap:12px;}
|
|
613
|
+
[data-bd-ledger-focus] [data-bd-ledger-number]{margin:7px 0 10px;}
|
|
614
|
+
[data-bd-ledger-focus] [data-bd-ledger-number] b{font-size:42px;}
|
|
615
|
+
[data-bd-ledger-focus] [data-bd-ledger-hero] p{margin-top:7px;}
|
|
616
|
+
[data-bd-ledger-focus] [data-bd-ledger-budget]{padding-top:10px;}
|
|
617
|
+
[data-bd-ledger-focus] [data-bd-ledger-sectionhead]{margin-bottom:8px;}
|
|
618
|
+
[data-bd-ledger-focus] [data-bd-tok-days]{height:65px;}
|
|
619
|
+
[data-bd-ledger-focus] [data-bd-tok-daytrack]{height:45px;}
|
|
620
|
+
[data-bd-ledger-focus] [data-bd-tok-list]{max-height:none;overflow:visible;}
|
|
621
|
+
[data-bd-ledger-focus] [data-bd-tokrow]{padding-top:8px;padding-bottom:8px;}
|
|
622
|
+
[data-bd-ledger-focus] [data-bd-ledger-footnote]{margin:0;}
|
|
623
|
+
/* 🪞 角色头像保留为唯一主视觉,分类与卡片不再重复堆图标。 */
|
|
624
|
+
[data-bd-settings-mark] [data-bd-settings-avatar]{width:100%;height:100%;border-radius:inherit;object-fit:cover;display:block;}
|
|
625
|
+
|
|
626
|
+
@media(max-width:660px){
|
|
627
|
+
[data-bd-pet-mask]{padding:8px;}
|
|
628
|
+
[data-bd-pet-modal]{height:calc(100dvh - 16px);border-radius:15px;}
|
|
629
|
+
[data-bd-settings-head]{padding:15px 14px 12px;}
|
|
630
|
+
[data-bd-pet-tabs]{padding:0 9px;}
|
|
631
|
+
[data-bd-pet-tab]{padding:10px 9px;}
|
|
632
|
+
[data-bd-pet-body]{padding:20px 14px 24px;}
|
|
633
|
+
[data-bd-page-content]>[data-bd-pet-grid],[data-bd-pet-body]>[data-bd-pet-grid]{columns:1;}
|
|
634
|
+
[data-bd-pet-grid],[data-bd-grid2]{grid-template-columns:minmax(0,1fr);}
|
|
635
|
+
[data-bd-pet-modal] [data-bd-sec]{padding:20px 17px;}
|
|
636
|
+
[data-bd-pet-modal] [data-bd-sub]{margin-left:0;padding-left:10px;}
|
|
637
|
+
[data-bd-ledger-summary]{grid-template-columns:minmax(0,1fr);padding:22px 18px 17px;gap:20px;}
|
|
638
|
+
[data-bd-tok-kpis]{grid-template-columns:repeat(3,minmax(0,1fr));padding:17px 0 0;border-left:0;border-top:1px solid #e0e9eb;gap:10px;}
|
|
639
|
+
[data-bd-tok-kpis] b{font-size:17px;}
|
|
640
|
+
[data-bd-ledger-heading]{align-items:flex-start;gap:10px;}
|
|
641
|
+
[data-bd-ledger-heading] h2{font-size:24px;}
|
|
642
|
+
[data-bd-ledger-heading] [data-bd-btn]{padding:7px 10px;font-size:11px;}
|
|
643
|
+
[data-bd-eyebrow]{font-size:8px;letter-spacing:.1em;}
|
|
644
|
+
[data-bd-tok-days]{gap:5px;}
|
|
645
|
+
[data-bd-tok-days] span{font-size:8px;}
|
|
646
|
+
[data-bd-tok-days]>div:nth-child(even):not(:last-child)>span{visibility:hidden;}
|
|
647
|
+
[data-bd-tokrow]{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:5px 12px;padding:13px 1px;}
|
|
648
|
+
[data-bd-tokrow] time{grid-column:1;grid-row:2;width:auto;font-size:9px;}
|
|
649
|
+
[data-bd-tok-task]{grid-column:1;grid-row:1;}
|
|
650
|
+
[data-bd-tok-task]>span{max-width:100%;}
|
|
651
|
+
[data-bd-tok-amount]{grid-column:2;grid-row:1;min-width:0;}
|
|
652
|
+
[data-bd-tok-amount] small{font-size:8px;}
|
|
653
|
+
[data-bd-tokrow]>[data-bd-tok-chip]{grid-column:2;grid-row:2;justify-self:end;font-size:9px;}
|
|
654
|
+
[data-bd-ledger-controls]{padding:18px 14px;}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/* Settings workbench: a quiet salt-blue index beside a warm-white workspace. */
|
|
658
|
+
[data-bd-pet-modal]{width:min(1080px,100%);height:min(850px,calc(100dvh - 36px));backdrop-filter:none;-webkit-backdrop-filter:none;}
|
|
659
|
+
[data-bd-settings-head]{padding:21px 26px;border-bottom:1px solid #dbe5e8;}
|
|
660
|
+
[data-bd-settings-title] b{font:600 20px/1.4 'Noto Serif SC','Source Han Serif SC','SimSun',serif;letter-spacing:.06em;}
|
|
661
|
+
[data-bd-settings-title] p{margin-top:4px;font-size:11px;}
|
|
662
|
+
[data-bd-settings-layout]{display:grid;grid-template-columns:172px minmax(0,1fr);flex:1;min-height:0;}
|
|
663
|
+
[data-bd-pet-tabs]{display:flex;flex-direction:column;align-items:stretch;gap:4px;padding:18px 12px;border:0;border-right:1px solid #dbe5e8;background:#f0f5f5;overflow-y:auto;min-height:0;}
|
|
664
|
+
[data-bd-nav-group]{font-size:9px;letter-spacing:.12em;color:#82959b;padding:13px 13px 5px;}
|
|
665
|
+
[data-bd-nav-group]:first-child{padding-top:0;}
|
|
666
|
+
[data-bd-pet-tab]{display:flex;flex-direction:column;align-items:flex-start;justify-content:center;gap:3px;white-space:normal;text-align:left;min-height:48px;padding:9px 13px;border-radius:8px;font-size:12px;line-height:1.4;color:#53707e;}
|
|
667
|
+
[data-bd-pet-tab] small{font-size:9px;font-weight:400;letter-spacing:.02em;color:#78909a;}
|
|
668
|
+
[data-bd-pet-tab]:hover{background:#e5edef;color:#255c73;}
|
|
669
|
+
[data-bd-pet-tab][data-on]{box-shadow:none;background:#dcebef;color:#20536b;}
|
|
670
|
+
[data-bd-pet-tab][data-on] small{color:#537e8f;}
|
|
671
|
+
[data-bd-nav-foot]{margin-top:auto;padding:20px 13px 4px;color:#8c9b9f;font:9px/1.8 'Consolas',monospace;letter-spacing:.07em;}
|
|
672
|
+
[data-bd-settings-main]{display:flex;flex-direction:column;min-width:0;min-height:0;}
|
|
673
|
+
[data-bd-pet-body]{padding:26px;min-height:0;}
|
|
674
|
+
[data-bd-pghead] b{font:600 24px/1.4 'Noto Serif SC','Source Han Serif SC','SimSun',serif;letter-spacing:.035em;color:#204b5e;}
|
|
675
|
+
[data-bd-page-content]{gap:22px;}
|
|
676
|
+
[data-bd-pet-modal] [data-bd-sec]{padding:20px;gap:13px;}
|
|
677
|
+
[data-bd-pet-modal] [data-bd-h] b{font-size:13px;}
|
|
678
|
+
[data-bd-segments]{display:inline-flex;align-self:flex-start;gap:4px;padding:4px;border:1px solid #d4e1e6;border-radius:10px;background:#edf3f3;max-width:100%;}
|
|
679
|
+
[data-bd-segments] button{border:0;border-radius:7px;padding:8px 17px;color:#64808b;background:transparent;font:inherit;font-size:11px;cursor:pointer;}
|
|
680
|
+
[data-bd-segments] button[aria-pressed="true"]{background:#fffefb;color:#235d76;box-shadow:0 1px 4px #233d4a14;}
|
|
681
|
+
[data-bd-pet-modal] [data-bd-learn-list]{height:360px;min-height:180px;overflow:auto;overscroll-behavior:contain;scrollbar-width:thin;padding:2px 7px 2px 0;}
|
|
682
|
+
[data-bd-pet-modal] [data-bd-learn-list]>[data-bd-sec]{padding:17px;flex:none;background:#f9fbfa;}
|
|
683
|
+
[data-bd-list-empty]{display:grid;place-items:center;height:360px;min-height:180px;border:1px dashed #d6e3e6;border-radius:9px;color:#79909a;font-size:11px;}
|
|
684
|
+
[data-bd-pet-modal] [data-bd-settings-log],[data-bd-pet-modal] [data-bd-act]{height:clamp(240px,42vh,420px);max-height:none;min-height:0;overflow:auto;overscroll-behavior:contain;scrollbar-width:thin;}
|
|
685
|
+
[data-bd-pet-modal] [data-bd-logrow]{padding:10px 13px;border-bottom:1px solid #e4ebed;}
|
|
686
|
+
[data-bd-pet-modal] [data-bd-logrow]:last-child{border-bottom:0;}
|
|
687
|
+
[data-bd-pet-modal] [data-bd-logevent]{font-size:11px;}
|
|
688
|
+
[data-bd-pet-modal] [data-bd-soul]{max-height:240px;overflow:auto;overscroll-behavior:contain;}
|
|
689
|
+
[data-bd-advanced]{border-top:1px solid #e0e9eb;padding-top:13px;}
|
|
690
|
+
[data-bd-advanced]>summary,[data-bd-memory]>summary{cursor:pointer;color:#4d7384;font-size:11px;line-height:1.7;}
|
|
691
|
+
[data-bd-advanced]>div{display:flex;flex-direction:column;gap:13px;padding-top:15px;}
|
|
692
|
+
[data-bd-memory]{display:block!important;}
|
|
693
|
+
[data-bd-memory]>summary{font-size:13px;font-weight:600;color:#244e62;}
|
|
694
|
+
[data-bd-memory][open]>summary{margin-bottom:18px;}
|
|
695
|
+
[data-bd-memory]>[data-bd-pet-grid]{margin:16px 0;}
|
|
696
|
+
[data-bd-memory]>[data-bd-pet-grid]>[data-bd-sec]{padding:12px!important;gap:9px!important;}
|
|
697
|
+
[data-bd-memory]>[data-bd-note]{margin-top:10px;}
|
|
698
|
+
[data-bd-pet-card]{background:#fafcf9;border-color:#ccdde4;backdrop-filter:none;-webkit-backdrop-filter:none;}
|
|
699
|
+
[data-bd-pet-card][data-bd-menu]{width:238px;overflow-y:auto;overscroll-behavior:contain;scrollbar-width:thin;}
|
|
700
|
+
[data-bd-menu] [data-bd-pet-cardhead]{padding:13px 15px;color:#748a95;}
|
|
701
|
+
[data-bd-menu] [data-bd-pet-item]{padding:10px 15px;gap:12px;font-size:12px;min-height:38px;}
|
|
702
|
+
[data-bd-menu] [data-bd-pet-item] span i{font-size:9px;margin-top:3px;}
|
|
703
|
+
[data-bd-menu] [data-bd-pet-cardhead] span{font-size:9px;}
|
|
704
|
+
[data-bd-menu] [data-bd-pet-sep]{margin:5px 14px;background:#e0e9eb;}
|
|
705
|
+
[data-bd-menu-token]{padding:12px 15px 14px;border-top:1px solid #dce7eb;background:#eef4f4;}
|
|
706
|
+
[data-bd-menu-token]>div{display:flex;justify-content:space-between;align-items:baseline;gap:12px;}
|
|
707
|
+
[data-bd-menu-token] span{font-size:10px;color:#65818d;}
|
|
708
|
+
[data-bd-menu-token] b{font:600 17px/1.3 'Bahnschrift','Consolas',sans-serif;color:#265970;font-variant-numeric:tabular-nums;}
|
|
709
|
+
[data-bd-menu-token] small{display:block;margin-top:4px;font-size:9px;line-height:1.5;color:#7a9199;}
|
|
710
|
+
[data-bd-menu-attention]{display:block;padding:6px 8px;margin-bottom:6px;border-radius:8px;background:#fff0d6;color:#805311;font-size:10.5px;line-height:1.5;}
|
|
711
|
+
@media(max-width:760px){
|
|
712
|
+
[data-bd-settings-layout]{grid-template-columns:minmax(0,1fr);grid-template-rows:auto minmax(0,1fr);}
|
|
713
|
+
[data-bd-pet-tabs]{flex-direction:row;gap:5px;padding:7px 12px;overflow-x:auto;overflow-y:hidden;border-right:0;border-bottom:1px solid #dbe5e8;}
|
|
714
|
+
[data-bd-nav-group],[data-bd-nav-foot],[data-bd-pet-tab] small{display:none;}
|
|
715
|
+
[data-bd-pet-tab]{min-height:34px;padding:8px 11px;white-space:nowrap;font-size:11px;}
|
|
716
|
+
[data-bd-pet-body]{padding:22px 18px;}
|
|
717
|
+
[data-bd-pghead] b{font-size:22px;}
|
|
718
|
+
[data-bd-pghead] p{font-size:10px;}
|
|
719
|
+
}
|
|
720
|
+
@media(max-width:660px){
|
|
721
|
+
[data-bd-page-content]>[data-bd-pet-grid],[data-bd-pet-body]>[data-bd-pet-grid]{grid-template-columns:minmax(0,1fr);}
|
|
722
|
+
[data-bd-settings-head]{padding:15px 18px;}
|
|
723
|
+
[data-bd-settings-title] b{font-size:18px;}
|
|
724
|
+
[data-bd-pet-modal] [data-bd-learn-list],[data-bd-list-empty]{height:320px;}
|
|
725
|
+
[data-bd-pet-modal] [data-bd-sec]{padding:18px 16px;}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/* Aligned rows keep the four daily cards on a shared baseline. */
|
|
729
|
+
[data-bd-page-content]>[data-bd-basic-grid]{align-items:stretch;}
|
|
730
|
+
[data-bd-basic-grid]>[data-bd-sec]{height:auto;box-sizing:border-box;}
|
|
731
|
+
[data-bd-basic-grid]>[data-bd-sec]>[data-bd-row]:last-child{margin-top:auto;padding-top:3px;}
|
|
732
|
+
[data-bd-basic-grid]>[data-bd-basic-card="pet"]{align-self:start;}
|
|
733
|
+
[data-bd-basic-grid]>[data-bd-basic-card="pet"]>[data-bd-row]:last-child{margin-top:0;padding-top:0;}
|
|
734
|
+
[data-bd-basic-grid]>[data-bd-sec]>textarea{height:132px;min-height:132px;}
|
|
735
|
+
[data-bd-basic-grid]>[data-bd-sec]:first-child>textarea{flex:1;}
|
|
736
|
+
/* The shelf uses the same warm paper as the workbench, with bounded scrolling. */
|
|
737
|
+
[data-bd-sticker-controls]{align-items:stretch!important;}
|
|
738
|
+
[data-bd-sticker-controls]>[data-bd-sec]>[data-bd-btn]{margin-top:auto;align-self:flex-start;}
|
|
739
|
+
[data-bd-sticker-number]{display:flex;align-items:center;justify-content:space-between;gap:12px;border-bottom:1px solid #e5ecee;padding:7px 0;font-size:11px;color:#4c6b7b;}
|
|
740
|
+
[data-bd-sticker-number]>span:last-child{display:flex;align-items:center;gap:7px;flex:none;}
|
|
741
|
+
[data-bd-sticker-number] [data-bd-input]{width:88px;text-align:right;}
|
|
742
|
+
[data-bd-sticker-number] small{color:#7c909a;width:20px;font-size:10px;}
|
|
743
|
+
[data-bd-sticker-count]{font-size:10px;color:#738b96;font-variant-numeric:tabular-nums;}
|
|
744
|
+
[data-bd-sticker-list]{height:440px;overflow:auto;overscroll-behavior:contain;scrollbar-width:thin;border-top:1px solid #dbe5e8;border-bottom:1px solid #dbe5e8;padding:15px 5px 15px 0;box-sizing:border-box;}
|
|
745
|
+
[data-bd-sticker-grid]{display:grid;grid-template-columns:repeat(auto-fill,minmax(128px,1fr));gap:13px;align-items:start;}
|
|
746
|
+
[data-bd-sticker-item]{min-width:0;overflow:hidden;background:#f8faf7;border:1px solid #dee7e5;border-radius:9px;}
|
|
747
|
+
[data-bd-sticker-open]{display:block;width:100%;padding:0;border:0;background:transparent;cursor:pointer;}
|
|
748
|
+
[data-bd-sticker-thumb]{display:grid;place-items:center;aspect-ratio:1;overflow:hidden;background:repeating-conic-gradient(#f0f4f2 0% 25%,#fafcf9 0% 50%) 50% / 18px 18px;color:#879991;font-size:10px;}
|
|
749
|
+
[data-bd-sticker-thumb] img{display:block;width:100%;height:100%;object-fit:contain;}
|
|
750
|
+
[data-bd-sticker-caption]{display:flex;flex-direction:column;gap:5px;padding:11px 10px 8px;min-width:0;}
|
|
751
|
+
[data-bd-sticker-caption] b{font-size:11px;font-weight:500;color:#365b69;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
|
752
|
+
[data-bd-sticker-caption] small{font-size:9px;color:#879892;}
|
|
753
|
+
[data-bd-sticker-itemfoot]{display:flex;align-items:center;gap:5px;min-height:29px;padding:0 8px 9px;font-size:9px;color:#7a8f8c;}
|
|
754
|
+
[data-bd-sticker-itemfoot]>span{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
|
755
|
+
[data-bd-pet-modal] [data-bd-sticker-itemfoot] [data-bd-btn]{font-size:9px;padding:3px 6px;color:#8a6e63;background:transparent;border-color:#decfc6;}
|
|
756
|
+
[data-bd-sticker-empty]{display:grid;place-items:center;height:100%;text-align:center;font-size:11px;color:#7b9095;line-height:1.8;padding:20px;box-sizing:border-box;}
|
|
757
|
+
[data-bd-sticker-detail]{display:grid;grid-template-columns:144px minmax(0,1fr);gap:19px;padding:17px;background:#edf4f2;border:1px solid #d1e0db;border-radius:9px;color:#44685f;}
|
|
758
|
+
[data-bd-sticker-detail] p{font-size:11px;line-height:1.7;overflow-wrap:anywhere;margin:8px 0;}
|
|
759
|
+
[data-bd-sticker-detail] [data-bd-sticker-thumb]{border-radius:7px;}
|
|
760
|
+
[data-bd-sticker-detail] [data-bd-grow]{overflow-wrap:anywhere;}
|
|
761
|
+
[data-bd-sticker-form]{display:flex;flex-direction:column;gap:14px;padding-top:14px;}
|
|
762
|
+
[data-bd-sticker-form]>[data-bd-btn]{align-self:flex-start;}
|
|
763
|
+
[data-bd-sticker-upload]{display:grid;grid-template-columns:112px minmax(0,1fr);gap:18px;align-items:start;}
|
|
764
|
+
[data-bd-sticker-upload-preview]{aspect-ratio:1;display:grid;place-items:center;overflow:hidden;border:1px dashed #b4cbd0;border-radius:9px;background:#f2f6f2;color:#76918f;font-size:11px;}
|
|
765
|
+
[data-bd-sticker-upload-preview] img{width:100%;height:100%;object-fit:contain;}
|
|
766
|
+
[data-bd-sticker-upload-fields]{display:flex;flex-direction:column;gap:12px;min-width:0;}
|
|
767
|
+
[data-bd-sticker-upload-fields] input[type="file"]{width:100%;box-sizing:border-box;font-size:11px;}
|
|
768
|
+
[data-bd-sticker-upload-fields] input[type="file"]::file-selector-button{border:1px solid #c5d8df;border-radius:6px;background:#f7faf9;color:#345e71;padding:6px 9px;margin-right:9px;cursor:pointer;}
|
|
769
|
+
[data-bd-sticker-fileinfo]{color:#527a78;overflow-wrap:anywhere;font-size:11px;line-height:1.7;}
|
|
770
|
+
@media(max-width:500px){[data-bd-sticker-upload]{grid-template-columns:minmax(0,1fr);gap:12px;}[data-bd-sticker-upload-preview]{width:88px;}}
|
|
771
|
+
/* Menu previews reuse the status snapshot and never issue a request on hover.
|
|
772
|
+
* ⭐ 2026-10-28 重画:旧版是纸白衬线卡(Noto Serif 标题 + 打字机 eyebrow + 直角上沿),
|
|
773
|
+
* 和天蓝主题的菜单/弹窗完全两个时代 —— 使用者反馈「风格明显不搭」。
|
|
774
|
+
* 现在整套复用菜单卡的设计语言:#edf8ff 底、#b3d5e9 描边、14px 圆角、同一套阴影,
|
|
775
|
+
* 标题/正文/虚线分隔与 [data-bd-pet-card] 同源,eyebrow 变小徽章,加一个轻轻的入场动画。 */
|
|
776
|
+
[data-bd-preview]{z-index:3;box-sizing:border-box;overflow:auto;overscroll-behavior:contain;scrollbar-width:thin;padding:13px 15px 14px;border:1px solid #b3d5e9;border-radius:14px;background:#edf8ff;color:#244a5e;box-shadow:0 16px 48px rgba(18,62,91,.24);line-height:1.6;animation:bdPreviewIn .16s ease-out;}
|
|
777
|
+
@keyframes bdPreviewIn{from{opacity:0;transform:translateY(5px) scale(.98);}to{opacity:1;transform:none;}}
|
|
778
|
+
[data-bd-preview-eyebrow]{display:inline-flex;align-items:center;gap:4px;width:fit-content;padding:2px 9px;border-radius:999px;background:#d9edf9;color:#2f6c92;font-size:10px;letter-spacing:.05em;}
|
|
779
|
+
[data-bd-preview-title]{display:block;font-size:13.5px;font-weight:700;color:#163b55;margin:8px 0 9px;}
|
|
780
|
+
[data-bd-preview-row]{display:flex;align-items:baseline;justify-content:space-between;gap:14px;padding:6px 0;border-bottom:1px dashed #c9e2f1;font-size:12px;}
|
|
781
|
+
[data-bd-preview-row]>span{color:#4b6c83;flex:none;}
|
|
782
|
+
[data-bd-preview-row]>b{min-width:0;max-width:190px;text-align:right;overflow-wrap:anywhere;font-weight:600;color:#163b55;}
|
|
783
|
+
[data-bd-preview] p{font-size:11.5px;line-height:1.7;color:#4b6c83;margin:9px 0 0;overflow-wrap:anywhere;}
|
|
784
|
+
[data-bd-preview-line]{padding:0 0 9px;border-bottom:1px dashed #c9e2f1;color:#21485f!important;}
|
|
785
|
+
@media(max-width:660px){[data-bd-sticker-list]{height:360px;}[data-bd-sticker-grid]{grid-template-columns:repeat(auto-fill,minmax(116px,1fr));gap:10px;}[data-bd-sticker-detail]{grid-template-columns:90px minmax(0,1fr);gap:12px;padding:13px;}[data-bd-basic-grid]>[data-bd-sec]>textarea{flex:none!important;}}
|
|
786
|
+
@media(prefers-reduced-motion:reduce){[data-bd-pet-modal]{animation:none;}[data-bd-preview]{animation:none;}[data-bd-pet-mood]{animation:none;}[data-bd-tok-bar] i,[data-bd-btn],[data-bd-sw]{transition:none;}}
|
|
787
|
+
|
|
788
|
+
`
|
|
789
|
+
|
|
790
|
+
/* ────────────── 小组件 ────────────── */
|
|
791
|
+
|
|
792
|
+
function Switch({ on, onChange, disabled }) {
|
|
793
|
+
return h('button', {
|
|
794
|
+
'data-bd-sw': '',
|
|
795
|
+
'data-on': on ? '' : undefined,
|
|
796
|
+
disabled: Boolean(disabled),
|
|
797
|
+
title: on ? '已开' : '已关',
|
|
798
|
+
onClick: () => { if (!disabled) onChange(!on) },
|
|
799
|
+
}, h('i'))
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function ToggleRow({ label, note, on, onChange, disabled }) {
|
|
803
|
+
return h('div', { 'data-bd-row': '', 'data-bd-toggle': '' },
|
|
804
|
+
h('div', { 'data-bd-grow': '' },
|
|
805
|
+
h('div', { 'data-bd-label': '' }, label),
|
|
806
|
+
note ? h('div', { 'data-bd-note': '' }, note) : null),
|
|
807
|
+
h(Switch, { on, onChange, disabled }))
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/* ⭐ 2026-10-26:文本输入统一走「本地草稿 + 失焦/回车才提交」。
|
|
811
|
+
* GPT-SoVITS 那四个输入框以前 value 直接绑 /pv 轮询值、每敲一键就 POST 一次 ——
|
|
812
|
+
* 既违反本文件自己立的"输入框必须绑本地草稿"的规矩(轮询会把正在打的字冲掉),
|
|
813
|
+
* 又让一条路径打 30 多个请求。草稿领先服务端时显示草稿;轮询带回同值后自动交回控制权。 */
|
|
814
|
+
function DraftInput({ value, onCommit, width, placeholder, type = 'text', inputMode }) {
|
|
815
|
+
const [draft, setDraft] = React.useState(null)
|
|
816
|
+
const server = String(value ?? '')
|
|
817
|
+
const shown = draft !== null && draft !== server ? draft : server
|
|
818
|
+
const commit = () => {
|
|
819
|
+
if (draft === null || draft === server) { if (draft !== null) setDraft(null); return }
|
|
820
|
+
onCommit(draft)
|
|
821
|
+
// 不清草稿:等轮询带回新值(draft === server)才交回控制权,避免提交瞬间闪回旧值
|
|
822
|
+
}
|
|
823
|
+
return h('input', {
|
|
824
|
+
'data-bd-input': '', style: { width }, type, placeholder,
|
|
825
|
+
...(inputMode ? { inputMode } : {}),
|
|
826
|
+
value: shown,
|
|
827
|
+
onChange: (e) => setDraft(e.target.value),
|
|
828
|
+
onBlur: commit,
|
|
829
|
+
onKeyDown: (e) => { if (e.key === 'Enter') { e.preventDefault(); commit() } },
|
|
830
|
+
})
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
function useStatus(intervalMs, paused = false) {
|
|
834
|
+
const [st, setSt] = React.useState(null)
|
|
835
|
+
// ⭐ 2026-10-19(用户报"没有开关这一项,只能让 ai 帮我开"):读不到配置时界面只显示"读设置中…",
|
|
836
|
+
// 用户会以为是开关坏了 ✗ ⇒ 数一下连续失败次数,交给界面把话说清楚(版本太旧 / 没重启 / 读不到)
|
|
837
|
+
const [fails, setFails] = React.useState(0)
|
|
838
|
+
/* ⭐ rev 增量(2026-10-27):服务端签名没变时只回一行 {unchanged:true} ——
|
|
839
|
+
前端连 setState 都不做,5 秒轮询从"每趟全量 payload + 全树重渲染"变成几乎白嫖。
|
|
840
|
+
⚠️ 本地乐观更新(patch / petShow 切换)改的是 st、rev 没变 ⇒ 不会被 unchanged 那趟冲掉,
|
|
841
|
+
比以前"下一次轮询拿服务端真值盖回来"更稳。 */
|
|
842
|
+
const revRef = React.useRef('')
|
|
843
|
+
React.useEffect(() => {
|
|
844
|
+
/* ⭐ paused:父组件已经在轮同一份 /status 时(弹窗共享桌宠根的那份),这里就不再起第二条轮询 */
|
|
845
|
+
if (paused) return undefined
|
|
846
|
+
let alive = true
|
|
847
|
+
const tick = async () => {
|
|
848
|
+
try {
|
|
849
|
+
const d = await jget('/status' + (revRef.current ? '?rev=' + encodeURIComponent(revRef.current) : ''))
|
|
850
|
+
if (!alive) return
|
|
851
|
+
if (d && d.ok) {
|
|
852
|
+
setFails(0)
|
|
853
|
+
if (d.unchanged) return
|
|
854
|
+
if (d.rev) revRef.current = String(d.rev)
|
|
855
|
+
setSt(d)
|
|
856
|
+
} else setFails((n) => n + 1)
|
|
857
|
+
} catch { if (alive) setFails((n) => n + 1) }
|
|
858
|
+
}
|
|
859
|
+
tick()
|
|
860
|
+
const t = setInterval(tick, intervalMs || 5000)
|
|
861
|
+
return () => { alive = false; clearInterval(t) }
|
|
862
|
+
}, [intervalMs, paused])
|
|
863
|
+
return [st, setSt, fails]
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/* 🗑 旧的全页设置 Panel 已退役(2026-10-27):它的配置项早已搬进 PetSettingsModal,
|
|
867
|
+
活动流水 / 出手节奏读数 / 判重账本行也一并搬了进来。回归断言改由 render-modal.cjs 盯弹窗本体。 */
|
|
868
|
+
|
|
869
|
+
/* ════════════════════════════════════════════════════════════════════════
|
|
870
|
+
* 🐾 桌宠(2026-10-26 桌宠化改造)
|
|
871
|
+
* ============================================================================
|
|
872
|
+
* 为什么不走 slots:slots 是「设置页」的地盘,桌宠是常驻浮层 —— 自己建一个 div
|
|
873
|
+
* 挂到 document.body,再用 react-dom 的 root 把下面这棵树渲进去(见 mountPet)。
|
|
874
|
+
* 服务端契约(index.js 已实现并 smoke 过,字段名一个字都不能差):
|
|
875
|
+
* POST /pet/session-new {} → {ok,sessionId,preset} | {ok:false,error,hint}
|
|
876
|
+
* POST /pet/start {mode,sessionId?,model?,tokenBudget?}
|
|
877
|
+
* → 成功 {ok:true,...};非法 mode/缺 sessionId 回 HTTP 400
|
|
878
|
+
* GET /pet/log?n=200 → {ok,entries:[{at,type,...}]}(时间正序)
|
|
879
|
+
* /status 新增:bgBusy(后台轮在跑)、petShow(「显示桌宠」的配置镜像)
|
|
880
|
+
* ──────────────────────────────────────────────────────────────────────────── */
|
|
881
|
+
|
|
882
|
+
const PET_POS_KEY = 'dafeiyu.pet.pos'
|
|
883
|
+
|
|
884
|
+
/* localStorage 在隐私模式/受限环境会直接抛异常 ⇒ 读写都包一层,存不上就用默认位 */
|
|
885
|
+
const petStore = {
|
|
886
|
+
get(k, dflt) {
|
|
887
|
+
try {
|
|
888
|
+
const v = window.localStorage && window.localStorage.getItem(k)
|
|
889
|
+
if (!v) return dflt
|
|
890
|
+
const j = JSON.parse(v)
|
|
891
|
+
return (j && typeof j === 'object') ? j : dflt
|
|
892
|
+
} catch { return dflt }
|
|
893
|
+
},
|
|
894
|
+
set(k, v) {
|
|
895
|
+
try { window.localStorage && window.localStorage.setItem(k, JSON.stringify(v)) } catch { /* 存不上就每次用默认位 */ }
|
|
896
|
+
},
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/* 拖动/开窗都要把鱼按回视口里:留 6px 边距,别让它半个身子探出去点不回来。
|
|
900
|
+
* 2026-10-26 贴图化后是 128×128 的正方形(纯 CSS 那版是 76×64)—— 常数跟 CSS 那个
|
|
901
|
+
* [data-bd-pet] 的 width/height 必须同步,否则拖到右下角会有一半垂到屏幕外。 */
|
|
902
|
+
const PET_W = 128
|
|
903
|
+
const PET_H = 128
|
|
904
|
+
const petClamp = (p) => {
|
|
905
|
+
const vw = window.innerWidth || 1280
|
|
906
|
+
const vh = window.innerHeight || 800
|
|
907
|
+
return {
|
|
908
|
+
x: Math.max(6, Math.min(Number(p && p.x) || 0, vw - PET_W - 6)),
|
|
909
|
+
y: Math.max(6, Math.min(Number(p && p.y) || 0, vh - PET_H - 6)),
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/* token 数用人话念:500000 → 「50 万」(桌宠那扇小窗塞不下六位数字) */
|
|
914
|
+
const petFmtWan = (n) => {
|
|
915
|
+
const v = Number(n) || 0
|
|
916
|
+
return v > 0 ? (Math.round(v / 1000) / 10) + ' 万' : '不限'
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
const petFmtTime = (at) => {
|
|
920
|
+
if (!at) return ''
|
|
921
|
+
try {
|
|
922
|
+
const d = new Date(at)
|
|
923
|
+
const p = (n) => String(n).padStart(2, '0')
|
|
924
|
+
return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
|
|
925
|
+
} catch { return '' }
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/** 秒数 → 「3 分 20 秒」(任务明细行用;别显示 7384 秒这种没人念得出来的数) */
|
|
929
|
+
const fmtDurSec = (s) => {
|
|
930
|
+
const n = Math.max(0, Math.round(Number(s) || 0))
|
|
931
|
+
if (n < 60) return n + ' 秒'
|
|
932
|
+
const m = Math.floor(n / 60)
|
|
933
|
+
if (m < 60) return m + ' 分' + (n % 60 ? ' ' + (n % 60) + ' 秒' : '')
|
|
934
|
+
return Math.floor(m / 60) + ' 小时 ' + String(m % 60).padStart(2, '0') + ' 分'
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/** 毫秒 → 「1 分 32 秒」(「正在想」呼吸条用;73840ms 别念成 73840) */
|
|
938
|
+
const fmtSpanMs = (ms) => fmtDurSec((Number(ms) || 0) / 1000)
|
|
939
|
+
|
|
940
|
+
/** 🐟 吃了多少 token → 称号(2026-10-27 的纯前端小彩蛋;只管显示,不改任何行为) */
|
|
941
|
+
const tokTitle = (n) => {
|
|
942
|
+
const v = Number(n) || 0
|
|
943
|
+
if (v <= 0) return '还没开饭'
|
|
944
|
+
if (v < 100000) return '挑食小鱼苗'
|
|
945
|
+
if (v < 500000) return '干饭小胖鱼'
|
|
946
|
+
if (v < 2000000) return '深海干饭王'
|
|
947
|
+
if (v < 5000000) return 'B站吞金兽'
|
|
948
|
+
return '亚特兰蒂斯守护鲲'
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
/* 桌宠日志一行的人话版本(type 契约见上面注释块) */
|
|
952
|
+
const petEntryText = (e) => {
|
|
953
|
+
const t = String((e && e.type) || 'note')
|
|
954
|
+
if (t === 'session-start') {
|
|
955
|
+
return `开刷(会话 · ${(e && e.model) || '默认模型'})`
|
|
956
|
+
+ ` · 预算 ${petFmtWan(e && e.tokenBudget)}`
|
|
957
|
+
+ (Number(e && e.maxRounds) > 0 ? ` · 最多 ${e.maxRounds} 轮` : '')
|
|
958
|
+
}
|
|
959
|
+
if (t === 'session-stop') return `停下 · ${(e && e.reason) || '手动'}${Number(e && e.rounds) > 0 ? ` · 共 ${e.rounds} 轮` : ''}`
|
|
960
|
+
if (t === 'bg-start') return [e && e.task === 'learn-review' ? '学习审核开跑' : '后台开跑', (e && e.model) || '默认模型',
|
|
961
|
+
'预算 ' + petFmtWan(e && e.tokenBudget), e && e.why ? '来源 ' + e.why : ''].filter(Boolean).join(' · ')
|
|
962
|
+
if (t === 'bg-progress') {
|
|
963
|
+
const stages = { 'model-start': '模型请求', 'model-done': '模型返回', 'model-error': '模型错误', 'tool-start': '工具开始', 'tool-done': '工具返回', 'tool-error': '工具错误', 'usage-unknown': '用量缺失', tool: '工具调用', diagnostic: '诊断', output: '输出' }
|
|
964
|
+
const label = stages[e && e.stage] || String((e && e.stage) || '进度')
|
|
965
|
+
const usage = e && e.total != null ? '已计量 ' + Number(e.total).toLocaleString('zh-CN') + ' token' : ''
|
|
966
|
+
return [label, e && (e.text || e.error), e && e.model, e && e.requests != null ? e.requests + ' 次请求' : '', usage].filter(Boolean).join(' · ')
|
|
967
|
+
}
|
|
968
|
+
if (t === 'bg-done') {
|
|
969
|
+
const reasons = { exit: '正常结束', error: '错误退出', user: '手动停止', 'time-limit': '到达时限', 'spawn-error': '启动失败', 'token-budget': '本趟预算用完', 'total-budget': '累计上限已到' }
|
|
970
|
+
const reason = String((e && e.reason) || '')
|
|
971
|
+
const tok = e && e.tokens
|
|
972
|
+
return [e && e.task === 'learn-review' ? '学习审核结束' : '后台结束', reasons[reason] || reason,
|
|
973
|
+
e && e.code != null ? '退出码 ' + e.code : '', e && e.seconds != null ? '用时 ' + e.seconds + ' 秒' : '',
|
|
974
|
+
e && e.model, e && e.requests != null ? e.requests + ' 次模型请求' : '', e && e.toolCalls != null ? e.toolCalls + ' 次工具调用' : '',
|
|
975
|
+
tok && tok.total != null ? '已计量 ' + Number(tok.total).toLocaleString('zh-CN') + ' token' : '',
|
|
976
|
+
Number(e && e.unknownRequests) > 0 ? '另有 ' + e.unknownRequests + ' 次响应用量缺失' : '', e && e.error].filter(Boolean).join(' · ')
|
|
977
|
+
}
|
|
978
|
+
if (t === 'learn-review-start') return ['审核开始', e && e.model, '本批 ' + Number(e && e.count || 0) + ' 条'].filter(Boolean).join(' · ')
|
|
979
|
+
if (t === 'learn-review-done') return [e && e.complete ? '本批审核完成' : '本批未全部完成', '批准 ' + Number(e && e.approved || 0), '拒绝 ' + Number(e && e.rejected || 0), '仍待审 ' + Number(e && e.remaining || 0), e && e.error, e && e.nextRetryAt ? '下次可重试 ' + petFmtTime(e.nextRetryAt) : ''].filter(Boolean).join(' · ')
|
|
980
|
+
if (t === 'error') return [(e && e.error) || '未知错误', e && e.model, e && e.runId ? '任务 ' + e.runId : ''].filter(Boolean).join(' · ')
|
|
981
|
+
if (e && (e.text != null || e.note != null)) return String(e.text ?? e.note)
|
|
982
|
+
try { return JSON.stringify(e || {}) } catch { return String(e || '') }
|
|
983
|
+
}
|
|
984
|
+
/* 桌宠日志的事件名与结果分开:右键面板只显示一行,设置页可展开完整原始字段。 */
|
|
985
|
+
const petEntryEvent = (e) => {
|
|
986
|
+
const t = String((e && e.type) || 'note')
|
|
987
|
+
return ({
|
|
988
|
+
'session-start': '会话开始',
|
|
989
|
+
'session-stop': '会话结束',
|
|
990
|
+
'bg-start': '后台开始',
|
|
991
|
+
'bg-progress': '后台进度',
|
|
992
|
+
'bg-done': '后台结束',
|
|
993
|
+
'learn-review-start': '学习审核',
|
|
994
|
+
'learn-review-done': '审核结果',
|
|
995
|
+
'schedule-start': '定时启动',
|
|
996
|
+
'schedule-error': '定时错误',
|
|
997
|
+
error: '错误',
|
|
998
|
+
note: '记录',
|
|
999
|
+
})[t] || t
|
|
1000
|
+
}
|
|
1001
|
+
const petEntrySummary = (e) => `${petEntryEvent(e)}:${petEntryText(e)}`
|
|
1002
|
+
const petEntryRaw = (e) => {
|
|
1003
|
+
try { return JSON.stringify(e || {}, null, 2) } catch { return String(e || '') }
|
|
1004
|
+
}
|
|
1005
|
+
const petEntryTone = (event) => {
|
|
1006
|
+
const e = event && typeof event === 'object' ? event : { type: event }
|
|
1007
|
+
const s = String(e.type || 'note')
|
|
1008
|
+
if (s === 'error' || s === 'schedule-error' || e.error || e.stage === 'model-error' || e.stage === 'tool-error' || (s === 'bg-done' && (e.reason === 'error' || e.reason === 'spawn-error' || (e.code != null && Number(e.code) !== 0)))) return 'error'
|
|
1009
|
+
if (s === 'session-start' || s === 'bg-start') return 'start'
|
|
1010
|
+
if (s === 'session-stop' || s === 'bg-done') return 'stop'
|
|
1011
|
+
return 'note'
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
/* ⭐ toast:设置页有 say()/msg 那条常驻回执;桌宠是浮层、说完就该走 ——
|
|
1015
|
+
* 4.6 秒自动消失,最多同时 3 条(再多就把鱼盖住了)。
|
|
1016
|
+
* ⚠️ 2026-10-27:定时器收进 Set、到期自己把自己删掉 —— 以前只 push 不清,
|
|
1017
|
+
* 弹一晚上菜单会攒几百个死 timer 引用(量小但没意义,顺手修掉)。 */
|
|
1018
|
+
function useToasts() {
|
|
1019
|
+
const [toasts, setToasts] = React.useState([])
|
|
1020
|
+
const seq = React.useRef(0)
|
|
1021
|
+
const timers = React.useRef(new Set())
|
|
1022
|
+
const pushRef = React.useRef(null)
|
|
1023
|
+
if (!pushRef.current) pushRef.current = (text, tone) => {
|
|
1024
|
+
const id = ++seq.current
|
|
1025
|
+
setToasts((old) => old.concat([{ id, text: String(text ?? ''), tone: tone || '' }]).slice(-3))
|
|
1026
|
+
const timer = setTimeout(() => {
|
|
1027
|
+
timers.current.delete(timer)
|
|
1028
|
+
setToasts((old) => old.filter((t) => t.id !== id))
|
|
1029
|
+
}, 4600)
|
|
1030
|
+
timers.current.add(timer)
|
|
1031
|
+
}
|
|
1032
|
+
React.useEffect(() => () => { for (const t of timers.current) clearTimeout(t) }, [])
|
|
1033
|
+
return [toasts, pushRef.current]
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
/* 🐟 形象:本体那张透明 PNG(554×561)+ 轻微浮动 + 泡泡,不叠状态光圈。
|
|
1037
|
+
* ⚠️ 2026-10-26:以前是纯 CSS 拼的胖鱼(怕"多加载一张图就多一个失败点")。
|
|
1038
|
+
* 现在使用者指定了本体形象图,就走后端静态路由 `/api/bili-duty/pet/image`(白名单只给 pet)。
|
|
1039
|
+
* 图挂了(路由不在/文件丢了)自动退回 CSS 那条鱼 —— 失败点仍然是有的,但兜住了。
|
|
1040
|
+
* aura 由父级按 /status 算好传进来:idle 空闲 / run 鲸鱼摸鱼在刷 / bg 后台轮 / err 没登录或读不到状态 */
|
|
1041
|
+
function PetFish({ aura, bubbles, title }) {
|
|
1042
|
+
const [imgOk, setImgOk] = React.useState(true)
|
|
1043
|
+
return h('div', { 'data-bd-pet-fish': '', 'data-aura': aura || 'idle', title: title || '' },
|
|
1044
|
+
imgOk
|
|
1045
|
+
? h('img', {
|
|
1046
|
+
'data-bd-pet-img': '', src: PET_IMG_URL, alt: '鲸鱼摸鱼', draggable: false,
|
|
1047
|
+
onError: () => setImgOk(false),
|
|
1048
|
+
})
|
|
1049
|
+
/* 兜底:贴图加载失败就退回原来那条纯 CSS 的胖鱼(不带 Fragment —— 假 React 里没有) */
|
|
1050
|
+
: [
|
|
1051
|
+
h('i', { key: 'tail', 'data-bd-pet-tail': '' }),
|
|
1052
|
+
h('i', { key: 'dorsal', 'data-bd-pet-dorsal': '' }),
|
|
1053
|
+
h('i', { key: 'body', 'data-bd-pet-torso': '' }),
|
|
1054
|
+
h('i', { key: 'belly', 'data-bd-pet-belly': '' }),
|
|
1055
|
+
h('i', { key: 'fin', 'data-bd-pet-fin': '' }),
|
|
1056
|
+
h('i', { key: 'eye', 'data-bd-pet-eye': '' }, h('i', null)),
|
|
1057
|
+
h('i', { key: 'mouth', 'data-bd-pet-mouth': '' }),
|
|
1058
|
+
],
|
|
1059
|
+
bubbles.map((b) => h('i', {
|
|
1060
|
+
'data-bd-pet-bubble': '', key: b.id,
|
|
1061
|
+
style: { '--dx': b.dx + 'px', '--s': b.s + 'px', '--t': b.t + 's', '--d': b.d + 's' },
|
|
1062
|
+
})))
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
/* 🌙 后台刷一轮…:headless 独立进程那一轮(不占对话)。模型下拉复用 /status 的
|
|
1066
|
+
* models 清单(拿不到就退手填,判据与设置页一致);token 预算以「万」为单位
|
|
1067
|
+
* 显示与输入,提交时换算成整数。开跑走 POST /pet/start {mode:'background'}。 */
|
|
1068
|
+
function PetBgPanel({ st, toast, onDone }) {
|
|
1069
|
+
const cfg = (st && st.config) || {}
|
|
1070
|
+
const rows = Array.isArray(st && st.models) ? st.models : []
|
|
1071
|
+
const [model, setModel] = React.useState(null) // null = 还没从配置初始化过('' 是合法值:默认模型)
|
|
1072
|
+
const [wan, setWan] = React.useState(null) // 同上;'' = 不限
|
|
1073
|
+
const [busy, setBusy] = React.useState(false)
|
|
1074
|
+
const [tail, setTail] = React.useState(null)
|
|
1075
|
+
const lock = React.useRef(false) // 防双发:state 的 busy 要等重渲染才生效,连点两下会漏
|
|
1076
|
+
React.useEffect(() => {
|
|
1077
|
+
if (model === null) setModel(String(cfg.backgroundModelId ?? cfg.modelId ?? ''))
|
|
1078
|
+
if (wan === null) {
|
|
1079
|
+
const t = Number(cfg.backgroundTokenBudget ?? cfg.tokenBudget) || 0
|
|
1080
|
+
setWan(t > 0 ? String(Math.round(t / 10000)) : '')
|
|
1081
|
+
}
|
|
1082
|
+
}, [cfg])
|
|
1083
|
+
/* 日志尾巴:后台那轮「在不在动」只能靠这本账 —— 它不进对话、也不进活动流水 */
|
|
1084
|
+
const tailRev = React.useRef('')
|
|
1085
|
+
React.useEffect(() => {
|
|
1086
|
+
let dead = false
|
|
1087
|
+
const pull = async () => {
|
|
1088
|
+
try {
|
|
1089
|
+
const r = await jget('/pet/log?n=10' + (tailRev.current ? '&rev=' + encodeURIComponent(tailRev.current) : ''))
|
|
1090
|
+
if (!dead && r && r.ok !== false) {
|
|
1091
|
+
if (r.unchanged) return // ⭐ 没新增行就不动(省一次解析+渲染)
|
|
1092
|
+
if (r.rev) tailRev.current = String(r.rev)
|
|
1093
|
+
setTail(Array.isArray(r.entries) ? r.entries : [])
|
|
1094
|
+
}
|
|
1095
|
+
} catch { /* 下次轮询会补 */ }
|
|
1096
|
+
}
|
|
1097
|
+
pull()
|
|
1098
|
+
const t = setInterval(pull, 3000)
|
|
1099
|
+
return () => { dead = true; clearInterval(t) }
|
|
1100
|
+
}, [])
|
|
1101
|
+
const bgBusy = Boolean(st && st.bgBusy)
|
|
1102
|
+
const run = async () => {
|
|
1103
|
+
if (lock.current || busy || bgBusy) return // 绝不双发:上一轮还活着就不再叠一轮
|
|
1104
|
+
lock.current = true
|
|
1105
|
+
setBusy(true)
|
|
1106
|
+
try {
|
|
1107
|
+
const body = { mode: 'background', tokenBudget: Math.max(0, Math.round((parseFloat(wan) || 0) * 10000)) }
|
|
1108
|
+
if (model) body.model = model
|
|
1109
|
+
const r = await jpost('/pet/start', body)
|
|
1110
|
+
if (r && r.ok === false) {
|
|
1111
|
+
toast('后台没跑起来:' + (r.error || '未知原因') + (r.hint ? ' —— ' + r.hint : ''), 'warn')
|
|
1112
|
+
return
|
|
1113
|
+
}
|
|
1114
|
+
if (r && r.started) {
|
|
1115
|
+
toast('后台那轮跑起来了 —— 几分钟,做完日志里会有回执', 'good')
|
|
1116
|
+
if (onDone) onDone()
|
|
1117
|
+
} else {
|
|
1118
|
+
toast('后台没跑起来:' + ((r && r.error) || '可能上一轮还没结束'), 'warn')
|
|
1119
|
+
}
|
|
1120
|
+
} catch (e) { toast('后台没跑起来:' + String(e?.message ?? e), 'warn') }
|
|
1121
|
+
finally { lock.current = false; setBusy(false) }
|
|
1122
|
+
}
|
|
1123
|
+
const cur = model === null ? '' : String(model)
|
|
1124
|
+
const extra = cur && !rows.some((m) => m.id === cur) ? [{ id: cur, label: cur + '(不在当前清单里)' }] : []
|
|
1125
|
+
return h('div', { 'data-bd-pet-card': '', 'data-wide': '' },
|
|
1126
|
+
h('div', { 'data-bd-pet-cardhead': '' }, h('b', {}, '后台刷一轮'), h('span', {}, 'headless 独立进程')),
|
|
1127
|
+
h('div', { 'data-bd-pet-pad': '' },
|
|
1128
|
+
bgBusy ? h('span', { 'data-bd-pet-badge': '', 'data-warn': '' }, '后台正跑着呢 —— 等它做完再开下一轮') : null,
|
|
1129
|
+
h('div', { 'data-bd-row': '' },
|
|
1130
|
+
h('span', { 'data-bd-label': '', 'data-bd-grow': '' }, '模型'),
|
|
1131
|
+
rows.length
|
|
1132
|
+
? h('select', {
|
|
1133
|
+
'data-bd-input': '', 'data-bd-num': '', style: { width: '190px' },
|
|
1134
|
+
value: cur, onChange: (e) => setModel(e.target.value),
|
|
1135
|
+
},
|
|
1136
|
+
h('option', { value: '' }, '默认(跟着当前对话)'),
|
|
1137
|
+
extra.length ? h('option', { key: 'extra', value: extra[0].id }, extra[0].label) : null,
|
|
1138
|
+
rows.map((m) => h('option', { key: m.id, value: m.id }, m.label || m.id)))
|
|
1139
|
+
: h('input', {
|
|
1140
|
+
'data-bd-input': '', style: { width: '190px' }, placeholder: 'provider/model(留空 = 默认)',
|
|
1141
|
+
value: cur, onChange: (e) => setModel(e.target.value),
|
|
1142
|
+
})),
|
|
1143
|
+
h('div', { 'data-bd-row': '' },
|
|
1144
|
+
h('span', { 'data-bd-label': '', 'data-bd-grow': '' }, 'token 预算(万 · 0 = 不限)'),
|
|
1145
|
+
h('input', {
|
|
1146
|
+
'data-bd-input': '', 'data-bd-num': '', style: { width: '88px' }, type: 'number', min: 0, step: 1,
|
|
1147
|
+
placeholder: '不限', value: wan === null ? '' : wan,
|
|
1148
|
+
onChange: (e) => setWan(e.target.value),
|
|
1149
|
+
})),
|
|
1150
|
+
h('div', { 'data-bd-note': '' }, '1 万 = 10000 token。后台预算是任务目标;实际运行时长在「设置 → 后台刷」中调整。'),
|
|
1151
|
+
h('div', { 'data-bd-row': '' },
|
|
1152
|
+
h('button', {
|
|
1153
|
+
'data-bd-btn': '', 'data-primary': '', disabled: busy || bgBusy, onClick: run,
|
|
1154
|
+
}, busy ? '开跑…' : '开跑'),
|
|
1155
|
+
busy ? h('i', { 'data-bd-pet-spin': '' }) : null)),
|
|
1156
|
+
tail && tail.length
|
|
1157
|
+
? h('div', {
|
|
1158
|
+
'data-bd-pet-log': '',
|
|
1159
|
+
style: { maxHeight: '122px', borderTop: '1px solid #d8e6ee' },
|
|
1160
|
+
},
|
|
1161
|
+
tail.slice(-4).map((e, i) => h('div', { 'data-bd-pet-logline': '', 'data-t': petEntryTone(e), key: i },
|
|
1162
|
+
h('time', {}, petFmtTime(e && e.at)), h('span', {}, petEntrySummary(e)))))
|
|
1163
|
+
: null)
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
/* 📜 运行日志:GET /pet/log,3 秒自动刷新;type 着色(start 绿 / stop 灰 / error 红 / note 蓝)。
|
|
1167
|
+
* 自动滚到底,但使用者往上翻时暂停自动滚(离底 <28px 才重新粘住),不然永远翻不上去。
|
|
1168
|
+
* 顶部「停止鲸鱼摸鱼」复用现有 POST /feiyu {action:'stop'}(跟设置页「停一下」同一个端点)。 */
|
|
1169
|
+
function PetLogPanel({ toast }) {
|
|
1170
|
+
const [entries, setEntries] = React.useState(null)
|
|
1171
|
+
const [busy, setBusy] = React.useState(false)
|
|
1172
|
+
const [err, setErr] = React.useState('')
|
|
1173
|
+
const listRef = React.useRef(null)
|
|
1174
|
+
const stick = React.useRef(true)
|
|
1175
|
+
const lock = React.useRef(false)
|
|
1176
|
+
const revRef = React.useRef('')
|
|
1177
|
+
React.useEffect(() => {
|
|
1178
|
+
let dead = false
|
|
1179
|
+
const pull = async () => {
|
|
1180
|
+
try {
|
|
1181
|
+
const r = await jget('/pet/log?n=200' + (revRef.current ? '&rev=' + encodeURIComponent(revRef.current) : ''))
|
|
1182
|
+
if (dead) return
|
|
1183
|
+
if (!r || r.ok === false) { setErr((r && r.error) || '读不到'); return }
|
|
1184
|
+
if (r.unchanged) return // ⭐ 日志没新增行:不重解析、不重渲染
|
|
1185
|
+
if (r.rev) revRef.current = String(r.rev)
|
|
1186
|
+
setEntries(Array.isArray(r.entries) ? r.entries : [])
|
|
1187
|
+
setErr('')
|
|
1188
|
+
} catch (e) { if (!dead) setErr(String(e?.message ?? e)) }
|
|
1189
|
+
}
|
|
1190
|
+
pull()
|
|
1191
|
+
const t = setInterval(pull, 3000)
|
|
1192
|
+
return () => { dead = true; clearInterval(t) }
|
|
1193
|
+
}, [])
|
|
1194
|
+
React.useEffect(() => {
|
|
1195
|
+
const el = listRef.current
|
|
1196
|
+
if (el && stick.current) el.scrollTop = el.scrollHeight
|
|
1197
|
+
}, [entries])
|
|
1198
|
+
const onScroll = () => {
|
|
1199
|
+
const el = listRef.current
|
|
1200
|
+
if (!el) return
|
|
1201
|
+
stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 28
|
|
1202
|
+
}
|
|
1203
|
+
const onStop = async () => {
|
|
1204
|
+
if (lock.current) return
|
|
1205
|
+
lock.current = true
|
|
1206
|
+
setBusy(true)
|
|
1207
|
+
try {
|
|
1208
|
+
const r = await jpost('/feiyu', { action: 'stop' })
|
|
1209
|
+
if (r && r.ok === false) toast('没停成:' + (r.error || r.hint || '未知原因'), 'warn')
|
|
1210
|
+
else toast(r && r.rounds != null ? `上岸啦~这趟一共游了 ${r.rounds} 轮。` : '它本来就没在刷。', 'good')
|
|
1211
|
+
} catch (e) { toast('没停成:' + String(e?.message ?? e), 'warn') }
|
|
1212
|
+
finally { lock.current = false; setBusy(false) }
|
|
1213
|
+
}
|
|
1214
|
+
return h('div', { 'data-bd-pet-card': '', 'data-wide': '' },
|
|
1215
|
+
h('div', { 'data-bd-pet-cardhead': '' },
|
|
1216
|
+
h('b', {}, '运行日志'),
|
|
1217
|
+
h('span', {}, '每 3 秒自动刷新'),
|
|
1218
|
+
h('button', {
|
|
1219
|
+
'data-bd-btn': '', 'data-sm': '', style: { marginLeft: '8px' }, disabled: busy, onClick: onStop,
|
|
1220
|
+
}, busy ? '停…' : '停止鲸鱼摸鱼')),
|
|
1221
|
+
err ? h('div', { 'data-bd-pet-pad': '' }, h('div', { 'data-bd-note': '' }, '读不到日志:' + err)) : null,
|
|
1222
|
+
h('div', { 'data-bd-pet-log': '', ref: listRef, onScroll },
|
|
1223
|
+
!entries
|
|
1224
|
+
? h('div', { 'data-bd-pet-logline': '' }, h('span', {}, '正在读…'))
|
|
1225
|
+
: entries.length === 0
|
|
1226
|
+
? h('div', { 'data-bd-pet-logline': '' }, h('span', {}, '还没动静 —— 开一次会话或跑一轮后台,这儿就会一条条记下来。'))
|
|
1227
|
+
: entries.map((e, i) => h('div', { 'data-bd-pet-logline': '', 'data-t': petEntryTone(e), key: i },
|
|
1228
|
+
h('time', {}, petFmtTime(e && e.at)), h('span', {}, petEntrySummary(e))))))
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
/* 🐟 状态卡:字段全部来自 /status(feiyu 激活/轮次/今日挑选/本轮 token、bgBusy、loggedIn) */
|
|
1232
|
+
function PetStatusCard({ st, fails }) {
|
|
1233
|
+
const fy = (st && st.feiyu) || {}
|
|
1234
|
+
const tok = fy.tokens || {}
|
|
1235
|
+
const budget = Number(tok.budget) || 0
|
|
1236
|
+
const measured = tok.thisRun !== null && tok.thisRun !== undefined && tok.thisRun !== ''
|
|
1237
|
+
&& !tok.tokensUnknown && Number.isFinite(Number(tok.thisRun)) && Number(tok.thisRun) >= 0
|
|
1238
|
+
const used = measured ? Number(tok.thisRun) : null
|
|
1239
|
+
const left = !measured ? null : tok.left != null
|
|
1240
|
+
? Math.max(0, Number(tok.left) || 0)
|
|
1241
|
+
: (budget > 0 ? Math.max(0, budget - used) : null)
|
|
1242
|
+
const fmtTok = (n) => (Number(n) || 0).toLocaleString()
|
|
1243
|
+
const rows = [
|
|
1244
|
+
['在刷与否', fy.active ? '在刷~' : '歇着'],
|
|
1245
|
+
['第几轮', fy.goal && fy.goal.rounds != null ? `${fy.goal.rounds} / ${fy.goal.maxRounds || '?'} 轮` : fy.source === 'manual' ? '手动刷站' : '—'],
|
|
1246
|
+
['今天挑了几条', `${Number(fy.pickedToday ?? (st && st.pickedToday) ?? 0)} / ${Number(st && st.config && st.config.perDay) || 0}`],
|
|
1247
|
+
['今天发了几条说说', `${Number(fy.momentsToday ?? 0)} / ${Number(fy.momentPerDay ?? 0)}`],
|
|
1248
|
+
['这轮烧的 token', !measured ? '未计量' : budget > 0
|
|
1249
|
+
? `${used.toLocaleString()} / ${budget.toLocaleString()}${left != null ? `(剩 ${left.toLocaleString()})` : ''}`
|
|
1250
|
+
: `${used.toLocaleString()} / 不限`],
|
|
1251
|
+
['今天烧的 token', fmtTok(st && st.tokensToday)],
|
|
1252
|
+
['一共烧的 token', fmtTok(st && st.tokensTotal) + (Number(st && st.tokensTotal) > 0 ? '(' + tokTitle(st && st.tokensTotal) + ')' : '')],
|
|
1253
|
+
['后台轮', st ? (st.bgBusy ? '跑着呢' : '闲着') : '—'],
|
|
1254
|
+
['登录', st ? (st.loggedIn ? '已登录' : '没登录') : (fails >= 3 ? '读不到状态' : '读取中…')],
|
|
1255
|
+
['在用模型', String(fy.modelActive || fy.model || (st && st.modelNow) || '—')],
|
|
1256
|
+
]
|
|
1257
|
+
return h('div', { 'data-bd-pet-card': '' },
|
|
1258
|
+
h('div', { 'data-bd-pet-cardhead': '' }, h('b', {}, '状态卡'),
|
|
1259
|
+
h('span', {}, '每 5 秒刷新' + (Number(st && st.activeDays) > 0 ? ' · 连着干活 ' + st.activeDays + ' 天' : ''))),
|
|
1260
|
+
h('div', { 'data-bd-pet-status': '' },
|
|
1261
|
+
rows.map(([k, v]) => h('div', { 'data-bd-pet-kv': '', key: k }, h('span', {}, k), h('b', {}, v)))))
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
/* 🪞 弹窗头像:角色立绘的脸部特写(/pet/image?name=face,服务端白名单路由)。
|
|
1265
|
+
图挂了(路由不在/文件丢了)自动退回原来的线条图标,不留破图框。 */
|
|
1266
|
+
// Read the selected workspace without accepting client-provided permissions.
|
|
1267
|
+
function newSessionContext(ctx) {
|
|
1268
|
+
try {
|
|
1269
|
+
const workspace = ctx && typeof ctx.get === 'function' ? ctx.get('uiWorkspace') : null
|
|
1270
|
+
const sourceSessionId = workspace?.selection?.getSnapshot?.()?.sessionId
|
|
1271
|
+
const items = workspace?.workspaces?.list?.getSnapshot?.()?.items || []
|
|
1272
|
+
const owner = sourceSessionId ? items.find((w) => Array.isArray(w.sessionIds) && w.sessionIds.includes(sourceSessionId)) : null
|
|
1273
|
+
return { ...(sourceSessionId ? { sourceSessionId } : {}), ...(owner?.workspaceId ? { workspaceId: owner.workspaceId } : {}) }
|
|
1274
|
+
} catch { return {} }
|
|
1275
|
+
}
|
|
1276
|
+
function PetIcon({ name }) {
|
|
1277
|
+
const paths = {
|
|
1278
|
+
play: ['M8 5l11 7-11 7z'], moon: ['M20.9 13A9 9 0 0 1 11 3.1 9 9 0 1 0 20.9 13z'],
|
|
1279
|
+
list: ['M9 6h11M9 12h11M9 18h11M4 6h.01M4 12h.01M4 18h.01'],
|
|
1280
|
+
gear: ['M9 3h6l1 3 3 1 2 5-2 5-3 1-1 3H9l-1-3-3-1-2-5 2-5 3-1z', 'M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0'],
|
|
1281
|
+
pulse: ['M3 12h4l3-8 4 16 3-8h4'], hide: ['M3 3l18 18M10.6 10.6a2 2 0 0 0 2.8 2.8M9.4 5.3A10.2 10.2 0 0 1 12 5c6 0 10 7 10 7a17 17 0 0 1-3 3.7M6.2 6.2A22 22 0 0 0 2 12s4 7 10 7a10.3 10.3 0 0 0 5-1.3'],
|
|
1282
|
+
sliders: ['M4 7h16M4 17h16M8 4v6M16 14v6'], spark: ['M12 3l2.4 6.6L21 12l-6.6 2.4L12 21l-2.4-6.6L3 12l6.6-2.4z'],
|
|
1283
|
+
film: ['M4 4h16v16H4zM8 4v16M16 4v16M4 8h4M4 16h4M16 8h4M16 16h4'],
|
|
1284
|
+
chip: ['M7 7h10v10H7zM9 3v4M15 3v4M9 17v4M15 17v4M3 9h4M3 15h4M17 9h4M17 15h4'],
|
|
1285
|
+
dots: ['M5 12h.01M12 12h.01M19 12h.01'], key: ['M14 7a5 5 0 1 1-10 0 5 5 0 0 1 10 0M13 10l8 8M17 14l-2 2M20 17l-2 2'],
|
|
1286
|
+
}
|
|
1287
|
+
return h('svg', { 'data-bd-ico': name, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true },
|
|
1288
|
+
...(paths[name] || paths.list).map((d, i) => h('path', { key: i, d })))
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
function SettingsAvatar() {
|
|
1292
|
+
const [imgOk, setImgOk] = React.useState(true)
|
|
1293
|
+
if (!imgOk) return h(PetIcon, { name: 'sliders' })
|
|
1294
|
+
return h('div', { 'data-bd-settings-mark': '', 'aria-hidden': true },
|
|
1295
|
+
h('img', { 'data-bd-settings-avatar': '', src: PET_AVATAR_URL, alt: '', draggable: false, onError: () => setImgOk(false) }))
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
/* ⭐ 设置大弹窗(2026-10-26 桌宠化改造):从桌宠 ⚙️ 进来的居中弹窗。
|
|
1299
|
+
* 九项侧栏,运行方式与日志各有内部分段;图库、学习与视频按所在页读取;
|
|
1300
|
+
* 端点与保存语义与旧设置页一致(/config、/feiyu、/pv、/login/*、/install*、/run)。
|
|
1301
|
+
* ⭐ 2026-10-27:旧的全页 Panel 已退役删掉 —— 它独有过的活动流水、出手节奏读数、
|
|
1302
|
+
* 判重账本概览、一键诊断都搬进了这张弹窗;弹窗现在是设置的**唯一**入口,
|
|
1303
|
+
* 回归断言由 devtools/render-modal.cjs 盯(debugParts 只剩 PetSettingsModal / PetRoot / PetFish)。 */
|
|
1304
|
+
function PetOtherSettings({ view = 'environment', st, setSt, busy, setBusy, say, patch, login, loginState, loginTone, onLoginStart, onInstall, onCheck, onInstallAsr, copyText, sw }) {
|
|
1305
|
+
const cfg = (st && st.config) || {}
|
|
1306
|
+
const [asrModelDraft, setAsrModelDraft] = React.useState(null)
|
|
1307
|
+
const [pythonPathDraft, setPythonPathDraft] = React.useState(null)
|
|
1308
|
+
const [mirrorDirDraft, setMirrorDirDraft] = React.useState(null)
|
|
1309
|
+
const [learnState, setLearnState] = React.useState('pending')
|
|
1310
|
+
const [learnKind, setLearnKind] = React.useState('')
|
|
1311
|
+
const [learnFilter, setLearnFilter] = React.useState('')
|
|
1312
|
+
const [learnOffset, setLearnOffset] = React.useState(0)
|
|
1313
|
+
const [learnQ, setLearnQ] = React.useState(null)
|
|
1314
|
+
const [learnError, setLearnError] = React.useState('')
|
|
1315
|
+
const [learnBusy, setLearnBusy] = React.useState({})
|
|
1316
|
+
const [learnRefresh, setLearnRefresh] = React.useState(0)
|
|
1317
|
+
const [mirrorNote, setMirrorNote] = React.useState('')
|
|
1318
|
+
const [soulBusy, setSoulBusy] = React.useState('')
|
|
1319
|
+
const alive = React.useRef(true)
|
|
1320
|
+
const learnInFlight = React.useRef(new Set())
|
|
1321
|
+
const learnPulling = React.useRef(false)
|
|
1322
|
+
const learnRequest = React.useRef(0)
|
|
1323
|
+
const [reviewModelDraft, setReviewModelDraft] = React.useState(null)
|
|
1324
|
+
const [reviewBatchDraft, setReviewBatchDraft] = React.useState(null)
|
|
1325
|
+
const [reviewMinutesDraft, setReviewMinutesDraft] = React.useState(null)
|
|
1326
|
+
const [reviewBudgetDraft, setReviewBudgetDraft] = React.useState(null)
|
|
1327
|
+
const learnViewRef = React.useRef(null)
|
|
1328
|
+
|
|
1329
|
+
React.useEffect(() => {
|
|
1330
|
+
if (asrModelDraft === null) setAsrModelDraft(String(cfg.asrModel || 'small'))
|
|
1331
|
+
if (pythonPathDraft === null) setPythonPathDraft(String(cfg.pythonPath || 'python'))
|
|
1332
|
+
if (mirrorDirDraft === null) setMirrorDirDraft(String(cfg.mirrorDir || ''))
|
|
1333
|
+
if (reviewModelDraft === null) setReviewModelDraft(String(cfg.learnReviewModelId || ''))
|
|
1334
|
+
if (reviewBatchDraft === null) setReviewBatchDraft(String(cfg.learnReviewBatchSize ?? 20))
|
|
1335
|
+
if (reviewMinutesDraft === null) setReviewMinutesDraft(String(cfg.learnReviewMinutes ?? 10))
|
|
1336
|
+
if (reviewBudgetDraft === null) setReviewBudgetDraft(String(cfg.learnReviewTokenBudget ?? 12000))
|
|
1337
|
+
}, [cfg, asrModelDraft, pythonPathDraft, mirrorDirDraft, reviewModelDraft, reviewBatchDraft, reviewMinutesDraft, reviewBudgetDraft])
|
|
1338
|
+
|
|
1339
|
+
React.useEffect(() => {
|
|
1340
|
+
alive.current = true
|
|
1341
|
+
return () => {
|
|
1342
|
+
alive.current = false
|
|
1343
|
+
learnRequest.current += 1
|
|
1344
|
+
learnInFlight.current.clear()
|
|
1345
|
+
}
|
|
1346
|
+
}, [])
|
|
1347
|
+
|
|
1348
|
+
const learnView = {
|
|
1349
|
+
state: learnState,
|
|
1350
|
+
kind: learnKind,
|
|
1351
|
+
filter: learnFilter,
|
|
1352
|
+
limit: 20,
|
|
1353
|
+
offset: learnOffset,
|
|
1354
|
+
}
|
|
1355
|
+
learnViewRef.current = learnView
|
|
1356
|
+
|
|
1357
|
+
React.useEffect(() => {
|
|
1358
|
+
if (view !== 'learning') return
|
|
1359
|
+
let dead = false
|
|
1360
|
+
let timer = null
|
|
1361
|
+
const queryView = { ...learnView }
|
|
1362
|
+
const request = async () => {
|
|
1363
|
+
if (dead || !alive.current || learnInFlight.current.size || learnPulling.current) return
|
|
1364
|
+
const seq = ++learnRequest.current
|
|
1365
|
+
learnPulling.current = true
|
|
1366
|
+
const query = '?state=' + encodeURIComponent(queryView.state)
|
|
1367
|
+
+ '&kind=' + encodeURIComponent(queryView.kind)
|
|
1368
|
+
+ '&filter=' + encodeURIComponent(queryView.filter)
|
|
1369
|
+
+ '&limit=' + queryView.limit + '&offset=' + queryView.offset
|
|
1370
|
+
try {
|
|
1371
|
+
const r = await jget('/learn' + query)
|
|
1372
|
+
if (!r || r.ok === false) throw new Error((r && r.error) || '读学习记录失败')
|
|
1373
|
+
const current = learnViewRef.current || {}
|
|
1374
|
+
const same = current.state === queryView.state && current.kind === queryView.kind
|
|
1375
|
+
&& current.filter === queryView.filter && Number(current.offset) === Number(queryView.offset)
|
|
1376
|
+
if (!dead && alive.current && seq === learnRequest.current && same && !learnInFlight.current.size) {
|
|
1377
|
+
setLearnQ(r)
|
|
1378
|
+
setLearnError('')
|
|
1379
|
+
}
|
|
1380
|
+
} catch (e) {
|
|
1381
|
+
if (!dead && alive.current && seq === learnRequest.current) setLearnError(String(e && e.message || e))
|
|
1382
|
+
} finally {
|
|
1383
|
+
learnPulling.current = false
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
request()
|
|
1387
|
+
timer = setInterval(request, 15000)
|
|
1388
|
+
return () => {
|
|
1389
|
+
dead = true
|
|
1390
|
+
learnRequest.current += 1
|
|
1391
|
+
if (timer) clearInterval(timer)
|
|
1392
|
+
}
|
|
1393
|
+
}, [view, learnState, learnKind, learnFilter, learnOffset, learnRefresh])
|
|
1394
|
+
|
|
1395
|
+
const icon = (name) => h(PetIcon, { name })
|
|
1396
|
+
const sectionHead = (_name, title, extra) => h('div', { 'data-bd-h': '' }, h('b', {}, title), extra || null)
|
|
1397
|
+
const safeSay = (text, tone) => { if (typeof say === 'function') say(String(text || ''), tone) }
|
|
1398
|
+
const check = (r, fallback) => {
|
|
1399
|
+
if (!r || r.ok === false) throw new Error((r && r.error) || fallback)
|
|
1400
|
+
return r
|
|
1401
|
+
}
|
|
1402
|
+
const readSwitch = (key) => typeof sw === 'function' ? Boolean(sw(key)) : Boolean(cfg[key])
|
|
1403
|
+
const formatTime = (value) => {
|
|
1404
|
+
if (!value) return ''
|
|
1405
|
+
try {
|
|
1406
|
+
const d = new Date(value)
|
|
1407
|
+
if (Number.isNaN(d.getTime())) return String(value)
|
|
1408
|
+
const p = (n) => String(n).padStart(2, '0')
|
|
1409
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
|
|
1410
|
+
} catch { return String(value) }
|
|
1411
|
+
}
|
|
1412
|
+
const switchControl = (on, disabled, onChange) => h('button', {
|
|
1413
|
+
'data-bd-sw': '', 'data-on': on ? '' : undefined, disabled: Boolean(disabled),
|
|
1414
|
+
type: 'button', 'aria-pressed': Boolean(on), onClick: () => onChange(!on),
|
|
1415
|
+
}, h('i', {}))
|
|
1416
|
+
const saveConfig = async (values, label) => {
|
|
1417
|
+
try {
|
|
1418
|
+
const result = await patch(values, label)
|
|
1419
|
+
if (result === false) throw new Error(label + '没有保存成功')
|
|
1420
|
+
return true
|
|
1421
|
+
} catch (e) {
|
|
1422
|
+
safeSay(String(e && e.message || e), 'warn')
|
|
1423
|
+
return false
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
const clearSoul = async (section) => {
|
|
1428
|
+
const label = (SOUL_SECTION_LABELS || []).find((x) => x[0] === section)
|
|
1429
|
+
const title = label ? label[1] : section
|
|
1430
|
+
if (typeof window !== 'undefined' && typeof window.confirm === 'function'
|
|
1431
|
+
&& !window.confirm(`清空「${title}」这一节?\n\n只清空这一节,其他内容保留。`)) return
|
|
1432
|
+
if (soulBusy) return
|
|
1433
|
+
setSoulBusy(section)
|
|
1434
|
+
setBusy('soul')
|
|
1435
|
+
try {
|
|
1436
|
+
const r = check(await jpost('/soul', { section }), '清除 soul 失败')
|
|
1437
|
+
if (alive.current) {
|
|
1438
|
+
setSt((old) => ({ ...old, soul: r }))
|
|
1439
|
+
safeSay(`「${title}」已清空`, 'good')
|
|
1440
|
+
}
|
|
1441
|
+
} catch (e) {
|
|
1442
|
+
if (alive.current) safeSay(String(e && e.message || e), 'warn')
|
|
1443
|
+
} finally {
|
|
1444
|
+
if (alive.current) { setSoulBusy(''); setBusy('') }
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
const sameLearnView = (view) => {
|
|
1449
|
+
const now = learnViewRef.current || {}
|
|
1450
|
+
return now.state === view.state && now.kind === view.kind && now.filter === view.filter
|
|
1451
|
+
&& Number(now.offset) === Number(view.offset)
|
|
1452
|
+
}
|
|
1453
|
+
const decideLearn = async (id, decision) => {
|
|
1454
|
+
const key = String(id || '')
|
|
1455
|
+
if (!key || learnInFlight.current.has(key)) return
|
|
1456
|
+
const view = { ...(learnViewRef.current || learnView) }
|
|
1457
|
+
learnInFlight.current.add(key)
|
|
1458
|
+
setLearnBusy((old) => ({ ...old, [key]: true }))
|
|
1459
|
+
try {
|
|
1460
|
+
const r = check(await jpost('/learn', { decision, id: key, ...view }), '学习队列操作失败')
|
|
1461
|
+
if (!alive.current) return
|
|
1462
|
+
if (sameLearnView(view)) setLearnQ(r)
|
|
1463
|
+
setLearnError('')
|
|
1464
|
+
const text = decision === 'approve' ? '已批准,这条内容会作为后续参考'
|
|
1465
|
+
: decision === 'reject' ? '已拒绝,记录仍保留在审计历史中' : '已忘掉,这条学习已撤销'
|
|
1466
|
+
safeSay(text, 'good')
|
|
1467
|
+
} catch (e) {
|
|
1468
|
+
if (alive.current) { setLearnError(String(e && e.message || e)); safeSay(String(e && e.message || e), 'warn') }
|
|
1469
|
+
} finally {
|
|
1470
|
+
learnInFlight.current.delete(key)
|
|
1471
|
+
if (alive.current) {
|
|
1472
|
+
setLearnBusy((old) => { const next = { ...old }; delete next[key]; return next })
|
|
1473
|
+
setLearnRefresh((n) => n + 1)
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
const syncMirror = async () => {
|
|
1479
|
+
setBusy('mirror')
|
|
1480
|
+
try {
|
|
1481
|
+
const r = check(await jpost('/mirror', {}), '记录镜像同步失败')
|
|
1482
|
+
const m = r.mirror || {}
|
|
1483
|
+
const copied = Array.isArray(m.copied) ? m.copied : []
|
|
1484
|
+
setMirrorNote(copied.length ? `已同步 ${copied.length} 个文件:${copied.join('、')}` : '没有变化,不需要重复复制')
|
|
1485
|
+
if (alive.current && m && typeof m === 'object') setSt((old) => ({ ...old, mirror: { ...(old.mirror || {}), ...m } }))
|
|
1486
|
+
} catch (e) {
|
|
1487
|
+
setMirrorNote('同步失败:' + String(e && e.message || e))
|
|
1488
|
+
} finally { setBusy('') }
|
|
1489
|
+
}
|
|
1490
|
+
const openMirror = async () => {
|
|
1491
|
+
setBusy('mirror-open')
|
|
1492
|
+
try {
|
|
1493
|
+
const r = check(await jpost('/open', { what: 'mirror' }), '打开记录镜像失败')
|
|
1494
|
+
safeSay('已请求系统打开记录镜像目录:' + (r.opened || r.dir || ''), 'good')
|
|
1495
|
+
} catch (e) { safeSay(String(e && e.message || e), 'warn') }
|
|
1496
|
+
finally { setBusy('') }
|
|
1497
|
+
}
|
|
1498
|
+
const openFolder = async (what, label) => {
|
|
1499
|
+
setBusy('open-' + what)
|
|
1500
|
+
try {
|
|
1501
|
+
const r = check(await jpost('/open', { what }), '打开' + label + '失败')
|
|
1502
|
+
safeSay('已请求系统打开' + label + ':' + (r.opened || r.dir || ''), 'good')
|
|
1503
|
+
} catch (e) { safeSay(String(e && e.message || e), 'warn') }
|
|
1504
|
+
finally { setBusy('') }
|
|
1505
|
+
}
|
|
1506
|
+
const verifySpeech = async () => {
|
|
1507
|
+
setBusy('verify')
|
|
1508
|
+
try {
|
|
1509
|
+
const r = check(await jpost('/verify', {}), '复核发言失败')
|
|
1510
|
+
const rows = Array.isArray(r.checked) ? r.checked : []
|
|
1511
|
+
const visible = rows.filter((x) => x && x.visible === true).length
|
|
1512
|
+
safeSay(rows.length ? `复核 ${rows.length} 条发言:公开可见 ${visible} 条,其余可能仍在审核` : (r.text || '没有需要复核的发言'), rows.length && !visible ? 'warn' : 'good')
|
|
1513
|
+
} catch (e) { safeSay(String(e && e.message || e), 'warn') }
|
|
1514
|
+
finally { setBusy('') }
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
/* 🩺 一键诊断(2026-10-27):把版本/环境/最近错误/日志尾部拼成一段文本复制走。
|
|
1518
|
+
* 出问题让使用者点一下、把剪贴板贴过来 —— 比教人翻 duty.log 有用一个量级。 */
|
|
1519
|
+
const onDiag = async () => {
|
|
1520
|
+
setBusy('diag')
|
|
1521
|
+
try {
|
|
1522
|
+
const r = check(await jget('/diag'), '诊断包没取到')
|
|
1523
|
+
const text = String(r.text || '')
|
|
1524
|
+
try {
|
|
1525
|
+
await navigator.clipboard.writeText(text)
|
|
1526
|
+
safeSay(`诊断包已复制到剪贴板(${text.length} 字)—— 直接去粘贴就行;凭证不在里面,可以放心贴`, 'good')
|
|
1527
|
+
} catch {
|
|
1528
|
+
try { console.log('[dsh-deepmoyu] 诊断包\n' + text) } catch { /* 无所谓 */ }
|
|
1529
|
+
safeSay(`剪贴板没复制上 —— 诊断包(${text.length} 字)已经打到浏览器控制台(F12)里,从那儿复制`, 'warn')
|
|
1530
|
+
}
|
|
1531
|
+
} catch (e) { safeSay(String(e && e.message || e), 'warn') }
|
|
1532
|
+
finally { setBusy('') }
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
const so = st && st.soul
|
|
1536
|
+
const filled = so && Array.isArray(so.filled) ? so.filled : []
|
|
1537
|
+
const soulCard = h('details', { 'data-bd-sec': '', 'data-bd-span': '2', 'data-bd-memory': '' },
|
|
1538
|
+
h('summary', {}, 'soul · 她自己的四节账'),
|
|
1539
|
+
h('div', { 'data-bd-note': '' }, so
|
|
1540
|
+
? (so.empty ? '这本账目前是空的,由鲸鱼摸鱼自己在冲浪过程中记录。这里只提供逐节清除。'
|
|
1541
|
+
: `${Number(so.chars) || 0}/${Number(so.budget) || 900} 字${so.updatedAt ? ',上次更新 ' + formatTime(so.updatedAt) : ''}`)
|
|
1542
|
+
: '正在读取 soul 状态…'),
|
|
1543
|
+
so && so.text ? h('pre', { 'data-bd-pre': '', 'data-bd-soul': '' }, String(so.text)) : null,
|
|
1544
|
+
h('div', { 'data-bd-pet-grid': '' },
|
|
1545
|
+
...(SOUL_SECTION_LABELS || []).map(([key, label]) => h('div', { key, 'data-bd-sec': '' },
|
|
1546
|
+
h('div', { 'data-bd-row': '' },
|
|
1547
|
+
h('span', { 'data-bd-label': '', 'data-bd-grow': '' }, label),
|
|
1548
|
+
h('span', { 'data-bd-badge': '', 'data-live': filled.includes(key) ? '' : undefined }, filled.includes(key) ? '有记录' : '空')),
|
|
1549
|
+
h('button', {
|
|
1550
|
+
'data-bd-btn': '', 'data-sm': '', disabled: Boolean(busy) || soulBusy === key || !filled.includes(key),
|
|
1551
|
+
onClick: () => clearSoul(key),
|
|
1552
|
+
}, icon('spark'), soulBusy === key ? '清除中…' : '清除这一节')))),
|
|
1553
|
+
so && so.file ? h('div', { 'data-bd-note': '' }, '文件:' + String(so.file)) : null
|
|
1554
|
+
)
|
|
1555
|
+
|
|
1556
|
+
const learnInfo = (st && st.learn) || {}
|
|
1557
|
+
const kinds = Array.isArray(learnInfo.kinds) ? learnInfo.kinds : []
|
|
1558
|
+
const labels = learnInfo.labels && typeof learnInfo.labels === 'object' ? learnInfo.labels : {}
|
|
1559
|
+
const q = learnQ && sameLearnView(learnView) ? learnQ : null
|
|
1560
|
+
const items = q && Array.isArray(q.items) ? q.items : []
|
|
1561
|
+
const sourceLinks = (one) => {
|
|
1562
|
+
const raw = []
|
|
1563
|
+
if (Array.isArray(one && one.sources)) raw.push(...one.sources)
|
|
1564
|
+
if (one && one.from) raw.push(String(one.from))
|
|
1565
|
+
const found = []
|
|
1566
|
+
for (const text of raw) {
|
|
1567
|
+
for (const bvid of String(text || '').match(/BV[0-9A-Za-z]{10}/g) || []) if (!found.includes(bvid)) found.push(bvid)
|
|
1568
|
+
}
|
|
1569
|
+
return found
|
|
1570
|
+
}
|
|
1571
|
+
const learnRows = q && items.length ? items.map((one) => {
|
|
1572
|
+
const stateLabel = ({ pending: '待审', approved: '已批准', rejected: '已拒绝' })[one.state] || one.state || ''
|
|
1573
|
+
const links = sourceLinks(one)
|
|
1574
|
+
const canApprove = one.state !== 'approved'
|
|
1575
|
+
const canReject = one.state !== 'rejected'
|
|
1576
|
+
const pending = Boolean(learnBusy[one.id]) || Boolean(busy)
|
|
1577
|
+
return h('div', { key: one.id, 'data-bd-sec': '', 'data-bd-learn-id': one.id },
|
|
1578
|
+
h('div', { 'data-bd-row': '' },
|
|
1579
|
+
h('span', { 'data-bd-badge': '' }, String(one.label || labels[one.kind] || one.kind || '未分类')),
|
|
1580
|
+
h('span', { 'data-bd-badge': '', 'data-live': one.state === 'approved' ? '' : undefined }, stateLabel),
|
|
1581
|
+
one.decidedAt ? h('span', { 'data-bd-note': '' }, '决定于 ' + formatTime(one.decidedAt)) : null,
|
|
1582
|
+
learnBusy[one.id] ? h('span', { 'data-bd-note': '', role: 'status' }, '处理中…') : null),
|
|
1583
|
+
h('div', { 'data-bd-label': '' }, String(one.text || '')),
|
|
1584
|
+
one.example ? h('blockquote', { 'data-bd-example': '' },
|
|
1585
|
+
h('span', { 'data-bd-note': '' }, '参考例句(外部数据)'), h('div', {}, String(one.example))) : null,
|
|
1586
|
+
one.from ? h('div', { 'data-bd-note': '', 'data-bd-source': '' },
|
|
1587
|
+
'来源:' + String(one.from),
|
|
1588
|
+
...links.map((bvid) => h('a', { key: bvid, href: 'https://www.bilibili.com/video/' + bvid, target: '_blank', rel: 'noopener noreferrer' }, '打开 ' + bvid))) : null,
|
|
1589
|
+
one.why ? h('div', { 'data-bd-note': '' }, '决定理由:' + String(one.why)) : null,
|
|
1590
|
+
h('div', { 'data-bd-note': '' }, `审计:记录于 ${formatTime(one.at) || '未知'} · 版本 ${Number(one.version) || 1}`),
|
|
1591
|
+
h('div', { 'data-bd-row': '' },
|
|
1592
|
+
canApprove ? h('button', { 'data-bd-btn': '', 'data-sm': '', 'data-primary': '', disabled: pending, onClick: () => decideLearn(one.id, 'approve') }, icon('spark'), one.state === 'rejected' ? '重新批准' : '批准') : null,
|
|
1593
|
+
canReject ? h('button', { 'data-bd-btn': '', 'data-sm': '', disabled: pending, onClick: () => decideLearn(one.id, 'reject') }, icon('dots'), one.state === 'approved' ? '改为拒绝' : '拒绝') : null,
|
|
1594
|
+
h('button', { 'data-bd-btn': '', 'data-sm': '', disabled: pending, onClick: () => decideLearn(one.id, 'forget') }, icon('gear'), '忘记')))
|
|
1595
|
+
}) : null
|
|
1596
|
+
const learnCard = h('section', { 'data-bd-sec': '', 'data-bd-span': '2' },
|
|
1597
|
+
h('div', { 'data-bd-row': '' }, sectionHead('list', '学习队列'),
|
|
1598
|
+
h('span', { 'data-bd-note': '', 'data-bd-grow': '' }, `待审 ${Number(learnInfo.pending) || 0} · 已批准 ${Number(learnInfo.approved) || 0} · 已拒绝 ${Number(learnInfo.rejected) || 0}`)),
|
|
1599
|
+
/* 判重账本概览(2026-10-27 从旧设置页搬进来):「刷过不再刷、回过不再回」记了多少得有地方看 */
|
|
1600
|
+
(() => {
|
|
1601
|
+
const sn = st && st.seen
|
|
1602
|
+
if (!sn) return null
|
|
1603
|
+
return h('div', { 'data-bd-note': '' },
|
|
1604
|
+
`判重账本:刷过 ${Number(sn.videos) || 0} 个视频(今天新记 ${Number(sn.videosToday) || 0} 个)· `
|
|
1605
|
+
+ `回过 ${Number(sn.replied) || 0} 条评论 —— 同一个不会再来一遍`)
|
|
1606
|
+
})(),
|
|
1607
|
+
h('div', { 'data-bd-note': '' }, '批准后才会注入后续行为;例句、来源、决定理由和版本都保留,忘记只撤销当前记录并留下审计痕迹。'),
|
|
1608
|
+
h('div', { 'data-bd-row': '' },
|
|
1609
|
+
...[['pending', '待审'], ['approved', '已批准'], ['rejected', '已拒绝'], ['all', '全部']].map(([value, label]) => h('button', {
|
|
1610
|
+
key: value, 'data-bd-btn': '', 'data-sm': '', 'data-primary': learnState === value ? '' : undefined,
|
|
1611
|
+
onClick: () => { setLearnState(value); setLearnOffset(0); setLearnQ(null) },
|
|
1612
|
+
}, label)),
|
|
1613
|
+
h('select', { 'data-bd-input': '', style: { width: '130px', flex: '0 1 130px' }, value: learnKind, onChange: (e) => { setLearnKind(e.target.value); setLearnOffset(0); setLearnQ(null) } },
|
|
1614
|
+
h('option', { value: '' }, '全部类别'), ...kinds.map((kind) => h('option', { key: kind, value: kind }, labels[kind] || kind)))),
|
|
1615
|
+
h('input', {
|
|
1616
|
+
'data-bd-input': '', value: learnFilter, maxLength: 160, placeholder: '筛正文、例句、来源或决定理由',
|
|
1617
|
+
'aria-label': '筛选学习记录', onChange: (e) => { setLearnFilter(e.target.value); setLearnOffset(0); setLearnQ(null) },
|
|
1618
|
+
}),
|
|
1619
|
+
learnError ? h('div', { 'data-bd-msg': '', 'data-warn': '' }, '学习记录读取失败:' + learnError) : null,
|
|
1620
|
+
!q ? h('div', { 'data-bd-list-empty': '', role: 'status' }, '正在读取学习记录…')
|
|
1621
|
+
: !items.length ? h('div', { 'data-bd-list-empty': '' }, learnFilter ? '没有匹配的学习记录' : '这个状态还没有学习记录')
|
|
1622
|
+
: h('div', { 'data-bd-learn-list': '', tabIndex: 0, 'aria-label': '学习记录列表' }, ...learnRows),
|
|
1623
|
+
q ? h('div', { 'data-bd-row' : '' },
|
|
1624
|
+
h('span', { 'data-bd-note': '', 'data-bd-grow': '' }, `显示 ${items.length ? learnOffset + 1 : 0}–${learnOffset + items.length} / ${q.matched == null ? items.length : q.matched} 条`),
|
|
1625
|
+
h('button', { 'data-bd-btn': '', 'data-sm': '', disabled: learnOffset <= 0 || Boolean(busy), onClick: () => { setLearnOffset(Math.max(0, learnOffset - 20)); setLearnQ(null) } }, '上一页'),
|
|
1626
|
+
h('button', { 'data-bd-btn': '', 'data-sm': '', disabled: !q.more || Boolean(busy), onClick: () => { setLearnOffset(learnOffset + 20); setLearnQ(null) } }, '下一页')) : null
|
|
1627
|
+
)
|
|
1628
|
+
|
|
1629
|
+
const reviewStatus = (st && st.learnReview) || {}
|
|
1630
|
+
const reviewModels = Array.isArray(st && st.models) ? st.models : []
|
|
1631
|
+
const reviewCard = h('section', { 'data-bd-sec': '', 'data-bd-span': '2' },
|
|
1632
|
+
sectionHead('pulse', '后台自动审核'),
|
|
1633
|
+
h('div', { 'data-bd-row': '', 'data-bd-toggle': '' },
|
|
1634
|
+
h('div', { 'data-bd-grow': '' },
|
|
1635
|
+
h('div', { 'data-bd-label': '' }, '自动审核待审内容'),
|
|
1636
|
+
h('div', { 'data-bd-note': '' }, '由模型逐条判断;证据不足会拒绝或保留待审,不会代替你发 B站 内容。')),
|
|
1637
|
+
switchControl(readSwitch('learnAutoReview'), Boolean(busy), (v) => saveConfig({ learnAutoReview: v }, '自动审核学习队列'))),
|
|
1638
|
+
h('details', { 'data-bd-advanced': '' }, h('summary', {}, '模型与审核参数'), h('div', {},
|
|
1639
|
+
h('div', { 'data-bd-row': '' },
|
|
1640
|
+
h('span', { 'data-bd-label': '', 'data-bd-grow': '' }, '审核模型'),
|
|
1641
|
+
reviewModels.length ? h('select', { 'data-bd-input': '', value: reviewModelDraft === null ? '' : reviewModelDraft, onChange: (e) => setReviewModelDraft(e.target.value) },
|
|
1642
|
+
h('option', { value: '' }, '跟随后台模型'),
|
|
1643
|
+
reviewModelDraft && !reviewModels.some((m) => m.id === reviewModelDraft) ? h('option', { value: reviewModelDraft }, reviewModelDraft + '(清单外)') : null,
|
|
1644
|
+
reviewModels.map((m) => h('option', { key: m.id, value: m.id }, m.label || m.id)))
|
|
1645
|
+
: h('input', { 'data-bd-input': '', value: reviewModelDraft === null ? '' : reviewModelDraft, placeholder: '留空 = 跟随后台模型', onChange: (e) => setReviewModelDraft(e.target.value) })),
|
|
1646
|
+
h('div', { 'data-bd-row': '' },
|
|
1647
|
+
h('label', { 'data-bd-field': '' }, h('span', { 'data-bd-label': '' }, '每批最多'), h('input', { 'data-bd-input': '', type: 'number', min: 1, max: 50, value: reviewBatchDraft ?? '20', onChange: (e) => setReviewBatchDraft(e.target.value) })),
|
|
1648
|
+
h('label', { 'data-bd-field': '' }, h('span', { 'data-bd-label': '' }, '最长分钟'), h('input', { 'data-bd-input': '', type: 'number', min: 1, max: 60, value: reviewMinutesDraft ?? '10', onChange: (e) => setReviewMinutesDraft(e.target.value) })),
|
|
1649
|
+
h('label', { 'data-bd-field': '' }, h('span', { 'data-bd-label': '' }, 'token 预算'), h('input', { 'data-bd-input': '', type: 'number', min: 0, max: 5000000, step: 1000, value: reviewBudgetDraft ?? '12000', onChange: (e) => setReviewBudgetDraft(e.target.value) }))),
|
|
1650
|
+
h('button', { 'data-bd-btn': '', disabled: Boolean(busy), onClick: () => saveConfig({
|
|
1651
|
+
learnReviewModelId: String(reviewModelDraft || '').trim(),
|
|
1652
|
+
learnReviewBatchSize: Math.max(1, Math.min(50, Number(reviewBatchDraft) || 20)),
|
|
1653
|
+
learnReviewMinutes: Math.max(1, Math.min(60, Number(reviewMinutesDraft) || 10)),
|
|
1654
|
+
learnReviewTokenBudget: Math.max(0, Math.min(5000000, Number(reviewBudgetDraft) || 0)),
|
|
1655
|
+
}, '模型审核设置') }, '保存审核设置'))) ,
|
|
1656
|
+
h('div', { 'data-bd-row': '' },
|
|
1657
|
+
h('button', { 'data-bd-btn': '', 'data-primary': '', disabled: Boolean(busy) || Boolean(reviewStatus.running) || !Number(reviewStatus.pending || learnInfo.pending), onClick: async () => {
|
|
1658
|
+
setBusy('learn-review')
|
|
1659
|
+
try {
|
|
1660
|
+
const r = check(await jpost('/learn/review', {}), '启动模型审核失败')
|
|
1661
|
+
if (r.learnReview) setSt((old) => ({ ...old, learnReview: r.learnReview }))
|
|
1662
|
+
safeSay(r.started ? '模型审核已开始,完成后会写入运行日志。' : (r.message || '当前没有待审核内容。'), r.started ? 'good' : '')
|
|
1663
|
+
} catch (e) { safeSay(String(e && e.message || e), 'warn') }
|
|
1664
|
+
finally { setBusy('') }
|
|
1665
|
+
} }, reviewStatus.running ? '审核中…' : '现在审核一批'),
|
|
1666
|
+
reviewStatus.running ? h('button', { 'data-bd-btn': '', disabled: Boolean(busy), onClick: async () => {
|
|
1667
|
+
setBusy('learn-review-stop')
|
|
1668
|
+
try { const r = check(await jpost('/learn/review', { action: 'stop' }), '停止模型审核失败'); if (r.learnReview) setSt((old) => ({ ...old, learnReview: r.learnReview })); safeSay('模型审核已请求停止。', 'good') }
|
|
1669
|
+
catch (e) { safeSay(String(e && e.message || e), 'warn') } finally { setBusy('') }
|
|
1670
|
+
} }, '停止审核') : null),
|
|
1671
|
+
h('div', { 'data-bd-row': '' },
|
|
1672
|
+
h('span', { 'data-bd-badge': '', 'data-live': reviewStatus.running ? '' : undefined }, reviewStatus.running ? '审核中' : reviewStatus.complete ? '本批完成' : reviewStatus.lastError ? '本批未完成' : '待命'),
|
|
1673
|
+
h('span', { 'data-bd-badge': '' }, '全部待审 ' + Number(reviewStatus.pending ?? learnInfo.pending ?? 0)),
|
|
1674
|
+
h('span', { 'data-bd-badge': '' }, '本批批准 ' + Number(reviewStatus.approved || 0)),
|
|
1675
|
+
h('span', { 'data-bd-badge': '' }, '本批拒绝 ' + Number(reviewStatus.rejected || 0)),
|
|
1676
|
+
reviewStatus.remaining != null ? h('span', { 'data-bd-badge': '' }, '本批剩余 ' + Number(reviewStatus.remaining)) : null),
|
|
1677
|
+
reviewStatus.model ? h('div', { 'data-bd-note': '' }, (reviewStatus.running ? '正在使用:' : '最近使用:') + reviewStatus.model) : null,
|
|
1678
|
+
reviewStatus.lastError ? h('div', { 'data-bd-msg': '', 'data-warn': '' }, '审核提示:' + reviewStatus.lastError) : null,
|
|
1679
|
+
!reviewStatus.running && Number(reviewStatus.nextRetryAt) > 0 ? h('div', { 'data-bd-note': '' }, '自动重试最早:' + formatTime(reviewStatus.nextRetryAt) + (reviewStatus.enabled ? '' : '(自动审核未开启)')) : null,
|
|
1680
|
+
h('div', { 'data-bd-note': '' }, reviewStatus.lastFinishedAt ? '上次结束:' + formatTime(reviewStatus.lastFinishedAt) : '尚未运行;打开自动审核后,下一次检查会处理待审队列。'))
|
|
1681
|
+
|
|
1682
|
+
const mirror = (st && st.mirror) || {}
|
|
1683
|
+
const mirrorCard = h('section', { 'data-bd-sec': '' },
|
|
1684
|
+
sectionHead('dots', '记录镜像'),
|
|
1685
|
+
h('div', { 'data-bd-note': '' }, mirror.error ? '镜像出错:' + mirror.error
|
|
1686
|
+
: mirror.dir ? `复制到:${mirror.dir}${mirror.lastAt ? ' · 上次 ' + formatTime(mirror.lastAt) + '(' + ((mirror.lastCopied || []).length) + ' 个)' : ''}`
|
|
1687
|
+
: '还没有可用镜像目录;可以填写目录,或让插件从 out/digest junction 自动推断。'),
|
|
1688
|
+
h('div', { 'data-bd-row': '' },
|
|
1689
|
+
h('input', { 'data-bd-input': '', value: mirrorDirDraft === null ? '' : mirrorDirDraft, placeholder: '留空 = 自动推断镜像目录', onChange: (e) => setMirrorDirDraft(e.target.value) }),
|
|
1690
|
+
h('button', { 'data-bd-btn': '', 'data-sm': '', disabled: Boolean(busy), onClick: () => saveConfig({ mirrorDir: String(mirrorDirDraft || '').trim() }, '镜像目录') }, '保存目录')),
|
|
1691
|
+
h('div', { 'data-bd-row': '' },
|
|
1692
|
+
h('button', { 'data-bd-btn': '', disabled: Boolean(busy) || !mirror.dir, onClick: syncMirror }, icon('dots'), '现在同步'),
|
|
1693
|
+
h('button', { 'data-bd-btn': '', disabled: Boolean(busy) || !mirror.dir, onClick: openMirror }, icon('gear'), '打开目录'),
|
|
1694
|
+
mirror.dir ? h('button', { 'data-bd-btn': '', 'data-sm': '', disabled: Boolean(busy), onClick: () => copyText(mirror.dir) }, '复制路径') : null),
|
|
1695
|
+
mirrorNote ? h('div', { 'data-bd-msg': '' }, mirrorNote) : null,
|
|
1696
|
+
h('div', { 'data-bd-note': '' }, '镜像是给人看的脱敏快照,不会复制登录凭证。'))
|
|
1697
|
+
|
|
1698
|
+
const accountCard = h('section', { 'data-bd-sec': '', 'data-bd-span': '2' },
|
|
1699
|
+
sectionHead('key', '账号与环境'),
|
|
1700
|
+
h('div', { 'data-bd-row': '' },
|
|
1701
|
+
h('span', { 'data-bd-badge': '', 'data-live': st && st.loggedIn ? '' : undefined }, st && st.loggedIn ? 'B站已登录' : 'B站未登录'),
|
|
1702
|
+
h('span', { 'data-bd-badge': '', 'data-live': st && st.venvReady ? '' : undefined }, st && st.venvReady ? '主环境就绪' : '主环境未就绪'),
|
|
1703
|
+
h('span', { 'data-bd-badge': '', 'data-live': st && st.dshCli && st.dshCli.ok ? '' : undefined }, st && st.dshCli && st.dshCli.ok ? 'dsh命令正常' : 'dsh命令待检查'),
|
|
1704
|
+
h('span', { 'data-bd-badge': '' }, 'v' + String((st && st.version) || '?'))),
|
|
1705
|
+
h('div', { 'data-bd-note': '' }, '首次使用先安装主依赖,再扫码登录;测一下会实际检查登录和搜索。'),
|
|
1706
|
+
h('div', { 'data-bd-row': '' },
|
|
1707
|
+
h('button', { 'data-bd-btn': '', disabled: Boolean(busy), onClick: onInstall }, icon('gear'), '装主依赖'),
|
|
1708
|
+
h('button', { 'data-bd-btn': '', disabled: Boolean(busy), onClick: onCheck }, icon('pulse'), '测一下'),
|
|
1709
|
+
h('button', { 'data-bd-btn': '', 'data-primary': '', disabled: Boolean(busy), onClick: onLoginStart }, icon('key'), '扫码登录'),
|
|
1710
|
+
h('button', { 'data-bd-btn': '', disabled: Boolean(busy), title: '把版本/环境/最近错误/日志尾部拼成一段文本复制走 —— 出问题直接把剪贴板贴出来', onClick: onDiag }, icon('dots'), busy === 'diag' ? '打包中…' : '一键诊断')),
|
|
1711
|
+
login ? h('div', { 'data-bd-qr': '' },
|
|
1712
|
+
login.qr ? h('img', { src: login.qr, alt: 'B站扫码登录二维码' }) : h('div', { 'data-bd-msg': '', 'data-warn': '' }, '二维码没有生成,请先安装主依赖。'),
|
|
1713
|
+
h('div', { 'data-bd-grow': '' },
|
|
1714
|
+
h('div', { 'data-bd-note': '' }, '手机 B站打开扫一扫,确认时选择记住我或长期登录。'),
|
|
1715
|
+
h('div', { 'data-bd-msg': '', 'data-warn': loginTone === 'warn' ? '' : undefined, 'data-good': loginTone === 'good' ? '' : undefined }, loginState || '等待扫码…'))) : null,
|
|
1716
|
+
loginState && !login ? h('div', { 'data-bd-msg': '', 'data-warn': loginTone === 'warn' ? '' : undefined, 'data-good': loginTone === 'good' ? '' : undefined }, loginState) : null,
|
|
1717
|
+
st && st.installLog ? h('div', { 'data-bd-row': '' }, h('span', { 'data-bd-note': '', 'data-bd-grow': '' }, '主依赖日志:' + st.installLog), h('button', { 'data-bd-btn': '', 'data-sm': '', onClick: () => copyText(st.installLog) }, '复制路径')) : null,
|
|
1718
|
+
st && st.installTail ? h('pre', { 'data-bd-pre': '' }, String(st.installTail).slice(-1800)) : null)
|
|
1719
|
+
|
|
1720
|
+
const asrCard = h('section', { 'data-bd-sec': '' },
|
|
1721
|
+
sectionHead('gear', '本地转写与 ASR'),
|
|
1722
|
+
h('div', { 'data-bd-row': '', 'data-bd-toggle': '' },
|
|
1723
|
+
h('div', { 'data-bd-grow': '' }, h('div', { 'data-bd-label': '' }, '启用本地转写'), h('div', { 'data-bd-note': '' }, '只有视频没有字幕时才使用,会下载独立环境和 Whisper 模型。')),
|
|
1724
|
+
switchControl(readSwitch('transcribe'), Boolean(busy), (v) => saveConfig({ transcribe: v }, '本地转写'))),
|
|
1725
|
+
h('div', { 'data-bd-row': '' },
|
|
1726
|
+
h('span', { 'data-bd-label': '', 'data-bd-grow': '' }, 'Whisper 模型'),
|
|
1727
|
+
h('select', { 'data-bd-input': '', style: { width: '140px' }, value: asrModelDraft === null ? 'small' : asrModelDraft, onChange: (e) => setAsrModelDraft(e.target.value) },
|
|
1728
|
+
...['tiny', 'base', 'small', 'medium'].map((name) => h('option', { key: name, value: name }, name)))),
|
|
1729
|
+
h('div', { 'data-bd-row': '' },
|
|
1730
|
+
h('span', { 'data-bd-label': '', 'data-bd-grow': '' }, '主 Python 路径'),
|
|
1731
|
+
h('input', { 'data-bd-input': '', value: pythonPathDraft === null ? '' : pythonPathDraft, onChange: (e) => setPythonPathDraft(e.target.value), placeholder: 'python 或 venv/python.exe' })),
|
|
1732
|
+
h('div', { 'data-bd-row': '' },
|
|
1733
|
+
h('button', { 'data-bd-btn': '', disabled: Boolean(busy), onClick: () => saveConfig({ asrModel: String(asrModelDraft || 'small'), pythonPath: String(pythonPathDraft || '').trim() }, 'ASR参数') }, '保存 ASR 参数'),
|
|
1734
|
+
h('button', { 'data-bd-btn': '', 'data-primary': '', disabled: Boolean(busy), onClick: onInstallAsr }, icon('gear'), st && st.asrReady ? '转写已安装' : '安装转写')),
|
|
1735
|
+
h('div', { 'data-bd-row': '' },
|
|
1736
|
+
h('span', { 'data-bd-badge': '', 'data-live': st && st.asrReady ? '' : undefined }, st && st.asrReady ? '转写环境就绪' : '转写环境未就绪'),
|
|
1737
|
+
h('span', { 'data-bd-badge': '', 'data-live': st && st.asrModelReady ? '' : undefined }, st && st.asrModelReady ? '模型已下载' : '模型未下载'),
|
|
1738
|
+
st && st.asrInstalling ? h('span', { 'data-bd-badge': '', 'data-live': '' }, '安装中') : null),
|
|
1739
|
+
st && st.asrLog ? h('div', { 'data-bd-row' : '' }, h('span', { 'data-bd-note': '', 'data-bd-grow': '' }, 'ASR日志:' + st.asrLog), h('button', { 'data-bd-btn': '', 'data-sm': '', onClick: () => copyText(st.asrLog) }, '复制路径')) : null,
|
|
1740
|
+
st && st.asrTail ? h('pre', { 'data-bd-pre': '' }, String(st.asrTail).slice(-1600)) : null)
|
|
1741
|
+
|
|
1742
|
+
const groundCard = h('section', { 'data-bd-sec': '', 'data-bd-span': '2' },
|
|
1743
|
+
sectionHead('pulse', '它的地盘与发言复核'),
|
|
1744
|
+
h('div', { 'data-bd-kv': '' }, '家目录:' + String((st && st.home) || '')),
|
|
1745
|
+
h('div', { 'data-bd-kv': '' }, '报告路径:' + String((st && st.report) || '')),
|
|
1746
|
+
h('div', { 'data-bd-row' : '' },
|
|
1747
|
+
h('button', { 'data-bd-btn': '', disabled: Boolean(busy), onClick: () => openFolder('home', '地盘') }, icon('pulse'), '打开地盘'),
|
|
1748
|
+
h('button', { 'data-bd-btn': '', disabled: Boolean(busy), onClick: () => openFolder('out', '产物目录') }, icon('gear'), '打开产物目录'),
|
|
1749
|
+
st && st.home ? h('button', { 'data-bd-btn': '', 'data-sm': '', disabled: Boolean(busy), onClick: () => copyText(st.home) }, '复制地盘路径') : null,
|
|
1750
|
+
st && st.report ? h('button', { 'data-bd-btn': '', 'data-sm': '', disabled: Boolean(busy), onClick: () => copyText(st.report) }, '复制报告路径') : null),
|
|
1751
|
+
h('div', { 'data-bd-note': '' }, '这里保留的是插件运行地盘、输出路径和发言复核入口;不会打开或改写登录凭证。'))
|
|
1752
|
+
|
|
1753
|
+
return h('div', { 'data-bd-page-content': '', 'data-bd-settings-page': view === 'learning' ? 'learning' : 'other' },
|
|
1754
|
+
h('div', { 'data-bd-pghead': '' }, h('div', {}, h('b', {}, view === 'learning' ? '学习与记忆' : '账号与环境'),
|
|
1755
|
+
h('p', {}, view === 'learning' ? '审核在后台进行。这里查看进度、管理记录与她的小心事。' : '登录、依赖与本地记录。环境状态集中在这里。'))),
|
|
1756
|
+
h('div', { 'data-bd-pet-grid': '', 'data-bd-other': '' },
|
|
1757
|
+
...(view === 'learning' ? [reviewCard, learnCard, soulCard] : [accountCard, asrCard, mirrorCard, groundCard])))
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
|
|
1761
|
+
/* 独立账本:总量只读宿主累计数;任务明细与日图只做展示,不反推总量。 */
|
|
1762
|
+
function PetTokenLedger({ st, budgetDraft, setBudgetDraft, busy, saveBudget, resetTokens }) {
|
|
1763
|
+
const [focus, setFocus] = React.useState(false)
|
|
1764
|
+
const known = (v) => v !== null && v !== undefined && v !== '' && Number.isFinite(Number(v)) && Number(v) >= 0
|
|
1765
|
+
const fmt = (v) => known(v) ? Number(v).toLocaleString('zh-CN') : '—'
|
|
1766
|
+
const total = st.tokensTotal
|
|
1767
|
+
const today = st.tokensToday
|
|
1768
|
+
const cap = Number((st.config || {}).totalTokenBudget ?? st.totalTokenBudget) || 0
|
|
1769
|
+
const ratio = cap > 0 && known(total) ? Math.round(Number(total) / cap * 100) : null
|
|
1770
|
+
const rounds = Array.isArray(st.tokenRounds && st.tokenRounds.entries) ? st.tokenRounds.entries : []
|
|
1771
|
+
const unmetered = (r) => Boolean(r.tokensUnknown) || !r.tokens || !known(r.tokens.total)
|
|
1772
|
+
const unknownCount = rounds.filter(unmetered).length
|
|
1773
|
+
const partial = (r) => !unmetered(r) && Number(r.unknownRequests) > 0
|
|
1774
|
+
const partialCount = rounds.filter(partial).length
|
|
1775
|
+
const background = st.backgroundRun || {}
|
|
1776
|
+
const current = background.active ? background.usage?.total : st.feiyu?.active ? st.feiyu.tokens?.thisRun : undefined
|
|
1777
|
+
const currentLabel = background.active ? (background.task === 'learn-review' ? '本趟审核' : '本趟后台') : st.feiyu?.active ? '本趟会话' : '当前任务'
|
|
1778
|
+
const endLabels = { user: '手动结束', exit: '已结束', error: '错误退出', 'token-budget': '本趟预算用完', 'total-budget': '累计上限已到', 'time-limit': '到达时限', 'spawn-error': '启动失败' }
|
|
1779
|
+
const warnEnds = ['error', 'token-budget', 'total-budget', 'time-limit', 'spawn-error']
|
|
1780
|
+
const cols = []
|
|
1781
|
+
const dayValues = st.tokenDays && typeof st.tokenDays === 'object' ? st.tokenDays : {}
|
|
1782
|
+
for (let i = 13; i >= 0; i--) {
|
|
1783
|
+
const d = new Date()
|
|
1784
|
+
d.setDate(d.getDate() - i)
|
|
1785
|
+
const pad = (v) => String(v).padStart(2, '0')
|
|
1786
|
+
const key = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
|
1787
|
+
cols.push({ key, label: `${pad(d.getMonth() + 1)}-${pad(d.getDate())}`, value: Number(dayValues[key]) || 0 })
|
|
1788
|
+
}
|
|
1789
|
+
const dayMax = Math.max(1, ...cols.map((c) => c.value))
|
|
1790
|
+
const shownRounds = focus ? rounds.slice(0, 4) : rounds
|
|
1791
|
+
const clear = async () => {
|
|
1792
|
+
if (busy || typeof window.confirm !== 'function') return
|
|
1793
|
+
if (!window.confirm('清零累计 token 和任务明细?\n\n累计上限不会改变,此操作不可撤销。')) return
|
|
1794
|
+
await resetTokens()
|
|
1795
|
+
}
|
|
1796
|
+
return h('div', { 'data-bd-page-content': '', 'data-bd-settings-page': 'ledger', 'data-bd-ledger': '', 'data-bd-ledger-focus': focus ? '' : undefined },
|
|
1797
|
+
h('div', { 'data-bd-ledger-heading': '' },
|
|
1798
|
+
h('div', {}, h('span', { 'data-bd-eyebrow': '' }, 'DAFEIYU / TOKEN JOURNAL'),
|
|
1799
|
+
h('h2', {}, 'token 账本'), h('p', {}, '每一趟认真摸鱼,都有据可查。')),
|
|
1800
|
+
h('button', { 'data-bd-btn': '', type: 'button', 'aria-pressed': focus, onClick: () => setFocus((old) => !old) }, focus ? '退出截图视图' : '截图视图')),
|
|
1801
|
+
h('section', { 'data-bd-ledger-summary': '', 'aria-label': 'token 用量总览' },
|
|
1802
|
+
h('div', { 'data-bd-ledger-hero': '' },
|
|
1803
|
+
h('span', { 'data-bd-ledger-label': '' }, '累计用量'),
|
|
1804
|
+
h('div', { 'data-bd-ledger-number': '' }, h('b', { 'data-bd-ledger-total': '' }, fmt(total)), h('span', {}, 'tokens')),
|
|
1805
|
+
h('div', { 'data-bd-ledger-meta': '' },
|
|
1806
|
+
known(total) ? h('span', { 'data-bd-tok-chip': '', title: '称号只是显示,不影响行为' }, '称号 · ' + tokTitle(total)) : null,
|
|
1807
|
+
Number(st.activeDays) > 0 ? h('span', {}, '连着干活 ' + st.activeDays + ' 天') : null),
|
|
1808
|
+
h('p', {}, '已记录的真实用量,不含未计量任务。')),
|
|
1809
|
+
h('div', { 'data-bd-tok-kpis': '' },
|
|
1810
|
+
h('div', {}, h('span', {}, '今日用量'), h('b', {}, fmt(today)), h('small', {}, '本地时间 00:00 起')),
|
|
1811
|
+
h('div', {}, h('span', {}, currentLabel), h('b', {}, fmt(current)), h('small', {}, background.active && Number(background.unknownRequests) > 0 ? '另有 ' + background.unknownRequests + ' 次响应用量缺失' : background.active || st.feiyu?.active ? '正在记录中' : '当前没有运行任务')),
|
|
1812
|
+
h('div', {}, h('span', {}, '累计上限'), h('b', {}, cap > 0 ? fmt(cap) : '不限'), h('small', {}, '已保存的总闸额度'))),
|
|
1813
|
+
cap > 0 ? h('div', { 'data-bd-ledger-budget': '' },
|
|
1814
|
+
h('div', { 'data-bd-row': '' }, h('span', { 'data-bd-grow': '' }, '累计上限进度'), h('b', {}, ratio === null ? '—' : ratio + '%')),
|
|
1815
|
+
h('div', { 'data-bd-tok-bar': '', role: 'progressbar', 'aria-label': '累计 token 上限使用比例', 'aria-valuemin': 0, 'aria-valuemax': 100, 'aria-valuenow': ratio === null ? undefined : Math.min(100, ratio) },
|
|
1816
|
+
h('i', { style: { width: (ratio === null ? 0 : Math.min(100, ratio)) + '%' } })),
|
|
1817
|
+
h('p', {}, '到顶会自动暂停会话刷;后台单轮预算在「后台刷」页单独设置。')) : null),
|
|
1818
|
+
h('section', { 'data-bd-ledger-trend': '', 'aria-label': '最近 14 天 token 用量' },
|
|
1819
|
+
h('div', { 'data-bd-ledger-sectionhead': '' }, h('h3', {}, '最近 14 天'), h('span', {}, '每日已计量用量 · tokens')),
|
|
1820
|
+
cols.some((c) => c.value > 0) ? h('div', { 'data-bd-tok-days': '' }, cols.map((c) => h('div', { key: c.key, 'data-zero': c.value > 0 ? undefined : '', title: `${c.key} · ${fmt(c.value)} tokens` },
|
|
1821
|
+
h('div', { 'data-bd-tok-daytrack': '' }, h('i', { style: { height: Math.max(3, Math.round(c.value / dayMax * 100)) + '%' } })),
|
|
1822
|
+
h('span', {}, c.label)))) : h('div', { 'data-bd-tok-empty': '' }, '最近 14 天还没有已计量用量。')),
|
|
1823
|
+
h('section', { 'data-bd-ledger-tasks': '', 'aria-label': '每趟任务明细' },
|
|
1824
|
+
h('div', { 'data-bd-ledger-sectionhead': '' }, h('h3', {}, '每趟任务'),
|
|
1825
|
+
h('span', {}, `${rounds.length} 条记录${unknownCount ? ' · ' + unknownCount + ' 条未计量' : ''}${partialCount ? ' · ' + partialCount + ' 条部分计量' : ''}`)),
|
|
1826
|
+
h('div', { 'data-bd-tok-list': '' }, shownRounds.length ? shownRounds.map((r) => h('div', { 'data-bd-tokrow': '', key: r.id },
|
|
1827
|
+
h('time', { title: r.startedAt || '' }, petFmtTime(r.startedAt) || '—'),
|
|
1828
|
+
h('div', { 'data-bd-tok-task': '' },
|
|
1829
|
+
h('strong', {}, r.label || '任务'),
|
|
1830
|
+
h('span', { title: r.model || '' }, [r.model || '默认模型', Number(r.rounds) ? r.rounds + ' 轮' : '', Number(r.durationSec) ? fmtDurSec(r.durationSec) : ''].filter(Boolean).join(' · '))),
|
|
1831
|
+
h('div', { 'data-bd-tok-amount': '' },
|
|
1832
|
+
h('em', { 'data-unknown': unmetered(r) ? '' : undefined }, unmetered(r) ? '未计量' : fmt(r.tokens.total)),
|
|
1833
|
+
!unmetered(r) ? h('small', {}, partial(r) ? '部分计量 · 另有 ' + r.unknownRequests + ' 次缺失' : '输入 ' + fmt(r.tokens.input) + ' / 输出 ' + fmt(r.tokens.output)) : h('small', {}, '不按 0 计算')),
|
|
1834
|
+
r.stopReason ? h('i', { 'data-bd-tok-chip': '', 'data-warn': warnEnds.includes(String(r.stopReason)) ? '' : undefined, title: String(r.stopReason) }, endLabels[r.stopReason] || String(r.stopReason)) : null))
|
|
1835
|
+
: h('div', { 'data-bd-tok-empty': '' }, '还没有任务记录 —— 开一次会话刷或后台刷,这里就会记下来。')),
|
|
1836
|
+
focus && rounds.length > shownRounds.length ? h('p', { 'data-bd-note': '' }, '截图视图展示最近 4 条;退出后查看全部 ' + rounds.length + ' 条记录。') : null),
|
|
1837
|
+
h('p', { 'data-bd-ledger-footnote': '' }, '计量说明:累计数只含已知用量,任务明细独立记账。部分计量保留已知数及缺失次数;未计量不等于没有消耗。'),
|
|
1838
|
+
!focus ? h('section', { 'data-bd-ledger-controls': '' },
|
|
1839
|
+
h('div', { 'data-bd-ledger-sectionhead': '' }, h('h3', {}, '预算与计数'), h('span', {}, '仅保存后生效')),
|
|
1840
|
+
h('div', { 'data-bd-row': '' },
|
|
1841
|
+
h('label', { 'data-bd-field': '', 'data-bd-grow': '' }, h('span', { 'data-bd-label': '' }, '累计最多用多少 token(0 = 不限)'),
|
|
1842
|
+
h('input', { 'data-bd-input': '', type: 'number', min: 0, max: 50000000, step: 100000, value: budgetDraft ?? '0', onChange: (e) => setBudgetDraft(e.target.value) })),
|
|
1843
|
+
h('button', { 'data-bd-btn': '', disabled: Boolean(busy), onClick: saveBudget }, '保存上限'),
|
|
1844
|
+
h('button', { 'data-bd-btn': '', 'data-bd-danger': '', disabled: Boolean(busy), title: '累计和任务明细一起清零,上限不动;操作前需确认', onClick: clear }, '清零计数'))) : null)
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
function PetStickerThumb({ item, large = false }) {
|
|
1848
|
+
const [failed, setFailed] = React.useState(false)
|
|
1849
|
+
const url = String(item.imageUrl || '')
|
|
1850
|
+
React.useEffect(() => setFailed(false), [url])
|
|
1851
|
+
return h('div', { 'data-bd-sticker-thumb': '', 'data-large': large ? '' : undefined },
|
|
1852
|
+
!failed && url.startsWith(API + '/stickers/image?')
|
|
1853
|
+
? h('img', { src: url, alt: item.name || '表情', loading: large ? 'eager' : 'lazy', decoding: 'async', onError: () => setFailed(true) })
|
|
1854
|
+
: h('span', {}, failed ? '图片暂时读不到' : '暂无预览'))
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
/* 图库按需读取;不会进入 /status,也不会在打开别的设置页时下载图片。 */
|
|
1858
|
+
function PetStickersPanel({ st, patch, busy, say }) {
|
|
1859
|
+
const cfg = st?.config || {}
|
|
1860
|
+
const [data, setData] = React.useState(null)
|
|
1861
|
+
const [error, setError] = React.useState('')
|
|
1862
|
+
const [working, setWorking] = React.useState('')
|
|
1863
|
+
const [draft, setDraft] = React.useState({})
|
|
1864
|
+
const [query, setQuery] = React.useState('')
|
|
1865
|
+
const [source, setSource] = React.useState('all')
|
|
1866
|
+
const [name, setName] = React.useState('')
|
|
1867
|
+
const [url, setUrl] = React.useState('')
|
|
1868
|
+
const [description, setDescription] = React.useState('')
|
|
1869
|
+
const [selected, setSelected] = React.useState('')
|
|
1870
|
+
const [uploadFile, setUploadFile] = React.useState(null)
|
|
1871
|
+
const [uploadName, setUploadName] = React.useState('')
|
|
1872
|
+
const [uploadDescription, setUploadDescription] = React.useState('')
|
|
1873
|
+
const [uploadPreview, setUploadPreview] = React.useState('')
|
|
1874
|
+
const [uploadError, setUploadError] = React.useState('')
|
|
1875
|
+
const [routeMissing, setRouteMissing] = React.useState(false)
|
|
1876
|
+
const fileInput = React.useRef(null)
|
|
1877
|
+
const fileReader = React.useRef(null)
|
|
1878
|
+
const fileVersion = React.useRef(0)
|
|
1879
|
+
const routeMissingRef = React.useRef(false)
|
|
1880
|
+
const alive = React.useRef(true)
|
|
1881
|
+
const reading = React.useRef(false)
|
|
1882
|
+
const writing = React.useRef(false)
|
|
1883
|
+
const [loading, setLoading] = React.useState(false)
|
|
1884
|
+
const defaults = { stickerCollectProbability: 35, stickerCollectCooldown: 60, stickerSendProbability: 30, stickerSendCooldown: 180, stickerCapacity: 60 }
|
|
1885
|
+
const field = (key) => draft[key] ?? cfg[key] ?? data?.settings?.[key] ?? defaults[key]
|
|
1886
|
+
const pull = async () => {
|
|
1887
|
+
if (reading.current || writing.current) return
|
|
1888
|
+
reading.current = true; setLoading(true)
|
|
1889
|
+
try {
|
|
1890
|
+
const r = await jget('/stickers')
|
|
1891
|
+
if (!r?.ok) throw new Error(r?.error || '图库暂时读不到')
|
|
1892
|
+
if (alive.current) { setData(r); setError(''); routeMissingRef.current = false; setRouteMissing(false) }
|
|
1893
|
+
} catch (e) {
|
|
1894
|
+
if (alive.current) {
|
|
1895
|
+
const missing = e?.status === 404 || /^HTTP 404\b/.test(String(e?.message || ''))
|
|
1896
|
+
routeMissingRef.current = missing; setRouteMissing(missing)
|
|
1897
|
+
setError(missing ? '当前 DSH 后端尚未加载图库接口。请完全退出并重新打开 DSH,再点「重试」;只刷新页面不会加载后端。' : String(e?.message ?? e))
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
finally { reading.current = false; if (alive.current) setLoading(false) }
|
|
1901
|
+
}
|
|
1902
|
+
React.useEffect(() => {
|
|
1903
|
+
alive.current = true
|
|
1904
|
+
pull()
|
|
1905
|
+
const timer = setInterval(() => { if (!document.hidden && !routeMissingRef.current) pull() }, 20000)
|
|
1906
|
+
return () => { alive.current = false; clearInterval(timer); fileVersion.current++; fileReader.current?.abort?.() }
|
|
1907
|
+
}, [])
|
|
1908
|
+
const integer = (key, min, max) => {
|
|
1909
|
+
const v = field(key)
|
|
1910
|
+
const n = Number(v)
|
|
1911
|
+
if (String(v ?? '').trim() === '' || !Number.isSafeInteger(n) || n < min || n > max) throw new Error('请填写 ' + min + '–' + max + ' 之间的整数')
|
|
1912
|
+
return n
|
|
1913
|
+
}
|
|
1914
|
+
const save = async (kind) => {
|
|
1915
|
+
try {
|
|
1916
|
+
const p = kind === 'collect' ? {
|
|
1917
|
+
stickerCollectProbability: integer('stickerCollectProbability', 0, 100), stickerCollectCooldown: integer('stickerCollectCooldown', 0, 86400), stickerCapacity: integer('stickerCapacity', 1, 60),
|
|
1918
|
+
} : { stickerSendProbability: integer('stickerSendProbability', 0, 100), stickerSendCooldown: integer('stickerSendCooldown', 0, 86400) }
|
|
1919
|
+
if (await patch(p, kind === 'collect' ? '自动收集参数' : '自动配图参数')) setDraft((old) => Object.fromEntries(Object.entries(old).filter(([key]) => !(key in p))))
|
|
1920
|
+
} catch (e) { say(String(e?.message ?? e), 'warn') }
|
|
1921
|
+
}
|
|
1922
|
+
const clearUpload = () => {
|
|
1923
|
+
fileVersion.current++; fileReader.current?.abort?.(); fileReader.current = null
|
|
1924
|
+
setUploadFile(null); setUploadPreview(''); setUploadError('')
|
|
1925
|
+
if (fileInput.current) fileInput.current.value = ''
|
|
1926
|
+
}
|
|
1927
|
+
const chooseFile = (event) => {
|
|
1928
|
+
const file = event.target.files?.[0]
|
|
1929
|
+
if (!file) return
|
|
1930
|
+
clearUpload()
|
|
1931
|
+
if (!file.size || file.size > 5 * 1024 * 1024) { setUploadError('请选择不超过 5 MB 的图片文件。'); return }
|
|
1932
|
+
const mime = String(file.type || '').toLowerCase()
|
|
1933
|
+
if (!(mime ? /^image\/(png|jpeg|gif|webp)$/.test(mime) : /\.(png|jpe?g|gif|webp)$/i.test(file.name))) {
|
|
1934
|
+
setUploadError('支持 PNG、JPEG、GIF、WebP 图片。'); return
|
|
1935
|
+
}
|
|
1936
|
+
const version = fileVersion.current
|
|
1937
|
+
const reader = new FileReader()
|
|
1938
|
+
fileReader.current = reader; setWorking('read-file')
|
|
1939
|
+
reader.onload = () => {
|
|
1940
|
+
if (!alive.current || fileVersion.current !== version) return
|
|
1941
|
+
fileReader.current = null; setWorking('')
|
|
1942
|
+
const result = String(reader.result || '')
|
|
1943
|
+
const comma = result.indexOf(',')
|
|
1944
|
+
if (comma < 0 || !result.slice(0, comma).endsWith(';base64')) { setUploadError('图片读取失败,请重新选择。'); return }
|
|
1945
|
+
setUploadFile({ name: file.name, size: file.size, dataBase64: result.slice(comma + 1) })
|
|
1946
|
+
// Preview only a browser-read image; the host independently checks the actual bytes.
|
|
1947
|
+
setUploadPreview(result)
|
|
1948
|
+
setUploadName((old) => old.trim() ? old : file.name.replace(/\.[^.]+$/, '').slice(0, 40))
|
|
1949
|
+
}
|
|
1950
|
+
reader.onerror = () => {
|
|
1951
|
+
if (alive.current && fileVersion.current === version) { fileReader.current = null; setWorking(''); setUploadError('文件读取失败,请重新选择图片。') }
|
|
1952
|
+
}
|
|
1953
|
+
try { reader.readAsDataURL(file) }
|
|
1954
|
+
catch { fileReader.current = null; setWorking(''); setUploadError('文件读取失败,请重新选择图片。') }
|
|
1955
|
+
}
|
|
1956
|
+
const mutate = async (kind, body) => {
|
|
1957
|
+
if (writing.current || reading.current) return
|
|
1958
|
+
writing.current = true; setWorking(kind)
|
|
1959
|
+
if (kind === 'upload') setUploadError('')
|
|
1960
|
+
try {
|
|
1961
|
+
const r = await jpost('/stickers/' + kind, body)
|
|
1962
|
+
if (!r?.ok) throw new Error(r?.error || '没有完成')
|
|
1963
|
+
if (!alive.current) return
|
|
1964
|
+
say(kind === 'collect' ? '已收进表情库。' : kind === 'upload' ? '已上传到表情库。' : '已从收集区移除。', 'good')
|
|
1965
|
+
if (kind === 'collect') { setName(''); setUrl(''); setDescription('') }
|
|
1966
|
+
if (kind === 'upload') { clearUpload(); setUploadName(''); setUploadDescription(''); setSource('collected'); setQuery(''); setSelected('collected:' + (r.name || body.name)) }
|
|
1967
|
+
if (kind === 'forget' && selected === 'collected:' + body.name) setSelected('')
|
|
1968
|
+
} catch (e) {
|
|
1969
|
+
if (alive.current) {
|
|
1970
|
+
const message = e?.status === 404 ? '当前 DSH 后端尚未加载这个图库功能。请完全退出并重新打开 DSH 后重试。' : String(e?.message ?? e)
|
|
1971
|
+
if (kind === 'upload') setUploadError(message)
|
|
1972
|
+
say(message, 'warn')
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
finally { writing.current = false; if (alive.current) { setWorking(''); pull() } }
|
|
1976
|
+
}
|
|
1977
|
+
const upload = (event) => {
|
|
1978
|
+
event?.preventDefault?.()
|
|
1979
|
+
if (working || !uploadFile) return
|
|
1980
|
+
if (!uploadName.trim()) { setUploadError('请给表情起一个名字。'); return }
|
|
1981
|
+
mutate('upload', { name: uploadName.trim(), filename: uploadFile.name, dataBase64: uploadFile.dataBase64, description: uploadDescription.trim() })
|
|
1982
|
+
}
|
|
1983
|
+
const collect = (event) => {
|
|
1984
|
+
event?.preventDefault?.()
|
|
1985
|
+
if (!name.trim() || !/^https?:\/\//i.test(url.trim())) { say('填一个好叫的名字和 http(s) 图片链接。', 'warn'); return }
|
|
1986
|
+
mutate('collect', { name: name.trim(), url: url.trim(), description: description.trim() })
|
|
1987
|
+
}
|
|
1988
|
+
const forget = (item) => {
|
|
1989
|
+
if (!item.removable || item.source !== 'collected') return
|
|
1990
|
+
if (typeof window.confirm !== 'function' || !window.confirm('从收集区移除「' + item.name + '」?内置表情不会改变。')) return
|
|
1991
|
+
mutate('forget', { name: item.name })
|
|
1992
|
+
}
|
|
1993
|
+
const rows = Array.isArray(data?.stickers) ? data.stickers : []
|
|
1994
|
+
const q = query.trim().toLowerCase()
|
|
1995
|
+
const shown = rows.filter((item) => (source === 'all' || item.source === source) && (!q || [item.name, item.description, ...(Array.isArray(item.tags) ? item.tags : [])].some((v) => String(v || '').toLowerCase().includes(q))))
|
|
1996
|
+
const current = rows.find((item) => item.source + ':' + item.name === selected)
|
|
1997
|
+
const disabled = Boolean(busy || working || loading)
|
|
1998
|
+
const input = (label, key, max, suffix, min = 0) => h('label', { 'data-bd-sticker-number': '' }, h('span', {}, label),
|
|
1999
|
+
h('span', {}, h('input', { 'data-bd-input': '', type: 'number', min, max, step: 1, 'aria-label': label, value: field(key), onChange: (e) => setDraft((old) => ({ ...old, [key]: e.target.value })) }), h('small', {}, suffix)))
|
|
2000
|
+
return h('div', { 'data-bd-page-content': '', 'data-bd-settings-page': 'stickers' },
|
|
2001
|
+
h('div', { 'data-bd-pghead': '' }, h('div', {}, h('b', {}, '表情收藏'), h('p', {}, '逛评论区,遇见喜欢的就收一张。让每次配图都有点自己的意思。'))),
|
|
2002
|
+
h('div', { 'data-bd-pet-grid': '', 'data-bd-sticker-controls': '' },
|
|
2003
|
+
h('section', { 'data-bd-sec': '' }, h('div', { 'data-bd-h': '' }, h('b', {}, '自己收表情')),
|
|
2004
|
+
ToggleRow({ label: '从评论区自动收集', on: Boolean(cfg.stickerAutoCollect), disabled, note: '翻评论时,按概率收取观众附带的图片;同图不重复,库满就停。', onChange: (v) => patch({ stickerAutoCollect: v }, '自动收表情') }),
|
|
2005
|
+
input('收集概率', 'stickerCollectProbability', 100, '%'), input('收集冷却(秒)', 'stickerCollectCooldown', 86400, '秒'), input('收集容量', 'stickerCapacity', 60, '张', 1),
|
|
2006
|
+
h('button', { 'data-bd-btn': '', disabled, onClick: () => save('collect') }, '保存收集参数')),
|
|
2007
|
+
h('section', { 'data-bd-sec': '' }, h('div', { 'data-bd-h': '' }, h('b', {}, '发言时配一张')),
|
|
2008
|
+
ToggleRow({ label: '新评论自动配图', on: Boolean(cfg.stickerAutoSend), disabled, note: '有话想说并已允许评论时,从库里选一张。只对支持图片的新评论生效。', onChange: (v) => patch({ stickerAutoSend: v }, '自动配表情') }),
|
|
2009
|
+
input('配图概率', 'stickerSendProbability', 100, '%'), input('配图冷却(秒)', 'stickerSendCooldown', 86400, '秒'),
|
|
2010
|
+
h('p', { 'data-bd-note': '' }, '0% 不主动配图,100% 每次符合条件都尝试。指定了表情时优先使用指定项;不会开启评论或私信权限。'),
|
|
2011
|
+
h('button', { 'data-bd-btn': '', disabled, onClick: () => save('send') }, '保存配图参数'))),
|
|
2012
|
+
h('section', { 'data-bd-sec': '', 'data-bd-sticker-upload-card': '' },
|
|
2013
|
+
h('div', { 'data-bd-h': '' }, h('b', {}, '上传本地表情')),
|
|
2014
|
+
h('p', { 'data-bd-note': '', style: { margin: 0 } }, '支持 PNG、JPEG、GIF、WebP,单张不超过 5 MB。上传后放进「自己收集」,可以预览、配图或移除。'),
|
|
2015
|
+
h('form', { 'data-bd-sticker-upload': '', onSubmit: upload },
|
|
2016
|
+
h('div', { 'data-bd-sticker-upload-preview': '', 'aria-label': '待上传图片预览' }, uploadPreview ? h('img', { src: uploadPreview, alt: uploadName || '待上传表情' }) : h('span', {}, '图片预览')),
|
|
2017
|
+
h('div', { 'data-bd-sticker-upload-fields': '' },
|
|
2018
|
+
h('label', { 'data-bd-field': '' }, h('span', { 'data-bd-label': '' }, '选择本地图片'),
|
|
2019
|
+
h('input', { ref: fileInput, type: 'file', accept: 'image/png,image/jpeg,image/gif,image/webp,.png,.jpg,.jpeg,.gif,.webp', 'aria-label': '本地表情图片', disabled: Boolean(busy || working), onChange: chooseFile })),
|
|
2020
|
+
routeMissing ? h('div', { 'data-bd-note': '', role: 'status' }, '图库接口尚未加载,重启 DSH 后即可上传;下面可以手动重试连接。') : null,
|
|
2021
|
+
uploadFile ? h('div', { 'data-bd-row': '' }, h('span', { 'data-bd-sticker-fileinfo': '', 'data-bd-grow': '' }, uploadFile.name + ' · ' + (uploadFile.size < 1024 * 1024 ? Math.max(1, Math.round(uploadFile.size / 1024)) + ' KB' : (uploadFile.size / 1024 / 1024).toFixed(2) + ' MB')),
|
|
2022
|
+
h('button', { 'data-bd-btn': '', type: 'button', disabled: Boolean(working), onClick: clearUpload }, '取消选择')) : null,
|
|
2023
|
+
h('label', { 'data-bd-field': '' }, h('span', { 'data-bd-label': '' }, '表情名称'), h('input', { 'data-bd-input': '', 'aria-label': '上传表情名称', value: uploadName, maxLength: 40, placeholder: '默认用文件名,可以改个好叫的', onChange: (e) => setUploadName(e.target.value) })),
|
|
2024
|
+
h('label', { 'data-bd-field': '' }, h('span', { 'data-bd-label': '' }, '适合什么场景(可选)'), h('input', { 'data-bd-input': '', 'aria-label': '上传表情描述', value: uploadDescription, maxLength: 160, placeholder: '例:悄悄围观,或者开心得冒泡', onChange: (e) => setUploadDescription(e.target.value) })),
|
|
2025
|
+
uploadError ? h('div', { 'data-bd-msg': '', 'data-warn': '', role: 'alert' }, uploadError) : null,
|
|
2026
|
+
h('div', { 'data-bd-row': '' }, h('button', { 'data-bd-btn': '', 'data-primary': '', type: 'submit', disabled: disabled || routeMissing || !uploadFile || !uploadName.trim() }, working === 'upload' ? '正在上传…' : working === 'read-file' ? '正在读取…' : '上传到表情库'))))),
|
|
2027
|
+
h('section', { 'data-bd-sec': '' },
|
|
2028
|
+
h('div', { 'data-bd-row': '' }, h('div', { 'data-bd-h': '', 'data-bd-grow': '' }, h('b', {}, '我的表情库')),
|
|
2029
|
+
h('span', { 'data-bd-sticker-count': '' }, data ? '内置 ' + (data.counts?.builtin ?? rows.filter((x) => x.source === 'builtin').length) + ' · 收集 ' + (data.counts?.collected ?? rows.filter((x) => x.source === 'collected').length) + ' / ' + (data.capacity ?? field('stickerCapacity')) : error ? '未连接' : '读取中'),
|
|
2030
|
+
h('button', { 'data-bd-btn': '', disabled, onClick: pull }, '刷新图库')),
|
|
2031
|
+
h('div', { 'data-bd-row': '' }, h('input', { 'data-bd-input': '', 'data-bd-grow': '', placeholder: '找名字、描述或标签', 'aria-label': '搜索表情', value: query, onChange: (e) => setQuery(e.target.value) }),
|
|
2032
|
+
h('select', { 'data-bd-input': '', 'aria-label': '表情来源', value: source, onChange: (e) => setSource(e.target.value) }, h('option', { value: 'all' }, '全部来源'), h('option', { value: 'builtin' }, '内置表情'), h('option', { value: 'collected' }, '自己收集'))),
|
|
2033
|
+
error ? h('div', { 'data-bd-msg': '', 'data-warn': '', role: 'alert' }, '图库读取失败:' + error, h('button', { 'data-bd-btn': '', onClick: pull }, '重试')) : null,
|
|
2034
|
+
!data && error ? null : h('div', { 'data-bd-sticker-list': '', role: 'region', 'aria-label': '已有表情', tabIndex: 0 },
|
|
2035
|
+
shown.length ? h('div', { 'data-bd-sticker-grid': '' }, shown.map((item) => h('article', { 'data-bd-sticker-item': item.source, key: item.source + ':' + item.name },
|
|
2036
|
+
h('button', { 'data-bd-sticker-open': '', 'aria-label': '查看表情:' + item.name, onClick: () => setSelected(item.source + ':' + item.name) }, h(PetStickerThumb, { item })),
|
|
2037
|
+
h('div', { 'data-bd-sticker-caption': '' }, h('b', { title: item.name }, item.name), h('small', {}, item.source === 'builtin' ? '内置 · 只读' : '自己收集')),
|
|
2038
|
+
h('div', { 'data-bd-sticker-itemfoot': '' }, h('span', {}, Array.isArray(item.tags) ? item.tags.slice(0, 2).join(' · ') : ''),
|
|
2039
|
+
item.removable && item.source === 'collected' ? h('button', { 'data-bd-btn': '', 'data-sm': '', disabled, 'aria-label': '移除表情:' + item.name, onClick: () => forget(item) }, '移除') : null))))
|
|
2040
|
+
: h('div', { 'data-bd-sticker-empty': '' }, data ? q || source !== 'all' ? '没有找到匹配的表情。' : '还没有表情。可以手动收一张,或开启自动收集后去逛评论区。' : '正在打开表情库…')),
|
|
2041
|
+
current ? h('div', { 'data-bd-sticker-detail': '' }, h(PetStickerThumb, { item: current, large: true }),
|
|
2042
|
+
h('div', {}, h('div', { 'data-bd-row': '' }, h('b', { 'data-bd-grow': '' }, current.name), h('button', { 'data-bd-btn': '', onClick: () => setSelected('') }, '收起预览')),
|
|
2043
|
+
h('p', {}, current.description || '还没有描述'), h('p', {}, '来源:' + (current.from || (current.source === 'builtin' ? '内置表情库' : '手动收集'))),
|
|
2044
|
+
current.addedAt ? h('p', {}, '收于 ' + petFmtTime(current.addedAt)) : null,
|
|
2045
|
+
Array.isArray(current.tags) && current.tags.length ? h('p', {}, current.tags.join(' · ')) : null)) : null,
|
|
2046
|
+
h('div', { 'data-bd-note': '' }, data?.automation?.collectPending ? '正在收取 ' + data.automation.collectPending + ' 张,稍后刷新可查看。' : '自动收集保留来源并按图片内容去重。内置区只读,移除只影响自己收集的表情。')),
|
|
2047
|
+
h('details', { 'data-bd-sec': '', 'data-bd-advanced': '' }, h('summary', {}, '手动收一张'),
|
|
2048
|
+
h('form', { 'data-bd-sticker-form': '', onSubmit: collect },
|
|
2049
|
+
h('label', { 'data-bd-field': '' }, h('span', { 'data-bd-label': '' }, '给它起个名字'), h('input', { 'data-bd-input': '', 'aria-label': '表情名称', value: name, maxLength: 40, placeholder: '例:悄悄探头', onChange: (e) => setName(e.target.value) })),
|
|
2050
|
+
h('label', { 'data-bd-field': '' }, h('span', { 'data-bd-label': '' }, '图片链接'), h('input', { 'data-bd-input': '', 'aria-label': '图片链接', type: 'url', value: url, placeholder: 'https://…', onChange: (e) => setUrl(e.target.value) })),
|
|
2051
|
+
h('label', { 'data-bd-field': '' }, h('span', { 'data-bd-label': '' }, '适合什么场景'), h('input', { 'data-bd-input': '', 'aria-label': '表情描述', value: description, maxLength: 160, placeholder: '例:发现有趣的小细节时', onChange: (e) => setDescription(e.target.value) })),
|
|
2052
|
+
h('button', { 'data-bd-btn': '', 'data-primary': '', type: 'submit', disabled }, working === 'collect' ? '正在收…' : '收进表情库'))))
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
function PetSettingsModal({ onClose, toast, ctx, status, initialTab = 'basic' }) {
|
|
2056
|
+
const [tab, setTab] = React.useState(initialTab)
|
|
2057
|
+
const [runMode, setRunMode] = React.useState('session')
|
|
2058
|
+
const [logMode, setLogMode] = React.useState('events')
|
|
2059
|
+
const [runDrafts, setRunDrafts] = React.useState({})
|
|
2060
|
+
const runLock = React.useRef(false)
|
|
2061
|
+
const modalRef = React.useRef(null)
|
|
2062
|
+
/* ⭐ 2026-10-27:桌宠根(PetRoot)本来就在 5 秒轮一次 /status ⇒ 弹窗直接吃同一份,
|
|
2063
|
+
* 弹窗开着时的 /status 请求从两条并成一条。单独渲染(devtools 预检)没有 status prop
|
|
2064
|
+
* ⇒ 自己轮询,行为跟以前完全一样(useStatus 内部 paused 挡住的是轮询,不是渲染)。 */
|
|
2065
|
+
const ownStatus = useStatus(5000, Boolean(status))
|
|
2066
|
+
const [st, setSt, fails] = status || ownStatus
|
|
2067
|
+
const [busy, setBusy] = React.useState('')
|
|
2068
|
+
const [msg, setMsg] = React.useState('')
|
|
2069
|
+
const [msgTone, setMsgTone] = React.useState('')
|
|
2070
|
+
/* 输入一律绑本地草稿:5 秒轮询会把正在打的字冲掉 */
|
|
2071
|
+
const [kws, setKws] = React.useState(null)
|
|
2072
|
+
const [wsDir, setWsDir] = React.useState(null) // 跟 uiWorkspace 服务的名字区分开
|
|
2073
|
+
const [rl, setRl] = React.useState(null)
|
|
2074
|
+
const [perDay, setPerDay] = React.useState(null)
|
|
2075
|
+
const [dmUid, setDmUid] = React.useState(null)
|
|
2076
|
+
const [dmMode, setDmMode] = React.useState(null)
|
|
2077
|
+
const [persona, setPersona] = React.useState(null)
|
|
2078
|
+
const [momentStyle, setMomentStyle] = React.useState(null)
|
|
2079
|
+
const [momentPerDay, setMomentPerDay] = React.useState(null)
|
|
2080
|
+
const [modelId, setModelId] = React.useState(null)
|
|
2081
|
+
const [tokenBudget, setTokenBudget] = React.useState(null)
|
|
2082
|
+
const [totalTokenBudget, setTotalTokenBudget] = React.useState(null)
|
|
2083
|
+
const [maxRounds, setMaxRounds] = React.useState(null)
|
|
2084
|
+
const [paceFactor, setPaceFactor] = React.useState(null)
|
|
2085
|
+
const [scheduleAllDay, setScheduleAllDay] = React.useState(null)
|
|
2086
|
+
const [scheduleStart, setScheduleStart] = React.useState(null)
|
|
2087
|
+
const [scheduleEnd, setScheduleEnd] = React.useState(null)
|
|
2088
|
+
const [learnReviewModelId, setLearnReviewModelId] = React.useState(null)
|
|
2089
|
+
const [learnReviewBatchSize, setLearnReviewBatchSize] = React.useState(null)
|
|
2090
|
+
const [learnReviewMinutes, setLearnReviewMinutes] = React.useState(null)
|
|
2091
|
+
const [learnReviewTokenBudget, setLearnReviewTokenBudget] = React.useState(null)
|
|
2092
|
+
const [login, setLogin] = React.useState(null)
|
|
2093
|
+
const [loginState, setLoginState] = React.useState('')
|
|
2094
|
+
const [loginTone, setLoginTone] = React.useState('')
|
|
2095
|
+
const loginTimer = React.useRef(null)
|
|
2096
|
+
const alive = React.useRef(true)
|
|
2097
|
+
/* 🎬 视频那一卡的独立轮询:跑起来 3 秒、闲着 15 秒 */
|
|
2098
|
+
const [pv, setPv] = React.useState(null)
|
|
2099
|
+
const [pvDown, setPvDown] = React.useState(false)
|
|
2100
|
+
const [pvBusy, setPvBusy] = React.useState('')
|
|
2101
|
+
const [pvDay, setPvDay] = React.useState('')
|
|
2102
|
+
const [settingLogs, setSettingLogs] = React.useState(null)
|
|
2103
|
+
const [settingLogError, setSettingLogError] = React.useState('')
|
|
2104
|
+
const [settingLogFilter, setSettingLogFilter] = React.useState('')
|
|
2105
|
+
const [settingLogOpen, setSettingLogOpen] = React.useState({})
|
|
2106
|
+
/* 📡 活动流水(2026-10-27 从旧设置页搬进来 —— 桌宠化时它是唯一没跟进弹窗的主力功能):
|
|
2107
|
+
单独 2.5 秒增量轮询,跟 5 秒的 /status 分开,不然那条流会一顿一顿的。 */
|
|
2108
|
+
const [acts, setActs] = React.useState([])
|
|
2109
|
+
const [actLive, setActLive] = React.useState(null)
|
|
2110
|
+
const pvRunning = Boolean(pv && pv.running)
|
|
2111
|
+
React.useEffect(() => {
|
|
2112
|
+
if (tab !== 'video') return
|
|
2113
|
+
let dead = false
|
|
2114
|
+
let timer = null
|
|
2115
|
+
const pull = async () => {
|
|
2116
|
+
try {
|
|
2117
|
+
const r = await jget('/pv')
|
|
2118
|
+
if (!dead && r) {
|
|
2119
|
+
if (r.ok === false) setPvDown(true)
|
|
2120
|
+
else { setPvDown(false); setPv(r) }
|
|
2121
|
+
}
|
|
2122
|
+
} catch { /* 抖一下就算了,下次接着拉 */ }
|
|
2123
|
+
if (dead) return
|
|
2124
|
+
timer = setTimeout(pull, pvRunning ? 3000 : 15000)
|
|
2125
|
+
}
|
|
2126
|
+
pull()
|
|
2127
|
+
return () => { dead = true; if (timer) clearTimeout(timer) }
|
|
2128
|
+
}, [tab, pvRunning])
|
|
2129
|
+
/* ⭐ 活动流水轮询:只拉增量(sinceUid = 已收到的最后一条 uid),报文一直很小。
|
|
2130
|
+
同一次工具调用先 call 后 done(共用一个 id)⇒ 原地改写那一行,不是再追加一条。 */
|
|
2131
|
+
const activityCursor = React.useRef('')
|
|
2132
|
+
React.useEffect(() => {
|
|
2133
|
+
if (tab !== 'logs') return
|
|
2134
|
+
let dead = false
|
|
2135
|
+
let sinceUid = activityCursor.current
|
|
2136
|
+
const pull = async () => {
|
|
2137
|
+
try {
|
|
2138
|
+
const r = await jget('/activity?sinceUid=' + encodeURIComponent(sinceUid))
|
|
2139
|
+
if (dead || !r) return
|
|
2140
|
+
// 「此刻在不在想」不跟增量走,是当场的状态 —— 每趟都要更新
|
|
2141
|
+
if (r.live) setActLive(r.live)
|
|
2142
|
+
if (Array.isArray(r.rows) && r.rows.length) {
|
|
2143
|
+
sinceUid = String(r.last || sinceUid)
|
|
2144
|
+
activityCursor.current = sinceUid
|
|
2145
|
+
setActs((old) => {
|
|
2146
|
+
const next = old.slice()
|
|
2147
|
+
const byUid = new Map(next.map((x) => [x && (x.uid || x.id), x]))
|
|
2148
|
+
for (const row of r.rows) {
|
|
2149
|
+
if (!row) continue
|
|
2150
|
+
const key = row.uid || row.id
|
|
2151
|
+
// 同一个 id 的是「同一次调用的后续状态」:原地改写,别追加成两行
|
|
2152
|
+
const at = row.id ? next.findIndex((x) => x && x.id === row.id) : -1
|
|
2153
|
+
if (at >= 0) next[at] = { ...next[at], ...row, t: next[at].t || row.t }
|
|
2154
|
+
else if (!key || !byUid.has(key)) next.push(row)
|
|
2155
|
+
}
|
|
2156
|
+
return next.length > 200 ? next.slice(next.length - 200) : next
|
|
2157
|
+
})
|
|
2158
|
+
}
|
|
2159
|
+
} catch { /* 抖一下就算了,下次接着拉 */ }
|
|
2160
|
+
}
|
|
2161
|
+
pull()
|
|
2162
|
+
const t = setInterval(pull, 2500)
|
|
2163
|
+
return () => { dead = true; clearInterval(t) }
|
|
2164
|
+
}, [tab])
|
|
2165
|
+
|
|
2166
|
+
const onClearAct = async () => {
|
|
2167
|
+
try { await jpost('/activity', { clear: true }); setActs([]); activityCursor.current = '' } catch (e) { say(String(e?.message ?? e), 'warn') }
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
const logRev = React.useRef('')
|
|
2171
|
+
React.useEffect(() => {
|
|
2172
|
+
if (tab !== 'logs') return
|
|
2173
|
+
let dead = false
|
|
2174
|
+
let timer = null
|
|
2175
|
+
const pull = async () => {
|
|
2176
|
+
try {
|
|
2177
|
+
const r = await jget('/pet/log?n=60' + (logRev.current ? '&rev=' + encodeURIComponent(logRev.current) : ''))
|
|
2178
|
+
if (dead) return
|
|
2179
|
+
if (!r || r.ok === false) { setSettingLogError((r && r.error) || '读不到'); return }
|
|
2180
|
+
if (r.unchanged) return // ⭐ 没新增行就不重渲染
|
|
2181
|
+
if (r.rev) logRev.current = String(r.rev)
|
|
2182
|
+
setSettingLogs(Array.isArray(r.entries) ? r.entries.slice(-60) : [])
|
|
2183
|
+
setSettingLogError('')
|
|
2184
|
+
} catch (e) { if (!dead) setSettingLogError(String(e?.message ?? e)) }
|
|
2185
|
+
finally { if (!dead) timer = setTimeout(pull, 7000) }
|
|
2186
|
+
}
|
|
2187
|
+
pull()
|
|
2188
|
+
return () => { dead = true; if (timer) clearTimeout(timer) }
|
|
2189
|
+
}, [tab])
|
|
2190
|
+
/* ⚠️ 扫码轮询的 timer 必须能停(切走/关弹窗后还跑会在已卸载组件上 setState) */
|
|
2191
|
+
React.useEffect(() => () => {
|
|
2192
|
+
alive.current = false
|
|
2193
|
+
if (loginTimer.current) { clearInterval(loginTimer.current); loginTimer.current = null }
|
|
2194
|
+
}, [])
|
|
2195
|
+
|
|
2196
|
+
const cfg = st && st.config
|
|
2197
|
+
React.useEffect(() => {
|
|
2198
|
+
const previous = document.activeElement
|
|
2199
|
+
const node = modalRef.current
|
|
2200
|
+
if (node?.querySelector) node.querySelector('[data-bd-pet-tab][data-on]')?.focus()
|
|
2201
|
+
return () => { if (previous?.isConnected && previous.focus) previous.focus() }
|
|
2202
|
+
}, [Boolean(cfg)])
|
|
2203
|
+
const lim = (st && st.limits) || {}
|
|
2204
|
+
const numLim = (key, lo, hi) => {
|
|
2205
|
+
const v = lim[key]
|
|
2206
|
+
return Array.isArray(v) && v.length === 2 ? v : [lo, hi]
|
|
2207
|
+
}
|
|
2208
|
+
const clampField = (key, val, lo, hi) => {
|
|
2209
|
+
const [a, b] = numLim(key, lo, hi)
|
|
2210
|
+
const n = Number(val)
|
|
2211
|
+
return Math.max(a, Math.min(Number.isFinite(n) ? n : a, b))
|
|
2212
|
+
}
|
|
2213
|
+
React.useEffect(() => {
|
|
2214
|
+
if (!cfg) return
|
|
2215
|
+
if (kws === null) setKws((cfg.keywords || []).join('\n'))
|
|
2216
|
+
if (wsDir === null) setWsDir(cfg.workspace || '')
|
|
2217
|
+
if (rl === null) setRl(cfg.reportLevel || 'full')
|
|
2218
|
+
if (perDay === null) setPerDay(String(cfg.perDay ?? 5))
|
|
2219
|
+
if (dmUid === null) setDmUid(cfg.dmUid ? String(cfg.dmUid) : '')
|
|
2220
|
+
if (dmMode === null) setDmMode(cfg.dmMode || 'off')
|
|
2221
|
+
if (persona === null) setPersona(cfg.persona || '')
|
|
2222
|
+
if (momentStyle === null) setMomentStyle(cfg.momentStyle || '感想')
|
|
2223
|
+
if (momentPerDay === null) setMomentPerDay(String(cfg.momentPerDay ?? 1))
|
|
2224
|
+
if (modelId === null) setModelId(cfg.modelId || '')
|
|
2225
|
+
if (tokenBudget === null) setTokenBudget(String(cfg.tokenBudget ?? 0))
|
|
2226
|
+
if (totalTokenBudget === null) setTotalTokenBudget(String(cfg.totalTokenBudget ?? 0))
|
|
2227
|
+
if (maxRounds === null) setMaxRounds(String(cfg.maxRounds ?? 64))
|
|
2228
|
+
if (paceFactor === null) setPaceFactor(String(cfg.paceFactor ?? 1))
|
|
2229
|
+
if (scheduleAllDay === null) setScheduleAllDay(Boolean(cfg.scheduleAllDay))
|
|
2230
|
+
if (scheduleStart === null) setScheduleStart(String(cfg.scheduleStart || '18:00'))
|
|
2231
|
+
if (scheduleEnd === null) setScheduleEnd(String(cfg.scheduleEnd || '09:00'))
|
|
2232
|
+
if (learnReviewModelId === null) setLearnReviewModelId(String(cfg.learnReviewModelId || ''))
|
|
2233
|
+
if (learnReviewBatchSize === null) setLearnReviewBatchSize(String(cfg.learnReviewBatchSize ?? 20))
|
|
2234
|
+
if (learnReviewMinutes === null) setLearnReviewMinutes(String(cfg.learnReviewMinutes ?? 10))
|
|
2235
|
+
if (learnReviewTokenBudget === null) setLearnReviewTokenBudget(String(cfg.learnReviewTokenBudget ?? 12000))
|
|
2236
|
+
}, [cfg])
|
|
2237
|
+
|
|
2238
|
+
const say = (text, tone) => { setMsg(text); setMsgTone(tone || '') }
|
|
2239
|
+
const patch = async (p, what) => {
|
|
2240
|
+
setBusy(JSON.stringify(p))
|
|
2241
|
+
try {
|
|
2242
|
+
const r = await jpost('/config', p)
|
|
2243
|
+
if (r?.config) setSt((old) => ({ ...old, config: r.config }))
|
|
2244
|
+
const clamped = Array.isArray(r?.clamped) ? r.clamped : []
|
|
2245
|
+
const clampTxt = clamped.map((c) => `${c.key} 想要 ${c.want},最多只能 ${c.got}`).join(';')
|
|
2246
|
+
if (r?.host && r.host.ok === false) say(`${what || '设置'}:插件自己那份存了,但宿主那份配置没写进去(${r.host.error})`, 'warn')
|
|
2247
|
+
else if (clamped.length) say(`${what || '设置'}:存下了,但有项目超出允许范围被改了 —— ${clampTxt}`, 'warn')
|
|
2248
|
+
else say(what ? `${what}:已保存` : '已保存', 'good')
|
|
2249
|
+
/* 数字框回填成真存下来的值(不然界面显示 100、实际存 50) */
|
|
2250
|
+
if (clamped.length) {
|
|
2251
|
+
for (const c of clamped) {
|
|
2252
|
+
if (c.key === 'perDay') setPerDay(String(c.got))
|
|
2253
|
+
else if (c.key === 'momentPerDay') setMomentPerDay(String(c.got))
|
|
2254
|
+
else if (c.key === 'maxRounds') setMaxRounds(String(c.got))
|
|
2255
|
+
else if (c.key === 'tokenBudget') setTokenBudget(String(c.got))
|
|
2256
|
+
else if (c.key === 'totalTokenBudget') setTotalTokenBudget(String(c.got))
|
|
2257
|
+
else if (c.key === 'paceFactor') setPaceFactor(String(c.got))
|
|
2258
|
+
}
|
|
2259
|
+
}
|
|
2260
|
+
return true
|
|
2261
|
+
} catch (e) { say(String(e?.message ?? e), 'warn'); return false }
|
|
2262
|
+
finally { setBusy('') }
|
|
2263
|
+
}
|
|
2264
|
+
const sw = (k) => Boolean(cfg && cfg[k])
|
|
2265
|
+
|
|
2266
|
+
const onLoginStart = async () => {
|
|
2267
|
+
setBusy('login')
|
|
2268
|
+
setLoginTone('')
|
|
2269
|
+
setLoginState('正在出码…')
|
|
2270
|
+
try {
|
|
2271
|
+
const r = await jpost('/login/start', {})
|
|
2272
|
+
if (!r?.ok) { setLoginTone('warn'); setLoginState(r?.error || '出码失败'); return }
|
|
2273
|
+
setLogin({ key: r.key, qr: r.qr })
|
|
2274
|
+
if (!r.qr) { setLoginTone('warn'); setLoginState('没画出二维码 —— 先点上面的「装主依赖」(venv 里要有 qrcode)') }
|
|
2275
|
+
else setLoginState('用手机 B站 扫这张码')
|
|
2276
|
+
if (loginTimer.current) { clearInterval(loginTimer.current); loginTimer.current = null }
|
|
2277
|
+
let n = 0
|
|
2278
|
+
const stop = () => { clearInterval(timer); if (loginTimer.current === timer) loginTimer.current = null }
|
|
2279
|
+
const timer = setInterval(async () => {
|
|
2280
|
+
if (!alive.current) { stop(); return }
|
|
2281
|
+
n += 1
|
|
2282
|
+
try {
|
|
2283
|
+
const p = await jpost('/login/poll', { key: r.key })
|
|
2284
|
+
if (p?.state === 'ok') {
|
|
2285
|
+
stop(); setLogin(null); setLoginTone('good'); setLoginState('登录成功')
|
|
2286
|
+
setSt((old) => ({ ...old, loggedIn: true }))
|
|
2287
|
+
} else if (p?.state === 'scanned') { setLoginTone(''); setLoginState('扫到了,请在手机上确认') }
|
|
2288
|
+
else if (p?.state === 'expired') { stop(); setLogin(null); setLoginTone('warn'); setLoginState('二维码过期了,再点一次「扫码登录」') }
|
|
2289
|
+
else if (p?.state === 'unknown') {
|
|
2290
|
+
stop(); setLogin(null); setLoginTone('warn')
|
|
2291
|
+
setLoginState('没登上:' + (p?.message || '响应里没有 SESSDATA —— 再点一次「扫码登录」'))
|
|
2292
|
+
} else { setLoginTone(''); setLoginState('等扫码…') }
|
|
2293
|
+
} catch { /* 抖动就下次再问 */ }
|
|
2294
|
+
if (n > 90) { stop(); setLogin(null); setLoginTone('warn'); setLoginState('超时了,再点一次「扫码登录」') }
|
|
2295
|
+
}, 2000)
|
|
2296
|
+
loginTimer.current = timer
|
|
2297
|
+
} catch (e) { setLoginTone('warn'); setLoginState(String(e?.message ?? e)) }
|
|
2298
|
+
finally { setBusy('') }
|
|
2299
|
+
}
|
|
2300
|
+
const onInstall = async () => {
|
|
2301
|
+
setBusy('install'); say('')
|
|
2302
|
+
try {
|
|
2303
|
+
const r = await jpost('/install', {})
|
|
2304
|
+
say(r?.hint || '已经在后台开始装了', '')
|
|
2305
|
+
} catch (e) { say(String(e?.message ?? e), 'warn') }
|
|
2306
|
+
finally { setBusy('') }
|
|
2307
|
+
}
|
|
2308
|
+
const onCheck = async () => {
|
|
2309
|
+
setBusy('check'); say('')
|
|
2310
|
+
try {
|
|
2311
|
+
const r = await jpost('/check', {})
|
|
2312
|
+
if (r && r.ok) say('能用!' + (r.steps || []).join('、'))
|
|
2313
|
+
else say('还不能用:' + (r && r.error ? r.error : '没拿到结果'))
|
|
2314
|
+
} catch (e) { say(String(e?.message ?? e), 'warn') } finally { setBusy('') }
|
|
2315
|
+
}
|
|
2316
|
+
const onInstallAsr = async () => {
|
|
2317
|
+
setBusy('asr'); say('')
|
|
2318
|
+
try {
|
|
2319
|
+
const r = await jpost('/install-asr', {})
|
|
2320
|
+
say(r?.started ? '开始装转写依赖 —— 进度显示在下面(要下模型,慢一点正常)' : (r?.hint || '没起来'),
|
|
2321
|
+
r?.started ? 'good' : 'warn')
|
|
2322
|
+
} catch (e) { say(String(e?.message ?? e), 'warn') } finally { setBusy('') }
|
|
2323
|
+
}
|
|
2324
|
+
/* 🎬 视频动作:先拦日期格式,再 POST /pv */
|
|
2325
|
+
const onPvMake = async (force) => {
|
|
2326
|
+
const day = (pvDay || (pv && pv.yesterday) || '').trim()
|
|
2327
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) { say('先选一天(YYYY-MM-DD)', 'warn'); return }
|
|
2328
|
+
setPvBusy('make')
|
|
2329
|
+
try {
|
|
2330
|
+
const r = await jpost('/pv', { action: 'make', date: day, force: force === true })
|
|
2331
|
+
if (!r || r.ok === false) { say('没做成:' + ((r && r.error) || '未知原因'), 'warn'); return }
|
|
2332
|
+
if (r.skipped) { say(`这天已经有视频了:${r.file}(要重做就再点一次「重做」)`, 'warn'); return }
|
|
2333
|
+
say(`开始做 ${r.date} 的视频了 —— 一条约 2–6 分钟,做完这张卡会自己变成播放器。`, 'good')
|
|
2334
|
+
setPv((old) => ({ ...(old || {}), running: true, stage: 'plan', date: r.date }))
|
|
2335
|
+
} catch (e) { say(String(e?.message ?? e), 'warn') } finally { setPvBusy('') }
|
|
2336
|
+
}
|
|
2337
|
+
const onPvStop = async () => {
|
|
2338
|
+
setPvBusy('stop')
|
|
2339
|
+
try {
|
|
2340
|
+
const r = await jpost('/pv', { action: 'stop' })
|
|
2341
|
+
say(r && r.stopped ? '停下了。' : '它本来就没在做。', r && r.stopped ? 'warn' : '')
|
|
2342
|
+
} catch (e) { say(String(e?.message ?? e), 'warn') } finally { setPvBusy('') }
|
|
2343
|
+
}
|
|
2344
|
+
const onPvRefresh = async () => {
|
|
2345
|
+
try { const r = await jget('/pv'); if (r) setPv(r) } catch { /* 下次轮询会补 */ }
|
|
2346
|
+
}
|
|
2347
|
+
const onPvRedo = () => onPvMake(true)
|
|
2348
|
+
const copyText = async (text) => {
|
|
2349
|
+
const v = String(text || '')
|
|
2350
|
+
try { await navigator.clipboard.writeText(v); toast('已复制:' + v, 'good') }
|
|
2351
|
+
catch { toast('复制失败 —— 路径是:' + v) }
|
|
2352
|
+
}
|
|
2353
|
+
|
|
2354
|
+
const tabDefs = [['basic', '偏好与桌宠', '口吻 / 选片 / 报告'], ['run', '运行方式', '会话 / 后台 / 定时'], ['speech', '互动与投递', '发言 / 私信 / 节奏'],
|
|
2355
|
+
['stickers', '表情收藏', '图库 / 收集 / 配图'], ['learning', '学习与记忆', '自动审核 / 记录'], ['ledger', 'token 账本', '用量 / 预算'], ['logs', '运行日志', '实时动态 / 事件'], ['video', '日报视频', '制作 / 配音'], ['other', '账号与环境', '登录 / 依赖 / 镜像']]
|
|
2356
|
+
const modalShell = (body) => h('div', { 'data-bd-pet-mask': '', onPointerDown: (e) => { if (e.target === e.currentTarget) onClose() } },
|
|
2357
|
+
h('div', { 'data-bd-pet-modal': '', role: 'dialog', 'aria-modal': true, 'aria-label': '鲸鱼摸鱼设置', ref: modalRef,
|
|
2358
|
+
onKeyDown: (e) => {
|
|
2359
|
+
if (e.key === 'Escape') { e.stopPropagation(); onClose() }
|
|
2360
|
+
if (e.key === 'Tab' && e.currentTarget.querySelectorAll) {
|
|
2361
|
+
const nodes = Array.from(e.currentTarget.querySelectorAll('button:not(:disabled),input:not(:disabled),select:not(:disabled),textarea:not(:disabled),a[href],summary,[tabindex="0"]')).filter((el) => el.getClientRects().length)
|
|
2362
|
+
const first = nodes[0], last = nodes[nodes.length - 1]
|
|
2363
|
+
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last?.focus() }
|
|
2364
|
+
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first?.focus() }
|
|
2365
|
+
}
|
|
2366
|
+
} },
|
|
2367
|
+
h('header', { 'data-bd-settings-head': '' }, h(SettingsAvatar),
|
|
2368
|
+
h('div', { 'data-bd-settings-title': '' }, h('b', {}, '鲸鱼摸鱼 · 设置'), h('p', {}, '慢慢逛,调成你喜欢的样子。')),
|
|
2369
|
+
h('button', { 'data-bd-pet-close': '', onClick: onClose, title: '关闭(ESC)', 'aria-label': '关闭设置' }, '×')),
|
|
2370
|
+
h('div', { 'data-bd-settings-layout': '' },
|
|
2371
|
+
h('nav', { 'data-bd-pet-tabs': '', role: 'tablist', 'aria-orientation': 'vertical', 'aria-label': '设置分类', onKeyDown: (e) => {
|
|
2372
|
+
if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(e.key)) return
|
|
2373
|
+
e.preventDefault()
|
|
2374
|
+
const current = tabDefs.findIndex(([key]) => key === tab)
|
|
2375
|
+
const forward = e.key === 'ArrowRight' || e.key === 'ArrowDown'
|
|
2376
|
+
const next = e.key === 'Home' ? 0 : e.key === 'End' ? tabDefs.length - 1 : (current + (forward ? 1 : -1) + tabDefs.length) % tabDefs.length
|
|
2377
|
+
setTab(tabDefs[next][0]); e.currentTarget.querySelectorAll('[role="tab"]')[next]?.focus()
|
|
2378
|
+
} }, ...tabDefs.flatMap(([key, label, note], i) => [
|
|
2379
|
+
i === 0 || i === 4 || i === 7 ? h('div', { key: 'group-' + key, 'data-bd-nav-group': '', role: 'presentation' }, i === 0 ? '日常使用' : i === 4 ? '记录与成长' : '制作与维护') : null,
|
|
2380
|
+
h('button', { key, id: 'bd-tab-' + key, 'data-bd-pet-tab': '', role: 'tab', 'aria-selected': tab === key, 'aria-controls': 'bd-settings-content', tabIndex: tab === key ? 0 : -1,
|
|
2381
|
+
'data-on': tab === key ? '' : undefined, onClick: () => setTab(key) }, label, h('small', {}, note)),
|
|
2382
|
+
]), h('div', { 'data-bd-nav-foot': '' }, 'DAFEIYU', h('br'), '小小控制室 / LOCAL')),
|
|
2383
|
+
h('main', { 'data-bd-settings-main': '' },
|
|
2384
|
+
msg ? h('div', { style: { margin: '14px 26px 0' }, role: msgTone === 'warn' ? 'alert' : 'status' },
|
|
2385
|
+
h('div', { 'data-bd-msg': '', 'data-warn': msgTone === 'warn' ? '' : undefined, 'data-good': msgTone === 'good' ? '' : undefined }, msg)) : null,
|
|
2386
|
+
h('div', { 'data-bd-pet-body': '', id: 'bd-settings-content', role: 'tabpanel', 'aria-labelledby': 'bd-tab-' + tab }, body)))))
|
|
2387
|
+
|
|
2388
|
+
if (!cfg) {
|
|
2389
|
+
return h('div', { 'data-bd-pet-mask': '' },
|
|
2390
|
+
h('div', { 'data-bd-pet-modal': '' },
|
|
2391
|
+
h('div', { 'data-bd-pet-tabs': '' },
|
|
2392
|
+
h('span', { 'data-bd-pet-tab': '', style: { cursor: 'default' } }, '鲸鱼摸鱼设置'),
|
|
2393
|
+
h('button', { 'data-bd-pet-close': '', onClick: onClose }, '✕')),
|
|
2394
|
+
h('div', { 'data-bd-pet-body': '' },
|
|
2395
|
+
h('div', { 'data-bd-note': '' }, fails >= 3
|
|
2396
|
+
? '读不到插件配置(已经试了 ' + fails + ' 次)—— 完全退出 DSH Desktop 再打开试试。'
|
|
2397
|
+
: '读设置中…'))))
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2400
|
+
/* 每项只在一页出现;旧 Panel 仅供历史回归工具使用,不挂入弹窗。 */
|
|
2401
|
+
const icon = (name) => h(PetIcon, { name })
|
|
2402
|
+
const heading = (_name, title) => h('div', { 'data-bd-h': '' }, h('b', {}, title))
|
|
2403
|
+
const pvCard = () => {
|
|
2404
|
+
if (pvDown) return h('section', { 'data-bd-sec': '' }, heading('film', '视频暂时不可用'),
|
|
2405
|
+
h('div', { 'data-bd-msg': '', 'data-warn': '' }, '读不到视频服务,请稍后重试。'),
|
|
2406
|
+
h('button', { 'data-bd-btn': '', onClick: onPvRefresh }, '重新读取'))
|
|
2407
|
+
const videos = Array.isArray(pv && pv.videos) ? pv.videos : []
|
|
2408
|
+
const latest = videos.length ? videos[0] : null
|
|
2409
|
+
const shown = (pv && pv.file && pv.stage === 'done') ? pv.file : (latest ? latest.file : '')
|
|
2410
|
+
const shownDay = (pv && pv.stage === 'done' && pv.date) ? pv.date : (latest ? latest.date : '')
|
|
2411
|
+
const deps = pv ? pv.deps : null
|
|
2412
|
+
const stageTxt = pv && pv.running
|
|
2413
|
+
? (pv.stage === 'plan' ? '正在整理分镜…' : '正在渲染画面…')
|
|
2414
|
+
: ''
|
|
2415
|
+
return h('div', { 'data-bd-sec': '' },
|
|
2416
|
+
h('div', { 'data-bd-h': '' }, icon('film'), h('b', {}, '冲浪日报视频'),
|
|
2417
|
+
pv && pv.running ? h('span', { 'data-bd-badge': '', 'data-live': '' }, stageTxt) : null,
|
|
2418
|
+
deps === false ? h('span', { 'data-bd-badge': '', 'data-warn': '' }, '依赖没装') : null,
|
|
2419
|
+
videos.length ? h('span', { 'data-bd-badge': '' }, `${videos.length} 条成品`) : null),
|
|
2420
|
+
h('div', { 'data-bd-note': '' },
|
|
2421
|
+
'把某一天的冲浪情况做成一条由鲸鱼摸鱼讲解配音的短片,存进当天文件夹。做在本地,不发布。'),
|
|
2422
|
+
deps === false ? h('div', { 'data-bd-msg': '', 'data-warn': '' },
|
|
2423
|
+
'渲染要的三样东西没装齐(edge-tts / imageio-ffmpeg / Pillow)—— 到「账号与环境」点一次「装主依赖」即可。') : null,
|
|
2424
|
+
h('div', { 'data-bd-row': '' },
|
|
2425
|
+
h('span', { 'data-bd-label': '', 'data-bd-grow': '' }, '做哪一天'),
|
|
2426
|
+
h('input', {
|
|
2427
|
+
'data-bd-input': '', style: { width: '150px' }, type: 'date',
|
|
2428
|
+
value: pvDay || (pv && pv.yesterday) || '', onChange: (e) => setPvDay(e.target.value),
|
|
2429
|
+
})),
|
|
2430
|
+
h('div', { 'data-bd-row': '' },
|
|
2431
|
+
h('button', {
|
|
2432
|
+
'data-bd-btn': '', 'data-primary': '', disabled: Boolean(pvBusy) || pvRunning || deps === false,
|
|
2433
|
+
onClick: () => onPvMake(false),
|
|
2434
|
+
}, pvRunning ? '正在做…' : '做这一天的视频'),
|
|
2435
|
+
pvRunning ? h('button', { 'data-bd-btn': '', 'data-sm': '', disabled: Boolean(pvBusy), onClick: onPvStop }, '停下') : null,
|
|
2436
|
+
shown ? h('button', { 'data-bd-btn': '', 'data-sm': '', disabled: Boolean(pvBusy) || pvRunning, onClick: onPvRedo }, '重做这天') : null,
|
|
2437
|
+
h('button', { 'data-bd-btn': '', 'data-sm': '', disabled: Boolean(pvBusy), onClick: onPvRefresh }, '刷新')),
|
|
2438
|
+
pv && pv.running ? h('div', { 'data-bd-msg': '' },
|
|
2439
|
+
`${pv.date}:${stageTxt}已 ${Math.round((Number(pv.ms) || 0) / 1000)} 秒。这一步要占几分钟 CPU,可以先干别的,做完这里会自己出画面。`) : null,
|
|
2440
|
+
pv && !pv.running && pv.stage === 'failed' ? h('div', { 'data-bd-msg': '', 'data-warn': '' },
|
|
2441
|
+
`上一次没做成:${pv.error || '未知原因'}` + (pv.log ? `(日志:${pv.log})` : '')) : null,
|
|
2442
|
+
pv && !pv.running && pv.stage === 'stopped' ? h('div', { 'data-bd-msg': '', 'data-warn': '' }, '上一次是手动停下的。') : null,
|
|
2443
|
+
shown
|
|
2444
|
+
? h('div', { 'data-bd-field': '' },
|
|
2445
|
+
h('video', {
|
|
2446
|
+
src: `/api/bili-duty/pv/video?day=${encodeURIComponent(shownDay)}&t=${Number(latest && latest.at ? Date.parse(latest.at) : 0) || 0}`,
|
|
2447
|
+
controls: true, preload: 'metadata',
|
|
2448
|
+
style: { width: '100%', borderRadius: '10px', background: '#000', display: 'block' },
|
|
2449
|
+
}),
|
|
2450
|
+
h('div', { 'data-bd-row': '' },
|
|
2451
|
+
h('span', { 'data-bd-note': '', 'data-bd-grow': '' },
|
|
2452
|
+
`${shownDay} · ${latest && latest.mb != null ? `${latest.mb} MB` : ''} · ${shown}`),
|
|
2453
|
+
h('button', { 'data-bd-btn': '', 'data-sm': '', onClick: () => copyText(shown) }, '复制路径')))
|
|
2454
|
+
: h('div', { 'data-bd-empty': '' },
|
|
2455
|
+
pvRunning ? '第一条正在做,做完会出现在这里' : '还没有成品 —— 选个日期点「做这一天的视频」'),
|
|
2456
|
+
videos.length > 1 ? h('div', { 'data-bd-kv': '' },
|
|
2457
|
+
'以前做的:' + videos.slice(0, 6).map((v) => `${v.date}${v.mb != null ? `(${v.mb}MB)` : ''}`).join(' · ')) : null,
|
|
2458
|
+
h('div', { 'data-bd-field': '' },
|
|
2459
|
+
h('div', { 'data-bd-row': '', 'data-bd-toggle': '' },
|
|
2460
|
+
h('div', { 'data-bd-grow': '' },
|
|
2461
|
+
h('div', { 'data-bd-label': '' }, '每天自动做「昨天」的'),
|
|
2462
|
+
h('div', { 'data-bd-note': '' }, '到了下面的时间点,如果昨天的成品还没有,就自己补一条。')),
|
|
2463
|
+
h(Switch, {
|
|
2464
|
+
on: Boolean(pv && pv.auto), disabled: Boolean(pvBusy),
|
|
2465
|
+
onChange: async (v) => {
|
|
2466
|
+
// 乐观更新 + 失败回滚(同 Panel 那套:等轮询回来才动,人会以为没点上)
|
|
2467
|
+
setPv((old) => ({ ...(old || {}), auto: v }))
|
|
2468
|
+
const okSaved = await patch({ pvAuto: v }, '每天自动做视频')
|
|
2469
|
+
if (!okSaved) setPv((old) => ({ ...(old || {}), auto: !v }))
|
|
2470
|
+
},
|
|
2471
|
+
})),
|
|
2472
|
+
h('div', { 'data-bd-row': '' },
|
|
2473
|
+
h('span', { 'data-bd-label': '', 'data-bd-grow': '' }, '每天几点做'),
|
|
2474
|
+
h('input', {
|
|
2475
|
+
'data-bd-input': '', 'data-bd-num': '', style: { width: '96px' }, type: 'time',
|
|
2476
|
+
value: (pv && pv.at) || '10:30',
|
|
2477
|
+
onChange: (e) => {
|
|
2478
|
+
const v = e.target.value
|
|
2479
|
+
if (!/^\d{2}:\d{2}$/.test(v)) return
|
|
2480
|
+
setPv((old) => ({ ...(old || {}), at: v }))
|
|
2481
|
+
patch({ pvAt: v }, '自动做视频的时间')
|
|
2482
|
+
},
|
|
2483
|
+
})),
|
|
2484
|
+
h('div', { 'data-bd-row': '' },
|
|
2485
|
+
h('span', { 'data-bd-label': '', 'data-bd-grow': '' }, '配音音色'),
|
|
2486
|
+
h('select', {
|
|
2487
|
+
'data-bd-input': '', style: { width: '230px' },
|
|
2488
|
+
value: (pv && pv.voice) || 'zh-CN-XiaoyiNeural',
|
|
2489
|
+
onChange: (e) => patch({ pvVoice: e.target.value }, '配音音色'),
|
|
2490
|
+
},
|
|
2491
|
+
h('option', { value: 'zh-CN-XiaoyiNeural' }, '晓伊(默认 · 偏软)'),
|
|
2492
|
+
h('option', { value: 'zh-CN-XiaoxiaoNeural' }, '晓晓(更清亮)'),
|
|
2493
|
+
h('option', { value: 'zh-CN-XiaochenNeural' }, '晓辰(偏成熟)'))),
|
|
2494
|
+
/* 参考音色三件套:DraftInput(本地草稿、失焦/回车才提交),语义与 Panel 相同 */
|
|
2495
|
+
h('div', { 'data-bd-row': '' },
|
|
2496
|
+
h('span', { 'data-bd-label': '', 'data-bd-grow': '' }, 'GPT-SoVITS 目录'),
|
|
2497
|
+
h(DraftInput, {
|
|
2498
|
+
width: '300px', type: 'text', placeholder: 'D:\\...\\GPT-SoVITS',
|
|
2499
|
+
value: (pv && pv.gsvRepo) || '', onCommit: (v) => patch({ pvGsvRepo: v }, 'GPT-SoVITS 目录'),
|
|
2500
|
+
})),
|
|
2501
|
+
h('div', { 'data-bd-row': '' },
|
|
2502
|
+
h('span', { 'data-bd-label': '', 'data-bd-grow': '' }, '参考音频(3~10 秒)'),
|
|
2503
|
+
h(DraftInput, {
|
|
2504
|
+
width: '300px', type: 'text', placeholder: 'D:\\...\\voice-ds\\ref\\dwg_0031_ref.wav',
|
|
2505
|
+
value: (pv && pv.gsvRef) || '', onCommit: (v) => patch({ pvGsvRef: v }, '参考音频'),
|
|
2506
|
+
})),
|
|
2507
|
+
h('div', { 'data-bd-row': '' },
|
|
2508
|
+
h('span', { 'data-bd-label': '', 'data-bd-grow': '' }, '参考音频的原话'),
|
|
2509
|
+
h(DraftInput, {
|
|
2510
|
+
width: '300px', type: 'text', placeholder: '那段音频里说的那句话(逐字)',
|
|
2511
|
+
value: (pv && pv.gsvPrompt) || '', onCommit: (v) => patch({ pvGsvPrompt: v }, '参考音频原话'),
|
|
2512
|
+
})),
|
|
2513
|
+
h('div', { 'data-bd-row': '' },
|
|
2514
|
+
h('span', { 'data-bd-label': '', 'data-bd-grow': '' }, '跑 GPT-SoVITS 的 Python'),
|
|
2515
|
+
h(DraftInput, {
|
|
2516
|
+
width: '300px', type: 'text', placeholder: '留空 = 用上面那个 python',
|
|
2517
|
+
value: (pv && pv.gsvPython) || '', onCommit: (v) => patch({ pvGsvPython: v }, 'GPT-SoVITS 的 Python'),
|
|
2518
|
+
})),
|
|
2519
|
+
h('div', { 'data-bd-note': '' },
|
|
2520
|
+
'默认走 edge-tts(需联网);参考音色配置齐全后使用本地 GPT-SoVITS,速度取决于设备。')))
|
|
2521
|
+
}
|
|
2522
|
+
|
|
2523
|
+
const settingLogRows = tab === 'logs' && logMode === 'events' && Array.isArray(settingLogs) ? settingLogs.filter((e) => {
|
|
2524
|
+
const q = String(settingLogFilter || '').trim().toLowerCase()
|
|
2525
|
+
if (!q) return true
|
|
2526
|
+
return [e && e.type, e && e.event, e && e.text, e && e.raw ? JSON.stringify(e.raw) : ''].some((v) => String(v || '').toLowerCase().includes(q))
|
|
2527
|
+
}).slice(-60) : []
|
|
2528
|
+
const settingLogView = () => settingLogs === null
|
|
2529
|
+
? h('div', { 'data-bd-note': '', 'data-bd-settings-log': '' }, '正在读取运行日志…')
|
|
2530
|
+
: settingLogRows.length
|
|
2531
|
+
? h('div', { 'data-bd-settings-log': '' }, settingLogRows.map((e) => { const key = String(e.eventId || e.uid || e.id || [e.at, e.type, petEntrySummary(e)].join(':')); return h('div', { 'data-bd-logrow': '', 'data-t': petEntryTone(e), key },
|
|
2532
|
+
h('div', { 'data-bd-row': '' },
|
|
2533
|
+
h('time', { 'data-bd-note': '' }, petFmtTime(e && e.at)),
|
|
2534
|
+
h('span', { 'data-bd-logevent': '', 'data-bd-grow': '' }, petEntrySummary(e)),
|
|
2535
|
+
h('button', { 'data-bd-btn': '', 'data-sm': '', onClick: () => setSettingLogOpen((old) => ({ ...old, [key]: !old[key] })) }, settingLogOpen[key] ? '收起' : '详情')),
|
|
2536
|
+
settingLogOpen[key] ? h('pre', { 'data-bd-pre': '' }, petEntryRaw(e)) : null) }))
|
|
2537
|
+
: h('div', { 'data-bd-empty': '' }, settingLogFilter ? '没有匹配的运行日志。' : '还没有运行记录。启动一次会话或后台任务后,这里会显示过程。')
|
|
2538
|
+
const settingLogCard = () => h('section', { 'data-bd-sec': '', 'data-bd-span': '2' },
|
|
2539
|
+
heading('list', '近期运行日志'),
|
|
2540
|
+
h('div', { 'data-bd-row': '' },
|
|
2541
|
+
h('span', { 'data-bd-note': '', 'data-bd-grow': '' }, '保留最近 60 条:启动、暂停、后台完成和错误都会写在这里。可按事件筛选,点击详情查看原始字段。'),
|
|
2542
|
+
h('button', { 'data-bd-btn': '', 'data-sm': '', disabled: Boolean(busy), onClick: async () => {
|
|
2543
|
+
try {
|
|
2544
|
+
const r = await jget('/pet/log?n=60')
|
|
2545
|
+
if (!r || r.ok === false) throw new Error((r && r.error) || '读不到日志')
|
|
2546
|
+
setSettingLogs(Array.isArray(r.entries) ? r.entries.slice(-60) : [])
|
|
2547
|
+
setSettingLogError('')
|
|
2548
|
+
say('运行日志已刷新', 'good')
|
|
2549
|
+
} catch (e) { setSettingLogError(String(e?.message ?? e)); say('日志刷新失败:' + String(e?.message ?? e), 'warn') }
|
|
2550
|
+
} }, '刷新'),
|
|
2551
|
+
h('input', { 'data-bd-input': '', 'data-sm': '', value: settingLogFilter, placeholder: '筛事件、结果或错误', 'aria-label': '筛选运行日志', onChange: (e) => setSettingLogFilter(e.target.value) })),
|
|
2552
|
+
settingLogError ? h('div', { 'data-bd-msg': '', 'data-warn': '' }, '日志读取失败:' + settingLogError) : null,
|
|
2553
|
+
settingLogView())
|
|
2554
|
+
const runSettings = (mode) => {
|
|
2555
|
+
const prefix = mode === 'session' ? 'session' : 'background'
|
|
2556
|
+
const saved = {
|
|
2557
|
+
model: cfg[prefix + 'ModelId'] ?? cfg.modelId ?? '',
|
|
2558
|
+
tokenBudget: cfg[prefix + 'TokenBudget'] ?? cfg.tokenBudget ?? 0,
|
|
2559
|
+
maxRounds: cfg.sessionMaxRounds ?? cfg.maxRounds ?? 64,
|
|
2560
|
+
durationMinutes: cfg[prefix + 'Minutes'] ?? (mode === 'session' ? 0 : 45),
|
|
2561
|
+
/* ⭐ 后台「时段」任务:填了 start–end 就在段内一直刷(跨午夜算到次日),留空才退回刷一轮 */
|
|
2562
|
+
windowStart: cfg.backgroundWindowStart ?? '',
|
|
2563
|
+
windowEnd: cfg.backgroundWindowEnd ?? '',
|
|
2564
|
+
}
|
|
2565
|
+
return { ...saved, ...(runDrafts[mode] || {}) }
|
|
2566
|
+
}
|
|
2567
|
+
const updateRun = (mode, field, value) => setRunDrafts((old) => ({
|
|
2568
|
+
...old, [mode]: { ...(old[mode] || {}), [field]: value },
|
|
2569
|
+
}))
|
|
2570
|
+
const readRun = (mode) => {
|
|
2571
|
+
const draft = runSettings(mode)
|
|
2572
|
+
const integer = (value, lo, hi, label) => {
|
|
2573
|
+
const n = Number(value)
|
|
2574
|
+
if (String(value).trim() === '' || !Number.isSafeInteger(n) || n < lo || n > hi) {
|
|
2575
|
+
throw new Error(label + '需要填写 ' + lo + '–' + hi + ' 之间的整数')
|
|
2576
|
+
}
|
|
2577
|
+
return n
|
|
2578
|
+
}
|
|
2579
|
+
/* ⭐ 后台时段(2026-10-28):两个要么都填、要么都空;填了就得是 HH:MM 且不能相等 */
|
|
2580
|
+
if (mode === 'background') {
|
|
2581
|
+
const start = String(draft.windowStart ?? '').trim(), end = String(draft.windowEnd ?? '').trim()
|
|
2582
|
+
const hm = (v) => /^([01]?\d|2[0-3]):[0-5]\d$/.test(v)
|
|
2583
|
+
if ((start && !end) || (!start && end)) throw new Error('后台时段的「开始」和「结束」要一起填(或都留空,退回刷一轮)')
|
|
2584
|
+
if (start && !hm(start)) throw new Error('后台时段的开始时间要写成 时:分(例如 20:00)')
|
|
2585
|
+
if (end && !hm(end)) throw new Error('后台时段的结束时间要写成 时:分(例如 23:00)')
|
|
2586
|
+
if (start && start === end) throw new Error('后台时段的开始和结束不能是同一分钟')
|
|
2587
|
+
}
|
|
2588
|
+
return {
|
|
2589
|
+
model: String(draft.model || '').trim(),
|
|
2590
|
+
tokenBudget: integer(draft.tokenBudget, 0, 5_000_000, 'token 预算'),
|
|
2591
|
+
durationMinutes: integer(draft.durationMinutes, mode === 'session' ? 0 : 1, mode === 'session' ? 1440 : 180, '运行时长'),
|
|
2592
|
+
...(mode === 'session' ? { maxRounds: integer(draft.maxRounds, 1, numLim('maxRounds', 1, 1000)[1], '轮数') } : {}),
|
|
2593
|
+
/* ⭐ 时段只走 /config 持久化(常设指令),**不进 /pet/start 请求体**——那条契约被 E2E 严格钉死 */
|
|
2594
|
+
...(mode === 'background' ? { windowStart: String(draft.windowStart ?? '').trim(), windowEnd: String(draft.windowEnd ?? '').trim() } : {}),
|
|
2595
|
+
}
|
|
2596
|
+
}
|
|
2597
|
+
const runConfig = (mode, options) => {
|
|
2598
|
+
const prefix = mode === 'session' ? 'session' : 'background'
|
|
2599
|
+
return {
|
|
2600
|
+
[prefix + 'ModelId']: options.model,
|
|
2601
|
+
[prefix + 'TokenBudget']: options.tokenBudget,
|
|
2602
|
+
[prefix + 'Minutes']: options.durationMinutes,
|
|
2603
|
+
...(mode === 'session' ? { sessionMaxRounds: options.maxRounds } : {}),
|
|
2604
|
+
/* ⭐ 后台时段落 config:常设指令,DSH 重启后 automationTick 自己把它捞起来 */
|
|
2605
|
+
...(mode === 'background' ? { backgroundWindowStart: options.windowStart, backgroundWindowEnd: options.windowEnd } : {}),
|
|
2606
|
+
}
|
|
2607
|
+
}
|
|
2608
|
+
const saveRun = async (mode) => {
|
|
2609
|
+
try {
|
|
2610
|
+
const options = readRun(mode)
|
|
2611
|
+
if (await patch(runConfig(mode, options), mode === 'session' ? '新会话默认设置' : '后台默认设置')) {
|
|
2612
|
+
setRunDrafts((old) => ({ ...old, [mode]: options }))
|
|
2613
|
+
}
|
|
2614
|
+
} catch (e) { say(String(e?.message ?? e), 'warn') }
|
|
2615
|
+
}
|
|
2616
|
+
const startRun = async (mode) => {
|
|
2617
|
+
if (runLock.current) return
|
|
2618
|
+
let options
|
|
2619
|
+
try { options = readRun(mode) } catch (e) { say(String(e?.message ?? e), 'warn'); return }
|
|
2620
|
+
runLock.current = true
|
|
2621
|
+
setBusy('start-' + mode)
|
|
2622
|
+
say('')
|
|
2623
|
+
try {
|
|
2624
|
+
let sessionId
|
|
2625
|
+
if (mode === 'session') {
|
|
2626
|
+
const created = await jpost('/pet/session-new', newSessionContext(ctx))
|
|
2627
|
+
if (!created?.ok || !created.sessionId) throw new Error((created?.error || '没能创建会话') + (created?.hint ? ';' + created.hint : ''))
|
|
2628
|
+
sessionId = created.sessionId
|
|
2629
|
+
}
|
|
2630
|
+
/* ⭐ 时段不走请求体:host 从 config 里读常设时段,手动开始只负责"现在就跑" */
|
|
2631
|
+
const body = mode === 'session'
|
|
2632
|
+
? { mode, model: options.model, tokenBudget: options.tokenBudget, durationMinutes: options.durationMinutes, maxRounds: options.maxRounds }
|
|
2633
|
+
: { mode, model: options.model, tokenBudget: options.tokenBudget, durationMinutes: options.durationMinutes }
|
|
2634
|
+
const r = await jpost('/pet/start', { ...body, ...(sessionId ? { sessionId } : {}) })
|
|
2635
|
+
if (!r?.ok || r.started === false) throw new Error((r?.error || r?.hint || '没有启动') + (r?.error && r?.hint ? ';' + r.hint : ''))
|
|
2636
|
+
if (sessionId) {
|
|
2637
|
+
try {
|
|
2638
|
+
const workspace = ctx && typeof ctx.get === 'function' ? ctx.get('uiWorkspace') : null
|
|
2639
|
+
if (workspace && typeof workspace.openSession === 'function') await workspace.openSession(sessionId)
|
|
2640
|
+
} catch { /* 已启动的会话仍可从侧栏打开 */ }
|
|
2641
|
+
say('已在新会话开始刷,可以从会话列表查看过程。', 'good')
|
|
2642
|
+
} else say('后台已开始,运行进度会显示在这里。', 'good')
|
|
2643
|
+
try { const fresh = await jget('/status'); if (fresh?.ok) setSt(fresh) } catch { /* 下次轮询补上 */ }
|
|
2644
|
+
} catch (e) { say(String(e?.message ?? e), 'warn') }
|
|
2645
|
+
finally { runLock.current = false; setBusy('') }
|
|
2646
|
+
}
|
|
2647
|
+
const controlRun = async (action) => {
|
|
2648
|
+
if (runLock.current) return
|
|
2649
|
+
runLock.current = true
|
|
2650
|
+
setBusy('control')
|
|
2651
|
+
try {
|
|
2652
|
+
const r = await jpost('/feiyu', { action, ...(st.feiyu?.sessionId ? { sessionId: st.feiyu.sessionId } : {}) })
|
|
2653
|
+
if (!r?.ok) throw new Error(r?.error || r?.hint || '操作没有完成')
|
|
2654
|
+
say(action === 'stop' ? '已暂停。' : action === 'resume' ? '已接着刷。' : '已收台。', 'good')
|
|
2655
|
+
const fresh = await jget('/status')
|
|
2656
|
+
if (fresh?.ok) setSt(fresh)
|
|
2657
|
+
} catch (e) { say(String(e?.message ?? e), 'warn') }
|
|
2658
|
+
finally { runLock.current = false; setBusy('') }
|
|
2659
|
+
}
|
|
2660
|
+
const modelField = (mode) => {
|
|
2661
|
+
const current = String(runSettings(mode).model || '')
|
|
2662
|
+
const models = Array.isArray(st.models) ? st.models : []
|
|
2663
|
+
const props = { 'data-bd-input': '', 'aria-label': '运行模型', value: current, onChange: (e) => updateRun(mode, 'model', e.target.value) }
|
|
2664
|
+
return models.length ? h('select', props,
|
|
2665
|
+
h('option', { value: '' }, mode === 'session' ? '使用当前会话的模型' : '使用宿主默认模型'),
|
|
2666
|
+
current && !models.some((m) => m.id === current) ? h('option', { value: current }, current + '(当前清单外)') : null,
|
|
2667
|
+
models.map((m) => h('option', { key: m.id, value: m.id }, m.label || m.id)))
|
|
2668
|
+
: h('input', { ...props, placeholder: 'provider/model;留空使用默认模型' })
|
|
2669
|
+
}
|
|
2670
|
+
const runPage = (mode) => {
|
|
2671
|
+
const session = mode === 'session'
|
|
2672
|
+
const options = runSettings(mode)
|
|
2673
|
+
const fy = st.feiyu || {}
|
|
2674
|
+
const live = session ? Boolean(fy.active) : Boolean(st.bgBusy)
|
|
2675
|
+
const tokens = fy.tokens || {}
|
|
2676
|
+
return h('div', { 'data-bd-page-content': '', 'data-bd-settings-page': mode },
|
|
2677
|
+
h('div', { 'data-bd-pet-grid': '' },
|
|
2678
|
+
h('section', { 'data-bd-sec': '' }, heading('sliders', '下次怎么刷'),
|
|
2679
|
+
h('label', { 'data-bd-field': '' }, h('span', { 'data-bd-label': '' }, '使用的模型'), modelField(mode)),
|
|
2680
|
+
/* ⭐ 后台「时段」任务(2026-10-28 使用者要求):填了 start–end,段内就**一直刷**——
|
|
2681
|
+
* 一轮接一轮接力到时段结束;DSH 重启 / 提前收工 / 崩溃都会被每分钟的调度钩子
|
|
2682
|
+
* 拉起来接着刷(配置是常设指令,不用守着)。留空才退回「刷一轮 X 分钟」。 */
|
|
2683
|
+
...(!session ? [
|
|
2684
|
+
h('div', { 'data-bd-row': '' },
|
|
2685
|
+
h('label', { 'data-bd-field': '', 'data-bd-grow': '' }, h('span', { 'data-bd-label': '' }, '从几点刷到几点'),
|
|
2686
|
+
h('input', { 'data-bd-input': '', type: 'time', value: options.windowStart || '', onChange: (e) => updateRun(mode, 'windowStart', e.target.value) })),
|
|
2687
|
+
h('span', { 'data-bd-label': '', style: { padding: '0 2px' } }, '→'),
|
|
2688
|
+
h('label', { 'data-bd-field': '', 'data-bd-grow': '' }, h('span', { 'data-bd-label': '' }, '结束(可跨午夜)'),
|
|
2689
|
+
h('input', { 'data-bd-input': '', type: 'time', value: options.windowEnd || '', onChange: (e) => updateRun(mode, 'windowEnd', e.target.value) }))),
|
|
2690
|
+
] : []),
|
|
2691
|
+
h('label', { 'data-bd-field': '' }, h('span', { 'data-bd-label': '' }, session ? '最多运行几分钟(0 = 不限时)' : '没填时段时,一轮刷几分钟'),
|
|
2692
|
+
h('input', { 'data-bd-input': '', type: 'number', min: session ? 0 : 1, max: session ? 1440 : 180, step: 1,
|
|
2693
|
+
value: options.durationMinutes, onChange: (e) => updateRun(mode, 'durationMinutes', e.target.value) })),
|
|
2694
|
+
session ? h('label', { 'data-bd-field': '' }, h('span', { 'data-bd-label': '' }, '最多刷多少轮'),
|
|
2695
|
+
h('input', { 'data-bd-input': '', type: 'number', min: 1, max: numLim('maxRounds', 1, 1000)[1], step: 1,
|
|
2696
|
+
value: options.maxRounds, onChange: (e) => updateRun(mode, 'maxRounds', e.target.value) })) : null,
|
|
2697
|
+
h('label', { 'data-bd-field': '' }, h('span', { 'data-bd-label': '' }, '这趟 token 预算(0 = 不限)'),
|
|
2698
|
+
h('input', { 'data-bd-input': '', type: 'number', min: 0, max: 5000000, step: 1000,
|
|
2699
|
+
value: options.tokenBudget, onChange: (e) => updateRun(mode, 'tokenBudget', e.target.value) })),
|
|
2700
|
+
h('div', { 'data-bd-note': '' }, session
|
|
2701
|
+
? '轮数与 token 在每轮结束后检查,可能略超预算;到达时限会暂停续轮,当前轮可能继续到结束。'
|
|
2702
|
+
+ '⭐ 它刷着的时候你随时插一句话,当前那轮就会作废重来 —— 想下指令(挑点别的、停下、换个口味)就大方打字,不用等它问。'
|
|
2703
|
+
: (String(options.windowStart || '') && String(options.windowEnd || '')
|
|
2704
|
+
? `时段任务:${options.windowStart}–${options.windowEnd} 内一直刷,一轮接一轮到时段结束;中途关掉 DSH,重新打开也会在时段内自动接着刷。手动「停止」后本时段不再自动拉起。token 预算是软目标,不能精确截断。`
|
|
2705
|
+
: '填上「从几点刷到几点」就是常设时段任务(推荐);留空则按下面的分钟数刷一轮,提前做完会自动接着刷到点。token 预算交给后台代理作任务目标,不能按精确用量自动截断。')),
|
|
2706
|
+
h('div', { 'data-bd-row': '' },
|
|
2707
|
+
h('button', { 'data-bd-btn': '', disabled: Boolean(busy), onClick: () => saveRun(mode) }, '保存默认设置'),
|
|
2708
|
+
h('button', { 'data-bd-btn': '', 'data-primary': '', disabled: Boolean(busy) || live, onClick: () => startRun(mode) },
|
|
2709
|
+
icon(session ? 'play' : 'moon'), busy === 'start-' + mode ? '启动中…' : session ? '新开会话,开始刷' : '开始后台刷')),
|
|
2710
|
+
h('div', { 'data-bd-note': '' }, '两个模式分别保存。修改参数不会改变正在运行的任务。')),
|
|
2711
|
+
h('section', { 'data-bd-sec': '' }, heading('pulse', '运行状态'),
|
|
2712
|
+
h('span', { 'data-bd-badge': '', 'data-live': live ? '' : undefined },
|
|
2713
|
+
live ? '运行中' : session && (fy.goal?.phase === 'paused' || fy.goal?.phase === 'blocked') ? '已暂停' : '空闲'),
|
|
2714
|
+
session ? h('div', { 'data-bd-readout': '' },
|
|
2715
|
+
h('div', {}, h('b', {}, fy.goal ? (fy.goal.rounds || 0) + ' / ' + (fy.goal.maxRounds || 0) : fy.source === 'manual' ? '手动刷站' : '—'), h('span', {}, '已刷轮数')),
|
|
2716
|
+
h('div', {}, h('b', {}, tokens.thisRun === null || tokens.thisRun === undefined || tokens.tokensUnknown ? '未计量' : Number(tokens.thisRun).toLocaleString()), h('span', {}, '本趟 token')),
|
|
2717
|
+
h('div', {}, h('b', {}, st.pickedToday ?? 0), h('span', {}, '今日精选'))) : null,
|
|
2718
|
+
h('div', { 'data-bd-kv': '' }, '当前模型:' + (session ? fy.modelActive || fy.model || '—' : st.backgroundRun?.model || (live ? '运行中' : '—'))),
|
|
2719
|
+
/* ⏳ 时段任务进度(2026-10-28):跑几小时的活不能只有「运行中」三个字 */
|
|
2720
|
+
!session && live && st.backgroundRun && Number(st.backgroundRun.deadlineAt) > 0 && Number(st.backgroundRun.startedAt) > 0 ? (() => {
|
|
2721
|
+
const start = Number(st.backgroundRun.startedAt), end = Number(st.backgroundRun.deadlineAt)
|
|
2722
|
+
const pct = Math.max(0, Math.min(100, Math.round(((Date.now() - start) / Math.max(1, end - start)) * 100)))
|
|
2723
|
+
const isWindow = Boolean(st.backgroundRun.windowEndAt)
|
|
2724
|
+
return h('div', {},
|
|
2725
|
+
h('div', { 'data-bd-kv': '' },
|
|
2726
|
+
(isWindow ? '本时段已刷 ' : '本轮已跑 ') + fmtSpanMs(Date.now() - start)
|
|
2727
|
+
+ (isWindow ? ' · 还剩 ' + fmtSpanMs(Math.max(0, end - Date.now())) : '')),
|
|
2728
|
+
h('div', { 'data-bd-tok-bar': '', title: '进度 ' + pct + '%' }, h('i', { style: { width: pct + '%' } })))
|
|
2729
|
+
})() : null,
|
|
2730
|
+
session && fy.source === 'manual' && live ? h('button', { 'data-bd-btn': '', disabled: Boolean(busy), onClick: () => controlRun('stop') }, '停止手动刷站') : null,
|
|
2731
|
+
session && fy.goal ? h('div', { 'data-bd-row': '' },
|
|
2732
|
+
live ? h('button', { 'data-bd-btn': '', disabled: Boolean(busy), onClick: () => controlRun('stop') }, '暂停')
|
|
2733
|
+
: h('button', { 'data-bd-btn': '', disabled: Boolean(busy), onClick: () => controlRun('resume') }, '接着刷'),
|
|
2734
|
+
h('button', { 'data-bd-btn': '', disabled: Boolean(busy), onClick: () => controlRun('clear') }, '收台')) : null,
|
|
2735
|
+
!session && live ? h('button', { 'data-bd-btn': '', disabled: Boolean(busy), onClick: async () => {
|
|
2736
|
+
setBusy('stop-bg')
|
|
2737
|
+
try { const r = await jpost('/pet/stop', {}); if (!r?.ok) throw new Error(r?.error || '没有停下'); say('已停止后台任务。', 'good') }
|
|
2738
|
+
catch (e) { say(String(e?.message ?? e), 'warn') } finally { setBusy('') }
|
|
2739
|
+
} }, '停止后台任务') : null,
|
|
2740
|
+
!session && st.lastRunAt ? h('div', { 'data-bd-note': '' }, '上次运行:' + petFmtTime(st.lastRunAt)) : null,
|
|
2741
|
+
h('div', { 'data-bd-note': '' }, '详细过程可在左侧「运行日志」中查看。'),
|
|
2742
|
+
/* ⏸️ 免跑牌期间如实说(st.bgWindow.holdUntil 由宿主 /status 提供) */
|
|
2743
|
+
!session && st.bgWindow && Number(st.bgWindow.holdUntil) > Date.now()
|
|
2744
|
+
? h('div', { 'data-bd-note': '' }, `本时段已停止自动拉起(到 ${new Date(st.bgWindow.holdUntil).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })});点「开始后台刷」可立刻恢复。`)
|
|
2745
|
+
: null)))
|
|
2746
|
+
}
|
|
2747
|
+
|
|
2748
|
+
/* 🐟 鲸鱼摸鱼模式的派生读数(流水卡和徽章共用) */
|
|
2749
|
+
const fyLive = Boolean((st.feiyu || {}).active)
|
|
2750
|
+
|
|
2751
|
+
const scheduleCard = () => h('section', { 'data-bd-sec': '', 'data-bd-span': '2' },
|
|
2752
|
+
heading('pulse', '定时自动运行'),
|
|
2753
|
+
ToggleRow({
|
|
2754
|
+
label: '每天按时间自动跑一轮', on: sw('scheduleEnabled'), disabled: Boolean(busy),
|
|
2755
|
+
note: '开启后每天只启动一次后台任务;窗口内重复刷新不会叠加运行。',
|
|
2756
|
+
onChange: (v) => patch({ scheduleEnabled: v }, '定时自动运行'),
|
|
2757
|
+
}),
|
|
2758
|
+
sw('scheduleEnabled') ? h('div', { 'data-bd-sub': '' },
|
|
2759
|
+
h('div', { 'data-bd-row': '', 'data-bd-toggle': '' },
|
|
2760
|
+
h('div', { 'data-bd-grow': '' },
|
|
2761
|
+
h('div', { 'data-bd-label': '' }, '运行范围'),
|
|
2762
|
+
h('div', { 'data-bd-note': '' }, scheduleAllDay ? '全天开放,今天第一次检查时启动' : '按下面的本地时间窗口执行,可跨午夜')),
|
|
2763
|
+
h(Switch, {
|
|
2764
|
+
on: Boolean(scheduleAllDay), disabled: Boolean(busy),
|
|
2765
|
+
onChange: (v) => { setScheduleAllDay(v); patch({ scheduleAllDay: v }, '定时运行范围') },
|
|
2766
|
+
})),
|
|
2767
|
+
!scheduleAllDay ? h('div', { 'data-bd-row' : '' },
|
|
2768
|
+
h('span', { 'data-bd-label': '', 'data-bd-grow': '' }, '开始时间'),
|
|
2769
|
+
h('input', { 'data-bd-input': '', type: 'time', value: scheduleStart || '18:00', onChange: (e) => { setScheduleStart(e.target.value); patch({ scheduleStart: e.target.value }, '定时开始时间') } }),
|
|
2770
|
+
h('span', { 'data-bd-label': '' }, '结束时间'),
|
|
2771
|
+
h('input', { 'data-bd-input': '', type: 'time', value: scheduleEnd || '09:00', onChange: (e) => { setScheduleEnd(e.target.value); patch({ scheduleEnd: e.target.value }, '定时结束时间') } })) : null,
|
|
2772
|
+
h('div', { 'data-bd-row' : '' },
|
|
2773
|
+
h('span', { 'data-bd-badge': '', 'data-live': st.schedule && st.schedule.inWindow ? '' : undefined }, st.schedule && st.schedule.inWindow ? '当前在窗口内' : '当前不在窗口内'),
|
|
2774
|
+
h('span', { 'data-bd-note': '', 'data-bd-grow': '' }, st.schedule && st.schedule.lastError ? '上次失败:' + st.schedule.lastError : st.schedule && st.schedule.lastStartedAt ? '上次启动:' + petFmtTime(st.schedule.lastStartedAt) : '尚未启动'),
|
|
2775
|
+
st.schedule && st.schedule.nextRunAt ? h('span', { 'data-bd-note': '' }, '下次检查:' + petFmtTime(st.schedule.nextRunAt)) : null)) : null)
|
|
2776
|
+
const basicCards = [
|
|
2777
|
+
() => h('div', { 'data-bd-sec': '', 'data-bd-basic-card': 'pet' },
|
|
2778
|
+
heading('spark', '桌宠'),
|
|
2779
|
+
ToggleRow({
|
|
2780
|
+
label: '显示桌宠', on: !(st && st.petShow === false), disabled: Boolean(busy),
|
|
2781
|
+
note: st && st.petShow === false
|
|
2782
|
+
? '藏起来了 —— 打开开关,几秒后它就游回来。'
|
|
2783
|
+
: '右键打开菜单;左键可拖动,双击按已保存的参数开新会话。',
|
|
2784
|
+
onChange: (v) => patch({ petShow: v }, '显示桌宠'),
|
|
2785
|
+
}),
|
|
2786
|
+
ToggleRow({
|
|
2787
|
+
label: '空闲时自言自语', on: !(st && st.config && st.config.idleChatter === false), disabled: Boolean(busy),
|
|
2788
|
+
note: '闲着的时候偶尔冒一句观察(两三个小时一条)——嫌吵就关成安静模式。',
|
|
2789
|
+
onChange: (v) => patch({ idleChatter: v }, '闲聊泡泡'),
|
|
2790
|
+
})),
|
|
2791
|
+
() => h('div', { 'data-bd-sec': '' },
|
|
2792
|
+
heading('spark', '刷什么 · 用什么口气'),
|
|
2793
|
+
h('div', { 'data-bd-note': '' }, '只在「鲸鱼摸鱼模式」预设里生效,留空用预设自带的那套口吻。'),
|
|
2794
|
+
h('textarea', {
|
|
2795
|
+
'data-bd-input': '', rows: 4, value: persona === null ? '' : persona,
|
|
2796
|
+
placeholder: '例:像个爱折腾的技术宅,说人话,看到夸大宣传会忍不住吐槽。',
|
|
2797
|
+
onChange: (e) => setPersona(e.target.value),
|
|
2798
|
+
}),
|
|
2799
|
+
h('div', { 'data-bd-row': '' },
|
|
2800
|
+
h('span', { 'data-bd-note': '', 'data-bd-grow': '' },
|
|
2801
|
+
persona && persona.length > 400 ? `已写 ${persona.length} 字(每轮都占一份 token)` : ''),
|
|
2802
|
+
h('button', {
|
|
2803
|
+
'data-bd-btn': '', 'data-primary': '', disabled: Boolean(busy),
|
|
2804
|
+
onClick: () => patch({ persona: String(persona || '').trim() }, '人格设定'),
|
|
2805
|
+
}, '保存'))),
|
|
2806
|
+
() => h('div', { 'data-bd-sec': '' },
|
|
2807
|
+
heading('list', '限量与报告'),
|
|
2808
|
+
h('div', { 'data-bd-row': '' },
|
|
2809
|
+
h('span', { 'data-bd-label': '', 'data-bd-grow': '' }, '一天最多挑几条'),
|
|
2810
|
+
h('input', {
|
|
2811
|
+
'data-bd-input': '', 'data-bd-num': '', type: 'number',
|
|
2812
|
+
min: numLim('perDay', 0, 50)[0], max: numLim('perDay', 0, 50)[1],
|
|
2813
|
+
value: perDay === null ? '5' : perDay, onChange: (e) => setPerDay(e.target.value),
|
|
2814
|
+
})),
|
|
2815
|
+
h('div', { 'data-bd-row': '' },
|
|
2816
|
+
h('span', { 'data-bd-label': '', 'data-bd-grow': '' }, '报告详细程度'),
|
|
2817
|
+
h('select', {
|
|
2818
|
+
'data-bd-input': '', style: { width: '140px' },
|
|
2819
|
+
value: rl === null ? 'full' : rl, onChange: (e) => setRl(e.target.value),
|
|
2820
|
+
},
|
|
2821
|
+
h('option', { value: 'full' }, '完整'),
|
|
2822
|
+
h('option', { value: 'brief' }, '精简'),
|
|
2823
|
+
h('option', { value: 'off' }, '不写'))),
|
|
2824
|
+
h('div', { 'data-bd-row': '' },
|
|
2825
|
+
h('button', {
|
|
2826
|
+
'data-bd-btn': '', 'data-primary': '', disabled: Boolean(busy),
|
|
2827
|
+
onClick: () => patch({ perDay: clampField('perDay', perDay, 0, 50), reportLevel: rl || 'full' }, '限量与报告'),
|
|
2828
|
+
}, '保存'))),
|
|
2829
|
+
() => h('div', { 'data-bd-sec': '' },
|
|
2830
|
+
heading('dots', '长期偏好'),
|
|
2831
|
+
h('div', { 'data-bd-note': '' }, '关键词与参考文件提供长期背景,不限制她刷什么。参考路径不改变新会话所属工作区。'),
|
|
2832
|
+
h('textarea', {
|
|
2833
|
+
'data-bd-input': '', rows: 3, placeholder: '一行一个',
|
|
2834
|
+
value: kws === null ? '' : kws, onChange: (e) => setKws(e.target.value),
|
|
2835
|
+
}),
|
|
2836
|
+
h('input', {
|
|
2837
|
+
'data-bd-input': '', 'aria-label': '参考文件或目录', placeholder: '参考文件或目录(可选)',
|
|
2838
|
+
value: wsDir === null ? '' : wsDir, onChange: (e) => setWsDir(e.target.value),
|
|
2839
|
+
}),
|
|
2840
|
+
h('div', { 'data-bd-row': '' },
|
|
2841
|
+
h('button', {
|
|
2842
|
+
'data-bd-btn': '', disabled: Boolean(busy),
|
|
2843
|
+
onClick: async () => {
|
|
2844
|
+
const list = String(kws || '').split('\n').map((s) => s.trim()).filter(Boolean)
|
|
2845
|
+
await patch({ keywords: list, workspace: String(wsDir || '').trim() }, '长期偏好')
|
|
2846
|
+
},
|
|
2847
|
+
}, '保存'))),
|
|
2848
|
+
() => h('div', { 'data-bd-sec': '' },
|
|
2849
|
+
heading('pulse', '出手的节奏'),
|
|
2850
|
+
h('div', { 'data-bd-row': '' },
|
|
2851
|
+
h('span', { 'data-bd-label': '', 'data-bd-grow': '' }, '节奏倍数(0.3 快 / 1 正常 / 3 慢三倍)'),
|
|
2852
|
+
h('input', {
|
|
2853
|
+
'data-bd-input': '', 'data-bd-num': '', type: 'number', step: 0.1,
|
|
2854
|
+
min: numLim('paceFactor', 0.3, 3)[0], max: numLim('paceFactor', 0.3, 3)[1],
|
|
2855
|
+
value: paceFactor === null ? '1' : paceFactor, onChange: (e) => setPaceFactor(e.target.value),
|
|
2856
|
+
})),
|
|
2857
|
+
h('div', { 'data-bd-note': '' },
|
|
2858
|
+
'倍数只影响「下次该等多久」,不会把已经钉下的间隔缩短 —— 想让它立刻变快,等手上这一条发完再调。'),
|
|
2859
|
+
h('div', { 'data-bd-row': '' },
|
|
2860
|
+
h('button', {
|
|
2861
|
+
'data-bd-btn': '', disabled: Boolean(busy),
|
|
2862
|
+
onClick: () => patch({ paceFactor: clampField('paceFactor', paceFactor, 0.3, 3) }, '出手节奏'),
|
|
2863
|
+
}, '保存'))),
|
|
2864
|
+
|
|
2865
|
+
/* ── 📡 它刚才在干嘛(2026-10-27 从旧设置页搬进来):实时流水是桌宠化时唯一没跟进弹窗的
|
|
2866
|
+
* 主力功能,这里补上。2.5 秒增量轮询 + 「正在想」呼吸条(模型读字幕的几十秒里流水
|
|
2867
|
+
* 静止,靠它撑住"没卡死"这口气)。 */
|
|
2868
|
+
() => h('section', { 'data-bd-sec': '', 'data-bd-span': '2' },
|
|
2869
|
+
h('div', { 'data-bd-row': '' },
|
|
2870
|
+
h('div', { 'data-bd-h': '', 'data-bd-grow': '' }, icon('pulse'), h('b', {}, '它刚才在干嘛'),
|
|
2871
|
+
h('span', { 'data-bd-badge': '', 'data-live': fyLive ? '' : undefined }, fyLive ? '在刷' : '没在刷')),
|
|
2872
|
+
h('button', { 'data-bd-btn': '', 'data-sm': '', onClick: onClearAct, disabled: Boolean(busy) }, '清屏')),
|
|
2873
|
+
fyLive && actLive && actLive.thinking
|
|
2874
|
+
? h('div', { 'data-bd-thinking': '' },
|
|
2875
|
+
h('i', { 'data-bd-dot': '' }),
|
|
2876
|
+
h('span', { 'data-bd-grow': '' }, actLive.round
|
|
2877
|
+
? `第 ${actLive.round}${actLive.maxRounds ? '/' + actLive.maxRounds : ''} 轮 · 正在想(翻字幕、挑片子、琢磨怎么冒泡)`
|
|
2878
|
+
: '正在想(翻字幕、挑片子、琢磨怎么冒泡)'),
|
|
2879
|
+
actLive.since
|
|
2880
|
+
? h('time', {}, '已跑 ' + fmtSpanMs(actLive.since))
|
|
2881
|
+
: null)
|
|
2882
|
+
: null,
|
|
2883
|
+
h('div', { 'data-bd-act': '' },
|
|
2884
|
+
acts.length === 0
|
|
2885
|
+
? h('div', { 'data-bd-empty': '' }, '还没动静~开冲之后这儿会一条条冒出来:搜了啥、挑了哪条、读到第几秒')
|
|
2886
|
+
: acts.map((a) => h('div', { 'data-bd-actline': '', 'data-kind': a.kind || 'info', key: a.id || a.seq },
|
|
2887
|
+
h('time', {}, petFmtTime(a.t)),
|
|
2888
|
+
h('span', {}, a.text),
|
|
2889
|
+
a.kind === 'call' ? h('em', { 'data-bd-running': '' }, '进行中') : null))),
|
|
2890
|
+
h('div', { 'data-bd-note': '' }, '它每做一件事都会在这里说一句人话;更完整的来龙去脉看下面的「近期运行日志」。'))
|
|
2891
|
+
]
|
|
2892
|
+
const pageHead = (title, note) => h('div', { 'data-bd-pghead': '' }, h('div', {}, h('b', {}, title), h('p', {}, note)))
|
|
2893
|
+
const basicPage = () => {
|
|
2894
|
+
/* 🧭 新手三步(2026-10-28):齐了这张卡自己消失 —— 不用任何人去关它 */
|
|
2895
|
+
const steps = [
|
|
2896
|
+
{ done: Boolean(st && st.venvReady), label: '装主依赖(Python 环境)', view: 'other' },
|
|
2897
|
+
{ done: Boolean(st && st.loggedIn), label: '扫码登录 B站', view: 'other' },
|
|
2898
|
+
{ done: Boolean(Number(st && st.ranToday) || (st && st.lastRunAt)), label: '开第一把刷', view: 'run' },
|
|
2899
|
+
]
|
|
2900
|
+
const todo = steps.filter((s) => !s.done)
|
|
2901
|
+
return h('div', { 'data-bd-page-content': '', 'data-bd-settings-page': 'basic' },
|
|
2902
|
+
pageHead('偏好与桌宠', '选什么,怎么说,记录多少。每张卡独立保存。'),
|
|
2903
|
+
todo.length ? h('section', { 'data-bd-sec': '', 'data-bd-onboard': '' },
|
|
2904
|
+
h('div', { 'data-bd-h': '' }, icon('spark'), h('b', {}, '先把鱼安顿好(三步,齐了这张卡自己消失)')),
|
|
2905
|
+
...steps.map((s) => h('div', { 'data-bd-row': '', key: s.label },
|
|
2906
|
+
h('span', { 'data-bd-badge': '', 'data-live': s.done ? '' : undefined }, s.done ? '✓ 已完成' : '待办'),
|
|
2907
|
+
h('span', { 'data-bd-grow': '', style: { fontWeight: s.done ? 400 : 600 } }, s.label),
|
|
2908
|
+
s.done ? null : h('button', { 'data-bd-btn': '', 'data-sm': '', onClick: () => setTab(s.view) }, '去处理')))) : null,
|
|
2909
|
+
h('div', { 'data-bd-pet-grid': '', 'data-bd-basic-grid': '' }, basicCards[1](), basicCards[3](), basicCards[0](), basicCards[2]()))
|
|
2910
|
+
}
|
|
2911
|
+
const speechPage = () => h('div', { 'data-bd-page-content': '', 'data-bd-settings-page': 'speech' },
|
|
2912
|
+
pageHead('互动与投递', '公开冒泡和私信往来,各有自己的开关。'),
|
|
2913
|
+
h('div', { 'data-bd-pet-grid': '' },
|
|
2914
|
+
h('section', { 'data-bd-sec': '' }, heading('spark', '公开发言'), ToggleRow({
|
|
2915
|
+
label: '自动发说说', on: sw('autoMoment'), disabled: Boolean(busy),
|
|
2916
|
+
note: '把这一轮的想法发成 B站 动态。默认关;关着的时候它一次都不会发。',
|
|
2917
|
+
onChange: (v) => patch({ autoMoment: v }, '自动发说说'),
|
|
2918
|
+
}),
|
|
2919
|
+
sw('autoMoment') ? h('div', { 'data-bd-sub': '' },
|
|
2920
|
+
h('div', { 'data-bd-row': '' },
|
|
2921
|
+
h('span', { 'data-bd-label': '', 'data-bd-grow': '' }, '口吻'),
|
|
2922
|
+
h('select', {
|
|
2923
|
+
'data-bd-input': '', 'data-bd-num': '', style: { width: '104px' },
|
|
2924
|
+
value: momentStyle === null ? '感想' : momentStyle, onChange: (e) => setMomentStyle(e.target.value),
|
|
2925
|
+
},
|
|
2926
|
+
['感想', '吐槽', '安利', '冷知识'].map((s) => h('option', { key: s, value: s }, s)))),
|
|
2927
|
+
h('div', { 'data-bd-row': '' },
|
|
2928
|
+
h('span', { 'data-bd-label': '', 'data-bd-grow': '' }, '每天动态目标'),
|
|
2929
|
+
h('input', {
|
|
2930
|
+
'data-bd-input': '', 'data-bd-num': '', type: 'number',
|
|
2931
|
+
min: numLim('momentPerDay', 0, 50)[0], max: numLim('momentPerDay', 0, 50)[1],
|
|
2932
|
+
value: momentPerDay === null ? '1' : momentPerDay, onChange: (e) => setMomentPerDay(e.target.value),
|
|
2933
|
+
})),
|
|
2934
|
+
h('div', { 'data-bd-note': '' }, '每天要发够这个量(不是"最多"——是当日目标;0 = 永远不发;后端上限 50)。')) : null,
|
|
2935
|
+
ToggleRow({
|
|
2936
|
+
label: '自动评论视频', on: sw('autoComment'), disabled: Boolean(busy),
|
|
2937
|
+
note: '看完有具体想说的话时再评论,也可以安静看完。按自己的口气说,不用 @ 任何人。',
|
|
2938
|
+
onChange: (v) => patch({ autoComment: v }, '自动评论'),
|
|
2939
|
+
}),
|
|
2940
|
+
sw('autoComment') ? h('div', { 'data-bd-sub': '' },
|
|
2941
|
+
h('div', { 'data-bd-note': '' }, '没有每日条数上限;是否评论由她自己决定。同一个视频只评一次,发送仍按节奏闸执行。'),
|
|
2942
|
+
dmMode === 'only' ? h('div', { 'data-bd-msg': '', 'data-warn': '' },
|
|
2943
|
+
'评论开关开着但不生效:投递方式是「只发私信」,公开评论整个关掉了~') : null) : null,
|
|
2944
|
+
ToggleRow({
|
|
2945
|
+
label: '自动回复评论', on: sw('replyToComments'), disabled: Boolean(busy),
|
|
2946
|
+
note: '有人回了你的评论,它想回就回 —— 不必每条都搭话。每轮先看一眼有没有人回你。',
|
|
2947
|
+
onChange: (v) => patch({ replyToComments: v }, '自动回复评论'),
|
|
2948
|
+
}),
|
|
2949
|
+
sw('replyToComments') ? h('div', { 'data-bd-sub': '' },
|
|
2950
|
+
h('div', { 'data-bd-note': '' }, '想回就回,没有条数上限,也可以不理。同一条只回一次 —— 它记着账本,不会对着一个人刷屏。')) : null,
|
|
2951
|
+
h('button', { 'data-bd-btn': '', disabled: Boolean(busy), onClick: () => patch({ momentStyle: momentStyle || '感想', momentPerDay: clampField('momentPerDay', momentPerDay, 0, 50) }, '动态设置') }, '保存动态设置')),
|
|
2952
|
+
h('section', { 'data-bd-sec': '' }, heading('key', '私信与投递'), ToggleRow({
|
|
2953
|
+
label: '私信投递', on: dmMode !== 'off' && dmMode !== null, disabled: Boolean(busy),
|
|
2954
|
+
note: '可选择私信或同时投递;填写你的收件 UID 后保存。',
|
|
2955
|
+
onChange: (v) => setDmMode(v ? 'both' : 'off'),
|
|
2956
|
+
}),
|
|
2957
|
+
dmMode !== 'off' && dmMode !== null ? h('div', { 'data-bd-sub': '' },
|
|
2958
|
+
h('div', { 'data-bd-row': '' },
|
|
2959
|
+
h('span', { 'data-bd-label': '', 'data-bd-grow': '' }, '收件人 UID'),
|
|
2960
|
+
h('input', {
|
|
2961
|
+
'data-bd-input': '', style: { width: '150px' }, placeholder: '数字 UID',
|
|
2962
|
+
value: dmUid === null ? '' : dmUid, onChange: (e) => setDmUid(e.target.value),
|
|
2963
|
+
})),
|
|
2964
|
+
h('div', { 'data-bd-row': '' },
|
|
2965
|
+
h('span', { 'data-bd-label': '', 'data-bd-grow': '' }, '方式'),
|
|
2966
|
+
h('select', {
|
|
2967
|
+
'data-bd-input': '', style: { width: '150px' },
|
|
2968
|
+
value: dmMode || 'off', onChange: (e) => setDmMode(e.target.value),
|
|
2969
|
+
},
|
|
2970
|
+
h('option', { value: 'off' }, '只发评论区'),
|
|
2971
|
+
h('option', { value: 'only' }, '只发私信'),
|
|
2972
|
+
h('option', { value: 'both' }, '两边都发')))) : null,
|
|
2973
|
+
/* 自动回复私信:跟「私信投递」是两件事(那是往外发成果,这是回别人发来的) */
|
|
2974
|
+
ToggleRow({
|
|
2975
|
+
label: '自动回复私信', on: sw('autoReplyDm'), disabled: Boolean(busy),
|
|
2976
|
+
note: '别人私信你,它就按自己那套脾气回一句。想回就回,不回也不算欠着。',
|
|
2977
|
+
onChange: (v) => patch({ autoReplyDm: v }, '自动回复私信'),
|
|
2978
|
+
}),
|
|
2979
|
+
sw('autoReplyDm') ? h('div', { 'data-bd-sub': '' },
|
|
2980
|
+
h('div', { 'data-bd-note': '' },
|
|
2981
|
+
`没有每日条数上限 —— 想回就回。同一条私信只回一次;新鲜事只回 ${DM_STALE_DAYS} 天内的,晾着的旧消息不去翻。`),
|
|
2982
|
+
h('div', { 'data-bd-row': '' },
|
|
2983
|
+
h('div', { 'data-bd-grow': '' },
|
|
2984
|
+
h('div', { 'data-bd-label': '' }, '只回我关注的人'),
|
|
2985
|
+
h('div', { 'data-bd-note': '' }, '开着更安全:陌生人私信多半是广告引流,一律不回;关掉就是谁都回。')),
|
|
2986
|
+
h(Switch, {
|
|
2987
|
+
on: Boolean(sw('dmReplyFollowedOnly')), disabled: Boolean(busy),
|
|
2988
|
+
onChange: (v) => patch({ dmReplyFollowedOnly: v }, '只回我关注的人'),
|
|
2989
|
+
}))) : null,
|
|
2990
|
+
h('button', { 'data-bd-btn': '', disabled: Boolean(busy), onClick: () => patch({ dmMode: dmMode || 'off', dmUid: String(dmUid || '').trim() }, '私信投递设置') }, '保存投递设置')),
|
|
2991
|
+
basicCards[4](), h('section', { 'data-bd-sec': '' }, heading('pulse', '此刻能不能发'), (() => {
|
|
2992
|
+
const pc = st && st.pace
|
|
2993
|
+
if (!pc || pc.ok === false) return h('div', { 'data-bd-note': '' }, '节奏读不到(可能要重启一次让新版生效)')
|
|
2994
|
+
const cd = pc.cooldown
|
|
2995
|
+
const qt = pc.quota
|
|
2996
|
+
const ks = pc.kinds || {}
|
|
2997
|
+
/* 🗨️ 先说人话(2026-10-28):把节奏账翻译成一句有期待感的总结,技术读数放后面 */
|
|
2998
|
+
const waits = Object.values(ks).map((v) => Number(v.waitSec) || 0)
|
|
2999
|
+
const maxWait = waits.length ? Math.max(0, ...waits) : 0
|
|
3000
|
+
const summary = cd
|
|
3001
|
+
? `她在小黑屋冷静,约 ${Math.ceil((Number(cd.waitSec) || 0) / 60)} 分钟后回来 —— 这段时间别催。`
|
|
3002
|
+
: qt && Object.keys(qt).length
|
|
3003
|
+
? '评论额度用完了,她先安静看片,到点自己再探。'
|
|
3004
|
+
: maxWait > 0
|
|
3005
|
+
? `下一次出手大约 ${Math.ceil(maxWait / 60)} 分钟后 —— 现在她在安静看片。`
|
|
3006
|
+
: '现在出手很顺,她想发就发。'
|
|
3007
|
+
const qRow = qt && Object.keys(qt).length
|
|
3008
|
+
? (() => {
|
|
3009
|
+
const q = Object.values(qt)[0] || {}
|
|
3010
|
+
const m = Math.max(1, Math.ceil((Number(q.waitSec) || 0) / 60))
|
|
3011
|
+
const strike = Number(q.strikes) || 1
|
|
3012
|
+
return h('div', { 'data-bd-msg': '', 'data-warn': '' },
|
|
3013
|
+
`📉 评论额度满了(12014):先歇 ${m} 分钟,到点自动再探一次`
|
|
3014
|
+
+ `(第 ${strike} 次,再撞会停更久:45 分 → 90 分 → 180 分封顶)。`
|
|
3015
|
+
+ '这不是风控,是账号的评论额度用完了(越撞停得越久)—— 回私信、回动态评论、发动态都不受影响。')
|
|
3016
|
+
})()
|
|
3017
|
+
: null
|
|
3018
|
+
const rows = Object.entries(ks).map(([k, v]) => {
|
|
3019
|
+
const wait = Number(v.waitSec) || 0
|
|
3020
|
+
return h('div', { 'data-bd-kv': '', key: k },
|
|
3021
|
+
`${v.label || k}:${wait > 0 ? '还要等 ' + (wait >= 90 ? Math.ceil(wait / 60) + ' 分钟' : wait + ' 秒') : '现在可以'}`
|
|
3022
|
+
+ ` · 间隔 ${v.gapSec}~${Number(v.gapSec) + Number(v.jitterSec)} 秒 · 每小时最多 ${v.hourCap} 条`)
|
|
3023
|
+
})
|
|
3024
|
+
const burst = pc.burst || {}
|
|
3025
|
+
return h('div', { 'data-bd-sub': '' },
|
|
3026
|
+
h('div', { 'data-bd-msg': '', 'data-good': !cd && !(qt && Object.keys(qt).length) ? '' : undefined }, summary),
|
|
3027
|
+
cd ? h('div', { 'data-bd-msg': '', 'data-warn': '' },
|
|
3028
|
+
`🚧 正在冷却:${cd.why || '被风控拦了'}`
|
|
3029
|
+
+ `(${cd.code || '?'})—— 还要等 ${Math.ceil((Number(cd.waitSec) || 0) / 60)} 分钟。`
|
|
3030
|
+
+ '这段别让它试探性再发,风控是越试越久的。') : null,
|
|
3031
|
+
qRow,
|
|
3032
|
+
h('div', { 'data-bd-msg': '', 'data-good': '' },
|
|
3033
|
+
`现在可以发:${Object.entries(ks).filter(([, v]) => Number(v.waitSec) <= 0).map(([, v]) => v.label).join('、') || '都还得等等'}`),
|
|
3034
|
+
...rows,
|
|
3035
|
+
h('div', { 'data-bd-note': '' },
|
|
3036
|
+
`连发刹车:${burst.windowMin || 10} 分钟里最多 ${burst.max || 10} 次出手(不管哪一类,已经用了 ${burst.used || 0} 次)—— `
|
|
3037
|
+
+ '风控看的是"这个号在动",不看动的是哪一种。'))
|
|
3038
|
+
})(),
|
|
3039
|
+
h('button', { 'data-bd-btn': '', disabled: Boolean(busy), onClick: async () => {
|
|
3040
|
+
setBusy('verify'); try { const r = await jpost('/verify', {}); if (!r?.ok) throw new Error(r?.error || '复核失败'); const rows = Array.isArray(r.checked) ? r.checked : []; say(rows.length ? '复核 ' + rows.length + ' 条,公开可见 ' + rows.filter(x => x.visible === true).length + ' 条。' : r.text || '没有需要复核的发言', 'good') } catch (e) { say(String(e?.message ?? e), 'warn') } finally { setBusy('') }
|
|
3041
|
+
} }, '复核发言'))))
|
|
3042
|
+
const runView = () => h('div', { 'data-bd-page-content': '', 'data-bd-settings-page': 'run' },
|
|
3043
|
+
pageHead('运行方式', '会话、后台分开保存。定时规则归在后台方式里。'),
|
|
3044
|
+
h('div', { 'data-bd-segments': '', 'aria-label': '运行方式' }, ...[['session','新开会话刷'], ['background','后台与定时']].map(([mode, label]) => h('button', { key: mode, 'aria-pressed': runMode === mode, onClick: () => setRunMode(mode) }, label))),
|
|
3045
|
+
runPage(runMode), runMode === 'background' ? scheduleCard() : null)
|
|
3046
|
+
const logsPage = () => h('div', { 'data-bd-page-content': '', 'data-bd-settings-page': 'logs' },
|
|
3047
|
+
pageHead('运行日志', '实时动态与运行事件,保留完整上下文。列表内滚动查看。'),
|
|
3048
|
+
h('div', { 'data-bd-row': '' },
|
|
3049
|
+
h('div', { 'data-bd-segments': '', 'aria-label': '日志类型' }, ...[['events','运行事件'], ['activity','实时动态']].map(([mode, label]) => h('button', { key: mode, 'aria-pressed': logMode === mode, onClick: () => setLogMode(mode) }, label))),
|
|
3050
|
+
h('span', { 'data-bd-grow': '' }),
|
|
3051
|
+
st.feiyu?.active ? h('button', { 'data-bd-btn': '', disabled: Boolean(busy), onClick: () => controlRun('stop') }, '暂停会话') : null,
|
|
3052
|
+
st.bgBusy ? h('button', { 'data-bd-btn': '', disabled: Boolean(busy), onClick: async () => { setBusy('stop-bg'); try { const r = await jpost('/pet/stop', {}); if (!r?.ok) throw new Error(r?.error || '停止失败'); say('已请求停止后台任务', 'good') } catch (e) { say(String(e?.message ?? e), 'warn') } finally { setBusy('') } } }, '停止后台任务') : null),
|
|
3053
|
+
logMode === 'events' ? settingLogCard() : basicCards[5]())
|
|
3054
|
+
const videoPage = () => h('div', { 'data-bd-page-content': '', 'data-bd-settings-page': 'video' },
|
|
3055
|
+
h('div', { 'data-bd-pghead': '' }, icon('film'), h('div', {}, h('b', {}, '视频'),
|
|
3056
|
+
h('p', {}, '制作日报、查看成品和配置配音。'))), pvCard())
|
|
3057
|
+
const otherPage = () => h(PetOtherSettings, { view: tab === 'learning' ? 'learning' : 'environment', st, setSt, busy, setBusy, say, patch, login, loginState,
|
|
3058
|
+
loginTone, onLoginStart, onInstall, onCheck, onInstallAsr, copyText, sw })
|
|
3059
|
+
const ledgerPage = () => h(PetTokenLedger, {
|
|
3060
|
+
st, budgetDraft: totalTokenBudget, setBudgetDraft: setTotalTokenBudget, busy,
|
|
3061
|
+
saveBudget: () => patch({ totalTokenBudget: clampField('totalTokenBudget', totalTokenBudget, 0, 50000000) }, '累计上限'),
|
|
3062
|
+
resetTokens: async () => {
|
|
3063
|
+
if (!await patch({ resetTokensTotal: true }, '累计 token 清零')) return
|
|
3064
|
+
try { const fresh = await jget('/status'); if (fresh?.ok) setSt(fresh) } catch { /* 下次状态轮询会补上 */ }
|
|
3065
|
+
},
|
|
3066
|
+
})
|
|
3067
|
+
return modalShell(tab === 'basic' ? basicPage() : tab === 'run' ? runView() : tab === 'speech' ? speechPage() : tab === 'logs' ? logsPage()
|
|
3068
|
+
: tab === 'stickers' ? h(PetStickersPanel, { st, patch, busy, say }) : tab === 'video' ? videoPage() : tab === 'ledger' ? ledgerPage() : otherPage())
|
|
3069
|
+
|
|
3070
|
+
}
|
|
3071
|
+
|
|
3072
|
+
function PetMenuPreview({ kind, anchor, st, fails, tokenValue, tokenNote, onEnter, onLeave }) {
|
|
3073
|
+
const box = React.useRef(null)
|
|
3074
|
+
const [placement, setPlacement] = React.useState(null)
|
|
3075
|
+
React.useEffect(() => {
|
|
3076
|
+
const place = () => {
|
|
3077
|
+
if (!anchor?.getBoundingClientRect) return
|
|
3078
|
+
const item = anchor.getBoundingClientRect()
|
|
3079
|
+
const menu = anchor.closest?.('[data-bd-menu]')?.getBoundingClientRect() || item
|
|
3080
|
+
const vw = window.innerWidth || 1280, vh = window.innerHeight || 800, gap = 8, edge = 10
|
|
3081
|
+
const leftRoom = menu.left - edge - gap, rightRoom = vw - menu.right - edge - gap
|
|
3082
|
+
const sideRoom = Math.max(leftRoom, rightRoom)
|
|
3083
|
+
const side = sideRoom >= 168
|
|
3084
|
+
const width = Math.max(140, Math.min(296, side ? sideRoom : vw - edge * 2))
|
|
3085
|
+
const desiredHeight = Math.min(box.current?.scrollHeight || 220, vh - edge * 2)
|
|
3086
|
+
const below = vh - menu.bottom - gap - edge, above = menu.top - gap - edge
|
|
3087
|
+
const belowMenu = below >= above
|
|
3088
|
+
const height = side ? desiredHeight : Math.max(60, Math.min(desiredHeight, belowMenu ? below : above))
|
|
3089
|
+
const left = side ? (leftRoom >= rightRoom ? menu.left - width - gap : menu.right + gap) : (vw - width) / 2
|
|
3090
|
+
const top = side ? item.top - 12 : belowMenu ? menu.bottom + gap : menu.top - gap - height
|
|
3091
|
+
setPlacement({ left: Math.max(edge, Math.min(vw - width - edge, left)), top: Math.max(edge, Math.min(vh - height - edge, top)), width, maxHeight: height })
|
|
3092
|
+
}
|
|
3093
|
+
place()
|
|
3094
|
+
window.addEventListener('resize', place)
|
|
3095
|
+
window.addEventListener('scroll', place, true)
|
|
3096
|
+
return () => { window.removeEventListener('resize', place); window.removeEventListener('scroll', place, true) }
|
|
3097
|
+
}, [kind, anchor])
|
|
3098
|
+
const cfg = st?.config || {}, fy = st?.feiyu || {}, bg = st?.backgroundRun || {}
|
|
3099
|
+
const value = (n, suffix = '') => n === null || n === undefined || n === '' ? '—' : String(n) + suffix
|
|
3100
|
+
const row = (label, text) => h('div', { 'data-bd-preview-row': '', key: label }, h('span', {}, label), h('b', {}, text))
|
|
3101
|
+
const enabled = (v) => v ? '已开' : '已关'
|
|
3102
|
+
const activity = (Array.isArray(st?.activity) ? st.activity : []).slice(-3)
|
|
3103
|
+
const title = { session: '新开会话刷', background: '后台刷一轮', logs: '最近的动静', status: '鲸鱼摸鱼状态', settings: '设置工作台', hide: '藏一会儿' }[kind]
|
|
3104
|
+
const body = kind === 'session' ? [
|
|
3105
|
+
row('模型', cfg.sessionModelId || cfg.modelId || '沿用当前会话模型'), row('本趟预算', petFmtWan(cfg.sessionTokenBudget ?? cfg.tokenBudget)), row('最多轮数', value(cfg.sessionMaxRounds ?? cfg.maxRounds, ' 轮')),
|
|
3106
|
+
h('p', { key: 'note' }, '点击后新开会话,沿用当前工作区与权限。双击鱼也能开始。'),
|
|
3107
|
+
] : kind === 'background' ? [
|
|
3108
|
+
row('现在', st?.bgBusy ? bg.task === 'learn-review' ? '正在审核学习队列' : '后台运行中' : '空闲'), row('模型', bg.active ? bg.model : cfg.backgroundModelId || '沿用宿主默认模型'),
|
|
3109
|
+
row('刷多久', (cfg.backgroundWindowStart && cfg.backgroundWindowEnd) ? cfg.backgroundWindowStart + '–' + cfg.backgroundWindowEnd + ' 内一直刷' : value(cfg.backgroundMinutes, ' 分钟')), row('本轮 token', st?.bgBusy ? tokenValue : '尚未运行'),
|
|
3110
|
+
h('p', { key: 'note' }, '点击展开后台参数与启动按钮。运行过程可在日志中查看。'),
|
|
3111
|
+
] : kind === 'logs' ? [
|
|
3112
|
+
...(activity.length ? activity.map((item, i) => h('p', { key: i, 'data-bd-preview-line': '' }, String(item.text || item.summary || '活动记录').slice(0, 180))) : [h('p', { key: 'empty' }, '状态快照里还没有近期动态。点击可看完整运行日志。')]),
|
|
3113
|
+
h('p', { key: 'note' }, '进入后可切换运行事件与实时动态。悬停预览沿用已有状态。'),
|
|
3114
|
+
] : kind === 'status' ? [
|
|
3115
|
+
row('登录', !st ? fails >= 3 ? '暂时读不到' : '读取中' : st.loggedIn ? '已登录' : '未登录'), row('当前', fy.active ? '会话里刷着呢' : st?.bgBusy ? '后台跑着呢' : '歇着'), row('今天精选', value(st?.pickedToday ?? fy.pickedToday, ' 条')),
|
|
3116
|
+
row('本轮 token', tokenValue), h('p', { key: 'note' }, tokenNote),
|
|
3117
|
+
] : kind === 'settings' ? [
|
|
3118
|
+
h('p', { key: 'pages' }, '偏好与桌宠、运行方式、互动与投递、表情收藏、学习与记忆、token 账本、运行日志、日报视频、账号与环境 九个页签 —— 左侧栏按「日常使用 / 记录与成长 / 制作与维护」分组。'),
|
|
3119
|
+
row('自动评论', enabled(cfg.autoComment)), row('自动收表情', enabled(cfg.stickerAutoCollect)), row('自动配图', enabled(cfg.stickerAutoSend)),
|
|
3120
|
+
h('p', { key: 'note' }, '点击打开工作台,每张卡独立保存。'),
|
|
3121
|
+
] : [h('p', { key: 'hide' }, '把鱼暂时藏起来,运行中的任务会继续。'), h('p', { key: 'return' }, '右下角会留下一个小圆点,点一下就能叫回来。')]
|
|
3122
|
+
return h('aside', { ref: box, id: 'bd-menu-preview-' + kind, 'data-bd-preview': kind, role: 'note', 'aria-label': title + '预览', style: placement ? { position: 'fixed', ...placement } : { position: 'fixed', visibility: 'hidden', width: 296 }, onMouseEnter: onEnter, onMouseLeave: onLeave },
|
|
3123
|
+
h('small', { 'data-bd-preview-eyebrow': '' }, '鲸鱼摸鱼 · 速览'), h('b', { 'data-bd-preview-title': '' }, title), ...body)
|
|
3124
|
+
}
|
|
3125
|
+
|
|
3126
|
+
const StablePetSettingsModal = typeof React.memo === 'function' ? React.memo(PetSettingsModal, (a, b) =>
|
|
3127
|
+
a.ctx === b.ctx && a.onClose === b.onClose && a.toast === b.toast && a.initialTab === b.initialTab
|
|
3128
|
+
&& a.status?.[0] === b.status?.[0] && a.status?.[1] === b.status?.[1] && a.status?.[2] === b.status?.[2]) : PetSettingsModal
|
|
3129
|
+
|
|
3130
|
+
/* 🐾 桌宠本体:状态灯 / 拖拽 / 弹菜单 / 新开会话刷(B 流程)都在这里编排。
|
|
3131
|
+
* 轮询只有一趟:useStatus(5000) 的 /status 拿全所有状态(feiyu 激活、bgBusy、
|
|
3132
|
+
* petShow、loggedIn、models、config)—— 状态灯「5 秒刷新」的契约就靠它。 */
|
|
3133
|
+
function PetRoot({ ctx }) {
|
|
3134
|
+
const [st, setSt, fails] = useStatus(5000)
|
|
3135
|
+
const [pos, setPos] = React.useState(null) // null = 还没从 localStorage/默认位初始化
|
|
3136
|
+
const [panel, setPanel] = React.useState(null) // null | menu | bg | log | status | settings(互斥)
|
|
3137
|
+
const [dragging, setDragging] = React.useState(false)
|
|
3138
|
+
const [sessBusy, setSessBusy] = React.useState(false)
|
|
3139
|
+
const [settingsTab, setSettingsTab] = React.useState('basic')
|
|
3140
|
+
const [bubbles, setBubbles] = React.useState([])
|
|
3141
|
+
const [toasts, toast] = useToasts()
|
|
3142
|
+
const drag = React.useRef(null)
|
|
3143
|
+
const sessLock = React.useRef(false) // 防双发(state 要等重渲染,连点两下会漏)
|
|
3144
|
+
const hiddenRef = React.useRef(false)
|
|
3145
|
+
const closeSettings = React.useRef(() => setPanel(null)).current
|
|
3146
|
+
const [preview, setPreview] = React.useState(null)
|
|
3147
|
+
const previewTimers = React.useRef({ open: null, close: null })
|
|
3148
|
+
const cancelPreviewTimers = () => {
|
|
3149
|
+
clearTimeout(previewTimers.current.open); clearTimeout(previewTimers.current.close)
|
|
3150
|
+
previewTimers.current = { open: null, close: null }
|
|
3151
|
+
}
|
|
3152
|
+
const showPreview = (kind, anchor) => {
|
|
3153
|
+
cancelPreviewTimers()
|
|
3154
|
+
previewTimers.current.open = setTimeout(() => { setPreview({ kind, anchor }); previewTimers.current.open = null }, 180)
|
|
3155
|
+
}
|
|
3156
|
+
const holdPreview = () => { clearTimeout(previewTimers.current.close); previewTimers.current.close = null }
|
|
3157
|
+
const leavePreview = () => {
|
|
3158
|
+
clearTimeout(previewTimers.current.open); clearTimeout(previewTimers.current.close)
|
|
3159
|
+
previewTimers.current.close = setTimeout(() => { setPreview(null); previewTimers.current.close = null }, 120)
|
|
3160
|
+
}
|
|
3161
|
+
const hidden = Boolean(st && st.petShow === false)
|
|
3162
|
+
hiddenRef.current = hidden
|
|
3163
|
+
React.useEffect(() => {
|
|
3164
|
+
cancelPreviewTimers()
|
|
3165
|
+
if (panel !== 'menu' || hidden) setPreview(null)
|
|
3166
|
+
return cancelPreviewTimers
|
|
3167
|
+
}, [panel, hidden])
|
|
3168
|
+
|
|
3169
|
+
/* 定位:先 localStorage(上次拖到哪),没有就右下角;窗口变了按回视口 */
|
|
3170
|
+
React.useEffect(() => {
|
|
3171
|
+
const dflt = { x: (window.innerWidth || 1280) - PET_W - 26, y: (window.innerHeight || 800) - PET_H - 26 }
|
|
3172
|
+
const saved = petStore.get(PET_POS_KEY, null)
|
|
3173
|
+
setPos(petClamp(saved && Number.isFinite(Number(saved.x)) && Number.isFinite(Number(saved.y)) ? saved : dflt))
|
|
3174
|
+
const onResize = () => setPos((p) => (p ? petClamp(p) : p))
|
|
3175
|
+
window.addEventListener('resize', onResize)
|
|
3176
|
+
return () => window.removeEventListener('resize', onResize)
|
|
3177
|
+
}, [])
|
|
3178
|
+
|
|
3179
|
+
/* 🍾 里程碑 toast(2026-10-27):称号升级、总预算用到 80% —— 桌宠自己开腔,别等人来翻设置。
|
|
3180
|
+
* 只在「跨越」的那一刻响一次(ref 记上次值),不会每 5 秒重复吵人。 */
|
|
3181
|
+
const lastTitleRef = React.useRef('')
|
|
3182
|
+
React.useEffect(() => {
|
|
3183
|
+
const total = Number(st && st.tokensTotal) || 0
|
|
3184
|
+
const title = tokTitle(total)
|
|
3185
|
+
if (total > 0 && lastTitleRef.current && title !== lastTitleRef.current) {
|
|
3186
|
+
toast(`称号升级:${lastTitleRef.current} → ${title}(一共吃下 ${total.toLocaleString()} token)`, 'good')
|
|
3187
|
+
}
|
|
3188
|
+
lastTitleRef.current = title
|
|
3189
|
+
}, [st && st.tokensTotal])
|
|
3190
|
+
const lastBudgetWarnRef = React.useRef(false)
|
|
3191
|
+
React.useEffect(() => {
|
|
3192
|
+
const cap = Number(st && st.config && st.config.totalTokenBudget) || 0
|
|
3193
|
+
const used = Number(st && st.tokensTotal) || 0
|
|
3194
|
+
if (!(cap > 0)) { lastBudgetWarnRef.current = false; return }
|
|
3195
|
+
const ratio = used / cap
|
|
3196
|
+
if (ratio >= 0.8 && !lastBudgetWarnRef.current) {
|
|
3197
|
+
lastBudgetWarnRef.current = true
|
|
3198
|
+
toast(`累计 token 用到 ${Math.round(ratio * 100)}% 了(${used.toLocaleString()} / ${cap.toLocaleString()})—— 快到总闸,它会自己收工`, 'warn')
|
|
3199
|
+
}
|
|
3200
|
+
if (ratio < 0.5) lastBudgetWarnRef.current = false // 清零或调大上限后,允许下次再提醒
|
|
3201
|
+
}, [st && st.tokensTotal, st && st.config && st.config.totalTokenBudget])
|
|
3202
|
+
/* ⚠️ 需要你(2026-10-28)+ 🎨 心情:必须在早退之前算好(早退后不得再挂 hook) */
|
|
3203
|
+
const attention = (st && st.attention && Array.isArray(st.attention.reasons) && st.attention.reasons.length) ? st.attention.reasons : []
|
|
3204
|
+
const mood = (st && st.mood) || 'neutral'
|
|
3205
|
+
/* 🗨️ 桌宠说话:宿主把收工小结/闲聊/提醒写进 st.petEvent —— at 变了就冒 toast。
|
|
3206
|
+
* 首见不冒(ref 从 0 起步),免得一打开就播昨天旧账。 */
|
|
3207
|
+
const lastPetEventRef = React.useRef(0)
|
|
3208
|
+
React.useEffect(() => {
|
|
3209
|
+
const ev = st && st.petEvent
|
|
3210
|
+
if (!ev || !Number(ev.at)) return
|
|
3211
|
+
if (lastPetEventRef.current && Number(ev.at) !== lastPetEventRef.current) {
|
|
3212
|
+
toast(String(ev.text || ''), ev.tone === 'warn' ? 'warn' : ev.tone === 'good' ? 'good' : 'info')
|
|
3213
|
+
}
|
|
3214
|
+
lastPetEventRef.current = Number(ev.at)
|
|
3215
|
+
}, [st && st.petEvent && st.petEvent.at])
|
|
3216
|
+
/* 需要你:出现新的待办时提一句(同一条只提一次,事由变了再说一遍) */
|
|
3217
|
+
const lastAttentionRef = React.useRef('')
|
|
3218
|
+
React.useEffect(() => {
|
|
3219
|
+
const key = attention.join('|')
|
|
3220
|
+
if (key && lastAttentionRef.current !== key) toast(`需要你:${attention[0]}`, 'warn')
|
|
3221
|
+
lastAttentionRef.current = key
|
|
3222
|
+
}, [attention.join('|')])
|
|
3223
|
+
|
|
3224
|
+
/* 吐泡泡:隔 5~9 秒吐 1~2 个上浮消散;页面不可见或桌宠被藏时不吐(白画一场) */
|
|
3225
|
+
React.useEffect(() => {
|
|
3226
|
+
let dead = false
|
|
3227
|
+
let seq = 0
|
|
3228
|
+
let timer = null
|
|
3229
|
+
const spawn = () => {
|
|
3230
|
+
if (dead) return
|
|
3231
|
+
if (!document.hidden && !hiddenRef.current) {
|
|
3232
|
+
const n = Math.random() < 0.4 ? 2 : 1
|
|
3233
|
+
const add = []
|
|
3234
|
+
for (let i = 0; i < n; i++) {
|
|
3235
|
+
const id = ++seq
|
|
3236
|
+
add.push({
|
|
3237
|
+
id, dx: 4 + Math.round(Math.random() * 20), s: 4 + Math.round(Math.random() * 4),
|
|
3238
|
+
t: 2.8 + Math.random() * 1.4, d: i * 0.45,
|
|
3239
|
+
})
|
|
3240
|
+
setTimeout(() => { if (!dead) setBubbles((old) => old.filter((b) => b.id !== id)) }, 4600 + i * 500)
|
|
3241
|
+
}
|
|
3242
|
+
setBubbles((old) => old.concat(add).slice(-6))
|
|
3243
|
+
}
|
|
3244
|
+
timer = setTimeout(spawn, 5600 + Math.random() * 3400)
|
|
3245
|
+
}
|
|
3246
|
+
timer = setTimeout(spawn, 2400)
|
|
3247
|
+
return () => { dead = true; if (timer) clearTimeout(timer) }
|
|
3248
|
+
}, [])
|
|
3249
|
+
|
|
3250
|
+
/* 🚀 B 流程:新开会话刷 —— /pet/session-new → openSession(防御式)→ /pet/start。
|
|
3251
|
+
* 失败逐级 toast(error 与 hint 都要说出来);期间按钮 disabled + 转圈,绝不双发。 */
|
|
3252
|
+
const runNewSession = async () => {
|
|
3253
|
+
if (sessLock.current) return
|
|
3254
|
+
sessLock.current = true
|
|
3255
|
+
setSessBusy(true)
|
|
3256
|
+
try {
|
|
3257
|
+
let r1 = null
|
|
3258
|
+
try { r1 = await jpost('/pet/session-new', newSessionContext(ctx)) }
|
|
3259
|
+
catch (e) { toast('开不了会话:' + String(e?.message ?? e), 'warn'); return }
|
|
3260
|
+
if (!r1 || r1.ok === false) {
|
|
3261
|
+
toast('开不了会话:' + ((r1 && r1.error) || '未知原因') + (r1 && r1.hint ? ' —— ' + r1.hint : ''), 'warn')
|
|
3262
|
+
return
|
|
3263
|
+
}
|
|
3264
|
+
/* 切过去看它刷:uiWorkspace 是宿主服务,可能没有/名字可能不同 ⇒ 全程防御式;
|
|
3265
|
+
* 切不过去也不拦开刷 —— 会话照样开了,只是没自动跳过去。 */
|
|
3266
|
+
try {
|
|
3267
|
+
const ws = (typeof ctx.get === 'function' ? ctx.get('uiWorkspace') : null)
|
|
3268
|
+
if (ws && typeof ws.openSession === 'function') ws.openSession(r1.sessionId)
|
|
3269
|
+
} catch { /* 切换会话失败不影响开刷 */ }
|
|
3270
|
+
toast('已开会话,鱼进去了', 'good')
|
|
3271
|
+
let r2 = null
|
|
3272
|
+
try { r2 = await jpost('/pet/start', { mode: 'session', sessionId: r1.sessionId }) }
|
|
3273
|
+
catch (e) { toast('开刷没成:' + String(e?.message ?? e), 'warn'); return }
|
|
3274
|
+
if (!r2 || r2.ok === false) {
|
|
3275
|
+
toast('开刷没成:' + ((r2 && r2.error) || '未知原因') + (r2 && r2.hint ? ' —— ' + r2.hint : ''), 'warn')
|
|
3276
|
+
return
|
|
3277
|
+
}
|
|
3278
|
+
toast('开冲!它在新会话里刷起来了 —— 点鱼随时看状态', 'good')
|
|
3279
|
+
setPanel(null)
|
|
3280
|
+
} finally { sessLock.current = false; setSessBusy(false) }
|
|
3281
|
+
}
|
|
3282
|
+
|
|
3283
|
+
/* 🙈 藏起来:先乐观消失(不然要等 5 秒轮询),存失败再弹回来 */
|
|
3284
|
+
const hidePet = async () => {
|
|
3285
|
+
setPanel(null)
|
|
3286
|
+
try {
|
|
3287
|
+
setSt((old) => ({ ...(old || {}), petShow: false }))
|
|
3288
|
+
const r = await jpost('/config', { petShow: false })
|
|
3289
|
+
if (!r || !r.config) throw new Error('没存上')
|
|
3290
|
+
} catch (e) {
|
|
3291
|
+
setSt((old) => ({ ...(old || {}), petShow: true }))
|
|
3292
|
+
toast('没藏住:' + String(e?.message ?? e), 'warn')
|
|
3293
|
+
}
|
|
3294
|
+
}
|
|
3295
|
+
|
|
3296
|
+
/* 拖拽(Pointer Events):<5px 算点击(防手抖把一点误判成拖动);拖动开始就收面板 */
|
|
3297
|
+
/* ⚠️ 卡片/弹窗也是 [data-bd-pet] 的后代(见下面渲染那段),它们的 pointerdown 会冒泡上来。
|
|
3298
|
+
不拦住的话:点菜单里的「设置」会先被当成"按住鱼拖动",松手又触发"点一下=收菜单",
|
|
3299
|
+
表现就是"点了没反应"甚至闪一下没了。所以只在鱼身上(不是卡片里)才认拖拽。 */
|
|
3300
|
+
const inFish = (e) => {
|
|
3301
|
+
const t = e.target
|
|
3302
|
+
if (!t || typeof t.closest !== 'function') return true
|
|
3303
|
+
return !t.closest('[data-bd-pet-card],[data-bd-preview],[data-bd-pet-mask],[data-bd-pet-toast],[data-bd-pet-dot]')
|
|
3304
|
+
}
|
|
3305
|
+
const onDown = (e) => {
|
|
3306
|
+
if (!inFish(e)) return
|
|
3307
|
+
if (e.pointerType === 'mouse' && e.button !== 0) return // 右键交给 onCtx
|
|
3308
|
+
drag.current = { sx: e.clientX, sy: e.clientY, ox: pos.x, oy: pos.y, moved: false, last: null }
|
|
3309
|
+
try { if (e.currentTarget.setPointerCapture) e.currentTarget.setPointerCapture(e.pointerId) } catch { }
|
|
3310
|
+
}
|
|
3311
|
+
const onMove = (e) => {
|
|
3312
|
+
const d = drag.current
|
|
3313
|
+
if (!d) return
|
|
3314
|
+
const dx = e.clientX - d.sx
|
|
3315
|
+
const dy = e.clientY - d.sy
|
|
3316
|
+
if (!d.moved && Math.abs(dx) + Math.abs(dy) >= 5) {
|
|
3317
|
+
d.moved = true
|
|
3318
|
+
setDragging(true)
|
|
3319
|
+
setPanel(null) // 面板别跟着鱼跑
|
|
3320
|
+
}
|
|
3321
|
+
if (d.moved) {
|
|
3322
|
+
d.last = petClamp({ x: d.ox + dx, y: d.oy + dy })
|
|
3323
|
+
setPos(d.last)
|
|
3324
|
+
}
|
|
3325
|
+
}
|
|
3326
|
+
const onUp = () => {
|
|
3327
|
+
const d = drag.current
|
|
3328
|
+
drag.current = null
|
|
3329
|
+
if (!d) return
|
|
3330
|
+
if (d.moved) {
|
|
3331
|
+
setDragging(false)
|
|
3332
|
+
petStore.set(PET_POS_KEY, d.last || { x: d.ox, y: d.oy })
|
|
3333
|
+
} else {
|
|
3334
|
+
setPanel((old) => (old ? null : 'menu')) // 点一下:开/收菜单
|
|
3335
|
+
}
|
|
3336
|
+
}
|
|
3337
|
+
const onDbl = (e) => {
|
|
3338
|
+
if (!inFish(e)) return
|
|
3339
|
+
e.preventDefault()
|
|
3340
|
+
setPanel(null)
|
|
3341
|
+
runNewSession() // 双击:直接新开会话刷
|
|
3342
|
+
}
|
|
3343
|
+
/* 右键:不管现在是什么面板,直接开菜单;菜单已开着且没别的东西就收起来(右键=开关)。
|
|
3344
|
+
使用者要的就是"右键它 → 出菜单 → 进设置"。 */
|
|
3345
|
+
const onCtx = (e) => {
|
|
3346
|
+
if (!inFish(e)) return
|
|
3347
|
+
e.preventDefault()
|
|
3348
|
+
e.stopPropagation()
|
|
3349
|
+
setPanel((old) => (old === 'menu' ? null : 'menu'))
|
|
3350
|
+
}
|
|
3351
|
+
|
|
3352
|
+
/* ESC / 点外部收面板;面板互斥由单一 panel 状态保证(同时只开一个) */
|
|
3353
|
+
React.useEffect(() => {
|
|
3354
|
+
if (!panel) return
|
|
3355
|
+
const onKey = (e) => { if (e.key === 'Escape') { e.stopPropagation(); setPanel(null) } }
|
|
3356
|
+
const onDocDown = (e) => {
|
|
3357
|
+
const t = e.target
|
|
3358
|
+
if (t && typeof t.closest === 'function' && t.closest('[data-bd-pet-host]')) return
|
|
3359
|
+
setPanel(null)
|
|
3360
|
+
}
|
|
3361
|
+
window.addEventListener('keydown', onKey, true)
|
|
3362
|
+
document.addEventListener('pointerdown', onDocDown, true)
|
|
3363
|
+
return () => {
|
|
3364
|
+
window.removeEventListener('keydown', onKey, true)
|
|
3365
|
+
document.removeEventListener('pointerdown', onDocDown, true)
|
|
3366
|
+
}
|
|
3367
|
+
}, [panel])
|
|
3368
|
+
|
|
3369
|
+
/* 🙈 藏起来之后:以前只能"设置 → 刷B站 → 显示桌宠"叫回来(设置页已经没了),
|
|
3370
|
+
* 现在原地留个 20px 的小圆点 —— 点它直接把 petShow 打开、鱼回到原位。
|
|
3371
|
+
* 位置钉在右下角(不挡内容、也不会跑到卡片底下去)。 */
|
|
3372
|
+
const showPet = async () => {
|
|
3373
|
+
try {
|
|
3374
|
+
setSt((old) => ({ ...(old || {}), petShow: true }))
|
|
3375
|
+
const r = await jpost('/config', { petShow: true })
|
|
3376
|
+
if (!r || !r.config) throw new Error('没存上')
|
|
3377
|
+
toast('我回来啦', 'good')
|
|
3378
|
+
} catch (e) {
|
|
3379
|
+
setSt((old) => ({ ...(old || {}), petShow: false }))
|
|
3380
|
+
toast('叫不回来:' + String(e?.message ?? e), 'warn')
|
|
3381
|
+
}
|
|
3382
|
+
}
|
|
3383
|
+
|
|
3384
|
+
/* ⚠️ 早退必须放在全部 hooks 之后(放前面会打乱 hook 顺序,重渲染直接炸) */
|
|
3385
|
+
if (!pos) return null
|
|
3386
|
+
if (hidden) {
|
|
3387
|
+
return h('button', {
|
|
3388
|
+
'data-bd-pet-dot': '', type: 'button', title: '鲸鱼摸鱼藏这儿了 · 点一下叫回来',
|
|
3389
|
+
style: { right: '18px', bottom: '18px' }, onClick: showPet,
|
|
3390
|
+
})
|
|
3391
|
+
}
|
|
3392
|
+
|
|
3393
|
+
const fy = (st && st.feiyu) || {}
|
|
3394
|
+
const cfg = (st && st.config) || {}
|
|
3395
|
+
/* 状态灯优先级:没登录/读不到状态(淡红)> 在刷(青绿呼吸)> 后台轮(琥珀呼吸)> 需要你 > 空闲(淡蓝灰) */
|
|
3396
|
+
const aura = (!st || fails >= 3 || st.loggedIn === false) ? 'err'
|
|
3397
|
+
: fy.active ? 'run'
|
|
3398
|
+
: st.bgBusy ? 'bg'
|
|
3399
|
+
: attention.length ? 'attention'
|
|
3400
|
+
: 'idle'
|
|
3401
|
+
const moodLabel = { curious: '好奇', content: '满足', glum: '有点蔫', neutral: '平静' }[mood] || '平静'
|
|
3402
|
+
const auraTitle = {
|
|
3403
|
+
idle: '鲸鱼摸鱼:歇着(点我弹菜单)',
|
|
3404
|
+
run: '在刷~(点我看状态卡)',
|
|
3405
|
+
bg: '后台那轮跑着呢…',
|
|
3406
|
+
attention: '需要你:' + (attention[0] || '有要你拿主意的事'),
|
|
3407
|
+
err: st && st.loggedIn === false ? '还没登录 B站 —— 菜单里进设置扫码' : '读不到状态(插件没起来?)',
|
|
3408
|
+
}[aura]
|
|
3409
|
+
/* 卡片默认从鱼上方弹出;上方装不下就翻到下方 —— JS 算好挂属性,CSS 消费。
|
|
3410
|
+
* 判据按「实际可用空间」算,不能写死 y<380:窗口矮的时候(笔记本 + 分屏)
|
|
3411
|
+
* 写死的阈值会让卡片翻到下面之后依然溢出屏幕底部。 */
|
|
3412
|
+
const vw = window.innerWidth || 1280
|
|
3413
|
+
const vh = window.innerHeight || 800
|
|
3414
|
+
const spaceAbove = pos.y - 10
|
|
3415
|
+
const spaceBelow = vh - pos.y - PET_H - 10
|
|
3416
|
+
const alignRight = pos.x > vw - 330
|
|
3417
|
+
const flip = spaceAbove < 330 && spaceBelow > spaceAbove
|
|
3418
|
+
const bgBusy = Boolean(st && st.bgBusy)
|
|
3419
|
+
|
|
3420
|
+
const bgRun = st?.backgroundRun
|
|
3421
|
+
const knownToken = (v) => v !== null && v !== undefined && v !== '' && Number.isFinite(Number(v)) && Number(v) >= 0
|
|
3422
|
+
const backgroundToken = Boolean(bgBusy && bgRun?.active)
|
|
3423
|
+
const runToken = backgroundToken ? bgRun?.usage?.total : fy.tokens?.thisRun
|
|
3424
|
+
const tokenValue = knownToken(runToken) ? Number(runToken).toLocaleString('zh-CN') : '未计量'
|
|
3425
|
+
const tokenNote = (backgroundToken ? (bgRun.task === 'learn-review' ? '后台审核' : '后台运行') : fy.active ? '会话运行中' : knownToken(runToken) ? '最近一趟会话' : '尚无本轮记录')
|
|
3426
|
+
+ (backgroundToken && knownToken(runToken) && bgRun.unknownRequests > 0 ? ' · 部分计量' : '')
|
|
3427
|
+
const menuCard = panel === 'menu' ? (() => {
|
|
3428
|
+
const modelTxt = (cfg.sessionModelId ?? cfg.modelId) || '当前会话的模型'
|
|
3429
|
+
const item = (key, icon, label, note, onClick, disabled, spinning) =>
|
|
3430
|
+
h('button', { 'data-bd-pet-item': '', 'data-bd-menu-key': key, disabled: Boolean(disabled),
|
|
3431
|
+
'aria-describedby': preview?.kind === key ? 'bd-menu-preview-' + key : undefined,
|
|
3432
|
+
onMouseEnter: (e) => showPreview(key, e.currentTarget), onMouseLeave: leavePreview,
|
|
3433
|
+
onFocus: (e) => showPreview(key, e.currentTarget), onBlur: leavePreview,
|
|
3434
|
+
onClick: () => { cancelPreviewTimers(); setPreview(null); onClick() } },
|
|
3435
|
+
h(PetIcon, { name: icon }),
|
|
3436
|
+
h('span', {}, h('div', {}, label), note ? h('i', {}, note) : null),
|
|
3437
|
+
spinning ? h('i', { 'data-bd-pet-spin': '' }) : null)
|
|
3438
|
+
return h('div', { 'data-bd-pet-card': '', 'data-bd-menu': '', style: { maxHeight: Math.max(80, (flip ? spaceBelow : spaceAbove) - 8) + 'px', maxWidth: Math.max(160, vw - 20) + 'px' } },
|
|
3439
|
+
h('div', { 'data-bd-pet-cardhead': '' }, h('b', {}, '鲸鱼摸鱼'), h('span', {}, '右键菜单 · 双击开刷')),
|
|
3440
|
+
/* 副标题:将用的模型/预算 —— 取设置里的 modelId/tokenBudget(/status 的 config 镜像) */
|
|
3441
|
+
item('session', 'play', '新开会话刷',
|
|
3442
|
+
sessBusy ? '正在开会话…' : `${modelTxt} · 预算 ${petFmtWan(cfg.sessionTokenBudget ?? cfg.tokenBudget)}`,
|
|
3443
|
+
() => { setPanel(null); runNewSession() }, sessBusy, sessBusy),
|
|
3444
|
+
item('background', 'moon', '后台刷一轮', '', () => setPanel('bg')),
|
|
3445
|
+
item('logs', 'list', '运行日志', '', () => { setSettingsTab('logs'); setPanel('settings') }),
|
|
3446
|
+
item('status', 'pulse', '状态卡', '', () => setPanel('status')),
|
|
3447
|
+
item('settings', 'gear', '设置', '', () => { setSettingsTab('basic'); setPanel('settings') }),
|
|
3448
|
+
h('hr', { 'data-bd-pet-sep': '' }),
|
|
3449
|
+
item('hide', 'hide', '隐藏桌宠', '', hidePet),
|
|
3450
|
+
h('footer', { 'data-bd-menu-token': '' },
|
|
3451
|
+
attention.length ? h('div', { 'data-bd-menu-attention': '' }, '⚠ ' + attention[0]) : null,
|
|
3452
|
+
h('div', {}, h('span', {}, '本轮 token'), h('b', {}, tokenValue)), h('small', {}, tokenNote)))
|
|
3453
|
+
})() : null
|
|
3454
|
+
|
|
3455
|
+
return h('div', { style: { position: 'absolute', left: '0', top: '0' } },
|
|
3456
|
+
h('div', {
|
|
3457
|
+
'data-bd-pet': '',
|
|
3458
|
+
'data-dragging': dragging ? '' : undefined,
|
|
3459
|
+
'data-align': alignRight ? 'right' : undefined,
|
|
3460
|
+
'data-flip': flip ? '' : undefined,
|
|
3461
|
+
style: { left: pos.x + 'px', top: pos.y + 'px' },
|
|
3462
|
+
onPointerDown: onDown, onPointerMove: onMove,
|
|
3463
|
+
onPointerUp: onUp, onPointerCancel: onUp,
|
|
3464
|
+
onDoubleClick: onDbl, onContextMenu: onCtx,
|
|
3465
|
+
}, [
|
|
3466
|
+
h(PetFish, { key: 'fish', aura, bubbles, title: auraTitle }),
|
|
3467
|
+
/* 🎨⭐ 心情/需要-你小灯:attention 时是急促的琥珀,平时按 soul 的心情换颜色 —— 纯显示层 */
|
|
3468
|
+
h('i', {
|
|
3469
|
+
'data-bd-pet-mood': '', 'data-mood': attention.length ? 'attention' : mood,
|
|
3470
|
+
title: attention.length ? `需要你:${attention[0]}` : `心情:${moodLabel}(来自她的小账本,只是显示)`,
|
|
3471
|
+
}),
|
|
3472
|
+
/* ⚠️ 卡片与面板必须挂在 [data-bd-pet] 里面,不能是它的兄弟:
|
|
3473
|
+
[data-bd-pet-card] 的 bottom:calc(100% + 14px) 要拿 128px 的鱼盒当参照,
|
|
3474
|
+
挂到外层那个 0 高的包装 div 上就会被推到 y<0(屏幕外,"点它没反应"就是这个)。
|
|
3475
|
+
同时 [data-bd-pet][data-align] / [data-flip] 这些选择器也要求卡片是它的后代。 */
|
|
3476
|
+
panel === 'menu' ? menuCard : null,
|
|
3477
|
+
panel === 'menu' && preview ? h(PetMenuPreview, { key: preview.kind, kind: preview.kind, anchor: preview.anchor, st, fails, tokenValue, tokenNote, onEnter: holdPreview, onLeave: leavePreview }) : null,
|
|
3478
|
+
panel === 'bg' ? h(PetBgPanel, { st, toast, onDone: () => setPanel('log') }) : null,
|
|
3479
|
+
panel === 'log' ? h(PetLogPanel, { toast }) : null,
|
|
3480
|
+
panel === 'status' ? h(PetStatusCard, { st, fails }) : null,
|
|
3481
|
+
panel === 'settings' ? h(StablePetSettingsModal, { onClose: closeSettings, toast, ctx, status: [st, setSt, fails], initialTab: settingsTab }) : null,
|
|
3482
|
+
h('div', { key: 'toast', 'data-bd-pet-toast': '' },
|
|
3483
|
+
toasts.map((t) => h('div', { key: t.id, 'data-tone': t.tone || undefined }, t.text))),
|
|
3484
|
+
]))
|
|
3485
|
+
}
|
|
3486
|
+
|
|
3487
|
+
/* 挂载用的渲染器:react-dom 三层降级(client.createRoot → createRoot → 老 render)。
|
|
3488
|
+
* 一个都拿不到(裁剪宿主 / devtools 的假 React)就返回 null —— 不挂、不报错。 */
|
|
3489
|
+
function petMakeRoot(node) {
|
|
3490
|
+
try {
|
|
3491
|
+
const rdc = require('react-dom/client')
|
|
3492
|
+
if (rdc && typeof rdc.createRoot === 'function') {
|
|
3493
|
+
const r = rdc.createRoot(node)
|
|
3494
|
+
return { render: (el) => r.render(el), unmount: () => { try { r.unmount() } catch { } } }
|
|
3495
|
+
}
|
|
3496
|
+
} catch { /* 老 react-dom 没有 client 子入口:往下试 */ }
|
|
3497
|
+
try {
|
|
3498
|
+
const rd = require('react-dom')
|
|
3499
|
+
if (rd && typeof rd.createRoot === 'function') {
|
|
3500
|
+
const r = rd.createRoot(node)
|
|
3501
|
+
return { render: (el) => r.render(el), unmount: () => { try { r.unmount() } catch { } } }
|
|
3502
|
+
}
|
|
3503
|
+
if (rd && typeof rd.render === 'function') {
|
|
3504
|
+
return {
|
|
3505
|
+
render: (el) => { try { rd.render(el, node) } catch { } },
|
|
3506
|
+
unmount: () => { try { rd.unmountComponentAtNode(node) } catch { } },
|
|
3507
|
+
}
|
|
3508
|
+
}
|
|
3509
|
+
} catch { /* 连 react-dom 都没有:理论上的裁剪宿主 */ }
|
|
3510
|
+
return null
|
|
3511
|
+
}
|
|
3512
|
+
|
|
3513
|
+
/* 挂载/卸载桌宠:自建 div 挂 document.body,不走 slots。
|
|
3514
|
+
* 样式全走 setAttribute(不碰 host.style —— 假 DOM 环境的元素没有 style 属性)。 */
|
|
3515
|
+
function mountPet(ctx) {
|
|
3516
|
+
let root = null
|
|
3517
|
+
let host = null
|
|
3518
|
+
try {
|
|
3519
|
+
host = document.createElement('div')
|
|
3520
|
+
host.setAttribute('data-bd-pet-host', '')
|
|
3521
|
+
host.setAttribute('style', 'position:fixed;left:0;top:0;width:0;height:0;overflow:visible;z-index:2147483000;')
|
|
3522
|
+
root = petMakeRoot(host)
|
|
3523
|
+
if (!root) return null
|
|
3524
|
+
document.body.appendChild(host)
|
|
3525
|
+
root.render(h(PetRoot, { ctx }))
|
|
3526
|
+
} catch { /* 挂不上(body 不可用等):清干净走人,不影响设置页 */ }
|
|
3527
|
+
if (!root) return null
|
|
3528
|
+
return () => {
|
|
3529
|
+
try { root.unmount() } catch { }
|
|
3530
|
+
try { if (host && host.remove) host.remove() } catch { }
|
|
3531
|
+
}
|
|
3532
|
+
}
|
|
3533
|
+
|
|
3534
|
+
return {
|
|
3535
|
+
/* ⚠️ 2026-10-26 起 **不再往 slots 注册任何东西**(使用者要求「鲸鱼摸鱼的设置不要出现在
|
|
3536
|
+
* DSH 的默认设置里」)。整页设置搬进了桌宠的 ⚙️ 设置弹窗(PetSettingsModal 的
|
|
3537
|
+
* 五个 tab,按功能分组且不重复挂旧设置页),所以 inject 也清空。
|
|
3538
|
+
* 改这条要连带改 selfcheck.mjs 的 slot 断言与 render-panel.cjs 的取组件方式。 */
|
|
3539
|
+
inject: [],
|
|
3540
|
+
apply(ctx) {
|
|
3541
|
+
const style = document.createElement('style')
|
|
3542
|
+
style.setAttribute('data-plugin-css', 'dsh-deepmoyu')
|
|
3543
|
+
style.textContent = CSS
|
|
3544
|
+
document.head.appendChild(style)
|
|
3545
|
+
ctx.effect(() => () => style.remove(), 'dsh-deepmoyu: 样式(桌宠 + 设置弹窗)')
|
|
3546
|
+
|
|
3547
|
+
/* 🐾 桌宠:挂在 document.body 上,本来就不依赖 slots ——
|
|
3548
|
+
* slots 拿不到(宿主裁剪)也不影响它。挂不上就安静放弃。 */
|
|
3549
|
+
const petDispose = mountPet(ctx)
|
|
3550
|
+
if (petDispose) ctx.effect(() => petDispose, 'dsh-deepmoyu: 桌宠')
|
|
3551
|
+
},
|
|
3552
|
+
/* 给 devtools 的 render-panel / shot-panel / selfcheck 留个抓手:
|
|
3553
|
+
* 没有 settings.section 之后,它们得直接拿这两个组件去渲。 */
|
|
3554
|
+
debugParts: { PetSettingsModal, PetRoot, PetFish },
|
|
3555
|
+
}
|
|
3556
|
+
},
|
|
3557
|
+
})
|