@telosmaylx/dsh-session-notify 0.1.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/LICENSE +21 -0
- package/README.md +188 -0
- package/lib/client.js +1180 -0
- package/lib/core.js +303 -0
- package/lib/index.js +291 -0
- package/package.json +66 -0
- package/scripts/build.sh +16 -0
- package/scripts/probe-card-render.mjs +100 -0
- package/scripts/probe-client-e2e.mjs +134 -0
- package/scripts/probe-client.mjs +145 -0
- package/scripts/probe-diag-settings.mjs +79 -0
- package/scripts/probe-settings-card.mjs +137 -0
- package/scripts/probe-settings-check.mjs +87 -0
- package/scripts/verify-notice.mjs +110 -0
package/lib/client.js
ADDED
|
@@ -0,0 +1,1180 @@
|
|
|
1
|
+
/* eslint-disable no-restricted-globals */
|
|
2
|
+
/**
|
|
3
|
+
* dsh-session-notify —— 浏览器端:完成推送 + 设置卡片。
|
|
4
|
+
*
|
|
5
|
+
* 与宿主插件(lib/index.js)配对:
|
|
6
|
+
* - 宿主把系统消息写进会话日志(内容随设置面板的语言/自定义模板定制);
|
|
7
|
+
* - 这里负责「推送提醒」(系统通知每次独立 tag + toast 永远展示),
|
|
8
|
+
* 并在官方「设置 → 插件」面板注册设置卡片(settings.plugin.item keyed slot、
|
|
9
|
+
* 宿主同名 settings 命名空间),让用户改语言/模板/开关。
|
|
10
|
+
*
|
|
11
|
+
* 打包格式:官方 client bundle 契约——`window.__ModuleLoader__.load({id, factory})`,
|
|
12
|
+
* factory 由浏览器侧模块表同步 require 驱动(本 bundle 仅 require('react') 用于卡片)。
|
|
13
|
+
*/
|
|
14
|
+
window.__ModuleLoader__.load({
|
|
15
|
+
id: '@telosmaylx/dsh-session-notify',
|
|
16
|
+
factory: (require) => {
|
|
17
|
+
var module = { exports: {} }
|
|
18
|
+
var exports = module.exports
|
|
19
|
+
|
|
20
|
+
/** Cordis loader 诊断用插件名(客户端侧)。 */
|
|
21
|
+
exports.name = 'session-complete-notify-client'
|
|
22
|
+
/** 等待客户端服务就绪:会话列表(推送边沿)、插槽(设置卡片)、设置作用域(读写)。 */
|
|
23
|
+
exports.inject = ['sessions', 'slots', 'settingsScope']
|
|
24
|
+
|
|
25
|
+
var PLUGIN_TAG = 'dsh-session-notify'
|
|
26
|
+
var SETTINGS_NS = 'session-complete-notify'
|
|
27
|
+
var NOTICE_PROJECTION_KEY = 'session-complete-notify' // 宿主投影单元 key(每个会话都带)
|
|
28
|
+
|
|
29
|
+
/** 语言代码与在原声展示名(所有语言面板统一用各自原声名)。 */
|
|
30
|
+
var LANG_OPTIONS = [
|
|
31
|
+
{ id: 'zh', label: '简体中文' },
|
|
32
|
+
{ id: 'zh-tw', label: '繁體中文' },
|
|
33
|
+
{ id: 'en', label: 'English' },
|
|
34
|
+
{ id: 'ja', label: '日本語' },
|
|
35
|
+
{ id: 'ko', label: '한국어' },
|
|
36
|
+
]
|
|
37
|
+
var LANG_IDS = ['zh', 'zh-tw', 'en', 'ja', 'ko']
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 设置面板 i18n:标题/预设/字段/标签/按钮/提示,5 语言。
|
|
41
|
+
* 按 cur.language 取用;切换语言即时重渲染整个面板。
|
|
42
|
+
*/
|
|
43
|
+
var I18N = {
|
|
44
|
+
zh: {
|
|
45
|
+
title: '会话完成提醒', subtitle: '完成推送的语言与文案模板(保存后刷新生效)',
|
|
46
|
+
preset: '预设', choosePreset: '选择预设…', language: '语言',
|
|
47
|
+
fields: { completed: '完成', error: '出错', aborted: '中止', blocked: '阻塞', 'max-tokens': '输出上限' },
|
|
48
|
+
titleLabel: '推送标题', titleHint: '留空则使用会话标题',
|
|
49
|
+
kindLabels: { completed: '会话已完成', error: '会话出错', aborted: '会话已中止', blocked: '会话被阻塞', 'max-tokens': '会话达到输出上限' },
|
|
50
|
+
tokens: { title: '会话标题', label: '完成标签', duration: '用时', usage: '消耗', error: '错误', cache: '缓存命中', tps: '速度 TPS' },
|
|
51
|
+
samples: { title: '示例会话', label: '会话已完成', duration: '12 秒', usage: '1,240 输入 / 3,560 输出', error: '连接超时', cache: '96.5%', tps: '92 tok/s' },
|
|
52
|
+
presets: { custom: '自定义预设', unnamed: '未命名预设', default: '默认', playful: '轻松风格 🎉', formal: '正式简洁', detail: '详细报告' },
|
|
53
|
+
placeholder: '自定义文案:点「+ 插入信息」,或直接输入文字',
|
|
54
|
+
insertInfo: '+ 插入信息', insertTitle: '在光标处插入信息', preview: '预览:',
|
|
55
|
+
skipSubagents: '跳过子代理会话',
|
|
56
|
+
discard: '放弃', save: '保存', saving: '保存中…', saved: '已保存 ✓ · 刷新页面后生效', saveFailed: '保存失败:', refresh: '点击刷新',
|
|
57
|
+
saveAs: '新增', update: '修改', remove: '删除', presetNamePlaceholder: '预设名称', confirm: '确定', cancel: '取消', customSaved: '自定义预设已保存', customUpdated: '自定义预设已更新', customDeleted: '自定义预设已删除', saveAsTitle: '将当前配置另存为自定义预设', updateTitle: '更新当前自定义预设', removeTitle: '删除当前自定义预设', fromPreset: '来自:', modifiedSuffix: ' · 已修改',
|
|
58
|
+
presetNameRequired: '请输入预设名称',
|
|
59
|
+
loading: '加载中…', unavailable: '设置不可用(宿主未注册命名空间)',
|
|
60
|
+
},
|
|
61
|
+
'zh-tw': {
|
|
62
|
+
title: '會話完成提醒', subtitle: '完成推送的語言與文案模板(儲存後重新整理生效)',
|
|
63
|
+
preset: '預設', choosePreset: '選擇預設…', language: '語言',
|
|
64
|
+
fields: { completed: '完成', error: '出錯', aborted: '中止', blocked: '阻塞', 'max-tokens': '輸出上限' },
|
|
65
|
+
titleLabel: '推送標題', titleHint: '留空則使用會話標題',
|
|
66
|
+
kindLabels: { completed: '會話已完成', error: '會話出錯', aborted: '會話已中止', blocked: '會話被阻塞', 'max-tokens': '會話達到輸出上限' },
|
|
67
|
+
tokens: { title: '會話標題', label: '完成標籤', duration: '用時', usage: '消耗', error: '錯誤', cache: '快取命中', tps: '速度 TPS' },
|
|
68
|
+
samples: { title: '範例會話', label: '會話已完成', duration: '12 秒', usage: '1,240 輸入 / 3,560 輸出', error: '連線逾時', cache: '96.5%', tps: '92 tok/s' },
|
|
69
|
+
presets: { custom: '自訂預設', unnamed: '未命名預設', default: '預設', playful: '輕鬆風格 🎉', formal: '正式簡潔', detail: '詳細報告' },
|
|
70
|
+
placeholder: '自訂文案:點「+ 插入資訊」,或直接輸入文字',
|
|
71
|
+
insertInfo: '+ 插入資訊', insertTitle: '在游標處插入資訊', preview: '預覽:',
|
|
72
|
+
skipSubagents: '跳過子代理會話',
|
|
73
|
+
discard: '放棄', save: '儲存', saving: '儲存中…', saved: '已儲存 ✓ · 重新整理頁面後生效', saveFailed: '儲存失敗:', refresh: '點擊重新整理',
|
|
74
|
+
saveAs: '新增', update: '修改', remove: '刪除', presetNamePlaceholder: '預設名稱', confirm: '確定', cancel: '取消', customSaved: '自訂預設已儲存', customUpdated: '自訂預設已更新', customDeleted: '自訂預設已刪除', saveAsTitle: '將當前設定另存為自訂預設', updateTitle: '更新目前自訂預設', removeTitle: '刪除目前自訂預設', fromPreset: '來自:', modifiedSuffix: ' · 已修改',
|
|
75
|
+
presetNameRequired: '請輸入預設名稱',
|
|
76
|
+
loading: '載入中…', unavailable: '設定不可用(宿主未註冊命名空間)',
|
|
77
|
+
},
|
|
78
|
+
en: {
|
|
79
|
+
title: 'Session completion alert', subtitle: 'Language & message templates for completion pushes (refresh after save)',
|
|
80
|
+
preset: 'Preset', choosePreset: 'Choose preset…', language: 'Language',
|
|
81
|
+
fields: { completed: 'Completed', error: 'Error', aborted: 'Aborted', blocked: 'Blocked', 'max-tokens': 'Token cap' },
|
|
82
|
+
titleLabel: 'Push title', titleHint: 'Leave empty to use the session title',
|
|
83
|
+
kindLabels: { completed: 'Session completed', error: 'Session failed', aborted: 'Session aborted', blocked: 'Session blocked', 'max-tokens': 'Session hit the output-token cap' },
|
|
84
|
+
tokens: { title: 'Session title', label: 'Label', duration: 'Duration', usage: 'Usage', error: 'Error', cache: 'Cache hit', tps: 'Speed TPS' },
|
|
85
|
+
samples: { title: 'Example session', label: 'Session completed', duration: '12s', usage: '1,240 in / 3,560 out', error: 'connection timeout', cache: '96.5%', tps: '92 tok/s' },
|
|
86
|
+
presets: { custom: 'Custom preset', unnamed: 'Untitled preset', default: 'Default', playful: 'Playful 🎉', formal: 'Formal', detail: 'Detailed report' },
|
|
87
|
+
placeholder: 'Custom text: use “+ Insert info”, or type directly',
|
|
88
|
+
insertInfo: '+ Insert info', insertTitle: 'Insert info at cursor', preview: 'Preview: ',
|
|
89
|
+
skipSubagents: 'Skip subagent sessions',
|
|
90
|
+
discard: 'Discard', save: 'Save', saving: 'Saving…', saved: 'Saved ✓ · refresh page to apply', saveFailed: 'Save failed: ', refresh: 'Refresh',
|
|
91
|
+
saveAs: 'New', update: 'Edit', remove: 'Delete', presetNamePlaceholder: 'Preset name', confirm: 'OK', cancel: 'Cancel', customSaved: 'Custom preset saved', customUpdated: 'Custom preset updated', customDeleted: 'Custom preset deleted', saveAsTitle: 'Save current config as custom preset', updateTitle: 'Update current custom preset', removeTitle: 'Delete current custom preset', fromPreset: 'From: ', modifiedSuffix: ' · modified',
|
|
92
|
+
presetNameRequired: 'Please enter a preset name',
|
|
93
|
+
loading: 'Loading…', unavailable: 'Unavailable (namespace not registered on host)',
|
|
94
|
+
},
|
|
95
|
+
ja: {
|
|
96
|
+
title: 'セッション完了通知', subtitle: '完了プッシュの言語とメッセージテンプレート(保存後に再読み込みで反映)',
|
|
97
|
+
preset: 'プリセット', choosePreset: 'プリセットを選択…', language: '言語',
|
|
98
|
+
fields: { completed: '完了', error: 'エラー', aborted: '中止', blocked: 'ブロック', 'max-tokens': 'トークン上限' },
|
|
99
|
+
titleLabel: 'プッシュタイトル', titleHint: '空欄の場合はセッションタイトルを使用',
|
|
100
|
+
kindLabels: { completed: 'セッション完了', error: 'セッションエラー', aborted: 'セッション中止', blocked: 'セッションがブロックされました', 'max-tokens': '出力トークン上限に到達' },
|
|
101
|
+
tokens: { title: 'セッションタイトル', label: '完了ラベル', duration: '所要時間', usage: '消費', error: 'エラー', cache: 'キャッシュヒット', tps: '速度 TPS' },
|
|
102
|
+
samples: { title: 'サンプルセッション', label: 'セッション完了', duration: '12 秒', usage: '1,240 入力 / 3,560 出力', error: '接続タイムアウト', cache: '96.5%', tps: '92 tok/s' },
|
|
103
|
+
presets: { custom: 'カスタムプリセット', unnamed: '未命名プリセット', default: 'デフォルト', playful: 'カジュアル 🎉', formal: 'フォーマル', detail: '詳細レポート' },
|
|
104
|
+
placeholder: 'カスタム文面:「+ 情報を挿入」または直接入力',
|
|
105
|
+
insertInfo: '+ 情報を挿入', insertTitle: 'カーソル位置に情報を挿入', preview: 'プレビュー:',
|
|
106
|
+
skipSubagents: 'サブエージェントセッションをスキップ',
|
|
107
|
+
discard: '破棄', save: '保存', saving: '保存中…', saved: '保存しました ✓ · ページを再読み込み', saveFailed: '保存失敗:', refresh: '再読み込み',
|
|
108
|
+
saveAs: '新規', update: '編集', remove: '削除', presetNamePlaceholder: 'プリセット名', confirm: '決定', cancel: 'キャンセル', customSaved: 'カスタムプリセットを保存しました', customUpdated: 'カスタムプリセットを更新しました', customDeleted: 'カスタムプリセットを削除しました', saveAsTitle: '現在の設定をカスタムプリセットとして保存', updateTitle: '現在のカスタムプリセットを更新', removeTitle: '現在のカスタムプリセットを削除', fromPreset: '由来:', modifiedSuffix: ' · 変更あり',
|
|
109
|
+
presetNameRequired: 'プリセット名を入力してください',
|
|
110
|
+
loading: '読み込み中…', unavailable: '利用不可(ホストに名前空間未登録)',
|
|
111
|
+
},
|
|
112
|
+
ko: {
|
|
113
|
+
title: '세션 완료 알림', subtitle: '완료 푸시의 언어 및 메시지 템플릿(저장 후 새로고침 시 적용)',
|
|
114
|
+
preset: '프리셋', choosePreset: '프리셋 선택…', language: '언어',
|
|
115
|
+
fields: { completed: '완료', error: '오류', aborted: '중단', blocked: '차단', 'max-tokens': '토큰 한도' },
|
|
116
|
+
titleLabel: '푸시 제목', titleHint: '비우면 세션 제목 사용',
|
|
117
|
+
kindLabels: { completed: '세션 완료', error: '세션 오류', aborted: '세션 중단됨', blocked: '세션 차단됨', 'max-tokens': '출력 토큰 한도 도달' },
|
|
118
|
+
tokens: { title: '세션 제목', label: '완료 라벨', duration: '소요 시간', usage: '소모', error: '오류', cache: '캐시 히트', tps: '속도 TPS' },
|
|
119
|
+
samples: { title: '예시 세션', label: '세션 완료', duration: '12초', usage: '1,240 입력 / 3,560 출력', error: '연결 시간 초과', cache: '96.5%', tps: '92 tok/s' },
|
|
120
|
+
presets: { custom: '사용자 지정 프리셋', unnamed: '이름 없는 프리셋', default: '기본', playful: '캐주얼 🎉', formal: '정식', detail: '상세 보고' },
|
|
121
|
+
placeholder: '사용자 지정 문구: "+ 정보 삽입" 또는 직접 입력',
|
|
122
|
+
insertInfo: '+ 정보 삽입', insertTitle: '커서 위치에 정보 삽입', preview: '미리보기: ',
|
|
123
|
+
skipSubagents: '하위 에이전트 세션 건너뛰기',
|
|
124
|
+
discard: '취소', save: '저장', saving: '저장 중…', saved: '저장됨 ✓ · 새로고침 후 적용', saveFailed: '저장 실패: ', refresh: '새로고침',
|
|
125
|
+
saveAs: '추가', update: '수정', remove: '삭제', presetNamePlaceholder: '프리셋 이름', confirm: '확인', cancel: '취소', customSaved: '사용자 지정 프리셋 저장됨', customUpdated: '사용자 지정 프리셋 업데이트됨', customDeleted: '사용자 지정 프리셋 삭제됨', saveAsTitle: '현재 구성을 사용자 지정 프리셋으로 저장', updateTitle: '현재 사용자 지정 프리셋 업데이트', removeTitle: '현재 사용자 지정 프리셋 삭제', fromPreset: '출처: ', modifiedSuffix: ' · 수정됨',
|
|
126
|
+
presetNameRequired: '프리셋 이름을 입력하세요',
|
|
127
|
+
loading: '불러오는 중…', unavailable: '사용 불가(호스트에 네임스페이스 미등록)',
|
|
128
|
+
},
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** 取当前语言的文案表(未知语言回落 zh)。 */
|
|
132
|
+
function tOf(lang) {
|
|
133
|
+
return I18N[lang] || I18N.zh
|
|
134
|
+
}
|
|
135
|
+
var TOAST_TTL_MS = 10000
|
|
136
|
+
var TOAST_MAX = 3 // 同时保留的 toast 上限:超出则移除最旧(新弹窗永远可见)
|
|
137
|
+
var permissionRequested = false // 每页加载只发起一次授权请求(后续完成事件不再触发询问)
|
|
138
|
+
|
|
139
|
+
exports.apply = function apply(ctx) {
|
|
140
|
+
var list = ctx.sessions.list
|
|
141
|
+
var notifyScope = null
|
|
142
|
+
try { notifyScope = ctx.settingsScope.bind({ namespace: SETTINGS_NS }) } catch (e) { /* 无设置作用域时用默认 */ }
|
|
143
|
+
var prevRunning = new Map() // sessionId -> 上次观测的 running 位
|
|
144
|
+
var primed = false // 首次观测只记录基线(已在 idle 的会话不补发),与官方 sidebar 提醒同策略
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
console.log('[' + PLUGIN_TAG + '-client] active, watching session activity for completion pushes')
|
|
148
|
+
} catch (e) { /* noop */ }
|
|
149
|
+
|
|
150
|
+
function onSnapshot() {
|
|
151
|
+
var snap = list.getSnapshot()
|
|
152
|
+
if (!snap || typeof snap.byId !== 'object' || snap.byId === null) return
|
|
153
|
+
var entries = Object.keys(snap.byId)
|
|
154
|
+
for (var i = 0; i < entries.length; i++) {
|
|
155
|
+
var id = entries[i]
|
|
156
|
+
var s = snap.byId[id]
|
|
157
|
+
if (!s || typeof s.running !== 'boolean') continue
|
|
158
|
+
var was = prevRunning.get(id)
|
|
159
|
+
prevRunning.set(id, s.running)
|
|
160
|
+
if (!primed) continue
|
|
161
|
+
// 完成事件:running true → false 的边沿
|
|
162
|
+
if (was === true && s.running === false) {
|
|
163
|
+
if (s.origin === 'subagent') continue // 子代理会话由父会话编排
|
|
164
|
+
pushCompletion(id, s)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
primed = true
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* 完成推送:标题 = 哪个会话(displayTitle),正文优先取宿主刚写入的
|
|
172
|
+
* 系统消息全文(含用时/token/错误详情,来自会话事件窗口的 notice 节点)。
|
|
173
|
+
* 宿主通知经 mux 到达有 1~3 秒延迟,轮询窗口内取到即用,取不到降级
|
|
174
|
+
* 为工作区信息。
|
|
175
|
+
*/
|
|
176
|
+
function pushCompletion(id, s) {
|
|
177
|
+
var name = s.displayTitle || s.title || id
|
|
178
|
+
var cwdLine = workspaceLine(s)
|
|
179
|
+
var start = Date.now()
|
|
180
|
+
function attempt() {
|
|
181
|
+
var notice = readNoticeAny(id)
|
|
182
|
+
if (notice || Date.now() - start > 6000) {
|
|
183
|
+
notifyUser(pushTitle(name, notice ? '' : ' 已完成'), notice || (cwdLine ? cwdLine + ' · ' : '') + '详情见会话内系统消息')
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
setTimeout(attempt, 400)
|
|
187
|
+
}
|
|
188
|
+
attempt()
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** 推送标题:用户模板({title} = 会话标题);无通知时附带完成标记。 */
|
|
192
|
+
function pushTitle(name, suffix) {
|
|
193
|
+
var tpl = '{title}'
|
|
194
|
+
try {
|
|
195
|
+
if (notifyScope) {
|
|
196
|
+
var st = notifyScope.getSnapshot()
|
|
197
|
+
if (st && st.value && typeof st.value.titleTemplate === 'string' && st.value.titleTemplate.trim() !== '') tpl = st.value.titleTemplate
|
|
198
|
+
}
|
|
199
|
+
} catch (e) { /* 默认 */ }
|
|
200
|
+
var out = String(tpl).replaceAll('{title}', name).trim()
|
|
201
|
+
if (out === '') out = name
|
|
202
|
+
return out + (suffix || '')
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* 通知正文优先级:
|
|
207
|
+
* 1. 宿主投影(session-complete-notify key)——每个会话都推,后台会话也有全文;
|
|
208
|
+
* 2. 会话事件窗口里的 notice 节点(正在看的会话,落盘后立刻可用);
|
|
209
|
+
* 3. 无 → 降级(工作区信息)。
|
|
210
|
+
*/
|
|
211
|
+
function readNoticeAny(id) {
|
|
212
|
+
try {
|
|
213
|
+
var snap = list.getSnapshot()
|
|
214
|
+
var cur = snap && snap.byId && snap.byId[id]
|
|
215
|
+
var pv = cur && cur.projectionValues && cur.projectionValues[NOTICE_PROJECTION_KEY]
|
|
216
|
+
if (typeof pv === 'string' && pv.trim() !== '') return pv
|
|
217
|
+
} catch (e) { /* 投影缺失 → 走窗口路径 */ }
|
|
218
|
+
return readNotice(id)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** 从会话事件窗口找宿主写入的最新 notice 全文(kind=context + form=notice)。 */
|
|
222
|
+
function readNotice(id) {
|
|
223
|
+
try {
|
|
224
|
+
var binding = ctx.sessions.binding(id)
|
|
225
|
+
var session = binding && binding.session
|
|
226
|
+
if (!session || typeof session.getSnapshot !== 'function') return null
|
|
227
|
+
var snap = session.getSnapshot()
|
|
228
|
+
var nodes = snap && snap.nodes
|
|
229
|
+
if (!Array.isArray(nodes)) return null
|
|
230
|
+
for (var i = nodes.length - 1; i >= 0; i--) {
|
|
231
|
+
var node = nodes[i]
|
|
232
|
+
if (!node || node.kind !== 'context' || node.form !== 'notice') continue
|
|
233
|
+
var text = ''
|
|
234
|
+
var blocks = node.content || []
|
|
235
|
+
for (var j = 0; j < blocks.length; j++) {
|
|
236
|
+
if (blocks[j] && blocks[j].type === 'text') text += blocks[j].text || ''
|
|
237
|
+
}
|
|
238
|
+
if (text) return text
|
|
239
|
+
}
|
|
240
|
+
return null
|
|
241
|
+
} catch (e) {
|
|
242
|
+
return null
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** 工作区一行(取 cwd 最后一段);无 cwd 返回空串。 */
|
|
247
|
+
function workspaceLine(s) {
|
|
248
|
+
if (!s.cwd) return ''
|
|
249
|
+
var parts = String(s.cwd).split(/[\\/]/).filter(Boolean)
|
|
250
|
+
return '工作区:' + (parts.length ? parts[parts.length - 1] : s.cwd)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
onSnapshot()
|
|
254
|
+
var dispose = list.subscribe(onSnapshot)
|
|
255
|
+
|
|
256
|
+
// 设置卡片(官方「设置 → 插件」面板的 keyed slot)
|
|
257
|
+
try {
|
|
258
|
+
registerSettingsCard(ctx)
|
|
259
|
+
} catch (e) {
|
|
260
|
+
log('settings card registration failed: ' + (e && e.message ? e.message : e))
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// 调试钩子(仅探针/排障用):window.__dsch_notify_debug
|
|
264
|
+
try {
|
|
265
|
+
if (typeof window !== 'undefined') {
|
|
266
|
+
window.__dsch_notify_debug = {
|
|
267
|
+
readNotice: readNotice,
|
|
268
|
+
snapshotDebug: function (id) {
|
|
269
|
+
try {
|
|
270
|
+
var binding = ctx.sessions.binding(id)
|
|
271
|
+
var session = binding && binding.session
|
|
272
|
+
if (!session || typeof session.getSnapshot !== 'function') return JSON.stringify({ ok: false, reason: 'no session face' })
|
|
273
|
+
var snap = session.getSnapshot()
|
|
274
|
+
var nodes = Array.isArray(snap && snap.nodes) ? snap.nodes : []
|
|
275
|
+
var tail = nodes.slice(-8).map(function (n) { return (n && n.kind) + (n && n.form ? ':' + n.form : '') })
|
|
276
|
+
var notices = nodes.filter(function (n) { return n && n.kind === 'context' && n.form === 'notice' })
|
|
277
|
+
return JSON.stringify({ ok: true, nodes: nodes.length, tail: tail, notices: notices.length, lastNotice: notices.length ? (notices[notices.length - 1].content || []).map(function (b) { return b.text || '' }).join('').slice(0, 200) : null })
|
|
278
|
+
} catch (e) {
|
|
279
|
+
return JSON.stringify({ ok: false, reason: String(e && e.message) })
|
|
280
|
+
}
|
|
281
|
+
},
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
} catch (e) { /* noop */ }
|
|
285
|
+
|
|
286
|
+
// 与宿主插件生命周期一致:ctx.effect 登记清理,fiber 卸载时释放。
|
|
287
|
+
if (ctx.effect && typeof ctx.effect === 'function') {
|
|
288
|
+
ctx.effect(function () {
|
|
289
|
+
return dispose
|
|
290
|
+
})
|
|
291
|
+
} else {
|
|
292
|
+
try {
|
|
293
|
+
var oc = ((ctx.onDispose && ctx.onDispose(dispose)) || undefined)
|
|
294
|
+
void oc
|
|
295
|
+
} catch (e) {
|
|
296
|
+
/* 老接口缺失时仅内存泄漏,不影响功能 */
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* 推送:系统通知(每次独立 tag,避免同 tag 替换/分组折叠导致后续弹窗不可见)
|
|
303
|
+
* + 页内 toast **总是展示**(保底可见反馈,即使系统通知被平台静默/折叠)。
|
|
304
|
+
*/
|
|
305
|
+
function notifyUser(title, body) {
|
|
306
|
+
try {
|
|
307
|
+
if (typeof Notification !== 'undefined') {
|
|
308
|
+
if (Notification.permission === 'granted') {
|
|
309
|
+
// 每次完成事件的独立 tag:不与前一次互相替换,也不被折叠成一个分组条目
|
|
310
|
+
var n = new Notification(title, { body: body, tag: PLUGIN_TAG + ':' + Date.now(), silent: false })
|
|
311
|
+
try {
|
|
312
|
+
n.onclick = function () { window.focus(); n.close() }
|
|
313
|
+
} catch (e) { /* noop */ }
|
|
314
|
+
log('notification shown: ' + title)
|
|
315
|
+
} else if (Notification.permission === 'default') {
|
|
316
|
+
if (!permissionRequested) {
|
|
317
|
+
permissionRequested = true
|
|
318
|
+
log('notification permission default, requesting…')
|
|
319
|
+
var req = Notification.requestPermission()
|
|
320
|
+
if (req && typeof req.then === 'function') {
|
|
321
|
+
req.then(function (p) {
|
|
322
|
+
if (p === 'granted') log('notification permission granted (subsequent completions will use it)')
|
|
323
|
+
else log('notification permission ' + p + ', toast always shows')
|
|
324
|
+
}).catch(function () { /* 权限拒绝 → toast 兜底 */ })
|
|
325
|
+
}
|
|
326
|
+
} else {
|
|
327
|
+
log('notification permission still pending, toast only')
|
|
328
|
+
}
|
|
329
|
+
} else {
|
|
330
|
+
log('notification permission denied, toast shows')
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
} catch (e) { /* 环境不支持 Notification → toast 兜底 */ }
|
|
334
|
+
// toast 永远展示:保底通道,保证每次完成都有可见弹窗
|
|
335
|
+
showToast(title, body)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function log(message) {
|
|
339
|
+
try { console.log('[' + PLUGIN_TAG + '-client] ' + message) } catch (e) { /* noop */ }
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** 页内浮动 toast(零依赖,右下角,点击关闭,10 秒自动消失)。 */
|
|
343
|
+
function showToast(title, body) {
|
|
344
|
+
if (typeof document === 'undefined') return
|
|
345
|
+
try {
|
|
346
|
+
var root = document.querySelector('[data-dsh-notify-root]')
|
|
347
|
+
if (!root) {
|
|
348
|
+
root = document.createElement('div')
|
|
349
|
+
root.setAttribute('data-dsh-notify-root', '')
|
|
350
|
+
root.style.cssText = 'position:fixed;right:16px;bottom:16px;z-index:2147483000;display:flex;flex-direction:column;gap:8px;pointer-events:none;font-family:system-ui,sans-serif'
|
|
351
|
+
document.body.appendChild(root)
|
|
352
|
+
}
|
|
353
|
+
var el = document.createElement('div')
|
|
354
|
+
el.style.cssText = 'pointer-events:auto;cursor:pointer;background:rgba(28,30,38,0.96);color:#f5f6f8;padding:10px 14px;border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,0.35);max-width:340px;min-width:220px;opacity:0;transform:translateY(8px);transition:opacity 0.2s,transform 0.2s'
|
|
355
|
+
var t1 = document.createElement('div')
|
|
356
|
+
t1.textContent = title
|
|
357
|
+
t1.style.cssText = 'font-size:13px;font-weight:600;line-height:1.5'
|
|
358
|
+
var t2 = document.createElement('div')
|
|
359
|
+
t2.textContent = body
|
|
360
|
+
t2.style.cssText = 'font-size:12px;line-height:1.5;opacity:0.8;margin-top:2px'
|
|
361
|
+
el.appendChild(t1)
|
|
362
|
+
el.appendChild(t2)
|
|
363
|
+
var close = function () { el.remove() }
|
|
364
|
+
el.addEventListener('click', close)
|
|
365
|
+
// 上限:移除最旧 toast,保证新弹窗始终可见(不被堆叠遮挡/顶出屏幕)
|
|
366
|
+
while (root.children.length >= TOAST_MAX) root.firstChild.remove()
|
|
367
|
+
root.appendChild(el)
|
|
368
|
+
requestAnimationFrame(function () {
|
|
369
|
+
el.style.opacity = '1'
|
|
370
|
+
el.style.transform = 'translateY(0)'
|
|
371
|
+
})
|
|
372
|
+
setTimeout(close, TOAST_TTL_MS)
|
|
373
|
+
} catch (e) { /* DOM 不可用时放弃 */ }
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// ============ 设置卡片:官方「设置 → 插件」面板 ============
|
|
377
|
+
|
|
378
|
+
var REASON_FIELDS = ['completed', 'error', 'aborted', 'blocked', 'max-tokens']
|
|
379
|
+
var REASON_LABELS = { completed: '完成', error: '出错', aborted: '中止', blocked: '阻塞', 'max-tokens': '输出上限' }
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* 注册设置卡片:settings.plugin.item(keyed,key = 宿主注册的 settings 命名空间)。
|
|
383
|
+
* 面板按命名空间枚举(宿主注册后自动出现),卡片负责表单与保存。
|
|
384
|
+
*/
|
|
385
|
+
function registerSettingsCard(ctx) {
|
|
386
|
+
try {
|
|
387
|
+
var react = require('react')
|
|
388
|
+
var Card = makeSettingsCard(react, ctx)
|
|
389
|
+
ctx.slots.inject('settings.plugin.item', function* () {
|
|
390
|
+
yield ctx.slots.register({ name: 'settings.plugin.item', id: PLUGIN_TAG, key: SETTINGS_NS, order: 40 }, Card)
|
|
391
|
+
})
|
|
392
|
+
log('settings card registered (settings.plugin.item key=' + SETTINGS_NS + ')')
|
|
393
|
+
} catch (e) {
|
|
394
|
+
log('settings card skipped: ' + (e && e.message ? e.message : e))
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** 设置卡片组件。chrome 逐值复刻原生插件卡片(modlens 同款):
|
|
399
|
+
* 12px 圆角容器、展开/收起两层背景、旋转 chevron 头部、
|
|
400
|
+
* footer 状态位 + 弃置 ghost + 主色保存按钮;字段用 primitives Input。 */
|
|
401
|
+
function makeSettingsCard(react, ctx) {
|
|
402
|
+
var h = react.createElement
|
|
403
|
+
var useState = react.useState
|
|
404
|
+
var useEffect = react.useEffect
|
|
405
|
+
var useRef = react.useRef
|
|
406
|
+
var scope = ctx.settingsScope.bind({ namespace: SETTINGS_NS })
|
|
407
|
+
|
|
408
|
+
var CARD = {
|
|
409
|
+
border: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.35))',
|
|
410
|
+
borderRadius: '12px',
|
|
411
|
+
transition: 'border-color .16s, background .16s',
|
|
412
|
+
}
|
|
413
|
+
var SUBTLE = 'var(--dsw-alias-label-tertiary, rgba(127,127,127,0.8))'
|
|
414
|
+
var DIVIDER = '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.35))'
|
|
415
|
+
var SELECT_STYLE = {
|
|
416
|
+
appearance: 'none', width: '100%', padding: '8px 12px', borderRadius: '8px',
|
|
417
|
+
border: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.35))',
|
|
418
|
+
background: 'transparent', color: 'inherit', font: 'inherit', fontSize: '13px',
|
|
419
|
+
// 关键:声明深色 color-scheme——Chromium 的原生下拉弹层按它渲染;
|
|
420
|
+
// 否则弹层保持白底而文字继承深色主题的浅色 → 白底白字不可见。
|
|
421
|
+
colorScheme: 'dark',
|
|
422
|
+
}
|
|
423
|
+
/** option 显式深色(弹层若不跟随 colorScheme,也至少保证文字/底均深色)。 */
|
|
424
|
+
var OPTION_STYLE = {
|
|
425
|
+
color: 'var(--dsw-alias-label-primary, #e8e8ea)',
|
|
426
|
+
background: 'var(--dsw-alias-bg-layer-2, #26272c)',
|
|
427
|
+
}
|
|
428
|
+
/** 深色 option:style + 常规属性一次给齐。 */
|
|
429
|
+
function darkOption(attrs, children) {
|
|
430
|
+
return h('option', Object.assign({ style: OPTION_STYLE }, attrs || {}), children)
|
|
431
|
+
}
|
|
432
|
+
var GHOST = {
|
|
433
|
+
appearance: 'none', font: 'inherit', fontSize: '13px', lineHeight: 1.5, cursor: 'pointer',
|
|
434
|
+
border: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.35))', borderRadius: '8px',
|
|
435
|
+
padding: '5px 14px', background: 'none', color: 'var(--dsw-alias-label-secondary, inherit)',
|
|
436
|
+
}
|
|
437
|
+
var PRIMARY = {
|
|
438
|
+
appearance: 'none', font: 'inherit', fontSize: '13px', lineHeight: 1.5, cursor: 'pointer',
|
|
439
|
+
border: 0, borderRadius: '8px', padding: '5px 14px',
|
|
440
|
+
background: 'var(--dsw-alias-label-primary, currentColor)', color: 'var(--dsw-alias-bg-layer-3, rgba(127,127,127,0.05))',
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** 预设:应用后继续可手改,保存才写入宿主。(结构见下方统一声明) */
|
|
444
|
+
|
|
445
|
+
/** 可插入的动态信息 token 名(标签/示例值按语言从 I18N 取)。
|
|
446
|
+
* 注意:{label} 已从插入菜单移除(旧模板中的 {label} 仍兼容渲染/预览)。 */
|
|
447
|
+
var TOKEN_KEYS = ['title', 'duration', 'usage', 'error', 'cache', 'tps']
|
|
448
|
+
|
|
449
|
+
/** 系统预设:仅保留「默认」(作为基线,其余以自定义预设承载)。 */
|
|
450
|
+
var PRESETS = [
|
|
451
|
+
{ id: 'default', labelKey: 'default', language: 'zh', titleTemplate: '', templates: { completed: '', error: '', aborted: '', blocked: '', 'max-tokens': '' } },
|
|
452
|
+
]
|
|
453
|
+
|
|
454
|
+
/** 本地存储的用户自定义预设库。 */
|
|
455
|
+
var CUSTOM_PRESETS_KEY = 'dsh-scn-custom-presets'
|
|
456
|
+
function loadCustomPresets() {
|
|
457
|
+
try {
|
|
458
|
+
var raw = window.localStorage.getItem(CUSTOM_PRESETS_KEY)
|
|
459
|
+
var list = raw ? JSON.parse(raw) : []
|
|
460
|
+
return Array.isArray(list) ? list : []
|
|
461
|
+
} catch (e) { return [] }
|
|
462
|
+
}
|
|
463
|
+
function saveCustomPresets(list) {
|
|
464
|
+
try { window.localStorage.setItem(CUSTOM_PRESETS_KEY, JSON.stringify(list)) } catch (e) { /* noop */ }
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** 当前(语言+模板)与哪个预设一致;无匹配返回 'custom'(自定义预设)。 */
|
|
468
|
+
function presetIdOf(cur, customPresets) {
|
|
469
|
+
var i
|
|
470
|
+
for (i = 0; i < PRESETS.length; i++) {
|
|
471
|
+
if (matchesPreset(cur, PRESETS[i])) return PRESETS[i].id
|
|
472
|
+
}
|
|
473
|
+
for (i = 0; i < (customPresets || []).length; i++) {
|
|
474
|
+
if (customPresets[i] && matchesPreset(cur, customPresets[i])) return customPresets[i].id
|
|
475
|
+
}
|
|
476
|
+
return 'custom'
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** 预设自动同步辅助:未命名预设识别/编号/命名。 */
|
|
480
|
+
function isUnnamedId(id) {
|
|
481
|
+
return id === 'unnamed' || /^unnamed-\d+$/.test(id)
|
|
482
|
+
}
|
|
483
|
+
function unnamedNumber(id) {
|
|
484
|
+
if (id === 'unnamed') return 1
|
|
485
|
+
var m = /^unnamed-(\d+)$/.exec(id)
|
|
486
|
+
return m ? parseInt(m[1], 10) : 0
|
|
487
|
+
}
|
|
488
|
+
function nextUnnamedNumber(list) {
|
|
489
|
+
var max = 0
|
|
490
|
+
for (var i = 0; i < (list || []).length; i++) {
|
|
491
|
+
if (isUnnamedId(list[i].id)) max = Math.max(max, unnamedNumber(list[i].id))
|
|
492
|
+
}
|
|
493
|
+
return max + 1
|
|
494
|
+
}
|
|
495
|
+
function unnamedDisplayName(id, t) {
|
|
496
|
+
var n = unnamedNumber(id)
|
|
497
|
+
return n > 1 ? t.presets.unnamed + ' ' + n : t.presets.unnamed
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** 表单(语言+模板)与某预设内容是否一致(用于「已修改」指示)。 */
|
|
501
|
+
function matchesPreset(cur, p) {
|
|
502
|
+
if (!p) return false
|
|
503
|
+
if (p.language !== (LANG_IDS.indexOf(cur.language) >= 0 ? cur.language : 'zh')) return false
|
|
504
|
+
if (String(cur.titleTemplate ?? '') !== String(p.titleTemplate ?? '')) return false
|
|
505
|
+
for (var j = 0; j < REASON_FIELDS.length; j++) {
|
|
506
|
+
if (templateOf(cur, REASON_FIELDS[j]) !== String(p.templates[REASON_FIELDS[j]] ?? '').trim()) return false
|
|
507
|
+
}
|
|
508
|
+
return true
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** 把模板拆成 [text|token] 片段(token = {label|duration|usage|error|cache|tps})。 */
|
|
512
|
+
function parseTemplate(tpl) {
|
|
513
|
+
var parts = []
|
|
514
|
+
var re = /\{(title|label|duration|usage|error|cache|tps)\}/g
|
|
515
|
+
var last = 0
|
|
516
|
+
var m
|
|
517
|
+
while ((m = re.exec(tpl)) !== null) {
|
|
518
|
+
if (m.index > last) parts.push({ type: 'text', value: tpl.slice(last, m.index) })
|
|
519
|
+
parts.push({ type: 'token', key: m[1] })
|
|
520
|
+
last = re.lastIndex
|
|
521
|
+
}
|
|
522
|
+
if (last < tpl.length) parts.push({ type: 'text', value: tpl.slice(last) })
|
|
523
|
+
return parts
|
|
524
|
+
}
|
|
525
|
+
/** 从模板中移除第一个指定 token(点击预览胶囊 × 时用)。 */
|
|
526
|
+
function removeToken(tpl, key) {
|
|
527
|
+
return String(tpl).replace(new RegExp('\\{' + key + '\\}'), '')
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** 扁平化读取:草稿键 tpl-<field> 优先,其次已保存的 templates[<field>]。
|
|
531
|
+
* 兼容清理:{label} 已全面移除——读取时直接剥除(保存即写净)。 */
|
|
532
|
+
function templateOf(cur, field) {
|
|
533
|
+
var tpl = (cur && cur.templates) || {}
|
|
534
|
+
return String(cur && cur['tpl-' + field] !== undefined ? cur['tpl-' + field] : (tpl[field] ?? ''))
|
|
535
|
+
.replace(/\{label\}/g, '').trim()
|
|
536
|
+
}
|
|
537
|
+
/** 扁平快照(用于 dirty 对比与模板打包)。 */
|
|
538
|
+
function flatOf(cur) {
|
|
539
|
+
var out = { language: LANG_IDS.indexOf(cur.language) >= 0 ? cur.language : 'zh', titleTemplate: String(cur.titleTemplate ?? ''), includeDuration: !!cur.includeDuration, includeUsage: !!cur.includeUsage }
|
|
540
|
+
for (var i = 0; i < REASON_FIELDS.length; i++) out['tpl-' + REASON_FIELDS[i]] = templateOf(cur, REASON_FIELDS[i])
|
|
541
|
+
return out
|
|
542
|
+
}
|
|
543
|
+
function sameFlat(a, b) {
|
|
544
|
+
var keys = Object.keys(a)
|
|
545
|
+
if (keys.length !== Object.keys(b).length) return false
|
|
546
|
+
for (var i = 0; i < keys.length; i++) if (a[keys[i]] !== b[keys[i]]) return false
|
|
547
|
+
return true
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
var InputPrimary = null
|
|
551
|
+
try {
|
|
552
|
+
var ui = require('@deepseek-ai/dsh-client-ui-primitives')
|
|
553
|
+
InputPrimary = (ui && ui.Input) || null
|
|
554
|
+
} catch (e) {
|
|
555
|
+
log('primitives Input unavailable, styled fallback: ' + (e && e.message ? e.message : e))
|
|
556
|
+
}
|
|
557
|
+
var TextInput = InputPrimary || function PlainInput(props) {
|
|
558
|
+
return h('input', Object.assign({}, props, { style: Object.assign({}, SELECT_STYLE, props.style || {}, { height: '36px' }) }))
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function chevron(open) {
|
|
562
|
+
return h('svg', {
|
|
563
|
+
width: 16, height: 16, viewBox: '0 0 16 16',
|
|
564
|
+
style: { color: 'var(--dsw-alias-label-tertiary, rgba(127,127,127,0.8))', flex: 'none', transition: 'transform .16s', transform: open ? 'rotate(180deg)' : 'none' },
|
|
565
|
+
}, h('path', { d: 'M4 6l4 4 4-4', fill: 'none', stroke: 'currentColor', strokeWidth: 1.5, strokeLinecap: 'round', strokeLinejoin: 'round' }))
|
|
566
|
+
}
|
|
567
|
+
/** 一行控件 = label(flex column,6px 间距,上边框分隔,13px 次级标签)。 */
|
|
568
|
+
function fieldRow(labelText, control, key) {
|
|
569
|
+
return h('div', {
|
|
570
|
+
key: key,
|
|
571
|
+
style: { display: 'flex', flexDirection: 'column', gap: '6px', padding: '12px 0', borderTop: DIVIDER },
|
|
572
|
+
}, h('div', { style: { fontSize: '13px', color: 'var(--dsw-alias-label-secondary, inherit)' } }, labelText), control)
|
|
573
|
+
}
|
|
574
|
+
function checkRow(labelText, checked, onChange, key) {
|
|
575
|
+
return h('label', { key: key, style: { display: 'flex', alignItems: 'center', gap: '8px', fontSize: '13px' } },
|
|
576
|
+
h('input', { type: 'checkbox', checked: checked, onChange: onChange }),
|
|
577
|
+
h('span', null, labelText))
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Chip 模板编辑器(contentEditable):
|
|
582
|
+
* - 普通文字就是文字,动态信息渲染成内联胶囊({duration} 等代码永不露出);
|
|
583
|
+
* - 胶囊点击移除、+ 在**光标处**插入(支持插到文字中间);
|
|
584
|
+
* - 通栏自适应换行(多行文字全可见),模板字符串无损往返;
|
|
585
|
+
* - 外部模板变化(预设/载入)重建 DOM;用户输入期间不打扰光标。
|
|
586
|
+
*/
|
|
587
|
+
function ChipEditor(props) {
|
|
588
|
+
var editorRef = useRef(null)
|
|
589
|
+
var propsRef = useRef(props)
|
|
590
|
+
propsRef.current = props // 每次渲染刷新:回调用最新 props(挂载句柄不再持有陈旧闭包)
|
|
591
|
+
var lastTpl = useRef(typeof props.template === 'string' ? props.template : '')
|
|
592
|
+
var lastLang = useRef(props.lang || 'zh')
|
|
593
|
+
|
|
594
|
+
function chipEl(key) {
|
|
595
|
+
var t = tOf(propsRef.current.lang || 'zh')
|
|
596
|
+
var label = t.tokens[key] || key
|
|
597
|
+
var el = document.createElement('span')
|
|
598
|
+
el.setAttribute('data-tok', key)
|
|
599
|
+
el.setAttribute('contenteditable', 'false')
|
|
600
|
+
el.style.cssText = 'display:inline-block;margin:1px 3px;padding:1px 6px;border-radius:6px;border:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,0.35));background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,0.15));color:var(--dsw-alias-label-secondary,inherit);font-size:12px;line-height:1.6;cursor:pointer;user-select:none'
|
|
601
|
+
el.title = label + '(点击移除)'
|
|
602
|
+
el.textContent = label + ' ×'
|
|
603
|
+
el.addEventListener('click', function () {
|
|
604
|
+
removeChipAt(el)
|
|
605
|
+
})
|
|
606
|
+
return el
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** 移除胶囊并把光标恢复到原位置(不再跳到编辑器开头)。 */
|
|
610
|
+
function removeChipAt(el) {
|
|
611
|
+
var editor = editorRef.current
|
|
612
|
+
var next = el.nextSibling
|
|
613
|
+
var prev = el.previousSibling
|
|
614
|
+
el.remove()
|
|
615
|
+
if (editor) {
|
|
616
|
+
var range = document.createRange()
|
|
617
|
+
if (next) range.setStartBefore(next)
|
|
618
|
+
else if (prev) range.setStartAfter(prev)
|
|
619
|
+
else { range.selectNodeContents(editor); range.collapse(true) }
|
|
620
|
+
range.collapse(true)
|
|
621
|
+
var sel = window.getSelection()
|
|
622
|
+
sel.removeAllRanges()
|
|
623
|
+
sel.addRange(range)
|
|
624
|
+
editor.focus()
|
|
625
|
+
}
|
|
626
|
+
syncFromDom()
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function domToTemplate() {
|
|
630
|
+
var editor = editorRef.current
|
|
631
|
+
if (!editor) return ''
|
|
632
|
+
var out = ''
|
|
633
|
+
Array.prototype.forEach.call(editor.childNodes, function (node) {
|
|
634
|
+
if (node.nodeType === 3) out += node.textContent
|
|
635
|
+
else if (node.nodeType === 1 && node.hasAttribute('data-tok')) out += '{' + node.getAttribute('data-tok') + '}'
|
|
636
|
+
else if (node.nodeType === 1 && !node.hasAttribute('data-ph')) out += node.textContent || ''
|
|
637
|
+
})
|
|
638
|
+
// 内容仍等于默认文案 → 视为「未改动」,模板保持空(默认语义)
|
|
639
|
+
if (typeof propsRef.current.hint === 'string' && propsRef.current.hint !== '' && out === propsRef.current.hint) return ''
|
|
640
|
+
return out
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function syncFromDom() {
|
|
644
|
+
var tpl = domToTemplate()
|
|
645
|
+
lastTpl.current = tpl
|
|
646
|
+
propsRef.current.onChange(tpl)
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function clearPlaceholder() {
|
|
650
|
+
var editor = editorRef.current
|
|
651
|
+
if (!editor) return false
|
|
652
|
+
var nodes = editor.querySelectorAll('[data-ph]')
|
|
653
|
+
if (nodes.length === 0) return false
|
|
654
|
+
Array.prototype.forEach.call(nodes, function (n) { n.remove() })
|
|
655
|
+
return true
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function rebuild(tpl) {
|
|
659
|
+
var editor = editorRef.current
|
|
660
|
+
if (!editor) return
|
|
661
|
+
lastTpl.current = typeof tpl === 'string' ? tpl : ''
|
|
662
|
+
editor.textContent = ''
|
|
663
|
+
var tplStr = typeof tpl === 'string' ? tpl : ''
|
|
664
|
+
// 空模板:优先提示串(真实默认内容,可编辑);否则非编辑占位(如推送标题)
|
|
665
|
+
var shown = tplStr !== '' ? tplStr : (typeof propsRef.current.hint === 'string' ? propsRef.current.hint : '')
|
|
666
|
+
if (shown !== '') {
|
|
667
|
+
var parts = parseTemplate(shown)
|
|
668
|
+
parts.forEach(function (seg) {
|
|
669
|
+
if (seg.type === 'text') {
|
|
670
|
+
if (seg.value !== '') editor.appendChild(document.createTextNode(seg.value))
|
|
671
|
+
} else {
|
|
672
|
+
editor.appendChild(chipEl(seg.key))
|
|
673
|
+
}
|
|
674
|
+
})
|
|
675
|
+
} else if (typeof propsRef.current.placeholder === 'string' && propsRef.current.placeholder !== '') {
|
|
676
|
+
var ph = document.createElement('span')
|
|
677
|
+
ph.setAttribute('data-ph', '1')
|
|
678
|
+
ph.style.cssText = 'color:var(--dsw-alias-label-tertiary,rgba(127,127,127,0.8));pointer-events:none;user-select:none'
|
|
679
|
+
ph.textContent = propsRef.current.placeholder
|
|
680
|
+
editor.appendChild(ph)
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function insertAtCaret(key) {
|
|
685
|
+
var editor = editorRef.current
|
|
686
|
+
if (!editor) return
|
|
687
|
+
clearPlaceholder()
|
|
688
|
+
var sel = window.getSelection()
|
|
689
|
+
var range = null
|
|
690
|
+
if (sel && sel.rangeCount > 0 && editor.contains(sel.anchorNode)) range = sel.getRangeAt(0)
|
|
691
|
+
if (!range) {
|
|
692
|
+
range = document.createRange()
|
|
693
|
+
range.selectNodeContents(editor)
|
|
694
|
+
range.collapse(false)
|
|
695
|
+
}
|
|
696
|
+
var chip = chipEl(key)
|
|
697
|
+
range.deleteContents()
|
|
698
|
+
range.insertNode(chip)
|
|
699
|
+
range.setStartAfter(chip)
|
|
700
|
+
range.collapse(true)
|
|
701
|
+
sel.removeAllRanges()
|
|
702
|
+
sel.addRange(range)
|
|
703
|
+
editor.focus()
|
|
704
|
+
syncFromDom()
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
useEffect(function () {
|
|
708
|
+
var lang = props.lang || 'zh'
|
|
709
|
+
if (props.template !== lastTpl.current || lang !== lastLang.current) {
|
|
710
|
+
lastLang.current = lang
|
|
711
|
+
rebuild(props.template)
|
|
712
|
+
}
|
|
713
|
+
// 仅外部变化(模板/语言)时重建;用户输入路径 lastTpl 已同步,不打扰光标
|
|
714
|
+
}, [props.template, props.lang])
|
|
715
|
+
|
|
716
|
+
useEffect(function () {
|
|
717
|
+
var editor = editorRef.current
|
|
718
|
+
if (editor) rebuild(props.template)
|
|
719
|
+
if (props.onReady) props.onReady({ insertAtCaret: insertAtCaret })
|
|
720
|
+
return function () { if (props.onReady) props.onReady(null) }
|
|
721
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
722
|
+
}, [])
|
|
723
|
+
|
|
724
|
+
return h('div', {
|
|
725
|
+
ref: editorRef,
|
|
726
|
+
contentEditable: true,
|
|
727
|
+
suppressContentEditableWarning: true,
|
|
728
|
+
onInput: syncFromDom,
|
|
729
|
+
onMouseDown: function () { clearPlaceholder() },
|
|
730
|
+
onKeyDown: function (e) {
|
|
731
|
+
clearPlaceholder()
|
|
732
|
+
if (e.key === 'Enter') { e.preventDefault(); return }
|
|
733
|
+
// Ctrl/Cmd+A:手动扩选“全部子节点(含胶囊)”——浏览器全选天然跳过
|
|
734
|
+
// contenteditable=false 的胶囊,导致全选删除后胶囊残留
|
|
735
|
+
if ((e.ctrlKey || e.metaKey) && (e.key === 'a' || e.key === 'A')) {
|
|
736
|
+
var editor = editorRef.current
|
|
737
|
+
if (editor) {
|
|
738
|
+
e.preventDefault()
|
|
739
|
+
var range = document.createRange()
|
|
740
|
+
range.selectNodeContents(editor)
|
|
741
|
+
var sel = window.getSelection()
|
|
742
|
+
sel.removeAllRanges()
|
|
743
|
+
sel.addRange(range)
|
|
744
|
+
}
|
|
745
|
+
return
|
|
746
|
+
}
|
|
747
|
+
if (e.key === 'Backspace' || e.key === 'Delete') {
|
|
748
|
+
// 让浏览器先删(会在下一拍触发 input),删除胶囊文本的残留交给 syncFromDom
|
|
749
|
+
setTimeout(syncFromDom, 0)
|
|
750
|
+
}
|
|
751
|
+
},
|
|
752
|
+
onPaste: function (e) {
|
|
753
|
+
e.preventDefault()
|
|
754
|
+
clearPlaceholder()
|
|
755
|
+
var text = (e.clipboardData || window.clipboardData || {}).getData ? (e.clipboardData || window.clipboardData).getData('text/plain') : ''
|
|
756
|
+
if (!text) return
|
|
757
|
+
try { document.execCommand('insertText', false, text) } catch (err) { /* noop */ }
|
|
758
|
+
setTimeout(syncFromDom, 0)
|
|
759
|
+
},
|
|
760
|
+
style: {
|
|
761
|
+
minHeight: '36px', padding: '7px 10px', borderRadius: '8px',
|
|
762
|
+
border: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.35))',
|
|
763
|
+
background: 'transparent', color: 'inherit', font: 'inherit', fontSize: '13px',
|
|
764
|
+
lineHeight: 1.5, outline: 'none', wordBreak: 'break-word',
|
|
765
|
+
},
|
|
766
|
+
})
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/** 内置默认内容的提示串(空模板时直接展示真实默认效果:文字 + 信息标签)。 */
|
|
770
|
+
function defaultHintOf(field, langCode) {
|
|
771
|
+
var lang = LANG_IDS.indexOf(langCode) >= 0 ? langCode : 'zh'
|
|
772
|
+
var tt = tOf(lang)
|
|
773
|
+
var label = (tt.kindLabels && tt.kindLabels[field]) || tt.samples.label
|
|
774
|
+
var d = '{duration}'
|
|
775
|
+
var u = '{usage}'
|
|
776
|
+
if (lang === 'en') return label + ' (took ' + d + ', used ' + u + ').'
|
|
777
|
+
if (lang === 'ja') return label + '(所要 ' + d + '、消費 ' + u + ')。'
|
|
778
|
+
if (lang === 'ko') return label + '(소요 ' + d + ', 소모 ' + u + ')。'
|
|
779
|
+
if (lang === 'zh-tw') return label + '(用時 ' + d + ',消耗 ' + u + ')。'
|
|
780
|
+
return label + '(用时 ' + d + ',消耗 ' + u + ')。'
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
return function SettingsCard() {
|
|
784
|
+
var statePair = useState(scope.getSnapshot())
|
|
785
|
+
var snap = statePair[0]
|
|
786
|
+
var setSnap = statePair[1]
|
|
787
|
+
useEffect(function () {
|
|
788
|
+
return scope.subscribe(function () {
|
|
789
|
+
setSnap(scope.getSnapshot())
|
|
790
|
+
})
|
|
791
|
+
}, [])
|
|
792
|
+
|
|
793
|
+
var openState = useState(false)
|
|
794
|
+
var open = openState[0]
|
|
795
|
+
var setOpen = openState[1]
|
|
796
|
+
var draftState = useState(null)
|
|
797
|
+
var draft = draftState[0]
|
|
798
|
+
var setDraft = draftState[1]
|
|
799
|
+
var noteState = useState('')
|
|
800
|
+
var note = noteState[0]
|
|
801
|
+
var setNote = noteState[1]
|
|
802
|
+
var justSavedState = useState(false) // 保存成功后显示「点击刷新」链接
|
|
803
|
+
var justSaved = justSavedState[0]
|
|
804
|
+
var setJustSaved = justSavedState[1]
|
|
805
|
+
var menuState = useState(null) // 当前展开「+插入信息」菜单的字段名(null=关闭)
|
|
806
|
+
var menuFor = menuState[0]
|
|
807
|
+
var setMenuFor = menuState[1]
|
|
808
|
+
var editorRefs = useRef({}) // field -> { insertAtCaret } 句柄
|
|
809
|
+
var customState = useState(loadCustomPresets()) // 用户自定义预设库(localStorage)
|
|
810
|
+
var customPresets = customState[0]
|
|
811
|
+
var setCustomPresets = customState[1]
|
|
812
|
+
var activeState = useState(null) // 当前表单「来自」哪个预设:{ id, name, custom, src } | null(只读来源指示/覆盖目标)
|
|
813
|
+
var activePreset = activeState[0]
|
|
814
|
+
var setActivePreset = activeState[1]
|
|
815
|
+
var namingState = useState(false) // 另存为的名字输入行
|
|
816
|
+
var naming = namingState[0]
|
|
817
|
+
var setNaming = namingState[1]
|
|
818
|
+
var nameState = useState('')
|
|
819
|
+
var newName = nameState[0]
|
|
820
|
+
var setName = nameState[1]
|
|
821
|
+
|
|
822
|
+
var ready = snap.status === 'ready'
|
|
823
|
+
var value = (snap && snap.value) || {}
|
|
824
|
+
var cur = draft === null ? value : draft
|
|
825
|
+
var dirty = draft !== null && !sameFlat(flatOf(draft), flatOf(value))
|
|
826
|
+
var t = tOf(cur.language) // 面板语言随「语言」切换即时更新
|
|
827
|
+
// 预设下拉显示「当前应用的预设」:载入来源优先,其次自动匹配(进入面板即显示当前应用项)
|
|
828
|
+
var shownPresetId = activePreset !== null ? activePreset.id : presetIdOf(cur, customPresets)
|
|
829
|
+
var shownEntry = null
|
|
830
|
+
var shownCustom = false
|
|
831
|
+
var ei
|
|
832
|
+
for (ei = 0; ei < PRESETS.length; ei++) if (PRESETS[ei].id === shownPresetId) { shownEntry = PRESETS[ei]; break }
|
|
833
|
+
if (!shownEntry && shownPresetId !== 'custom') {
|
|
834
|
+
for (ei = 0; ei < customPresets.length; ei++) if (customPresets[ei].id === shownPresetId) { shownEntry = customPresets[ei]; shownCustom = true; break }
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function setVal(key, x) {
|
|
838
|
+
var next = Object.assign({}, cur)
|
|
839
|
+
next[key] = x
|
|
840
|
+
setDraft(next)
|
|
841
|
+
setNote('')
|
|
842
|
+
setJustSaved(false)
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/** 载入内置或自定义预设到表单(下拉只负责载入,不跟踪表单状态)。 */
|
|
846
|
+
function loadPreset(entry, custom) {
|
|
847
|
+
var next = Object.assign({}, cur)
|
|
848
|
+
next.language = entry.language
|
|
849
|
+
next.titleTemplate = String(entry.titleTemplate ?? '')
|
|
850
|
+
for (var j = 0; j < REASON_FIELDS.length; j++) next['tpl-' + REASON_FIELDS[j]] = entry.templates[REASON_FIELDS[j]]
|
|
851
|
+
setDraft(next)
|
|
852
|
+
setNote('')
|
|
853
|
+
setJustSaved(false)
|
|
854
|
+
setActivePreset({ id: entry.id, name: custom ? entry.name : null, custom: custom, src: entry })
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
function flatTemplatesOf(x) {
|
|
858
|
+
var flat = flatOf(x)
|
|
859
|
+
var templates = {}
|
|
860
|
+
for (var i = 0; i < REASON_FIELDS.length; i++) {
|
|
861
|
+
var k = REASON_FIELDS[i]
|
|
862
|
+
templates[k] = flat['tpl-' + k]
|
|
863
|
+
}
|
|
864
|
+
return templates
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
function saveAsCustom() {
|
|
868
|
+
var name = newName.trim()
|
|
869
|
+
if (!name) { setNote(t.presetNameRequired); return }
|
|
870
|
+
var cp = {
|
|
871
|
+
id: 'cu-' + Date.now().toString(36),
|
|
872
|
+
name: name,
|
|
873
|
+
language: LANG_IDS.indexOf(cur.language) >= 0 ? cur.language : 'zh',
|
|
874
|
+
titleTemplate: flatOf(cur).titleTemplate,
|
|
875
|
+
templates: flatTemplatesOf(cur),
|
|
876
|
+
}
|
|
877
|
+
var next = customPresets.concat([cp])
|
|
878
|
+
setCustomPresets(next)
|
|
879
|
+
saveCustomPresets(next)
|
|
880
|
+
setActivePreset({ id: cp.id, name: name, custom: true, src: cp })
|
|
881
|
+
setNaming(false)
|
|
882
|
+
setName('')
|
|
883
|
+
setNote(t.customSaved)
|
|
884
|
+
log('custom preset saved: ' + name)
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
function deleteCustom() {
|
|
888
|
+
if (!shownCustom || !shownEntry) return
|
|
889
|
+
var next = customPresets.filter(function (cp) { return cp.id !== shownEntry.id })
|
|
890
|
+
setCustomPresets(next)
|
|
891
|
+
saveCustomPresets(next)
|
|
892
|
+
setActivePreset(null)
|
|
893
|
+
setNote(t.customDeleted)
|
|
894
|
+
log('custom preset deleted')
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function save() {
|
|
898
|
+
var flat = flatOf(cur)
|
|
899
|
+
var templates = {}
|
|
900
|
+
for (var i = 0; i < REASON_FIELDS.length; i++) {
|
|
901
|
+
var k = REASON_FIELDS[i]
|
|
902
|
+
templates[k] = flat['tpl-' + k]
|
|
903
|
+
}
|
|
904
|
+
setNote(t.saving)
|
|
905
|
+
var tasks = [
|
|
906
|
+
scope.set('language', flat.language),
|
|
907
|
+
scope.set('templates', templates),
|
|
908
|
+
scope.set('titleTemplate', flat.titleTemplate),
|
|
909
|
+
]
|
|
910
|
+
Promise.all(tasks).then(function () {
|
|
911
|
+
// 预设自动同步:
|
|
912
|
+
// - 来源是自定义预设(含未命名预设)→ 更新它;
|
|
913
|
+
// - 来源是默认/空白 → 【若已有未命名预设则继续编号新建】未命名预设 2/3…
|
|
914
|
+
var lang = LANG_IDS.indexOf(cur.language) >= 0 ? cur.language : 'zh'
|
|
915
|
+
var targetId
|
|
916
|
+
var entry
|
|
917
|
+
var list
|
|
918
|
+
var found = null
|
|
919
|
+
if (shownCustom && shownEntry) {
|
|
920
|
+
targetId = shownEntry.id
|
|
921
|
+
entry = Object.assign({}, shownEntry, { language: lang, titleTemplate: flat.titleTemplate, templates: templates })
|
|
922
|
+
list = customPresets.map(function (cp) {
|
|
923
|
+
if (cp.id === targetId) { found = Object.assign({}, cp, entry); return found }
|
|
924
|
+
return cp
|
|
925
|
+
})
|
|
926
|
+
if (!found) { list = list.concat([entry]); found = entry }
|
|
927
|
+
} else {
|
|
928
|
+
var n = nextUnnamedNumber(customPresets)
|
|
929
|
+
targetId = n === 1 ? 'unnamed' : 'unnamed-' + n
|
|
930
|
+
entry = { id: targetId, name: null, auto: true, language: lang, titleTemplate: flat.titleTemplate, templates: templates }
|
|
931
|
+
list = customPresets.concat([entry])
|
|
932
|
+
found = entry
|
|
933
|
+
}
|
|
934
|
+
setCustomPresets(list)
|
|
935
|
+
saveCustomPresets(list)
|
|
936
|
+
setActivePreset({ id: targetId, custom: true, src: found })
|
|
937
|
+
setDraft(null)
|
|
938
|
+
setNote(t.saved)
|
|
939
|
+
setJustSaved(true)
|
|
940
|
+
log('settings saved; preset synced: ' + targetId)
|
|
941
|
+
}).catch(function (e) {
|
|
942
|
+
setNote(t.saveFailed + (e && e.message ? e.message : e))
|
|
943
|
+
log('settings save failed: ' + (e && e.message ? e.message : e))
|
|
944
|
+
})
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
function discard() {
|
|
948
|
+
setDraft(null)
|
|
949
|
+
setNote('')
|
|
950
|
+
setJustSaved(false)
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
var body = null
|
|
954
|
+
if (!ready) {
|
|
955
|
+
body = h('div', { style: { padding: '12px 0', color: SUBTLE, fontSize: '13px' } }, snap.status === 'unavailable' ? t.unavailable : t.loading)
|
|
956
|
+
} else {
|
|
957
|
+
var rows = [
|
|
958
|
+
h('div', {
|
|
959
|
+
key: 'preset-row',
|
|
960
|
+
style: { display: 'flex', flexDirection: 'column', gap: '6px', padding: '12px 0', borderTop: DIVIDER },
|
|
961
|
+
},
|
|
962
|
+
h('div', { style: { fontSize: '13px', color: 'var(--dsw-alias-label-secondary, inherit)' } }, t.preset),
|
|
963
|
+
h('div', { style: { display: 'flex', gap: '6px' } },
|
|
964
|
+
h('select', {
|
|
965
|
+
value: shownPresetId,
|
|
966
|
+
onChange: function (e) {
|
|
967
|
+
var v = e.target.value
|
|
968
|
+
if (v === 'custom') return // 仅显示兜底,不可选
|
|
969
|
+
var builtin = null
|
|
970
|
+
var custom = null
|
|
971
|
+
for (var i = 0; i < PRESETS.length; i++) if (PRESETS[i].id === v) builtin = PRESETS[i]
|
|
972
|
+
for (var j = 0; j < customPresets.length; j++) if (customPresets[j].id === v) custom = customPresets[j]
|
|
973
|
+
if (builtin) loadPreset(builtin, false)
|
|
974
|
+
else if (custom) loadPreset(custom, true)
|
|
975
|
+
},
|
|
976
|
+
style: SELECT_STYLE,
|
|
977
|
+
},
|
|
978
|
+
PRESETS.map(function (p) {
|
|
979
|
+
return darkOption({ value: p.id, key: p.id }, t.presets[p.labelKey] || p.labelKey)
|
|
980
|
+
}),
|
|
981
|
+
customPresets.map(function (cp) {
|
|
982
|
+
return darkOption({ value: cp.id, key: cp.id }, isUnnamedId(cp.id) ? unnamedDisplayName(cp.id, t) : cp.name)
|
|
983
|
+
})),
|
|
984
|
+
h('button', {
|
|
985
|
+
type: 'button', title: t.saveAsTitle, onClick: function () { setNaming(!naming); setName('') },
|
|
986
|
+
style: { flex: 'none', appearance: 'none', font: 'inherit', fontSize: '12px', cursor: 'pointer', padding: '3px 10px', borderRadius: '999px', border: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.35))', background: 'none', color: 'var(--dsw-alias-label-secondary, inherit)' },
|
|
987
|
+
}, t.saveAs),
|
|
988
|
+
shownCustom
|
|
989
|
+
? h('button', {
|
|
990
|
+
type: 'button', title: t.removeTitle, onClick: deleteCustom,
|
|
991
|
+
style: { flex: 'none', appearance: 'none', font: 'inherit', fontSize: '12px', cursor: 'pointer', padding: '3px 10px', borderRadius: '999px', border: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.35))', background: 'none', color: 'var(--dsw-alias-label-secondary, inherit)' },
|
|
992
|
+
}, t.remove)
|
|
993
|
+
: null,
|
|
994
|
+
),
|
|
995
|
+
naming
|
|
996
|
+
? h('div', { style: { display: 'flex', gap: '6px', marginTop: '2px' } },
|
|
997
|
+
h('input', {
|
|
998
|
+
value: newName,
|
|
999
|
+
placeholder: t.presetNamePlaceholder,
|
|
1000
|
+
autoFocus: true,
|
|
1001
|
+
onKeyDown: function (e) { if (e.key === 'Enter') saveAsCustom(); if (e.key === 'Escape') { setNaming(false); setName('') } },
|
|
1002
|
+
onChange: function (e) { setName(e.target.value) },
|
|
1003
|
+
style: Object.assign({}, SELECT_STYLE, { flex: 1, fontSize: '13px' }),
|
|
1004
|
+
}),
|
|
1005
|
+
h('button', { type: 'button', onClick: saveAsCustom, style: { flex: 'none', appearance: 'none', font: 'inherit', fontSize: '12px', cursor: 'pointer', padding: '5px 12px', borderRadius: '999px', border: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.35))', background: 'none', color: 'var(--dsw-alias-label-secondary, inherit)' } }, t.confirm),
|
|
1006
|
+
h('button', { type: 'button', onClick: function () { setNaming(false); setName('') }, style: { flex: 'none', appearance: 'none', font: 'inherit', fontSize: '12px', cursor: 'pointer', padding: '5px 12px', borderRadius: '999px', border: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.35))', background: 'none', color: 'var(--dsw-alias-label-secondary, inherit)' } }, t.cancel),
|
|
1007
|
+
)
|
|
1008
|
+
: null,
|
|
1009
|
+
),
|
|
1010
|
+
fieldRow(t.language, h('select', {
|
|
1011
|
+
value: LANG_IDS.indexOf(cur.language) >= 0 ? cur.language : 'zh',
|
|
1012
|
+
onChange: function (e) { setVal('language', e.target.value) }, style: SELECT_STYLE,
|
|
1013
|
+
}, LANG_OPTIONS.map(function (o) {
|
|
1014
|
+
return darkOption({ value: o.id, key: o.id }, o.label)
|
|
1015
|
+
})), 'language'),
|
|
1016
|
+
h('div', { key: 'title-row', style: { display: 'flex', flexDirection: 'column', gap: '6px', padding: '12px 0', borderTop: DIVIDER } },
|
|
1017
|
+
h('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' } },
|
|
1018
|
+
h('div', { style: { fontSize: '13px', color: 'var(--dsw-alias-label-secondary, inherit)' } }, t.titleLabel),
|
|
1019
|
+
h('button', {
|
|
1020
|
+
type: 'button', title: t.insertTitle,
|
|
1021
|
+
onClick: function () { setMenuFor(menuFor === 'title' ? null : 'title') },
|
|
1022
|
+
style: { appearance: 'none', font: 'inherit', fontSize: '12px', cursor: 'pointer', padding: '3px 10px', borderRadius: '999px', border: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.35))', background: 'none', color: 'var(--dsw-alias-label-secondary, inherit)' },
|
|
1023
|
+
}, t.insertInfo),
|
|
1024
|
+
),
|
|
1025
|
+
h(ChipEditor, {
|
|
1026
|
+
template: String(cur.titleTemplate ?? ''),
|
|
1027
|
+
lang: cur.language,
|
|
1028
|
+
hint: '',
|
|
1029
|
+
placeholder: t.titleHint,
|
|
1030
|
+
onChange: function (tpl) { setVal('titleTemplate', tpl) },
|
|
1031
|
+
onReady: function (handle) {
|
|
1032
|
+
if (handle) editorRefs.current.title = handle
|
|
1033
|
+
else delete editorRefs.current.title
|
|
1034
|
+
},
|
|
1035
|
+
}),
|
|
1036
|
+
menuFor === 'title'
|
|
1037
|
+
? h('div', { key: 'menu-title', style: { display: 'flex', flexWrap: 'wrap', gap: '6px', padding: '8px', borderRadius: '8px', border: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.35))', background: 'var(--dsw-alias-bg-layer-2, rgba(127,127,127,0.10))' } },
|
|
1038
|
+
h('button', {
|
|
1039
|
+
type: 'button', onClick: function () {
|
|
1040
|
+
var handle = editorRefs.current.title
|
|
1041
|
+
if (handle) handle.insertAtCaret('title')
|
|
1042
|
+
else setVal('titleTemplate', String(cur.titleTemplate ?? '') + '{title}')
|
|
1043
|
+
setMenuFor(null)
|
|
1044
|
+
},
|
|
1045
|
+
style: { appearance: 'none', font: 'inherit', fontSize: '12px', cursor: 'pointer', padding: '4px 10px', borderRadius: '999px', border: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.35))', background: 'none', color: 'var(--dsw-alias-label-secondary, inherit)' },
|
|
1046
|
+
}, '+ ' + t.tokens.title)
|
|
1047
|
+
)
|
|
1048
|
+
: null,
|
|
1049
|
+
),
|
|
1050
|
+
]
|
|
1051
|
+
// 模板(forEach:每条回调独立绑定字段,避免 var 闭包串写)
|
|
1052
|
+
// 每栏 = 标签行(左文案 + 右「+插入信息」)+ ChipEditor(文字+内联胶囊,光标处插入)
|
|
1053
|
+
// + 实时预览(正文普通文本、信息为示例值)
|
|
1054
|
+
REASON_FIELDS.forEach(function (field) {
|
|
1055
|
+
var tplValue = templateOf(cur, field)
|
|
1056
|
+
// 预览:空模板时展示默认内容(与编辑器一致);否则展示当前模板
|
|
1057
|
+
var previewTpl = tplValue === '' ? defaultHintOf(field, cur.language) : tplValue
|
|
1058
|
+
var segments = parseTemplate(previewTpl)
|
|
1059
|
+
rows.push(h('div', {
|
|
1060
|
+
key: 'tpl-' + field,
|
|
1061
|
+
style: { display: 'flex', flexDirection: 'column', gap: '6px', padding: '12px 0', borderTop: DIVIDER },
|
|
1062
|
+
},
|
|
1063
|
+
h('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' } },
|
|
1064
|
+
h('div', { style: { fontSize: '13px', color: 'var(--dsw-alias-label-secondary, inherit)' } }, t.fields[field]),
|
|
1065
|
+
h('button', {
|
|
1066
|
+
type: 'button',
|
|
1067
|
+
title: t.insertTitle,
|
|
1068
|
+
onClick: function () { setMenuFor(menuFor === field ? null : field) },
|
|
1069
|
+
style: {
|
|
1070
|
+
appearance: 'none', font: 'inherit', fontSize: '12px', cursor: 'pointer', padding: '3px 10px',
|
|
1071
|
+
borderRadius: '999px', border: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.35))',
|
|
1072
|
+
background: 'none', color: 'var(--dsw-alias-label-secondary, inherit)',
|
|
1073
|
+
},
|
|
1074
|
+
}, t.insertInfo),
|
|
1075
|
+
),
|
|
1076
|
+
h(ChipEditor, {
|
|
1077
|
+
template: tplValue,
|
|
1078
|
+
lang: cur.language,
|
|
1079
|
+
hint: defaultHintOf(field, cur.language),
|
|
1080
|
+
onChange: function (tpl) { setVal('tpl-' + field, tpl) },
|
|
1081
|
+
onReady: function (handle) {
|
|
1082
|
+
if (handle) editorRefs.current[field] = handle
|
|
1083
|
+
else delete editorRefs.current[field]
|
|
1084
|
+
},
|
|
1085
|
+
}),
|
|
1086
|
+
menuFor === field
|
|
1087
|
+
? h('div', {
|
|
1088
|
+
key: 'menu-' + field,
|
|
1089
|
+
style: {
|
|
1090
|
+
display: 'flex', flexWrap: 'wrap', gap: '6px', padding: '8px',
|
|
1091
|
+
borderRadius: '8px', border: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.35))',
|
|
1092
|
+
background: 'var(--dsw-alias-bg-layer-2, rgba(127,127,127,0.10))',
|
|
1093
|
+
},
|
|
1094
|
+
}, TOKEN_KEYS.map(function (k) {
|
|
1095
|
+
return h('button', {
|
|
1096
|
+
key: k, type: 'button',
|
|
1097
|
+
onClick: function () {
|
|
1098
|
+
var handle = editorRefs.current[field]
|
|
1099
|
+
if (handle) handle.insertAtCaret(k)
|
|
1100
|
+
else setVal('tpl-' + field, templateOf(cur, field) + '{' + k + '}')
|
|
1101
|
+
setMenuFor(null)
|
|
1102
|
+
},
|
|
1103
|
+
style: {
|
|
1104
|
+
appearance: 'none', font: 'inherit', fontSize: '12px', cursor: 'pointer', padding: '4px 10px',
|
|
1105
|
+
borderRadius: '999px', border: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.35))',
|
|
1106
|
+
background: 'none', color: 'var(--dsw-alias-label-secondary, inherit)',
|
|
1107
|
+
},
|
|
1108
|
+
}, '+ ' + t.tokens[k])
|
|
1109
|
+
}))
|
|
1110
|
+
: null,
|
|
1111
|
+
))
|
|
1112
|
+
rows.push(h('div', {
|
|
1113
|
+
key: 'pv-' + field,
|
|
1114
|
+
style: { display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: '4px', fontSize: '12px', color: 'var(--dsw-alias-label-tertiary, rgba(127,127,127,0.8))', marginTop: '-2px', lineHeight: 1.6 },
|
|
1115
|
+
}, h('span', { style: { opacity: 0.85 } }, t.preview), segments.map(function (seg, idx) {
|
|
1116
|
+
// 预览 = 最终效果:信息直接以示例值流入正文,不做任何标签样式
|
|
1117
|
+
if (seg.type === 'text') {
|
|
1118
|
+
return seg.value === '' ? null : h('span', { key: 'tv-' + field + '-' + idx }, seg.value)
|
|
1119
|
+
}
|
|
1120
|
+
return h('span', { key: 'tk-' + field + '-' + idx }, t.samples[seg.key] || '')
|
|
1121
|
+
})))
|
|
1122
|
+
})
|
|
1123
|
+
rows.push(h('div', {
|
|
1124
|
+
key: 'footer',
|
|
1125
|
+
style: { borderTop: DIVIDER, display: 'flex', justifyContent: 'flex-end', alignItems: 'center', gap: '8px', padding: '12px 0 4px' },
|
|
1126
|
+
},
|
|
1127
|
+
h('span', { role: 'status', style: { marginRight: 'auto', marginLeft: '10px', fontSize: '12px', color: SUBTLE } },
|
|
1128
|
+
note,
|
|
1129
|
+
justSaved
|
|
1130
|
+
? h('button', {
|
|
1131
|
+
type: 'button',
|
|
1132
|
+
onClick: function () { try { window.location.reload() } catch (e) { /* noop */ } },
|
|
1133
|
+
style: {
|
|
1134
|
+
appearance: 'none', font: 'inherit', fontSize: '12px', cursor: 'pointer', marginLeft: '8px',
|
|
1135
|
+
padding: '0', border: 'none', background: 'none',
|
|
1136
|
+
color: 'var(--dsw-alias-label-secondary, inherit)',
|
|
1137
|
+
textDecoration: 'underline', textUnderlineOffset: '2px',
|
|
1138
|
+
},
|
|
1139
|
+
}, t.refresh)
|
|
1140
|
+
: null,
|
|
1141
|
+
),
|
|
1142
|
+
h('button', {
|
|
1143
|
+
type: 'button', disabled: !dirty || note === t.saving, onClick: discard,
|
|
1144
|
+
style: Object.assign({}, GHOST, { opacity: dirty ? 1 : 0.4 }),
|
|
1145
|
+
}, t.discard),
|
|
1146
|
+
h('button', {
|
|
1147
|
+
type: 'button', disabled: !dirty || note === t.saving, onClick: save,
|
|
1148
|
+
style: Object.assign({}, PRIMARY, { opacity: dirty ? 1 : 0.4 }),
|
|
1149
|
+
}, t.save),
|
|
1150
|
+
))
|
|
1151
|
+
body = h('div', null, rows)
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
return h('div', {
|
|
1155
|
+
style: Object.assign({}, CARD, {
|
|
1156
|
+
background: open ? 'var(--dsw-alias-bg-layer-2, rgba(127,127,127,0.10))' : 'var(--dsw-alias-bg-layer-3, rgba(127,127,127,0.05))',
|
|
1157
|
+
}),
|
|
1158
|
+
},
|
|
1159
|
+
h('button', {
|
|
1160
|
+
type: 'button', 'aria-expanded': open,
|
|
1161
|
+
onClick: function () { setOpen(!open) },
|
|
1162
|
+
style: {
|
|
1163
|
+
appearance: 'none', width: '100%', font: 'inherit', color: 'inherit', textAlign: 'left', cursor: 'pointer',
|
|
1164
|
+
background: 'none', border: 0, borderRadius: '12px', display: 'flex', alignItems: 'center', gap: '12px', padding: '14px 16px',
|
|
1165
|
+
},
|
|
1166
|
+
},
|
|
1167
|
+
h('div', { style: { flex: 1, minWidth: 0 } },
|
|
1168
|
+
h('div', { style: { fontSize: '14px', fontWeight: 600 } }, t.title),
|
|
1169
|
+
h('div', { style: { color: SUBTLE, fontSize: '13px', lineHeight: 1.5 } }, t.subtitle),
|
|
1170
|
+
),
|
|
1171
|
+
chevron(open),
|
|
1172
|
+
),
|
|
1173
|
+
open ? h('div', { style: { margin: '0 16px', paddingBottom: '8px' } }, body) : null,
|
|
1174
|
+
)
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
return module.exports
|
|
1179
|
+
}
|
|
1180
|
+
})
|