dsh-theme-customizer 1.0.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/lib/client.js ADDED
@@ -0,0 +1,3102 @@
1
+ // Generated by build_static.cjs from p3_2_client.js (clientBodyP31).
2
+ // Static bundle for the dsh web client module system.
3
+ // Do not edit by hand; regenerate with: node build_static.cjs <src> <dst>
4
+ window.__ModuleLoader__.load({
5
+ id: "dsh-theme-customizer",
6
+ factory: (require) => {
7
+ const module = { exports: {} }
8
+ const exports = module.exports
9
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" })
10
+ const React = require("react")
11
+ const plugin = {
12
+ apply(ctx) {
13
+ // ════════ 00_constants.js ════════
14
+ // ┌─ 片段 00_constants ─────────────────────────────
15
+ // │ 职责:apply 入口(slots 守卫)+ 模块/区域/颜色常量
16
+ // │ 定义:MODULES / AREAS / COLOR_ITEMS / COLOR_TOKENS / emptyArea
17
+ // │ 测试:改区域或模块清单后检查设置页渲染;改 emptyArea 检查配置默认值
18
+ const slots = ctx.get('slots')
19
+ if (slots === undefined) return
20
+
21
+ const MODULES = [
22
+ { id: 'background', title: '界面' },
23
+ // 框线板块(v0.9.18):所有 UI 默认边框/分隔线颜色 + 透明度,5 区域独立;放「字体颜色」上面(用户定)
24
+ { id: 'borders', title: '框线' },
25
+ { id: 'colors', title: '字体颜色' },
26
+ { id: 'layout', title: '布局调整' },
27
+ { id: 'presets', title: '预设' },
28
+ ]
29
+ // 框线区域(v0.9.18):main=主界面(含侧边栏,覆盖布局三列)/ cordis=Cordis 插件界面 / composer=输入区 /
30
+ // details=设置界面 / float=浮窗面板;label 显示名在 15_borders.js,选择器在 07_css_builder buildBordersCss
31
+ const BORDER_KEYS = ['main', 'cordis', 'composer', 'details', 'float']
32
+ // 框线默认色(v0.9.18 用户定):主界面/Cordis/输入区 = 淡灰 #e6e6e6(官方 border-l2 rgba(0,0,0,.1) 白底合成,与色块一致);
33
+ // 设置界面/浮窗面板 = 黑色。CSS 用(07 buildBordersCss)、色块显示用(15 BordersModule)共用一处定义
34
+ const BORDER_DEFAULT_LIGHT = '#e6e6e6'
35
+ const BORDER_DEFAULT_DARK = '#000000'
36
+ const AREAS = [
37
+ { id: 'app', label: '主界面', supported: true, modes: ['none', 'color', 'image'] },
38
+ { id: 'sidebar', label: '侧边栏', supported: true, modes: ['none', 'transparent', 'color', 'image'] },
39
+ { id: 'conversation', label: '对话区', supported: true, modes: ['none'] },
40
+ { id: 'composer', label: '输入区', supported: true, modes: ['none', 'color', 'image'] },
41
+ // 设置界面(2026-08-22,v0.9.12):官方设置面板 [class*="VOzbGW_panel"](居中模态,背景 --dsw-alias-bg-layer-2,圆角 24px)
42
+ // 逻辑类似主界面:无/纯色/图片 + 透明度 + 底色;无显示区域开关(非 app)
43
+ { id: 'details', label: '设置界面', supported: true, modes: ['none', 'color', 'image'] },
44
+ // 浮窗面板(2026-08-22,v0.9.13):自研浮窗 [data-thmcz-float](原 floatOpacity 滑条已删,改背景调整)
45
+ // 逻辑类似主界面:无/纯色/图片 + 透明度 + 底色;无显示区域开关
46
+ { id: 'float', label: '浮窗面板', supported: true, modes: ['none', 'color', 'image'] },
47
+ // Cordis 插件界面(2026-08-22,v0.9.14):官方 Cordis 面板 [class*="Nqubda_panel"](z-index:30 建层叠上下文 → ::before 安全,
48
+ // 背景 --dsw-alias-bg-base,圆角 12px)。原 cordisOpacity 滑条已删,改背景调整(像设置界面)+ 底色含透明度
49
+ { id: 'cordis', label: 'Cordis 插件界面', supported: true, modes: ['none', 'color', 'image'] },
50
+ ]
51
+ // P3 文字颜色分类(用户确认的 5 类)
52
+ const COLOR_ITEMS = [
53
+ { key: 'main', label: '正文', desc: '消息正文、标题、侧边栏导航、菜单项' },
54
+ { key: 'process', label: '过程文字', desc: '工具调用(read/pwsh)、思考摘要、上下文计量' },
55
+ { key: 'aux', label: '辅助文字', desc: '时间戳、描述、次级信息' },
56
+ { key: 'faded', label: '弱化文字', desc: '注释、占位符、禁用态' },
57
+ { key: 'accent', label: '强调文字', desc: '链接、激活高亮、品牌色文字' },
58
+ ]
59
+ const COLOR_TOKENS = {
60
+ main: ['--dsw-alias-label-primary'],
61
+ process: ['--dsw-alias-label-tertiary'],
62
+ aux: ['--dsw-alias-label-secondary'],
63
+ faded: ['--dsw-alias-label-caption', '--dsw-alias-label-dimmed'],
64
+ accent: ['--dsw-alias-brand-primary'],
65
+ }
66
+ function emptyArea() { return { mode: 'none', color: '#ffffff', opacity: 0, image: null, bottomColor: null, bottomEnabled: false, bottomOpacity: 0 } }
67
+
68
+ // ════════ 01_state.js ════════
69
+ // ┌─ 片段 01_state ─────────────────────────────────
70
+ // │ 职责:全部可变状态 + 默认值(区域/新会话/浮窗/Cordis/透明度提示)
71
+ // │ 测试:新增配置字段 = 此片段加 let + 05_storage 持久化 + 06_presets 预设快照三处联动
72
+ let floatVisible = false
73
+ let floatPos = { x: 80, y: 80, width: 560, height: 640 }
74
+ // v0.9.20 默认全开(含框线 borders——v0.9.18 加模块时漏加,这里补上;含布局调整 layout)
75
+ let floatModules = { background: true, borders: true, colors: true, layout: true, presets: true }
76
+ let floatShowReset = true // 浮窗内是否显示「全局恢复默认」按钮(v0.9.16)
77
+ let cordisEntry = true // Cordis 按钮常驻开关(侧边栏设置上方)
78
+ let areas = {
79
+ // 透明度语义统一:数值大 = 透明(主界面与侧边栏一致);默认 0 = 不透明(实/清晰)
80
+ // 主界面底色:默认开启白色(bottomEnabled/bottomColor/bottomOpacity,复用 emptyArea 字段,逻辑同侧边栏)
81
+ // 作用是主界面最底层垫色:模式"无"时显示白底;纯色/图片盖在底色之上
82
+ app: { ...emptyArea(), opacity: 0, bottomEnabled: true, bottomColor: '#ffffff', bottomOpacity: 0 },
83
+ sidebar: { ...emptyArea(), opacity: 0 },
84
+ // 输入区(composer):像主界面——默认白底常驻(bottomEnabled:true/color:#fff),背景作用在 [data-composer-seat]
85
+ composer: { ...emptyArea(), opacity: 0, bottomEnabled: true, bottomColor: '#ffffff', bottomOpacity: 0 },
86
+ // 设置界面(details,v0.9.12):像主界面——默认白底常驻,背景作用在官方设置面板 [class*="VOzbGW_panel"]
87
+ details: { ...emptyArea(), opacity: 0, bottomEnabled: true, bottomColor: '#ffffff', bottomOpacity: 0 },
88
+ // 浮窗面板(float,v0.9.13):像主界面——默认白底常驻,背景作用在自研浮窗容器 [data-thmcz-float]
89
+ float: { ...emptyArea(), opacity: 0, bottomEnabled: true, bottomColor: '#ffffff', bottomOpacity: 0 },
90
+ // Cordis 插件界面(cordis,v0.9.14):像主界面——默认白底常驻,背景作用在官方 Cordis 面板 [class*="Nqubda_panel"]
91
+ cordis: { ...emptyArea(), opacity: 0, bottomEnabled: true, bottomColor: '#ffffff', bottomOpacity: 0 },
92
+ conversation: emptyArea(),
93
+ }
94
+ // 新会话按钮(侧边栏顶部"新建会话"):showText=显示"新会话"文本;showIcon=显示图标;
95
+ // 样式 mode: none(无) / color(纯色) / image(图片),独立于侧边栏模式与底色(与主界面/侧边栏同套逻辑),
96
+ // 透明度语义与侧边栏一致(大=透明);底色(bottomEnabled/bottomColor/bottomOpacity)独立控制按钮底色
97
+ // v0.9.20:默认底色**开启 + 白色**(用户定;主界面/输入区同款默认白底)
98
+ // 旧数据 unified("与侧边栏样式统一")已废弃删除:unified=true 的旧效果 = 样式"无"
99
+ let newSession = {
100
+ showText: true, showIcon: true, mode: 'none', color: '#ffffff', opacity: 0, bottomEnabled: true, bottomColor: '#ffffff', bottomOpacity: 0, image: null,
101
+ // 图标/文本颜色:null = 跟随正文(官方默认),设置后独立覆盖(与正文解耦)
102
+ iconColor: null, textColor: null,
103
+ }
104
+ // DeepSeek Harness 标志(左上角品牌行,v0.9.17):默认品牌蓝 #3964fe + 不透明。
105
+ // 与正文解耦(常驻独立色,不跟随 --dsw-alias-label-primary);透明度语义同全局(数值大=透明);
106
+ // 作用于 .hHd-Xa_brand svg(BrandWordmark 的 path 全部 fill:currentColor → 给 svg 设 color 即变色)
107
+ let brand = { color: '#3964fe', opacity: 0 }
108
+ // 标志文字 "Harness" 部分单独颜色(v0.9.20):color null = 跟随标志整体色(brand);
109
+ // BrandWordmark path 顺序:第 1-10 个 = DeepSeek,第 11-17 个 = Harness(x 坐标 132-178)
110
+ // → CSS `svg path:nth-child(n+11) { fill: 色 }` 直接覆盖 currentColor
111
+ let brandHarness = { color: null, opacity: 0 }
112
+ // 框线(v0.9.18):所有 UI 默认边框/分隔线颜色 + 透明度(数值大=透明),5 区域独立。
113
+ // color null=官方默认(此时 opacity>0 用 color-mix 淡化官方色,层级保留);
114
+ // main=主界面(含侧边栏)/ cordis=Cordis 插件界面 / composer=输入区 / details=设置界面 / float=浮窗面板
115
+ let borders = {
116
+ main: { color: null, opacity: 0 },
117
+ cordis: { color: null, opacity: 0 },
118
+ composer: { color: null, opacity: 0 },
119
+ details: { color: null, opacity: 0 },
120
+ float: { color: null, opacity: 0 },
121
+ }
122
+ // 设置界面面板拖动位置(v0.9.19):null=官方居中(默认);{x,y}=拖动后的固定位置(persist 到 localStorage)
123
+ // 拖动用内联 style 直更(pointermove),pointerup 写回本状态 → CSS 重建保持位置(刷新不丢)
124
+ let detailsPos = null
125
+ // 设置界面可移动开关(v0.9.19,布局调整板块):false = 面板不可拖动(拖动委托直接忽略)+ 恢复原位按钮消失
126
+ let detailsDragEnabled = true
127
+ let sidebarInfo = null
128
+ let baseBg = 'rgba(249,250,251,1)'
129
+ let colors = { main: null, process: null, aux: null, faded: null, accent: null }
130
+ let officialColors = {} // 官方原色参考(读 computed style)
131
+ // 透明度说明提示(界面板块顶部,默认显示,可在「其他」板块关闭)
132
+ let showOpacityHint = true
133
+ // 输入区统计条「完全展开」:false=官方单行省略(默认),true=换行完整显示
134
+ let composerStatsExpanded = false
135
+ // 输入框高度:false=动态增高(官方默认,随内容增长直到 max-height 滚动);true=固定 N 行高度(超出滚动)
136
+ let composerFixedHeight = false
137
+ let composerRows = 4 // 固定高度时的行数(1~10),4 = 官方默认初始行数附近的舒适高度
138
+ // 输入区卡片实测宽高比缓存(固定高度时用):AreaCss useLayoutEffect 在 DOM 布局后测量写入,
139
+ // 避免 getCropRatio 在渲染期读到旧布局值(改行数后选区不跟手)。非用户配置,不持久化
140
+ let composerCardRatio = null
141
+ // Cordis 面板实测宽高比缓存(v0.9.15):面板高度内容驱动(max-height 60vh),固定公式不准 →
142
+ // AreaCss useLayoutEffect + MutationObserver 在面板开合时测量写入;未打开时用默认近似
143
+ let cordisCardRatio = null
144
+ // 输入区统计条各子项显隐(9 项独立开关):默认全显示
145
+ let composerStatsItems = {
146
+ turns: true, // 轮数
147
+ steps: true, // 步数
148
+ llm: true, // LLM 时长
149
+ tool: true, // 工具调用时长
150
+ ttft: true, // 首 token 平均
151
+ tps: true, // tok/s
152
+ cache: true, // 缓存命中
153
+ input: true, // 输入 token
154
+ output: true, // 输出 token
155
+ }
156
+ // 对话区背景(2026-08-21 新增;08-22 定稿 5 项 + 任务栏 2 项 = 7 项;08-22 v0.9.14 命令两按钮加透明度):null = 官方默认;设置后亮暗统一用用户色。
157
+ // bubble = 我的发言气泡(--dsw-specific-bubble);
158
+ // inline = 你的发言中的行内代码背景(--dsw-alias-markdown-inline-code);
159
+ // code = 代码块/编辑卡片/脚本终端背景(--dsw-alias-markdown-code-block + banner);
160
+ // scrollbar = 代码块滚动条([class*="_block_"], .md-code-block 重定义 --dsh-scrollbar-thumb);
161
+ // chatScroll = 对话区滚动条([class*="_scroll"]:has([data-chat-flow]));
162
+ // todoCollapsed / todoExpanded = 任务栏收起/展开背景([data-testid="todo-panel"]:has([aria-expanded=...]),官方 token --dsw-specific-tip)
163
+ // addBtn = 输入框「+」命令按钮背景([class*="uV2eYG_add"],官方 token --dsw-specific-selector,hover 官方 --dsw-alias-interactive-bg-hover-solid);
164
+ // cmdMenu = 命令菜单面板背景([class*="_3e4SsG_menu"],官方 token --dsw-specific-menu——被多包共用,必须精确选择器覆盖);
165
+ // addBtnOpacity / cmdMenuOpacity = 命令两按钮透明度(0=不透明官方,1=全透明;数值大=透明,语义同全局;仅 addBtn/cmdMenu 有值时生效)
166
+ // toBottom = 对话区「V」一键到底部按钮背景([class*="Md3f7G_toBottom"],官方 token --dsw-alias-button-floating-fill——被布局手柄共用,必须精确选择器覆盖)
167
+ // sliderColor / sliderOpacity = 主题设置插件滑条(range,data-thmcz-range)已填充部分(左半边)+圆点 颜色/透明度
168
+ // sliderTrackColor / sliderTrackOpacity = 滑条未填充轨道(右半边)颜色/透明度(v0.9.15;默认浏览器深色 → 必须自定义)
169
+ // scrollColor / scrollOpacity = 浮窗界面滚动条([data-thmcz-float-scroll])thumb 颜色/透明度
170
+ let convBgs = { bubble: null, inline: null, code: null, scrollbar: null, chatScroll: null, todoCollapsed: null, todoExpanded: null, addBtn: null, cmdMenu: null, addBtnOpacity: 0, cmdMenuOpacity: 0, toBottom: null, sliderColor: null, sliderOpacity: 0, sliderTrackColor: null, sliderTrackOpacity: 0, scrollColor: null, scrollOpacity: 0 }
171
+
172
+ // ════════ 02_utils.js ════════
173
+ // ┌─ 片段 02_utils ─────────────────────────────────
174
+ // │ 职责:纯工具函数(颜色解析 / var 链解析 / 转 hex),无状态
175
+ // │ 定义:parseRgb / resolveVarToken / toHex
176
+ function parseRgb(color) {
177
+ try {
178
+ const s = String(color || '').trim()
179
+ if (s.startsWith('#')) {
180
+ const h = s.replace('#', '')
181
+ if (h.length === 3) return [parseInt(h[0] + h[0], 16), parseInt(h[1] + h[1], 16), parseInt(h[2] + h[2], 16)]
182
+ if (h.length === 6) return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]
183
+ }
184
+ const m = s.match(/rgba?\(([^)]+)\)/)
185
+ if (m) {
186
+ const parts = m[1].split(',').map((x) => parseFloat(x))
187
+ return [parts[0], parts[1], parts[2]]
188
+ }
189
+ } catch (e) { /* 忽略 */ }
190
+ return [30, 32, 38]
191
+ }
192
+
193
+ // P3: 解析 var(--x) 链到最终颜色值(官方 token 定义是 var() 引用)
194
+ function resolveVarToken(value, cs) {
195
+ let v = value
196
+ for (let i = 0; i < 3; i++) {
197
+ const m = String(v || '').match(/^var\(--([^)]+)\)$/)
198
+ if (!m) break
199
+ v = cs.getPropertyValue('--' + m[1]).trim()
200
+ }
201
+ return v
202
+ }
203
+ // P3: 任意颜色格式转 #rrggbb(input[type=color] 只接受 #hex)
204
+ function toHex(color) {
205
+ try {
206
+ let s = String(color || '').trim()
207
+ if (s.startsWith('#')) {
208
+ if (s.length === 4) return '#' + s[1] + s[1] + s[2] + s[2] + s[3] + s[3]
209
+ if (s.length === 7) return s
210
+ }
211
+ const m = s.match(/rgba?\(([^)]+)\)/)
212
+ if (m) {
213
+ const parts = m[1].split(',').map((x) => parseFloat(x.trim()))
214
+ const to = (n) => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, '0')
215
+ return '#' + to(parts[0]) + to(parts[1]) + to(parts[2])
216
+ }
217
+ } catch (e) { /* 忽略 */ }
218
+ return null
219
+ }
220
+
221
+ // 大图降采样(异步,Image.onload 事件回调,非定时器):
222
+ // 按最长边缩放输出 WebP 0.92(支持透明、体积小;失败回退 PNG)。
223
+ // 用于选图后避免超大 dataURI(如 40MB 原图 → 53MB dataURL)进内存/CropPanel/CSS 变量/localStorage:
224
+ // · CSS 变量 var() 替换超大值会失效 → 背景不显示(2026-08-21 主界面/侧边栏/新会话大图事故根因)
225
+ // · localStorage 配额(~5MB)超限 → 图片被降级丢弃
226
+ // · CropPanel 加载 53MB dataURL 慢、内存暴涨
227
+ function downscaleDataUrl(dataUrl, maxDim, cb) {
228
+ const img = new Image()
229
+ img.onload = () => {
230
+ try {
231
+ let w = img.naturalWidth || 1, h = img.naturalHeight || 1
232
+ const m = Math.max(w, h)
233
+ if (m > maxDim) { const s = maxDim / m; w = Math.max(1, Math.round(w * s)); h = Math.max(1, Math.round(h * s)) }
234
+ const c = document.createElement('canvas')
235
+ c.width = w
236
+ c.height = h
237
+ c.getContext('2d').drawImage(img, 0, 0, w, h)
238
+ let out
239
+ try { out = c.toDataURL('image/webp', 0.92) } catch (e) { out = c.toDataURL('image/png') }
240
+ cb(out)
241
+ } catch (e) { cb(dataUrl) }
242
+ }
243
+ img.onerror = () => cb(dataUrl)
244
+ img.src = dataUrl
245
+ }
246
+
247
+ // #rrggbb ↔ HSV(SV 色板 ColorField 用;明度语义 = 亮度,白色会丢 H/S,拖动中需本地保留 hsv 不能反推覆盖)
248
+ function hexToHsv(hex) {
249
+ const m = String(hex || '').match(/^#?([0-9a-f]{6})$/i)
250
+ if (!m) return [0, 0, 100]
251
+ const n = parseInt(m[1], 16)
252
+ const r = ((n >> 16) & 255) / 255, g = ((n >> 8) & 255) / 255, b = (n & 255) / 255
253
+ const max = Math.max(r, g, b), min = Math.min(r, g, b)
254
+ const d = max - min
255
+ const v = max
256
+ if (d === 0) return [0, 0, Math.round(v * 100)]
257
+ const s = d / max
258
+ let h = 0
259
+ if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) * 60
260
+ else if (max === g) h = ((b - r) / d + 2) * 60
261
+ else h = ((r - g) / d + 4) * 60
262
+ return [Math.round(h), Math.round(s * 100), Math.round(v * 100)]
263
+ }
264
+ function hsvToHex(h, s, v) {
265
+ h = ((Math.round(h) % 360) + 360) % 360
266
+ s = Math.max(0, Math.min(100, s)) / 100
267
+ v = Math.max(0, Math.min(100, v)) / 100
268
+ const c = v * s
269
+ const x = c * (1 - Math.abs(((h / 60) % 2) - 1))
270
+ const m = v - c
271
+ let r = 0, g = 0, b = 0
272
+ if (h < 60) { r = c; g = x } else if (h < 120) { r = x; g = c } else if (h < 180) { g = c; b = x } else if (h < 240) { g = x; b = c } else if (h < 300) { r = x; b = c } else { r = c; b = x }
273
+ const to = (n) => Math.max(0, Math.min(255, Math.round((n + m) * 255))).toString(16).padStart(2, '0')
274
+ return '#' + to(r) + to(g) + to(b)
275
+ }
276
+
277
+ // ════════ 03_detect.js ════════
278
+ // ┌─ 片段 03_detect ────────────────────────────────
279
+ // │ 职责:DOM/官方 token 检测(官方原色采集、侧边栏探测);官方 token 定义在 body 上
280
+ // │ 定义:detectColors / detectSidebar
281
+ function detectColors() {
282
+ try {
283
+ if (typeof document === 'undefined') return
284
+ // 注意:官方 token 定义在 body 上(design-platform.css: body {...}),不是 html 根
285
+ const cs = window.getComputedStyle(document.body)
286
+ const bg = cs.getPropertyValue('--dsw-alias-bg-base').trim()
287
+ if (bg && bg !== 'transparent') baseBg = bg
288
+ // P3: 读取官方文字原色(在 token 被覆盖前)。注意:若 AreaCss 在颜色
289
+ // 已覆盖后重挂载(仅插件重启场景),读到的是覆盖值——内存态重启即重置,可接受
290
+ const off = {}
291
+ for (const it of COLOR_ITEMS) {
292
+ for (const t of COLOR_TOKENS[it.key]) {
293
+ const raw = cs.getPropertyValue(t).trim()
294
+ if (!raw || raw === 'transparent' || raw === 'initial' || raw === '') continue
295
+ const hex = toHex(resolveVarToken(raw, cs))
296
+ if (hex) { off[it.key] = hex; break }
297
+ }
298
+ }
299
+ setOfficialColors(off)
300
+ } catch (e) { /* 忽略 */ }
301
+ }
302
+
303
+ function detectSidebar() {
304
+ try {
305
+ if (typeof document === 'undefined') return null
306
+ const candidates = ['[class*="sidebar"]', '[class*="Sidebar"]', '[class*="sideBar"]', 'nav']
307
+ for (const sel of candidates) {
308
+ const els = document.querySelectorAll(sel)
309
+ for (const el of els) {
310
+ const r = el.getBoundingClientRect()
311
+ if (r.width > 50 && r.height > 100 && r.left === 0 && r.top === 0) {
312
+ const cls = typeof el.className === 'string' ? el.className.split(/\s+/).filter(Boolean)[0] : ''
313
+ const selector = cls ? '.' + cls.replace(/[^a-zA-Z0-9_-]/g, '\\$&') : el.tagName.toLowerCase()
314
+ return { selector, ratio: r.width / r.height, width: r.width, height: r.height }
315
+ }
316
+ }
317
+ }
318
+ return null
319
+ } catch (e) { /* 忽略 */ }
320
+ return null
321
+ }
322
+
323
+ // ════════ 04_store.js ════════
324
+ // ┌─ 片段 04_store ─────────────────────────────────
325
+ // │ 职责:状态订阅与修改(分通道 notify + setXxx 动作 + useStore)
326
+ // │ 性能(2026-08-21 优化):通知分 4 通道(main/stats/float/preset),组件只订阅自己关心的通道 →
327
+ // │ 颜色/透明度拖动(main)不重渲染预设面板(preset)/统计条(stats)/浮窗外壳(float),拖动更顺滑。
328
+ // │ 注意:拖动类高频动作只 notify 不 saveNow(pointerup 时保存,零定时器约定)
329
+ const listeners = { main: new Set(), stats: new Set(), float: new Set(), preset: new Set() }
330
+ function emit(ch) { const s = listeners[ch]; if (s) for (const fn of s) fn() }
331
+ // main = CSS + 通用 UI(areas/newSession/colors/sidebarInfo/showOpacityHint/composerFixedHeight|Rows/cordisEntry/floatVisible)
332
+ function notify() { emit('main') }
333
+ // stats = 输入区统计条开关(composerStatsExpanded / composerStatsItems)
334
+ function statsNotify() { emit('stats') }
335
+ // float = 浮窗(floatVisible/floatModules/floatPos/areas.float)
336
+ function floatNotify() { emit('float') }
337
+ // preset = 预设列表数据变化(loadPresets 异步完成 / 拖拽排序)
338
+ function presetNotify() { emit('preset') }
339
+ // 低频操作:同步立即保存;浮窗拖动高频只更新内存(pointerup 时保存一次)
340
+ // ⚠️ 性能(2026-08-21):CSS 相关 setter(areas/newSession/composer 高度)在拖动抑制期
341
+ // (saveSuppress 窗口,见 05_storage)走 manualCssRefresh 直更 style 标签 → 拖动中零 React 渲染(只更新 CSS 字符串);
342
+ // 松开(commitSave)后 notify 同步 React 状态。非抑制期保持 notify + saveNow(离散操作/低频不变)
343
+ function setFloatVisible(v) { floatVisible = v; notify(); floatNotify(); saveNow() }
344
+ function setCordisEntry(v) { cordisEntry = v; applyCordisEntryFlag(); notify(); saveNow() }
345
+ function setFloatPos(next) { floatPos = next; floatNotify() }
346
+ function resetFloatPos() { floatPos = { x: 80, y: 80, width: 560, height: 640 }; floatNotify(); saveNow() }
347
+ function setFloatModules(next) { floatModules = next; floatNotify(); saveNow() }
348
+ function setFloatShowReset(v) { floatShowReset = v; floatNotify(); saveNow() }
349
+ function setArea(id, next) {
350
+ areas[id] = next
351
+ if (Date.now() < saveSuppressUntil) manualCssRefresh(id)
352
+ else { notify(); saveNow() }
353
+ }
354
+ function setNewSession(next) {
355
+ newSession = next
356
+ if (Date.now() < saveSuppressUntil) manualCssRefresh('ns')
357
+ else { notify(); saveNow() }
358
+ }
359
+ // DeepSeek Harness 标志(v0.9.17):颜色/透明度拖动走抑制路径(直更 brand style 标签,零 React 渲染)
360
+ function setBrand(next) {
361
+ brand = { ...brand, ...next }
362
+ if (Date.now() < saveSuppressUntil) manualCssRefresh('brand')
363
+ else { notify(); saveNow() }
364
+ }
365
+ // 标志 "Harness" 部分单独颜色(v0.9.20):同 setBrand(走 brand style 标签)
366
+ function setBrandHarness(next) {
367
+ brandHarness = { ...brandHarness, ...next }
368
+ if (Date.now() < saveSuppressUntil) manualCssRefresh('brand')
369
+ else { notify(); saveNow() }
370
+ }
371
+ // 框线(v0.9.18):颜色/透明度拖动走抑制路径(直更 borders style 标签,零 React 渲染)
372
+ function setBorder(area, next) {
373
+ borders = { ...borders, [area]: { ...(borders[area] || {}), ...next } }
374
+ if (Date.now() < saveSuppressUntil) manualCssRefresh('borders')
375
+ else { notify(); saveNow() }
376
+ }
377
+ // 全部恢复官方默认(框线 5 区域全 null/0,仿 resetAllConvBgs)
378
+ function resetAllBorders() {
379
+ const nb = {}
380
+ for (const key of BORDER_KEYS) nb[key] = { color: null, opacity: 0 }
381
+ borders = nb
382
+ notify(); saveNow()
383
+ }
384
+ // 设置界面面板拖动位置(v0.9.19):null=官方居中;{x,y}=固定位置。pointerup 才调用(非高频)→ notify + saveNow
385
+ function setDetailsPos(v) { detailsPos = v; notify(); saveNow() }
386
+ // 设置界面可移动开关(v0.9.19)
387
+ function setDetailsDragEnabled(v) { detailsDragEnabled = v; notify(); saveNow() }
388
+ // ⚠️ 值未变跳过(2026-08-22 梳理修复):resize/observer 每触发都调用,值没变也 notify 会全量重渲染
389
+ // (Cordis 面板 MutationObserver 监听整个 body 节点增删 → 对话流每挂载一条消息都触发一次)
390
+ function setSidebarInfo(next) {
391
+ if (sidebarInfo && next && sidebarInfo.selector === next.selector && sidebarInfo.ratio === next.ratio && sidebarInfo.width === next.width && sidebarInfo.height === next.height) return
392
+ sidebarInfo = next
393
+ notify()
394
+ }
395
+ function setColor(key, v) { colors = { ...colors, [key]: v }; notify(); saveNow() }
396
+ function resetColor(key) { colors = { ...colors, [key]: null }; notify(); saveNow() }
397
+ function resetAllColors() { colors = { main: null, process: null, aux: null, faded: null, accent: null }; notify(); saveNow() }
398
+ function setOfficialColors(next) { officialColors = next; notify() }
399
+ function setShowOpacityHint(v) { showOpacityHint = v; notify(); saveNow() }
400
+ function setComposerStatsExpanded(v) { composerStatsExpanded = v; statsNotify(); saveNow() }
401
+ function setComposerFixedHeight(v) {
402
+ composerFixedHeight = v
403
+ if (Date.now() < saveSuppressUntil) manualCssRefresh('composer')
404
+ else { notify(); saveNow() }
405
+ }
406
+ function setComposerRows(v) {
407
+ composerRows = v
408
+ if (Date.now() < saveSuppressUntil) manualCssRefresh('composer')
409
+ else { notify(); saveNow() }
410
+ }
411
+ function setComposerStatsItems(next) { composerStatsItems = next; statsNotify(); saveNow() }
412
+ // 对话区背景:bubble/inline/code/scrollbar/chatScroll/todoCollapsed/todoExpanded/addBtn/cmdMenu(null=官方)
413
+ // ⚠️ 性能(2026-08-22 v0.9.14 修复):拖动抑制期(saveSuppress 窗口,见 05_storage)走 manualCssRefresh
414
+ // 直更 style 标签(零 React 渲染)——此前每帧 notify 全量重渲染,同一滑条连续拖动越拖越卡。
415
+ // ⚠️ v0.9.15 优化:bubble/inline/code(token 键)走 'conv'(style+token);其余键(滚动条/滑条/命令/V等)
416
+ // 只走 'convStyle'(纯 style 标签,不刷 token)——此前拖动滚动条/滑条颜色每帧跑 buildThemeTokens+overrideTokens → 卡顿
417
+ const CONV_TOKEN_KEYS = { bubble: true, inline: true, code: true }
418
+ function setConvBg(key, v) {
419
+ convBgs = { ...convBgs, [key]: v }
420
+ if (Date.now() < saveSuppressUntil) manualCssRefresh(CONV_TOKEN_KEYS[key] ? 'conv' : 'convStyle')
421
+ else { notify(); saveNow() }
422
+ }
423
+ // 全部恢复官方默认(对话区 8 项全 null,仿字体颜色 resetAllColors;v0.9.15 含 V 按钮)
424
+ function resetAllConvBgs() {
425
+ convBgs = { bubble: null, inline: null, code: null, scrollbar: null, chatScroll: null, todoCollapsed: null, todoExpanded: null, toBottom: null }
426
+ notify(); saveNow()
427
+ }
428
+ // 命令子板块全部恢复官方默认(仅 addBtn/cmdMenu + 透明度两项,输入区卡片内;v0.9.14 含透明度归零)
429
+ function resetCmdBgs() {
430
+ convBgs = { ...convBgs, addBtn: null, cmdMenu: null, addBtnOpacity: 0, cmdMenuOpacity: 0 }
431
+ notify(); saveNow()
432
+ }
433
+ // 全局恢复默认(v0.9.16):按钮在主题设置预设板块下方 + 浮窗内(TripleConfirmButton 三级确认),恢复所有用户配置到初始默认。
434
+ // 范围:areas(全部区域)/colors(文字颜色)/newSession/convBgs(对话区+命令+滑条+滚动条)/
435
+ // floatVisible/floatPos/floatModules/cordisEntry/showOpacityHint/composerStats*;
436
+ // 不动:sidebarInfo/baseBg/officialColors(检测缓存非用户配置)、composerCardRatio/cordisCardRatio(实测缓存)
437
+ function resetAllSettings() {
438
+ floatVisible = false
439
+ floatPos = { x: 80, y: 80, width: 560, height: 640 }
440
+ floatModules = { background: true, borders: true, colors: true, layout: true, presets: true }
441
+ floatShowReset = true
442
+ cordisEntry = true
443
+ applyCordisEntryFlag()
444
+ areas = {
445
+ app: { ...emptyArea(), opacity: 0, bottomEnabled: true, bottomColor: '#ffffff', bottomOpacity: 0 },
446
+ sidebar: { ...emptyArea(), opacity: 0 },
447
+ composer: { ...emptyArea(), opacity: 0, bottomEnabled: true, bottomColor: '#ffffff', bottomOpacity: 0 },
448
+ details: { ...emptyArea(), opacity: 0, bottomEnabled: true, bottomColor: '#ffffff', bottomOpacity: 0 },
449
+ float: { ...emptyArea(), opacity: 0, bottomEnabled: true, bottomColor: '#ffffff', bottomOpacity: 0 },
450
+ cordis: { ...emptyArea(), opacity: 0, bottomEnabled: true, bottomColor: '#ffffff', bottomOpacity: 0 },
451
+ conversation: emptyArea(),
452
+ }
453
+ newSession = { showText: true, showIcon: true, mode: 'none', color: '#ffffff', opacity: 0, bottomEnabled: true, bottomColor: '#ffffff', bottomOpacity: 0, image: null, iconColor: null, textColor: null }
454
+ brand = { color: '#3964fe', opacity: 0 }
455
+ brandHarness = { color: null, opacity: 0 }
456
+ borders = { main: { color: null, opacity: 0 }, cordis: { color: null, opacity: 0 }, composer: { color: null, opacity: 0 }, details: { color: null, opacity: 0 }, float: { color: null, opacity: 0 } }
457
+ colors = { main: null, process: null, aux: null, faded: null, accent: null }
458
+ convBgs = { bubble: null, inline: null, code: null, scrollbar: null, chatScroll: null, todoCollapsed: null, todoExpanded: null, addBtn: null, cmdMenu: null, addBtnOpacity: 0, cmdMenuOpacity: 0, toBottom: null, sliderColor: null, sliderOpacity: 0, sliderTrackColor: null, sliderTrackOpacity: 0, scrollColor: null, scrollOpacity: 0 }
459
+ showOpacityHint = true
460
+ composerStatsExpanded = false
461
+ detailsPos = null
462
+ detailsDragEnabled = true
463
+ composerFixedHeight = false
464
+ composerRows = 4
465
+ composerStatsItems = { turns: true, steps: true, llm: true, tool: true, ttft: true, tps: true, cache: true, input: true, output: true }
466
+ notify(); statsNotify(); floatNotify(); saveNow()
467
+ }
468
+ // 输入区卡片实测宽高比(非用户配置,不持久化):AreaCss 布局后测量,供 getCropRatio 使用。
469
+ // ⚠️ 值未变跳过(2026-08-22 梳理修复,同 setSidebarInfo):useLayoutEffect 每次依赖变化都调用,同值 notify 无谓重渲染
470
+ function setComposerCardRatio(v) {
471
+ if (composerCardRatio === v) return
472
+ composerCardRatio = v
473
+ notify()
474
+ }
475
+ // Cordis 面板实测宽高比(v0.9.15,非用户配置,不持久化):AreaCss useLayoutEffect+observer 面板开合时测量,供 getCropRatio 使用。
476
+ // ⚠️ 2026-08-22 事故:v0.9.15 初版漏定义本函数 → AreaCss 调用抛 ReferenceError → 整个主题 UI 崩溃(主界面图消失/层次混乱)
477
+ // ⚠️ 值未变跳过(2026-08-22 梳理修复):observer 监听 body 全量子树增删,对话流消息挂载高频触发;面板尺寸未变时不再 notify
478
+ function setCordisCardRatio(v) {
479
+ if (cordisCardRatio === v) return
480
+ cordisCardRatio = v
481
+ notify()
482
+ }
483
+ // 订阅指定通道(默认 main):channels 数组;组件只在自己关注的通道变化时重渲染
484
+ function useStore(channels) {
485
+ const [, force] = React.useState(0)
486
+ React.useEffect(() => {
487
+ const fn = () => force((n) => n + 1)
488
+ const chs = Array.isArray(channels) && channels.length ? channels : ['main']
489
+ for (const c of chs) { const s = listeners[c]; if (s) s.add(fn) }
490
+ return () => { for (const c of chs) { const s = listeners[c]; if (s) s.delete(fn) } }
491
+ }, [])
492
+ return { floatVisible, floatPos, cordisEntry, floatModules, floatShowReset, areas, sidebarInfo, colors, officialColors, lastSavedAt, newSession, brand, brandHarness, borders, detailsPos, detailsDragEnabled, showOpacityHint, composerStatsExpanded, composerFixedHeight, composerRows, composerStatsItems, composerCardRatio, cordisCardRatio, convBgs }
493
+ }
494
+
495
+ // ════════ 05_storage.js ════════
496
+ // ┌─ 片段 05_storage ───────────────────────────────
497
+ // │ 职责:localStorage 持久化(读/写/配额降级/关页前保存)+ 旧数据迁移(opacitySem/unified)
498
+ // │ 定义:loadSettings / slimAreas / saveNow / flushPersist;执行:beforeunload 监听 + loadSettings()
499
+ // │ 测试:刷新后配置保持;旧配置迁移;超大图降级保存(QuotaExceeded)
500
+ // ── 持久化:localStorage(纯浏览器存储,刷新/重启页面不丢)──
501
+ // ⚠️ 性能(2026-08-21 优化):saveNow 内不再 notify —— 所有 setXxx 已是 notify + saveNow,
502
+ // 原双重通知导致每次操作触发 2 次全量重渲染 + 2 次 CSS 重建。
503
+ // ⚠️ 拖动型控件(透明度滑块/颜色选择器)拖动期间连续 onChange → 每帧 saveNow →
504
+ // 全量 JSON.stringify(含图片 dataURI 几 MB)+ setItem 同步阻塞主线程 → 卡顿。
505
+ // 抑制方案(零定时器):拖动开始置时间戳窗口,期间 saveNow 跳过写入;
506
+ // pointerup/blur 提交保存一次;窗口 60s 超时自动恢复(pointerup 丢失兜底);beforeunload 强制保存。
507
+ const STORAGE_KEY = 'theme-customizer-config-v1'
508
+ // ⚠️ 透明度语义(统一):数值大 = 透明/虚(主界面与侧边栏一致,用户确认)。
509
+ // opacitySem: 4 = 当前统一透明度语义;3 = 主界面不透明度/侧边栏透明度(旧);2 = 全透明度(更旧);无标记 = 更早全不透明度语义
510
+ function loadSettings() {
511
+ try {
512
+ const raw = window.localStorage.getItem(STORAGE_KEY)
513
+ if (!raw) return
514
+ const c = JSON.parse(raw)
515
+ if (!c || typeof c !== 'object') return
516
+ if (c.areas && typeof c.areas === 'object') {
517
+ const sem = c.opacitySem
518
+ const next = {}
519
+ for (const k of Object.keys(c.areas)) {
520
+ const a = c.areas[k]
521
+ if (!a || typeof a !== 'object' || typeof a.opacity !== 'number') { next[k] = a; continue }
522
+ if (sem === 4) next[k] = a
523
+ else if (sem === 3) {
524
+ // 旧语义:主界面=不透明度(取反为透明度),侧边栏=透明度(保持)
525
+ next[k] = k === 'app' ? { ...a, opacity: 1 - a.opacity } : a
526
+ } else if (sem === 2) {
527
+ // 全透明度:保持
528
+ next[k] = a
529
+ } else {
530
+ // 更早全不透明度语义:全部取反
531
+ next[k] = { ...a, opacity: 1 - a.opacity }
532
+ }
533
+ }
534
+ areas = next
535
+ }
536
+ // 主界面底色(2026-08-21 新增):默认开启白色底。旧配置无此功能 → 一次性迁移开启白底。
537
+ // appBottomVersion 标记随 saveNow 持久化:迁移后下次保存带上标记,此后不再重复迁移(尊重用户之后的开关/颜色设置)
538
+ if (c.appBottomVersion !== 1 && areas.app && typeof areas.app === 'object') {
539
+ areas.app = { ...areas.app, bottomEnabled: true, bottomColor: '#ffffff', bottomOpacity: 0 }
540
+ }
541
+ // 输入区底色(2026-08-21 新增,像主界面默认白底常驻):复用同一 appBottomVersion 标记一次性迁移
542
+ if (c.appBottomVersion !== 1 && areas.composer && typeof areas.composer === 'object') {
543
+ areas.composer = { ...areas.composer, bottomEnabled: true, bottomColor: '#ffffff', bottomOpacity: 0 }
544
+ }
545
+ // 设置界面底色(2026-08-22 新增,v0.9.12,像主界面默认白底常驻):
546
+ // ⚠️ 不能复用 appBottomVersion 标记(旧配置已是 1,details 迁移会被跳过);
547
+ // 改用独立条件:details 存在且从未设过底色(bottomColor == null)→ 补默认白底。
548
+ // 无损:用户已设置的底色(字符串)不被覆盖;后续保存自动带上
549
+ if (areas.details && typeof areas.details === 'object' && areas.details.bottomColor == null) {
550
+ areas.details = { ...areas.details, bottomEnabled: true, bottomColor: '#ffffff', bottomOpacity: 0 }
551
+ }
552
+ // 浮窗面板(2026-08-22 新增区域,v0.9.13,像主界面默认白底常驻):
553
+ // ⚠️ areas = next 整体替换旧配置 → 旧配置无 float 键 → areas.float 是 undefined,
554
+ // buildFloatCss/buildImgVars 读 areas.float.mode 会抛错(整站主题面板消失事故,v0.9.13 修复)。
555
+ // 必须无条件补全整个默认区域对象(不是仅补底色字段)
556
+ if (!areas.float || typeof areas.float !== 'object' || areas.float.mode === undefined) {
557
+ areas.float = { mode: 'none', color: '#ffffff', opacity: 0, image: null, bottomEnabled: true, bottomColor: '#ffffff', bottomOpacity: 0 }
558
+ } else if (areas.float.bottomColor == null) {
559
+ areas.float = { ...areas.float, bottomEnabled: true, bottomColor: '#ffffff', bottomOpacity: 0 }
560
+ }
561
+ // Cordis 插件界面(2026-08-22 新增区域,v0.9.14,像主界面默认白底常驻):同 float 无条件补全(防缺键崩溃)
562
+ if (!areas.cordis || typeof areas.cordis !== 'object' || areas.cordis.mode === undefined) {
563
+ areas.cordis = { mode: 'none', color: '#ffffff', opacity: 0, image: null, bottomEnabled: true, bottomColor: '#ffffff', bottomOpacity: 0 }
564
+ } else if (areas.cordis.bottomColor == null) {
565
+ areas.cordis = { ...areas.cordis, bottomEnabled: true, bottomColor: '#ffffff', bottomOpacity: 0 }
566
+ }
567
+ if (c.colors && typeof c.colors === 'object') colors = { main: null, process: null, aux: null, faded: null, accent: null, ...c.colors }
568
+ if (c.newSession && typeof c.newSession === 'object') {
569
+ // 兼容旧数据:无 bottomEnabled 字段时,旧 bottomColor 有值 = 底色已开启;
570
+ // "无"选项已恢复(mode='none' 合法);旧 unified=true(跟随侧边栏)→ 迁移为样式"无"(等效透明透出);
571
+ // 旧 unified=false(独立设置)→ 保留原 mode;
572
+ // v0.9.20:无 bottomEnabled 的旧数据 → 默认开启白底(新默认;有显式值则尊重)
573
+ const nsOld = c.newSession
574
+ const nsMode = nsOld.unified === true ? 'none' : (nsOld.mode === 'transparent' ? 'none' : nsOld.mode)
575
+ newSession = { ...newSession, ...nsOld, mode: nsMode, bottomEnabled: typeof nsOld.bottomEnabled === 'boolean' ? nsOld.bottomEnabled : true }
576
+ }
577
+ // 框线(v0.9.18):旧数据无 borders 字段 → 保持默认(全 null/0);字段非法时回默认
578
+ if (c.borders && typeof c.borders === 'object') {
579
+ const nb = {}
580
+ for (const key of BORDER_KEYS) {
581
+ const b = c.borders[key]
582
+ nb[key] = {
583
+ color: b && typeof b === 'object' && typeof b.color === 'string' && /^#[0-9a-f]{6}$/i.test(b.color) ? b.color : null,
584
+ opacity: b && typeof b === 'object' && typeof b.opacity === 'number' && b.opacity >= 0 && b.opacity <= 1 ? b.opacity : 0,
585
+ }
586
+ }
587
+ borders = nb
588
+ }
589
+ if (typeof c.cordisEntry === 'boolean') cordisEntry = c.cordisEntry
590
+ // DeepSeek Harness 标志(v0.9.17):旧数据无 brand 字段 → 保持默认(品牌蓝 + 不透明);字段非法时回默认
591
+ if (c.brand && typeof c.brand === 'object') {
592
+ brand = {
593
+ color: typeof c.brand.color === 'string' && /^#[0-9a-f]{6}$/i.test(c.brand.color) ? c.brand.color : '#3964fe',
594
+ opacity: typeof c.brand.opacity === 'number' && c.brand.opacity >= 0 && c.brand.opacity <= 1 ? c.brand.opacity : 0,
595
+ }
596
+ }
597
+ // 标志 "Harness" 单独颜色(v0.9.20):color null=跟随标志整体;缺失/非法保持默认
598
+ if (c.brandHarness && typeof c.brandHarness === 'object') {
599
+ brandHarness = {
600
+ color: typeof c.brandHarness.color === 'string' && /^#[0-9a-f]{6}$/i.test(c.brandHarness.color) ? c.brandHarness.color : null,
601
+ opacity: typeof c.brandHarness.opacity === 'number' && c.brandHarness.opacity >= 0 && c.brandHarness.opacity <= 1 ? c.brandHarness.opacity : 0,
602
+ }
603
+ }
604
+ // 浮窗显示内容:旧配置缺新模块键(borders 等)→ 合并默认补全(v0.9.20:默认全开)
605
+ if (c.floatModules && typeof c.floatModules === 'object') floatModules = { ...floatModules, ...c.floatModules }
606
+ if (typeof c.floatShowReset === 'boolean') floatShowReset = c.floatShowReset
607
+ if (c.floatPos && typeof c.floatPos === 'object') floatPos = c.floatPos
608
+ if (typeof c.floatVisible === 'boolean') floatVisible = c.floatVisible
609
+ if (typeof c.showOpacityHint === 'boolean') showOpacityHint = c.showOpacityHint
610
+ // 设置界面拖动位置(v0.9.19):合法 {x,y} 才恢复;缺失/非法 = null(居中)
611
+ if (c.detailsPos && typeof c.detailsPos === 'object' && typeof c.detailsPos.x === 'number' && typeof c.detailsPos.y === 'number') detailsPos = c.detailsPos
612
+ if (typeof c.detailsDragEnabled === 'boolean') detailsDragEnabled = c.detailsDragEnabled
613
+ if (typeof c.composerStatsExpanded === 'boolean') composerStatsExpanded = c.composerStatsExpanded
614
+ if (typeof c.composerFixedHeight === 'boolean') composerFixedHeight = c.composerFixedHeight
615
+ if (typeof c.composerRows === 'number') composerRows = c.composerRows
616
+ if (c.composerStatsItems && typeof c.composerStatsItems === 'object') composerStatsItems = { ...composerStatsItems, ...c.composerStatsItems }
617
+ // 对话区背景(2026-08-21 新增):null=官方默认;旧数据无此字段 → 保持默认;旧 terminal/assistant 字段已改版(忽略)
618
+ // v0.9.14:命令两按钮透明度字段(addBtnOpacity/cmdMenuOpacity)默认 0;v0.9.15:toBottom/sliderColor/sliderTrackColor/scrollColor 及透明度
619
+ if (c.convBgs && typeof c.convBgs === 'object') convBgs = { bubble: null, inline: null, code: null, scrollbar: null, chatScroll: null, todoCollapsed: null, todoExpanded: null, addBtn: null, cmdMenu: null, addBtnOpacity: 0, cmdMenuOpacity: 0, toBottom: null, sliderColor: null, sliderOpacity: 0, sliderTrackColor: null, sliderTrackOpacity: 0, scrollColor: null, scrollOpacity: 0, ...c.convBgs }
620
+ // 兼容旧数据:缺新字段时立即写回一次(补全 cordisEntry 等);saveNow 不再 notify,这里补一次
621
+ if (typeof c.cordisEntry !== 'boolean' || c.opacitySem !== 4) { saveNow(); notify() }
622
+ } catch (e) { /* 忽略 */ }
623
+ }
624
+ function slimAreas() {
625
+ // 图片过大超出 localStorage 限额时:保留配置、丢弃图片
626
+ const out = {}
627
+ for (const k of Object.keys(areas)) {
628
+ const a = areas[k]
629
+ out[k] = a.mode === 'image' ? { ...a, image: null } : a
630
+ }
631
+ return out
632
+ }
633
+ let lastSavedAt = 0 // 最近保存时间(诊断用)
634
+ let saveSuppressUntil = 0 // 保存抑制窗口(时间戳毫秒):拖动型控件拖动期间不写 localStorage
635
+ let saveSuppressOwner = 0 // 抑制窗口归属(拖动控件 id):blur/pointerup 只提交自己开的窗口
636
+ // ⚠️ v0.9.14 修复:窗口必须带 owner——否则"拖完 A 直接拖 B"时 A 的 onBlur(焦点转移)会把 B 刚开的
637
+ // 窗口误关 → B 拖动走 notify 全量渲染 → 卡顿 + 受控值抖动("有些透明度滑条不能及时变化"用户反馈)
638
+ function suppressSave(owner) { saveSuppressUntil = Date.now() + 60000; saveSuppressOwner = owner }
639
+ // 拖动结束提交保存一次(抑制已解除或非本控件窗口时跳过,避免 pointerup+blur 双触发重复写/误关他人窗口)
640
+ // 拖动期间走 manualCssRefresh(无 notify)→ 这里补 notify 同步 React 状态(滑块值/文本/组件渲染)
641
+ function commitSave(owner) {
642
+ if (saveSuppressOwner !== owner) return
643
+ if (saveSuppressUntil > Date.now()) { saveSuppressUntil = 0; saveSuppressOwner = 0; saveNow(); notify() }
644
+ }
645
+ function saveNow() {
646
+ // 抑制窗口内跳过写入(不更新 lastSavedAt——"最近保存"仍显示上次真实保存时间)
647
+ if (Date.now() < saveSuppressUntil) return
648
+ try {
649
+ const data = JSON.stringify({ v: 1, opacitySem: 4, appBottomVersion: 1, areas, colors, cordisEntry, floatModules, floatShowReset, floatPos, floatVisible, newSession, brand, brandHarness, borders, detailsPos, detailsDragEnabled, showOpacityHint, composerStatsExpanded, composerFixedHeight, composerRows, composerStatsItems, convBgs })
650
+ window.localStorage.setItem(STORAGE_KEY, data)
651
+ lastSavedAt = Date.now()
652
+ } catch (e) {
653
+ // QuotaExceededError:图片太大,降级只存配置
654
+ try {
655
+ const slim = JSON.stringify({ v: 1, opacitySem: 4, appBottomVersion: 1, areas: slimAreas(), colors, cordisEntry, floatModules, floatShowReset, floatPos, floatVisible, newSession: newSession.mode === 'image' ? { ...newSession, image: null } : newSession, brand, brandHarness, borders, detailsPos, detailsDragEnabled, showOpacityHint, composerStatsExpanded, composerFixedHeight, composerRows, composerStatsItems, convBgs })
656
+ window.localStorage.setItem(STORAGE_KEY, slim)
657
+ lastSavedAt = Date.now()
658
+ } catch (e2) { /* 忽略 */ }
659
+ }
660
+ }
661
+ // ⚠️ 动态环境定时器不可用:setTimeout 被宿主拦截直接抛错("setTimeout is not available in a
662
+ // dynamic client half")。持久化策略 = 全部同步保存;浮窗拖动在 pointerup 结束时保存一次。
663
+ // 关页前强制保存(绕过抑制窗口:拖动中直接关页也不丢最后值)
664
+ function flushPersist() {
665
+ saveSuppressUntil = 0
666
+ saveNow()
667
+ }
668
+ try { window.addEventListener('beforeunload', flushPersist) } catch (e) { /* 忽略 */ }
669
+ try { loadSettings() } catch (e) { /* 忽略 */ }
670
+
671
+ // ════════ 06_presets.js ════════
672
+ // ┌─ 片段 06_presets ───────────────────────────────
673
+ // │ 职责:预设库(IndexedDB CRUD + 旧 localStorage 迁移 + applyPresetData)
674
+ // │ 定义:openPresetDb / idbPutAll / idbGetAll / idbDelete / loadPresets / savePresets / applyPresetData
675
+ // │ 测试:预设保存/应用/删除;旧 presets-v1 迁移;应用预设必须覆盖全部颜色 key(null=恢复官方)
676
+ // ── 预设库:IndexedDB 存储(容量远超 localStorage,多图预设也能存);旧 localStorage 数据自动迁移 ──
677
+ const PRESET_LIMIT = 10
678
+ let presets = []
679
+ let presetDb = null
680
+ function openPresetDb() {
681
+ if (presetDb) return Promise.resolve(presetDb)
682
+ return new Promise((resolve, reject) => {
683
+ try {
684
+ const req = window.indexedDB.open('theme-customizer-presets', 1)
685
+ req.onupgradeneeded = () => {
686
+ const db = req.result
687
+ if (!db.objectStoreNames.contains('presets')) db.createObjectStore('presets', { keyPath: 'id' })
688
+ }
689
+ req.onsuccess = () => { presetDb = req.result; resolve(presetDb) }
690
+ req.onerror = () => reject(req.error || new Error('IndexedDB 打开失败'))
691
+ } catch (e) { reject(e) }
692
+ })
693
+ }
694
+ function idbPutAll(items) {
695
+ return openPresetDb().then((db) => new Promise((resolve, reject) => {
696
+ try {
697
+ const tx = db.transaction('presets', 'readwrite')
698
+ const store = tx.objectStore('presets')
699
+ for (const it of items) store.put(it)
700
+ tx.oncomplete = () => resolve(true)
701
+ tx.onerror = () => reject(tx.error || new Error('写入失败'))
702
+ tx.onabort = () => reject(tx.error || new Error('写入中止'))
703
+ } catch (e) { reject(e) }
704
+ }))
705
+ }
706
+ function idbGetAll() {
707
+ return openPresetDb().then((db) => new Promise((resolve, reject) => {
708
+ try {
709
+ const tx = db.transaction('presets', 'readonly')
710
+ const req = tx.objectStore('presets').getAll()
711
+ req.onsuccess = () => resolve(req.result || [])
712
+ req.onerror = () => reject(req.error || new Error('读取失败'))
713
+ } catch (e) { reject(e) }
714
+ }))
715
+ }
716
+ function idbDelete(id) {
717
+ return openPresetDb().then((db) => new Promise((resolve, reject) => {
718
+ try {
719
+ const tx = db.transaction('presets', 'readwrite')
720
+ tx.objectStore('presets').delete(id)
721
+ tx.oncomplete = () => resolve(true)
722
+ tx.onerror = () => reject(tx.error || new Error('删除失败'))
723
+ } catch (e) { reject(e) }
724
+ }))
725
+ }
726
+ function loadPresets() {
727
+ // 旧 localStorage 数据(presets-v1)自动迁移到 IndexedDB
728
+ try {
729
+ const raw = window.localStorage.getItem('theme-customizer-presets-v1')
730
+ if (raw) {
731
+ const arr = JSON.parse(raw)
732
+ if (Array.isArray(arr) && arr.length) {
733
+ idbPutAll(arr).then(() => {
734
+ try { window.localStorage.removeItem('theme-customizer-presets-v1') } catch (e) { /* 忽略 */ }
735
+ return idbGetAll()
736
+ }).then((items) => {
737
+ presets = (items || []).filter((p) => p && typeof p === 'object' && p.data && p.data.areas)
738
+ presetNotify()
739
+ }).catch(() => { /* 忽略 */ })
740
+ return
741
+ }
742
+ try { window.localStorage.removeItem('theme-customizer-presets-v1') } catch (e) { /* 忽略 */ }
743
+ }
744
+ } catch (e) { /* 忽略 */ }
745
+ idbGetAll().then((items) => {
746
+ presets = (items || []).filter((p) => p && typeof p === 'object' && p.data && p.data.areas)
747
+ presetNotify()
748
+ }).catch(() => { /* 忽略 */ })
749
+ }
750
+ function savePresets() {
751
+ return idbPutAll(presets)
752
+ }
753
+ // 当前应用的预设 id(localStorage 持久,刷新后仍显示)
754
+ let lastAppliedId = null
755
+ try { lastAppliedId = window.localStorage.getItem('theme-customizer-last-applied') || null } catch (e) { /* 忽略 */ }
756
+ // 应用预设数据(导入文件 / 切换预设共用)。
757
+ // ⚠️ 必须完整应用所有颜色 key(null=恢复官方),否则上一个预设的颜色会残留
758
+ // ⚠️ 性能(2026-08-22 梳理修复):原实现逐 setter(35+ 次 notify + saveNow,每次全量渲染 + 全量序列化含图 dataURI)
759
+ // → 改为**直接赋值状态 + 保留必要副作用(applyCordisEntryFlag)+ 末尾一次通知保存**。行为不变(中间态本就不该渲染)
760
+ function applyPresetData(c) {
761
+ // ── 区域:完整覆盖(缺键回默认)+ opacity 语义迁移(与 loadSettings 一致)──
762
+ const nextAreas = {}
763
+ const sem = c.opacitySem
764
+ for (const a of AREAS) {
765
+ const src = c.areas[a.id]
766
+ let area = src && typeof src === 'object' ? { ...emptyArea(), ...src } : emptyArea()
767
+ // 预设 opacity 语义迁移(与 loadSettings 一致,统一为透明度语义):
768
+ // sem 4 = 统一透明度(当前);3 = 主界面不透明度/侧边栏透明度;2 = 全透明度;无标记 = 全不透明度
769
+ if (src && typeof src === 'object' && typeof src.opacity === 'number') {
770
+ if (sem === 4 || sem === 2) { /* 当前语义/全透明度,直接用 */ }
771
+ else if (sem === 3) { if (a.id === 'app') area.opacity = 1 - src.opacity }
772
+ else { area.opacity = 1 - src.opacity }
773
+ }
774
+ nextAreas[a.id] = area
775
+ }
776
+ areas = nextAreas
777
+ // ── 文字颜色:完整覆盖(null=恢复官方)──
778
+ colors = { main: null, process: null, aux: null, faded: null, accent: null }
779
+ const pc = c.colors && typeof c.colors === 'object' ? c.colors : {}
780
+ for (const it of COLOR_ITEMS) {
781
+ const v = pc[it.key]
782
+ if (typeof v === 'string' && v) colors[it.key] = v
783
+ }
784
+ // ── Cordis 按钮常驻:有 boolean 才应用(副作用:同步 window 变量 + 重渲染官方按钮)──
785
+ if (typeof c.cordisEntry === 'boolean') { cordisEntry = c.cordisEntry; applyCordisEntryFlag() }
786
+ // ── 浮窗/其他开关:缺失保留当前值(原行为)──
787
+ if (c.floatModules && typeof c.floatModules === 'object') floatModules = { ...floatModules, ...c.floatModules }
788
+ // 浮窗显示全局恢复按钮(v0.9.16):⚠️ 2026-08-22 梳理修复——原实现漏应用此键,预设保存了但应用时不恢复
789
+ if (typeof c.floatShowReset === 'boolean') floatShowReset = c.floatShowReset
790
+ if (c.floatPos && typeof c.floatPos === 'object') floatPos = { ...floatPos, ...c.floatPos }
791
+ if (typeof c.floatVisible === 'boolean') floatVisible = c.floatVisible
792
+ if (typeof c.showOpacityHint === 'boolean') showOpacityHint = c.showOpacityHint
793
+ // 设置界面拖动位置(v0.9.19):合法 {x,y} 应用;缺失/非法 = null(居中)
794
+ if (c.detailsPos && typeof c.detailsPos === 'object' && typeof c.detailsPos.x === 'number' && typeof c.detailsPos.y === 'number') detailsPos = c.detailsPos
795
+ else detailsPos = null
796
+ if (typeof c.detailsDragEnabled === 'boolean') detailsDragEnabled = c.detailsDragEnabled
797
+ if (typeof c.composerStatsExpanded === 'boolean') composerStatsExpanded = c.composerStatsExpanded
798
+ if (typeof c.composerFixedHeight === 'boolean') composerFixedHeight = c.composerFixedHeight
799
+ if (typeof c.composerRows === 'number') composerRows = c.composerRows
800
+ if (c.composerStatsItems && typeof c.composerStatsItems === 'object') composerStatsItems = { ...composerStatsItems, ...c.composerStatsItems }
801
+ // ── 新会话:有才应用(缺失保留当前值,原行为);旧 unified 迁移 + 默认白底──
802
+ if (c.newSession && typeof c.newSession === 'object') {
803
+ const nsOld = c.newSession
804
+ const nsMode = nsOld.unified === true ? 'none' : (nsOld.mode === 'transparent' ? 'none' : nsOld.mode)
805
+ // bottomEnabled fallback 与 05_storage 统一为 true(v0.9.20 新默认白底;2026-08-22 梳理修复,原为 bottomColor != null)
806
+ newSession = { showText: true, showIcon: true, mode: 'none', color: '#ffffff', opacity: 0, bottomEnabled: true, bottomColor: '#ffffff', bottomOpacity: 0, image: null, iconColor: null, textColor: null, ...nsOld, mode: nsMode, bottomEnabled: typeof nsOld.bottomEnabled === 'boolean' ? nsOld.bottomEnabled : true }
807
+ }
808
+ // ── 标志:完整覆盖(预设缺该字段 → 回默认,避免残留上一预设)──
809
+ const pb = c.brand && typeof c.brand === 'object' ? c.brand : {}
810
+ brand = {
811
+ color: typeof pb.color === 'string' && /^#[0-9a-f]{6}$/i.test(pb.color) ? pb.color : '#3964fe',
812
+ opacity: typeof pb.opacity === 'number' && pb.opacity >= 0 && pb.opacity <= 1 ? pb.opacity : 0,
813
+ }
814
+ const ph = c.brandHarness && typeof c.brandHarness === 'object' ? c.brandHarness : {}
815
+ brandHarness = {
816
+ color: typeof ph.color === 'string' && /^#[0-9a-f]{6}$/i.test(ph.color) ? ph.color : null,
817
+ opacity: typeof ph.opacity === 'number' && ph.opacity >= 0 && ph.opacity <= 1 ? ph.opacity : 0,
818
+ }
819
+ // ── 对话区背景:有才应用;颜色键完整覆盖(null=官方默认)+ 透明度键完整覆盖(0-1,缺失回 0)──
820
+ if (c.convBgs && typeof c.convBgs === 'object') {
821
+ const cb = c.convBgs
822
+ for (const key of ['bubble', 'inline', 'code', 'scrollbar', 'chatScroll', 'todoCollapsed', 'todoExpanded', 'addBtn', 'cmdMenu', 'toBottom', 'sliderColor', 'sliderTrackColor', 'scrollColor']) {
823
+ convBgs = { ...convBgs, [key]: (typeof cb[key] === 'string' && cb[key]) ? cb[key] : null }
824
+ }
825
+ // 透明度类键(v0.9.14/15):数值 0-1,缺失/非法回 0(不残留上一预设)
826
+ for (const key of ['addBtnOpacity', 'cmdMenuOpacity', 'sliderOpacity', 'sliderTrackOpacity', 'scrollOpacity']) {
827
+ const v = cb[key]
828
+ convBgs = { ...convBgs, [key]: (typeof v === 'number' && v >= 0 && v <= 1) ? v : 0 }
829
+ }
830
+ }
831
+ // ── 框线:有才完整覆盖(缺键回默认);缺失时全部重置(原行为)──
832
+ if (c.borders && typeof c.borders === 'object') {
833
+ for (const key of BORDER_KEYS) {
834
+ const b = c.borders[key]
835
+ borders = { ...borders, [key]: {
836
+ color: b && typeof b === 'object' && typeof b.color === 'string' && /^#[0-9a-f]{6}$/i.test(b.color) ? b.color : null,
837
+ opacity: b && typeof b === 'object' && typeof b.opacity === 'number' && b.opacity >= 0 && b.opacity <= 1 ? b.opacity : 0,
838
+ } }
839
+ }
840
+ } else {
841
+ for (const key of BORDER_KEYS) borders = { ...borders, [key]: { color: null, opacity: 0 } }
842
+ }
843
+ // 末尾一次通知 + 保存(批量:替代原 35+ 次逐 setter 通知)
844
+ notify(); statsNotify(); floatNotify(); saveNow()
845
+ }
846
+ try { loadPresets() } catch (e) { /* 忽略 */ }
847
+
848
+ // ════════ 07_css_builder.js ════════
849
+ // ┌─ 片段 07_css_builder ───────────────────────────
850
+ // │ 职责:全部 CSS 注入(token 覆盖 + 背景/侧边栏/新会话/Cordis/浮窗层级样式)
851
+ // │ 定义:AreaCss + buildXxx 纯函数 + manualCssRefresh
852
+ // │ ⚠️ 层级铁律见 EXPERIENCE §5.8bis(v20:容器不建层叠上下文)
853
+ // │ 性能(2026-08-21 优化):
854
+ // │ · CSS 按模块拆分独立 useMemo + 独立 <style> 标签(img/app/composer/sidebar/ns/lift/cordis/float),
855
+ // │ 改某区域透明度/颜色只重算该模块,其他模块不重算(此前整份一起重建,调主界面连坐输入区卡顿);
856
+ // │ · 图片 dataURI 单独注入 CSS 变量(--tcz-*-img)→ 透明度/颜色变化不重拼几 MB 图片字符串;
857
+ // │ ⚠️ 主界面(app)例外:其背景是多层 background-image,var() 在多层列表中替换失效 → app 直接拼 dataURI;
858
+ // │ · 拖动中(saveSuppress 抑制期)setXxx 走 manualCssRefresh 直接写 style 标签(绕过 React 渲染链,
859
+ // │ 零虚拟 DOM 重建),松开(commitSave)后 notify 同步 React 状态 → 滑块拖动更顺滑
860
+ // 各模块 CSS 生成纯函数(读全局状态 let;拖动中 manualCssRefresh 与 React useMemo 共用,保证一致)
861
+ function buildImgVars() {
862
+ const parts = []
863
+ if (areas.sidebar.mode === 'image' && areas.sidebar.image && sidebarInfo && sidebarInfo.selector) {
864
+ parts.push(sidebarInfo.selector + ' { --tcz-sidebar-img: url("' + areas.sidebar.image.dataURI + '"); }')
865
+ }
866
+ if (areas.composer.mode === 'image' && areas.composer.image) {
867
+ parts.push('[data-composer-card] { --tcz-composer-img: url("' + areas.composer.image.dataURI + '"); }')
868
+ }
869
+ if (newSession.mode === 'image' && newSession.image) {
870
+ parts.push('[class*="_newSession"]:not([class*="_newSessionLabel"]) { --tcz-ns-img: url("' + newSession.image.dataURI + '"); }')
871
+ }
872
+ const detImg = areas.details || {}
873
+ if (detImg.mode === 'image' && detImg.image) {
874
+ parts.push('[class*="VOzbGW_panel"] { --tcz-details-img: url("' + detImg.image.dataURI + '"); }')
875
+ }
876
+ // ⚠️ 防御:areas.float/details/cordis 可能 undefined(旧配置整体替换后缺键),读前兜底空对象(v0.9.13 事故修复)
877
+ const flImg = areas.float || {}
878
+ if (flImg.mode === 'image' && flImg.image) {
879
+ parts.push('[data-thmcz-float] { --tcz-float-img: url("' + flImg.image.dataURI + '"); }')
880
+ }
881
+ const corImg = areas.cordis || {}
882
+ if (corImg.mode === 'image' && corImg.image) {
883
+ parts.push('[class*="Nqubda_panel"] { --tcz-cordis-img: url("' + corImg.image.dataURI + '"); }')
884
+ }
885
+ return parts.length ? parts.join(' ') : null
886
+ }
887
+ function buildAppCss() {
888
+ // 主界面(html,body 背景)。底色 = 最底层垫色(常驻仅颜色);模式内容(纯色渐变/图片)叠在其上。
889
+ // 图片直接拼 dataURI(不用 var():多层 background-image 里 var() 替换失效 → 图片不显示);图片已在选图时 downscale 体积小
890
+ const cssParts = []
891
+ let appBottom = null
892
+ if (areas.app.bottomColor) {
893
+ const abRgb = parseRgb(areas.app.bottomColor)
894
+ appBottom = 'rgb(' + abRgb[0] + ',' + abRgb[1] + ',' + abRgb[2] + ')'
895
+ }
896
+ const appImg = areas.app.image
897
+ const appInclude = areas.app.includeSidebar !== false
898
+ if (areas.app.mode === 'image' && appImg) {
899
+ const op = areas.app.opacity == null ? 0 : areas.app.opacity
900
+ const layerRgb = appBottom ? parseRgb(areas.app.bottomColor) : parseRgb(baseBg)
901
+ const grad = 'linear-gradient(rgba(' + layerRgb[0] + ',' + layerRgb[1] + ',' + layerRgb[2] + ',' + op + '), rgba(' + layerRgb[0] + ',' + layerRgb[1] + ',' + layerRgb[2] + ',' + op + '))'
902
+ const url = 'url("' + appImg.dataURI + '")'
903
+ if (!appInclude && sidebarInfo && sidebarInfo.width) {
904
+ const sbW = Math.round(sidebarInfo.width)
905
+ const mainW = Math.max(100, Math.round(window.innerWidth - sbW))
906
+ cssParts.push('html, body { background-color: ' + (appBottom || baseBg) + ' !important; background-image: ' + grad + ', ' + url + ' !important; background-size: ' + mainW + 'px 100%, ' + mainW + 'px 100% !important; background-position: ' + sbW + 'px center, ' + sbW + 'px center !important; background-repeat: no-repeat, no-repeat !important; }')
907
+ } else {
908
+ cssParts.push('html, body { background-color: ' + (appBottom || 'transparent') + ' !important; background-image: ' + grad + ', ' + url + ' !important; background-size: cover, cover !important; background-position: center !important; background-repeat: no-repeat !important; background-attachment: fixed !important; }')
909
+ }
910
+ } else if (areas.app.mode === 'color') {
911
+ const a = 1 - (areas.app.opacity == null ? 0 : areas.app.opacity)
912
+ const rgbC = parseRgb(areas.app.color)
913
+ const bg = appBottom || baseBg
914
+ const grad = 'linear-gradient(rgba(' + rgbC[0] + ',' + rgbC[1] + ',' + rgbC[2] + ',' + a + '), rgba(' + rgbC[0] + ',' + rgbC[1] + ',' + rgbC[2] + ',' + a + '))'
915
+ if (!appInclude && sidebarInfo && sidebarInfo.width) {
916
+ const sbW = Math.round(sidebarInfo.width)
917
+ const mainW = Math.max(100, Math.round(window.innerWidth - sbW))
918
+ cssParts.push('html, body { background-color: ' + bg + ' !important; background-image: ' + grad + ' !important; background-size: ' + mainW + 'px 100% !important; background-position: ' + sbW + 'px center !important; background-repeat: no-repeat !important; }')
919
+ } else {
920
+ cssParts.push('html, body { background-color: ' + bg + ' !important; background-image: ' + grad + ' !important; background-size: cover !important; background-position: center !important; background-repeat: no-repeat !important; }')
921
+ }
922
+ } else if (appBottom) {
923
+ cssParts.push('html, body { background-color: ' + appBottom + ' !important; }')
924
+ }
925
+ return cssParts.join(' ')
926
+ }
927
+ // 卡片式背景通用构建(2026-08-22 梳理去重):composer/details/cordis/float 四区域共用同一套
928
+ // 「无/纯色/图片 + 底色 + ::before 铺满」结构(原四个函数 90% 相同,各 ~45 行)。
929
+ // opts: { sel, area, radius, mainExtra, imgVar }
930
+ // · mainExtra = 图片/纯色分支主元素附加声明(composer 'z-index:0'、details 'z-index:1';::before z-index:-1 沉底的前提)
931
+ // · imgVar = 图片 dataURI 的 CSS 变量名(--tcz-*-img,buildImgVars 注入)
932
+ // · 纯底色分支(无模式)不拼 mainExtra(::before 不存在,无需建层叠上下文)——与原实现一致
933
+ // 底色透明度:数值大=透明 → alpha = 1-bottomOpacity;alpha=1 时输出 rgb 等价值
934
+ function buildCardCss(opts) {
935
+ const sel = opts.sel
936
+ const area = opts.area || {}
937
+ const radius = opts.radius || 12
938
+ const mainExtra = opts.mainExtra ? ' ' + opts.mainExtra : ''
939
+ const imgVar = opts.imgVar || '--tcz-card-img'
940
+ const cssParts = []
941
+ let bottom = null
942
+ if (area.bottomColor) {
943
+ const rgb = parseRgb(area.bottomColor)
944
+ const a = 1 - (area.bottomOpacity == null ? 0 : area.bottomOpacity)
945
+ bottom = a >= 1 ? 'rgb(' + rgb[0] + ',' + rgb[1] + ',' + rgb[2] + ')' : 'rgba(' + rgb[0] + ',' + rgb[1] + ',' + rgb[2] + ',' + a + ')'
946
+ }
947
+ if (area.mode === 'image' && area.image) {
948
+ const alpha = 1 - (area.opacity == null ? 0 : area.opacity)
949
+ cssParts.push(sel + ' { background-color: ' + (bottom || 'transparent') + ' !important;' + mainExtra + ' }')
950
+ cssParts.push(sel + '::before { content: "" !important; position: absolute !important; inset: 0 !important; z-index: -1 !important; pointer-events: none !important; border-radius: ' + radius + 'px !important; background-image: var(' + imgVar + ') !important; background-size: cover !important; background-position: center !important; background-repeat: no-repeat !important; -webkit-mask-image: linear-gradient(rgba(0,0,0,' + alpha + '), rgba(0,0,0,' + alpha + ')) !important; mask-image: linear-gradient(rgba(0,0,0,' + alpha + '), rgba(0,0,0,' + alpha + ')) !important; }')
951
+ } else if (area.mode === 'color') {
952
+ const a = 1 - (area.opacity == null ? 0 : area.opacity)
953
+ const rgbC = parseRgb(area.color)
954
+ cssParts.push(sel + ' { background-color: ' + (bottom || baseBg) + ' !important;' + mainExtra + ' }')
955
+ cssParts.push(sel + '::before { content: "" !important; position: absolute !important; inset: 0 !important; z-index: -1 !important; pointer-events: none !important; border-radius: ' + radius + 'px !important; background-image: linear-gradient(rgba(' + rgbC[0] + ',' + rgbC[1] + ',' + rgbC[2] + ',' + a + '), rgba(' + rgbC[0] + ',' + rgbC[1] + ',' + rgbC[2] + ',' + a + ')) !important; background-size: cover !important; background-position: center !important; background-repeat: no-repeat !important; }')
956
+ } else if (bottom) {
957
+ cssParts.push(sel + ' { background-color: ' + bottom + ' !important; }')
958
+ }
959
+ return cssParts.join(' ')
960
+ }
961
+ function buildComposerCss() {
962
+ // 输入区(composer):背景作用在 [data-composer-card];纯色/图片用 ::before 铺满(显示范围一致);含统计条隐藏/固定高度/光标
963
+ const cssParts = []
964
+ cssParts.push(buildCardCss({ sel: '[data-composer-card]', area: areas.composer, radius: 22, mainExtra: 'z-index: 0 !important;', imgVar: '--tcz-composer-img' }))
965
+ // 官方 StatsLine 隐藏(v0.9.19 调整后恢复无条件):统计条始终由我们的 ComposerStatsLine 接管渲染——
966
+ // 9 项子开关在不展开时也要生效(官方无法按项过滤);外观复刻官方(block/居中/单行省略)
967
+ cssParts.push('.FJxK0a_root { display: none !important; }')
968
+ if (composerFixedHeight) {
969
+ const rows = Math.max(1, Math.min(10, Math.round(composerRows || 4)))
970
+ const h = rows * 24
971
+ cssParts.push('[data-input-scroll] { height: ' + h + 'px !important; max-height: ' + h + 'px !important; }')
972
+ }
973
+ cssParts.push('[data-input-scroll] { cursor: text !important; }')
974
+ return cssParts.join(' ')
975
+ }
976
+ function buildDetailsCss() {
977
+ // 设置界面(details,v0.9.12):官方设置面板 [class*="VOzbGW_panel"](居中模态,背景 --dsw-alias-bg-layer-2,
978
+ // 圆角 24px,z-index:1 建层叠上下文 → ::before z-index:-1 安全,同 composer 方案)。
979
+ // 逻辑像主界面:无/纯色/图片 + 透明度(大=透明)+ 底色(常驻白底);无显示区域开关
980
+ // ⚠️ detSel 必须先声明(v0.9.19 事故:detailsPos 分支在 const detSel 之前用 → TDZ ReferenceError →
981
+ // buildDetailsCss 抛错 → AreaCss 崩溃 → 全部 CSS 失效,用户"拖动后全部设置不生效")
982
+ const detSel = '[class*="VOzbGW_panel"]'
983
+ const cssParts = []
984
+ // 拖动位置(v0.9.19):非 null 时面板脱离 overlay flex 居中,固定在拖动位置(刷新后由本 CSS 保持)
985
+ if (detailsPos) {
986
+ cssParts.push(detSel + ' { position: fixed !important; margin: 0 !important; left: ' + detailsPos.x + 'px !important; top: ' + detailsPos.y + 'px !important; }')
987
+ }
988
+ // 拖动区 header 光标 = 系统原生 move(与浮窗一致,v0.9.19)
989
+ cssParts.push(detSel + ' [class*="_header"] { cursor: move !important; }')
990
+ // ⚠️ 防御:areas.details 可能 undefined(旧配置整体替换后缺键),读前兜底空对象(v0.9.13 事故同源修复)
991
+ cssParts.push(buildCardCss({ sel: detSel, area: areas.details || {}, radius: 24, mainExtra: 'z-index: 1 !important;', imgVar: '--tcz-details-img' }))
992
+ return cssParts.join(' ')
993
+ }
994
+ function buildSidebarCss() {
995
+ // 侧边栏(底色 + 图片/纯色/无);三层独立架构(层1 body / 层2 底色 / 层3 ::before 图片)
996
+ const cssParts = []
997
+ let bottomColor = null
998
+ if (areas.sidebar.bottomEnabled && areas.sidebar.bottomColor) {
999
+ const ba = 1 - (areas.sidebar.bottomOpacity == null ? 0 : areas.sidebar.bottomOpacity)
1000
+ const [br, bg, bb] = parseRgb(areas.sidebar.bottomColor)
1001
+ bottomColor = 'rgba(' + br + ',' + bg + ',' + bb + ',' + ba + ')'
1002
+ }
1003
+ if (areas.sidebar.mode === 'image' && areas.sidebar.image && sidebarInfo && sidebarInfo.selector) {
1004
+ const maskAlpha = 1 - (areas.sidebar.opacity == null ? 0 : areas.sidebar.opacity)
1005
+ const bottom = bottomColor || 'transparent'
1006
+ cssParts.push(sidebarInfo.selector + ' { position: relative !important; background-color: ' + bottom + ' !important; }')
1007
+ cssParts.push(sidebarInfo.selector + '::before { content: "" !important; position: absolute !important; inset: 0 !important; pointer-events: none !important; background-image: var(--tcz-sidebar-img) !important; background-size: cover !important; background-position: center !important; background-repeat: no-repeat !important; -webkit-mask-image: linear-gradient(rgba(0,0,0,' + maskAlpha + '), rgba(0,0,0,' + maskAlpha + ')) !important; mask-image: linear-gradient(rgba(0,0,0,' + maskAlpha + '), rgba(0,0,0,' + maskAlpha + ')) !important; }')
1008
+ }
1009
+ if (areas.sidebar.mode === 'color' && sidebarInfo && sidebarInfo.selector) {
1010
+ const a = 1 - (areas.sidebar.opacity == null ? 0 : areas.sidebar.opacity)
1011
+ const [r, g, b] = parseRgb(areas.sidebar.color)
1012
+ const bottom = bottomColor || 'transparent'
1013
+ cssParts.push(sidebarInfo.selector + ' { background-color: ' + bottom + ' !important; background-image: linear-gradient(rgba(' + r + ',' + g + ',' + b + ',' + a + '), rgba(' + r + ',' + g + ',' + b + ',' + a + ')) !important; background-size: cover !important; background-position: center !important; background-repeat: no-repeat !important; }')
1014
+ }
1015
+ if ((areas.sidebar.mode === 'transparent' || (areas.sidebar.mode === 'image' && !areas.sidebar.image)) && bottomColor && sidebarInfo && sidebarInfo.selector) {
1016
+ cssParts.push(sidebarInfo.selector + ' { background-color: ' + bottomColor + ' !important; }')
1017
+ }
1018
+ return cssParts.join(' ')
1019
+ }
1020
+ function buildBrandCss() {
1021
+ // DeepSeek Harness 标志(v0.9.17):左上角品牌行 wordmark(BrandWordmark svg,path 全部 fill:currentColor → 给 svg 设 color 即变色)。
1022
+ // 作用于 logoRow 内 [_brand] 的 svg(稳定后缀;限定祖先防误伤其他 _brand 元素);折叠态 brand 不渲染(wide 才显示),无副作用。
1023
+ // 透明度:数值大=透明 → alpha = 1-opacity(rgba 直接作用于 color,无需 color-mix——用户色非 token)
1024
+ const cssParts = []
1025
+ const bc = brand.color || '#3964fe'
1026
+ const ba = Math.max(0, Math.min(1, 1 - (brand.opacity == null ? 0 : brand.opacity)))
1027
+ const color = ba >= 1 ? bc : (() => {
1028
+ const rgb = parseRgb(bc)
1029
+ return 'rgba(' + rgb[0] + ',' + rgb[1] + ',' + rgb[2] + ',' + ba + ')'
1030
+ })()
1031
+ cssParts.push('[class*="_logoRow"] [class*="_brand"] svg { color: ' + color + ' !important; }')
1032
+ // 标志文字 "Harness" 部分单独颜色(v0.9.20):实际渲染的 BrandWordmark(dsh 内部版)结构 =
1033
+ // svg > path×9(DeepSeek) + g[whale-clip](鲸鱼图标) + rect(129,52 = Harness 徽章背景) + g[badge-clip](Harness 文字×7)。
1034
+ // Harness 文字 = badge-clip 的 g 内 path → `svg g[clip-path*="badge"] path`(属性值含 badge,精确区分 whale g)。
1035
+ // fill 直接覆盖 currentColor。color null 时:opacity>0 → 淡化**标志整体色**(跟随标志 + 透明度也生效)
1036
+ const bh = brandHarness || {}
1037
+ const bhOp = bh.opacity == null ? 0 : bh.opacity
1038
+ if (bh.color || bhOp > 0) {
1039
+ const base = bh.color || brand.color || '#3964fe'
1040
+ const bha = Math.max(0, Math.min(1, 1 - bhOp))
1041
+ const bhrgb = parseRgb(base)
1042
+ const hColor = bha >= 1 ? base : 'rgba(' + bhrgb[0] + ',' + bhrgb[1] + ',' + bhrgb[2] + ',' + bha + ')'
1043
+ cssParts.push('[class*="_logoRow"] [class*="_brand"] svg g[clip-path*="badge"] path { fill: ' + hColor + ' !important; }')
1044
+ }
1045
+ return cssParts.join(' ')
1046
+ }
1047
+ // 框线(v0.9.18):所有 UI 默认边框/分隔线颜色 + 透明度,5 区域独立。
1048
+ // 各区域容器覆盖官方边框 token(子树内优先于 body 继承值):
1049
+ // · color 设了 → 5 变量 = rgba(用户色, 1-opacity)(层级统一为用户色);
1050
+ // · color 未设(默认)→ 各区域固定默认色:主界面/Cordis/输入区 = 淡灰(BORDER_DEFAULT_LIGHT,与色块显示一致,
1051
+ // 官方 --dsw-alias-border-l2 rgba(0,0,0,.1) 白底合成,官方 l1=.04 太淡像没颜色,用户要求);
1052
+ // 设置界面/浮窗面板 = 黑色(BORDER_DEFAULT_DARK,用户定)。常量统一在 00_constants(07/15 共用)。
1053
+ // 透明度(数值大=透明)淡化默认色/用户色。始终注入(无 early return)→ 默认色恒生效。
1054
+ // main = 布局三列(sidebarCol/centerCol/detailsCol,稳定后缀;浮层面板都不在三列内 → 天然隔离)
1055
+ function buildBordersCss() {
1056
+ const VARS = ['--dsw-alias-border-l1', '--dsw-alias-border-l2', '--dsw-alias-border-l2-darkmode-thin', '--dsw-alias-border-l3', '--dsw-alias-border-l4']
1057
+ const AREA_SELS = {
1058
+ main: '[class*="_sidebarCol"], [class*="_centerCol"], [class*="_detailsCol"]',
1059
+ cordis: '[class*="Nqubda_panel"]',
1060
+ composer: '[data-composer-seat], [data-composer-card]',
1061
+ details: '[class*="VOzbGW_panel"]',
1062
+ float: '[data-thmcz-float]',
1063
+ }
1064
+ const cssParts = []
1065
+ for (const k of BORDER_KEYS) {
1066
+ const b = borders[k] || {}
1067
+ const op = b.opacity == null ? 0 : b.opacity
1068
+ const a = Math.max(0, Math.min(1, 1 - op))
1069
+ const base = b.color || ((k === 'details' || k === 'float') ? BORDER_DEFAULT_DARK : BORDER_DEFAULT_LIGHT)
1070
+ const rgb = parseRgb(base)
1071
+ const c = a >= 1 ? base : 'rgba(' + rgb[0] + ',' + rgb[1] + ',' + rgb[2] + ',' + a + ')'
1072
+ cssParts.push(AREA_SELS[k] + ' { ' + VARS.map((v) => v + ': ' + c).join('; ') + '; }')
1073
+ }
1074
+ return cssParts.join(' ')
1075
+ }
1076
+ function buildNewSessionCss() {
1077
+ // 新会话按钮(_newSession 是 _newSessionLabel 子串 → 选择器必须排除 label)
1078
+ const cssParts = []
1079
+ const ns = newSession
1080
+ const nsBtn = '[class*="_newSession"]:not([class*="_newSessionLabel"])'
1081
+ const nsLabel = '[class*="_newSessionLabel"]'
1082
+ let bottom = 'transparent'
1083
+ if (ns.bottomEnabled && ns.bottomColor) {
1084
+ const ba = 1 - (ns.bottomOpacity == null ? 0 : ns.bottomOpacity)
1085
+ const [br, bg2, bb] = parseRgb(ns.bottomColor)
1086
+ bottom = 'rgba(' + br + ',' + bg2 + ',' + bb + ',' + ba + ')'
1087
+ }
1088
+ if (!ns.showText) cssParts.push(nsLabel + ' { display: none !important; }')
1089
+ if (!ns.showIcon) cssParts.push(nsBtn + ' svg { display: none !important; }')
1090
+ if (ns.mode === 'none') {
1091
+ cssParts.push(nsBtn + ' { background-color: ' + bottom + ' !important; border-color: transparent !important; }')
1092
+ cssParts.push(nsBtn + ':hover { background-color: transparent !important; }')
1093
+ } else if (ns.mode === 'color') {
1094
+ const a = 1 - (ns.opacity == null ? 0 : ns.opacity)
1095
+ const [r, g, b] = parseRgb(ns.color)
1096
+ cssParts.push(nsBtn + ' { background-color: ' + bottom + ' !important; border-color: transparent !important; position: relative !important; z-index: 0 !important; }')
1097
+ cssParts.push(nsBtn + '::before { content: "" !important; position: absolute !important; inset: 0 !important; z-index: -1 !important; pointer-events: none !important; background-image: linear-gradient(rgba(' + r + ',' + g + ',' + b + ',' + a + '), rgba(' + r + ',' + g + ',' + b + ',' + a + ')) !important; background-size: cover !important; background-position: center !important; background-repeat: no-repeat !important; }')
1098
+ cssParts.push(nsBtn + ':hover { background-color: transparent !important; }')
1099
+ } else if (ns.mode === 'image' && ns.image) {
1100
+ const alpha = 1 - (ns.opacity == null ? 0 : ns.opacity)
1101
+ cssParts.push(nsBtn + ' { background-color: ' + bottom + ' !important; border-color: transparent !important; position: relative !important; z-index: 0 !important; }')
1102
+ cssParts.push(nsBtn + '::before { content: "" !important; position: absolute !important; inset: 0 !important; z-index: -1 !important; pointer-events: none !important; background-image: var(--tcz-ns-img) !important; background-size: cover !important; background-position: center !important; background-repeat: no-repeat !important; -webkit-mask-image: linear-gradient(rgba(0,0,0,' + alpha + '), rgba(0,0,0,' + alpha + ')) !important; mask-image: linear-gradient(rgba(0,0,0,' + alpha + '), rgba(0,0,0,' + alpha + ')) !important; }')
1103
+ cssParts.push(nsBtn + ':hover { background-color: transparent !important; }')
1104
+ }
1105
+ if (ns.iconColor) cssParts.push(nsBtn + ' svg { color: ' + ns.iconColor + ' !important; }')
1106
+ if (ns.textColor) cssParts.push(nsLabel + ' { color: ' + ns.textColor + ' !important; }')
1107
+ return cssParts.join(' ')
1108
+ }
1109
+ function buildLiftCss() {
1110
+ // 侧边栏图片模式下顶部 UI 提升(v20 铁律:只提升具体 UI 元素本身;须渲染在 ns 之后覆盖其 z-index:0)
1111
+ const cssParts = []
1112
+ if (areas.sidebar.mode === 'image' && areas.sidebar.image && sidebarInfo && sidebarInfo.selector) {
1113
+ cssParts.push(sidebarInfo.selector + ' [class*="_logoRow"] { position: relative !important; z-index: 1 !important; }')
1114
+ cssParts.push('[class*="_newSession"]:not([class*="_newSessionLabel"]) { position: relative !important; z-index: 1 !important; }')
1115
+ }
1116
+ return cssParts.join(' ')
1117
+ }
1118
+ function buildCordisCss() {
1119
+ // Cordis 插件界面(v0.9.14):官方面板 [class*="Nqubda_panel"](z-index:30 建层叠上下文 + position:fixed → ::before z-index:-1 安全,
1120
+ // 圆角 12px,背景 --dsw-alias-bg-base)。背景调整像设置界面:无/纯色/图片 + 透明度(大=透明)+ 底色(含透明度)。
1121
+ // 原 cordisOpacity(半透明白盖层)已删;「Cordis 按钮常驻」开关在 13_boot 的 applyCordisEntryFlag(与本 CSS 无关)
1122
+ // ⚠️ 防御:areas.cordis 可能 undefined(旧配置整体替换后缺键),读前兜底空对象(v0.9.13 事故修复)
1123
+ return buildCardCss({ sel: '[class*="Nqubda_panel"]', area: areas.cordis || {}, radius: 12, imgVar: '--tcz-cordis-img' })
1124
+ }
1125
+ function buildFloatCss() {
1126
+ // 浮窗面板(v0.9.13):自研浮窗容器 [data-thmcz-float](12_float GlobalFloat)。
1127
+ // 背景像主界面/设置界面:无/纯色/图片 + 透明度(大=透明)+ 底色(常驻白底);
1128
+ // 容器本身 position:fixed + z-index 2147483647(建层叠上下文)→ ::before z-index:-1 安全(同 details 方案)。
1129
+ // 另保留浮窗可见时的 overlay 层级提升([data-shell-overlay])。
1130
+ const cssParts = []
1131
+ if (floatVisible) cssParts.push('[data-shell-overlay] { z-index: 2147483646 !important; }')
1132
+ // ⚠️ 防御:areas.float 可能 undefined(旧配置整体替换后缺键),读前兜底空对象(v0.9.13 事故修复)
1133
+ cssParts.push(buildCardCss({ sel: '[data-thmcz-float]', area: areas.float || {}, radius: 12, imgVar: '--tcz-float-img' }))
1134
+ return cssParts.join(' ')
1135
+ }
1136
+ // 对话区:代码块滚动条 + 对话区滚动条 + 任务栏收起/展开背景(各自定向,避免全局变化)
1137
+ function buildConvCss() {
1138
+ const cssParts = []
1139
+ // 代码块滚动条(v0.9.19 根治):对话区内容区 [data-chat-flow](column)整体设变量——
1140
+ // column 内部**所有**滚动条(代码块 terminal/read/search/diff、JsonBlock、IO 卡片 IN/OUT 等,
1141
+ // 不管哈希类名)都跟随「代码块滚动条」。对话区滚动容器是 column 的**祖先**(不继承 column 变量)→
1142
+ // 不受影响,仍由「对话区滚动条」直接 thumb 控制。此前逐个补 _block_/md-code-block/pre/_body_ 仍有漏网(IO 卡片)
1143
+ if (convBgs.scrollbar) {
1144
+ cssParts.push('[data-chat-flow] { --dsh-scrollbar-thumb: ' + convBgs.scrollbar + ' !important; --dsh-scrollbar-thumb-hover: ' + convBgs.scrollbar + ' !important; }')
1145
+ }
1146
+ // 对话区滚动条(含 data-chat-flow 的 _scroll 容器):v0.9.19 改**直接作用于自身 thumb**(不设变量)——
1147
+ // 变量会被内部滚动条(JsonBlock 裸 pre 等)继承 → 代码块竖滚动条错误跟随对话区颜色(用户反馈"竖的绑错")。
1148
+ // ⚠️ v0.9.19 再修:不用 Firefox scrollbar-color——scrollbar-color 是**继承属性**,会继续污染内部代码块滚动条;
1149
+ // 只保留 Webkit ::-webkit-scrollbar-thumb 直接样式(只控制对话区最外层滚动条,即最右边的滚动条)
1150
+ if (convBgs.chatScroll) {
1151
+ cssParts.push('[class*="_scroll"]:has([data-chat-flow])::-webkit-scrollbar-thumb { background: ' + convBgs.chatScroll + ' !important; }')
1152
+ cssParts.push('[class*="_scroll"]:has([data-chat-flow])::-webkit-scrollbar-thumb:hover { background: ' + convBgs.chatScroll + ' !important; }')
1153
+ }
1154
+ // 任务栏:展开滑入动画(官方渲染 list 时播放)+ 背景渐变(0.2s 快速跟手,与状态切换基本同步);
1155
+ // 收起 = 官方行为(list 瞬间移除,不做列表动画)
1156
+ cssParts.push('@keyframes tcz-todo-expand { from { opacity: 0; transform: scaleY(0.6); } to { opacity: 1; transform: scaleY(1); } }')
1157
+ cssParts.push('[data-testid="todo-panel"] [class*="_list"] { animation: tcz-todo-expand 0.4s ease-in-out !important; transform-origin: top !important; }')
1158
+ // 收起 = 官方行为(list 瞬间移除,不做列表动画);背景色渐变(设色时)
1159
+ if (convBgs.todoCollapsed || convBgs.todoExpanded) {
1160
+ cssParts.push('[data-testid="todo-panel"] { transition: background-color 0.2s ease-out !important; }')
1161
+ }
1162
+ if (convBgs.todoCollapsed) {
1163
+ cssParts.push('[data-testid="todo-panel"]:has([aria-expanded="false"]) { background-color: ' + convBgs.todoCollapsed + ' !important; }')
1164
+ }
1165
+ if (convBgs.todoExpanded) {
1166
+ cssParts.push('[data-testid="todo-panel"]:has([aria-expanded="true"]) { background-color: ' + convBgs.todoExpanded + ' !important; }')
1167
+ }
1168
+ // 输入框「+」命令按钮背景(官方 --dsw-specific-selector;hover 官方 --dsw-alias-interactive-bg-hover-solid → 一并覆盖防悬停跳回官方色)
1169
+ // v0.9.14:支持透明度——自定义颜色用 rgba(1-opacity);官方默认色(addBtn null)时透明度作用于官方 token(color-mix)
1170
+ const addBtnA = Math.max(0, Math.min(1, 1 - (convBgs.addBtnOpacity == null ? 0 : convBgs.addBtnOpacity)))
1171
+ if (convBgs.addBtn) {
1172
+ const rgb = parseRgb(convBgs.addBtn)
1173
+ const bg = addBtnA >= 1 ? convBgs.addBtn : 'rgba(' + rgb[0] + ',' + rgb[1] + ',' + rgb[2] + ',' + addBtnA + ')'
1174
+ cssParts.push('[class*="uV2eYG_add"] { background-color: ' + bg + ' !important; }')
1175
+ cssParts.push('[class*="uV2eYG_add"]:hover:not(:disabled) { background-color: ' + bg + ' !important; }')
1176
+ } else if (addBtnA < 1) {
1177
+ // 官方默认色 + 透明度:color-mix 混合官方 token 与透明(background-color 单值,var() 安全)
1178
+ const pct = Math.round(addBtnA * 100) + '%'
1179
+ cssParts.push('[class*="uV2eYG_add"] { background-color: color-mix(in srgb, var(--dsw-specific-selector) ' + pct + ', transparent) !important; }')
1180
+ cssParts.push('[class*="uV2eYG_add"]:hover:not(:disabled) { background-color: color-mix(in srgb, var(--dsw-specific-selector) ' + pct + ', transparent) !important; }')
1181
+ }
1182
+ // 命令菜单面板背景(官方 --dsw-specific-menu,被多包共用 → 精确选择器覆盖,不改全局 token)
1183
+ // v0.9.14:支持透明度(cmdMenuOpacity,同 addBtn;官方默认色用 color-mix)
1184
+ const cmdMenuA = Math.max(0, Math.min(1, 1 - (convBgs.cmdMenuOpacity == null ? 0 : convBgs.cmdMenuOpacity)))
1185
+ if (convBgs.cmdMenu) {
1186
+ const rgb = parseRgb(convBgs.cmdMenu)
1187
+ const bg = cmdMenuA >= 1 ? convBgs.cmdMenu : 'rgba(' + rgb[0] + ',' + rgb[1] + ',' + rgb[2] + ',' + cmdMenuA + ')'
1188
+ cssParts.push('[class*="_3e4SsG_menu"] { background-color: ' + bg + ' !important; }')
1189
+ } else if (cmdMenuA < 1) {
1190
+ const pct = Math.round(cmdMenuA * 100) + '%'
1191
+ cssParts.push('[class*="_3e4SsG_menu"] { background-color: color-mix(in srgb, var(--dsw-specific-menu) ' + pct + ', transparent) !important; }')
1192
+ }
1193
+ // 对话区「V」一键到底部按钮背景(v0.9.15):官方 token --dsw-alias-button-floating-fill 被布局手柄共用 → 精确选择器覆盖
1194
+ if (convBgs.toBottom) {
1195
+ cssParts.push('[class*="Md3f7G_toBottom"] { background-color: ' + convBgs.toBottom + ' !important; }')
1196
+ }
1197
+ // 主题设置插件滑条(v0.9.15):range 分左右两半——左半边(已填充)+ 右半边(未填充轨道)+ 圆点,各自独立颜色/透明度。
1198
+ // 仅作用于 data-thmcz-range(插件自己的 range,不碰官方滑条)。
1199
+ // ⚠️ 矩形框 bug 修复:必须 -webkit-appearance:none 去掉原生轨道样式(原生+自定义背景叠加 = 矩形框);
1200
+ // track 设固定高度+圆角(渐变 = 左填充 P% / 右轨道 P%,P = --tcz-range-val JS 每帧更新);thumb 圆形 = sliderColor。
1201
+ // 透明度:数值大=透明 → alpha = 1-opacity(rgba)
1202
+ const sliderFill = convBgs.sliderColor
1203
+ const sliderTrack = convBgs.sliderTrackColor
1204
+ if (sliderFill || sliderTrack) {
1205
+ const fillA = Math.max(0, Math.min(1, 1 - (convBgs.sliderOpacity == null ? 0 : convBgs.sliderOpacity)))
1206
+ const trackA = Math.max(0, Math.min(1, 1 - (convBgs.sliderTrackOpacity == null ? 0 : convBgs.sliderTrackOpacity)))
1207
+ const fillRgb = sliderFill ? parseRgb(sliderFill) : null
1208
+ const trackRgb = sliderTrack ? parseRgb(sliderTrack) : null
1209
+ const fBg = sliderFill ? (fillA >= 1 ? sliderFill : 'rgba(' + fillRgb[0] + ',' + fillRgb[1] + ',' + fillRgb[2] + ',' + fillA + ')') : 'transparent'
1210
+ const tBg = sliderTrack ? (trackA >= 1 ? sliderTrack : 'rgba(' + trackRgb[0] + ',' + trackRgb[1] + ',' + trackRgb[2] + ',' + trackA + ')') : 'rgba(128,128,128,0.3)'
1211
+ cssParts.push('[data-thmcz-range] { -webkit-appearance: none !important; appearance: none !important; background: transparent !important; --tcz-range-fill: ' + fBg + '; --tcz-range-track: ' + tBg + '; }')
1212
+ cssParts.push('[data-thmcz-range]::-webkit-slider-runnable-track { -webkit-appearance: none !important; appearance: none !important; height: 6px !important; border-radius: 3px !important; background: linear-gradient(to right, var(--tcz-range-fill) var(--tcz-range-val, 0%), var(--tcz-range-track) var(--tcz-range-val, 0%)) !important; }')
1213
+ cssParts.push('[data-thmcz-range]::-webkit-slider-thumb { -webkit-appearance: none !important; appearance: none !important; width: 14px !important; height: 14px !important; border-radius: 50% !important; margin-top: -4px !important; background-color: ' + fBg + ' !important; border: none !important; }')
1214
+ cssParts.push('[data-thmcz-range]::-moz-range-track { height: 6px !important; border-radius: 3px !important; background: linear-gradient(to right, var(--tcz-range-fill) var(--tcz-range-val, 0%), var(--tcz-range-track) var(--tcz-range-val, 0%)) !important; }')
1215
+ if (sliderFill) {
1216
+ cssParts.push('[data-thmcz-range]::-moz-range-thumb { width: 14px !important; height: 14px !important; border-radius: 50% !important; background-color: ' + fBg + ' !important; border: none !important; }')
1217
+ }
1218
+ }
1219
+ // 浮窗界面滚动条(v0.9.15 修正):用户要的是浮窗(GlobalFloat)内容区滚动条 [data-thmcz-float-scroll],
1220
+ // 不是设置面板的(此前误做成 VOzbGW_options,用户反馈"没作用"——因为看的是浮窗滚动条)。
1221
+ // 变量作用于滚动容器(thumb var() 解析基准),滚动条宽度走 body 全局 8px
1222
+ if (convBgs.scrollColor) {
1223
+ const ca = Math.max(0, Math.min(1, 1 - (convBgs.scrollOpacity == null ? 0 : convBgs.scrollOpacity)))
1224
+ const crgb = parseRgb(convBgs.scrollColor)
1225
+ const cBg = ca >= 1 ? convBgs.scrollColor : 'rgba(' + crgb[0] + ',' + crgb[1] + ',' + crgb[2] + ',' + ca + ')'
1226
+ cssParts.push('[data-thmcz-float-scroll] { --dsh-scrollbar-thumb: ' + cBg + ' !important; --dsh-scrollbar-thumb-hover: ' + cBg + ' !important; }')
1227
+ }
1228
+ return cssParts.join(' ')
1229
+ }
1230
+ // 完整 token 覆盖集合(v0.9.15 抽函数):背景(主界面/侧边栏透明化)+ 文字颜色 + 对话区气泡/行内代码/代码块。
1231
+ // AreaCss useEffect(React 路径)与 refreshConvTokens(拖动抑制期直更)共用——保证两路径覆盖同一完整集合,
1232
+ // 避免 overrideTokens 替换 namespace 时顶掉彼此("调 V 按钮主界面背景失效"事故根因)
1233
+ function buildThemeTokens() {
1234
+ const tokens = {}
1235
+ const app = areas.app, sb = areas.sidebar
1236
+ if (app.mode === 'color' || app.mode === 'image') {
1237
+ tokens['--dsw-alias-bg-base'] = { light: 'transparent', dark: 'transparent' }
1238
+ if (app.includeSidebar !== false) tokens['--dsw-specific-sidebar-fill'] = { light: 'transparent', dark: 'transparent' }
1239
+ }
1240
+ if (sb.mode === 'transparent' || sb.mode === 'image' || sb.mode === 'color') tokens['--dsw-specific-sidebar-fill'] = { light: 'transparent', dark: 'transparent' }
1241
+ for (const it of COLOR_ITEMS) {
1242
+ const v = colors[it.key]
1243
+ if (!v) continue
1244
+ for (const t of COLOR_TOKENS[it.key]) {
1245
+ tokens[t] = { light: v, dark: v }
1246
+ }
1247
+ }
1248
+ // 对话区背景(2026-08-21):我的发言气泡 + 行内代码 + 代码块/编辑卡片/脚本终端(官方 token 覆盖,亮暗统一用户色)
1249
+ if (convBgs.bubble) tokens['--dsw-specific-bubble'] = { light: convBgs.bubble, dark: convBgs.bubble }
1250
+ if (convBgs.inline) tokens['--dsw-alias-markdown-inline-code'] = { light: convBgs.inline, dark: convBgs.inline }
1251
+ if (convBgs.code) {
1252
+ tokens['--dsw-alias-markdown-code-block'] = { light: convBgs.code, dark: convBgs.code }
1253
+ // banner(read/search 卡片标题栏)同色,补全"背景不完全"
1254
+ tokens['--dsw-alias-markdown-code-block-banner'] = { light: convBgs.code, dark: convBgs.code }
1255
+ }
1256
+ return tokens
1257
+ }
1258
+ // 对话区 token 覆盖直更(bubble/inline/code 走 theme.overrideTokens,不在 conv style 标签内):
1259
+ // 拖动抑制期 notify 被跳过 → AreaCss 的 token useEffect 不重跑 → 这里手动同步完整 token 集合(buildThemeTokens)
1260
+ function refreshConvTokens() {
1261
+ try {
1262
+ const theme = ctx.get('theme')
1263
+ if (!theme || typeof theme.overrideTokens !== 'function') return
1264
+ const tokens = buildThemeTokens()
1265
+ if (Object.keys(tokens).length) theme.overrideTokens('theme-customizer', tokens)
1266
+ } catch (e) { /* 忽略 */ }
1267
+ }
1268
+ // 拖动中直更 style 标签(绕过 React 渲染链):滑块拖动每帧调用。
1269
+ // ⚠️ 只更新变化的模块(id 对应区域),绝不全量重建——appCss 含主界面大图 dataURI(多层背景不能用变量的代价),
1270
+ // 全量重建会连坐所有滑块卡顿(与"整份 CSS 一起重建"同源问题)。
1271
+ // imgVars 不重建(大 dataURI 不随透明度/颜色变化,换图/切模式走 React 正常路径)
1272
+ // ⚠️ 性能(2026-08-22 v0.9.15 优化):conv 键分两类——
1273
+ // · 非 token 键(scrollbar/chatScroll/todo*/addBtn/cmdMenu/toBottom/slider*/scroll*):只刷 conv style 标签('convStyle');
1274
+ // · token 键(bubble/inline/code):style + refreshConvTokens('conv')。
1275
+ // 此前拖动滚动条/滑条颜色每帧都跑 buildThemeTokens+overrideTokens(无用开销)→ 卡顿
1276
+ function manualCssRefresh(id) {
1277
+ try {
1278
+ const setStyle = (key, css) => {
1279
+ const el = document.querySelector('style[data-thmcz-' + key + ']')
1280
+ if (el && typeof css === 'string') el.textContent = css
1281
+ }
1282
+ if (!id) {
1283
+ setStyle('app', buildAppCss())
1284
+ setStyle('composer', buildComposerCss())
1285
+ setStyle('details', buildDetailsCss())
1286
+ setStyle('sidebar', buildSidebarCss())
1287
+ setStyle('brand', buildBrandCss())
1288
+ setStyle('borders', buildBordersCss())
1289
+ setStyle('ns', buildNewSessionCss())
1290
+ setStyle('lift', buildLiftCss())
1291
+ setStyle('cordis', buildCordisCss())
1292
+ setStyle('float', buildFloatCss())
1293
+ setStyle('conv', buildConvCss())
1294
+ refreshConvTokens()
1295
+ return
1296
+ }
1297
+ if (id === 'app') setStyle('app', buildAppCss())
1298
+ else if (id === 'composer') setStyle('composer', buildComposerCss())
1299
+ else if (id === 'details') setStyle('details', buildDetailsCss())
1300
+ else if (id === 'sidebar') { setStyle('sidebar', buildSidebarCss()); setStyle('lift', buildLiftCss()) }
1301
+ else if (id === 'brand') setStyle('brand', buildBrandCss())
1302
+ else if (id === 'borders') setStyle('borders', buildBordersCss())
1303
+ else if (id === 'ns') setStyle('ns', buildNewSessionCss())
1304
+ else if (id === 'cordis') setStyle('cordis', buildCordisCss())
1305
+ else if (id === 'float') setStyle('float', buildFloatCss())
1306
+ else if (id === 'convStyle') setStyle('conv', buildConvCss())
1307
+ else if (id === 'conv') { setStyle('conv', buildConvCss()); refreshConvTokens() }
1308
+ } catch (e) { /* 忽略 */ }
1309
+ }
1310
+
1311
+ function AreaCss() {
1312
+ const { areas, sidebarInfo, floatVisible, newSession, composerFixedHeight, composerRows, convBgs } = useStore()
1313
+ React.useEffect(() => {
1314
+ detectColors()
1315
+ const refresh = () => {
1316
+ const info = detectSidebar()
1317
+ if (info) setSidebarInfo(info)
1318
+ }
1319
+ if (!sidebarInfo) refresh()
1320
+ // 网页变化(窗口缩放/布局变动)时重新检测侧边栏比例,保证选区比例始终跟手
1321
+ window.addEventListener('resize', refresh)
1322
+ return () => window.removeEventListener('resize', refresh)
1323
+ }, [])
1324
+
1325
+ // 输入区固定高度:卡片实测宽高比缓存。
1326
+ // useLayoutEffect 在 DOM 提交后、浏览器绘制前同步执行,读 clientWidth/Height 强制 reflow → 拿到新 CSS 生效后的最新布局值
1327
+ // (getCropRatio 若在渲染期读 DOM,改行数那次渲染时新 CSS 尚未应用,会读到旧高度 → 选区不跟手)。
1328
+ // 固定高度关闭 / 卡片缺失时置 null(getCropRatio 回退视口比例)
1329
+ React.useLayoutEffect(() => {
1330
+ if (!composerFixedHeight) { setComposerCardRatio(null); return }
1331
+ const el = document.querySelector('[data-composer-card]')
1332
+ if (el && el.clientWidth > 0 && el.clientHeight > 0) {
1333
+ setComposerCardRatio(el.clientWidth / el.clientHeight)
1334
+ } else {
1335
+ setComposerCardRatio(null)
1336
+ }
1337
+ }, [composerFixedHeight, composerRows])
1338
+
1339
+ // Cordis 面板实测宽高比缓存(v0.9.15):面板高度内容驱动(max-height 60vh,宽固定 420),固定公式不准。
1340
+ // useLayoutEffect 在面板打开时测量;MutationObserver 监听面板开合(DOM 挂载/卸载)→ 开时重测、关时置 null;
1341
+ // resize 重测(面板随视口缩放)。零定时器(observer 是事件驱动,非轮询)
1342
+ React.useLayoutEffect(() => {
1343
+ const measure = () => {
1344
+ const el = document.querySelector('[class*="Nqubda_panel"]')
1345
+ if (el && el.clientWidth > 0 && el.clientHeight > 0) {
1346
+ setCordisCardRatio(el.clientWidth / el.clientHeight)
1347
+ } else {
1348
+ setCordisCardRatio(null)
1349
+ }
1350
+ }
1351
+ measure()
1352
+ let observer = null
1353
+ try {
1354
+ observer = new MutationObserver(() => { measure() })
1355
+ observer.observe(document.body, { childList: true, subtree: true })
1356
+ } catch (e) { /* 忽略(observer 不可用时不监听,只在挂载时测一次) */ }
1357
+ window.addEventListener('resize', measure)
1358
+ return () => {
1359
+ if (observer) { try { observer.disconnect() } catch (e) { /* 忽略 */ } }
1360
+ window.removeEventListener('resize', measure)
1361
+ }
1362
+ }, [])
1363
+
1364
+ // 输入框聚焦增强:官方 textarea 只覆盖文字实际高度,点文字下方的空白不聚焦。
1365
+ React.useEffect(() => {
1366
+ const onDocClick = (e) => {
1367
+ const scroll = document.querySelector('[data-input-scroll]')
1368
+ if (!scroll || !scroll.contains(e.target)) return
1369
+ const grow = scroll.firstElementChild
1370
+ const ta = scroll.querySelector('textarea')
1371
+ if (!grow || !ta) return
1372
+ const gb = grow.getBoundingClientRect()
1373
+ if (e.clientY >= gb.top && e.clientY <= gb.bottom) return
1374
+ const len = ta.value.length
1375
+ ta.focus()
1376
+ try { ta.setSelectionRange(len, len) } catch (err) { /* 忽略 */ }
1377
+ }
1378
+ document.addEventListener('click', onDocClick, true)
1379
+ return () => document.removeEventListener('click', onDocClick, true)
1380
+ }, [])
1381
+
1382
+ // 任务栏全面板点击(2026-08-22 定稿):列表收起用官方行为(瞬间移除,不做列表动画);
1383
+ // 点击 header 以外区域时模拟 header 点击(官方切换展开/收起);document 委托,无需等待 panel 渲染
1384
+ React.useEffect(() => {
1385
+ const onClick = (e) => {
1386
+ const panel = document.querySelector('[data-testid="todo-panel"]')
1387
+ if (!panel || !panel.contains(e.target)) return
1388
+ const header = panel.querySelector('[class*="_header"]')
1389
+ if (header && header.contains(e.target)) return // header 自身处理
1390
+ if (header) { try { header.click() } catch (err) { /* 忽略 */ } }
1391
+ }
1392
+ document.addEventListener('click', onClick, true)
1393
+ return () => document.removeEventListener('click', onClick, true)
1394
+ }, [])
1395
+
1396
+ // 设置界面面板拖动(v0.9.19):document 委托(面板每次打开重新挂载,委托无需绑定/解绑)。
1397
+ // 拖动区 = 面板 header([class*="_header"],54px 标题栏,排除关闭按钮);拖动中内联 style 直更(零 React),
1398
+ // pointerup 写回 detailsPos(持久化)→ CSS 重建固定位置。未拖动过(detailsPos null)→ 官方居中不动。
1399
+ // detailsDragEnabled=false(布局调整开关)时直接忽略拖动。
1400
+ React.useEffect(() => {
1401
+ const onHeaderDown = (e) => {
1402
+ if (e.button !== 0) return
1403
+ if (!detailsDragEnabled) return
1404
+ const header = e.target.closest && e.target.closest('[class*="VOzbGW_panel"] [class*="_header"]')
1405
+ if (!header) return
1406
+ if (e.target.closest && e.target.closest('[class*="_close"]')) return // 关闭按钮不触发拖动
1407
+ e.preventDefault()
1408
+ const panel = header.closest('[class*="VOzbGW_panel"]')
1409
+ if (!panel) return
1410
+ const rect = panel.getBoundingClientRect()
1411
+ panel.style.position = 'fixed'
1412
+ panel.style.margin = '0'
1413
+ // ⚠️ !important:detailsPos 写入后 buildDetailsCss 输出 left/top !important——内联必须同优先级,
1414
+ // 否则第二次拖动被 CSS 规则压住("拖一下拖不了了")。用 setProperty 带 important
1415
+ panel.style.setProperty('left', rect.left + 'px', 'important')
1416
+ panel.style.setProperty('top', rect.top + 'px', 'important')
1417
+ const sx = e.clientX, sy = e.clientY
1418
+ const ox = rect.left, oy = rect.top
1419
+ const onMove = (ev) => {
1420
+ panel.style.setProperty('left', (ox + ev.clientX - sx) + 'px', 'important')
1421
+ panel.style.setProperty('top', (oy + ev.clientY - sy) + 'px', 'important')
1422
+ }
1423
+ const onUp = () => {
1424
+ window.removeEventListener('pointermove', onMove)
1425
+ window.removeEventListener('pointerup', onUp)
1426
+ const r = panel.getBoundingClientRect()
1427
+ setDetailsPos({ x: Math.round(r.left), y: Math.round(r.top) })
1428
+ }
1429
+ window.addEventListener('pointermove', onMove)
1430
+ window.addEventListener('pointerup', onUp)
1431
+ }
1432
+ document.addEventListener('pointerdown', onHeaderDown, true)
1433
+ return () => document.removeEventListener('pointerdown', onHeaderDown, true)
1434
+ }, [detailsDragEnabled])
1435
+
1436
+ // 侧边栏复位按钮(v0.9.19):开启「设置界面移动」(detailsDragEnabled)时,官方设置按钮
1437
+ // ([class*="VOzbGW_trigger"])**右半注入复位按钮**(一分为二:左=设置,右=复位)——用户要求。
1438
+ // 原生 DOM 注入(不注册 sidebar 槽位:sidebar.settings 是官方容器槽位,直接注册会覆盖 trigger)。
1439
+ // MutationObserver 监听 trigger 挂载(侧边栏设置区渲染后注入);detailsDragEnabled 关闭时清理。
1440
+ React.useEffect(() => {
1441
+ const cleanup = () => document.querySelectorAll('[data-thmcz-details-reset]').forEach((el) => { try { el.remove() } catch (e) { /* 忽略 */ } })
1442
+ if (!detailsDragEnabled) { cleanup(); return undefined }
1443
+ let btn = null
1444
+ const ensure = () => {
1445
+ if (btn) return
1446
+ const t = document.querySelector('[class*="VOzbGW_trigger"]')
1447
+ if (!t) return
1448
+ if (t.style.position !== 'fixed') t.style.position = 'relative'
1449
+ btn = document.createElement('button')
1450
+ btn.setAttribute('data-thmcz-details-reset', 'true')
1451
+ btn.textContent = '设置复位'
1452
+ btn.title = '恢复设置面板到居中位置'
1453
+ btn.style.cssText = 'position:absolute;top:0;right:0;bottom:0;width:50%;border:none;border-left:1px solid var(--dsw-alias-border-l1);background:rgba(57,100,254,0.12);color:var(--dsw-alias-label-primary);cursor:pointer;border-radius:0 12px 12px 0;display:flex;align-items:center;justify-content:center;font-size:11px;z-index:1;padding:0'
1454
+ btn.addEventListener('click', (e) => { e.stopPropagation(); e.preventDefault(); setDetailsPos(null) })
1455
+ t.appendChild(btn)
1456
+ }
1457
+ ensure()
1458
+ let obs = null
1459
+ try {
1460
+ obs = new MutationObserver(ensure)
1461
+ obs.observe(document.body, { childList: true, subtree: true })
1462
+ } catch (e) { /* 忽略 */ }
1463
+ window.addEventListener('resize', ensure)
1464
+ return () => {
1465
+ if (obs) { try { obs.disconnect() } catch (e) { /* 忽略 */ } }
1466
+ window.removeEventListener('resize', ensure)
1467
+ cleanup()
1468
+ }
1469
+ }, [detailsDragEnabled])
1470
+
1471
+ // 合并的 token 覆盖(背景 + 文字颜色)——单一 source 单层覆盖,避免多层交互导致互斥
1472
+ // 依赖精细化:透明度/颜色值变化不重跑 overrideTokens,只在 模式/包含侧边栏/图片对象/文字颜色 变化时重跑
1473
+ // ⚠️ v0.9.15 修复:token 构建抽成 buildThemeTokens()(模块级纯函数),refreshConvTokens(拖动抑制期直更)与
1474
+ // 本 useEffect 共用同一完整集合——否则 overrideTokens 会替换整个 namespace,把主界面 bg-base/sidebar-fill
1475
+ // 顶掉("调 V 按钮时主界面背景失效,调好又恢复"用户反馈)
1476
+ React.useEffect(() => {
1477
+ const theme = ctx.get('theme')
1478
+ if (!theme || typeof theme.overrideTokens !== 'function') return undefined
1479
+ const tokens = buildThemeTokens()
1480
+ let disposer
1481
+ if (Object.keys(tokens).length) {
1482
+ try { disposer = theme.overrideTokens('theme-customizer', tokens) } catch (e) { /* 忽略 */ }
1483
+ }
1484
+ return () => { if (typeof disposer === 'function') disposer() }
1485
+ }, [areas.app.mode, areas.app.includeSidebar, areas.app.image, areas.sidebar.mode, areas.sidebar.image, colors, convBgs])
1486
+
1487
+ // 各模块 CSS 用缓存(依赖只含本模块状态;拖动中 manualCssRefresh 已直更 style,这里保证 React 状态一致)
1488
+ // ⚠️ 防御:areas.float/details/cordis 可能 undefined(旧配置整体替换后缺键),依赖数组用兜底访问(v0.9.13 事故修复)
1489
+ const flDep = areas.float || {}
1490
+ const detDep = areas.details || {}
1491
+ const corDep = areas.cordis || {}
1492
+ const imgVars = React.useMemo(() => buildImgVars(), [areas.sidebar.mode, areas.sidebar.image, areas.composer.mode, areas.composer.image, newSession.mode, newSession.image, detDep.mode, detDep.image, flDep.mode, flDep.image, corDep.mode, corDep.image, sidebarInfo])
1493
+ const appCss = React.useMemo(() => buildAppCss(), [areas.app, sidebarInfo, baseBg])
1494
+ const composerCss = React.useMemo(() => buildComposerCss(), [areas.composer, composerFixedHeight, composerRows, baseBg])
1495
+ const detailsCss = React.useMemo(() => buildDetailsCss(), [areas.details, baseBg, detailsPos])
1496
+ const sidebarCss = React.useMemo(() => buildSidebarCss(), [areas.sidebar, sidebarInfo])
1497
+ const brandCss = React.useMemo(() => buildBrandCss(), [brand, brandHarness])
1498
+ const bordersCss = React.useMemo(() => buildBordersCss(), [borders])
1499
+ const newSessionCss = React.useMemo(() => buildNewSessionCss(), [newSession])
1500
+ const liftCss = React.useMemo(() => buildLiftCss(), [areas.sidebar.mode, areas.sidebar.image, sidebarInfo])
1501
+ const cordisCss = React.useMemo(() => buildCordisCss(), [areas.cordis, baseBg])
1502
+ const floatCss = React.useMemo(() => buildFloatCss(), [floatVisible, areas.float])
1503
+ const convCss = React.useMemo(() => buildConvCss(), [convBgs.scrollbar, convBgs.chatScroll, convBgs.todoCollapsed, convBgs.todoExpanded, convBgs.addBtn, convBgs.cmdMenu, convBgs.addBtnOpacity, convBgs.cmdMenuOpacity, convBgs.toBottom, convBgs.sliderColor, convBgs.sliderOpacity, convBgs.sliderTrackColor, convBgs.sliderTrackOpacity, convBgs.scrollColor, convBgs.scrollOpacity])
1504
+
1505
+ // 各模块独立 <style> 标签:规则相对顺序与原单标签一致(img → app → composer → details → sidebar → ns → lift → cordis → float → conv)
1506
+ return React.createElement(React.Fragment, null,
1507
+ imgVars ? React.createElement('style', { 'data-thmcz-img': true }, imgVars) : null,
1508
+ React.createElement('style', { 'data-thmcz-app': true }, appCss),
1509
+ React.createElement('style', { 'data-thmcz-composer': true }, composerCss),
1510
+ React.createElement('style', { 'data-thmcz-details': true }, detailsCss),
1511
+ React.createElement('style', { 'data-thmcz-sidebar': true }, sidebarCss),
1512
+ React.createElement('style', { 'data-thmcz-brand': true }, brandCss),
1513
+ bordersCss ? React.createElement('style', { 'data-thmcz-borders': true }, bordersCss) : null,
1514
+ React.createElement('style', { 'data-thmcz-ns': true }, newSessionCss),
1515
+ React.createElement('style', { 'data-thmcz-lift': true }, liftCss),
1516
+ React.createElement('style', { 'data-thmcz-cordis': true }, cordisCss),
1517
+ React.createElement('style', { 'data-thmcz-float': true }, floatCss),
1518
+ convCss ? React.createElement('style', { 'data-thmcz-conv': true }, convCss) : null,
1519
+ )
1520
+ }
1521
+
1522
+ // ════════ 08_ui_common.js ════════
1523
+ // ┌─ 片段 08_ui_common ─────────────────────────────
1524
+ // │ 职责:通用小组件与工具(裁剪/滑条/两步确认/底色行/选图裁剪 hook/裁剪比例)
1525
+ // │ 定义:CropPanel / OpacitySlider / ConfirmButton / BottomRow / useImageCrop / getCropRatio
1526
+ function CropPanel({ image, ratio, onConfirm, onCancel, maxDim }) {
1527
+ const imgRef = React.useRef(null)
1528
+ const [orig, setOrig] = React.useState(null)
1529
+ const [disp, setDisp] = React.useState(null)
1530
+ const [rect, setRect] = React.useState(null)
1531
+ const r = ratio || 16 / 9
1532
+ // 选区随 ratio 变化实时重算(如固定高度改行数 → 卡片比例变 → 选区按新比例;保持宽度、居中,超限则降宽)
1533
+ // 仅当 ratio 真正变化时重算(拖拽导致的 rect 变化直接跳过)
1534
+ const prevRatioRef = React.useRef(r)
1535
+ React.useLayoutEffect(() => {
1536
+ const prev = prevRatioRef.current
1537
+ prevRatioRef.current = r
1538
+ if (prev === r || !orig || !rect) return
1539
+ let w = rect.w
1540
+ let h = w / r
1541
+ if (h > orig.h) { h = orig.h; w = h * r }
1542
+ setRect({ x: (orig.w - w) / 2, y: (orig.h - h) / 2, w, h })
1543
+ }, [r, orig, rect])
1544
+ const onImgLoad = (e) => {
1545
+ const el = e.target
1546
+ const iw = el.naturalWidth, ih = el.naturalHeight
1547
+ setOrig({ w: iw, h: ih })
1548
+ setDisp({ w: el.clientWidth, h: el.clientHeight })
1549
+ let w = iw, h = w / r
1550
+ if (h > ih) { h = ih; w = h * r }
1551
+ setRect({ x: (iw - w) / 2, y: (ih - h) / 2, w, h })
1552
+ }
1553
+ const computeRect = (r0, dx, mode, maxW, maxH, r2) => {
1554
+ let w = r0.w, x = r0.x
1555
+ if (mode === 'se' || mode === 'ne') { w = r0.w + dx; x = r0.x }
1556
+ else if (mode === 'sw' || mode === 'nw') { w = r0.w - dx; x = r0.x + dx }
1557
+ else { x = r0.x + dx }
1558
+ let h = w / r2
1559
+ const MIN = 48
1560
+ if (h > maxH) { h = maxH; w = h * r2 }
1561
+ if (w > maxW) { w = maxW; h = w / r2 }
1562
+ if (w < MIN) { w = MIN; h = w / r2 }
1563
+ if (x < 0) x = 0
1564
+ if (x + w > maxW) x = maxW - w
1565
+ let y = r0.y
1566
+ if (mode === 'nw' || mode === 'ne') y = r0.y + (r0.h - h)
1567
+ if (y < 0) y = 0
1568
+ if (y + h > maxH) y = maxH - h
1569
+ return { x, y, w, h }
1570
+ }
1571
+ const startDrag = (e, mode) => {
1572
+ if (!orig || !rect) return
1573
+ e.preventDefault()
1574
+ e.stopPropagation()
1575
+ const scale = disp.w / orig.w
1576
+ const sx = e.clientX, sy = e.clientY
1577
+ const r0 = { ...rect }
1578
+ const move = (ev) => {
1579
+ const dx = (ev.clientX - sx) / scale
1580
+ const dy = (ev.clientY - sy) / scale
1581
+ const next = computeRect(r0, dx, mode, orig.w, orig.h, r)
1582
+ if (mode === 'move') next.y = Math.max(0, Math.min(orig.h - next.h, r0.y + dy))
1583
+ setRect(next)
1584
+ }
1585
+ const up = () => {
1586
+ window.removeEventListener('pointermove', move)
1587
+ window.removeEventListener('pointerup', up)
1588
+ }
1589
+ window.addEventListener('pointermove', move)
1590
+ window.addEventListener('pointerup', up)
1591
+ }
1592
+ const confirm = () => {
1593
+ if (!orig || !rect || !imgRef.current) return
1594
+ try {
1595
+ // maxDim 限制输出最长边(背景大图 downscale:小 dataURI 存得下 localStorage、CSS 变量替换不超限、重绘快)
1596
+ let w = Math.max(1, Math.round(rect.w))
1597
+ let h = Math.max(1, Math.round(rect.h))
1598
+ if (typeof maxDim === 'number' && maxDim > 0 && Math.max(w, h) > maxDim) {
1599
+ const scale = maxDim / Math.max(w, h)
1600
+ w = Math.max(1, Math.round(w * scale))
1601
+ h = Math.max(1, Math.round(h * scale))
1602
+ }
1603
+ const canvas = document.createElement('canvas')
1604
+ canvas.width = w
1605
+ canvas.height = h
1606
+ const c2d = canvas.getContext('2d')
1607
+ c2d.drawImage(imgRef.current, rect.x, rect.y, rect.w, rect.h, 0, 0, canvas.width, canvas.height)
1608
+ // WebP 0.92(支持透明 + 体积小,背景图 dataURI 更小);老浏览器回退 PNG
1609
+ let out
1610
+ try { out = canvas.toDataURL('image/webp', 0.92) } catch (e) { out = canvas.toDataURL('image/png') }
1611
+ onConfirm({ dataURI: out, fileName: '裁剪背景.webp', fit: 'cover' })
1612
+ } catch (err) {
1613
+ onConfirm({ dataURI: image, fileName: '原图背景.png', fit: 'cover' })
1614
+ }
1615
+ }
1616
+ if (!image) return null
1617
+ const H = 18
1618
+ const hd = (mode, hx, vy) => ({
1619
+ mode,
1620
+ left: (rect ? (rect.x / orig.w) * disp.w + (rect.w / orig.w) * disp.w * hx : 0) - H / 2,
1621
+ top: (rect ? (rect.y / orig.h) * disp.h + (rect.h / orig.h) * disp.h * vy : 0) - H / 2,
1622
+ })
1623
+ const handles = [hd('nw', 0, 0), hd('ne', 1, 0), hd('sw', 0, 1), hd('se', 1, 1)]
1624
+ return React.createElement('div', { style: { marginBottom: '12px' } },
1625
+ React.createElement('div', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-secondary)', marginBottom: '6px' } },
1626
+ '选区宽高比 = ' + r.toFixed(2) + ':拖框内移动,拖四角调整大小。'),
1627
+ React.createElement('div', { style: { position: 'relative', display: 'inline-block', maxWidth: '100%' } },
1628
+ React.createElement('img', { ref: imgRef, src: image, onLoad: onImgLoad, draggable: false, style: { display: 'block', maxWidth: '100%', maxHeight: '300px', objectFit: 'contain', userSelect: 'none' } }),
1629
+ orig && disp && rect
1630
+ ? React.createElement(React.Fragment, null,
1631
+ React.createElement('div', { style: { position: 'absolute', inset: 0, overflow: 'hidden', pointerEvents: 'none', borderRadius: '4px' } },
1632
+ React.createElement('div', { style: { position: 'absolute', left: (rect.x / orig.w) * disp.w, top: (rect.y / orig.h) * disp.h, width: (rect.w / orig.w) * disp.w, height: (rect.h / orig.h) * disp.h, border: '2px solid #4f8cff', boxSizing: 'border-box', boxShadow: '0 0 0 9999px rgba(0,0,0,0.45)' } })),
1633
+ React.createElement('div', { onPointerDown: (e) => startDrag(e, 'move'), style: { position: 'absolute', left: (rect.x / orig.w) * disp.w, top: (rect.y / orig.h) * disp.h, width: (rect.w / orig.w) * disp.w, height: (rect.h / orig.h) * disp.h, cursor: 'move', border: '2px solid #4f8cff', boxSizing: 'border-box' } }),
1634
+ handles.map((h2) => React.createElement('div', { key: h2.mode, onPointerDown: (e) => startDrag(e, h2.mode), style: { position: 'absolute', width: H, height: H, borderRadius: '4px', background: '#4f8cff', border: '2px solid #fff', boxSizing: 'border-box', cursor: h2.mode === 'se' || h2.mode === 'nw' ? 'nwse-resize' : 'nesw-resize', left: h2.left, top: h2.top, zIndex: 5 } })),
1635
+ )
1636
+ : null,
1637
+ ),
1638
+ React.createElement('div', { style: { display: 'flex', gap: '8px', marginTop: '8px' } },
1639
+ React.createElement('button', { type: 'button', onClick: confirm, style: { padding: '6px 18px', borderRadius: '6px', cursor: 'pointer', border: 'none', background: 'var(--dsw-alias-brand-primary)', color: '#fff', fontWeight: 600, fontSize: '13px', position: 'relative', zIndex: 10 } }, '✓ 应用选区'),
1640
+ React.createElement('button', { type: 'button', onClick: onCancel, style: { padding: '6px 18px', borderRadius: '6px', cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2)', background: 'none', fontSize: '13px', position: 'relative', zIndex: 10 } }, '✕ 取消'),
1641
+ ),
1642
+ )
1643
+ }
1644
+
1645
+ // 拖动型控件(透明度滑块/颜色选择器)的保存抑制:拖动开始 suppressSave(owner)(期间 saveNow 跳过写 localStorage),
1646
+ // pointerup/失焦 commitSave(owner) 提交一次。owner 由各控件组件自持(useRef(++dragOwnerSeq)),
1647
+ // ⚠️ v0.9.14:owner 唯一 → blur/pointerup 只提交本控件开的窗口(拖完 A 直接拖 B 不互相干扰)
1648
+ let dragOwnerSeq = 0
1649
+
1650
+ // 统一恢复按钮样式三件套(v0.9.18):未确认态 = 黑字红框;确认态 = 红底白字;取消 = 灰框。
1651
+ // 尺寸三档:sm = 行内小按钮(4px 10px)/ md = 板块底部(5px 14px)/ lg = 全局恢复默认大按钮(8px 18px)。
1652
+ // ConfirmButton / TripleConfirmButton 的 size prop 引用(2026-08-22 梳理去重:此前 10+ 处整段复制粘贴)
1653
+ const RESET_BTN_SIZES = {
1654
+ sm: {
1655
+ style: { padding: '4px 10px', borderRadius: '6px', cursor: 'pointer', border: '1px solid var(--dsw-alias-state-error-primary)', background: 'none', color: 'var(--dsw-alias-label-primary)', fontSize: '12px' },
1656
+ confirmStyle: { padding: '4px 10px', borderRadius: '6px', cursor: 'pointer', border: '1px solid var(--dsw-alias-state-error-primary)', background: 'var(--dsw-alias-state-error-primary)', color: '#fff', fontSize: '12px' },
1657
+ cancelStyle: { padding: '4px 10px', borderRadius: '6px', cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2)', background: 'none', fontSize: '12px' },
1658
+ },
1659
+ md: {
1660
+ style: { padding: '5px 14px', borderRadius: '6px', cursor: 'pointer', border: '1px solid var(--dsw-alias-state-error-primary)', background: 'none', color: 'var(--dsw-alias-label-primary)', fontSize: '12px' },
1661
+ confirmStyle: { padding: '5px 14px', borderRadius: '6px', cursor: 'pointer', border: '1px solid var(--dsw-alias-state-error-primary)', background: 'var(--dsw-alias-state-error-primary)', color: '#fff', fontSize: '12px' },
1662
+ cancelStyle: { padding: '5px 14px', borderRadius: '6px', cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2)', background: 'none', fontSize: '12px' },
1663
+ },
1664
+ lg: {
1665
+ style: { padding: '8px 18px', borderRadius: '8px', cursor: 'pointer', border: '1px solid var(--dsw-alias-state-error-primary)', background: 'none', color: 'var(--dsw-alias-label-primary)', fontSize: '13px' },
1666
+ confirmStyle: { padding: '8px 18px', borderRadius: '8px', cursor: 'pointer', border: '1px solid var(--dsw-alias-state-error-primary)', background: 'var(--dsw-alias-state-error-primary)', color: '#fff', fontSize: '13px' },
1667
+ cancelStyle: { padding: '8px 18px', borderRadius: '8px', cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2)', background: 'none', fontSize: '13px' },
1668
+ },
1669
+ }
1670
+
1671
+ // 自定义颜色选择器(性能优化 2026-08-21 重构):替代原生 input type=color(Chrome 系统对话框拖动卡)。
1672
+ // 色盘形式(直观):SV 二维色板(横轴饱和度 × 纵轴明度,左上白/右上纯色相/下缘黑)+ 色相条(0-359,到顶不跳 0)+ hex 输入。
1673
+ // 拖动走 setXxx 抑制路径(manualCssRefresh,零全局 React 渲染);HSV 本地保留(白色会丢 H/S,拖动中不能反推覆盖)。
1674
+ // 色块点击展开/收起;色盘为 absolute 浮层(不挤动同行元素,如恢复默认按钮位置不变);点击色盘外关闭。
1675
+ function ColorField({ value, onChange, swatchStyle }) {
1676
+ const [open, setOpen] = React.useState(false)
1677
+ const [hexDraft, setHexDraft] = React.useState(null)
1678
+ const [preview, setPreview] = React.useState(null)
1679
+ // 本地 HSV:拖动中保留(亮度拖到 100 变白时 H/S 不归零);外部 value 变化(非拖动,如应用预设)时同步
1680
+ const [hsv, setHsv] = React.useState(() => hexToHsv(value || '#ffffff'))
1681
+ const draggingRef = React.useRef(false)
1682
+ const rootRef = React.useRef(null)
1683
+ const boardRef = React.useRef(null)
1684
+ const hueRef = React.useRef(null)
1685
+ // 本控件唯一 owner(v0.9.14):beginDrag/endDrag 共用,避免与其他滑条的抑制窗口互扰
1686
+ const ownerRef = React.useRef(++dragOwnerSeq)
1687
+ React.useEffect(() => {
1688
+ if (draggingRef.current) return
1689
+ setHsv(hexToHsv(value || '#ffffff'))
1690
+ }, [value])
1691
+ // 点击色盘外关闭(capture:色盘内点击/拖动不触发)
1692
+ React.useEffect(() => {
1693
+ if (!open) return undefined
1694
+ const onDoc = (e) => {
1695
+ if (rootRef.current && !rootRef.current.contains(e.target)) setOpen(false)
1696
+ }
1697
+ document.addEventListener('click', onDoc, true)
1698
+ return () => document.removeEventListener('click', onDoc, true)
1699
+ }, [open])
1700
+ const h = hsv[0], s = hsv[1], vv = hsv[2]
1701
+ const v = preview || value || '#ffffff'
1702
+ const shownHex = hexDraft != null ? hexDraft : v
1703
+ const beginDrag = () => { draggingRef.current = true; suppressSave(ownerRef.current) }
1704
+ const endDrag = () => { draggingRef.current = false; setPreview(null); commitSave(ownerRef.current) }
1705
+ const emit = (hx, s2, v2) => {
1706
+ const hex = hsvToHex(hx, s2, v2)
1707
+ setHsv([hx, s2, v2])
1708
+ setHexDraft(null)
1709
+ setPreview(hex)
1710
+ onChange(hex)
1711
+ }
1712
+ // SV 色板:横轴饱和度(左 0 → 右 100),纵轴明度(下 0 → 上 100)
1713
+ const onBoardPointer = (e) => {
1714
+ const el = boardRef.current
1715
+ if (!el) return
1716
+ try { el.setPointerCapture(e.pointerId) } catch (err) { /* 忽略 */ }
1717
+ const r = el.getBoundingClientRect()
1718
+ const s2 = Math.max(0, Math.min(100, ((e.clientX - r.left) / r.width) * 100))
1719
+ const v2 = Math.max(0, Math.min(100, (1 - (e.clientY - r.top) / r.height) * 100))
1720
+ emit(h, s2, v2)
1721
+ }
1722
+ // 色相条(竖排,与色板等高 180px ≈ 2°/px;0-359,到顶不跳 0)
1723
+ const onHuePointer = (e) => {
1724
+ const el = hueRef.current
1725
+ if (!el) return
1726
+ try { el.setPointerCapture(e.pointerId) } catch (err) { /* 忽略 */ }
1727
+ const r = el.getBoundingClientRect()
1728
+ const h2 = Math.max(0, Math.min(359, ((e.clientY - r.top) / r.height) * 360))
1729
+ emit(h2, s, vv)
1730
+ }
1731
+ const commitHex = () => {
1732
+ const raw = (hexDraft != null ? hexDraft : v).trim()
1733
+ setHexDraft(null)
1734
+ const m = raw.match(/^#?([0-9a-f]{6})$/i)
1735
+ if (m) onChange('#' + m[1].toLowerCase())
1736
+ }
1737
+ return React.createElement('div', { ref: rootRef, style: { display: 'flex', alignItems: 'center', gap: '6px', flexWrap: 'wrap', position: 'relative' } },
1738
+ React.createElement('button', { type: 'button', onClick: () => setOpen(!open), title: open ? '收起颜色调整' : '展开颜色调整', // 色块加黑框(v0.9.18):默认白/浅色块在白色界面上边界看不清 → 黑框分隔(swatchStyle 可覆盖)
1739
+ style: { width: '26px', height: '24px', borderRadius: '6px', border: '1px solid #000', background: v, cursor: 'pointer', padding: 0, flex: 'none', ...(swatchStyle || {}) } }),
1740
+ React.createElement('input', { value: shownHex, onChange: (e) => setHexDraft(e.target.value), onBlur: commitHex, onKeyDown: (e) => { if (e.key === 'Enter') { commitHex(); e.currentTarget.blur() } }, style: { width: '76px', padding: '3px 6px', borderRadius: '6px', border: '1px solid var(--dsw-alias-border-l2)', background: 'var(--dsw-alias-bg-layer-1)', fontSize: '12px', fontVariantNumeric: 'tabular-nums', color: 'var(--dsw-alias-label-primary)' } }),
1741
+ // absolute 浮层:不占 flex 布局流 → 同行恢复默认等按钮位置不随展开变动;点外部(document click)关闭
1742
+ // 尺寸:180×180 正方形 SV 色板 + 右侧竖色相条(等高)→ 面板约 220×200 接近正方形、两维精度均衡(主流 picker 规格)
1743
+ open ? React.createElement('div', { style: { position: 'absolute', left: '0', top: 'calc(100% + 6px)', zIndex: 60, width: '220px', padding: '8px', border: '1px solid var(--dsw-alias-border-l1)', borderRadius: '8px', background: 'var(--dsw-alias-bg-layer-1)', boxShadow: '0 4px 16px rgba(0,0,0,0.25)' } },
1744
+ React.createElement('div', { style: { display: 'flex', gap: '8px' } },
1745
+ // SV 色板(正方形:S/V 两维精度均衡)
1746
+ React.createElement('div', { ref: boardRef, onPointerDown: (e) => { beginDrag(); onBoardPointer(e) }, onPointerMove: (e) => { if (e.buttons === 1) onBoardPointer(e) }, onPointerUp: endDrag, onPointerCancel: endDrag, style: { position: 'relative', flex: '1 1 auto', height: '180px', borderRadius: '8px', cursor: 'crosshair', touchAction: 'none', background: 'linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, hsl(' + h + ',100%,50%))' } },
1747
+ React.createElement('div', { style: { position: 'absolute', left: 'calc(' + s + '% - 7px)', top: 'calc(' + (100 - vv) + '% - 7px)', width: '14px', height: '14px', borderRadius: '50%', border: '2px solid #fff', boxShadow: '0 0 2px rgba(0,0,0,0.6)', boxSizing: 'border-box', pointerEvents: 'none' } }),
1748
+ ),
1749
+ // 色相条(竖排,与色板等高)
1750
+ React.createElement('div', { ref: hueRef, onPointerDown: (e) => { beginDrag(); onHuePointer(e) }, onPointerMove: (e) => { if (e.buttons === 1) onHuePointer(e) }, onPointerUp: endDrag, onPointerCancel: endDrag, style: { position: 'relative', flex: 'none', width: '14px', height: '180px', borderRadius: '7px', cursor: 'pointer', touchAction: 'none', background: 'linear-gradient(to bottom, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00)' } },
1751
+ React.createElement('div', { style: { position: 'absolute', left: '-2px', right: '-2px', top: 'calc(' + (h / 360 * 100) + '% - 2px)', height: '4px', borderRadius: '2px', background: '#fff', border: '1px solid rgba(0,0,0,0.5)', boxSizing: 'border-box', pointerEvents: 'none' } }),
1752
+ ),
1753
+ ),
1754
+ ) : null,
1755
+ )
1756
+ }
1757
+
1758
+ function OpacitySlider({ value, onChange, label }) {
1759
+ // 拖动中本地预览(性能优化 2026-08-21):拖动期 onChange 走 setXxx 抑制路径(manualCssRefresh,无全局 React 渲染),
1760
+ // 数值文本用本地预览跟随;松开后清预览 + commitSave(saveNow + notify 同步全局)
1761
+ // label 可选(默认"透明度";BottomRow 传"底色透明度"等)——2026-08-22 梳理去重:BottomRow 内联滑条改为复用本组件
1762
+ const [preview, setPreview] = React.useState(null)
1763
+ const v = preview != null ? preview : (value == null ? 1 : value)
1764
+ // 本控件唯一 owner(v0.9.14):endDrag(pointerup/blur 共用)只提交自己开的窗口
1765
+ const ownerRef = React.useRef(++dragOwnerSeq)
1766
+ const endDrag = () => { setPreview(null); commitSave(ownerRef.current) }
1767
+ return React.createElement('div', { style: { fontSize: '12px', marginTop: '6px', display: 'flex', alignItems: 'center', gap: '8px' } },
1768
+ React.createElement('span', null, label || '透明度:'),
1769
+ React.createElement('input', { 'data-thmcz-range': true, style: { '--tcz-range-val': Math.round(v * 100) + '%' }, type: 'range', min: '0', max: '1', step: '0.05', value: v, onChange: (e) => { const nv = parseFloat(e.target.value); setPreview(nv); onChange(nv) }, onPointerDown: () => suppressSave(ownerRef.current), onPointerUp: endDrag, onBlur: endDrag }),
1770
+ React.createElement('span', { style: { minWidth: '38px', fontVariantNumeric: 'tabular-nums' } }, v.toFixed(2)),
1771
+ )
1772
+ }
1773
+
1774
+ // 两步确认按钮(通用):第一次点击进入确认态,再点执行;resetKey 变化(外部改动)自动解除确认态。
1775
+ // size:sm/md/lg(RESET_BTN_SIZES 统一恢复按钮样式,v0.9.18);style/confirmStyle/cancelStyle 显式传入时覆盖 size 默认
1776
+ function ConfirmButton({ label, confirmLabel, onConfirm, style, confirmStyle, cancelStyle, resetKey, size }) {
1777
+ const S = RESET_BTN_SIZES[size || 'sm']
1778
+ const [armed, setArmed] = React.useState(false)
1779
+ const prevKeyRef = React.useRef(resetKey)
1780
+ if (prevKeyRef.current !== resetKey) {
1781
+ prevKeyRef.current = resetKey
1782
+ if (armed) setArmed(false)
1783
+ }
1784
+ return React.createElement(React.Fragment, null,
1785
+ React.createElement('button', { type: 'button', onClick: () => { if (!armed) { setArmed(true); return } setArmed(false); onConfirm() }, style: armed ? (confirmStyle || S.confirmStyle) : (style || S.style) }, armed ? confirmLabel : label),
1786
+ armed ? React.createElement('button', { type: 'button', onClick: () => setArmed(false), style: cancelStyle || S.cancelStyle }, '取消') : null,
1787
+ )
1788
+ }
1789
+
1790
+ // 三级确认按钮(v0.9.16,全局恢复默认专用):第一次 → confirmLabel1,第二次 → confirmLabel2,第三次 → 执行。
1791
+ // 主按钮居中;确认态时取消按钮换行到下面(独立行)。
1792
+ // resetKey 变化(外部改动)自动解除确认态回到初始;size 同 ConfirmButton(默认 lg)
1793
+ function TripleConfirmButton({ label, confirmLabel1, confirmLabel2, onConfirm, style, confirmStyle, cancelStyle, resetKey, size }) {
1794
+ const S = RESET_BTN_SIZES[size || 'lg']
1795
+ const [stage, setStage] = React.useState(0)
1796
+ const prevKeyRef = React.useRef(resetKey)
1797
+ if (prevKeyRef.current !== resetKey) {
1798
+ prevKeyRef.current = resetKey
1799
+ if (stage !== 0) setStage(0)
1800
+ }
1801
+ const curStyle = stage === 0 ? (style || S.style) : (confirmStyle || S.confirmStyle)
1802
+ const curLabel = stage === 0 ? label : (stage === 1 ? confirmLabel1 : confirmLabel2)
1803
+ return React.createElement('div', { style: { display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '8px' } },
1804
+ React.createElement('button', { type: 'button', onClick: () => { if (stage < 2) { setStage(stage + 1); return } setStage(0); onConfirm() }, style: curStyle }, curLabel),
1805
+ stage > 0
1806
+ ? React.createElement('button', { type: 'button', onClick: () => setStage(0), style: cancelStyle || S.cancelStyle }, '取消')
1807
+ : null,
1808
+ )
1809
+ }
1810
+
1811
+ // 底色编辑器行(侧边栏 / 新会话 / 主界面共用):
1812
+ // 行1 开关+颜色+恢复默认(改动过才显示,noSwitch=true 时常驻无开关);行2 底色透明度(hideOpacity=true 时隐藏)
1813
+ function BottomRow({ value, onChange, style, title, hideOpacity, noSwitch }) {
1814
+ const enabled = noSwitch ? true : !!value.bottomEnabled
1815
+ const resetShown = hideOpacity
1816
+ ? (value.bottomColor && value.bottomColor !== '#ffffff')
1817
+ : (value.bottomColor && value.bottomColor !== '#ffffff') || (value.bottomOpacity != null && value.bottomOpacity !== 0)
1818
+ return React.createElement('div', null,
1819
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap', ...style } },
1820
+ noSwitch
1821
+ ? React.createElement('span', { style: { fontSize: '13px' } }, title || '底色')
1822
+ : React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '8px', fontSize: '13px' } },
1823
+ React.createElement('input', { type: 'checkbox', style: { cursor: 'pointer' }, checked: !!value.bottomEnabled, onChange: (e) => onChange({ bottomEnabled: e.target.checked, ...(e.target.checked && !value.bottomColor ? { bottomColor: '#ffffff' } : {}) }) }),
1824
+ React.createElement('span', null, title || '底色'),
1825
+ ),
1826
+ enabled
1827
+ ? React.createElement(React.Fragment, null,
1828
+ React.createElement(ColorField, { value: value.bottomColor || '#ffffff', onChange: (hex) => onChange({ bottomColor: hex }) }),
1829
+ resetShown
1830
+ ? React.createElement(ConfirmButton, {
1831
+ label: '恢复默认', confirmLabel: '确认恢复默认',
1832
+ onConfirm: () => onChange(hideOpacity ? { bottomColor: '#ffffff' } : { bottomColor: '#ffffff', bottomOpacity: 0 }),
1833
+ resetKey: (value.bottomColor || '') + '|' + (hideOpacity ? '' : (value.bottomOpacity == null ? '' : value.bottomOpacity)),
1834
+ })
1835
+ : null,
1836
+ )
1837
+ : null,
1838
+ ),
1839
+ enabled && !hideOpacity
1840
+ ? React.createElement(OpacitySlider, { label: (title || '底色') + '透明度:', value: value.bottomOpacity == null ? 0 : value.bottomOpacity, onChange: (v) => onChange({ bottomOpacity: v }) })
1841
+ : null,
1842
+ )
1843
+ }
1844
+
1845
+ // 选图 + 裁剪流程(AreaEditor / NewSessionEditor 共用):选文件 → FileReader → 进入裁剪 stage。
1846
+ // maxDim:大图读入后先降采样(dataURL >1.5MB 时),避免超大 dataURI 进内存/CSS/localStorage(见 downscaleDataUrl)
1847
+ function useImageCrop(beforePick, maxDim) {
1848
+ const [stage, setStage] = React.useState('none')
1849
+ const [rawImage, setRawImage] = React.useState(null)
1850
+ const fileRef = React.useRef(null)
1851
+ const handleFile = (e) => {
1852
+ const file = e.target.files && e.target.files[0]
1853
+ if (!file) return
1854
+ if (typeof beforePick === 'function') beforePick()
1855
+ const reader = new FileReader()
1856
+ reader.onload = () => {
1857
+ const dataUrl = String(reader.result)
1858
+ if (typeof maxDim === 'number' && maxDim > 0 && dataUrl.length > 1500000) {
1859
+ // 大图:异步降采样到 maxDim 再进裁剪面板(Image.onload 事件回调,非定时器)
1860
+ downscaleDataUrl(dataUrl, maxDim, (small) => { setRawImage(small); setStage('crop') })
1861
+ } else {
1862
+ setRawImage(dataUrl)
1863
+ setStage('crop')
1864
+ }
1865
+ }
1866
+ reader.readAsDataURL(file)
1867
+ e.target.value = ''
1868
+ }
1869
+ const closeCrop = () => { setStage('none'); setRawImage(null) }
1870
+ return { stage, rawImage, fileRef, handleFile, closeCrop }
1871
+ }
1872
+
1873
+ // 裁剪宽高比:侧边栏按实测比例;主界面"不包含侧边栏"按剩余区域;输入区固定高度时按卡片实测,否则视口比例;
1874
+ // 设置界面(details)按设置面板实际尺寸公式(VOzbGW_panel:width 800px/max-width 100vw-48,height min(800px,100vh-48))——CSS 固定,公式精确无需读 DOM;
1875
+ // 浮窗面板(float)按浮窗默认尺寸 560×640(0.875);
1876
+ // Cordis(cordis)按面板 420px 宽 × max-height 60vh → 视口近似(0.42*vw / min(0.6*vh, 面板实际) 简化取 420/可显示高)
1877
+ function getCropRatio(a) {
1878
+ if (a.id === 'sidebar' && sidebarInfo) return sidebarInfo.ratio
1879
+ if (a.id === 'float') return (floatPos.width || 560) / (floatPos.height || 640)
1880
+ if (a.id === 'cordis') {
1881
+ // v0.9.15:面板打开时用实测宽高比(cordisCardRatio,AreaCss useLayoutEffect+observer 维护);
1882
+ // 未打开(面板不在 DOM)时回退固定近似:宽 420 / 高 60vh
1883
+ if (cordisCardRatio && cordisCardRatio > 0) return cordisCardRatio
1884
+ const vh = typeof window !== 'undefined' ? window.innerHeight : 800
1885
+ return 420 / Math.min(0.6 * vh, vh - 200)
1886
+ }
1887
+ if (a.id === 'details') {
1888
+ const vw = typeof window !== 'undefined' ? window.innerWidth : 1280
1889
+ const vh = typeof window !== 'undefined' ? window.innerHeight : 800
1890
+ const w = Math.min(800, vw - 48)
1891
+ const h = Math.min(800, vh - 48)
1892
+ if (w > 0 && h > 0) return w / h
1893
+ return 1
1894
+ }
1895
+ if (a.id === 'composer') {
1896
+ // 输入区固定高度:用布局后实测的卡片宽高比(composerCardRatio,AreaCss useLayoutEffect 维护);
1897
+ // 否则视口近似(输入框高度动态增减时)
1898
+ if (composerFixedHeight && composerCardRatio) return composerCardRatio
1899
+ if (typeof window !== 'undefined' && window.innerWidth > 0 && window.innerHeight > 0) return window.innerWidth / window.innerHeight
1900
+ return 16 / 9
1901
+ }
1902
+ if (a.id === 'app' && areas.app.includeSidebar === false && sidebarInfo && sidebarInfo.width) {
1903
+ return (window.innerWidth - sidebarInfo.width) / window.innerHeight
1904
+ }
1905
+ if (typeof window !== 'undefined' && window.innerWidth > 0 && window.innerHeight > 0) {
1906
+ return window.innerWidth / window.innerHeight
1907
+ }
1908
+ return 16 / 9
1909
+ }
1910
+
1911
+ // ════════ 09_background.js ════════
1912
+ // ┌─ 片段 09_background ────────────────────────────
1913
+ // │ 职责:界面板块全套(主界面/侧边栏编辑卡片 + 新会话按钮板块 + 「界面」模块外壳)
1914
+ // │ 定义:AreaEditor / NewSessionEditor / BackgroundModule
1915
+ // │ 测试:改区域编辑/新会话/界面模块外壳(Cordis/浮窗/其他卡片)时来此片段
1916
+ // memo 化(性能优化 2026-08-21):AreaEditor 不订阅 store,仅依赖 props(area/value/onChange/ratio/children),
1917
+ // 父级 BackgroundModule 重渲染时 props 引用稳定(见 BackgroundModule 的 useMemo/useCallback)→ 跳过重渲染;
1918
+ // 对应区域 setArea 后 value 引用变化 → 正常重渲染
1919
+ const AreaEditor = React.memo(function AreaEditor({ area, value, onChange, ratio, children }) {
1920
+ const migratedNoneRef = React.useRef(false)
1921
+ // 两步确认:移除图片(第一次点击进入确认态,再点才执行)
1922
+ const [confirmRemove, setConfirmRemove] = React.useState(false)
1923
+ // 侧边栏"无(官方原样)"选项已删除:旧数据残留 none → 一次性迁移为"无"(transparent)
1924
+ React.useEffect(() => {
1925
+ if (!migratedNoneRef.current && area.id === 'sidebar' && value.mode === 'none') {
1926
+ migratedNoneRef.current = true
1927
+ onChange({ ...value, mode: 'transparent' })
1928
+ }
1929
+ }, [])
1930
+ // 选图前强制重新检测侧边栏比例(保证选区比例跟手);移除图片确认态同步解除
1931
+ // maxDim:composer 1280(小卡片),主界面/侧边栏 2560(2K 清晰,防超大 dataURI 撑爆 CSS 变量/localStorage)
1932
+ const crop = useImageCrop(() => {
1933
+ if (area.id === 'sidebar') {
1934
+ const info = detectSidebar()
1935
+ if (info) setSidebarInfo(info)
1936
+ }
1937
+ setConfirmRemove(false)
1938
+ }, area.id === 'composer' ? 1280 : 2560)
1939
+ const setMode = (mode) => {
1940
+ const next = { ...value, mode }
1941
+ if (mode !== 'image') next.image = null
1942
+ setConfirmRemove(false)
1943
+ onChange(next)
1944
+ }
1945
+ const modes = [
1946
+ // 侧边栏的"无(官方原样)"已废弃删除;透明改名"无"
1947
+ ...(area.id !== 'sidebar' && area.modes.includes('none') ? [{ id: 'none', label: '无' }] : []),
1948
+ ...(area.modes.includes('transparent') ? [{ id: 'transparent', label: area.id === 'sidebar' ? '无' : '透明(透出主体背景)' }] : []),
1949
+ ...(area.modes.includes('color') ? [{ id: 'color', label: '纯色' }] : []),
1950
+ ...(area.modes.includes('image') ? [{ id: 'image', label: '图片' }] : []),
1951
+ ]
1952
+ // 侧边栏已无 none 选项:旧数据若为 none,显示为"无"(等效透明)
1953
+ const currentMode = modes.some((m) => m.id === value.mode) ? value.mode : (modes[0] ? modes[0].id : 'none')
1954
+ return React.createElement('div', { style: { padding: '10px', border: '1px solid var(--dsw-alias-border-l1)', borderRadius: '8px', marginBottom: '10px' } },
1955
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '10px', flexWrap: 'wrap', marginBottom: '6px' } },
1956
+ React.createElement('span', { style: { fontSize: '13px', fontWeight: 600, minWidth: '80px' } }, area.label),
1957
+ // 只有一个模式(如对话区/详情栏仅"无")时下拉无意义,不渲染
1958
+ modes.length > 1
1959
+ ? React.createElement('select', { value: currentMode, onChange: (e) => setMode(e.target.value), style: { padding: '4px 8px', borderRadius: '6px', border: '1px solid var(--dsw-alias-border-l2)', background: 'var(--dsw-alias-bg-layer-1)', fontSize: '12px' } },
1960
+ modes.map((m) => React.createElement('option', { key: m.id, value: m.id }, m.label)))
1961
+ : null,
1962
+ ),
1963
+ area.id === 'app'
1964
+ ? React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '8px', marginTop: '4px', marginBottom: '4px', flexWrap: 'wrap' } },
1965
+ React.createElement('span', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-secondary)', flex: 'none' } }, '显示区域:'),
1966
+ React.createElement('button', { type: 'button', onClick: () => onChange({ ...value, includeSidebar: true }), style: { padding: '4px 10px', borderRadius: '6px', cursor: 'pointer', border: '1px solid ' + (value.includeSidebar === false ? 'var(--dsw-alias-border-l2)' : 'var(--dsw-alias-brand-primary)'), background: value.includeSidebar === false ? 'transparent' : 'var(--dsw-alias-brand-primary)', color: value.includeSidebar === false ? 'var(--dsw-alias-label-primary)' : '#fff', fontSize: '12px' } }, '包含侧边栏'),
1967
+ React.createElement('button', { type: 'button', onClick: () => onChange({ ...value, includeSidebar: false }), style: { padding: '4px 10px', borderRadius: '6px', cursor: 'pointer', border: '1px solid ' + (value.includeSidebar === false ? 'var(--dsw-alias-brand-primary)' : 'var(--dsw-alias-border-l2)'), background: value.includeSidebar === false ? 'var(--dsw-alias-brand-primary)' : 'transparent', color: value.includeSidebar === false ? '#fff' : 'var(--dsw-alias-label-primary)', fontSize: '12px' } }, '不包含侧边栏'),
1968
+ )
1969
+ : null,
1970
+ value.mode === 'color'
1971
+ ? React.createElement('div', null,
1972
+ React.createElement('div', { style: { fontSize: '12px', marginTop: '4px', display: 'flex', alignItems: 'center', gap: '8px' } },
1973
+ React.createElement('span', null, '颜色:'),
1974
+ React.createElement(ColorField, { value: value.color, onChange: (hex) => onChange({ ...value, color: hex }) }),
1975
+ React.createElement('span', null, value.color),
1976
+ ),
1977
+ React.createElement(OpacitySlider, { value: value.opacity, onChange: (op) => onChange({ ...value, opacity: op }) }),
1978
+ )
1979
+ : null,
1980
+ value.mode === 'image'
1981
+ ? React.createElement('div', null,
1982
+ React.createElement('input', { ref: crop.fileRef, type: 'file', accept: 'image/png,image/jpeg,image/webp,image/gif,image/svg+xml', style: { display: 'none' }, onChange: crop.handleFile }),
1983
+ React.createElement('div', { style: { marginTop: '4px', display: 'flex', alignItems: 'center', gap: '8px' } },
1984
+ React.createElement('button', { type: 'button', onClick: () => crop.fileRef.current && crop.fileRef.current.click(), style: { padding: '5px 12px', borderRadius: '6px', cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2)', background: 'var(--dsw-alias-bg-layer-1)', fontSize: '12px' } }, value.image ? '重新选择图片' : '选择图片'),
1985
+ value.image && React.createElement('button', { type: 'button', onClick: () => { if (!confirmRemove) { setConfirmRemove(true); return } setConfirmRemove(false); onChange({ ...value, image: null }) }, style: { padding: '5px 12px', borderRadius: '6px', cursor: 'pointer', border: '1px solid var(--dsw-alias-state-error-primary)', background: confirmRemove ? 'var(--dsw-alias-state-error-primary)' : 'none', color: confirmRemove ? '#fff' : 'var(--dsw-alias-state-error-primary)', fontSize: '12px' } }, confirmRemove ? '确认移除图片' : '移除图片'),
1986
+ confirmRemove && React.createElement('button', { type: 'button', onClick: () => setConfirmRemove(false), style: { padding: '5px 12px', borderRadius: '6px', cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2)', background: 'none', fontSize: '12px' } }, '取消'),
1987
+ ),
1988
+ value.image && React.createElement(OpacitySlider, { value: value.opacity, onChange: (op) => onChange({ ...value, opacity: op }) }),
1989
+ area.id === 'composer'
1990
+ ? React.createElement('div', { style: { fontSize: '11px', color: 'var(--dsw-alias-label-caption)', marginTop: '4px' } }, '注:开启固定高度时,选区按输入框实测宽高比;高度动态增减时按视口近似,与实际宽高比可能不完全一致。')
1991
+ : null,
1992
+ )
1993
+ : null,
1994
+ crop.stage === 'crop' && crop.rawImage
1995
+ ? React.createElement(CropPanel, { image: crop.rawImage, ratio: ratio, maxDim: area.id === 'composer' ? 1280 : 2560, onConfirm: (bg) => { crop.closeCrop(); onChange({ ...value, mode: 'image', image: bg }) }, onCancel: crop.closeCrop })
1996
+ : null,
1997
+ !area.supported
1998
+ ? React.createElement('div', { style: { fontSize: '11px', color: 'var(--dsw-alias-state-warn-primary)', marginTop: '4px' } }, '该区块待支持(需要官方选择器)')
1999
+ : null,
2000
+ // 底色:主界面(app)/输入区/设置界面/浮窗面板/Cordis 卡片底部"底色";
2001
+ // 透明度显示(v0.9.14 用户定):sidebar + details + float + composer + cordis 显示底色透明度;app 仅颜色(用户 v0.9.2 定主界面底色仅颜色)
2002
+ area.id === 'app' || area.id === 'sidebar' || area.id === 'composer' || area.id === 'details' || area.id === 'float' || area.id === 'cordis'
2003
+ ? React.createElement(BottomRow, { value: value, onChange: (next) => onChange({ ...value, ...next }), style: { marginTop: '4px', marginBottom: '4px' }, title: '底色', hideOpacity: area.id === 'app', noSwitch: area.id !== 'sidebar' })
2004
+ : null,
2005
+ children,
2006
+ )
2007
+ })
2008
+
2009
+ // DeepSeek Harness 标志子版块(v0.9.17):渲染在「侧边栏」区块内、「新会话」之上(细分隔线 + 标题,同「滑条与滚动条」子版块样式)。
2010
+ // 默认品牌蓝 #3964fe + 不透明;与正文解耦(常驻独立色,不跟随正文);透明度数值大=透明(语义同全局);
2011
+ // 恢复默认:颜色回品牌蓝 + 透明度归零(改动过才显示,两步确认,同主界面底色「恢复默认」模式)
2012
+ function BrandEditor() {
2013
+ const { brand, brandHarness } = useStore(['main'])
2014
+ const bColor = brand.color || '#3964fe'
2015
+ const changed = bColor !== '#3964fe' || (brand.opacity != null && brand.opacity !== 0)
2016
+ // Harness 部分单独颜色(v0.9.20):null=跟随标志整体色;未设置时色块显示品牌蓝(跟随标志)
2017
+ const bh = brandHarness || {}
2018
+ const hChanged = !!(bh.color || (bh.opacity != null && bh.opacity > 0))
2019
+ return React.createElement('div', { style: { marginTop: '8px', paddingTop: '10px', borderTop: '1px solid var(--dsw-alias-border-l1)' } },
2020
+ React.createElement('div', { style: { fontSize: '13px', fontWeight: 600, marginBottom: '6px' } }, '标志(DeepSeek Harness)'),
2021
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap', fontSize: '12px', marginBottom: '6px' } },
2022
+ React.createElement('span', { style: { width: '92px', flex: 'none', fontSize: '13px' } }, '标志颜色:'),
2023
+ React.createElement(ColorField, { value: bColor, onChange: (hex) => setBrand({ color: hex }) }),
2024
+ // 状态文本固定宽度(对齐恢复按钮),只显示"已自定义/默认"(色值已由 ColorField 显示)
2025
+ React.createElement('span', { style: { width: '76px', flex: 'none', fontVariantNumeric: 'tabular-nums', color: 'var(--dsw-alias-label-secondary)', fontSize: '12px' } }, changed ? '已自定义' : '默认'),
2026
+ changed ? React.createElement(ConfirmButton, {
2027
+ label: '恢复默认', confirmLabel: '确认恢复默认',
2028
+ onConfirm: () => setBrand({ color: '#3964fe', opacity: 0 }), resetKey: bColor + '|' + (brand.opacity == null ? '' : brand.opacity),
2029
+ }) : null,
2030
+ ),
2031
+ React.createElement(OpacitySlider, { value: brand.opacity == null ? 0 : brand.opacity, onChange: (v) => setBrand({ opacity: v }) }),
2032
+ // ── Harness 单独颜色(v0.9.20)──
2033
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap', fontSize: '12px', marginTop: '4px' } },
2034
+ React.createElement('span', { style: { width: '92px', flex: 'none', fontSize: '13px' } }, 'Harness 颜色:'),
2035
+ React.createElement(ColorField, { value: bh.color || '#3964fe', onChange: (hex) => setBrandHarness({ color: hex }) }),
2036
+ React.createElement('span', { style: { width: '76px', flex: 'none', fontVariantNumeric: 'tabular-nums', color: 'var(--dsw-alias-label-secondary)', fontSize: '12px' } }, hChanged ? '已自定义' : '跟随标志'),
2037
+ hChanged ? React.createElement(ConfirmButton, {
2038
+ label: '恢复默认', confirmLabel: '确认恢复默认',
2039
+ onConfirm: () => setBrandHarness({ color: null, opacity: 0 }), resetKey: (bh.color || '') + '|' + (bh.opacity == null ? '' : bh.opacity),
2040
+ }) : null,
2041
+ ),
2042
+ React.createElement(OpacitySlider, { value: bh.opacity == null ? 0 : bh.opacity, onChange: (v) => setBrandHarness({ opacity: v }) }),
2043
+ )
2044
+ }
2045
+
2046
+ // memo 化(性能优化 2026-08-21):自身订阅 store(newSession/sidebarInfo),store 变化时自行重渲染;
2047
+ // memo 只防父级(AreaEditor)重渲染时无谓重复
2048
+ const NewSessionEditor = React.memo(function NewSessionEditor() {
2049
+ const { newSession, sidebarInfo } = useStore()
2050
+ // 两步确认:移除图片
2051
+ const [confirmRemoveNS, setConfirmRemoveNS] = React.useState(false)
2052
+ // 按钮小,maxDim 1280 足够(大图读入先降采样,防超大 dataURI)
2053
+ const crop = useImageCrop(() => setConfirmRemoveNS(false), 1280)
2054
+ // 按钮选区比例:宽 ≈ 侧边栏宽 - 16px(容器 padding 6px×2 + 按钮 margin 2px×2),高 38px;折叠模式近方形 → 1:1
2055
+ const btnRatio = sidebarInfo ? (sidebarInfo.width < 80 ? 1 : Math.max(0.5, (sidebarInfo.width - 16) / 38)) : 2
2056
+ const ns = newSession
2057
+ const change = (next) => { setConfirmRemoveNS(false); setNewSession({ ...ns, ...next }) }
2058
+ // 作为「侧边栏」区块的一部分:无独立卡片边框,用顶部细分隔线与侧边栏内容分开
2059
+ // 布局与主界面/侧边栏一致:「新会话」标题旁 = 样式下拉(无/纯色/图片);
2060
+ // 标题下方紧跟模式内容(颜色/透明度/图片选择),再往下才是显示文本/图标/底色
2061
+ return React.createElement('div', { style: { marginTop: '8px', paddingTop: '10px', borderTop: '1px solid var(--dsw-alias-border-l1)' } },
2062
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '10px', flexWrap: 'wrap', marginBottom: '6px' } },
2063
+ React.createElement('span', { style: { fontSize: '13px', fontWeight: 600, minWidth: '80px' } }, '新会话'),
2064
+ React.createElement('select', { value: ['none', 'color', 'image'].includes(ns.mode) ? ns.mode : 'none', onChange: (e) => change({ mode: e.target.value, ...(e.target.value !== 'image' ? { image: null } : {}) }), style: { padding: '4px 8px', borderRadius: '6px', border: '1px solid var(--dsw-alias-border-l2)', background: 'var(--dsw-alias-bg-layer-1)', fontSize: '12px' } },
2065
+ React.createElement('option', { value: 'none' }, '无'),
2066
+ React.createElement('option', { value: 'color' }, '纯色'),
2067
+ React.createElement('option', { value: 'image' }, '图片'),
2068
+ ),
2069
+ ),
2070
+ ns.mode === 'color'
2071
+ ? React.createElement('div', null,
2072
+ React.createElement('div', { style: { fontSize: '12px', marginTop: '4px', display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' } },
2073
+ React.createElement('span', null, '颜色:'),
2074
+ React.createElement(ColorField, { value: ns.color, onChange: (hex) => change({ color: hex }) }),
2075
+ React.createElement('span', null, ns.color),
2076
+ // 纯色恢复默认(v0.9.20):颜色回默认白 + 透明度归零(改动过才显示,黑字红框统一样式)
2077
+ (ns.color !== '#ffffff' || (ns.opacity != null && ns.opacity !== 0))
2078
+ ? React.createElement(ConfirmButton, {
2079
+ label: '恢复默认', confirmLabel: '确认恢复默认',
2080
+ onConfirm: () => change({ color: '#ffffff', opacity: 0 }), resetKey: (ns.color || '') + '|' + (ns.opacity == null ? '' : ns.opacity),
2081
+ })
2082
+ : null,
2083
+ ),
2084
+ React.createElement(OpacitySlider, { value: ns.opacity, onChange: (op) => change({ opacity: op }) }),
2085
+ )
2086
+ : null,
2087
+ ns.mode === 'image'
2088
+ ? React.createElement('div', null,
2089
+ React.createElement('input', { ref: crop.fileRef, type: 'file', accept: 'image/png,image/jpeg,image/webp,image/gif,image/svg+xml', style: { display: 'none' }, onChange: crop.handleFile }),
2090
+ React.createElement('div', { style: { marginTop: '4px', display: 'flex', alignItems: 'center', gap: '8px' } },
2091
+ React.createElement('button', { type: 'button', onClick: () => crop.fileRef.current && crop.fileRef.current.click(), style: { padding: '5px 12px', borderRadius: '6px', cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2)', background: 'var(--dsw-alias-bg-layer-1)', fontSize: '12px' } }, ns.image ? '重新选择图片' : '选择图片'),
2092
+ ns.image && React.createElement('button', { type: 'button', onClick: () => { if (!confirmRemoveNS) { setConfirmRemoveNS(true); return } setConfirmRemoveNS(false); change({ image: null }) }, style: { padding: '5px 12px', borderRadius: '6px', cursor: 'pointer', border: '1px solid var(--dsw-alias-state-error-primary)', background: confirmRemoveNS ? 'var(--dsw-alias-state-error-primary)' : 'none', color: confirmRemoveNS ? '#fff' : 'var(--dsw-alias-state-error-primary)', fontSize: '12px' } }, confirmRemoveNS ? '确认移除图片' : '移除图片'),
2093
+ confirmRemoveNS && React.createElement('button', { type: 'button', onClick: () => setConfirmRemoveNS(false), style: { padding: '5px 12px', borderRadius: '6px', cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2)', background: 'none', fontSize: '12px' } }, '取消'),
2094
+ ),
2095
+ ns.image && React.createElement(OpacitySlider, { value: ns.opacity, onChange: (op) => change({ opacity: op }) }),
2096
+ crop.stage === 'crop' && crop.rawImage
2097
+ ? React.createElement(CropPanel, { image: crop.rawImage, ratio: btnRatio, maxDim: 1280, onConfirm: (bg) => { crop.closeCrop(); change({ mode: 'image', image: bg }) }, onCancel: crop.closeCrop })
2098
+ : null,
2099
+ )
2100
+ : null,
2101
+ // 开关仅勾选框本身触发(v0.9.20 用户定:不要整栏触发——label 点击会切换,改 div+span)
2102
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '8px', fontSize: '13px', marginBottom: '6px' } },
2103
+ React.createElement('input', { type: 'checkbox', style: { cursor: 'pointer' }, checked: !!ns.showIcon, onChange: (e) => change({ showIcon: e.target.checked }) }),
2104
+ React.createElement('span', null, '显示图标'),
2105
+ ),
2106
+ // 图标颜色:默认(null)=跟随正文;单独设置后与正文解耦、独立覆盖。跟随"显示图标"开关显示,紧贴其下(用户定)
2107
+ ...(ns.showIcon
2108
+ ? [React.createElement('div', { key: 'nsIconColor', style: { fontSize: '12px', marginBottom: '6px', display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' } },
2109
+ React.createElement('span', null, '图标颜色:'),
2110
+ React.createElement(ColorField, { value: ns.iconColor || '#000000', onChange: (hex) => change({ iconColor: hex }) }),
2111
+ React.createElement('span', { style: { fontVariantNumeric: 'tabular-nums' } }, ns.iconColor || '官方默认'),
2112
+ ns.iconColor ? React.createElement(ConfirmButton, {
2113
+ label: '恢复官方默认', confirmLabel: '确认恢复',
2114
+ onConfirm: () => change({ iconColor: null }), resetKey: ns.iconColor,
2115
+ }) : null,
2116
+ )]
2117
+ : []),
2118
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '8px', fontSize: '13px', marginBottom: '6px' } },
2119
+ React.createElement('input', { type: 'checkbox', style: { cursor: 'pointer' }, checked: !!ns.showText, onChange: (e) => change({ showText: e.target.checked }) }),
2120
+ React.createElement('span', null, '显示文本'),
2121
+ ),
2122
+ // 文本颜色:默认(null)=跟随正文;单独设置后与正文解耦、独立覆盖。跟随"显示文本"开关显示
2123
+ ...(ns.showText
2124
+ ? [React.createElement('div', { key: 'nsTextColor', style: { fontSize: '12px', marginBottom: '6px', display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' } },
2125
+ React.createElement('span', null, '文本颜色:'),
2126
+ React.createElement(ColorField, { value: ns.textColor || '#000000', onChange: (hex) => change({ textColor: hex }) }),
2127
+ React.createElement('span', { style: { fontVariantNumeric: 'tabular-nums' } }, ns.textColor || '官方默认'),
2128
+ ns.textColor ? React.createElement(ConfirmButton, {
2129
+ label: '恢复官方默认', confirmLabel: '确认恢复',
2130
+ onConfirm: () => change({ textColor: null }), resetKey: ns.textColor,
2131
+ }) : null,
2132
+ )]
2133
+ : []),
2134
+ // 底色与样式无关:无论样式(无/纯色/图片)都显示、都生效(开底色 = 按钮有底色,关 = 透明透出侧边栏)
2135
+ React.createElement(BottomRow, { value: ns, onChange: (next) => change(next), style: { marginBottom: '6px' } }),
2136
+ )
2137
+ })
2138
+
2139
+ // 「显示透明度说明」勾选(v0.9.13 起在浮窗面板卡片内,原「其他」板块已删):
2140
+ // 自身订阅 store(main 通道),childModules useMemo 闭包不捕获旧值
2141
+ function OpacityHintToggle() {
2142
+ const { showOpacityHint } = useStore(['main'])
2143
+ // 开关仅勾选框触发(v0.9.20)
2144
+ return React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '8px', fontSize: '13px', marginTop: '6px' } },
2145
+ React.createElement('input', { type: 'checkbox', style: { cursor: 'pointer' }, checked: !!showOpacityHint, onChange: (e) => setShowOpacityHint(e.target.checked) }),
2146
+ React.createElement('span', null, '显示透明度说明'),
2147
+ )
2148
+ }
2149
+
2150
+ // 「Cordis 按钮常驻」勾选(v0.9.14 起在 Cordis 插件界面区域卡片内,原独立卡片已删):
2151
+ // 自身订阅 store(main 通道),childModules useMemo 闭包不捕获旧值。
2152
+ // v1.0.0 开源:开关旁加小字——此开关依赖官方 dsh-client-ui-cordis 补丁,未打补丁时不生效(用户定"留着,标明要打补丁")
2153
+ function CordisEntryToggle() {
2154
+ const { cordisEntry } = useStore(['main'])
2155
+ // 开关仅勾选框触发(v0.9.20)
2156
+ return React.createElement('div', { style: { marginTop: '6px' } },
2157
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '8px', fontSize: '13px' } },
2158
+ React.createElement('input', { type: 'checkbox', style: { cursor: 'pointer' }, checked: !!cordisEntry, onChange: (e) => setCordisEntry(e.target.checked) }),
2159
+ React.createElement('span', null, 'Cordis 按钮常驻'),
2160
+ ),
2161
+ React.createElement('div', { style: { fontSize: '11px', color: 'var(--dsw-alias-label-caption)', marginTop: '4px', marginLeft: '22px' } },
2162
+ '需安装官方 Cordis 按钮补丁才生效(见 PATCH-CORDIS-BUTTON.md)'),
2163
+ )
2164
+ }
2165
+
2166
+ // 「设置界面」恢复原位按钮(v0.9.19):面板被拖动离开居中后显示,点击恢复官方居中(detailsPos=null);
2167
+ // 布局调整「设置界面移动」开关关闭时按钮也消失(detailsDragEnabled=false,与拖动同步禁用)
2168
+ function DetailsResetPos() {
2169
+ const { detailsPos, detailsDragEnabled } = useStore(['main'])
2170
+ if (!detailsPos || !detailsDragEnabled) return null
2171
+ return React.createElement('div', { style: { marginTop: '6px' } },
2172
+ React.createElement(ConfirmButton, {
2173
+ label: '恢复原位', confirmLabel: '确认恢复原位',
2174
+ onConfirm: () => setDetailsPos(null), resetKey: detailsPos.x + ',' + detailsPos.y,
2175
+ }),
2176
+ )
2177
+ }
2178
+
2179
+ // 滑条 + 滚动条颜色/透明度(v0.9.15,浮窗面板卡片内的子板块——细分隔线 + 标题,同输入区「命令」子板块):
2180
+ // sliderColor/sliderOpacity = 插件滑条左半边(已填充)+ 圆点;sliderTrackColor/sliderTrackOpacity = 右半边(未填充轨道);
2181
+ // scrollColor/scrollOpacity = 浮窗界面滚动条 thumb
2182
+ function SliderScrollEditor() {
2183
+ const { convBgs } = useStore(['main'])
2184
+ return React.createElement('div', { style: { marginTop: '8px', paddingTop: '10px', borderTop: '1px solid var(--dsw-alias-border-l1)' } },
2185
+ React.createElement('div', { style: { fontSize: '13px', fontWeight: 600, marginBottom: '6px' } }, '滑条与滚动条'),
2186
+ ConvBgRow({ label: '滑条颜色', value: convBgs.sliderColor, onSet: (v) => setConvBg('sliderColor', v), opacity: convBgs.sliderOpacity == null ? 0 : convBgs.sliderOpacity, onOpacity: (v) => setConvBg('sliderOpacity', v), onReset: () => { setConvBg('sliderColor', null); setConvBg('sliderOpacity', 0) }, noDefaultText: true }),
2187
+ ConvBgRow({ label: '滑条轨道颜色', value: convBgs.sliderTrackColor, onSet: (v) => setConvBg('sliderTrackColor', v), opacity: convBgs.sliderTrackOpacity == null ? 0 : convBgs.sliderTrackOpacity, onOpacity: (v) => setConvBg('sliderTrackOpacity', v), onReset: () => { setConvBg('sliderTrackColor', null); setConvBg('sliderTrackOpacity', 0) }, noDefaultText: true }),
2188
+ ConvBgRow({ label: '滚动条颜色', value: convBgs.scrollColor, onSet: (v) => setConvBg('scrollColor', v), opacity: convBgs.scrollOpacity == null ? 0 : convBgs.scrollOpacity, onOpacity: (v) => setConvBg('scrollOpacity', v), onReset: () => { setConvBg('scrollColor', null); setConvBg('scrollOpacity', 0) }, noDefaultText: true }),
2189
+ )
2190
+ }
2191
+
2192
+ // 对话区背景行(2026-08-21):标签 + 色盘 + 当前状态(官方默认/自定义)+ 恢复官方默认(改动过才显示,同文字颜色逻辑)
2193
+ // 标签窄 + 间距紧凑:确认态"确认恢复官方默认 + 取消"不换行
2194
+ // v0.9.14:可选 opacity/onOpacity(命令两按钮)——**始终显示透明度滑条**(官方默认色也可调透明度,作用于官方 token 色);
2195
+ // 可选 onReset(命令两按钮恢复时同时归零透明度,默认只清颜色);
2196
+ // v0.9.15:可选 noDefaultText——滑条/滚动条是插件自己的控件(无官方默认),未设置时不显示"官方默认"文本
2197
+ // v0.9.18:恢复按钮条件含透明度(只调透明度未调颜色时也可恢复)——框线/滑条区需要
2198
+ // v0.9.18:可选 defaultSwatch——未设置(value null)时色块显示的默认色(默认 #ffffff;框线板块传官方淡灰/设置界面黑色)
2199
+ // v0.9.18:可选 defaultText——未设置时的状态文本(默认"官方默认";浮窗面板传"默认"——浮窗是插件自己的面板无官方语义)
2200
+ function ConvBgRow({ label, value, onSet, opacity, onOpacity, onReset, noDefaultText, defaultSwatch, defaultText }) {
2201
+ // 透明度滑条始终可用(官方默认色 + 用户透明度 → buildConvCss 用 color-mix 作用于官方 token)
2202
+ const hasOpacity = opacity != null && onOpacity != null
2203
+ const custom = !!(value || (hasOpacity && opacity > 0))
2204
+ return React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '6px', flexWrap: 'wrap', marginBottom: '6px', fontSize: '12px' } },
2205
+ React.createElement('span', { style: { width: '92px', flex: 'none', fontSize: '13px' } }, label),
2206
+ // 未设置(null)时色盘显示默认色(官方默认近似色 #ffffff → 框线板块用官方淡灰/黑色,见 defaultSwatch)
2207
+ React.createElement(ColorField, { value: value || defaultSwatch || '#ffffff', onChange: (hex) => onSet(hex) }),
2208
+ // 状态文本固定宽度:对齐恢复按钮;只显示"已自定义/官方默认"(色值已由 ColorField 显示,避免重复 #rrggbb)
2209
+ // noDefaultText(插件自己的滑条/滚动条):未设置时留空(无"官方默认"——它没有官方默认)
2210
+ React.createElement('span', { style: { width: '76px', flex: 'none', fontVariantNumeric: 'tabular-nums', color: 'var(--dsw-alias-label-secondary)', fontSize: '12px' } }, custom ? '已自定义' : (noDefaultText ? '' : (defaultText || '官方默认'))),
2211
+ custom ? React.createElement(ConfirmButton, {
2212
+ label: '恢复官方默认', confirmLabel: '确认恢复官方默认',
2213
+ onConfirm: () => { if (onReset) onReset(); else onSet(null) }, resetKey: (value || '') + '|' + (hasOpacity ? (opacity == null ? 0 : opacity) : ''),
2214
+ }) : null,
2215
+ // 透明度滑条(仅命令两按钮 + 有自定义颜色时):数值大=透明(语义同全局);拖动走 OpacitySlider 内部抑制(suppress/commit,直更 style)
2216
+ hasOpacity ? React.createElement(OpacitySlider, { value: opacity, onChange: (v) => onOpacity(v) }) : null,
2217
+ )
2218
+ }
2219
+
2220
+ // 对话区子版块(2026-08-21):8 类背景(用户发言 / 行内代码 / 代码块背景 / 代码块滚动条 / 对话区滚动条 / 任务栏收起 / 任务栏展开 / 一键到底),默认官方
2221
+ // 渲染在「对话区」卡片内部(原待支持区块,现挂接本子版块);底部"全部恢复官方默认"(任一改动过才显示,仿字体颜色)
2222
+ function ConversationEditor() {
2223
+ const { convBgs } = useStore()
2224
+ const hasCustom = !!convBgs.bubble || !!convBgs.inline || !!convBgs.code || !!convBgs.scrollbar || !!convBgs.chatScroll || !!convBgs.todoCollapsed || !!convBgs.todoExpanded || !!convBgs.toBottom
2225
+ return React.createElement('div', null,
2226
+ ConvBgRow({ label: '用户发言背景', value: convBgs.bubble, onSet: (v) => setConvBg('bubble', v) }),
2227
+ ConvBgRow({ label: '行内代码背景', value: convBgs.inline, onSet: (v) => setConvBg('inline', v) }),
2228
+ // v0.9.19:代码块滚动条 / 对话区滚动条两行位置对调(元素整体移动,键跟随;用户要求交换位置+文本)
2229
+ // v0.9.20 小调整:代码块背景 / 代码块滚动条位置交换(背景在前、滚动条在后,更符合逻辑)
2230
+ ConvBgRow({ label: '代码块背景', value: convBgs.code, onSet: (v) => setConvBg('code', v) }),
2231
+ ConvBgRow({ label: '代码块滚动条', value: convBgs.scrollbar, onSet: (v) => setConvBg('scrollbar', v) }),
2232
+ ConvBgRow({ label: '对话区滚动条', value: convBgs.chatScroll, onSet: (v) => setConvBg('chatScroll', v) }),
2233
+ ConvBgRow({ label: '任务栏收起', value: convBgs.todoCollapsed, onSet: (v) => setConvBg('todoCollapsed', v) }),
2234
+ ConvBgRow({ label: '任务栏展开', value: convBgs.todoExpanded, onSet: (v) => setConvBg('todoExpanded', v) }),
2235
+ ConvBgRow({ label: '一键到底', value: convBgs.toBottom, onSet: (v) => setConvBg('toBottom', v) }),
2236
+ hasCustom
2237
+ ? React.createElement('div', { style: { marginTop: '4px' } },
2238
+ React.createElement(ConfirmButton, {
2239
+ label: '全部恢复官方默认', confirmLabel: '全部确认恢复官方默认',
2240
+ onConfirm: resetAllConvBgs, resetKey: hasCustom, size: 'md',
2241
+ }),
2242
+ )
2243
+ : null,
2244
+ )
2245
+ }
2246
+
2247
+ // 「界面」模块外壳(原 ModuleContent background 分支):透明度说明 → 全部 AREAS 区域卡片(主界面/侧边栏/对话区/输入区/设置界面/浮窗面板/Cordis 插件界面)
2248
+ function BackgroundModule() {
2249
+ const { areas, sidebarInfo, cordisEntry, showOpacityHint, convBgs } = useStore()
2250
+ // 性能优化 2026-08-21:AreaEditor 已 memo 化 → onChange/children 必须稳定引用,
2251
+ // 否则父级每次重渲染都新建闭包/元素,memo 浅比较失效(所有区域跟着全量重渲染)
2252
+ const changeArea = React.useMemo(() => {
2253
+ const m = {}
2254
+ for (const a of AREAS) m[a.id] = (v) => setArea(a.id, v)
2255
+ return m
2256
+ }, [])
2257
+ const childModules = React.useMemo(() => ({
2258
+ // 「新会话」是侧边栏区块的一部分;「统计条/命令」是输入区区块的一部分(渲染在对应卡片内部末尾);
2259
+ // 「对话区背景」是对话区区块的一部分(渲染在对话区卡片内部末尾)
2260
+ // 输入区卡片内部结构(v0.9.11 用户定):底色(AreaEditor 内)→ 输入框高度(ComposerHeightRow,板块直属)→ 统计条/命令(ComposerEditor 子版块)
2261
+ // 浮窗面板卡片(v0.9.13):背景调整(AreaEditor)+ 显示透明度说明勾选(children)
2262
+ // 各子版块自身订阅 store → store 变化时自行重渲染,不受父级跳过影响
2263
+ sidebar: React.createElement(React.Fragment, null,
2264
+ React.createElement(BrandEditor, null),
2265
+ React.createElement(NewSessionEditor, null),
2266
+ ),
2267
+ composer: React.createElement(React.Fragment, null,
2268
+ React.createElement(ComposerHeightRow, null),
2269
+ React.createElement(ComposerEditor, null),
2270
+ ),
2271
+ conversation: React.createElement(ConversationEditor, null),
2272
+ float: React.createElement(React.Fragment, null,
2273
+ React.createElement(SliderScrollEditor, null),
2274
+ React.createElement(OpacityHintToggle, null),
2275
+ ),
2276
+ cordis: React.createElement(CordisEntryToggle, null),
2277
+ details: React.createElement(DetailsResetPos, null),
2278
+ }), [])
2279
+ return React.createElement('div', null,
2280
+ showOpacityHint
2281
+ ? React.createElement('div', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-secondary)', marginBottom: '8px', whiteSpace: 'pre-line' } },
2282
+ '透明度说明:数值越大越透明,数值越小越不透明。\n透明度为0时完全半透明,透明度为1时完全透明',
2283
+ )
2284
+ : null,
2285
+ AREAS.map((a) => React.createElement(AreaEditor, {
2286
+ // ⚠️ 防御:旧配置整体替换后新区域(float/details/cordis)可能缺失 → emptyArea 兜底(v0.9.13 事故修复)
2287
+ area: a, value: areas[a.id] && typeof areas[a.id] === 'object' ? areas[a.id] : emptyArea(), onChange: changeArea[a.id],
2288
+ ratio: getCropRatio(a),
2289
+ children: childModules[a.id] || null,
2290
+ })),
2291
+ )
2292
+ }
2293
+
2294
+ // ════════ 10_colors.js ════════
2295
+ // ┌─ 片段 10_colors ────────────────────────────────
2296
+ // │ 职责:字体颜色板块(颜色行组件 + 「字体颜色」模块外壳)
2297
+ // │ 定义:ColorRow / ColorsModule
2298
+ // │ 测试:改 5 类文字颜色行为/颜色模块说明与全部恢复按钮时来此片段
2299
+ function ColorRow({ item, value, official, onChange, onReset }) {
2300
+ const v = value || official || '#000000'
2301
+ const isCustom = !!value
2302
+ return React.createElement('div', { style: { padding: '8px 10px', border: '1px solid var(--dsw-alias-border-l1)', borderRadius: '8px', marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '10px', flexWrap: 'wrap' } },
2303
+ React.createElement(ColorField, { value: v, onChange: (hex) => onChange(hex) }),
2304
+ React.createElement('div', { style: { flex: 1, minWidth: '160px' } },
2305
+ React.createElement('div', { style: { fontSize: '13px', fontWeight: 600 } },
2306
+ item.label,
2307
+ isCustom ? React.createElement('span', { style: { marginLeft: '6px', fontSize: '11px', color: 'var(--dsw-alias-brand-primary)' } }, '已自定义') : React.createElement('span', { style: { marginLeft: '6px', fontSize: '11px', color: 'var(--dsw-alias-label-secondary)' } }, '官方原样'),
2308
+ ),
2309
+ React.createElement('div', { style: { fontSize: '11px', color: 'var(--dsw-alias-label-secondary)', marginTop: '2px' } }, item.desc),
2310
+ React.createElement('div', { style: { fontSize: '11px', color: 'var(--dsw-alias-label-caption)', marginTop: '2px', fontVariantNumeric: 'tabular-nums' } },
2311
+ '当前: ' + v,
2312
+ ),
2313
+ ),
2314
+ isCustom
2315
+ ? React.createElement(ConfirmButton, {
2316
+ label: '恢复官方默认', confirmLabel: '确认恢复官方默认',
2317
+ onConfirm: onReset, resetKey: v,
2318
+ })
2319
+ : null,
2320
+ )
2321
+ }
2322
+
2323
+ // 「字体颜色」模块外壳(原 ModuleContent colors 分支):说明 + 5 行 ColorRow + 全部恢复官方默认(改动过才显示)
2324
+ function ColorsModule() {
2325
+ const { colors, officialColors } = useStore()
2326
+ const hasCustomColor = COLOR_ITEMS.some((it) => !!colors[it.key])
2327
+ return React.createElement('div', null,
2328
+ React.createElement('div', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-secondary)', marginBottom: '8px' } },
2329
+ '亮暗主题共用同一套颜色;不设置 = 官方原样。状态色(错误/成功/警告)为功能语义色,保持官方。',
2330
+ ),
2331
+ COLOR_ITEMS.map((it) => React.createElement(ColorRow, {
2332
+ key: it.key,
2333
+ item: it,
2334
+ value: colors[it.key],
2335
+ official: officialColors[it.key],
2336
+ onChange: (v) => setColor(it.key, v),
2337
+ onReset: () => resetColor(it.key),
2338
+ })),
2339
+ hasCustomColor
2340
+ ? React.createElement(ConfirmButton, {
2341
+ label: '全部恢复官方默认', confirmLabel: '全部确认恢复官方默认',
2342
+ onConfirm: resetAllColors, resetKey: hasCustomColor, size: 'md',
2343
+ })
2344
+ : null,
2345
+ )
2346
+ }
2347
+
2348
+ // ════════ 11_presets_panel.js ════════
2349
+ // ┌─ 片段 11_presets_panel ─────────────────────────
2350
+ // │ 职责:预设板块交互 + UI(保存/导入/同名覆盖/放弃/应用/删除/导出/重命名/拖拽排序 FLIP)
2351
+ // │ 数据层在 06_presets.js(IndexedDB + applyPresetData)
2352
+ // │ ⚠️ 保留 useStore(['preset']) 订阅:预设异步加载/拖拽排序后靠 preset 通道触发重渲染,去掉订阅列表不会刷新
2353
+ // memo 化 + 通道订阅(性能优化 2026-08-21):只订阅 preset 通道(loadPresets 异步完成/拖拽排序刷新),
2354
+ // 不订阅 main 通道 → 颜色/透明度/背景拖动时预设面板不再跟着每帧重渲染(最大组件,收益最大)
2355
+ const PresetsPanel = React.memo(function PresetsPanel() {
2356
+ const [presetMsg, setPresetMsg] = React.useState('')
2357
+ const [newPresetName, setNewPresetName] = React.useState('')
2358
+ const [naming, setNaming] = React.useState(false)
2359
+ // 同名冲突:{ kind: 'save' | 'import', name, data, targetId };null = 无冲突
2360
+ const [conflict, setConflict] = React.useState(null)
2361
+ // 预设删除:两步确认(记录待确认删除的预设 id)
2362
+ const [confirmDeleteId, setConfirmDeleteId] = React.useState(null)
2363
+ const [renameTargetId, setRenameTargetId] = React.useState(null)
2364
+ const [renameInput, setRenameInput] = React.useState('')
2365
+ const [dragIndex, setDragIndex] = React.useState(null)
2366
+ const dragIndexRef = React.useRef(null)
2367
+ const listRef = React.useRef(null)
2368
+ const flipDataRef = React.useRef(null)
2369
+ const presetFileRef = React.useRef(null)
2370
+ useStore(['preset'])
2371
+ // FLIP 动画:预设重排后,各行从旧位置平滑过渡到新位置(拖拽交换时其他行也有移动动画)
2372
+ React.useEffect(() => {
2373
+ if (!flipDataRef.current || !listRef.current) return
2374
+ const rows = listRef.current.querySelectorAll('[data-preset-row]')
2375
+ rows.forEach((el) => {
2376
+ const oldTop = flipDataRef.current.get(el)
2377
+ if (oldTop === undefined) return
2378
+ const newTop = el.getBoundingClientRect().top
2379
+ const dy = oldTop - newTop
2380
+ if (dy !== 0) {
2381
+ el.style.transition = 'none'
2382
+ el.style.transform = 'translateY(' + dy + 'px)'
2383
+ void el.offsetHeight // 强制 reflow
2384
+ el.style.transition = 'transform 160ms ease'
2385
+ el.style.transform = 'translateY(0px)'
2386
+ }
2387
+ })
2388
+ flipDataRef.current = null
2389
+ }, [presets])
2390
+ // P5 预设:本地预设库(最多 10 个,IndexedDB 存储);保存/应用/重命名/删除/导出单个/导入
2391
+ const handlePresetFile = (e) => {
2392
+ const file = e.target.files && e.target.files[0]
2393
+ if (!file) return
2394
+ setConfirmDeleteId(null)
2395
+ const reader = new FileReader()
2396
+ reader.onload = () => {
2397
+ try {
2398
+ const c = JSON.parse(String(reader.result))
2399
+ if (!c || typeof c !== 'object' || !c.areas || typeof c.areas !== 'object') throw new Error('不是有效的主题预设文件')
2400
+ const base = (file.name || '导入预设').replace(/\.tczp$/i, '').replace(/\.json$/i, '').trim() || '导入预设'
2401
+ // 同名检测:已有同名预设时不直接入库,提示用户选择(覆盖 / 放弃)
2402
+ const dup = presets.find((p) => p.name === base)
2403
+ if (dup) {
2404
+ setConflict({ kind: 'import', name: base, data: c, targetId: dup.id })
2405
+ setPresetMsg('')
2406
+ return
2407
+ }
2408
+ // 预设库满员:直接拒绝导入(不应用设置),提示失败
2409
+ if (presets.length >= PRESET_LIMIT) {
2410
+ setPresetMsg('❌ 导入失败:能保存的预设数量达到上限(' + PRESET_LIMIT + ' 个),请先删除部分预设后再导入')
2411
+ return
2412
+ }
2413
+ applyPresetData(c) // 应用(setter 路径 + 末尾统一保存)
2414
+ const newItem = { id: String(Date.now()) + '-' + presets.length, name: base, savedAt: Date.now(), data: c }
2415
+ presets = [...presets, newItem]
2416
+ savePresets().then(() => {
2417
+ // 导入后更新"当前预设"显示
2418
+ lastAppliedId = newItem.id
2419
+ try { window.localStorage.setItem('theme-customizer-last-applied', newItem.id) } catch (e) { /* 忽略 */ }
2420
+ setPresetMsg('✅ 导入成功:设置已恢复,并已存入预设库(' + presets.length + '/' + PRESET_LIMIT + ')')
2421
+ }).catch(() => {
2422
+ presets = presets.slice(0, -1)
2423
+ setPresetMsg('✅ 导入成功:设置已恢复。⚠️ 预设库存储空间不足(图片较大),未存入库')
2424
+ })
2425
+ } catch (err) {
2426
+ const stack = err && err.stack ? String(err.stack).split('\n').slice(0, 3).join(' | ') : ''
2427
+ setPresetMsg('❌ 导入失败:' + (err && err.message ? err.message : String(err)) + (stack ? ' | ' + stack : ''))
2428
+ }
2429
+ }
2430
+ reader.readAsText(file)
2431
+ e.target.value = ''
2432
+ }
2433
+ const savePreset = () => {
2434
+ setConfirmDeleteId(null)
2435
+ const name = (newPresetName || '').trim() || ('预设 ' + (presets.length + 1))
2436
+ // 同名检测:已有同名预设时不直接保存,提示用户选择(覆盖 / 放弃)
2437
+ const dup = presets.find((p) => p.name === name)
2438
+ if (dup) {
2439
+ setConflict({ kind: 'save', name, data: null, targetId: dup.id })
2440
+ setPresetMsg('')
2441
+ return
2442
+ }
2443
+ if (presets.length >= PRESET_LIMIT) {
2444
+ setPresetMsg('❌ 能保存的预设数量达到上限(' + PRESET_LIMIT + ' 个),请先删除部分预设')
2445
+ return
2446
+ }
2447
+ // ⚠️ 必须深拷贝快照(JSON 往返):data 里的 areas/colors 若直接引用当前对象,
2448
+ // 保存后修改设置会让已存预设"跟随"变化(应用时读到的是当前设置而非保存快照)
2449
+ const data = JSON.parse(JSON.stringify({ v: 1, opacitySem: 4, areas, colors, cordisEntry, floatModules, floatShowReset, floatPos, floatVisible, newSession, brand, brandHarness, borders, detailsPos, detailsDragEnabled, showOpacityHint, convBgs }))
2450
+ const newItem = { id: String(Date.now()) + '-' + presets.length, name, savedAt: Date.now(), data }
2451
+ presets = [...presets, newItem]
2452
+ savePresets().then(() => {
2453
+ setNewPresetName('')
2454
+ setNaming(false)
2455
+ // 保存后更新"当前预设"显示
2456
+ lastAppliedId = newItem.id
2457
+ try { window.localStorage.setItem('theme-customizer-last-applied', newItem.id) } catch (e) { /* 忽略 */ }
2458
+ setPresetMsg('✅ 已保存预设:' + name + '(' + presets.length + '/' + PRESET_LIMIT + ')')
2459
+ }).catch(() => {
2460
+ presets = presets.slice(0, -1)
2461
+ setPresetMsg('❌ 保存失败:存储空间不足(图片较大),请删除部分预设或换用较小图片')
2462
+ })
2463
+ }
2464
+ // 同名冲突:覆盖当前同名预设(保留原 id 和排序位置,仅更新数据)
2465
+ const confirmOverwrite = () => {
2466
+ if (!conflict) return
2467
+ setConfirmDeleteId(null)
2468
+ const targetId = conflict.targetId
2469
+ const name = conflict.name
2470
+ const oldItem = presets.find((p) => p.id === targetId)
2471
+ if (!oldItem) { setConflict(null); return }
2472
+ if (conflict.kind === 'save') {
2473
+ // 保存覆盖:用当前设置生成深拷贝快照
2474
+ const data = JSON.parse(JSON.stringify({ v: 1, opacitySem: 4, areas, colors, cordisEntry, floatModules, floatShowReset, floatPos, floatVisible, newSession, brand, brandHarness, borders, detailsPos, detailsDragEnabled, showOpacityHint, convBgs }))
2475
+ presets = presets.map((p) => p.id === targetId ? { ...p, name, savedAt: Date.now(), data } : p)
2476
+ savePresets().then(() => {
2477
+ setConflict(null)
2478
+ setNewPresetName('')
2479
+ setNaming(false)
2480
+ setPresetMsg('✅ 已覆盖同名预设:' + name)
2481
+ }).catch(() => {
2482
+ presets = presets.map((p) => p.id === targetId && oldItem ? oldItem : p)
2483
+ setPresetMsg('❌ 覆盖失败:存储空间不足(图片较大),原预设未变')
2484
+ })
2485
+ } else {
2486
+ // 导入覆盖:应用文件设置 + 覆盖该预设数据
2487
+ try { applyPresetData(conflict.data) } catch (err) {
2488
+ setConflict(null)
2489
+ setPresetMsg('❌ 覆盖失败:' + (err && err.message ? err.message : String(err)))
2490
+ return
2491
+ }
2492
+ presets = presets.map((p) => p.id === targetId ? { ...p, name, savedAt: Date.now(), data: conflict.data } : p)
2493
+ savePresets().then(() => {
2494
+ setConflict(null)
2495
+ setPresetMsg('✅ 导入成功:已覆盖同名预设 ' + name + '(设置已恢复)')
2496
+ }).catch(() => {
2497
+ presets = presets.map((p) => p.id === targetId && oldItem ? oldItem : p)
2498
+ setPresetMsg('❌ 导入成功:设置已恢复。⚠️ 覆盖入库失败(空间不足),原预设未变')
2499
+ })
2500
+ }
2501
+ }
2502
+ // 同名冲突:放弃增加预设(保存=清空命名框关闭;导入=不入库不应用)
2503
+ const cancelConflict = () => {
2504
+ const wasSave = conflict && conflict.kind === 'save'
2505
+ setConfirmDeleteId(null)
2506
+ setConflict(null)
2507
+ if (wasSave) {
2508
+ setNewPresetName('')
2509
+ setNaming(false)
2510
+ setPresetMsg('⏹ 已放弃保存:未增加预设')
2511
+ } else {
2512
+ setPresetMsg('⏹ 已放弃导入:未增加预设')
2513
+ }
2514
+ }
2515
+ const applyPreset = (item) => {
2516
+ setConfirmDeleteId(null)
2517
+ try {
2518
+ applyPresetData(item.data)
2519
+ lastAppliedId = item.id
2520
+ try { window.localStorage.setItem('theme-customizer-last-applied', item.id) } catch (e) { /* 忽略 */ }
2521
+ setPresetMsg('✅ 已应用预设:' + item.name)
2522
+ } catch (err) {
2523
+ setPresetMsg('❌ 应用预设失败:' + (err && err.message ? err.message : String(err)))
2524
+ }
2525
+ }
2526
+ const deletePreset = (id) => {
2527
+ // 两步确认:第一次点击进入确认态(按钮变"确认删除"),再点才真正删除
2528
+ if (confirmDeleteId !== id) { setConfirmDeleteId(id); setPresetMsg(''); return }
2529
+ setConfirmDeleteId(null)
2530
+ const it = presets.find((p) => p.id === id)
2531
+ presets = presets.filter((p) => p.id !== id)
2532
+ if (lastAppliedId === id) {
2533
+ lastAppliedId = null
2534
+ try { window.localStorage.removeItem('theme-customizer-last-applied') } catch (e) { /* 忽略 */ }
2535
+ }
2536
+ idbDelete(id).then(() => {
2537
+ setPresetMsg('🗑 已删除预设:' + (it ? it.name : '') + '(剩余 ' + presets.length + '/' + PRESET_LIMIT + ')')
2538
+ }).catch(() => {
2539
+ setPresetMsg('⚠️ 已从列表移除,但存储清理失败(刷新后可能重现),请重试')
2540
+ })
2541
+ }
2542
+ // 导出指定预设为 .tczp 文件(原"导出当前设置"废弃)
2543
+ const exportPresetItem = (item) => {
2544
+ setConfirmDeleteId(null)
2545
+ try {
2546
+ const blob = new Blob([JSON.stringify(item.data)], { type: 'application/json' })
2547
+ const url = URL.createObjectURL(blob)
2548
+ const a = document.createElement('a')
2549
+ a.href = url
2550
+ a.download = (item.name || '预设').replace(/[\\/:*?"<>|]/g, '-') + '.tczp'
2551
+ document.body.appendChild(a)
2552
+ a.click()
2553
+ document.body.removeChild(a)
2554
+ try { URL.revokeObjectURL(url) } catch (e) { /* 忽略 */ }
2555
+ setPresetMsg('✅ 已导出预设:' + item.name + '(.tczp,含图片)')
2556
+ } catch (e) {
2557
+ setPresetMsg('❌ 导出失败:' + (e && e.message ? e.message : String(e)))
2558
+ }
2559
+ }
2560
+ const startRename = (item) => { setConfirmDeleteId(null); setRenameTargetId(item.id); setRenameInput('') }
2561
+ const cancelRename = () => { setConfirmDeleteId(null); setRenameTargetId(null); setRenameInput('') }
2562
+ // 拖拽排序:三条灰线手柄 → 按起始位置+行高推算目标(不读实时 DOM,避免高频 pointermove 下 React 渲染滞后导致边界抖动)→ FLIP 动画 → 松手保存
2563
+ const startDragPreset = (e, index) => {
2564
+ if (e.button !== 0) return
2565
+ e.preventDefault()
2566
+ setConfirmDeleteId(null)
2567
+ dragIndexRef.current = index
2568
+ setDragIndex(index)
2569
+ // 拖拽期间锁定光标(grabbing)并禁止选中,避免指针经过按钮/文本时光标频繁变化
2570
+ let dragStyle = null
2571
+ try {
2572
+ dragStyle = document.createElement('style')
2573
+ dragStyle.setAttribute('data-thmcz-drag', 'true')
2574
+ dragStyle.textContent = '* { cursor: grabbing !important; user-select: none !important; }'
2575
+ document.head.appendChild(dragStyle)
2576
+ } catch (err) { /* 忽略 */ }
2577
+ const startY = e.clientY
2578
+ let rowH = 40
2579
+ try {
2580
+ const rows0 = listRef.current && listRef.current.querySelectorAll('[data-preset-row]')
2581
+ const el0 = rows0 && rows0[index]
2582
+ if (el0) rowH = el0.getBoundingClientRect().height || 40
2583
+ } catch (err) { /* 忽略 */ }
2584
+ const onMove = (ev) => {
2585
+ if (!listRef.current) return
2586
+ const rows = listRef.current.querySelectorAll('[data-preset-row]')
2587
+ if (!rows.length) return
2588
+ // 目标 = 起始序号 + 移动距离/行高(Math.round 滞回:越过半行高才换位,边界不来回抖动)
2589
+ const dy = ev.clientY - startY
2590
+ let target = index + Math.round(dy / rowH)
2591
+ target = Math.max(0, Math.min(rows.length - 1, target))
2592
+ if (target !== dragIndexRef.current) {
2593
+ // FLIP 前置:记录各行当前位置(React 重排后由 useEffect 播放过渡动画)
2594
+ const rowsNow = listRef.current.querySelectorAll('[data-preset-row]')
2595
+ const oldTops = new Map()
2596
+ rowsNow.forEach((r) => oldTops.set(r, r.getBoundingClientRect().top))
2597
+ const arr = [...presets]
2598
+ const [moved] = arr.splice(dragIndexRef.current, 1)
2599
+ arr.splice(target, 0, moved)
2600
+ presets = arr
2601
+ dragIndexRef.current = target
2602
+ flipDataRef.current = oldTops
2603
+ presetNotify()
2604
+ }
2605
+ }
2606
+ const onUp = () => {
2607
+ window.removeEventListener('pointermove', onMove)
2608
+ window.removeEventListener('pointerup', onUp)
2609
+ try {
2610
+ const s = document.querySelector('style[data-thmcz-drag]')
2611
+ if (s) s.remove()
2612
+ } catch (err) { /* 忽略 */ }
2613
+ setDragIndex(null)
2614
+ dragIndexRef.current = null
2615
+ savePresets().then(() => {
2616
+ setPresetMsg('↕ 已调整预设顺序')
2617
+ }).catch(() => {
2618
+ setPresetMsg('⚠️ 顺序已调整,但保存失败(刷新后可能恢复原序)')
2619
+ })
2620
+ }
2621
+ window.addEventListener('pointermove', onMove)
2622
+ window.addEventListener('pointerup', onUp)
2623
+ }
2624
+ const confirmRename = () => {
2625
+ const name = (renameInput || '').trim()
2626
+ if (!name) { setPresetMsg('❌ 名称不能为空'); return }
2627
+ const targetId = renameTargetId
2628
+ const old = presets.find((p) => p.id === targetId)
2629
+ presets = presets.map((p) => p.id === targetId ? { ...p, name } : p)
2630
+ savePresets().then(() => {
2631
+ setRenameTargetId(null)
2632
+ setRenameInput('')
2633
+ }).catch(() => {
2634
+ presets = presets.map((p) => p.id === targetId && old ? { ...p, name: old.name } : p)
2635
+ setPresetMsg('❌ 重命名保存失败')
2636
+ })
2637
+ }
2638
+ const btnSmall = { padding: '5px 12px', borderRadius: '6px', cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2)', background: 'var(--dsw-alias-bg-layer-1)', fontSize: '12px', flex: 'none' }
2639
+ const currentName = (presets.find((p) => p.id === lastAppliedId) || {}).name || '未应用'
2640
+ return React.createElement('div', null,
2641
+ React.createElement('div', { style: { padding: '10px', border: '1px solid var(--dsw-alias-border-l1)', borderRadius: '8px', marginTop: '4px' } },
2642
+ React.createElement('div', { style: { fontSize: '13px', fontWeight: 600, marginBottom: '6px' } }, '本地预设库(' + presets.length + '/' + PRESET_LIMIT + ')'),
2643
+ React.createElement('div', { style: { display: 'flex', gap: '8px', marginBottom: '8px', alignItems: 'center', flexWrap: 'wrap' } },
2644
+ React.createElement('span', { style: { fontSize: '13px', flex: 'none' } }, '当前预设:'),
2645
+ React.createElement('span', { style: { fontSize: '13px', fontWeight: 600, color: 'var(--dsw-alias-brand-primary)', flex: 1, minWidth: 80, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, currentName),
2646
+ React.createElement('button', { type: 'button', onClick: () => { setConfirmDeleteId(null); setNaming(true) }, style: { ...btnSmall, background: 'var(--dsw-alias-brand-primary)', color: '#fff', border: 'none' } }, '💾 保存预设'),
2647
+ React.createElement('button', { type: 'button', onClick: () => presetFileRef.current && presetFileRef.current.click(), style: btnSmall }, '📥 导入预设'),
2648
+ React.createElement('input', { ref: presetFileRef, type: 'file', accept: '.tczp,application/json,.json', style: { display: 'none' }, onChange: handlePresetFile }),
2649
+ ),
2650
+ conflict
2651
+ ? React.createElement('div', { style: { display: 'flex', gap: '8px', marginBottom: '8px', alignItems: 'center', flexWrap: 'wrap', padding: '8px 10px', border: '1px solid var(--dsw-alias-state-warning-primary, #d97706)', borderRadius: '6px', background: 'var(--dsw-alias-bg-layer-1)' } },
2652
+ React.createElement('span', { style: { fontSize: '13px', color: 'var(--dsw-alias-state-warning-primary, #d97706)', flex: 1, minWidth: 200 } }, '⚠️ 当前预设与已有预设同名:' + conflict.name),
2653
+ React.createElement('button', { type: 'button', onClick: confirmOverwrite, style: { ...btnSmall, background: 'var(--dsw-alias-brand-primary)', color: '#fff', border: 'none' } }, '覆盖当前同名预设'),
2654
+ React.createElement('button', { type: 'button', onClick: cancelConflict, style: btnSmall }, '放弃增加预设'),
2655
+ )
2656
+ : null,
2657
+ presetMsg
2658
+ ? React.createElement('div', { style: { marginBottom: '8px', fontSize: '12px', color: presetMsg.indexOf('❌') === 0 ? 'var(--dsw-alias-state-error-primary)' : 'var(--dsw-alias-label-secondary)', fontVariantNumeric: 'tabular-nums' } }, presetMsg)
2659
+ : null,
2660
+ naming
2661
+ ? React.createElement('div', { style: { display: 'flex', gap: '8px', marginBottom: '8px', alignItems: 'center' } },
2662
+ React.createElement('span', { style: { fontSize: '13px', flex: 'none' } }, '预设名称:'),
2663
+ React.createElement('input', { autoFocus: true, value: newPresetName, onChange: (e) => setNewPresetName(e.target.value), placeholder: '输入名称', style: { flex: 1, padding: '6px 10px', borderRadius: '6px', border: '1px solid var(--dsw-alias-border-l2)', background: 'var(--dsw-alias-bg-layer-1)', fontSize: '13px', minWidth: 120 } }),
2664
+ React.createElement('button', { type: 'button', onClick: savePreset, style: { ...btnSmall, background: 'var(--dsw-alias-brand-primary)', color: '#fff', border: 'none' } }, '✓ 确定'),
2665
+ React.createElement('button', { type: 'button', onClick: () => setNaming(false), style: btnSmall }, '✕ 取消'),
2666
+ )
2667
+ : null,
2668
+ presets.length === 0
2669
+ ? React.createElement('div', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-caption)' } }, '还没有预设:点「保存预设」命名保存,或导入 .tczp 文件(自动入库)。')
2670
+ : React.createElement('div', { ref: listRef, style: { display: 'flex', flexDirection: 'column', gap: '6px' } },
2671
+ presets.map((p, pi) => React.createElement(React.Fragment, { key: p.id },
2672
+ React.createElement('div', { 'data-preset-row': true, style: { display: 'flex', alignItems: 'center', gap: '8px', padding: '8px 10px', border: '1px solid var(--dsw-alias-border-l1)', borderRadius: '6px', opacity: dragIndex === pi ? 0.5 : 1 } },
2673
+ React.createElement('div', { style: { flex: 1, minWidth: 0 } },
2674
+ React.createElement('div', { style: { fontSize: '13px', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, p.name),
2675
+ React.createElement('div', { style: { fontSize: '11px', color: 'var(--dsw-alias-label-caption)', fontVariantNumeric: 'tabular-nums' } }, new Date(p.savedAt).toLocaleString()),
2676
+ ),
2677
+ React.createElement('button', { type: 'button', onClick: () => applyPreset(p), style: btnSmall }, '▶ 应用'),
2678
+ React.createElement('button', { type: 'button', onClick: () => startRename(p), style: btnSmall }, '✏ 重命名'),
2679
+ React.createElement('button', { type: 'button', onClick: () => exportPresetItem(p), style: btnSmall }, '📤 导出'),
2680
+ React.createElement('button', { type: 'button', onClick: () => deletePreset(p.id), style: confirmDeleteId === p.id ? { ...btnSmall, background: 'var(--dsw-alias-state-error-primary)', color: '#fff', border: 'none' } : { ...btnSmall, color: 'var(--dsw-alias-state-error-primary)', borderColor: 'var(--dsw-alias-state-error-primary)' } }, confirmDeleteId === p.id ? '确认删除' : '🗑 删除'),
2681
+ confirmDeleteId === p.id
2682
+ ? React.createElement('button', { type: 'button', onClick: () => setConfirmDeleteId(null), style: btnSmall }, '取消')
2683
+ : null,
2684
+ React.createElement('div', { onPointerDown: (e) => startDragPreset(e, pi), title: '拖拽排序', style: { cursor: 'grab', padding: '4px', display: 'flex', flex: 'none', touchAction: 'none' } },
2685
+ React.createElement('svg', { width: '14', height: '12', viewBox: '0 0 14 12', style: { display: 'block' } },
2686
+ React.createElement('line', { x1: '0', y1: '1', x2: '14', y2: '1', stroke: 'var(--dsw-alias-border-l2)', strokeWidth: '2' }),
2687
+ React.createElement('line', { x1: '0', y1: '6', x2: '14', y2: '6', stroke: 'var(--dsw-alias-border-l2)', strokeWidth: '2' }),
2688
+ React.createElement('line', { x1: '0', y1: '11', x2: '14', y2: '11', stroke: 'var(--dsw-alias-border-l2)', strokeWidth: '2' }),
2689
+ ),
2690
+ ),
2691
+ ),
2692
+ renameTargetId === p.id
2693
+ ? React.createElement('div', { style: { display: 'flex', gap: '8px', alignItems: 'center', padding: '6px 10px', border: '1px dashed var(--dsw-alias-border-l2)', borderRadius: '6px' } },
2694
+ React.createElement('span', { style: { fontSize: '13px', flex: 'none' } }, '重命名为:'),
2695
+ React.createElement('input', { autoFocus: true, value: renameInput, onChange: (e) => setRenameInput(e.target.value), placeholder: '输入新名称', style: { flex: 1, padding: '6px 10px', borderRadius: '6px', border: '1px solid var(--dsw-alias-border-l2)', background: 'var(--dsw-alias-bg-layer-1)', fontSize: '13px', minWidth: 120 } }),
2696
+ React.createElement('button', { type: 'button', onClick: confirmRename, style: { ...btnSmall, background: 'var(--dsw-alias-brand-primary)', color: '#fff', border: 'none' } }, '✓ 确定'),
2697
+ React.createElement('button', { type: 'button', onClick: cancelRename, style: btnSmall }, '✕ 取消'),
2698
+ )
2699
+ : null,
2700
+ )),
2701
+ ),
2702
+ ),
2703
+ )
2704
+ })
2705
+
2706
+ // ════════ 12_float.js ════════
2707
+ // ┌─ 片段 12_float ─────────────────────────────────
2708
+ // │ 职责:浮窗(显示内容勾选 + 可拖浮窗面板;拖动用局部状态,pointerup 才写回全局 + 保存)
2709
+ // │ 定义:Section / FloatModulePicker / GlobalFloat
2710
+ function Section({ title, children }) {
2711
+ return React.createElement('section', { style: { marginBottom: '16px', padding: '16px', border: '1px solid var(--dsw-alias-border-l1)', borderRadius: '8px' } },
2712
+ React.createElement('h3', { style: { marginTop: 0, marginBottom: '10px', fontSize: '14px' } }, title),
2713
+ children,
2714
+ )
2715
+ }
2716
+
2717
+ function FloatModulePicker() {
2718
+ const { floatModules, floatShowReset } = useStore(['float'])
2719
+ return React.createElement(Section, { title: '浮窗显示内容' },
2720
+ React.createElement('p', { style: { color: 'var(--dsw-alias-label-secondary)', fontSize: '13px', margin: '0 0 10px' } }, '勾选要在浮窗中显示/可调整的模块:'),
2721
+ React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: '8px' } },
2722
+ // 开关仅勾选框触发(v0.9.20 用户定:不要整栏触发)
2723
+ MODULES.map((m) => React.createElement('div', { key: m.id, style: { display: 'flex', alignItems: 'center', gap: '8px', fontSize: '13px' } },
2724
+ React.createElement('input', { type: 'checkbox', style: { cursor: 'pointer' }, checked: !!floatModules[m.id], onChange: (e) => setFloatModules({ ...floatModules, [m.id]: e.target.checked }) }),
2725
+ React.createElement('span', null, m.title),
2726
+ )),
2727
+ // v0.9.16:浮窗内是否显示「全局恢复默认」按钮
2728
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '8px', fontSize: '13px', marginTop: '4px' } },
2729
+ React.createElement('input', { type: 'checkbox', style: { cursor: 'pointer' }, checked: !!floatShowReset, onChange: (e) => setFloatShowReset(e.target.checked) }),
2730
+ React.createElement('span', null, '显示「全局恢复默认」按钮'),
2731
+ ),
2732
+ ),
2733
+ )
2734
+ }
2735
+
2736
+ function GlobalFloat() {
2737
+ // 只订阅 float 通道:位置/显示内容拖动(float)不重渲染设置页;背景由 areas.float(main 通道 → buildFloatCss)控制
2738
+ const { floatVisible, floatPos, floatModules, floatShowReset } = useStore(['float'])
2739
+ const [dragging, setDragging] = React.useState(false)
2740
+ // 拖动位置局部状态:pointermove 只重渲染浮窗自身(不走全局 notify,避免每帧全量重渲染)
2741
+ const [dragPos, setDragPos] = React.useState(null)
2742
+ const dragRef = React.useRef(null)
2743
+ if (!floatVisible) return null
2744
+ const shown = MODULES.filter((m) => floatModules[m.id])
2745
+ const pos = dragPos ? { ...floatPos, ...dragPos } : floatPos
2746
+ // 背景(底色/纯色/图片 + 透明度)由 buildFloatCss 注入到 [data-thmcz-float](v0.9.13 起,原 floatOpacity 半透明已删);
2747
+ // 这里只保留布局/阴影/圆角(边框色用主题 token,随主题自动亮暗)
2748
+ const onPointerDown = (e) => {
2749
+ if (e.button !== 0) return
2750
+ e.preventDefault()
2751
+ const startX = e.clientX, startY = e.clientY
2752
+ const orig = { x: floatPos.x, y: floatPos.y }
2753
+ setDragging(true)
2754
+ const onMove = (ev) => {
2755
+ const next = { x: orig.x + (ev.clientX - startX), y: orig.y + (ev.clientY - startY) }
2756
+ dragRef.current = next
2757
+ setDragPos(next)
2758
+ }
2759
+ const onUp = () => {
2760
+ window.removeEventListener('pointermove', onMove)
2761
+ window.removeEventListener('pointerup', onUp)
2762
+ setDragging(false)
2763
+ // 拖动结束才写回全局 + 保存一次(无定时器约定)
2764
+ const p = dragRef.current
2765
+ dragRef.current = null
2766
+ setDragPos(null)
2767
+ if (p) setFloatPos({ ...floatPos, x: p.x, y: p.y })
2768
+ saveNow()
2769
+ }
2770
+ window.addEventListener('pointermove', onMove)
2771
+ window.addEventListener('pointerup', onUp)
2772
+ }
2773
+ return React.createElement('div', { 'data-thmcz-float': true, style: { position: 'fixed', left: pos.x, top: pos.y, width: pos.width, height: pos.height, zIndex: 2147483647, display: 'flex', flexDirection: 'column', borderRadius: '12px', boxShadow: '0 8px 32px rgba(0,0,0,0.35)', border: '1px solid var(--dsw-alias-border-l1)', color: 'var(--dsw-alias-label-primary)', overflow: 'hidden', userSelect: dragging ? 'none' : 'auto' } },
2774
+ React.createElement('div', { onPointerDown: onPointerDown, style: { height: '36px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 12px', cursor: 'move', flex: 'none', background: 'rgba(255,255,255,0.07)', borderBottom: '1px solid var(--dsw-alias-border-l1)' } },
2775
+ React.createElement('span', { style: { fontSize: '13px', fontWeight: 600 } }, '主题设置 · 浮动'),
2776
+ React.createElement('button', { type: 'button', onClick: () => setFloatVisible(false), style: { border: 'none', background: 'none', color: 'var(--dsw-alias-label-secondary)', cursor: 'pointer', fontSize: '13px', padding: '4px' } }, '✕'),
2777
+ ),
2778
+ // v0.9.15:滚动容器加 data-thmcz-float-scroll → 浮窗滚动条颜色/透明度(scrollColor/scrollOpacity)作用于此处
2779
+ React.createElement('div', { 'data-thmcz-float-scroll': true, style: { flex: 1, overflowY: 'auto', padding: '12px', display: 'flex', flexDirection: 'column', gap: '12px' } },
2780
+ shown.length === 0
2781
+ ? React.createElement('p', { style: { color: 'var(--dsw-alias-label-secondary)', fontSize: '13px' } }, '未选择任何模块,请到 设置 → 主题 → 浮窗显示内容 勾选。')
2782
+ : shown.map((m) => React.createElement('section', { key: m.id, style: { padding: '12px', border: '1px solid var(--dsw-alias-border-l1)', borderRadius: '8px' } },
2783
+ React.createElement('h4', { style: { margin: '0 0 8px', fontSize: '13px' } }, m.title),
2784
+ React.createElement(ModuleContent, { id: m.id }),
2785
+ )),
2786
+ // 全局恢复默认(v0.9.16):浮窗内也显示(与主题设置页一致),独立、居中、三级确认;受「浮窗显示内容」勾选控制
2787
+ floatShowReset
2788
+ ? React.createElement('div', { style: { marginTop: '4px', borderTop: '1px solid var(--dsw-alias-border-l1)', paddingTop: '12px' } },
2789
+ React.createElement(TripleConfirmButton, {
2790
+ label: '全局恢复默认', confirmLabel1: '确认全局恢复默认', confirmLabel2: '确认吗?你的全部自定义设置都会消失',
2791
+ onConfirm: resetAllSettings, resetKey: 'reset-all-settings',
2792
+ }),
2793
+ )
2794
+ : null,
2795
+ ),
2796
+ )
2797
+ }
2798
+
2799
+ // ════════ 13_boot.js ════════
2800
+ // ┌─ 片段 13_boot ──────────────────────────────────
2801
+ // │ 职责:主题页 + 模块注册表 + 槽位注册 + Cordis 常驻开关 + 启动副作用(集中在本片段)
2802
+ // │ 定义:ModuleContent / ThemePage / applyCordisEntryFlag;执行:slots.inject ×3 + applyCordisEntryFlag()
2803
+ // 「布局调整」板块开关(v0.9.19):设置界面可移动(详情见 07 拖动委托 + 09 DetailsResetPos)
2804
+ function LayoutToggle() {
2805
+ const { detailsDragEnabled } = useStore(['main'])
2806
+ // 开关仅勾选框触发(v0.9.20)
2807
+ return React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '8px', fontSize: '13px', marginBottom: '8px' } },
2808
+ React.createElement('input', { type: 'checkbox', style: { cursor: 'pointer' }, checked: !!detailsDragEnabled, onChange: (e) => setDetailsDragEnabled(e.target.checked) }),
2809
+ React.createElement('span', null, '设置界面可移动'),
2810
+ )
2811
+ }
2812
+
2813
+ // 模块注册表(四模块总开关):设置页与浮窗共用;各模块渲染已归位到所属板块片段(09 界面 / 10 颜色 / 11 预设)
2814
+ function ModuleContent({ id }) {
2815
+ if (id === 'background') return React.createElement(BackgroundModule, null)
2816
+ if (id === 'borders') return React.createElement(BordersModule, null)
2817
+ if (id === 'colors') return React.createElement(ColorsModule, null)
2818
+ // 布局调整:目前只有「设置界面可移动」开关(P4 拖拽布局占位按钮已删——2026-08-22 梳理,开源前去掉未完成功能占位)
2819
+ if (id === 'layout') return React.createElement(LayoutToggle, null)
2820
+ if (id === 'presets') return React.createElement(PresetsPanel, null)
2821
+ return null
2822
+ }
2823
+
2824
+ // 主题页注册**最前**(EXPERIENCE 铁律:保证设置入口存在,即使其他槽位注册失败——v0.9.19 侧边栏槽位事故后再次确认)
2825
+ slots.inject('settings.section', () => slots.register({ name: 'settings.section', id: 'theme-customizer', label: '主题', order: 900 }, () => React.createElement(ThemePage, null)))
2826
+
2827
+ slots.inject('shell.overlay', () => slots.register({ name: 'shell.overlay', id: 'theme-customizer-bg', order: 50 }, () => React.createElement(AreaCss, null)))
2828
+ slots.inject('shell.overlay', () => slots.register({ name: 'shell.overlay', id: 'theme-customizer-float', order: 100 }, () => React.createElement(GlobalFloat, null)))
2829
+
2830
+ // 统计条接管:注册到 conversation.composer.dock(官方 stats 同 slot,order 更大靠后渲染;
2831
+ // 官方 stats 用 CSS 隐藏,见 07_css_builder)。组件收到 slot 注入的 useSession/useProjection。
2832
+ slots.inject('conversation.composer.dock', () => slots.register({ name: 'conversation.composer.dock', id: 'theme-customizer-stats', order: 100 }, (props) => React.createElement(ComposerStatsLine, props)))
2833
+
2834
+ // ⚠️ v0.9.19 事故:曾直接 slots.register('sidebar.settings')——官方把它注册为**容器槽位**(children 含
2835
+ // settings.trigger/header/action/close),直接注册同名冲突 → 官方设置按钮消失 → "主题没有了"。
2836
+ // 曾试子槽位 sidebar.settings.settings.action 也不生效(官方子槽位仅容器内部使用)。
2837
+ // ✅ 最终方案:侧边栏复位按钮改用**原生 DOM 注入**(07 AreaCss useEffect:VOzbGW_trigger 右半注入),
2838
+ // 不再注册任何 sidebar 槽位。
2839
+
2840
+ // ── Cordis 常驻:官方按钮已由官方包补丁支持常驻(window.__TCZ_HIDE_OFFICIAL_CORDIS 控制开关)──
2841
+ // 开关状态同步到 window 变量(官方组件渲染时读取);false=常驻,true=恢复官方逻辑
2842
+ // 官方包补丁已监听 'tcz:cordis-visibility' 事件 → 切换后立即重渲染(无需刷新页面)
2843
+ function applyCordisEntryFlag() {
2844
+ try { window.__TCZ_HIDE_OFFICIAL_CORDIS = !cordisEntry } catch (e) { /* 忽略 */ }
2845
+ try { window.dispatchEvent(new Event('tcz:cordis-visibility')) } catch (e) { /* 忽略 */ }
2846
+ }
2847
+ applyCordisEntryFlag()
2848
+
2849
+ function ThemePage() {
2850
+ // main(lastSavedAt/floatVisible 双发)+ float(floatModules)
2851
+ const { floatVisible, floatModules, lastSavedAt } = useStore(['main', 'float'])
2852
+ return React.createElement('div', { style: { maxWidth: '720px', padding: '8px 4px' } },
2853
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '16px' } },
2854
+ React.createElement('h2', { style: { fontSize: '18px', margin: 0 } }, '主题设置'),
2855
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '8px' } },
2856
+ React.createElement('button', { type: 'button', onClick: resetFloatPos, style: { padding: '6px 14px', borderRadius: '6px', cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2)', background: 'var(--dsw-alias-bg-layer-1)', fontSize: '13px' } }, '↺ 复位浮窗'),
2857
+ React.createElement('button', { type: 'button', onClick: () => setFloatVisible(!floatVisible), style: { padding: '6px 14px', borderRadius: '6px', cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2)', background: floatVisible ? 'var(--dsw-alias-brand-primary)' : 'var(--dsw-alias-bg-layer-1)', color: floatVisible ? '#fff' : 'var(--dsw-alias-label-primary)' } }, floatVisible ? '关闭浮窗' : '↗ 打开浮窗'),
2858
+ ),
2859
+ ),
2860
+ React.createElement(FloatModulePicker, null),
2861
+ MODULES.map((m) => React.createElement(Section, { key: m.id, title: m.title }, React.createElement(ModuleContent, { id: m.id }))),
2862
+ // 全局恢复默认(v0.9.16):预设板块下面、不属于任何板块、居中;三级确认(文本依次:确认全局恢复默认 → 确认吗?你的全部自定义设置都会消失),取消按钮换行到下面
2863
+ React.createElement('div', { style: { marginTop: '14px', borderTop: '1px solid var(--dsw-alias-border-l1)', paddingTop: '12px' } },
2864
+ React.createElement(TripleConfirmButton, {
2865
+ label: '全局恢复默认', confirmLabel1: '确认全局恢复默认', confirmLabel2: '确认吗?你的全部自定义设置都会消失',
2866
+ onConfirm: resetAllSettings, resetKey: 'reset-all-settings',
2867
+ }),
2868
+ ),
2869
+ React.createElement('div', { style: { marginTop: '12px', fontSize: '11px', color: 'var(--dsw-alias-label-caption)', fontVariantNumeric: 'tabular-nums' } },
2870
+ '最近保存: ' + (lastSavedAt ? new Date(lastSavedAt).toLocaleTimeString() : '—'),
2871
+ ),
2872
+ )
2873
+ }
2874
+ // ════════ 14_composer.js ════════
2875
+ // ┌─ 片段 14_composer ──────────────────────────────
2876
+ // │ 职责:输入区子版块(ComposerEditor:统计条完全展开 + 统计条子项开关 + 命令子版块)
2877
+ // │ + 输入区板块直属行 ComposerHeightRow(输入框高度固定,底色下、子版块外)
2878
+ // │ + 统计条渲染组件 ComposerStatsLine(复刻官方 StatsLine,9 项独立开关)
2879
+ // │ 定义:ComposerEditor / ComposerHeightRow / ComposerStatsLine;挂载:composer children(BackgroundModule),ComposerStatsLine 注册到 conversation.composer.dock
2880
+ // │ 测试:高度固定开关锁 N 行;完全展开去省略号;子项开关控制统计条各段显隐;命令背景两项设置/恢复
2881
+ // ── 统计条纯函数(复刻官方 StatsLine,见 dsh-client-ui-conversation 源码)──
2882
+ function statsFormatTokens(n) {
2883
+ const scaled = (v) => v >= 100 ? String(Math.round(v)) : String(Math.round(v * 10) / 10)
2884
+ if (n < 1e3) return String(n)
2885
+ if (n < 1e6) return scaled(n / 1e3) + 'K'
2886
+ return scaled(n / 1e6) + 'M'
2887
+ }
2888
+ function statsFormatDuration(ms) {
2889
+ const s = ms / 1e3
2890
+ if (s < 60) return Math.round(s * 10) / 10 + 's'
2891
+ const whole = Math.round(s)
2892
+ return Math.floor(whole / 60) + 'm' + (whole % 60) + 's'
2893
+ }
2894
+ function statsFormatTps(tps) {
2895
+ const c = Math.max(0, tps)
2896
+ return c >= 10 ? String(Math.round(c)) : String(Math.round(c * 10) / 10)
2897
+ }
2898
+ function statsUsageOutputTokens(usage) {
2899
+ if (typeof usage !== 'object' || usage === null) return null
2900
+ const v = usage.outputTokens
2901
+ return typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : null
2902
+ }
2903
+ function statsAssistantReading(node) {
2904
+ const timing = node.timing
2905
+ return {
2906
+ ttftMs: timing !== void 0 && timing.stepStartTime !== null && timing.firstTokenTime !== null ? Math.max(0, timing.firstTokenTime - timing.stepStartTime) : null,
2907
+ decodeMs: timing !== void 0 && timing.firstTokenTime !== null ? Math.max(0, timing.completedTime - timing.firstTokenTime) : null,
2908
+ outputTokens: statsUsageOutputTokens(node.usage)
2909
+ }
2910
+ }
2911
+ function statsDerive(nodes) {
2912
+ const turns = new Set()
2913
+ let steps = 0, llmMs = 0, toolMs = 0, ttftMs = 0, ttftSteps = 0, decodeMs = 0, decodeTokens = 0
2914
+ for (const node of nodes) {
2915
+ if (node.kind === 'tool-result') {
2916
+ if (node.callTime !== null) toolMs += Math.max(0, node.time - node.callTime)
2917
+ continue
2918
+ }
2919
+ if (node.kind !== 'assistant') continue
2920
+ turns.add(node.turn)
2921
+ steps += 1
2922
+ if (node.timing !== void 0 && node.timing.stepStartTime !== null) llmMs += Math.max(0, node.timing.completedTime - node.timing.stepStartTime)
2923
+ const r = statsAssistantReading(node)
2924
+ if (r.ttftMs !== null) { ttftMs += r.ttftMs; ttftSteps += 1 }
2925
+ if (r.decodeMs !== null && r.outputTokens !== null) { decodeMs += r.decodeMs; decodeTokens += r.outputTokens }
2926
+ }
2927
+ return { turns: turns.size, steps, llmMs, toolMs, ttftMs, ttftSteps, decodeMs, decodeTokens }
2928
+ }
2929
+ function statsBilledInput(usage) {
2930
+ return usage.uncachedInputTokens + usage.cacheReadTokens + usage.cacheWriteTokens
2931
+ }
2932
+ // 统计条渲染组件:注册到 conversation.composer.dock,slot 注入 useProjection/useSession。
2933
+ // 复刻官方 StatsLine,但 9 项独立按 composerStatsItems 开关渲染(官方把 LLM+工具调用、首token+tok/s 各合并一组,无法 CSS 拆分,故整体接管)。
2934
+ function ComposerStatsLine({ useSession, useProjection }) {
2935
+ // 只订阅 stats 通道(统计条开关):颜色/透明度/背景拖动(main)不重渲染统计条
2936
+ const { composerStatsExpanded, composerStatsItems } = useStore(['stats'])
2937
+ // 统计条始终由我们渲染(官方隐藏,v0.9.19 调整):9 项子开关在不展开时也生效;
2938
+ // 不展开 = 官方外观(block/居中/单行省略 + hover 完整内容提示,等效官方 Tooltip);展开 = 完整展开居中
2939
+ const settledNodes = useSession ? useSession((s) => s.chat.legacy.nodes) : null
2940
+ const usage = useProjection ? useProjection('tokenUsage') : void 0
2941
+ const projected = useProjection ? useProjection('sessionStats') : void 0
2942
+ const stats = projected || statsDerive(settledNodes || [])
2943
+ const items = []
2944
+ if (stats.steps > 0) {
2945
+ if (composerStatsItems.turns) items.push(stats.turns + ' 轮')
2946
+ if (composerStatsItems.steps) items.push(stats.steps + ' 步')
2947
+ if (composerStatsItems.llm && stats.llmMs > 0) items.push('LLM ' + statsFormatDuration(stats.llmMs))
2948
+ if (composerStatsItems.tool && stats.toolMs > 0) items.push('工具调用 ' + statsFormatDuration(stats.toolMs))
2949
+ if (composerStatsItems.ttft && stats.ttftSteps > 0) items.push('首 token 平均 ' + statsFormatDuration(stats.ttftMs / stats.ttftSteps))
2950
+ if (composerStatsItems.tps && stats.decodeMs > 0) items.push(statsFormatTps(stats.decodeTokens / (stats.decodeMs / 1e3)) + ' tok/s')
2951
+ }
2952
+ if (usage !== void 0 && (statsBilledInput(usage) > 0 || usage.outputTokens > 0)) {
2953
+ if (composerStatsItems.cache) {
2954
+ const denom = statsBilledInput(usage)
2955
+ if (denom > 0) items.push('缓存命中 ' + Math.round(usage.cacheReadTokens / denom * 100) + '%')
2956
+ }
2957
+ if (composerStatsItems.input && statsBilledInput(usage) > 0) items.push('输入 ' + statsFormatTokens(statsBilledInput(usage)) + ' tok')
2958
+ if (composerStatsItems.output && usage.outputTokens > 0) items.push('输出 ' + statsFormatTokens(usage.outputTokens) + ' tok')
2959
+ }
2960
+ if (items.length === 0) return null
2961
+ const rootStyle = { textAlign: 'center', maxWidth: 'var(--dsh-chat-content-width)', boxSizing: 'border-box', width: '100%', padding: '4px calc(var(--dsh-composer-side-clearance) + 16px) 0px', color: 'var(--dsw-alias-label-tertiary)', whiteSpace: 'nowrap', textOverflow: 'ellipsis', margin: '0 auto', fontSize: '12px', lineHeight: '20px', display: 'block', overflow: 'hidden' }
2962
+ if (composerStatsExpanded) {
2963
+ rootStyle.whiteSpace = 'nowrap'
2964
+ rootStyle.overflow = 'visible'
2965
+ rootStyle.textOverflow = 'clip'
2966
+ rootStyle.display = 'flex'
2967
+ rootStyle.justifyContent = 'center'
2968
+ rootStyle.alignItems = 'baseline'
2969
+ }
2970
+ const sepStyle = { color: 'var(--dsw-alias-separator-primary)', margin: '0 10px', flex: composerStatsExpanded ? '0 0 auto' : null }
2971
+ const itemStyle = composerStatsExpanded ? { flex: '0 0 auto' } : null
2972
+ // 不展开(单行省略)时 hover 显示完整内容(等效官方 Tooltip:label = 完整 line);展开时内容全显示无需提示
2973
+ const lineText = items.join(' | ')
2974
+ return React.createElement('div', { 'data-thmcz-stats': true, title: composerStatsExpanded ? undefined : lineText, style: rootStyle },
2975
+ items.map((it, i) => React.createElement(React.Fragment, { key: i },
2976
+ i > 0 ? React.createElement('span', { 'aria-hidden': true, style: sepStyle }, '|') : null,
2977
+ React.createElement('span', { style: itemStyle }, it),
2978
+ )),
2979
+ )
2980
+ }
2981
+
2982
+ // 输入区子版块:作为「输入区」区块的一部分(渲染在输入区卡片内部末尾),
2983
+ // 顶部细分隔线与输入区背景内容分开。后续输入区新功能继续加在这里。
2984
+ // 子版块:统计条(完全展开 + 9 项开关)/ 命令(「+」按钮背景 + 命令菜单背景 + 全部恢复官方默认);
2985
+ // 输入框高度为「输入区」板块直属行(ComposerHeightRow,渲染在底色下方、子版块外,见 BackgroundModule)
2986
+ // memo 化(性能优化 2026-08-21):自身订阅 store(composer 系列字段),store 变化时自行重渲染;memo 只防父级重渲染时无谓重复
2987
+ const ComposerEditor = React.memo(function ComposerEditor() {
2988
+ // main(固定高度/行数)+ stats(统计条开关)+ convBgs(命令按钮/菜单背景)
2989
+ const { composerStatsExpanded, composerStatsItems, convBgs } = useStore(['main', 'stats'])
2990
+ const itemDefs = [
2991
+ { key: 'turns', label: '轮数' },
2992
+ { key: 'steps', label: '步数' },
2993
+ { key: 'llm', label: 'LLM 时长' },
2994
+ { key: 'tool', label: '工具调用时长' },
2995
+ { key: 'ttft', label: '首 token 平均' },
2996
+ { key: 'tps', label: 'tok/s' },
2997
+ { key: 'cache', label: '缓存命中' },
2998
+ { key: 'input', label: '输入 token' },
2999
+ { key: 'output', label: '输出 token' },
3000
+ ]
3001
+ const toggleItem = (key) => setComposerStatsItems({ ...composerStatsItems, [key]: !composerStatsItems[key] })
3002
+ // 命令子板块:任一改动过才显示「全部恢复官方默认」(仿对话区 resetAllConvBgs,仅命令两项;含透明度非 0)
3003
+ const cmdCustom = !!convBgs.addBtn || !!convBgs.cmdMenu || (convBgs.addBtnOpacity > 0) || (convBgs.cmdMenuOpacity > 0)
3004
+ return React.createElement('div', { style: { marginTop: '8px', paddingTop: '10px', borderTop: '1px solid var(--dsw-alias-border-l1)' } },
3005
+ // ── 统计条 ──
3006
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '10px', flexWrap: 'wrap', marginBottom: '4px' } },
3007
+ React.createElement('span', { style: { fontSize: '13px', fontWeight: 600, minWidth: '80px' } }, '统计条'),
3008
+ // 开关仅勾选框触发(v0.9.20)
3009
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '8px', fontSize: '13px' } },
3010
+ React.createElement('input', { type: 'checkbox', style: { cursor: 'pointer' }, checked: !!composerStatsExpanded, onChange: (e) => setComposerStatsExpanded(e.target.checked) }),
3011
+ React.createElement('span', null, '完全展开'),
3012
+ ),
3013
+ ),
3014
+ React.createElement('div', { style: { display: 'flex', flexWrap: 'wrap', gap: '6px 14px', marginBottom: '4px' } },
3015
+ itemDefs.map((d) => React.createElement('div', { key: d.key, style: { display: 'flex', alignItems: 'center', gap: '5px', fontSize: '12px' } },
3016
+ React.createElement('input', { type: 'checkbox', style: { cursor: 'pointer' }, checked: !!composerStatsItems[d.key], onChange: () => toggleItem(d.key) }),
3017
+ React.createElement('span', null, d.label),
3018
+ )),
3019
+ ),
3020
+ // ── 命令(「+」按钮背景 / 命令菜单背景 + 透明度 + 全部恢复官方默认)──
3021
+ React.createElement('div', { style: { marginTop: '8px', paddingTop: '10px', borderTop: '1px solid var(--dsw-alias-border-l1)' } },
3022
+ React.createElement('div', { style: { fontSize: '13px', fontWeight: 600, marginBottom: '6px' } }, '命令'),
3023
+ ConvBgRow({ label: '命令按钮', value: convBgs.addBtn, onSet: (v) => setConvBg('addBtn', v), opacity: convBgs.addBtnOpacity == null ? 0 : convBgs.addBtnOpacity, onOpacity: (v) => setConvBg('addBtnOpacity', v), onReset: () => { setConvBg('addBtn', null); setConvBg('addBtnOpacity', 0) } }),
3024
+ ConvBgRow({ label: '命令菜单', value: convBgs.cmdMenu, onSet: (v) => setConvBg('cmdMenu', v), opacity: convBgs.cmdMenuOpacity == null ? 0 : convBgs.cmdMenuOpacity, onOpacity: (v) => setConvBg('cmdMenuOpacity', v), onReset: () => { setConvBg('cmdMenu', null); setConvBg('cmdMenuOpacity', 0) } }),
3025
+ cmdCustom
3026
+ ? React.createElement('div', { style: { marginTop: '4px' } },
3027
+ React.createElement(ConfirmButton, {
3028
+ label: '全部恢复官方默认', confirmLabel: '全部确认恢复官方默认',
3029
+ onConfirm: resetCmdBgs, resetKey: cmdCustom, size: 'md',
3030
+ }),
3031
+ )
3032
+ : null,
3033
+ ),
3034
+ )
3035
+ })
3036
+
3037
+ // 「输入区」板块直属行:输入框高度(固定高度开关 + 行数下拉),渲染在底色下方、子版块(统计条/命令)之外。
3038
+ // 拆分自 ComposerEditor(2026-08-22 v0.9.11 用户定:输入框高度不属于子版块)。
3039
+ // memo 化 + 订阅 main:固定高度/行数变化自行重渲染,父级重渲染不重复
3040
+ const ComposerHeightRow = React.memo(function ComposerHeightRow() {
3041
+ const { composerFixedHeight, composerRows } = useStore(['main'])
3042
+ const rows = Math.max(1, Math.min(10, Math.round(composerRows || 4)))
3043
+ return React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '10px', flexWrap: 'wrap', marginTop: '6px' } },
3044
+ React.createElement('span', { style: { fontSize: '13px', fontWeight: 600, minWidth: '80px' } }, '输入框高度'),
3045
+ // 开关仅勾选框触发(v0.9.20)
3046
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '8px', fontSize: '13px' } },
3047
+ React.createElement('input', { type: 'checkbox', style: { cursor: 'pointer' }, checked: !!composerFixedHeight, onChange: (e) => setComposerFixedHeight(e.target.checked) }),
3048
+ React.createElement('span', null, '固定高度'),
3049
+ ),
3050
+ composerFixedHeight
3051
+ ? React.createElement('select', { value: String(rows), onChange: (e) => setComposerRows(parseInt(e.target.value, 10)), style: { padding: '4px 8px', borderRadius: '6px', border: '1px solid var(--dsw-alias-border-l2)', background: 'var(--dsw-alias-bg-layer-1)', fontSize: '12px' } },
3052
+ Array.from({ length: 10 }, (_, i) => React.createElement('option', { key: i + 1, value: String(i + 1) }, (i + 1) + ' 行')),
3053
+ )
3054
+ : null,
3055
+ )
3056
+ })
3057
+
3058
+ // ════════ 15_borders.js ════════
3059
+ // ┌─ 片段 15_borders ──────────────────────────────
3060
+ // │ 职责:「框线」板块(v0.9.18):所有 UI 默认边框/分隔线颜色 + 透明度,5 区域独立
3061
+ // │ 定义:BordersModule;挂载:ModuleContent(13_boot,id='borders',MODULES 在字体颜色上面)
3062
+ // │ 测试:5 区域各自调色/透明度互不影响;只调透明度也可恢复;全部恢复官方默认
3063
+ // 框线板块(「字体颜色」上面,用户定):主界面(含侧边栏)/ Cordis 插件界面 / 输入区 / 设置界面 / 浮窗面板。
3064
+ // 每行 = ConvBgRow(颜色 + 状态 + 恢复官方默认 + 透明度滑条,始终可用——官方默认色也可调透明度,CSS 用 color-mix 淡化官方色)。
3065
+ // 透明度数值大=透明(语义同全局)。底部「全部恢复官方默认」:任一区域改动过才显示(仿对话区 resetAllConvBgs)
3066
+ function BordersModule() {
3067
+ const { borders } = useStore(['main'])
3068
+ const defs = [
3069
+ { id: 'main', label: '主界面' },
3070
+ { id: 'cordis', label: 'Cordis 插件界面' },
3071
+ { id: 'composer', label: '输入区' },
3072
+ { id: 'details', label: '设置界面' },
3073
+ { id: 'float', label: '浮窗面板' },
3074
+ ]
3075
+ const hasCustom = defs.some((d) => { const b = borders[d.id]; return b && (b.color || (b.opacity != null && b.opacity > 0)) })
3076
+ // 色块默认显示(未设置时)用 00_constants 统一常量(与 buildBordersCss 同源):
3077
+ // 主界面/Cordis/输入区 = 淡灰、设置界面/浮窗面板 = 黑色
3078
+ return React.createElement('div', null,
3079
+ React.createElement('div', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-secondary)', marginBottom: '8px' } },
3080
+ '所有界面默认边框与分隔线颜色 + 透明度(数值大=越透明),各区域独立调整'),
3081
+ defs.map((d) => {
3082
+ const b = borders[d.id] || {}
3083
+ return React.createElement(ConvBgRow, { key: d.id, label: d.label, value: b.color, onSet: (v) => setBorder(d.id, { color: v }), opacity: b.opacity == null ? 0 : b.opacity, onOpacity: (v) => setBorder(d.id, { opacity: v }), onReset: () => setBorder(d.id, { color: null, opacity: 0 }), defaultSwatch: (d.id === 'details' || d.id === 'float') ? BORDER_DEFAULT_DARK : BORDER_DEFAULT_LIGHT, defaultText: d.id === 'float' ? '默认' : undefined })
3084
+ }),
3085
+ hasCustom
3086
+ ? React.createElement('div', { style: { marginTop: '4px' } },
3087
+ React.createElement(ConfirmButton, {
3088
+ label: '全部恢复官方默认', confirmLabel: '全部确认恢复官方默认',
3089
+ onConfirm: resetAllBorders, resetKey: hasCustom, size: 'md',
3090
+ }),
3091
+ )
3092
+ : null,
3093
+ )
3094
+ }
3095
+
3096
+ },
3097
+ }
3098
+ exports.apply = plugin.apply
3099
+ exports.inject = ["slots", "theme"]
3100
+ return module.exports
3101
+ },
3102
+ })