dsh-session-flow 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/client.js +842 -183
- package/lib/host.js +63 -5
- package/package.json +4 -1
package/lib/client.js
CHANGED
|
@@ -13,7 +13,7 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
|
|
|
13
13
|
var module = { exports: {} }; var exports = module.exports;
|
|
14
14
|
|
|
15
15
|
const React = require('react')
|
|
16
|
-
const { useState, useEffect, useMemo, useRef } = React
|
|
16
|
+
const { useState, useEffect, useMemo, useRef, useSyncExternalStore } = React
|
|
17
17
|
const h = React.createElement
|
|
18
18
|
const { createRoot } = require('react-dom/client')
|
|
19
19
|
|
|
@@ -40,6 +40,8 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
|
|
|
40
40
|
pianoJumpFail: '该轮次尚未加载:请在会话区向上滚动加载历史后再点击',
|
|
41
41
|
fullText: '全文检索', fullTextSearching: '搜索中…', fullTextPartial: '已扫描 {N}/{M} 个会话(结果未完整)', fullTextNone: '全文无命中', fullTextFail: '全文检索失败',
|
|
42
42
|
dock: '并入右栏', dockExit: '退出并入', dockHint: '会话流已并入右侧栏 · 随当前会话切换', dockEmpty: '打开会话后可在此查看会话流',
|
|
43
|
+
dockLiveHint: '实时:只显示开播后的对话,不加载历史回合', dockLiveEmpty: '实时中 · 等待新对话…', dockLiveEnterFail: '当前会话已结束或不可实时',
|
|
44
|
+
dockPinNotice: '📌 直播已钉住原会话(当前已切换会话)', dockPinRebind: '转播当前会话',
|
|
43
45
|
rename: '重命名', renamePlaceholder: '输入新标题…', renameFail: '重命名失败', origTitle: '原名',
|
|
44
46
|
search: '搜索标题/工具/文件… 支持 tool: pwsh · file: src · err:', allWorkspaces: '全部工作区',
|
|
45
47
|
sortRecent: '最近运行', sortNewest: '最近创建', sortOldest: '最早创建', sortTools: '工具最多', sortLongest: '耗时最长',
|
|
@@ -76,12 +78,26 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
|
|
|
76
78
|
healthSilentMin: '静默 {N} 分钟', healthSilentSec: '静默 {N} 秒',
|
|
77
79
|
healthChipTitle: '会话健康状态(会话流)· 点击打开详情',
|
|
78
80
|
renameClearBlocked: '该会话标题由官方会话管理:请输入新标题(不支持清空)',
|
|
81
|
+
settingsTitle: '会话流', settingsDesc: '调整轮次悬浮条与实时跟踪的行为参数。保存后立即生效(轮次悬浮条行数在重进会话页后完全生效)。',
|
|
82
|
+
setGroupPiano: '轮次悬浮条', setGroupLive: '实时跟踪', setGroupHealth: '健康监控',
|
|
83
|
+
setPianoWindow: '可见行数', setPianoWindowHint: '悬浮条同时显示的轮次条数(6–18)',
|
|
84
|
+
setPianoWheelSpeed: '滚轮灵敏度', setPianoWheelSpeedHint: '每像素滚动的行数(0.005–0.03),越大滚得越快',
|
|
85
|
+
setPianoSnapMs: '静止吸附延迟(ms)', setPianoSnapMsHint: '滚轮停止多少毫秒后吸附到整行(100–400)',
|
|
86
|
+
setLivePollMs: '轮询间隔(ms)', setLivePollMsHint: '实时模式的刷新间隔(1500–10000),下次开播生效',
|
|
87
|
+
setLiveFollowPx: '吸底阈值(px)', setLiveFollowPxHint: '距底部多少像素内视为「在底部」并跟随滚动(20–120)',
|
|
88
|
+
setLiveHistoryTurns: '保留历史回合', setLiveHistoryTurnsHint: '实时模式下保留的历史回合数(0–10),下次开播生效',
|
|
89
|
+
setStallThresholdMin: '卡死阈值(分钟)', setStallThresholdMinHint: '运行中但静默超过该时长判定为疑似卡死(1–10)',
|
|
90
|
+
setSave: '保存', setDiscard: '放弃更改', setSaveFail: '部分设置未保存成功,请检查后重试',
|
|
91
|
+
setReset: '恢复默认', setOverridden: '已修改', setInvalid: '超出范围或格式无效',
|
|
92
|
+
setUnavailable: '当前环境未暴露设置服务,无法编辑(请升级 dsh 后重试)',
|
|
79
93
|
} : {
|
|
80
94
|
entry: 'Session Flow', title: 'Session Flow', rescan: 'Rescan', scanning: 'Scanning…',
|
|
81
95
|
pianoTitle: 'Turns', pianoNew: 'New session', pianoFav: 'Favorite', pianoUnfav: 'Unfavorite', pianoFavs: 'Favorites', pianoOpenInFlow: 'Open in Session Flow',
|
|
82
96
|
pianoJumpFail: 'Turn not loaded — scroll up in the conversation to load history, then retry',
|
|
83
97
|
fullText: 'Full-text', fullTextSearching: 'Searching…', fullTextPartial: 'Scanned {N}/{M} sessions (partial)', fullTextNone: 'No full-text matches', fullTextFail: 'Full-text search failed',
|
|
84
98
|
dock: 'Dock right', dockExit: 'Undock', dockHint: 'Session Flow docked · follows current session', dockEmpty: 'Open a session to view its flow here',
|
|
99
|
+
dockLiveHint: 'Live: only the conversation since enabled, no history', dockLiveEmpty: 'Live · waiting for new turns…', dockLiveEnterFail: 'Session ended or not live-capable',
|
|
100
|
+
dockPinNotice: '📌 Live pinned to previous session (session switched)', dockPinRebind: 'Follow current session',
|
|
85
101
|
rename: 'Rename', renamePlaceholder: 'New title…', renameFail: 'Rename failed', origTitle: 'Original',
|
|
86
102
|
search: 'Search title / tools / files… use tool: · file: · err:', allWorkspaces: 'All workspaces',
|
|
87
103
|
sortRecent: 'Recently run', sortNewest: 'Recently created', sortOldest: 'Oldest first', sortTools: 'Most tools', sortLongest: 'Longest',
|
|
@@ -119,8 +135,214 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
|
|
|
119
135
|
healthSilentMin: 'silent {N} min', healthSilentSec: 'silent {N} s',
|
|
120
136
|
healthChipTitle: 'Session health (Session Flow) · click to open detail',
|
|
121
137
|
renameClearBlocked: 'Title is managed by the official session: enter a new title (clearing unsupported)',
|
|
138
|
+
settingsTitle: 'Session Flow', settingsDesc: 'Tune the piano-keys strip and live-tracking behavior. Applies on save (strip row count fully applies after re-entering a session page).',
|
|
139
|
+
setGroupPiano: 'Turn strip', setGroupLive: 'Live tracking', setGroupHealth: 'Health monitor',
|
|
140
|
+
setPianoWindow: 'Visible rows', setPianoWindowHint: 'How many turn keys the strip shows at once (6–18)',
|
|
141
|
+
setPianoWheelSpeed: 'Wheel speed', setPianoWheelSpeedHint: 'Rows scrolled per pixel (0.005–0.03); larger scrolls faster',
|
|
142
|
+
setPianoSnapMs: 'Snap delay (ms)', setPianoSnapMsHint: 'Delay after the wheel stops before snapping to a whole row (100–400)',
|
|
143
|
+
setLivePollMs: 'Poll interval (ms)', setLivePollMsHint: 'Live refresh interval (1500–10000); applies to the next live session',
|
|
144
|
+
setLiveFollowPx: 'Follow threshold (px)', setLiveFollowPxHint: 'Distance from the bottom that still counts as "at bottom" and follows (20–120)',
|
|
145
|
+
setLiveHistoryTurns: 'History turns kept', setLiveHistoryTurnsHint: 'History turns kept in live mode (0–10); applies to the next live session',
|
|
146
|
+
setStallThresholdMin: 'Stall threshold (min)', setStallThresholdMinHint: 'Running but silent beyond this counts as likely stalled (1–10)',
|
|
147
|
+
setSave: 'Save', setDiscard: 'Discard', setSaveFail: 'Some settings were not saved — check and retry',
|
|
148
|
+
setReset: 'Reset', setOverridden: 'Modified', setInvalid: 'Out of range or invalid format',
|
|
149
|
+
setUnavailable: 'The settings service is not exposed in this environment (upgrade dsh and retry)',
|
|
122
150
|
}
|
|
123
151
|
|
|
152
|
+
// ── 插件设置(settings.section 独立 tab)──────────────────────────
|
|
153
|
+
// Host 注册 settings 命名空间 'session-flow'(lib/host.js SETTINGS_DEFAULTS 同源);
|
|
154
|
+
// client 经 settingsScope 读写(pet 同款:优先 webUiSettings 桥,回退 ctx.settingsScope)。
|
|
155
|
+
// 全部消费点读 sfSettings.current.xxx,缺省 = 原硬编码值——无设置时行为零变化。
|
|
156
|
+
const SETTINGS_DEFAULTS = { pianoWindow: 12, pianoWheelSpeed: 0.012, pianoSnapMs: 170, livePollMs: 3000, liveFollowPx: 40, liveHistoryTurns: 3, stallThresholdMin: 3 }
|
|
157
|
+
const sfSettings = { current: { ...SETTINGS_DEFAULTS } }
|
|
158
|
+
const SETTING_GROUPS = [
|
|
159
|
+
{ titleKey: 'setGroupPiano', fields: ['pianoWindow', 'pianoWheelSpeed', 'pianoSnapMs'] },
|
|
160
|
+
{ titleKey: 'setGroupLive', fields: ['livePollMs', 'liveFollowPx', 'liveHistoryTurns'] },
|
|
161
|
+
{ titleKey: 'setGroupHealth', fields: ['stallThresholdMin'] },
|
|
162
|
+
]
|
|
163
|
+
const SETTING_FIELD_SPECS = {
|
|
164
|
+
pianoWindow: { min: 6, max: 18, integer: true, labelKey: 'setPianoWindow', hintKey: 'setPianoWindowHint' },
|
|
165
|
+
pianoWheelSpeed: { min: 0.005, max: 0.03, integer: false, labelKey: 'setPianoWheelSpeed', hintKey: 'setPianoWheelSpeedHint' },
|
|
166
|
+
pianoSnapMs: { min: 100, max: 400, integer: true, labelKey: 'setPianoSnapMs', hintKey: 'setPianoSnapMsHint' },
|
|
167
|
+
livePollMs: { min: 1500, max: 10000, integer: true, labelKey: 'setLivePollMs', hintKey: 'setLivePollMsHint' },
|
|
168
|
+
liveFollowPx: { min: 20, max: 120, integer: true, labelKey: 'setLiveFollowPx', hintKey: 'setLiveFollowPxHint' },
|
|
169
|
+
liveHistoryTurns: { min: 0, max: 10, integer: true, labelKey: 'setLiveHistoryTurns', hintKey: 'setLiveHistoryTurnsHint' },
|
|
170
|
+
stallThresholdMin: { min: 1, max: 10, integer: true, labelKey: 'setStallThresholdMin', hintKey: 'setStallThresholdMinHint' },
|
|
171
|
+
}
|
|
172
|
+
function formatSettingValue(v) { return v === undefined || v === null ? '' : String(v) }
|
|
173
|
+
function parseSettingValue(spec, text) {
|
|
174
|
+
const n = Number(String(text).trim())
|
|
175
|
+
if (!Number.isFinite(n) || n < spec.min || n > spec.max) return undefined
|
|
176
|
+
if (spec.integer && !Number.isInteger(n)) return undefined
|
|
177
|
+
return n
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// 设置表单控制器:草稿(staged edits)+ scope 读写(pet CardForm 简化版——
|
|
181
|
+
// 同样是「保存才落盘」语义;getSnapshot 缓存快照供 useSyncExternalStore)。
|
|
182
|
+
class SfSettingsController {
|
|
183
|
+
constructor(scope) {
|
|
184
|
+
this.scope = scope
|
|
185
|
+
this.listeners = new Set()
|
|
186
|
+
this.draft = new Map() // key -> { text, clear }
|
|
187
|
+
this.saving = false
|
|
188
|
+
this.failed = false
|
|
189
|
+
this.cached = null
|
|
190
|
+
this.unsubscribe = scope.subscribe(() => this.notify())
|
|
191
|
+
this.notify()
|
|
192
|
+
}
|
|
193
|
+
dispose() {
|
|
194
|
+
try { if (this.unsubscribe) this.unsubscribe() } catch (e) {}
|
|
195
|
+
this.unsubscribe = null
|
|
196
|
+
this.listeners.clear()
|
|
197
|
+
}
|
|
198
|
+
subscribe(fn) { this.listeners.add(fn); return () => this.listeners.delete(fn) }
|
|
199
|
+
// 快照值合并进模块级桥(所有功能代码的消费点;settings 变化即时下发)。
|
|
200
|
+
applySnapshotToBridge() {
|
|
201
|
+
try {
|
|
202
|
+
const snap = this.scope.getSnapshot()
|
|
203
|
+
if (snap && snap.status === 'ready' && snap.value && typeof snap.value === 'object') {
|
|
204
|
+
sfSettings.current = { ...SETTINGS_DEFAULTS, ...snap.value }
|
|
205
|
+
}
|
|
206
|
+
} catch (e) {}
|
|
207
|
+
}
|
|
208
|
+
notify() {
|
|
209
|
+
this.applySnapshotToBridge()
|
|
210
|
+
this.cached = this.buildSnapshot()
|
|
211
|
+
for (const fn of this.listeners) fn()
|
|
212
|
+
}
|
|
213
|
+
getSnapshot() {
|
|
214
|
+
if (this.cached === null) this.cached = this.buildSnapshot()
|
|
215
|
+
return this.cached
|
|
216
|
+
}
|
|
217
|
+
buildSnapshot() {
|
|
218
|
+
let snap = null
|
|
219
|
+
try { snap = this.scope.getSnapshot() } catch (e) {}
|
|
220
|
+
const status = snap ? String(snap.status || 'unavailable') : 'unavailable'
|
|
221
|
+
const value = { ...SETTINGS_DEFAULTS, ...(snap && snap.value ? snap.value : {}) }
|
|
222
|
+
const user = (snap && snap.user && typeof snap.user === 'object') ? snap.user : {}
|
|
223
|
+
const fields = {}
|
|
224
|
+
for (const key of Object.keys(SETTING_FIELD_SPECS)) {
|
|
225
|
+
const spec = SETTING_FIELD_SPECS[key]
|
|
226
|
+
const staged = this.draft.get(key)
|
|
227
|
+
const stored = Object.prototype.hasOwnProperty.call(user, key)
|
|
228
|
+
if (staged === undefined) {
|
|
229
|
+
fields[key] = { text: formatSettingValue(value[key]), overridden: stored, invalid: false, clear: false }
|
|
230
|
+
} else {
|
|
231
|
+
fields[key] = { text: staged.text, overridden: !staged.clear, invalid: !staged.clear && parseSettingValue(spec, staged.text) === undefined, clear: staged.clear }
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return {
|
|
235
|
+
status,
|
|
236
|
+
writable: snap ? snap.writable !== false : false,
|
|
237
|
+
fields,
|
|
238
|
+
dirty: this.draft.size > 0,
|
|
239
|
+
invalid: Object.keys(fields).some((k) => fields[k].invalid),
|
|
240
|
+
saving: this.saving,
|
|
241
|
+
failed: this.failed,
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
edit(key, text) { this.draft.set(key, { text, clear: false }); this.failed = false; this.notify() }
|
|
245
|
+
resetField(key) {
|
|
246
|
+
// 恢复默认 = 清除 user 层覆盖(草稿显示 base 值,保存时 unset)。
|
|
247
|
+
let baseText = formatSettingValue(SETTINGS_DEFAULTS[key])
|
|
248
|
+
try {
|
|
249
|
+
const snap = this.scope.getSnapshot()
|
|
250
|
+
if (snap && snap.base && snap.base[key] !== undefined) baseText = formatSettingValue(snap.base[key])
|
|
251
|
+
} catch (e) {}
|
|
252
|
+
this.draft.set(key, { text: baseText, clear: true })
|
|
253
|
+
this.failed = false
|
|
254
|
+
this.notify()
|
|
255
|
+
}
|
|
256
|
+
discard() { this.draft.clear(); this.failed = false; this.notify() }
|
|
257
|
+
async save() {
|
|
258
|
+
const snap = this.getSnapshot()
|
|
259
|
+
if (this.draft.size === 0 || this.saving || snap.invalid) return
|
|
260
|
+
const pending = new Map(this.draft)
|
|
261
|
+
this.saving = true
|
|
262
|
+
this.failed = false
|
|
263
|
+
this.notify()
|
|
264
|
+
const landed = new Set()
|
|
265
|
+
for (const [key, staged] of pending) {
|
|
266
|
+
try {
|
|
267
|
+
if (staged.clear) { await this.scope.unset(key); landed.add(key); continue }
|
|
268
|
+
const v = parseSettingValue(SETTING_FIELD_SPECS[key], staged.text)
|
|
269
|
+
if (v === undefined) continue
|
|
270
|
+
await this.scope.set(key, v)
|
|
271
|
+
landed.add(key)
|
|
272
|
+
} catch (e) {}
|
|
273
|
+
}
|
|
274
|
+
for (const [key, before] of pending) if (landed.has(key) && this.draft.get(key) === before) this.draft.delete(key)
|
|
275
|
+
this.saving = false
|
|
276
|
+
this.failed = landed.size !== pending.size
|
|
277
|
+
this.notify()
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// 设置弹窗「会话流」分节:分组表单(左标签+说明,右数字输入),底部保存/放弃。
|
|
282
|
+
function SessionFlowSettingsSection(props) {
|
|
283
|
+
const controller = props.controller
|
|
284
|
+
const state = useSyncExternalStore((fn) => controller.subscribe(fn), () => controller.getSnapshot())
|
|
285
|
+
if (state.status !== 'ready') {
|
|
286
|
+
return h('div', { className: 'sfset-root' }, h('p', { className: 'sfset-unavailable' }, STR.setUnavailable))
|
|
287
|
+
}
|
|
288
|
+
const disabled = !state.writable || state.saving
|
|
289
|
+
const rows = []
|
|
290
|
+
for (const group of SETTING_GROUPS) {
|
|
291
|
+
rows.push(h('div', { key: group.titleKey, className: 'sfset-groupTitle' }, STR[group.titleKey]))
|
|
292
|
+
for (const key of group.fields) {
|
|
293
|
+
const spec = SETTING_FIELD_SPECS[key]
|
|
294
|
+
const field = state.fields[key]
|
|
295
|
+
rows.push(h('div', { key, className: 'sfset-row' + (field.invalid ? ' sfset-invalid' : '') },
|
|
296
|
+
h('div', { className: 'sfset-meta' },
|
|
297
|
+
h('div', { className: 'sfset-label' }, STR[spec.labelKey],
|
|
298
|
+
field.overridden ? h('span', { className: 'sfset-overridden' }, STR.setOverridden) : null),
|
|
299
|
+
h('div', { className: 'sfset-hint' }, STR[spec.hintKey])),
|
|
300
|
+
h('div', { className: 'sfset-controls' },
|
|
301
|
+
h('input', {
|
|
302
|
+
className: 'sfset-input', type: 'number',
|
|
303
|
+
min: spec.min, max: spec.max, step: spec.integer ? 1 : 0.001,
|
|
304
|
+
value: field.text, disabled,
|
|
305
|
+
onChange: (e) => controller.edit(key, e.target.value),
|
|
306
|
+
}),
|
|
307
|
+
h('button', { type: 'button', className: 'sfset-reset', disabled, onClick: () => controller.resetField(key) }, STR.setReset))))
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return h('div', { className: 'sfset-root' },
|
|
311
|
+
h('p', { className: 'sfset-desc' }, STR.settingsDesc),
|
|
312
|
+
rows,
|
|
313
|
+
h('div', { className: 'sfset-footer' },
|
|
314
|
+
h('button', { type: 'button', className: 'sfset-save', disabled: disabled || !state.dirty || state.invalid, onClick: () => { controller.save() } }, STR.setSave),
|
|
315
|
+
h('button', { type: 'button', className: 'sfset-discard', disabled: disabled || !state.dirty, onClick: () => controller.discard() }, STR.setDiscard),
|
|
316
|
+
state.invalid ? h('span', { className: 'sfset-error' }, STR.setInvalid) : null,
|
|
317
|
+
state.failed ? h('span', { className: 'sfset-error' }, STR.setSaveFail) : null))
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// 取色对齐宠物设置卡(同一弹窗内经真机明暗/皮肤验证):纯 dsw-alias 变量、
|
|
321
|
+
// 不写 hex 兜底——变量缺失时继承弹窗上下文色,硬编码兜底在皮肤主题下反而会撞色。
|
|
322
|
+
const SETTINGS_STYLE = [
|
|
323
|
+
`.sfset-root{padding:4px 2px;display:flex;flex-direction:column;max-width:560px}`,
|
|
324
|
+
`.sfset-desc{margin:0 0 10px;font-size:13px;line-height:1.5;color:var(--dsw-alias-label-tertiary)}`,
|
|
325
|
+
`.sfset-groupTitle{margin:14px 0 4px;font-size:13px;font-weight:600;line-height:1.5;color:var(--dsw-alias-label-primary);border-bottom:1px solid var(--dsw-alias-border-l2);padding-bottom:4px}`,
|
|
326
|
+
`.sfset-row{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:10px 0}`,
|
|
327
|
+
`.sfset-meta{min-width:0;flex:1}`,
|
|
328
|
+
`.sfset-label{font-size:13px;font-weight:500;line-height:1.5;color:var(--dsw-alias-label-primary);display:flex;align-items:center;gap:8px}`,
|
|
329
|
+
`.sfset-overridden{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}`,
|
|
330
|
+
`.sfset-hint{font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary);margin-top:2px}`,
|
|
331
|
+
`.sfset-controls{flex:none;display:flex;align-items:center;gap:8px}`,
|
|
332
|
+
`.sfset-input{width:110px;height:34px;box-sizing:border-box;padding:0 12px;font:inherit;font-size:13px;line-height:1.5;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary)}`,
|
|
333
|
+
`.sfset-row.sfset-invalid .sfset-input{border-color:var(--dsw-alias-label-error)}`,
|
|
334
|
+
`.sfset-reset{font:inherit;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;padding:0}`,
|
|
335
|
+
`.sfset-reset:hover:not(:disabled){color:var(--dsw-alias-label-primary)}`,
|
|
336
|
+
`.sfset-footer{display:flex;align-items:center;gap:8px;margin-top:16px;border-top:1px solid var(--dsw-alias-border-l2);padding-top:12px}`,
|
|
337
|
+
`.sfset-save{appearance:none;font:inherit;font-size:13px;line-height:1.5;cursor:pointer;border:1px solid #0000;border-radius:8px;padding:5px 14px;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)}`,
|
|
338
|
+
`.sfset-save:disabled{opacity:.4;cursor:default}`,
|
|
339
|
+
`.sfset-discard{appearance:none;font:inherit;font-size:13px;line-height:1.5;cursor:pointer;border-radius:8px;padding:5px 14px;border:1px solid var(--dsw-alias-border-l2);background:0 0;color:var(--dsw-alias-label-secondary)}`,
|
|
340
|
+
`.sfset-discard:hover:not(:disabled){color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-dimmed)}`,
|
|
341
|
+
`.sfset-discard:disabled{opacity:.4;cursor:default}`,
|
|
342
|
+
`.sfset-error{font-size:12px;line-height:1.5;color:var(--dsw-alias-label-error)}`,
|
|
343
|
+
`.sfset-unavailable{font-size:13px;line-height:1.5;color:var(--dsw-alias-state-warn-primary)}`,
|
|
344
|
+
].join('\n')
|
|
345
|
+
|
|
124
346
|
// ── 右侧导航图标(组件级共享,定义在顶层避免作用域问题)───────────
|
|
125
347
|
// feather 风格线条,currentColor 自适应明暗主题、与文字同色。
|
|
126
348
|
const NAV_ICON_USER = `<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>`
|
|
@@ -148,7 +370,13 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
|
|
|
148
370
|
`[data-dsh-frame][data-sidebar-collapsed] .sf-entryLabel{display:none}`,
|
|
149
371
|
// box-sizing:border-box 必须:height:100% + padding 在 content-box 下会溢出容器,
|
|
150
372
|
// 产生多余的整页滚动条,导致 top 栏随滚动消失(用户实测反馈)。
|
|
151
|
-
|
|
373
|
+
// T2 修复:flex:1 1 0(flex-basis 用绝对 0 长度,非 flex:1 的 0% 百分比)。
|
|
374
|
+
// 根因(诊断数据 d47b82bf 实测):官方 viewArea 是 min-height:auto(聊天视图靠它
|
|
375
|
+
// 撑开内容 + 粘性输入框,不可全局强改);flex:1 的 0% 基准在父级高度不定时被
|
|
376
|
+
// Chrome 按 content 处理 → .sf-view 假设尺寸=内容高 → viewArea 内容下限被顶高 →
|
|
377
|
+
// 溢出 scrollBody → 整块滚动。基准用绝对 0 → 假设尺寸=0 → viewArea 下限=0 →
|
|
378
|
+
// 回到 flex 分配空间,两列各自内部滚动。工作台模式父容器非 flex,flex 无效无害。
|
|
379
|
+
`.sf-view{box-sizing:border-box;background:var(--dsw-alias-bg-base,#fff);min-width:0;height:100%;flex:1 1 0;min-height:0;color:var(--dsw-alias-label-primary,#222);font-family:var(--dsw-font-family,inherit);flex-direction:column;gap:10px;padding:14px 16px 16px;display:flex;overflow:hidden}`,
|
|
152
380
|
// top 栏悬浮固定:flex:none 在布局上已固定;再加 sticky + 背景双保险。
|
|
153
381
|
`.sf-viewHeader{flex:none;position:sticky;top:0;z-index:1;background:var(--dsw-alias-bg-base,#fff);align-items:center;gap:10px;display:flex;flex-wrap:wrap}`,
|
|
154
382
|
`.sf-viewTitle{color:var(--dsw-alias-label-primary,#222);white-space:nowrap;margin:0;font-size:16px;font-weight:700}`,
|
|
@@ -338,17 +566,26 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
|
|
|
338
566
|
`.sf-fulltextCount{flex:none;border-radius:999px;background:rgba(90,140,255,.15);color:#4a7dff;font-size:10px;line-height:15px;min-width:18px;text-align:center;padding:0 5px;font-weight:700}`,
|
|
339
567
|
`.sf-fulltext .sf-muted{font-size:11px;color:var(--dsw-alias-label-secondary,#666)}`,
|
|
340
568
|
// M12:详情并入右侧栏(details 槽位轻量视图)。
|
|
569
|
+
// 头部布局:标题+按钮**聚左**,spacer 吃掉右侧余量——彻底规避右上角
|
|
570
|
+
// dsh-better-sidebar toggleCluster(视口级常驻浮钮,压过官方 details 列右缘,
|
|
571
|
+
// 实测「退出并入」被压)。按钮不再进右上角浮动图标区,任何第三方浮钮都压不到。
|
|
341
572
|
`.dk-root{box-sizing:border-box;height:100%;min-height:0;display:flex;flex-direction:column;gap:8px;padding:10px 12px;overflow:hidden;color:var(--dsw-alias-label-primary,#222);font-family:var(--dsw-font-family,inherit)}`,
|
|
342
|
-
`.dk-head{flex:none;display:flex;align-items:center;gap:8px;min-width:0}`,
|
|
343
|
-
`.dk-title{flex:
|
|
344
|
-
`.dk-
|
|
573
|
+
`.dk-head{flex:none;display:flex;align-items:center;flex-wrap:wrap;gap:8px;min-width:0}`,
|
|
574
|
+
`.dk-title{flex:none;min-width:0;font-size:13px;font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:40%}`,
|
|
575
|
+
`.dk-headSpacer{flex:1 1 auto;min-width:0;align-self:stretch;pointer-events:none}`,
|
|
576
|
+
`.dk-btn{flex:none;border:1px solid var(--dsw-alias-border-l2,#ddd);background:var(--dsw-alias-bg-layer-1,#f5f5f5);color:var(--dsw-alias-label-primary,#222);border-radius:7px;padding:3px 9px;font-size:11px;cursor:pointer;white-space:nowrap}`,
|
|
345
577
|
`.dk-btn:hover{border-color:var(--dsw-alias-brand-primary,#4a7dff);color:var(--dsw-alias-brand-primary,#4a7dff)}`,
|
|
578
|
+
// T6:dock 实时开关激活态(绿色语义,与详情页 sf-liveBar 同色系)。
|
|
579
|
+
`.dk-btn.dk-btnLive{border-color:rgba(28,158,90,.5);background:rgba(28,158,90,.08);color:#1c9e5a;font-weight:600}`,
|
|
346
580
|
`.dk-body{flex:1;min-height:0;overflow:auto;display:flex;flex-direction:column;gap:6px}`,
|
|
347
581
|
`.dk-summary{border:1px solid var(--dsw-alias-border-l2,#e4e6eb);border-radius:10px;padding:8px 10px;background:var(--dsw-alias-bg-layer-1,#f8f9fa);flex:none;display:flex;flex-direction:column;gap:4px}`,
|
|
348
582
|
`.dk-summaryRow{display:flex;gap:6px;font-size:11.5px;line-height:1.5;color:var(--dsw-alias-label-secondary,#666);min-width:0}`,
|
|
349
583
|
`.dk-summaryTag{flex:none;border-radius:5px;padding:0 5px;font-size:10px;line-height:16px;font-weight:700;background:rgba(90,140,255,.12);color:#4a7dff}`,
|
|
350
584
|
`.dk-summaryText{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}`,
|
|
351
585
|
`.dk-summaryFull{font-size:12px;line-height:1.6;color:var(--dsw-alias-label-primary,#222);white-space:pre-wrap;word-break:break-word;max-height:140px;overflow:auto}`,
|
|
586
|
+
// T8-B 钉住提示条:琥珀色警示(直播会话 ≠ 当前会话)。
|
|
587
|
+
`.dk-pinBar{flex:none;display:flex;align-items:center;gap:8px;padding:5px 8px;border:1px solid rgba(224,150,63,.45);background:rgba(224,150,63,.08);border-radius:8px}`,
|
|
588
|
+
`.dk-pinText{flex:1;min-width:0;font-size:12px;color:#c07a2a}`,
|
|
352
589
|
// M12:并入期间禁用 layout grid 列宽过渡——官方会话切换会 closeDetails(宽 360→0),
|
|
353
590
|
// 守护重开(0→360)若带过渡动画会产生「关→开」闪烁;禁用后切换直接刷新内容(实测踩坑)。
|
|
354
591
|
`html[data-dsh-dock-active] [class*=frame],html[data-dsh-dock-active] [data-side=details]{transition:none!important}`,
|
|
@@ -393,35 +630,73 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
|
|
|
393
630
|
// 垂直居中悬浮于会话内容区中间(top 由组件算区中心 + translateY(-50%))。
|
|
394
631
|
// 动画体系:一切组件的产生/消失都有「来源与目标」——display 硬切全部改为
|
|
395
632
|
// transform/opacity/max-height 过渡(实测踩坑:display 切换无动画、视觉突兀)。
|
|
396
|
-
`[data-dsh-piano-keys]{position:fixed;left:0;top:50%;width:32px;max-height:60vh;z-index:15;display:flex;flex-direction:column;font-family:var(--dsw-font-family,inherit);transition:width .26s cubic-bezier(.3,1.15,.4,1)
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
|
|
633
|
+
`[data-dsh-piano-keys]{position:fixed;left:0;top:50%;width:32px;max-height:60vh;z-index:15;display:flex;flex-direction:column;padding:0 0 8px;font-family:var(--dsw-font-family,inherit);transition:width .26s cubic-bezier(.3,1.15,.4,1)}`,
|
|
634
|
+
// 收拢时序(T4):宽度与条目高度(.pk-key .22s)**同时起跑**——用户要求纵向横向
|
|
635
|
+
// 同时收拢(此前宽度延迟 .22s 造成「先纵后横」两段式);详情信息框仍先行立即
|
|
636
|
+
// 滑出(.pk-detail.hidden 无延迟),不参与纵向节奏。进入(hover/focus)同态:
|
|
637
|
+
// 无延迟即时展开,收拢=展开的倒放。
|
|
638
|
+
// 收拢锁(collapse-lock):pointerleave 后 300ms 内抑制 :hover 展开(focus-within 不受影响)。
|
|
639
|
+
// 日志实锤的几何自反馈回路:展开态越界 → 整条收拢(几何瞬变)→ 收拢后的胶囊条
|
|
640
|
+
// 「弹到」静止鼠标下方 → 合成 mouseenter → :hover 重新展开 → 再越界……每 300-500ms
|
|
641
|
+
// 一个来回(「横条反复出现消失」)。锁窗内 :hover 不展开,回路断开;真实驻留 300ms
|
|
642
|
+
// 后正常展开(顺带构成驻留去抖:快速掠过不再闪开)。
|
|
643
|
+
`[data-dsh-piano-keys]:not([data-collapse-lock]):hover,[data-dsh-piano-keys]:focus-within{width:200px;transition-delay:0s}`,
|
|
400
644
|
`html[${ACTIVE_ATTR}] [data-dsh-piano-keys]{display:none!important}`,
|
|
401
|
-
|
|
402
|
-
|
|
645
|
+
// T5:strip 高度 JS 显式管理(setStripHeight:可见行数 × 实测行距 + 上下内边距
|
|
646
|
+
// 16 − 间隙 5;收拢/展开键高动画期间 RO 逐帧跟随)——clipper 是 absolute 脱流,
|
|
647
|
+
// strip 不能再靠内容撑高(实测塌缩整条消失)。**必须 flex:1 1 auto**(basis:auto
|
|
648
|
+
// 时 height 属性才是弹性基准;flex:1 = basis 0% 会架空 JS height——实测踩坑)。
|
|
649
|
+
// shrink 保留配合 60vh 硬上限压缩。裁剪面=pk-clip(absolute inset:0 = strip
|
|
650
|
+
// 【内容盒】):CSS overflow 裁剪在内边距盒——上下缓冲键各露 3px 进内边距带
|
|
651
|
+
// (「上面会话的下边框露出一点点」实测踩坑);clipper 把可见区收到恰好 12 行。
|
|
652
|
+
// 垂直呼吸全部移出 strip:标题行下边距 8 + 根容器下内边距 8(均静止件);
|
|
653
|
+
// strip 零 padding——绝对定位 clipper 的 inset:0 解析的是【padding box】而非
|
|
654
|
+
// 内容盒,padding 留在 strip 上不参与裁剪、键却从 y=0 起排画进 padding 带
|
|
655
|
+
// (顶键贴死标题行、底部框线被裁——实测踩坑)。strip 高度=纯内容 rows×pitch−5。
|
|
656
|
+
`.pk-strip{flex:1 1 auto;min-height:0;max-height:calc(60vh - 60px);position:relative;outline:none}`,
|
|
657
|
+
`.pk-clip{position:absolute;inset:0;overflow:hidden}`,
|
|
658
|
+
// 键挂 pk-list 上;transform 由 JS 逐帧驱动(rAF 追逐 scrollPos + 实测行距,
|
|
659
|
+
// renderStart 离散跳变时同步重设——CSS transition 会把内容重排的跳变也做成
|
|
660
|
+
// 动画,每滚一行顿一次,实测踩坑)。
|
|
661
|
+
// 垂直内边距在 strip(静止件)不在 list(移动件):padding 挂移动件会随
|
|
662
|
+
// transform 平移,键从 y=8 起排 → 缓冲键底边 +3px 露出裁剪线(实测踩坑)。
|
|
663
|
+
`.pk-list{display:flex;flex-direction:column;gap:5px;padding:0 4px;will-change:transform}`,
|
|
403
664
|
// 收拢态:短横条(胶囊线);overflow:hidden 使 label 文字被高度裁剪(来源=胶囊);
|
|
404
665
|
// ::before 延伸命中区(±4px,点击/悬浮无死区)。
|
|
405
|
-
|
|
666
|
+
// T4 二阶段:box-sizing:border-box + 确定高度(4px↔30px)——原展开态 height:auto
|
|
667
|
+
// 不可插值(日志实锤:全程无 height 过渡事件),高度在两态间瞬移 120↔408px,
|
|
668
|
+
// 收拢条边缘 hover 进出即整条几何反复跳变(用户实测「闪烁抽搐」)。
|
|
669
|
+
`.pk-key{position:relative;flex:none;box-sizing:border-box;height:4px;min-height:4px;border-radius:99px;border:none;cursor:pointer;padding:0;margin:0 4px;background:var(--dsw-alias-border-l2,#d7dae0);color:var(--dsw-alias-label-primary,#222);overflow:hidden;transition:height .22s ease,padding .22s ease,margin .22s ease,border-radius .22s ease,background .14s ease,border-color .14s ease,box-shadow .18s ease;outline:none;min-width:0}`,
|
|
406
670
|
`.pk-key::before{content:'';position:absolute;left:0;right:0;top:-4px;bottom:-4px}`,
|
|
671
|
+
// 热区填满死区(三日志轮实锤症状一):strip 上下内边距 8px 只有 ::before ±4px 覆盖,
|
|
672
|
+
// 标题行/首键之间残留 4px 死区——鼠标停进去 key[0] 高亮+详情面板熄灭,跨出又点亮,
|
|
673
|
+
// 窄带内反复横跳=「横条出现消失」。首/末键 ::before 精确补到 strip 边缘(-8px),
|
|
674
|
+
// 带内任意位置都算 hover 该键,高亮/面板稳定常驻;键间隙 5px 由相邻 ±4px 互叠已全覆盖。
|
|
675
|
+
`.pk-key:first-child::before{top:-8px}`,
|
|
676
|
+
`.pk-key:last-child::before{bottom:-8px}`,
|
|
407
677
|
`.pk-key:hover{background:var(--dsw-alias-brand-primary,#4a7dff)}`,
|
|
408
678
|
`.pk-key:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#4a7dff);outline-offset:-1px}`,
|
|
409
679
|
`.pk-keyActive{background:var(--dsw-alias-brand-primary,#4a7dff)}`,
|
|
410
680
|
`.pk-keyFocus{background:var(--dsw-alias-brand-primary,#4a7dff)}`,
|
|
411
681
|
// 展开态(悬浮/聚焦):条目变文字卡片;label 常驻(缩略态被条目高度裁剪),
|
|
412
682
|
// 展开时随高度过渡露出文字(来源=胶囊内部,目标=卡片全文)。
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
`[data-dsh-piano-keys]:hover .pk-
|
|
683
|
+
// T4 二阶段:height:auto → height:30px(label 18 + padding 10 + border 2 = 原自然
|
|
684
|
+
// 高度,最终几何不变)——确定长度可插值,与收拢态 4px 平滑互转(倒放对称)。
|
|
685
|
+
`[data-dsh-piano-keys]:not([data-collapse-lock]):hover .pk-key,[data-dsh-piano-keys]:focus-within .pk-key{display:flex;align-items:center;gap:6px;height:30px;min-height:30px;border-radius:10px;border:1px solid var(--dsw-alias-border-l2,#e4e6eb);padding:5px 8px;margin:0;background:color-mix(in srgb,var(--dsw-alias-bg-base,#fff) 80%,transparent);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);box-shadow:0 1px 3px rgba(0,0,0,.07)}`,
|
|
686
|
+
// 热区链(四日志轮定位症状一):head↔gap↔首键 必须连续同态,否则边界 1px 晃动
|
|
687
|
+
// 即反复点亮/熄灭首键高亮(横条出现消失)。gap 由首键 ::before 覆盖(上文),
|
|
688
|
+
// head 由下行选择器链到首键:hover 标题行 ≡ hover 第一个键。
|
|
689
|
+
`[data-dsh-piano-keys]:not([data-collapse-lock]):hover .pk-key:hover,[data-dsh-piano-keys]:focus-within .pk-key:hover,[data-dsh-piano-keys]:not([data-collapse-lock]):hover .pk-head:hover ~ .pk-strip .pk-key:first-child{background:color-mix(in srgb,var(--dsw-alias-bg-base,#fff) 96%,transparent);border-color:var(--dsw-alias-brand-primary,#4a7dff)}`,
|
|
690
|
+
`[data-dsh-piano-keys]:not([data-collapse-lock]):hover .pk-keyActive,[data-dsh-piano-keys]:focus-within .pk-keyActive{border-color:var(--dsw-alias-brand-primary,#4a7dff);background:color-mix(in srgb,var(--dsw-alias-brand-primary,#4a7dff) 10%,var(--dsw-alias-bg-base,#fff))}`,
|
|
416
691
|
`.pk-label{flex:1;min-width:0;font-size:12px;line-height:18px;max-height:18px;color:var(--dsw-alias-label-primary,#222);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;user-select:none;opacity:0;transition:opacity .18s ease .06s}`,
|
|
417
|
-
`[data-dsh-piano-keys]:hover .pk-label,[data-dsh-piano-keys]:focus-within .pk-label{opacity:1}`,
|
|
692
|
+
`[data-dsh-piano-keys]:not([data-collapse-lock]):hover .pk-label,[data-dsh-piano-keys]:focus-within .pk-label{opacity:1}`,
|
|
418
693
|
// 轮次序号:缩略态折叠(宽度 0 + 透明),展开态从左侧展开露出(来源=左缘)。
|
|
419
694
|
`.pk-turnNo{flex:none;width:0;min-width:0;overflow:hidden;opacity:0;text-align:left;font-size:9.5px;line-height:16px;font-weight:600;color:var(--dsw-alias-label-tertiary,#999);font-family:ui-monospace,Consolas,monospace;user-select:none;white-space:nowrap;transition:width .22s ease,opacity .16s ease .04s}`,
|
|
420
|
-
`[data-dsh-piano-keys]:hover .pk-turnNo,[data-dsh-piano-keys]:focus-within .pk-turnNo{width:28px;min-width:28px;opacity:1}`,
|
|
695
|
+
`[data-dsh-piano-keys]:not([data-collapse-lock]):hover .pk-turnNo,[data-dsh-piano-keys]:focus-within .pk-turnNo{width:28px;min-width:28px;opacity:1}`,
|
|
421
696
|
// hover 条目:在【该条目右侧】展开信息面板(.pk-detail)——absolute 相对悬浮条容器,
|
|
422
697
|
// 坐标按条目位置换算为容器内偏移(注意:容器有 transform:translateY(-50%),fixed 子元素
|
|
423
698
|
// 会被 transform 祖先劫持成相对容器定位——用视口坐标会双重偏移,实测踩坑)。
|
|
424
|
-
`[data-dsh-piano-keys]:hover .pk-key:hover,[data-dsh-piano-keys]:focus-within .pk-key.pk-keyFocus{position:relative;z-index:2;box-shadow:0 4px 16px rgba(0,0,0,.18)}`,
|
|
699
|
+
`[data-dsh-piano-keys]:not([data-collapse-lock]):hover .pk-key:hover,[data-dsh-piano-keys]:focus-within .pk-key.pk-keyFocus,[data-dsh-piano-keys]:not([data-collapse-lock]):hover .pk-head:hover ~ .pk-strip .pk-key:first-child{position:relative;z-index:2;box-shadow:0 4px 16px rgba(0,0,0,.18)}`,
|
|
425
700
|
// 展开时序:详情框延迟 .28s 再滑入——等悬浮条展开动画完成、条目就位后,
|
|
426
701
|
// 位置才准确(第一次展开时条目仍在变形,立即计算会错位,实测踩坑);
|
|
427
702
|
// 隐藏则立即(无延迟)。
|
|
@@ -436,14 +711,10 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
|
|
|
436
711
|
`.pk-status{position:absolute;left:0;right:0;top:calc(100% + 4px);padding:7px 10px;border-radius:8px;background:color-mix(in srgb,var(--dsw-alias-bg-base,#fff) 82%,transparent);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);border:1px solid var(--dsw-alias-border-l2,#e4e6eb);font-size:11px;line-height:1.5;color:var(--dsw-alias-label-secondary,#666);box-shadow:0 4px 14px rgba(0,0,0,.12);z-index:6;text-align:center;white-space:normal;word-break:break-word;opacity:1;transform:translateY(0);transition:opacity .18s ease,transform .22s ease,visibility .22s}`,
|
|
437
712
|
`.pk-status.hidden{opacity:0;transform:translateY(8px);visibility:hidden;pointer-events:none}`,
|
|
438
713
|
`.pk-status.warn{color:#d97706;border-color:rgba(217,119,6,.4)}`,
|
|
439
|
-
// 展开态显示悬浮条细滚动条(缩略态保持无滚动条)。
|
|
440
|
-
`[data-dsh-piano-keys]:hover .pk-strip,[data-dsh-piano-keys]:focus-within .pk-strip{scrollbar-width:thin;scrollbar-color:var(--dsw-alias-border-l2,#d7dae0) transparent}`,
|
|
441
|
-
`[data-dsh-piano-keys]:hover .pk-strip::-webkit-scrollbar,[data-dsh-piano-keys]:focus-within .pk-strip::-webkit-scrollbar{width:6px;display:block}`,
|
|
442
|
-
`[data-dsh-piano-keys]:hover .pk-strip::-webkit-scrollbar-thumb{background:var(--dsw-alias-border-l2,#d7dae0);border-radius:3px}`,
|
|
443
714
|
// 头部:从上方滑入(来源=悬浮条顶缘)——max-height/padding/opacity/transform 过渡,
|
|
444
715
|
// 不占缩略态布局。内容「标题 + ▲▼ 按钮组(并排右上)」。
|
|
445
|
-
`.pk-head{display:flex;flex:none;align-items:center;gap:4px;padding:0 6px;color:var(--dsw-alias-label-secondary,#666);font-size:10px;line-height:1.2;border-bottom:1px solid var(--dsw-alias-border-l2,#e5e5e5);max-height:0;overflow:hidden;opacity:0;transform:translateY(-6px);transition:max-height .24s ease,padding .24s ease,opacity .18s ease,transform .24s ease}`,
|
|
446
|
-
`[data-dsh-piano-keys]:hover .pk-head,[data-dsh-piano-keys]:focus-within .pk-head{max-height:30px;padding-top:5px;padding-bottom:5px;opacity:1;transform:translateY(0)}`,
|
|
716
|
+
`.pk-head{display:flex;flex:none;align-items:center;gap:4px;padding:0 6px;margin-bottom:8px;color:var(--dsw-alias-label-secondary,#666);font-size:10px;line-height:1.2;border-bottom:1px solid var(--dsw-alias-border-l2,#e5e5e5);max-height:0;overflow:hidden;opacity:0;transform:translateY(-6px);transition:max-height .24s ease,padding .24s ease,opacity .18s ease,transform .24s ease}`,
|
|
717
|
+
`[data-dsh-piano-keys]:not([data-collapse-lock]):hover .pk-head,[data-dsh-piano-keys]:focus-within .pk-head{max-height:30px;padding-top:5px;padding-bottom:5px;opacity:1;transform:translateY(0)}`,
|
|
447
718
|
`.pk-headLabel{flex:1;min-width:0;font-size:10.5px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}`,
|
|
448
719
|
`.pk-navGroup{flex:none;display:flex;gap:3px}`,
|
|
449
720
|
`.pk-navBtn{flex:none;width:20px;height:20px;border:1px solid var(--dsw-alias-border-l2,#ddd);background:var(--dsw-alias-bg-layer-1,#f5f5f5);color:var(--dsw-alias-label-primary,#222);border-radius:6px;cursor:pointer;font-size:9px;line-height:1;padding:0;transition:border-color .14s ease,color .14s ease}`,
|
|
@@ -792,12 +1063,17 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
|
|
|
792
1063
|
return () => { alive = false; clearInterval(iv) }
|
|
793
1064
|
}, [props.connection, state.phase])
|
|
794
1065
|
// 头部芯片直达:工作台打开意图 → 列表就绪后自动进入该会话详情(每个意图只消费一次)。
|
|
1066
|
+
// T3 修复:无论命中与否都标记消费 + 通知父级清意图——此前 Overview 卸载(进详情)后
|
|
1067
|
+
// intentConsumed ref 随之丢失,「← 返回」重挂 Overview 时旧意图被再次消费,
|
|
1068
|
+
// 表现为返回后又被拉回详情(用户实测);未命中时也消费,避免每次重挂都重试查找。
|
|
795
1069
|
const intentConsumed = useRef(null)
|
|
796
1070
|
useEffect(() => {
|
|
797
1071
|
if (!props.intentId || intentConsumed.current === props.intentId || state.phase !== 'ready') return
|
|
1072
|
+
intentConsumed.current = props.intentId
|
|
1073
|
+
if (props.onIntentConsumed) props.onIntentConsumed()
|
|
798
1074
|
const all = ((state.data && state.data.workspaces) || []).flatMap((g) => g.sessions || [])
|
|
799
1075
|
const target = all.find((x) => x.id === props.intentId)
|
|
800
|
-
if (target)
|
|
1076
|
+
if (target) props.onOpen(target)
|
|
801
1077
|
}, [props.intentId, state.phase])
|
|
802
1078
|
useEffect(() => () => {
|
|
803
1079
|
if (fullTextTimer.current) clearTimeout(fullTextTimer.current)
|
|
@@ -1732,9 +2008,9 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
|
|
|
1732
2008
|
return
|
|
1733
2009
|
}
|
|
1734
2010
|
const tl = (derived.timeline && derived.timeline.turns) || []
|
|
1735
|
-
// 实时视图只保留「最近
|
|
1736
|
-
// 不随 history 窗口大小变化——新回合出现时最旧的被挤出。
|
|
1737
|
-
const LIVE_MAX_HISTORY_TURNS =
|
|
2011
|
+
// 实时视图只保留「最近 N 个历史回合 + 当前运行回合」(若有),数量恒定,
|
|
2012
|
+
// 不随 history 窗口大小变化——新回合出现时最旧的被挤出。N 可配置(设置页)。
|
|
2013
|
+
const LIVE_MAX_HISTORY_TURNS = sfSettings.current.liveHistoryTurns
|
|
1738
2014
|
const liveRunning = derived.running === true
|
|
1739
2015
|
const keepCount = tl.length > LIVE_MAX_HISTORY_TURNS + (liveRunning ? 1 : 0)
|
|
1740
2016
|
? LIVE_MAX_HISTORY_TURNS + (liveRunning ? 1 : 0)
|
|
@@ -1769,11 +2045,26 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
|
|
|
1769
2045
|
const enterLive = () => {
|
|
1770
2046
|
setLiveOn(true)
|
|
1771
2047
|
liveSeenTurns.current.clear() // 重进实时:全新视图,首轮全部折叠
|
|
2048
|
+
tlFollowRef.current = true // T7:开播默认吸底
|
|
1772
2049
|
fetchLive.current()
|
|
1773
2050
|
if (liveTimer.current === null) {
|
|
1774
|
-
liveTimer.current = setInterval(() => { fetchLive.current() },
|
|
2051
|
+
liveTimer.current = setInterval(() => { fetchLive.current() }, sfSettings.current.livePollMs)
|
|
1775
2052
|
}
|
|
1776
2053
|
}
|
|
2054
|
+
// T7 实时吸底跟随:用户在时间线底部(距底 ≤liveFollowPx)时,每次轮询刷新后平滑滚到
|
|
2055
|
+
// 新底部;上滑读历史不跟随(onScroll 实时追踪)。阈值默认 40px(可在设置页调整)。
|
|
2056
|
+
const tlScrollRef = useRef(null)
|
|
2057
|
+
const tlFollowRef = useRef(true)
|
|
2058
|
+
const onTlScroll = () => {
|
|
2059
|
+
const el = tlScrollRef.current
|
|
2060
|
+
if (el === null) return
|
|
2061
|
+
tlFollowRef.current = el.scrollHeight - el.scrollTop - el.clientHeight <= sfSettings.current.liveFollowPx
|
|
2062
|
+
}
|
|
2063
|
+
useEffect(() => {
|
|
2064
|
+
if (!liveOn || liveState.timeline === null) return
|
|
2065
|
+
const el = tlScrollRef.current
|
|
2066
|
+
if (el !== null && tlFollowRef.current) el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' })
|
|
2067
|
+
}, [liveState.timeline, liveOn])
|
|
1777
2068
|
const exitLive = () => {
|
|
1778
2069
|
setLiveOn(false)
|
|
1779
2070
|
liveSeenTurns.current.clear()
|
|
@@ -2009,10 +2300,15 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
|
|
|
2009
2300
|
running ? h('span', { className: 'sf-badge sf-badgeRun' }, STR.running) : h('span', { className: 'sf-badge sf-badgeEnd' }, STR.ended),
|
|
2010
2301
|
session.delegationDepth > 0 && h('span', { className: 'sf-badge sf-badgeSub' }, STR.subagent),
|
|
2011
2302
|
h('button', { className: 'sf-btn', onClick: () => { setPendingSelect(null); setView('lineage') } }, STR.lineage),
|
|
2012
|
-
//
|
|
2013
|
-
|
|
2303
|
+
// T6:并入右栏(工作台详情页 + 嵌入标签页都显示)。嵌入模式点击后额外
|
|
2304
|
+
// 自动切回「对话」标签——右栏已接管会话流展示,中栏标签页继续停留会造成
|
|
2305
|
+
// 双视图冗余(用户指定交互:点击=打开 dock+跳回对话)。
|
|
2306
|
+
h('button', {
|
|
2014
2307
|
className: 'sf-btn',
|
|
2015
|
-
onClick: () => {
|
|
2308
|
+
onClick: () => {
|
|
2309
|
+
if (dockBridge.controller) dockBridge.controller.open()
|
|
2310
|
+
if (props.embedded) activateChatTab()
|
|
2311
|
+
},
|
|
2016
2312
|
title: STR.dockHint,
|
|
2017
2313
|
}, STR.dock),
|
|
2018
2314
|
// M6 实时通道:进行中会话可实时查看(sessions.history → derive → 折叠视图)。
|
|
@@ -2031,8 +2327,8 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
|
|
|
2031
2327
|
state.phase === 'error' && h('div', { className: 'sf-hint', style: { color: '#d43b3b' } }, STR.detailFailed + ': ' + String(state.error)),
|
|
2032
2328
|
state.phase === 'empty' && h('div', { className: 'sf-hint' }, STR.emptyHint),
|
|
2033
2329
|
state.phase === 'ready' && h('div', { className: 'sf-detailBody' },
|
|
2034
|
-
//
|
|
2035
|
-
h('div', { className: 'sf-timeline' },
|
|
2330
|
+
// 左侧时间线(T7:ref+onScroll 供实时吸底跟随)
|
|
2331
|
+
h('div', { className: 'sf-timeline', ref: tlScrollRef, onScroll: onTlScroll },
|
|
2036
2332
|
h('div', { className: 'sf-muted', style: { flex: 'none' }, title: data.workspaceCwd || data.workspace },
|
|
2037
2333
|
(data.workspaceLabel || data.workspace || '') + ' · ' + fmtTime(sessionInfo.createdAt) +
|
|
2038
2334
|
' · ' + STR.duration + ' ' + fmtDuration((sessionInfo.lastEventTime || 0) - (sessionInfo.createdAt || 0)) +
|
|
@@ -2111,7 +2407,7 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
|
|
|
2111
2407
|
),
|
|
2112
2408
|
h('button', { className: 'sf-btn sf-liveExitBtn', onClick: exitLive }, STR.liveExit),
|
|
2113
2409
|
),
|
|
2114
|
-
|
|
2410
|
+
liveOn && liveState.phase === 'error' && h('div', { className: 'sf-hint', style: { color: '#d43b3b' } }, liveState.error),
|
|
2115
2411
|
liveOn && liveState.phase === 'loading' && liveState.timeline === null && h('div', { className: 'sf-hint' }, STR.liveLoading),
|
|
2116
2412
|
liveOn && liveState.timeline !== null && h(TimelineTurns, {
|
|
2117
2413
|
// 保留回合头(默认折叠可点击展开):hideHead=true 时折叠回合既无内容
|
|
@@ -2384,7 +2680,7 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
|
|
|
2384
2680
|
useEffect(() => {
|
|
2385
2681
|
liveSeenTurns.current.clear()
|
|
2386
2682
|
fetchLive.current()
|
|
2387
|
-
if (timer.current === null) timer.current = setInterval(() => { fetchLive.current() },
|
|
2683
|
+
if (timer.current === null) timer.current = setInterval(() => { fetchLive.current() }, sfSettings.current.livePollMs)
|
|
2388
2684
|
return () => { if (timer.current !== null) { clearInterval(timer.current); timer.current = null } }
|
|
2389
2685
|
}, [sessionId])
|
|
2390
2686
|
|
|
@@ -2455,6 +2751,9 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
|
|
|
2455
2751
|
connection: props.connection,
|
|
2456
2752
|
liveTitles,
|
|
2457
2753
|
intentId,
|
|
2754
|
+
// T3:意图消费后即清除(SessionFlowView 不随详情↔总览切换卸载,状态稳)——
|
|
2755
|
+
// 防止「← 返回」重挂 Overview 时旧意图再次消费把用户拉回详情。
|
|
2756
|
+
onIntentConsumed: () => setIntentId(null),
|
|
2458
2757
|
})
|
|
2459
2758
|
: h(SessionFlowDetail, {
|
|
2460
2759
|
session: detail.session,
|
|
@@ -2485,6 +2784,34 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
|
|
|
2485
2784
|
: { open: false, listeners: new Set(), controller: null })
|
|
2486
2785
|
const PIANO_WINDOW = 15
|
|
2487
2786
|
|
|
2787
|
+
// T6:从嵌入标签页切回「对话」标签。官方 conversation.view 槽位环的标签栏没有
|
|
2788
|
+
// 公开激活 API(标准 props 仅 sessionId/useSession)——DOM 兜底:先按文案定位我们
|
|
2789
|
+
// 自己的标签按钮(「会话流」),再取其父容器(标签栏)的【直接子元素】逐个按文本
|
|
2790
|
+
// 精确匹配「对话 / Chat」——不限定 button:实测部分内置标签(对话/轨迹)不是
|
|
2791
|
+
// <button> 元素,按 button 过滤会漏掉它们、只命中插件 tab(点了会跳 Plan 图,
|
|
2792
|
+
// 实测踩坑)。命中后点击该元素或其内部首个按钮;未命中则不动作(宁可不动也
|
|
2793
|
+
// 不跳错标签)。React 合成监听挂在本事件上,click() 有效。
|
|
2794
|
+
function activateChatTab() {
|
|
2795
|
+
try {
|
|
2796
|
+
const pane = document.querySelector(CONVERSATION_SELECTOR)
|
|
2797
|
+
if (pane === null) return
|
|
2798
|
+
const btns = Array.prototype.slice.call(pane.querySelectorAll('button'))
|
|
2799
|
+
const ours = btns.find((b) => (b.textContent || '').trim().indexOf(STR.entry) >= 0)
|
|
2800
|
+
if (ours === undefined) return
|
|
2801
|
+
const bar = ours.parentElement
|
|
2802
|
+
if (bar === null) return
|
|
2803
|
+
const kids = Array.prototype.slice.call(bar.children)
|
|
2804
|
+
let target = null
|
|
2805
|
+
for (const k of kids) {
|
|
2806
|
+
if (k === ours || (typeof k.contains === 'function' && k.contains(ours))) continue
|
|
2807
|
+
if (/^(对话|Chat)$/.test((k.textContent || '').trim())) { target = k; break }
|
|
2808
|
+
}
|
|
2809
|
+
if (target === null) return
|
|
2810
|
+
const clickable = target.tagName === 'BUTTON' ? target : (target.querySelector('button') || target)
|
|
2811
|
+
clickable.click()
|
|
2812
|
+
} catch (e) {}
|
|
2813
|
+
}
|
|
2814
|
+
|
|
2488
2815
|
// 定位目标:会话内容区。注意必须选**滚动容器** scrollBody(data-conversation-scroll):
|
|
2489
2816
|
// viewArea 在滚动场景下 rect 是「内容全尺寸」(如 y=-25687, h=26823),用它定位会把
|
|
2490
2817
|
// 竖条甩到视口外(实测踩坑)。scrollBody 才有正确的视口内矩形。
|
|
@@ -2511,16 +2838,29 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
|
|
|
2511
2838
|
const { sessions, onOpenWorkbench } = props
|
|
2512
2839
|
const [entries, setEntries] = useState([]) // [{turn, seq, preview}] host 全量用户轮次
|
|
2513
2840
|
const [activeIdx, setActiveIdx] = useState(-1)
|
|
2514
|
-
|
|
2841
|
+
// T5 平滑滚动:连续滚动位置(浮点,单位=行)。替代原整数 winOffset 的「每格硬跳
|
|
2842
|
+
// 1 条」——滚轮增量累积(deltaMode 归一)+ 列表 transform 百分比位移(transition
|
|
2843
|
+
// 追逐),渲染窗口 = 可视 WINDOW 条 + 上下缓冲,位移按列表自身高度百分比换算,
|
|
2844
|
+
// 收拢(4px)/展开(30px)行高变化时比例自守恒,无需测量行距。
|
|
2845
|
+
const [scrollPos, setScrollPos] = useState(0)
|
|
2515
2846
|
const [kbGlobal, setKbGlobal] = useState(null)
|
|
2516
2847
|
const [detailIdx, setDetailIdx] = useState(null) // 右侧信息面板显示的条目索引
|
|
2517
2848
|
const detailTimer = useRef(null)
|
|
2518
2849
|
const rootRef = useRef(null) // 悬浮条根容器(面板定位换算基准)
|
|
2519
2850
|
const [jumpFail, setJumpFail] = useState(false) // 未加载轮次提示
|
|
2520
2851
|
const [, force] = useState(0)
|
|
2852
|
+
// detailIdx 镜像:过渡监听 effect(deps [])闭包里读取当前值,避免读到旧 state。
|
|
2853
|
+
const detailIdxRef = useRef(null)
|
|
2521
2854
|
|
|
2522
2855
|
// 定位自管理:垂直居中悬浮于会话内容区(scrollBody 矩形中心 + translateY(-50%))。
|
|
2523
2856
|
const [pos, setPos] = useState(null) // {left, top(区中心)}
|
|
2857
|
+
useEffect(() => {
|
|
2858
|
+
return () => {
|
|
2859
|
+
const lock = collapseLockRef.current
|
|
2860
|
+
if (lock.timer) clearTimeout(lock.timer)
|
|
2861
|
+
if (lock.retry) clearTimeout(lock.retry)
|
|
2862
|
+
}
|
|
2863
|
+
}, [])
|
|
2524
2864
|
useEffect(() => {
|
|
2525
2865
|
const compute = () => {
|
|
2526
2866
|
const area = pianoViewArea()
|
|
@@ -2552,7 +2892,7 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
|
|
|
2552
2892
|
useEffect(() => {
|
|
2553
2893
|
if (initTailDone.current || entries.length <= WINDOW) return
|
|
2554
2894
|
initTailDone.current = true
|
|
2555
|
-
|
|
2895
|
+
setScrollPos(entries.length - WINDOW)
|
|
2556
2896
|
}, [entries])
|
|
2557
2897
|
|
|
2558
2898
|
// 当前会话 id(仅取 current,不展示跨会话列表)。
|
|
@@ -2639,14 +2979,34 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
|
|
|
2639
2979
|
.replace(/[*_#>|~-]{1,3}/g, ' ')
|
|
2640
2980
|
.replace(/\s+/g, ' ')
|
|
2641
2981
|
.trim()
|
|
2642
|
-
//
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2982
|
+
// 行→条目映射(单调整体映射,active 定位与点击跳转共用):
|
|
2983
|
+
// 按文档序游标推进匹配用户行(「继续」类重复文本不会误配到前面条目);
|
|
2984
|
+
// 助手行未匹配时继承前一条用户行的条目(轮次归属)——视口首行为助手
|
|
2985
|
+
// 回复时 active 仍指向其所属用户轮次,不再丢失高亮(T1 修复点一)。
|
|
2986
|
+
const mapRows = () => {
|
|
2987
|
+
const rows = domRows()
|
|
2988
|
+
const idxs = new Array(rows.length).fill(-1)
|
|
2989
|
+
let cursor = 0
|
|
2990
|
+
let last = -1
|
|
2991
|
+
for (let i = 0; i < rows.length; i++) {
|
|
2992
|
+
const t = norm(textOf(rows[i]))
|
|
2993
|
+
if (t) {
|
|
2994
|
+
for (let j = cursor; j < entries.length; j++) {
|
|
2995
|
+
const core = norm(String(entries[j].preview || '').replace(/…+$/u, '')).slice(0, 50)
|
|
2996
|
+
if (core && t.includes(core.slice(0, 30))) { cursor = j + 1; last = j; break }
|
|
2997
|
+
}
|
|
2998
|
+
}
|
|
2999
|
+
idxs[i] = last
|
|
2649
3000
|
}
|
|
3001
|
+
return { rows, idxs }
|
|
3002
|
+
}
|
|
3003
|
+
// 条目 → DOM 行:从映射中取首个归属该条目的行(用户行本身;映射按文档序
|
|
3004
|
+
// 单调推进,重复文本不会命中到前面的同文条目)。
|
|
3005
|
+
const findRow = (entry) => {
|
|
3006
|
+
const target = entries.indexOf(entry)
|
|
3007
|
+
if (target === -1) return null
|
|
3008
|
+
const { rows, idxs } = mapRows()
|
|
3009
|
+
for (let i = 0; i < rows.length; i++) if (idxs[i] === target) return rows[i]
|
|
2650
3010
|
return null
|
|
2651
3011
|
}
|
|
2652
3012
|
|
|
@@ -2660,35 +3020,85 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
|
|
|
2660
3020
|
setTimeout(() => setJumpFail(false), 2600)
|
|
2661
3021
|
}
|
|
2662
3022
|
|
|
2663
|
-
// active
|
|
3023
|
+
// active:视口内最靠上的可见行 → 映射条目索引高亮(阅读位置跟随)。
|
|
3024
|
+
// 可见判定用 bottom 进入视口(含被顶部裁切一半的行)——此前 top>=0 会
|
|
3025
|
+
// 跳过被裁切行导致高亮偏晚一轮(T1 修复点二)。
|
|
2664
3026
|
useEffect(() => {
|
|
2665
3027
|
const compute = () => {
|
|
2666
3028
|
if (entries.length === 0) { setActiveIdx(-1); return }
|
|
2667
|
-
const rows =
|
|
2668
|
-
let target =
|
|
3029
|
+
const { rows, idxs } = mapRows()
|
|
3030
|
+
let target = -1
|
|
2669
3031
|
let bestTop = Number.POSITIVE_INFINITY
|
|
2670
|
-
for (
|
|
2671
|
-
const
|
|
2672
|
-
if (
|
|
3032
|
+
for (let i = 0; i < rows.length; i++) {
|
|
3033
|
+
const r = rows[i].getBoundingClientRect()
|
|
3034
|
+
if (r.bottom > 1 && r.top < bestTop) { bestTop = r.top; target = i }
|
|
2673
3035
|
}
|
|
2674
|
-
if (
|
|
2675
|
-
const
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
return core && t.includes(core.slice(0, 30))
|
|
2679
|
-
})
|
|
2680
|
-
setActiveIdx(idx)
|
|
3036
|
+
if (target === -1) { setActiveIdx(entries.length - 1); return }
|
|
3037
|
+
const idx = idxs[target]
|
|
3038
|
+
// idx<0(视口首行在第一条用户消息之前):保持现状,不清高亮不跳变。
|
|
3039
|
+
if (idx >= 0) setActiveIdx(idx)
|
|
2681
3040
|
}
|
|
2682
3041
|
compute()
|
|
2683
3042
|
const iv = setInterval(compute, 800)
|
|
2684
3043
|
return () => clearInterval(iv)
|
|
2685
3044
|
}, [entries])
|
|
2686
3045
|
|
|
2687
|
-
const WINDOW =
|
|
3046
|
+
const WINDOW = sfSettings.current.pianoWindow
|
|
2688
3047
|
const hidden = entries.length < 2
|
|
2689
|
-
|
|
2690
|
-
const
|
|
2691
|
-
const
|
|
3048
|
+
// 虚拟窗口:renderStart 起渲染 WINDOW+3 条(上方缓冲 1 + 下方缓冲 2)。
|
|
3049
|
+
const maxOff = Math.max(0, entries.length - WINDOW)
|
|
3050
|
+
const clampedPos = Math.max(0, Math.min(maxOff, scrollPos))
|
|
3051
|
+
const renderStart = Math.max(0, Math.floor(clampedPos) - 1)
|
|
3052
|
+
const renderCount = Math.min(WINDOW + 3, entries.length - renderStart)
|
|
3053
|
+
const windowed = entries.slice(renderStart, renderStart + renderCount)
|
|
3054
|
+
const winOffset = Math.round(clampedPos) // 逻辑口径(高亮/面板/▲▼基准)四舍五入
|
|
3055
|
+
const canUp = clampedPos > 0.01
|
|
3056
|
+
const canDown = clampedPos < maxOff - 0.01
|
|
3057
|
+
// T5 显示位(ref,非 state——逐帧 DOM 更新不过 React):renderStartRef 镜像供
|
|
3058
|
+
// 逐帧换算;displayPos 由 rAF 追逐 clampedPos(指数趋近 .22/帧 ≈ .16s 到位)。
|
|
3059
|
+
const displayPosRef = useRef(0)
|
|
3060
|
+
const renderStartRef = useRef(0)
|
|
3061
|
+
renderStartRef.current = renderStart
|
|
3062
|
+
const applyShift = () => {
|
|
3063
|
+
const list = listRef.current
|
|
3064
|
+
if (list === null || list.children.length < 2) return
|
|
3065
|
+
const pitch = list.children[1].offsetTop - list.children[0].offsetTop
|
|
3066
|
+
if (!(pitch > 0)) return
|
|
3067
|
+
list.style.transform = 'translate3d(0,' + (-(displayPosRef.current - renderStartRef.current) * pitch).toFixed(2) + 'px,0)'
|
|
3068
|
+
// strip 高度显式管理:clipper 脱流后 strip 不能靠内容撑高(塌缩=整条消失)。
|
|
3069
|
+
// 纯内容高 = 可见行数 × 实测行距 − 间隙(12×35−5=415 / 12×9−5=103);呼吸
|
|
3070
|
+
// 在 strip 外(head 下边距 8 + 根下内边距 8);键高动画期间 RO 逐帧跟随。
|
|
3071
|
+
const rows = Math.min(WINDOW, entries.length)
|
|
3072
|
+
const strip = stripRef.current
|
|
3073
|
+
if (strip !== null) strip.style.height = (rows * pitch - 5).toFixed(1) + 'px'
|
|
3074
|
+
}
|
|
3075
|
+
useEffect(() => {
|
|
3076
|
+
let raf = 0
|
|
3077
|
+
const step = () => {
|
|
3078
|
+
const target = Math.max(0, Math.min(Math.max(0, entries.length - WINDOW), scrollPos))
|
|
3079
|
+
const cur = displayPosRef.current
|
|
3080
|
+
if (Math.abs(target - cur) < 0.002) {
|
|
3081
|
+
displayPosRef.current = target
|
|
3082
|
+
applyShift()
|
|
3083
|
+
return
|
|
3084
|
+
}
|
|
3085
|
+
displayPosRef.current = cur + (target - cur) * 0.22
|
|
3086
|
+
applyShift()
|
|
3087
|
+
raf = requestAnimationFrame(step)
|
|
3088
|
+
}
|
|
3089
|
+
raf = requestAnimationFrame(step)
|
|
3090
|
+
return () => cancelAnimationFrame(raf)
|
|
3091
|
+
}, [scrollPos, entries.length])
|
|
3092
|
+
// renderStart 跳变(内容重排)后同步重设 transform(渲染后、绘制前),显示位
|
|
3093
|
+
// 不变 → 无视觉跳变;列表高度随键高变化(收拢/展开)时重算行距保持对齐。
|
|
3094
|
+
useEffect(() => { applyShift() })
|
|
3095
|
+
useEffect(() => {
|
|
3096
|
+
const list = listRef.current
|
|
3097
|
+
if (list === null || typeof ResizeObserver !== 'function') return undefined
|
|
3098
|
+
const ro = new ResizeObserver(() => applyShift())
|
|
3099
|
+
ro.observe(list)
|
|
3100
|
+
return () => ro.disconnect()
|
|
3101
|
+
}, [renderStart, entries.length])
|
|
2692
3102
|
|
|
2693
3103
|
// 悬浮条滚轮/▲▼:只翻悬浮条自身窗口,绝不影响会话区滚动(用户明确要求)。
|
|
2694
3104
|
const onKeyDown = (e) => {
|
|
@@ -2707,35 +3117,118 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
|
|
|
2707
3117
|
}
|
|
2708
3118
|
useEffect(() => {
|
|
2709
3119
|
if (kbGlobal === null) return
|
|
2710
|
-
|
|
2711
|
-
|
|
3120
|
+
setScrollPos((o) => {
|
|
3121
|
+
const p = Math.max(0, Math.min(maxOff, o))
|
|
3122
|
+
if (kbGlobal < p) return kbGlobal
|
|
3123
|
+
if (kbGlobal >= p + WINDOW) return Math.max(0, Math.min(maxOff, kbGlobal - WINDOW + 1))
|
|
3124
|
+
return o
|
|
3125
|
+
})
|
|
2712
3126
|
}, [kbGlobal])
|
|
2713
3127
|
|
|
2714
|
-
//
|
|
3128
|
+
// 滚轮平滑滚动:React onWheel 是被动监听器(passive: true),内部 preventDefault
|
|
2715
3129
|
// 无效并刷屏告警(实测踩坑)——改原生 addEventListener('wheel', {passive:false})。
|
|
3130
|
+
// 增量累积(deltaMode 归一为像素;~0.012 行/px:一整格滚轮 ≈1.2 行,触控板小
|
|
3131
|
+
// 增量连续累积),rAF 追逐成连续位移。**静止吸附**:滚轮停 ~170ms 后取整到最近
|
|
3132
|
+
// 整行(chase 平滑滑过去)——小数位置会让顶/底条露半截(「展示了 T23 的一部
|
|
3133
|
+
// 分」实测踩坑),用户要求静止时恰好完整 12 条。
|
|
2716
3134
|
const stripRef = useRef(null)
|
|
3135
|
+
const listRef = useRef(null) // T5 虚拟滚动列表容器(键的父级,transform 平移)
|
|
3136
|
+
const snapTimerRef = useRef(0)
|
|
2717
3137
|
useEffect(() => {
|
|
2718
3138
|
const el = stripRef.current
|
|
2719
3139
|
if (el === null) return undefined
|
|
2720
3140
|
const onNativeWheel = (e) => {
|
|
2721
3141
|
e.preventDefault()
|
|
2722
3142
|
e.stopPropagation()
|
|
2723
|
-
|
|
2724
|
-
|
|
3143
|
+
let dy = e.deltaY
|
|
3144
|
+
if (e.deltaMode === 1) dy *= 33
|
|
3145
|
+
else if (e.deltaMode === 2) dy *= 300
|
|
3146
|
+
setScrollPos((o) => Math.max(0, Math.min(Math.max(0, entries.length - WINDOW), o + dy * sfSettings.current.pianoWheelSpeed)))
|
|
3147
|
+
// 静止吸附:连续滚动不断顺延;停止后吸附到最近整行。
|
|
3148
|
+
if (snapTimerRef.current !== 0) clearTimeout(snapTimerRef.current)
|
|
3149
|
+
snapTimerRef.current = setTimeout(() => {
|
|
3150
|
+
snapTimerRef.current = 0
|
|
3151
|
+
setScrollPos((o) => Math.round(o))
|
|
3152
|
+
}, sfSettings.current.pianoSnapMs)
|
|
2725
3153
|
}
|
|
2726
3154
|
el.addEventListener('wheel', onNativeWheel, { passive: false })
|
|
2727
|
-
return () =>
|
|
3155
|
+
return () => {
|
|
3156
|
+
el.removeEventListener('wheel', onNativeWheel)
|
|
3157
|
+
if (snapTimerRef.current !== 0) { clearTimeout(snapTimerRef.current); snapTimerRef.current = 0 }
|
|
3158
|
+
}
|
|
2728
3159
|
}, [entries.length])
|
|
2729
3160
|
|
|
3161
|
+
// 面板锚定刷新:detailPos 在渲染时刻从键的实时矩形计算——mouseenter 常发生在
|
|
3162
|
+
// 收拢/半展开几何(root 宽 32),面板被锚在收拢条右缘=鼠标旁,且展开完成后无
|
|
3163
|
+
// 重渲染而停留在旧坐标。监听过渡结束(React 合成事件不含 transitionend 的完整
|
|
3164
|
+
// 语义),仅几何属性(width/height/max-height)且面板显示中时强制重渲染,用最终
|
|
3165
|
+
// 几何重算(面板可见延迟 .28s > 展开 .26s,用户看到的就是正确位置)。
|
|
3166
|
+
useEffect(() => {
|
|
3167
|
+
const el = rootRef.current
|
|
3168
|
+
if (el === null) return undefined
|
|
3169
|
+
const onEnd = (e) => {
|
|
3170
|
+
if (detailIdxRef.current !== null &&
|
|
3171
|
+
(e.propertyName === 'width' || e.propertyName === 'height' || e.propertyName === 'max-height')) {
|
|
3172
|
+
force((t) => t + 1)
|
|
3173
|
+
}
|
|
3174
|
+
}
|
|
3175
|
+
el.addEventListener('transitionend', onEnd)
|
|
3176
|
+
return () => el.removeEventListener('transitionend', onEnd)
|
|
3177
|
+
}, [])
|
|
3178
|
+
|
|
2730
3179
|
// 右侧信息面板:hover 条目/键盘聚焦时弹出该轮信息(保留弹出面板、移除原生
|
|
2731
|
-
// tooltip——tooltip
|
|
3180
|
+
// tooltip——tooltip 与面板重复且视觉凌乱);鼠标在条目间移动 250ms 宽限。
|
|
2732
3181
|
const showDetail = (idx) => {
|
|
3182
|
+
const lock = collapseLockRef.current
|
|
3183
|
+
if (Date.now() < lock.until) {
|
|
3184
|
+
// 锁检查必须先于 clearTimeout:收拢条上的合成 mouseenter(skipped)不得
|
|
3185
|
+
// 清掉待执行的隐藏定时器(实测:连续 skipped 把面板拖了 ~1s 不走)。
|
|
3186
|
+
if (lock.kind === 'enter') {
|
|
3187
|
+
// 进入护栏期(hover-guard):推迟到锁过期后重试——驻留则面板照常出现,
|
|
3188
|
+
// 快速路过则随离场作废(重试前检查 root 仍 :hover)。
|
|
3189
|
+
if (lock.retry) clearTimeout(lock.retry)
|
|
3190
|
+
lock.retry = setTimeout(() => {
|
|
3191
|
+
lock.retry = null
|
|
3192
|
+
if (rootRef.current !== null && rootRef.current.matches(':hover')) showDetail(idx)
|
|
3193
|
+
}, Math.max(20, lock.until - Date.now() + 20))
|
|
3194
|
+
return
|
|
3195
|
+
}
|
|
3196
|
+
return
|
|
3197
|
+
}
|
|
3198
|
+
if (lock.retry) { clearTimeout(lock.retry); lock.retry = null }
|
|
2733
3199
|
if (detailTimer.current) clearTimeout(detailTimer.current)
|
|
2734
3200
|
setDetailIdx(idx)
|
|
2735
3201
|
}
|
|
2736
3202
|
const hideDetail = () => {
|
|
3203
|
+
const lock = collapseLockRef.current
|
|
3204
|
+
if (lock.retry) { clearTimeout(lock.retry); lock.retry = null }
|
|
2737
3205
|
if (detailTimer.current) clearTimeout(detailTimer.current)
|
|
2738
|
-
|
|
3206
|
+
// 宽限 250ms:标题行/首键边界窄带来回移动时,面板滑出动画(.24s)频繁被
|
|
3207
|
+
// 打断重入,加长宽限减少闪动。
|
|
3208
|
+
detailTimer.current = setTimeout(() => setDetailIdx(null), 250)
|
|
3209
|
+
}
|
|
3210
|
+
const collapseLockRef = useRef({ until: 0, timer: null, retry: null, kind: '' })
|
|
3211
|
+
// hover-guard / collapse-lock 二合一:进入锁 120ms(「快速路过闪动」——锁窗内
|
|
3212
|
+
// :hover 不展开,路过即零动画;驻留 120ms 后照常展开)+离开锁 300ms(防几何
|
|
3213
|
+
// 自反馈回路:展开态越界→收拢→胶囊条弹到静止鼠标下→合成 mouseenter→再展开)。
|
|
3214
|
+
// kind 区分 showDetail 策略:enter 锁→推迟重试(驻留后面板照常),leave 锁→跳过。
|
|
3215
|
+
const armCollapseLock = (ms, kind) => {
|
|
3216
|
+
const lock = collapseLockRef.current
|
|
3217
|
+
const el = rootRef.current
|
|
3218
|
+
const w = typeof ms === 'number' ? ms : 300
|
|
3219
|
+
if (el !== null && el.getAttribute('data-collapse-lock') === null) {
|
|
3220
|
+
el.setAttribute('data-collapse-lock', '')
|
|
3221
|
+
}
|
|
3222
|
+
lock.kind = kind === 'enter' ? 'enter' : 'leave'
|
|
3223
|
+
lock.until = Date.now() + w
|
|
3224
|
+
if (lock.timer) clearTimeout(lock.timer)
|
|
3225
|
+
lock.timer = setTimeout(() => {
|
|
3226
|
+
lock.until = 0
|
|
3227
|
+
lock.kind = ''
|
|
3228
|
+
if (lock.timer) { clearTimeout(lock.timer); lock.timer = null }
|
|
3229
|
+
const el2 = rootRef.current
|
|
3230
|
+
if (el2 !== null) el2.removeAttribute('data-collapse-lock')
|
|
3231
|
+
}, w)
|
|
2739
3232
|
}
|
|
2740
3233
|
useEffect(() => () => { if (detailTimer.current) clearTimeout(detailTimer.current) }, [])
|
|
2741
3234
|
// 键盘聚焦跟随显示面板。
|
|
@@ -2743,6 +3236,9 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
|
|
|
2743
3236
|
if (kbGlobal !== null && entries[kbGlobal]) showDetail(kbGlobal)
|
|
2744
3237
|
}, [kbGlobal])
|
|
2745
3238
|
|
|
3239
|
+
// detailIdx 镜像同步(供 deps [] 的过渡监听闭包读取当前值)。
|
|
3240
|
+
useEffect(() => { detailIdxRef.current = detailIdx }, [detailIdx])
|
|
3241
|
+
|
|
2746
3242
|
const rootStyle = pos === null ? { display: 'none' } : {
|
|
2747
3243
|
left: pos.left + 'px',
|
|
2748
3244
|
top: pos.top + 'px',
|
|
@@ -2752,12 +3248,12 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
|
|
|
2752
3248
|
|
|
2753
3249
|
const detailEntry = detailIdx !== null ? entries[detailIdx] : null
|
|
2754
3250
|
// 面板位置:absolute 相对悬浮条容器(容器有 translateY(-50%) transform,fixed
|
|
2755
|
-
// 会退化为相对容器——必须按容器内偏移计算)。条目 =
|
|
2756
|
-
// 索引 = detailIdx -
|
|
3251
|
+
// 会退化为相对容器——必须按容器内偏移计算)。条目 = 列表(pk-list)直接子元素,
|
|
3252
|
+
// 索引 = detailIdx - renderStart(虚拟窗口含缓冲);越界修正按视口坐标换算回容器内。
|
|
2757
3253
|
let detailPos = null
|
|
2758
3254
|
const rootRect = rootRef.current ? rootRef.current.getBoundingClientRect() : null
|
|
2759
|
-
if (detailEntry && rootRect) {
|
|
2760
|
-
const el =
|
|
3255
|
+
if (detailEntry && rootRect && listRef.current) {
|
|
3256
|
+
const el = listRef.current.children[detailIdx - renderStart]
|
|
2761
3257
|
if (el instanceof Element) {
|
|
2762
3258
|
const r = el.getBoundingClientRect()
|
|
2763
3259
|
const topV = Math.min(r.top, Math.max(4, window.innerHeight - 320))
|
|
@@ -2765,37 +3261,68 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
|
|
|
2765
3261
|
}
|
|
2766
3262
|
}
|
|
2767
3263
|
|
|
2768
|
-
return h('div', {
|
|
2769
|
-
|
|
3264
|
+
return h('div', {
|
|
3265
|
+
'data-dsh-piano-keys': '', ref: rootRef, style: rootStyle,
|
|
3266
|
+
onPointerEnter: () => {
|
|
3267
|
+
// 进入锁(hover-guard 120ms):快速路过收拢条零动画零闪动;驻留后照常展开。
|
|
3268
|
+
armCollapseLock(120, 'enter')
|
|
3269
|
+
},
|
|
3270
|
+
onPointerLeave: () => {
|
|
3271
|
+
// 面板随收拢同步消失:root 级离开=真实收拢开始,立即隐藏(淡出 .18s 与收拢
|
|
3272
|
+
// .22-.26s 同步收完)——不走 250ms 宽限+滑出拖尾 ~0.5s。
|
|
3273
|
+
if (detailIdx !== null) {
|
|
3274
|
+
if (detailTimer.current) clearTimeout(detailTimer.current)
|
|
3275
|
+
setDetailIdx(null)
|
|
3276
|
+
}
|
|
3277
|
+
// 锁只在「当前不在锁窗内」时武装:锁窗内的离开(条本就被抑制未展开)不续期,
|
|
3278
|
+
// 否则收拢条边缘快速进出让锁自续(实测 1.4s 不展开),300ms 后照常展开。
|
|
3279
|
+
if (Date.now() >= collapseLockRef.current.until) armCollapseLock()
|
|
3280
|
+
},
|
|
3281
|
+
},
|
|
3282
|
+
h('div', {
|
|
3283
|
+
className: 'pk-head',
|
|
3284
|
+
// 热区链:hover 标题行 ≡ hover 第一个键(CSS 侧高亮已链;JS 侧面板跟随)。
|
|
3285
|
+
// head↔gap↔首键连续同态,消灭「横条出现消失」的状态边界。
|
|
3286
|
+
onMouseEnter: () => showDetail(winOffset),
|
|
3287
|
+
onMouseLeave: hideDetail,
|
|
3288
|
+
},
|
|
2770
3289
|
h('span', { className: 'pk-headLabel' }, STR.pianoTitle),
|
|
2771
3290
|
h('div', { className: 'pk-navGroup' },
|
|
2772
|
-
h('button', { className: 'pk-navBtn', disabled: !canUp, onClick: () =>
|
|
2773
|
-
h('button', { className: 'pk-navBtn', disabled: !canDown, onClick: () =>
|
|
3291
|
+
h('button', { className: 'pk-navBtn', disabled: !canUp, onClick: () => { const n = Math.max(0, winOffset - 1); setScrollPos(n); showDetail(n) }, title: STR.expanded }, '▲'),
|
|
3292
|
+
h('button', { className: 'pk-navBtn', disabled: !canDown, onClick: () => { const n = Math.min(Math.max(0, entries.length - WINDOW), winOffset + 1); setScrollPos(n); showDetail(n) }, title: STR.collapsed }, '▼'),
|
|
2774
3293
|
),
|
|
2775
3294
|
),
|
|
2776
3295
|
h('div', { className: 'pk-strip', ref: stripRef, tabIndex: 0, onKeyDown, onMouseDown: (e) => { if (e.target === e.currentTarget) e.preventDefault() }, 'aria-label': STR.pianoTitle },
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
3296
|
+
// T5 裁剪框(strip 内容盒):缓冲键全裁,可见区恰好 12 行;strip 本体保留
|
|
3297
|
+
// 内边距(视觉呼吸)+ 定位(relative)。
|
|
3298
|
+
h('div', { className: 'pk-clip' },
|
|
3299
|
+
// T5 虚拟滚动列表:transform 由 JS 逐帧驱动(applyShift),不设 style
|
|
3300
|
+
// 以免 React 重渲染覆盖 JS 值。
|
|
3301
|
+
h('div', { className: 'pk-list', ref: listRef },
|
|
3302
|
+
windowed.map((entry, idx) => {
|
|
3303
|
+
const gi = renderStart + idx
|
|
3304
|
+
const act = activeIdx === gi
|
|
3305
|
+
return h('button', {
|
|
3306
|
+
key: 'turn-' + gi + '-' + entry.seq,
|
|
3307
|
+
type: 'button',
|
|
3308
|
+
className: 'pk-key' + (act ? ' pk-keyActive' : '') + (kbGlobal === gi ? ' pk-keyFocus' : ''),
|
|
3309
|
+
// 点击后主动失焦:避免 button 焦点让 :focus-within 常驻展开
|
|
3310
|
+
// (鼠标移开后悬浮条收不拢,箭头「残留」——实测踩坑)。
|
|
3311
|
+
onClick: (e) => { open(entry); e.currentTarget.blur() },
|
|
3312
|
+
onMouseEnter: () => showDetail(gi),
|
|
3313
|
+
onMouseLeave: hideDetail,
|
|
3314
|
+
onContextMenu: (e) => {
|
|
3315
|
+
e.preventDefault()
|
|
3316
|
+
if (onOpenWorkbench) onOpenWorkbench()
|
|
3317
|
+
},
|
|
2792
3318
|
},
|
|
2793
|
-
|
|
2794
|
-
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
)
|
|
2798
|
-
|
|
3319
|
+
// 轮次序号:展开态常驻【最左侧】独立模块(与文本模块分离,互不侵占)。
|
|
3320
|
+
h('span', { className: 'pk-turnNo' }, 'T' + entry.turn),
|
|
3321
|
+
h('span', { className: 'pk-label' }, entry.preview || '(空)'),
|
|
3322
|
+
)
|
|
3323
|
+
}),
|
|
3324
|
+
),
|
|
3325
|
+
),
|
|
2799
3326
|
),
|
|
2800
3327
|
// 信息面板:跟随 hover 条目右侧展开(fixed,位置由 detailPos 控制);
|
|
2801
3328
|
// 常驻渲染 + hidden 类做滑入/滑出;内容 key 变化触发 pkSlideIn。
|
|
@@ -2816,27 +3343,171 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
|
|
|
2816
3343
|
function DetailsDockView(props) {
|
|
2817
3344
|
const { sessionId, connection, onExit } = props
|
|
2818
3345
|
const [state, setState] = useState({ phase: 'loading', error: null, data: null })
|
|
2819
|
-
|
|
3346
|
+
// T8-B:重挂载续播时折叠集合按钉住时间线播种(新回合照常并入折叠)。
|
|
3347
|
+
const [collapsed, setCollapsed] = useState(() => {
|
|
3348
|
+
const s = new Set()
|
|
3349
|
+
if (dockBridge.live && dockBridge.live.on && dockBridge.live.lastState) {
|
|
3350
|
+
for (const t of dockBridge.live.lastState.timeline || []) s.add('turn-' + t.turn)
|
|
3351
|
+
}
|
|
3352
|
+
return s
|
|
3353
|
+
})
|
|
2820
3354
|
const collapsedInit = useRef(false)
|
|
2821
3355
|
const [turnItems, setTurnItems] = useState(() => new Map())
|
|
2822
3356
|
const loadingTurns = useRef(new Set())
|
|
2823
3357
|
|
|
2824
|
-
|
|
2825
|
-
|
|
3358
|
+
// ── T6 dock 实时模式:复用实时通道(sessions.history → derive,与详情页 M6
|
|
3359
|
+
// 同构),但只显示「开播后」的对话——首轮记录基准回合集合,此后仅显示新出现
|
|
3360
|
+
// 的回合(含开播时正在运行的回合);不加载历史回合(档案 get 在实时期间跳过)。
|
|
3361
|
+
// ── T8-B 钉住原会话:dock 视图随会话切换整体重挂载(槽位 session 作用域
|
|
3362
|
+
// key),组件级实时状态必丢——实时状态提升到 dockBridge.live 模块级存储
|
|
3363
|
+
// (钉住 sid/基准/已见/定时器/最近状态跨挂载存活):开播即钉住当时的会话,
|
|
3364
|
+
// GUI 切走后右栏继续直播原会话并显示提示条(告知用户),可一键转播当前会话。
|
|
3365
|
+
const liveStore = dockBridge.live || (dockBridge.live = {
|
|
3366
|
+
on: false, sid: undefined, timer: null, base: null, seen: new Set(),
|
|
3367
|
+
lastState: null, conn: undefined, emit: null, tick: null,
|
|
3368
|
+
})
|
|
3369
|
+
const [liveOn, setLiveOn] = useState(() => liveStore.on)
|
|
3370
|
+
const [liveState, setLiveState] = useState(() => liveStore.lastState
|
|
3371
|
+
|| { phase: 'idle', error: null, timeline: [], running: false, eventCount: 0, health: null })
|
|
3372
|
+
const fetchLive = useRef(async () => {})
|
|
3373
|
+
fetchLive.current = async () => {
|
|
3374
|
+
const sid = liveStore.sid
|
|
3375
|
+
const conn = liveStore.conn
|
|
3376
|
+
if (sid === undefined || conn === undefined || conn.api === undefined || conn.api.sessions === undefined) {
|
|
3377
|
+
liveStore.lastState = { phase: 'error', error: STR.liveUnavailable, timeline: [], running: false, eventCount: 0, health: null }
|
|
3378
|
+
if (liveStore.emit !== null) liveStore.emit(liveStore.lastState)
|
|
3379
|
+
return
|
|
3380
|
+
}
|
|
3381
|
+
if (liveStore.emit !== null) liveStore.emit({ ...liveStateKeep(), phase: 'loading' })
|
|
3382
|
+
try {
|
|
3383
|
+
const resp = await conn.api.sessions.history({ sessionId: sid, mode: 'full', maxMessages: 400 })
|
|
3384
|
+
const val = resp && resp.result && resp.result.value
|
|
3385
|
+
if (val === undefined || !Array.isArray(val.events)) {
|
|
3386
|
+
liveStore.lastState = { ...liveStateKeep(), phase: 'error', error: STR.liveFail }
|
|
3387
|
+
if (liveStore.emit !== null) liveStore.emit(liveStore.lastState)
|
|
3388
|
+
return
|
|
3389
|
+
}
|
|
3390
|
+
const events = val.events.map((entry) => (entry && entry.event) || entry)
|
|
3391
|
+
const derived = await api('derive', { events, now: Date.now() })
|
|
3392
|
+
if (!derived || !derived.ok) {
|
|
3393
|
+
liveStore.lastState = { ...liveStateKeep(), phase: 'error', error: (derived && derived.error) || STR.liveFail }
|
|
3394
|
+
if (liveStore.emit !== null) liveStore.emit(liveStore.lastState)
|
|
3395
|
+
return
|
|
3396
|
+
}
|
|
3397
|
+
const tl = (derived.timeline && derived.timeline.turns) || []
|
|
3398
|
+
const running = derived.running === true
|
|
3399
|
+
if (liveStore.base === null) {
|
|
3400
|
+
// 开播基准:当时已存在的全部回合;运行中的那个除外(它是「正在发生的
|
|
3401
|
+
// 实时对话」,从开播起就要展示其后续流式变化)。
|
|
3402
|
+
liveStore.base = new Set(tl.map((t) => t.turn))
|
|
3403
|
+
if (running && tl.length > 0) liveStore.base.delete(tl[tl.length - 1].turn)
|
|
3404
|
+
}
|
|
3405
|
+
// 只保留:开播基准之后新出现的回合 + 开播时正在运行的那个回合。
|
|
3406
|
+
const display = tl.filter((t) => !liveStore.base.has(t.turn))
|
|
3407
|
+
const newTurns = display.filter((t) => !liveStore.seen.has(t.turn))
|
|
3408
|
+
for (const t of display) liveStore.seen.add(t.turn)
|
|
3409
|
+
if (newTurns.length > 0 && liveStore.emit !== null) {
|
|
3410
|
+
setCollapsed((prev) => {
|
|
3411
|
+
const next = new Set(prev)
|
|
3412
|
+
for (const t of newTurns) next.add('turn-' + t.turn)
|
|
3413
|
+
return next
|
|
3414
|
+
})
|
|
3415
|
+
}
|
|
3416
|
+
liveStore.lastState = { phase: 'ready', error: null, timeline: display, running, eventCount: events.length, health: derived.health || null }
|
|
3417
|
+
if (liveStore.emit !== null) liveStore.emit(liveStore.lastState)
|
|
3418
|
+
} catch (e) {
|
|
3419
|
+
liveStore.lastState = { ...liveStateKeep(), phase: 'error', error: STR.liveFail + ': ' + String(e && e.message || e) }
|
|
3420
|
+
if (liveStore.emit !== null) liveStore.emit(liveStore.lastState)
|
|
3421
|
+
}
|
|
3422
|
+
}
|
|
3423
|
+
// 保留当前显示数据、只换 phase 的辅助(emit 前构造)。
|
|
3424
|
+
function liveStateKeep() {
|
|
3425
|
+
return liveStore.lastState || { phase: 'idle', error: null, timeline: [], running: false, eventCount: 0, health: null }
|
|
3426
|
+
}
|
|
3427
|
+
const enterLive = () => {
|
|
3428
|
+
liveStore.on = true
|
|
3429
|
+
liveStore.sid = sessionId // T8-B:开播即钉住当前会话
|
|
3430
|
+
liveStore.seen.clear()
|
|
3431
|
+
liveStore.base = null
|
|
3432
|
+
liveStore.lastState = null
|
|
3433
|
+
setLiveOn(true)
|
|
3434
|
+
liveFollowRef.current = true // T7:开播默认吸底
|
|
3435
|
+
fetchLive.current()
|
|
3436
|
+
if (liveStore.timer === null) {
|
|
3437
|
+
liveStore.timer = setInterval(() => { if (liveStore.tick !== null) liveStore.tick() }, sfSettings.current.livePollMs)
|
|
3438
|
+
}
|
|
3439
|
+
}
|
|
3440
|
+
const exitLive = () => {
|
|
3441
|
+
liveStore.on = false
|
|
3442
|
+
liveStore.sid = undefined
|
|
3443
|
+
liveStore.seen.clear()
|
|
3444
|
+
liveStore.base = null
|
|
3445
|
+
liveStore.lastState = null
|
|
3446
|
+
if (liveStore.timer !== null) { clearInterval(liveStore.timer); liveStore.timer = null }
|
|
3447
|
+
setLiveOn(false)
|
|
3448
|
+
setLiveState({ phase: 'idle', error: null, timeline: [], running: false, eventCount: 0, health: null })
|
|
3449
|
+
// 回到档案视图:重新加载(实时期间可能已产生新回合)。
|
|
2826
3450
|
setState({ phase: 'loading', error: null, data: null })
|
|
2827
3451
|
setTurnItems(new Map())
|
|
2828
3452
|
collapsedInit.current = false
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
3453
|
+
}
|
|
3454
|
+
// T8-B:转播当前会话(提示条按钮)——重钉到当前 sessionId、基准重算。
|
|
3455
|
+
const rebindLive = () => {
|
|
3456
|
+
liveStore.sid = sessionId
|
|
3457
|
+
liveStore.seen.clear()
|
|
3458
|
+
liveStore.base = null
|
|
3459
|
+
fetchLive.current()
|
|
3460
|
+
}
|
|
3461
|
+
// 挂载注册:连接对象/状态发射器/定时器分发指向当前实例(跨重挂载续播);
|
|
3462
|
+
// 卸载时若 3s 内无新实例接管(dock 真正关闭走 onExit→exitLive),停表清理。
|
|
3463
|
+
useEffect(() => {
|
|
3464
|
+
liveStore.conn = props.connection
|
|
3465
|
+
liveStore.emit = setLiveState
|
|
3466
|
+
liveStore.tick = () => { fetchLive.current() }
|
|
3467
|
+
return () => {
|
|
3468
|
+
if (liveStore.emit === setLiveState) liveStore.emit = null
|
|
3469
|
+
if (liveStore.tick !== null) {
|
|
3470
|
+
const t = liveStore.tick
|
|
3471
|
+
setTimeout(() => { if (liveStore.tick === t && liveStore.emit === null) {
|
|
3472
|
+
if (liveStore.timer !== null) { clearInterval(liveStore.timer); liveStore.timer = null }
|
|
3473
|
+
liveStore.on = false; liveStore.tick = null
|
|
3474
|
+
} }, 500)
|
|
2836
3475
|
}
|
|
2837
|
-
}
|
|
3476
|
+
}
|
|
3477
|
+
}, [])
|
|
3478
|
+
// T7 实时吸底跟随(dock):dk-body 距底 ≤40px 时每次刷新平滑滚到新底部。
|
|
3479
|
+
const dkBodyRef = useRef(null)
|
|
3480
|
+
const liveFollowRef = useRef(true)
|
|
3481
|
+
const onDkBodyScroll = () => {
|
|
3482
|
+
const el = dkBodyRef.current
|
|
3483
|
+
if (el === null) return
|
|
3484
|
+
liveFollowRef.current = el.scrollHeight - el.scrollTop - el.clientHeight <= sfSettings.current.liveFollowPx
|
|
3485
|
+
}
|
|
3486
|
+
useEffect(() => {
|
|
3487
|
+
if (!liveOn || liveState.timeline === null || liveState.timeline.length === 0) return
|
|
3488
|
+
const el = dkBodyRef.current
|
|
3489
|
+
if (el !== null && liveFollowRef.current) el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' })
|
|
3490
|
+
}, [liveState.timeline, liveOn])
|
|
3491
|
+
|
|
3492
|
+
useEffect(() => {
|
|
3493
|
+
let alive = true
|
|
3494
|
+
// T8-B:实时钉住在 liveStore.sid(会话切换不重绑);档案加载仅非实时时进行。
|
|
3495
|
+
if (!liveOn) {
|
|
3496
|
+
setState({ phase: 'loading', error: null, data: null })
|
|
3497
|
+
setTurnItems(new Map())
|
|
3498
|
+
collapsedInit.current = false
|
|
3499
|
+
if (!sessionId) { setState({ phase: 'empty', error: null, data: null }); return }
|
|
3500
|
+
api('get', { sessionId }).then((json) => {
|
|
3501
|
+
if (!alive) return
|
|
3502
|
+
if (json && json.ok) {
|
|
3503
|
+
setState({ phase: 'ready', error: null, data: json })
|
|
3504
|
+
} else {
|
|
3505
|
+
setState({ phase: 'error', error: (json && json.error) || 'get failed', data: null })
|
|
3506
|
+
}
|
|
3507
|
+
}).catch((e) => { if (alive) setState({ phase: 'error', error: String(e), data: null }) })
|
|
3508
|
+
}
|
|
2838
3509
|
return () => { alive = false }
|
|
2839
|
-
}, [sessionId])
|
|
3510
|
+
}, [sessionId, liveOn])
|
|
2840
3511
|
|
|
2841
3512
|
useEffect(() => {
|
|
2842
3513
|
if (state.phase !== 'ready' || collapsedInit.current) return
|
|
@@ -2890,14 +3561,40 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
|
|
|
2890
3561
|
|
|
2891
3562
|
return h('div', { className: 'dk-root' },
|
|
2892
3563
|
h('div', { className: 'dk-head' },
|
|
2893
|
-
|
|
3564
|
+
// T6:标题「会话流」+ 按钮**紧跟标题**(左侧区);spacer 吃掉右侧余量——
|
|
3565
|
+
// 彻底避开右上角 dsh-better-sidebar toggleCluster(视口级常驻浮钮区)。
|
|
3566
|
+
h('span', { className: 'dk-title', title: STR.dockHint }, STR.entry),
|
|
3567
|
+
props.connection !== undefined && h('button', {
|
|
3568
|
+
className: 'dk-btn' + (liveOn ? ' dk-btnLive' : ''),
|
|
3569
|
+
onClick: liveOn ? exitLive : enterLive,
|
|
3570
|
+
title: STR.dockLiveHint,
|
|
3571
|
+
}, liveOn ? STR.liveActive + ' ✕' : STR.live),
|
|
2894
3572
|
h('button', { className: 'dk-btn', onClick: onExit }, STR.dockExit),
|
|
3573
|
+
h('span', { className: 'dk-headSpacer' }),
|
|
2895
3574
|
),
|
|
2896
|
-
h('div', { className: 'dk-body' },
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
3575
|
+
h('div', { className: 'dk-body', ref: dkBodyRef, onScroll: onDkBodyScroll },
|
|
3576
|
+
liveOn && h('div', { className: 'sf-liveBar', style: { flex: 'none' } },
|
|
3577
|
+
h('span', { className: 'sf-badge sf-badgeRun', style: { flex: 'none' } }, STR.liveActive),
|
|
3578
|
+
healthBadge(liveState.health),
|
|
3579
|
+
h('span', { className: 'sf-liveText' },
|
|
3580
|
+
h('span', { className: 'sf-muted', style: { flex: 'none' } }, (liveState.eventCount || 0) + ' ' + STR.liveEvents),
|
|
3581
|
+
liveState.phase === 'loading' && h('span', { className: 'sf-muted', style: { flex: 'none' } }, STR.liveRefreshing),
|
|
3582
|
+
),
|
|
3583
|
+
),
|
|
3584
|
+
liveOn && liveState.phase === 'error' && h('div', { className: 'sf-hint', style: { color: '#d43b3b' } }, liveState.error),
|
|
3585
|
+
liveOn && liveStore.sid !== undefined && liveStore.sid !== sessionId && h('div', { className: 'dk-pinBar', title: liveStore.sid },
|
|
3586
|
+
h('span', { className: 'dk-pinText' }, STR.dockPinNotice),
|
|
3587
|
+
h('button', { className: 'dk-btn', onClick: rebindLive }, STR.dockPinRebind),
|
|
3588
|
+
),
|
|
3589
|
+
liveOn && liveState.phase !== 'error' && liveState.timeline.length === 0 && h('div', { className: 'sf-hint' }, STR.dockLiveEmpty),
|
|
3590
|
+
liveOn && liveState.timeline.length > 0 && h(TimelineTurns, {
|
|
3591
|
+
turns: liveState.timeline, collapsed, toggle, liveActive: liveState.running === true,
|
|
3592
|
+
activeTurn: null, setActiveTurn: () => {},
|
|
3593
|
+
}),
|
|
3594
|
+
!liveOn && state.phase === 'loading' && h('div', { className: 'sf-hint' }, STR.detailLoading),
|
|
3595
|
+
!liveOn && state.phase === 'empty' && h('div', { className: 'sf-hint' }, STR.dockEmpty),
|
|
3596
|
+
!liveOn && state.phase === 'error' && h('div', { className: 'sf-hint', style: { color: '#d43b3b' } }, STR.detailFailed + ': ' + String(state.error)),
|
|
3597
|
+
!liveOn && state.phase === 'ready' && h('div', { className: 'dk-summary' },
|
|
2901
3598
|
ruleTaskCount > 0 && h('div', { className: 'dk-summaryRow' },
|
|
2902
3599
|
h('span', { className: 'dk-summaryTag' }, STR.summaryTaskCount),
|
|
2903
3600
|
h('span', { className: 'dk-summaryText' }, ruleTaskCount),
|
|
@@ -2916,8 +3613,8 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
|
|
|
2916
3613
|
),
|
|
2917
3614
|
data.summary && h('div', { className: 'dk-summaryFull' }, data.summary),
|
|
2918
3615
|
),
|
|
2919
|
-
state.phase === 'ready' && lightTurns.length === 0 && h('div', { className: 'sf-hint' }, STR.noTimeline),
|
|
2920
|
-
state.phase === 'ready' && h(TurnList, {
|
|
3616
|
+
!liveOn && state.phase === 'ready' && lightTurns.length === 0 && h('div', { className: 'sf-hint' }, STR.noTimeline),
|
|
3617
|
+
!liveOn && state.phase === 'ready' && h(TurnList, {
|
|
2921
3618
|
sessionId,
|
|
2922
3619
|
lightTurns,
|
|
2923
3620
|
collapsed,
|
|
@@ -3135,7 +3832,7 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
|
|
|
3135
3832
|
if (!document.getElementById('dsh-session-flow-style')) {
|
|
3136
3833
|
const style = document.createElement('style')
|
|
3137
3834
|
style.id = 'dsh-session-flow-style'
|
|
3138
|
-
style.textContent = STYLE
|
|
3835
|
+
style.textContent = STYLE + '\n' + SETTINGS_STYLE
|
|
3139
3836
|
document.head.appendChild(style)
|
|
3140
3837
|
}
|
|
3141
3838
|
|
|
@@ -3215,6 +3912,27 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
|
|
|
3215
3912
|
} catch (error) {
|
|
3216
3913
|
console.error('[dsh-session-flow] header health chip registration failed:', error)
|
|
3217
3914
|
}
|
|
3915
|
+
// 插件设置页:settings.section 顶级 tab(order 140,排在皮肤中心 120/宠物 130 之后)。
|
|
3916
|
+
// 读写经 settingsScope(pet 同款:优先 webUiSettings 桥,回退 settingsScope);
|
|
3917
|
+
// 两者都不可用(旧宿主/未暴露)时不注册 tab。controller 闭包持有,
|
|
3918
|
+
// 卸载经 slots.inject 返回的 disposer 释放 scope 订阅。
|
|
3919
|
+
try {
|
|
3920
|
+
const settingsBinder = ctx.get('webUiSettings') ?? ctx.get('settingsScope')
|
|
3921
|
+
if (settingsBinder !== undefined && settingsBinder !== null && typeof settingsBinder.bind === 'function') {
|
|
3922
|
+
const sfSettingsCtrl = new SfSettingsController(settingsBinder.bind({ namespace: 'session-flow' }))
|
|
3923
|
+
slots.inject('settings.section', () => {
|
|
3924
|
+
const unregister = slots.register({
|
|
3925
|
+
name: 'settings.section',
|
|
3926
|
+
id: 'session-flow',
|
|
3927
|
+
order: 140,
|
|
3928
|
+
label: () => STR.settingsTitle,
|
|
3929
|
+
}, () => h(SessionFlowSettingsSection, { controller: sfSettingsCtrl }))
|
|
3930
|
+
return () => { sfSettingsCtrl.dispose(); unregister() }
|
|
3931
|
+
})
|
|
3932
|
+
}
|
|
3933
|
+
} catch (error) {
|
|
3934
|
+
console.error('[dsh-session-flow] settings.section registration failed:', error)
|
|
3935
|
+
}
|
|
3218
3936
|
}
|
|
3219
3937
|
|
|
3220
3938
|
// M12:详情并入右侧栏 —— details 槽位动态占用(priority -1 低于官方 tool-details)。
|
|
@@ -3233,66 +3951,10 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
|
|
|
3233
3951
|
} catch (e) {}
|
|
3234
3952
|
}
|
|
3235
3953
|
|
|
3236
|
-
// M12×aionui
|
|
3237
|
-
//
|
|
3238
|
-
//
|
|
3239
|
-
//
|
|
3240
|
-
// dock 激活期间按实际轨宽重算把手位置:三方写 frame style → MutationObserver;
|
|
3241
|
-
// 纯缩放(grid 字符串不变)→ ResizeObserver。3 轨时公式退化为官方原值,无副作用。
|
|
3242
|
-
const parseGridTracksCompat = (input) => {
|
|
3243
|
-
const tracks = []
|
|
3244
|
-
let depth = 0
|
|
3245
|
-
let current = ''
|
|
3246
|
-
for (const ch of String(input)) {
|
|
3247
|
-
if (ch === '(') depth += 1
|
|
3248
|
-
if (ch === ')') depth = Math.max(0, depth - 1)
|
|
3249
|
-
if (ch === ' ' && depth === 0) {
|
|
3250
|
-
if (current !== '') { tracks.push(current); current = '' }
|
|
3251
|
-
continue
|
|
3252
|
-
}
|
|
3253
|
-
current += ch
|
|
3254
|
-
}
|
|
3255
|
-
if (current !== '') tracks.push(current)
|
|
3256
|
-
return tracks
|
|
3257
|
-
}
|
|
3258
|
-
const detailsHandleCompat = {
|
|
3259
|
-
frame: null,
|
|
3260
|
-
observer: null,
|
|
3261
|
-
sizeObserver: null,
|
|
3262
|
-
start() {
|
|
3263
|
-
if (this.observer !== null) return
|
|
3264
|
-
const frame = document.querySelector('[data-dsh-frame]') ||
|
|
3265
|
-
(document.querySelector('[class*="sidebarCol"]') || {}).parentElement || null
|
|
3266
|
-
if (frame === null) return
|
|
3267
|
-
this.frame = frame
|
|
3268
|
-
this.observer = new MutationObserver(() => this.apply())
|
|
3269
|
-
this.observer.observe(frame, { attributes: true, attributeFilter: ['style'] })
|
|
3270
|
-
this.sizeObserver = new ResizeObserver(() => this.apply())
|
|
3271
|
-
this.sizeObserver.observe(frame)
|
|
3272
|
-
this.apply()
|
|
3273
|
-
},
|
|
3274
|
-
stop() {
|
|
3275
|
-
if (this.observer !== null) { this.observer.disconnect(); this.observer = null }
|
|
3276
|
-
if (this.sizeObserver !== null) { this.sizeObserver.disconnect(); this.sizeObserver = null }
|
|
3277
|
-
this.frame = null
|
|
3278
|
-
},
|
|
3279
|
-
apply() {
|
|
3280
|
-
const frame = this.frame
|
|
3281
|
-
if (frame === null) return
|
|
3282
|
-
const tracks = parseGridTracksCompat(frame.style.gridTemplateColumns)
|
|
3283
|
-
if (tracks.length < 3) return
|
|
3284
|
-
const trackPx = (t) => {
|
|
3285
|
-
const m = /^(-?[\d.]+)px$/.exec(String(t).trim())
|
|
3286
|
-
return m === null ? 0 : Number(m[1])
|
|
3287
|
-
}
|
|
3288
|
-
const details = trackPx(tracks[2])
|
|
3289
|
-
const preview = tracks.length >= 4 ? trackPx(tracks[3]) : 0
|
|
3290
|
-
const explorer = tracks.length >= 5 ? trackPx(tracks[4]) : 0
|
|
3291
|
-
const handle = frame.querySelector('[data-side="details"]')
|
|
3292
|
-
if (handle === null) return
|
|
3293
|
-
handle.style.left = `${Math.round(frame.getBoundingClientRect().width - details - preview - explorer)}px`
|
|
3294
|
-
},
|
|
3295
|
-
}
|
|
3954
|
+
// M12×aionui:官方 details 把手在 5 轨 grid 下的位置补偿已由上游
|
|
3955
|
+
// aionui-panel ≥0.2.0 接管(dsh-web-ui PR #311,applyGrid 每帧重算;
|
|
3956
|
+
// 3 轨时退化为官方原值)。本插件过渡兼容层 detailsHandleCompat 已移除
|
|
3957
|
+
// (2026-08-18,本机 aionui 0.2.0 实证含修复;旧版 aionui 请升级)。
|
|
3296
3958
|
const ensureColumn = () => {
|
|
3297
3959
|
if (dockBridge.open && layout !== undefined && typeof layout.openDetails === 'function') {
|
|
3298
3960
|
try { layout.openDetails() } catch (e) {}
|
|
@@ -3334,7 +3996,6 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
|
|
|
3334
3996
|
if (!dockBridge.open) {
|
|
3335
3997
|
dockBridge.open = true
|
|
3336
3998
|
syncDetails()
|
|
3337
|
-
detailsHandleCompat.start()
|
|
3338
3999
|
// 会话切换时官方会自动 closeDetails —— 高频守护快速重开(无动画,内容直接刷新)。
|
|
3339
4000
|
if (detailsGuard === null) {
|
|
3340
4001
|
detailsGuard = setInterval(() => { ensureColumn() }, 150)
|
|
@@ -3346,7 +4007,6 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
|
|
|
3346
4007
|
if (dockBridge.open) {
|
|
3347
4008
|
dockBridge.open = false
|
|
3348
4009
|
syncDetails()
|
|
3349
|
-
detailsHandleCompat.stop()
|
|
3350
4010
|
if (detailsGuard !== null) { clearInterval(detailsGuard); detailsGuard = null }
|
|
3351
4011
|
for (const fn of dockBridge.listeners) fn()
|
|
3352
4012
|
}
|
|
@@ -3400,7 +4060,6 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
|
|
|
3400
4060
|
}
|
|
3401
4061
|
if (detailsGuard !== null) { clearInterval(detailsGuard); detailsGuard = null }
|
|
3402
4062
|
setDockActive(false)
|
|
3403
|
-
detailsHandleCompat.stop()
|
|
3404
4063
|
if (layout !== undefined && typeof layout.closeDetails === 'function') {
|
|
3405
4064
|
try { layout.closeDetails() } catch (e) {}
|
|
3406
4065
|
}
|