@telosmaylx/dsh-session-notify 0.1.9 → 0.1.11

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 CHANGED
@@ -44,100 +44,120 @@ window.__ModuleLoader__.load({
44
44
  zh: {
45
45
  title: '会话完成提醒', subtitle: '完成推送的语言与文案模板(保存后刷新生效)',
46
46
  preset: '预设', choosePreset: '选择预设…', language: '语言',
47
- fields: { completed: '完成', error: '出错', aborted: '中止', blocked: '阻塞', 'max-tokens': '输出上限' },
48
- titleLabel: '推送标题', titleHint: '所有原因共用;留空时按原因用默认标题', titleTplLabel: '按原因定制标题(可选)', titleTplOpen: '展开', titleTplClose: '收起', ttPlaceholders: { completed: '任务已完成', error: '任务出错', aborted: '任务已中止', blocked: '任务被阻塞', 'max-tokens': '任务达到输出上限' },
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' },
47
+ fields: { completed: '完成', error: '出错', aborted: '中止', blocked: '阻塞', 'max-tokens': '上限' },
48
+ titlesGroup: '标题', titleLabel: '推送标题', titleHint: '通用推送标题,留空时则使用默认标题,优先级低于下方自定义标题', titleTplLabel: '按原因定制标题(可选)', tplLabel: '内容', ttPlaceholders: { completed: '任务已完成', error: '任务出错', aborted: '任务已中止', blocked: '任务被阻塞', 'max-tokens': '任务达到上限' },
49
+ kindLabels: { completed: '会话已完成', error: '会话出错', aborted: '会话已中止', blocked: '会话被阻塞', 'max-tokens': '会话达到上限' },
50
+ tokens: { title: '会话标题', label: '完成标签', duration: '用时', usage: '消耗', error: '错误', cache: '缓存命中', tps: '速度 TPS', image: '图片', icon: '图标' },
51
+ samples: { title: '示例会话', label: '会话已完成', duration: '12 秒', usage: '1,240 输入 / 3,560 输出', error: '连接超时', cache: '96.5%', tps: '92 tok/s', image: '[图片]', icon: '[图标]' },
52
+ imagePickTitle: '选择本地图片(自动压缩为通知大图)', iconPickTitle: '选择本地图片(自动压缩为通知图标)', imageFailed: '图片读取失败',
53
+ clearMedia: '清除',
54
+ mediaNote: '本地上传会自动处理:大图按通知显示比例 16:9 居中裁切(512×288)、图标 1:1 方形裁切(128×128),裁切后即通知中显示的效果',
52
55
  presets: { custom: '自定义预设', unnamed: '未命名预设', default: '默认', playful: '轻松风格 🎉', formal: '正式简洁', detail: '详细报告' },
53
- placeholder: '自定义文案:点「+ 插入信息」,或直接输入文字',
54
- insertInfo: '+ 插入信息', insertTitle: '在光标处插入信息', sendTest: '发送', sendTestTitle: '发送测试通知(当前模板渲染)', preview: '预览:',
56
+ placeholder: '留空则使用默认文案',
57
+ insertInfo: '+ 插入信息', insertTitle: '在光标处插入信息', sendTestTitle: '发送测试通知(当前模板渲染)',
55
58
  skipSubagents: '跳过子代理会话',
56
59
  pushMode: '推送方式', pushDual: '系统通知 + 页内提示', pushSystem: '仅系统通知', pushToast: '仅页内提示',
60
+ imageUrl: '通知大图', iconUrl: '通知图标',
57
61
  discard: '放弃', save: '保存', saving: '保存中…', saved: '已保存 ✓ · 刷新页面后生效', saveFailed: '保存失败:', refresh: '点击刷新', reset: '重置', resetDone: '已重置为默认值 · 刷新页面后生效',
58
62
  permTitle: '通知权限', permGranted: '已授权:系统通知可用', permDefault: '尚未授权', permDenied: '已被浏览器屏蔽', permUnsupported: '当前环境不支持系统通知',
59
63
  permDefaultHint: '点击「请求授权」;若浏览器未弹出询问,请用地址栏权限图标手动允许', permDeniedHint: '浏览器阻止了本网站的通知:点击地址栏左侧权限图标 → 网站设置 → 通知 → 允许,然后刷新页面', permUnsupportedHint: '系统通知需要 HTTPS 或 localhost 访问;可改用「仅页内提示」', permRequest: '请求授权',
60
- saveAs: '新增', update: '修改', remove: '删除', presetNamePlaceholder: '预设名称', confirm: '确定', cancel: '取消', customSaved: '自定义预设已保存', customUpdated: '自定义预设已更新', customDeleted: '自定义预设已删除', saveAsTitle: '将当前配置另存为自定义预设', updateTitle: '更新当前自定义预设', removeTitle: '删除当前自定义预设', fromPreset: '来自:', modifiedSuffix: ' · 已修改',
64
+ saveAs: '新增', rename: '重命名', update: '修改', remove: '删除', presetNamePlaceholder: '预设名称', confirm: '确定', cancel: '取消', customSaved: '自定义预设已保存', customUpdated: '自定义预设已更新', customDeleted: '自定义预设已删除', saveAsTitle: '将当前配置另存为自定义预设', renameTitle: '重命名当前自定义预设', updateTitle: '更新当前自定义预设', removeTitle: '删除当前自定义预设', fromPreset: '来自:', modifiedSuffix: ' · 已修改',
61
65
  presetNameRequired: '请输入预设名称',
62
66
  loading: '加载中…', unavailable: '设置不可用(宿主未注册命名空间)',
63
67
  },
64
68
  'zh-tw': {
65
69
  title: '會話完成提醒', subtitle: '完成推送的語言與文案模板(儲存後重新整理生效)',
66
70
  preset: '預設', choosePreset: '選擇預設…', language: '語言',
67
- fields: { completed: '完成', error: '出錯', aborted: '中止', blocked: '阻塞', 'max-tokens': '輸出上限' },
68
- titleLabel: '推送標題', titleHint: '所有原因共用;留空時按原因用預設標題', titleTplLabel: '按原因自訂標題(選填)', titleTplOpen: '展開', titleTplClose: '收起', ttPlaceholders: { completed: '任務已完成', error: '任務出錯', aborted: '任務已中止', blocked: '任務被阻塞', 'max-tokens': '任務達到輸出上限' },
69
- kindLabels: { completed: '會話已完成', error: '會話出錯', aborted: '會話已中止', blocked: '會話被阻塞', 'max-tokens': '會話達到輸出上限' },
70
- tokens: { title: '會話標題', label: '完成標籤', duration: '用時', usage: '消耗', error: '錯誤', cache: '快取命中', tps: '速度 TPS' },
71
- samples: { title: '範例會話', label: '會話已完成', duration: '12 秒', usage: '1,240 輸入 / 3,560 輸出', error: '連線逾時', cache: '96.5%', tps: '92 tok/s' },
71
+ fields: { completed: '完成', error: '出錯', aborted: '中止', blocked: '阻塞', 'max-tokens': '上限' },
72
+ titlesGroup: '標題', titleLabel: '推送標題', titleHint: '通用推送標題,留空時則使用預設標題,優先級低於下方自訂標題', titleTplLabel: '按原因自訂標題(選填)', tplLabel: '內容', ttPlaceholders: { completed: '任務已完成', error: '任務出錯', aborted: '任務已中止', blocked: '任務被阻塞', 'max-tokens': '任務達到上限' },
73
+ kindLabels: { completed: '會話已完成', error: '會話出錯', aborted: '會話已中止', blocked: '會話被阻塞', 'max-tokens': '會話達到上限' },
74
+ tokens: { title: '會話標題', label: '完成標籤', duration: '用時', usage: '消耗', error: '錯誤', cache: '快取命中', tps: '速度 TPS', image: '圖片', icon: '圖示' },
75
+ samples: { title: '範例會話', label: '會話已完成', duration: '12 秒', usage: '1,240 輸入 / 3,560 輸出', error: '連線逾時', cache: '96.5%', tps: '92 tok/s', image: '[圖片]', icon: '[圖示]' },
76
+ imagePickTitle: '選擇本機圖片(自動壓縮為通知大圖)', iconPickTitle: '選擇本機圖片(自動壓縮為通知圖示)', imageFailed: '圖片讀取失敗',
77
+ clearMedia: '清除',
78
+ mediaNote: '本機上傳會自動處理:大圖按通知顯示比例 16:9 居中裁切(512×288)、圖示 1:1 方形裁切(128×128),裁切後即通知中顯示的效果',
72
79
  presets: { custom: '自訂預設', unnamed: '未命名預設', default: '預設', playful: '輕鬆風格 🎉', formal: '正式簡潔', detail: '詳細報告' },
73
- placeholder: '自訂文案:點「+ 插入資訊」,或直接輸入文字',
74
- insertInfo: '+ 插入資訊', insertTitle: '在游標處插入資訊', sendTest: '傳送', sendTestTitle: '傳送測試通知(目前範本渲染)', preview: '預覽:',
80
+ placeholder: '留空則使用預設文案',
81
+ insertInfo: '+ 插入資訊', insertTitle: '在游標處插入資訊', sendTestTitle: '傳送測試通知(目前範本渲染)',
75
82
  skipSubagents: '跳過子代理會話',
76
83
  pushMode: '推送方式', pushDual: '系統通知 + 頁內提示', pushSystem: '僅系統通知', pushToast: '僅頁內提示',
84
+ imageUrl: '通知大圖', iconUrl: '通知圖示',
77
85
  discard: '放棄', save: '儲存', saving: '儲存中…', saved: '已儲存 ✓ · 重新整理頁面後生效', saveFailed: '儲存失敗:', refresh: '點擊重新整理', reset: '重置', resetDone: '已重置為預設值 · 重新整理頁面後生效',
78
86
  permTitle: '通知權限', permGranted: '已授權:系統通知可用', permDefault: '尚未授權', permDenied: '已被瀏覽器封鎖', permUnsupported: '目前環境不支援系統通知',
79
87
  permDefaultHint: '點擊「請求授權」;若瀏覽器未彈出詢問,請用網址列權限圖示手動允許', permDeniedHint: '瀏覽器封鎖了本網站的通知:點擊網址列左側權限圖示 → 網站設定 → 通知 → 允許,然後重新整理頁面', permUnsupportedHint: '系統通知需要 HTTPS 或 localhost 存取;可改用「僅頁內提示」', permRequest: '請求授權',
80
- saveAs: '新增', update: '修改', remove: '刪除', presetNamePlaceholder: '預設名稱', confirm: '確定', cancel: '取消', customSaved: '自訂預設已儲存', customUpdated: '自訂預設已更新', customDeleted: '自訂預設已刪除', saveAsTitle: '將當前設定另存為自訂預設', updateTitle: '更新目前自訂預設', removeTitle: '刪除目前自訂預設', fromPreset: '來自:', modifiedSuffix: ' · 已修改',
88
+ saveAs: '新增', rename: '重新命名', update: '修改', remove: '刪除', presetNamePlaceholder: '預設名稱', confirm: '確定', cancel: '取消', customSaved: '自訂預設已儲存', customUpdated: '自訂預設已更新', customDeleted: '自訂預設已刪除', saveAsTitle: '將當前設定另存為自訂預設', renameTitle: '重新命名目前自訂預設', updateTitle: '更新目前自訂預設', removeTitle: '刪除目前自訂預設', fromPreset: '來自:', modifiedSuffix: ' · 已修改',
81
89
  presetNameRequired: '請輸入預設名稱',
82
90
  loading: '載入中…', unavailable: '設定不可用(宿主未註冊命名空間)',
83
91
  },
84
92
  en: {
85
93
  title: 'Session completion alert', subtitle: 'Language & message templates for completion pushes (refresh after save)',
86
94
  preset: 'Preset', choosePreset: 'Choose preset…', language: 'Language',
87
- fields: { completed: 'Completed', error: 'Error', aborted: 'Aborted', blocked: 'Blocked', 'max-tokens': 'Token cap' },
88
- titleLabel: 'Push title', titleHint: 'Shared by all reasons; leave empty for per-reason defaults', titleTplLabel: 'Per-reason titles (optional)', titleTplOpen: 'Expand', titleTplClose: 'Collapse', ttPlaceholders: { completed: 'Task completed', error: 'Task failed', aborted: 'Task aborted', blocked: 'Task blocked', 'max-tokens': 'Task hit the output cap' },
89
- kindLabels: { completed: 'Session completed', error: 'Session failed', aborted: 'Session aborted', blocked: 'Session blocked', 'max-tokens': 'Session hit the output-token cap' },
90
- tokens: { title: 'Session title', label: 'Label', duration: 'Duration', usage: 'Usage', error: 'Error', cache: 'Cache hit', tps: 'Speed TPS' },
91
- 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' },
95
+ fields: { completed: 'Completed', error: 'Error', aborted: 'Aborted', blocked: 'Blocked', 'max-tokens': 'Cap' },
96
+ titlesGroup: 'Title', titleLabel: 'Push title', titleHint: 'Generic push title; leave empty to use the default title (per-reason titles below take precedence)', titleTplLabel: 'Per-reason titles (optional)', tplLabel: 'Content', ttPlaceholders: { completed: 'Task completed', error: 'Task failed', aborted: 'Task aborted', blocked: 'Task blocked', 'max-tokens': 'Task hit the cap' },
97
+ kindLabels: { completed: 'Session completed', error: 'Session failed', aborted: 'Session aborted', blocked: 'Session blocked', 'max-tokens': 'Session hit the cap' },
98
+ tokens: { title: 'Session title', label: 'Label', duration: 'Duration', usage: 'Usage', error: 'Error', cache: 'Cache hit', tps: 'Speed TPS', image: 'Image', icon: 'Icon' },
99
+ 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', image: '[image]', icon: '[icon]' },
100
+ imagePickTitle: 'Pick a local image (auto-compressed for the notification)', iconPickTitle: 'Pick a local image (auto-compressed for the notification icon)', imageFailed: 'Failed to read the image',
101
+ clearMedia: 'Clear',
102
+ mediaNote: 'Local uploads are processed automatically: large images are center-cropped to the notification ratio 16:9 (512×288) and icons to 1:1 square (128×128) — the cropped result is exactly what shows in the notification',
92
103
  presets: { custom: 'Custom preset', unnamed: 'Untitled preset', default: 'Default', playful: 'Playful 🎉', formal: 'Formal', detail: 'Detailed report' },
93
- placeholder: 'Custom text: use “+ Insert info”, or type directly',
94
- insertInfo: '+ Insert info', insertTitle: 'Insert info at cursor', sendTest: 'Send', sendTestTitle: 'Send test notification (rendered from current template)', preview: 'Preview: ',
104
+ placeholder: 'Leave empty to use the default text',
105
+ insertInfo: '+ Insert info', insertTitle: 'Insert info at cursor', sendTestTitle: 'Send test notification (rendered from current template)',
95
106
  skipSubagents: 'Skip subagent sessions',
96
107
  pushMode: 'Push channel', pushDual: 'System + in-page', pushSystem: 'System only', pushToast: 'In-page only',
108
+ imageUrl: 'Notification image', iconUrl: 'Notification icon',
97
109
  discard: 'Discard', save: 'Save', saving: 'Saving…', saved: 'Saved ✓ · refresh page to apply', saveFailed: 'Save failed: ', refresh: 'Refresh', reset: 'Reset', resetDone: 'Reset to defaults ✓ · refresh page to apply',
98
110
  permTitle: 'Notification permission', permGranted: 'Granted: system notifications available', permDefault: 'Not yet granted', permDenied: 'Blocked by browser', permUnsupported: 'System notifications not supported here',
99
111
  permDefaultHint: 'Click "Request"; if the browser shows no prompt, allow via the address-bar permissions icon', permDeniedHint: 'This browser blocked notifications for this site: click the address-bar permissions icon → Site settings → Notifications → Allow, then reload', permUnsupportedHint: 'System notifications require HTTPS or localhost; use "In-page only" instead', permRequest: 'Request',
100
- 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',
112
+ saveAs: 'New', rename: 'Rename', 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', renameTitle: 'Rename current custom preset', updateTitle: 'Update current custom preset', removeTitle: 'Delete current custom preset', fromPreset: 'From: ', modifiedSuffix: ' · modified',
101
113
  presetNameRequired: 'Please enter a preset name',
102
114
  loading: 'Loading…', unavailable: 'Unavailable (namespace not registered on host)',
103
115
  },
104
116
  ja: {
105
117
  title: 'セッション完了通知', subtitle: '完了プッシュの言語とメッセージテンプレート(保存後に再読み込みで反映)',
106
118
  preset: 'プリセット', choosePreset: 'プリセットを選択…', language: '言語',
107
- fields: { completed: '完了', error: 'エラー', aborted: '中止', blocked: 'ブロック', 'max-tokens': 'トークン上限' },
108
- titleLabel: 'プッシュタイトル', titleHint: '全理由共通;空欄なら理由ごとのデフォルトを使用', titleTplLabel: '理由ごとのタイトル(任意)', titleTplOpen: '展開', titleTplClose: '折りたたむ', ttPlaceholders: { completed: 'タスク完了', error: 'タスクエラー', aborted: 'タスク中止', blocked: 'タスクがブロックされました', 'max-tokens': '出力上限に到達' },
109
- kindLabels: { completed: 'セッション完了', error: 'セッションエラー', aborted: 'セッション中止', blocked: 'セッションがブロックされました', 'max-tokens': '出力トークン上限に到達' },
110
- tokens: { title: 'セッションタイトル', label: '完了ラベル', duration: '所要時間', usage: '消費', error: 'エラー', cache: 'キャッシュヒット', tps: '速度 TPS' },
111
- samples: { title: 'サンプルセッション', label: 'セッション完了', duration: '12 秒', usage: '1,240 入力 / 3,560 出力', error: '接続タイムアウト', cache: '96.5%', tps: '92 tok/s' },
119
+ fields: { completed: '完了', error: 'エラー', aborted: '中止', blocked: 'ブロック', 'max-tokens': '上限' },
120
+ titlesGroup: 'タイトル', titleLabel: 'プッシュタイトル', titleHint: '共通のプッシュタイトル。空欄の場合はデフォルトタイトルを使用(下の理由別タイトルが優先)', titleTplLabel: '理由ごとのタイトル(任意)', tplLabel: 'コンテンツ', ttPlaceholders: { completed: 'タスク完了', error: 'タスクエラー', aborted: 'タスク中止', blocked: 'タスクがブロックされました', 'max-tokens': '上限に到達' },
121
+ kindLabels: { completed: 'セッション完了', error: 'セッションエラー', aborted: 'セッション中止', blocked: 'セッションがブロックされました', 'max-tokens': '上限に到達' },
122
+ tokens: { title: 'セッションタイトル', label: '完了ラベル', duration: '所要時間', usage: '消費', error: 'エラー', cache: 'キャッシュヒット', tps: '速度 TPS', image: '画像', icon: 'アイコン' },
123
+ samples: { title: 'サンプルセッション', label: 'セッション完了', duration: '12 秒', usage: '1,240 入力 / 3,560 出力', error: '接続タイムアウト', cache: '96.5%', tps: '92 tok/s', image: '[画像]', icon: '[アイコン]' },
124
+ imagePickTitle: 'ローカル画像を選択(通知用に自動圧縮)', iconPickTitle: 'ローカル画像を選択(通知アイコン用に自動圧縮)', imageFailed: '画像の読み込みに失敗しました',
125
+ clearMedia: 'クリア',
126
+ mediaNote: 'ローカルアップロードは自動処理されます:大きな画像は通知表示比率 16:9(512×288)に中央クロップ、アイコンは 1:1 正方形(128×128)。クロップ後の見た目がそのまま通知に表示されます',
112
127
  presets: { custom: 'カスタムプリセット', unnamed: '未命名プリセット', default: 'デフォルト', playful: 'カジュアル 🎉', formal: 'フォーマル', detail: '詳細レポート' },
113
- placeholder: 'カスタム文面:「+ 情報を挿入」または直接入力',
114
- insertInfo: '+ 情報を挿入', insertTitle: 'カーソル位置に情報を挿入', sendTest: '送信', sendTestTitle: 'テスト通知を送信(現在のテンプレートで描画)', preview: 'プレビュー:',
128
+ placeholder: '空欄の場合はデフォルトの文面を使用',
129
+ insertInfo: '+ 情報を挿入', insertTitle: 'カーソル位置に情報を挿入', sendTestTitle: 'テスト通知を送信(現在のテンプレートで描画)',
115
130
  skipSubagents: 'サブエージェントセッションをスキップ',
116
131
  pushMode: '通知チャネル', pushDual: 'システム+ページ内', pushSystem: 'システムのみ', pushToast: 'ページ内のみ',
132
+ imageUrl: '通知イメージ', iconUrl: '通知アイコン',
117
133
  discard: '破棄', save: '保存', saving: '保存中…', saved: '保存しました ✓ · ページを再読み込み', saveFailed: '保存失敗:', refresh: '再読み込み', reset: 'リセット', resetDone: 'デフォルトに戻しました ✓ · ページを再読み込み',
118
134
  permTitle: '通知権限', permGranted: '許可済み:システム通知が利用可能', permDefault: '未許可', permDenied: 'ブラウザがブロック', permUnsupported: 'この環境ではシステム通知を利用できません',
119
135
  permDefaultHint: '「許可をリクエスト」をクリック。ブラウザに確認が出ない場合は、アドレスバーの権限アイコンから手動で許可', permDeniedHint: 'このサイトの通知がブロックされています:アドレスバー左の権限アイコン → サイト設定 → 通知 → 許可 → ページを再読み込み', permUnsupportedHint: 'システム通知には HTTPS または localhost が必要です。「ページ内のみ」を推奨', permRequest: '許可をリクエスト',
120
- saveAs: '新規', update: '編集', remove: '削除', presetNamePlaceholder: 'プリセット名', confirm: '決定', cancel: 'キャンセル', customSaved: 'カスタムプリセットを保存しました', customUpdated: 'カスタムプリセットを更新しました', customDeleted: 'カスタムプリセットを削除しました', saveAsTitle: '現在の設定をカスタムプリセットとして保存', updateTitle: '現在のカスタムプリセットを更新', removeTitle: '現在のカスタムプリセットを削除', fromPreset: '由来:', modifiedSuffix: ' · 変更あり',
136
+ saveAs: '新規', rename: '名前を変更', update: '編集', remove: '削除', presetNamePlaceholder: 'プリセット名', confirm: '決定', cancel: 'キャンセル', customSaved: 'カスタムプリセットを保存しました', customUpdated: 'カスタムプリセットを更新しました', customDeleted: 'カスタムプリセットを削除しました', saveAsTitle: '現在の設定をカスタムプリセットとして保存', renameTitle: '現在のカスタムプリセットの名前を変更', updateTitle: '現在のカスタムプリセットを更新', removeTitle: '現在のカスタムプリセットを削除', fromPreset: '由来:', modifiedSuffix: ' · 変更あり',
121
137
  presetNameRequired: 'プリセット名を入力してください',
122
138
  loading: '読み込み中…', unavailable: '利用不可(ホストに名前空間未登録)',
123
139
  },
124
140
  ko: {
125
141
  title: '세션 완료 알림', subtitle: '완료 푸시의 언어 및 메시지 템플릿(저장 후 새로고침 시 적용)',
126
142
  preset: '프리셋', choosePreset: '프리셋 선택…', language: '언어',
127
- fields: { completed: '완료', error: '오류', aborted: '중단', blocked: '차단', 'max-tokens': '토큰 한도' },
128
- titleLabel: '푸시 제목', titleHint: '모든 이유 공통;비우면 이유별 기본값 사용', titleTplLabel: '이유별 제목 (선택)', titleTplOpen: '펼치기', titleTplClose: '접기', ttPlaceholders: { completed: '작업 완료', error: '작업 오류', aborted: '작업 중단됨', blocked: '작업 차단됨', 'max-tokens': '출력 한도 도달' },
129
- kindLabels: { completed: '세션 완료', error: '세션 오류', aborted: '세션 중단됨', blocked: '세션 차단됨', 'max-tokens': '출력 토큰 한도 도달' },
130
- tokens: { title: '세션 제목', label: '완료 라벨', duration: '소요 시간', usage: '소모', error: '오류', cache: '캐시 히트', tps: '속도 TPS' },
131
- samples: { title: '예시 세션', label: '세션 완료', duration: '12초', usage: '1,240 입력 / 3,560 출력', error: '연결 시간 초과', cache: '96.5%', tps: '92 tok/s' },
143
+ fields: { completed: '완료', error: '오류', aborted: '중단', blocked: '차단', 'max-tokens': '한도' },
144
+ titlesGroup: '제목', titleLabel: '푸시 제목', titleHint: '공통 푸시 제목. 비우면 기본 제목 사용(아래 사유별 제목이 우선)', titleTplLabel: '이유별 제목 (선택)', tplLabel: '콘텐츠', ttPlaceholders: { completed: '작업 완료', error: '작업 오류', aborted: '작업 중단됨', blocked: '작업 차단됨', 'max-tokens': '한도 도달' },
145
+ kindLabels: { completed: '세션 완료', error: '세션 오류', aborted: '세션 중단됨', blocked: '세션 차단됨', 'max-tokens': '한도 도달' },
146
+ tokens: { title: '세션 제목', label: '완료 라벨', duration: '소요 시간', usage: '소모', error: '오류', cache: '캐시 히트', tps: '속도 TPS', image: '이미지', icon: '아이콘' },
147
+ samples: { title: '예시 세션', label: '세션 완료', duration: '12초', usage: '1,240 입력 / 3,560 출력', error: '연결 시간 초과', cache: '96.5%', tps: '92 tok/s', image: '[이미지]', icon: '[아이콘]' },
148
+ imagePickTitle: '로컬 이미지 선택(알림용으로 자동 압축)', iconPickTitle: '로컬 이미지 선택(알림 아이콘용으로 자동 압축)', imageFailed: '이미지를 읽지 못했습니다',
149
+ clearMedia: '지우기',
150
+ mediaNote: '로컬 업로드는 자동 처리됩니다: 큰 이미지는 알림 표시 비율 16:9(512×288) 중앙 크롭, 아이콘은 1:1 정사각(128×128). 크롭 후 모습이 그대로 알림에 표시됩니다',
132
151
  presets: { custom: '사용자 지정 프리셋', unnamed: '이름 없는 프리셋', default: '기본', playful: '캐주얼 🎉', formal: '정식', detail: '상세 보고' },
133
- placeholder: '사용자 지정 문구: "+ 정보 삽입" 또는 직접 입력',
134
- insertInfo: '+ 정보 삽입', insertTitle: '커서 위치에 정보 삽입', sendTest: '보내기', sendTestTitle: '테스트 알림 보내기 (현재 템플릿으로 렌더링)', preview: '미리보기: ',
152
+ placeholder: '비우면 기본 문구 사용',
153
+ insertInfo: '+ 정보 삽입', insertTitle: '커서 위치에 정보 삽입', sendTestTitle: '테스트 알림 보내기 (현재 템플릿으로 렌더링)',
135
154
  skipSubagents: '하위 에이전트 세션 건너뛰기',
136
155
  pushMode: '알림 채널', pushDual: '시스템+페이지 내', pushSystem: '시스템만', pushToast: '페이지 내만',
137
- discard: '취소', save: '저장', saving: '저장 중…', saved: '저장됨 ✓ · 새로고침 후 적용', saveFailed: '저장 실패: ', refresh: '새로고침', reset: '초기화', resetDone: '기본값으로 초기화됨 ✓ · 새로고침 후 적용',
156
+ imageUrl: '알림 이미지', iconUrl: '알림 아이콘',
157
+ discard: '포기', save: '저장', saving: '저장 중…', saved: '저장됨 ✓ · 새로고침 후 적용', saveFailed: '저장 실패: ', refresh: '새로고침', reset: '초기화', resetDone: '기본값으로 초기화됨 ✓ · 새로고침 후 적용',
138
158
  permTitle: '알림 권한', permGranted: '허용됨: 시스템 알림 사용 가능', permDefault: '아직 허용되지 않음', permDenied: '브라우저에서 차단됨', permUnsupported: '이 환경에서는 시스템 알림을 사용할 수 없음',
139
159
  permDefaultHint: '「권한 요청」클릭. 브라우저에 확인이 없으면 주소 표시줄 권한 아이콘에서 수동 허용', permDeniedHint: '이 사이트의 알림이 차단되었습니다: 주소 표시줄 왼쪽 권한 아이콘 → 사이트 설정 → 알림 → 허용 후 새로고침', permUnsupportedHint: '시스템 알림은 HTTPS 또는 localhost가 필요합니다. 「페이지 내만」사용 권장', permRequest: '권한 요청',
140
- saveAs: '추가', update: '수정', remove: '삭제', presetNamePlaceholder: '프리셋 이름', confirm: '확인', cancel: '취소', customSaved: '사용자 지정 프리셋 저장됨', customUpdated: '사용자 지정 프리셋 업데이트됨', customDeleted: '사용자 지정 프리셋 삭제됨', saveAsTitle: '현재 구성을 사용자 지정 프리셋으로 저장', updateTitle: '현재 사용자 지정 프리셋 업데이트', removeTitle: '현재 사용자 지정 프리셋 삭제', fromPreset: '출처: ', modifiedSuffix: ' · 수정됨',
160
+ saveAs: '추가', rename: '이름 변경', update: '수정', remove: '삭제', presetNamePlaceholder: '프리셋 이름', confirm: '확인', cancel: '취소', customSaved: '사용자 지정 프리셋 저장됨', customUpdated: '사용자 지정 프리셋 업데이트됨', customDeleted: '사용자 지정 프리셋 삭제됨', saveAsTitle: '현재 구성을 사용자 지정 프리셋으로 저장', renameTitle: '현재 사용자 지정 프리셋 이름 변경', updateTitle: '현재 사용자 지정 프리셋 업데이트', removeTitle: '현재 사용자 지정 프리셋 삭제', fromPreset: '출처: ', modifiedSuffix: ' · 수정됨',
141
161
  presetNameRequired: '프리셋 이름을 입력하세요',
142
162
  loading: '불러오는 중…', unavailable: '사용 불가(호스트에 네임스페이스 미등록)',
143
163
  },
@@ -198,7 +218,7 @@ window.__ModuleLoader__.load({
198
218
  var proj = readNoticeAny(id)
199
219
  if (proj || Date.now() - start > 6000) {
200
220
  retryTimer = null
201
- notifyUser(pushTitle(name, proj ? proj.title : '', proj ? '' : ' 已完成'), (proj && proj.text) || (cwdLine ? cwdLine + ' · ' : '') + '详情见会话内系统消息', pushModeOf())
221
+ notifyUser(pushTitle(name, proj ? proj.title : '', proj ? '' : ' 已完成'), (proj && proj.text) || (cwdLine ? cwdLine + ' · ' : '') + '详情见会话内系统消息', pushModeOf(), mediaOf(proj ? proj.kind : ''))
202
222
  return
203
223
  }
204
224
  retryTimer = setTimeout(attempt, 400)
@@ -217,6 +237,27 @@ window.__ModuleLoader__.load({
217
237
  return 'dual'
218
238
  }
219
239
 
240
+ /** 通知媒体:iconUrl = 全局小图标 URL;iconData = 该原因上传的图标(icons[kind],data URI);
241
+ * imageData = 该原因上传的大图(images[kind],data URI);imageUrl = 全局大图 URL 兜底。
242
+ * kind 缺失(投影降级)时只用全局 URL。 */
243
+ function mediaOf(kind) {
244
+ var out = { imageUrl: '', iconUrl: '', imageData: '', iconData: '' }
245
+ try {
246
+ if (notifyScope) {
247
+ var st = notifyScope.getSnapshot()
248
+ if (st && st.value) {
249
+ out.imageUrl = String(st.value.imageUrl || '')
250
+ out.iconUrl = String(st.value.iconUrl || '')
251
+ var imgs = st.value.images
252
+ if (kind && imgs && typeof imgs[kind] === 'string') out.imageData = imgs[kind]
253
+ var ics = st.value.icons
254
+ if (kind && ics && typeof ics[kind] === 'string') out.iconData = ics[kind]
255
+ }
256
+ }
257
+ } catch (e) { /* 默认 */ }
258
+ return out
259
+ }
260
+
220
261
  /**
221
262
  * 推送标题:优先级——
222
263
  * 1. 宿主投影渲染好的该原因标题(projTitle,含 {title} 替换);
@@ -378,13 +419,20 @@ window.__ModuleLoader__.load({
378
419
  * - toast:仅页内 toast(Chromium 壳浏览器推不了 Windows 通知时用——不再调用 Notification API,
379
420
  * 浏览器横幅/气泡不会出现,页内只有插件自己的小 toast)。
380
421
  */
381
- function notifyUser(title, body, pushMode) {
422
+ function notifyUser(title, body, pushMode, media) {
382
423
  var mode = pushMode === 'system' || pushMode === 'toast' ? pushMode : 'dual'
383
424
  try {
384
425
  if (mode !== 'toast' && typeof Notification !== 'undefined') {
385
426
  if (Notification.permission === 'granted') {
386
- // 每次完成事件的独立 tag:不与前一次互相替换,也不被折叠成一个分组条目
387
- var n = new Notification(title, { body: body, tag: PLUGIN_TAG + ':' + Date.now(), silent: false })
427
+ // 每次完成事件的独立 tag:不与前一次互相替换,也不被折叠成一个分组条目;
428
+ // 自定义媒体:icon = 通知小图标(按原因 iconData 优先,其次全局 iconUrl;空 = 站点默认),
429
+ // image = 通知卡片中的大图(按原因 imageData 优先,其次全局 imageUrl)
430
+ var opts = { body: body, tag: PLUGIN_TAG + ':' + Date.now(), silent: false }
431
+ var mediaIcon = media ? (media.iconData || media.iconUrl || '') : ''
432
+ if (mediaIcon) opts.icon = mediaIcon
433
+ var mediaImg = media ? (media.imageData || media.imageUrl || '') : ''
434
+ if (mediaImg) opts.image = mediaImg
435
+ var n = new Notification(title, opts)
388
436
  try {
389
437
  n.onclick = function () { window.focus(); n.close() }
390
438
  } catch (e) { /* noop */ }
@@ -492,6 +540,7 @@ window.__ModuleLoader__.load({
492
540
  appearance: 'none', width: '100%', padding: '8px 12px', borderRadius: '8px',
493
541
  border: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.35))',
494
542
  background: 'transparent', color: 'inherit', font: 'inherit', fontSize: '13px',
543
+ outline: 'none', // 去掉聚焦白框/选中态描边
495
544
  // 关键:声明深色 color-scheme——Chromium 的原生下拉弹层按它渲染;
496
545
  // 否则弹层保持白底而文字继承深色主题的浅色 → 白底白字不可见。
497
546
  colorScheme: 'dark',
@@ -528,11 +577,20 @@ window.__ModuleLoader__.load({
528
577
  ]
529
578
 
530
579
  /** 重置目标:与宿主 DEFAULT_SETTINGS 一致(设置面板可编辑字段子集;语言保留当前值)。 */
580
+ var DEFAULT_IMAGES = { completed: '', error: '', aborted: '', blocked: '', 'max-tokens': '' }
531
581
  var DEFAULT_FLAT = {
532
582
  language: 'zh',
533
583
  titleTemplate: '',
534
584
  titleTemplates: { completed: '', error: '', aborted: '', blocked: '', 'max-tokens': '' },
535
585
  pushMode: 'dual',
586
+ imageUrl: '',
587
+ iconUrl: '',
588
+ imagePreviewUrl: '',
589
+ iconPreviewUrl: '',
590
+ images: { completed: '', error: '', aborted: '', blocked: '', 'max-tokens': '' },
591
+ icons: { completed: '', error: '', aborted: '', blocked: '', 'max-tokens': '' },
592
+ imagePreviews: { completed: '', error: '', aborted: '', blocked: '', 'max-tokens': '' },
593
+ iconPreviews: { completed: '', error: '', aborted: '', blocked: '', 'max-tokens': '' },
536
594
  templates: { completed: '', error: '', aborted: '', blocked: '', 'max-tokens': '' },
537
595
  }
538
596
 
@@ -597,7 +655,7 @@ window.__ModuleLoader__.load({
597
655
  /** 把模板拆成 [text|token] 片段(token = {label|duration|usage|error|cache|tps})。 */
598
656
  function parseTemplate(tpl) {
599
657
  var parts = []
600
- var re = /\{(title|label|duration|usage|error|cache|tps)\}/g
658
+ var re = /\{(title|label|duration|usage|error|cache|tps|image|icon)\}/g
601
659
  var last = 0
602
660
  var m
603
661
  while ((m = re.exec(tpl)) !== null) {
@@ -625,6 +683,29 @@ window.__ModuleLoader__.load({
625
683
  var tt = (cur && cur.titleTemplates) || {}
626
684
  return String(cur && cur['tt-' + field] !== undefined ? cur['tt-' + field] : (tt[field] ?? '')).trim()
627
685
  }
686
+ /** 按原因上传的通知大图:草稿键 img-<field> 优先,其次已保存的 images[<field>](data URI)。 */
687
+ function imageOf(cur, field) {
688
+ var im = (cur && cur.images) || {}
689
+ return String(cur && cur['img-' + field] !== undefined ? cur['img-' + field] : (im[field] ?? ''))
690
+ }
691
+ /** 按原因上传的通知图标:草稿键 icon-<field> 优先,其次已保存的 icons[<field>](data URI)。 */
692
+ function iconOf(cur, field) {
693
+ var ic = (cur && cur.icons) || {}
694
+ return String(cur && cur['icon-' + field] !== undefined ? cur['icon-' + field] : (ic[field] ?? ''))
695
+ }
696
+ /** 完整预览图(lightbox 查看原图用):等比 1024 未裁切版。kind ∈ 'img'|'icon'|'imageUrl'|'iconUrl'。
697
+ * 草稿键 pv-img-<field> / pv-icon-<field> / preview-imageUrl / preview-iconUrl 优先,
698
+ * 其次已保存的 imagePreviews / iconPreviews / imagePreviewUrl / iconPreviewUrl;无则空串(回退裁切版)。 */
699
+ function previewOf(cur, kind, field) {
700
+ if (kind === 'imageUrl') {
701
+ return String(cur && cur['preview-imageUrl'] !== undefined ? cur['preview-imageUrl'] : ((cur && cur.imagePreviewUrl) || ''))
702
+ }
703
+ if (kind === 'iconUrl') {
704
+ return String(cur && cur['preview-iconUrl'] !== undefined ? cur['preview-iconUrl'] : ((cur && cur.iconPreviewUrl) || ''))
705
+ }
706
+ var saved = kind === 'img' ? ((cur && cur.imagePreviews) || {}) : ((cur && cur.iconPreviews) || {})
707
+ return String(cur && cur['pv-' + kind + '-' + field] !== undefined ? cur['pv-' + kind + '-' + field] : (saved[field] ?? ''))
708
+ }
628
709
  /** 扁平快照(用于 dirty 对比与模板打包)。 */
629
710
  function flatOf(cur) {
630
711
  var out = {
@@ -632,9 +713,17 @@ window.__ModuleLoader__.load({
632
713
  titleTemplate: String(cur.titleTemplate ?? ''),
633
714
  includeDuration: !!cur.includeDuration, includeUsage: !!cur.includeUsage,
634
715
  pushMode: ['dual', 'system', 'toast'].indexOf(cur.pushMode) >= 0 ? cur.pushMode : 'dual',
716
+ imageUrl: String(cur.imageUrl ?? ''),
717
+ iconUrl: String(cur.iconUrl ?? ''),
635
718
  }
636
719
  for (var i = 0; i < REASON_FIELDS.length; i++) out['tpl-' + REASON_FIELDS[i]] = templateOf(cur, REASON_FIELDS[i])
637
720
  for (var j = 0; j < REASON_FIELDS.length; j++) out['tt-' + REASON_FIELDS[j]] = titleTplOf(cur, REASON_FIELDS[j])
721
+ for (var k = 0; k < REASON_FIELDS.length; k++) out['img-' + REASON_FIELDS[k]] = imageOf(cur, REASON_FIELDS[k])
722
+ for (var m = 0; m < REASON_FIELDS.length; m++) out['icon-' + REASON_FIELDS[m]] = iconOf(cur, REASON_FIELDS[m])
723
+ for (var n = 0; n < REASON_FIELDS.length; n++) out['pv-img-' + REASON_FIELDS[n]] = previewOf(cur, 'img', REASON_FIELDS[n])
724
+ for (var p = 0; p < REASON_FIELDS.length; p++) out['pv-icon-' + REASON_FIELDS[p]] = previewOf(cur, 'icon', REASON_FIELDS[p])
725
+ out['preview-imageUrl'] = previewOf(cur, 'imageUrl')
726
+ out['preview-iconUrl'] = previewOf(cur, 'iconUrl')
638
727
  return out
639
728
  }
640
729
  function sameFlat(a, b) {
@@ -732,17 +821,56 @@ window.__ModuleLoader__.load({
732
821
  propsRef.current = props // 每次渲染刷新:回调用最新 props(挂载句柄不再持有陈旧闭包)
733
822
  var lastTpl = useRef(typeof props.template === 'string' ? props.template : '')
734
823
  var lastLang = useRef(props.lang || 'zh')
824
+ var everEdited = useRef(false) // 用户是否编辑过(输入/插入):编辑后删空不再显示默认文案(hint),只留占位提示
735
825
 
736
826
  function chipEl(key) {
737
827
  var t = tOf(propsRef.current.lang || 'zh')
738
828
  var label = t.tokens[key] || key
829
+ // blockMedia:按原因定制标题等纯文字标题场景——{image}/{icon} 保持字面文本,不渲染胶囊
830
+ // (+ 插入菜单本就不含它们,这里防手输;发送时 renderTitle 会剥除)
831
+ if (propsRef.current.blockMedia && (key === 'image' || key === 'icon')) {
832
+ return document.createTextNode('{' + key + '}')
833
+ }
739
834
  var el = document.createElement('span')
740
835
  el.setAttribute('data-tok', key)
741
836
  el.setAttribute('contenteditable', 'false')
742
- 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'
743
- el.title = label + '(点击移除)'
744
- el.textContent = label + ' ×'
745
- el.addEventListener('click', function () {
837
+ el.style.cssText = 'display:inline-block;margin:0 3px;padding:0 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:20px;cursor:pointer;user-select:none'
838
+ var isMedia = key === 'image' || key === 'icon'
839
+ var mediaSrc = isMedia ? (key === 'image' ? propsRef.current.imageData : propsRef.current.iconData) : ''
840
+ if (isMedia) {
841
+ // 图片/图标标签:缩略图 + 独立 × ——点 × 才删除,点缩略图/文字查看大图(防误删)
842
+ el.title = label
843
+ if (mediaSrc) {
844
+ var th = document.createElement('img')
845
+ th.src = mediaSrc
846
+ th.alt = ''
847
+ th.style.cssText = key === 'icon'
848
+ ? 'width:16px;height:16px;object-fit:cover;border-radius:3px;vertical-align:-3px;margin-right:3px'
849
+ : 'width:24px;height:15px;object-fit:cover;border-radius:3px;vertical-align:-3px;margin-right:3px'
850
+ el.appendChild(th)
851
+ }
852
+ el.appendChild(document.createTextNode(label))
853
+ var rm = document.createElement('span')
854
+ rm.setAttribute('data-rm', '1')
855
+ rm.title = t.clearMedia
856
+ rm.style.cssText = 'margin-left:4px;font-weight:bold;color:var(--dsw-alias-label-tertiary,rgba(127,127,127,0.8))'
857
+ rm.textContent = ' ×'
858
+ el.appendChild(rm)
859
+ } else {
860
+ el.title = label + '(点击移除)'
861
+ el.textContent = label + ' ×'
862
+ }
863
+ el.addEventListener('click', function (e) {
864
+ // 只有 × 才删除;图片/图标点击本体 → 大图预览(lightbox)
865
+ if (e.target && e.target.getAttribute && e.target.getAttribute('data-rm')) { removeChipAt(el); return }
866
+ if (isMedia) {
867
+ // 预览 = 完整原图(等比未裁切版,优先),无则回退裁切版
868
+ var s = key === 'image'
869
+ ? (propsRef.current.imagePreview || propsRef.current.imageData)
870
+ : (propsRef.current.iconPreview || propsRef.current.iconData)
871
+ if (s && propsRef.current.onPreview) propsRef.current.onPreview(s)
872
+ return
873
+ }
746
874
  removeChipAt(el)
747
875
  })
748
876
  return el
@@ -783,31 +911,128 @@ window.__ModuleLoader__.load({
783
911
  }
784
912
 
785
913
  function syncFromDom() {
914
+ everEdited.current = true // 任何用户编辑(输入/删除/插入标签)都标记,删空后不再复原默认文案
786
915
  var tpl = domToTemplate()
787
916
  lastTpl.current = tpl
788
917
  // 占位提示只随内容变化:有输入 → 隐藏;清空 → 恢复(hint 可编辑内容或 placeholder)
789
918
  if (tpl !== '') clearPlaceholder()
790
- else rebuild('')
919
+ else {
920
+ rebuild('')
921
+ // 删空后光标恢复到编辑器末尾:否则 rebuild 重建 DOM 会丢光标,
922
+ // 按住 Backspace 无法继续连续删除,且视觉上像「内容自动复原」
923
+ var ed = editorRef.current
924
+ if (ed && document.activeElement === ed) {
925
+ try {
926
+ var range = document.createRange()
927
+ range.selectNodeContents(ed)
928
+ range.collapse(false)
929
+ var sel = window.getSelection()
930
+ sel.removeAllRanges()
931
+ sel.addRange(range)
932
+ } catch (e) { /* noop */ }
933
+ }
934
+ }
791
935
  propsRef.current.onChange(tpl)
792
936
  }
793
937
 
794
938
  function clearPlaceholder() {
939
+ // CSS 伪元素占位:输入内容后移除 data-ph-empty 属性即消失(不可选中、不可删除)
795
940
  var editor = editorRef.current
796
941
  if (!editor) return false
797
- var nodes = editor.querySelectorAll('[data-ph]')
798
- if (nodes.length === 0) return false
799
- Array.prototype.forEach.call(nodes, function (n) { n.remove() })
800
- return true
942
+ var had = editor.hasAttribute('data-ph-empty')
943
+ editor.removeAttribute('data-ph-empty')
944
+ return had
945
+ }
946
+
947
+ /** 记录光标在编辑器内的「字符/胶囊偏移」(文本节点按字符数、胶囊按 1)。
948
+ * 供 rebuild 重建 DOM 后恢复(图片上传/替换/删除触发重建时保持输入位置,不跳开头)。 */
949
+ function saveCaret(editor) {
950
+ var sel = window.getSelection()
951
+ if (!sel || sel.rangeCount === 0) return null
952
+ var range = sel.getRangeAt(0)
953
+ if (!range.collapsed || !editor.contains(range.startContainer)) return null
954
+ var offset = 0
955
+ var found = false
956
+ function walk(node) {
957
+ if (found) return
958
+ if (node === range.startContainer) { offset += range.startOffset; found = true; return }
959
+ // 胶囊(data-tok)整体算 1 个位置,不深入内部文字——内部文字长度因标签而异
960
+ // (图片胶囊「图片 ×」与文字胶囊「用时 ×」不同),深入会导致偏移权重不一致、恢复漂移
961
+ if (node.nodeType === 1 && node.hasAttribute && node.hasAttribute('data-tok')) { offset += 1; return }
962
+ if (node.nodeType === 3) offset += node.textContent.length
963
+ else {
964
+ for (var i = 0; i < node.childNodes.length; i++) walk(node.childNodes[i])
965
+ }
966
+ }
967
+ walk(editor)
968
+ return found ? offset : null
969
+ }
970
+
971
+ /** 按偏移恢复光标;偏移超出内容(编辑器已变空等)→ 落到末尾。 */
972
+ function restoreCaret(editor, offset) {
973
+ if (offset === null || offset === undefined) return
974
+ var target = null
975
+ var targetOffset = 0
976
+ var done = false
977
+ function walk(node) {
978
+ if (done) return
979
+ // 胶囊整体算 1 个位置(与 saveCaret 一致)
980
+ if (node.nodeType === 1 && node.hasAttribute && node.hasAttribute('data-tok')) {
981
+ if (offset <= 0) { target = node; targetOffset = 0; done = true; return }
982
+ offset -= 1
983
+ return
984
+ }
985
+ if (node.nodeType === 3) {
986
+ if (offset <= node.textContent.length) { target = node; targetOffset = offset; done = true; return }
987
+ offset -= node.textContent.length
988
+ } else {
989
+ for (var i = 0; i < node.childNodes.length; i++) { if (done) return; walk(node.childNodes[i]) }
990
+ }
991
+ }
992
+ walk(editor)
993
+ try {
994
+ var range = document.createRange()
995
+ if (!done) { range.selectNodeContents(editor); range.collapse(false) }
996
+ else if (target.nodeType === 3) { range.setStart(target, targetOffset); range.collapse(true) }
997
+ else { range.setStartAfter(target); range.collapse(true) }
998
+ var sel = window.getSelection()
999
+ sel.removeAllRanges()
1000
+ sel.addRange(range)
1001
+ } catch (e) { /* noop */ }
1002
+ }
1003
+
1004
+ /** 占位状态:编辑器真正为空(无文本且无胶囊)时挂 data-ph-empty + data-placeholder(CSS ::before 渲染)。
1005
+ * 注意:空模板但显示了 hint(默认文案)时编辑器有内容 → 不显示占位。 */
1006
+ function updatePlaceholderState() {
1007
+ var editor = editorRef.current
1008
+ if (!editor) return
1009
+ var hasContent = false
1010
+ Array.prototype.forEach.call(editor.childNodes, function (node) {
1011
+ if (hasContent) return
1012
+ if (node.nodeType === 3 && node.textContent !== '') hasContent = true
1013
+ else if (node.nodeType === 1 && node.hasAttribute && node.hasAttribute('data-tok')) hasContent = true
1014
+ })
1015
+ if (!hasContent) {
1016
+ editor.setAttribute('data-ph-empty', '1')
1017
+ editor.setAttribute('data-placeholder', typeof propsRef.current.placeholder === 'string' ? propsRef.current.placeholder : '')
1018
+ } else {
1019
+ editor.removeAttribute('data-ph-empty')
1020
+ }
801
1021
  }
802
1022
 
803
1023
  function rebuild(tpl) {
804
1024
  var editor = editorRef.current
805
1025
  if (!editor) return
1026
+ // 重建前保存光标(图片上传/替换/删除等触发 rebuild 时保持输入位置,不跳开头)
1027
+ var caret = saveCaret(editor)
806
1028
  lastTpl.current = typeof tpl === 'string' ? tpl : ''
807
1029
  editor.textContent = ''
808
1030
  var tplStr = typeof tpl === 'string' ? tpl : ''
809
- // 空模板:优先提示串(真实默认内容,可编辑);否则非编辑占位(如推送标题)
810
- var shown = tplStr !== '' ? tplStr : (typeof propsRef.current.hint === 'string' ? propsRef.current.hint : '')
1031
+ // 空模板:优先显示默认文案(hint,可编辑的真实内容——默认预设下所见即默认推送文案);
1032
+ // 用户编辑过再删空(everEdited)→ 不显示默认文案(避免「删完自动复原」),只留 CSS 占位提示
1033
+ var shown = tplStr !== ''
1034
+ ? tplStr
1035
+ : (everEdited.current ? '' : (typeof propsRef.current.hint === 'string' ? propsRef.current.hint : ''))
811
1036
  if (shown !== '') {
812
1037
  var parts = parseTemplate(shown)
813
1038
  parts.forEach(function (seg) {
@@ -817,13 +1042,10 @@ window.__ModuleLoader__.load({
817
1042
  editor.appendChild(chipEl(seg.key))
818
1043
  }
819
1044
  })
820
- } else if (typeof propsRef.current.placeholder === 'string' && propsRef.current.placeholder !== '') {
821
- var ph = document.createElement('span')
822
- ph.setAttribute('data-ph', '1')
823
- ph.style.cssText = 'color:var(--dsw-alias-label-tertiary,rgba(127,127,127,0.8));pointer-events:none;user-select:none'
824
- ph.textContent = propsRef.current.placeholder
825
- editor.appendChild(ph)
826
1045
  }
1046
+ updatePlaceholderState()
1047
+ // 重建后恢复光标(若原光标在编辑器内;内容变化导致偏移漂移时尽量贴近原位置)
1048
+ restoreCaret(editor, caret)
827
1049
  }
828
1050
 
829
1051
  function insertAtCaret(key) {
@@ -853,25 +1075,74 @@ window.__ModuleLoader__.load({
853
1075
  var lang = props.lang || 'zh'
854
1076
  if (props.template !== lastTpl.current || lang !== lastLang.current) {
855
1077
  lastLang.current = lang
1078
+ // 外部模板变化(载入预设/切换语言)= 新上下文 → 重置「已编辑」标记,默认预设下重新显示默认文案
1079
+ if (props.template !== lastTpl.current) everEdited.current = false
856
1080
  rebuild(props.template)
857
1081
  }
858
1082
  // 仅外部变化(模板/语言)时重建;用户输入路径 lastTpl 已同步,不打扰光标
859
1083
  }, [props.template, props.lang])
860
1084
 
1085
+ // 图片/图标数据变化(上传完成/替换/清除)→ 按需重建 DOM,让 {image}/{icon} 标签立即显示新缩略图。
1086
+ // 删除胶囊路径(点 × / Backspace)removeChipAt 已同步 DOM:模板中已无该媒体标签、DOM 中也没有
1087
+ // 对应胶囊 → 无需重建(重建会因偏移近似把光标挪走,导致「跳前/消失」,无法连贯删除)。
1088
+ useEffect(function () {
1089
+ var ed = editorRef.current
1090
+ var need = false
1091
+ if (!ed) need = true
1092
+ else {
1093
+ var chips = ed.querySelectorAll('[data-tok="image"], [data-tok="icon"]')
1094
+ for (var i = 0; i < chips.length && !need; i++) {
1095
+ var key = chips[i].getAttribute('data-tok')
1096
+ var cur = key === 'image' ? props.imageData : props.iconData
1097
+ var img = chips[i].querySelector('img')
1098
+ // 有数据但胶囊里没有对应缩略图(或缩略图是旧图)→ 需要重建刷新
1099
+ if (cur && (!img || img.getAttribute('src') !== cur)) need = true
1100
+ }
1101
+ }
1102
+ if (need) rebuild(props.template)
1103
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1104
+ }, [props.imageData, props.iconData])
1105
+
861
1106
  useEffect(function () {
862
1107
  var editor = editorRef.current
863
1108
  if (editor) rebuild(props.template)
864
1109
  if (props.onReady) props.onReady({ insertAtCaret: insertAtCaret })
1110
+ // 注入占位 CSS:contentEditable 无原生 placeholder,用 ::before 伪元素模拟
1111
+ // (不可选中、不可删除、不进入文本流——与 input 的 placeholder 行为一致)。
1112
+ // 按「当前 document 里是否有该 style」判断(幂等),而非 window 标志——
1113
+ // 宿主重建/切换 document 时 window 标志残留会导致不再注入、占位消失。
1114
+ try {
1115
+ if (!document.querySelector('style[data-dsn-ph-css]')) {
1116
+ var st = document.createElement('style')
1117
+ st.setAttribute('data-dsn-ph-css', '1')
1118
+ st.textContent = '[data-dsn-ph][data-ph-empty]::before{content:attr(data-placeholder);color:rgba(127,127,127,0.5);pointer-events:none;user-select:none;white-space:pre-wrap}select:focus{outline:none!important;box-shadow:none!important}'
1119
+ ;(document.head || document.documentElement).appendChild(st)
1120
+ }
1121
+ } catch (e) { /* noop */ }
865
1122
  return function () { if (props.onReady) props.onReady(null) }
866
1123
  // eslint-disable-next-line react-hooks/exhaustive-deps
867
1124
  }, [])
868
1125
 
869
1126
  return h('div', {
870
1127
  ref: editorRef,
1128
+ 'data-dsn-ph': '1',
871
1129
  contentEditable: true,
872
1130
  suppressContentEditableWarning: true,
873
1131
  onInput: syncFromDom,
874
- onMouseDown: function () { /* 不清理占位:点击聚焦不消失,输入内容才隐藏 */ },
1132
+ onMouseDown: function () {
1133
+ // 空编辑器点击:光标移到最前(占位位置)——与原生 input placeholder 一致,输入文字从最左开始
1134
+ var ed = editorRef.current
1135
+ if (ed && !ed.textContent && !ed.querySelector('[data-tok]')) {
1136
+ try {
1137
+ var range = document.createRange()
1138
+ range.selectNodeContents(ed)
1139
+ range.collapse(true)
1140
+ var sel = window.getSelection()
1141
+ sel.removeAllRanges()
1142
+ sel.addRange(range)
1143
+ } catch (e) { /* noop */ }
1144
+ }
1145
+ },
875
1146
  onKeyDown: function (e) {
876
1147
  if (e.key === 'Enter') { e.preventDefault(); return }
877
1148
  // Ctrl/Cmd+A:手动扩选“全部子节点(含胶囊)”——浏览器全选天然跳过
@@ -889,7 +1160,31 @@ window.__ModuleLoader__.load({
889
1160
  return
890
1161
  }
891
1162
  if (e.key === 'Backspace' || e.key === 'Delete') {
892
- // 让浏览器先删(会在下一拍触发 input),删除胶囊文本的残留交给 syncFromDom
1163
+ // 删除紧邻的胶囊时手动处理:浏览器删除 contenteditable=false 元素后会把光标乱跳
1164
+ // (常跳到编辑器开头),这里走 removeChipAt(光标恢复到删除位置)
1165
+ var sel = window.getSelection()
1166
+ var range = sel && sel.rangeCount > 0 ? sel.getRangeAt(0) : null
1167
+ var target = null
1168
+ if (range && range.collapsed) {
1169
+ var cc = range.startContainer
1170
+ if (e.key === 'Backspace') {
1171
+ if (cc.nodeType === 3 && range.startOffset === 0) target = cc.previousSibling
1172
+ else if (cc.nodeType === 3 && range.startOffset > 0) { /* 文本中间,正常删字符 */ }
1173
+ // 光标停在元素容器内(如胶囊后、编辑器末尾):按子节点序号取前一个
1174
+ else if (cc.nodeType === 1 && range.startOffset > 0) target = cc.childNodes[range.startOffset - 1]
1175
+ } else {
1176
+ if (cc.nodeType === 3 && range.startOffset === cc.length) target = cc.nextSibling
1177
+ // 光标停在元素容器内(如胶囊前):按子节点序号取当前一个
1178
+ else if (cc.nodeType === 1 && range.startOffset < cc.childNodes.length) target = cc.childNodes[range.startOffset]
1179
+ }
1180
+ while (target && target.nodeType !== 1) target = e.key === 'Backspace' ? target.previousSibling : target.nextSibling
1181
+ if (target && target.hasAttribute && target.hasAttribute('data-tok')) {
1182
+ e.preventDefault()
1183
+ removeChipAt(target)
1184
+ return
1185
+ }
1186
+ }
1187
+ // 普通文本删除:交给浏览器(会在下一拍触发 input),删除文本的残留交给 syncFromDom
893
1188
  setTimeout(syncFromDom, 0)
894
1189
  }
895
1190
  },
@@ -902,10 +1197,11 @@ window.__ModuleLoader__.load({
902
1197
  setTimeout(syncFromDom, 0)
903
1198
  },
904
1199
  style: {
905
- minHeight: '36px', padding: '7px 10px', borderRadius: '8px',
1200
+ minHeight: '38px', padding: props.compact ? '9px 8px' : '9px 10px', borderRadius: '8px',
906
1201
  border: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.35))',
907
1202
  background: 'transparent', color: 'inherit', font: 'inherit', fontSize: '13px',
908
- lineHeight: 1.5, outline: 'none', wordBreak: 'break-word',
1203
+ lineHeight: '20px', outline: 'none', wordBreak: 'break-word',
1204
+ boxSizing: 'border-box', // 与普通 input 高度一致(minHeight 含 padding/border)
909
1205
  },
910
1206
  })
911
1207
  }
@@ -913,22 +1209,18 @@ window.__ModuleLoader__.load({
913
1209
  /** 各语言「会话」前缀(用于默认提示中在标签内插入会话标题标签)。 */
914
1210
  var SESSION_PREFIX = { zh: '会话', 'zh-tw': '會話', en: 'Session ', ja: 'セッション', ko: '세션 ' }
915
1211
 
916
- /** 内置默认内容的提示串(空模板时直接展示真实默认效果:会话「会话标题」+ 信息标签)。 */
1212
+ /** 各语言「请点击查看」尾句。 */
1213
+ var CLICK_SEE = { zh: ',请点击查看。', 'zh-tw': ',請點擊查看。', en: '. Click to view.', ja: '。クリックして表示。', ko: '. 클릭하여 확인.' }
1214
+
1215
+ /** 内置默认内容的提示串(空模板时直接展示真实默认效果:会话「{title}」已{状态},请点击查看)。 */
917
1216
  function defaultHintOf(field, langCode) {
918
1217
  var lang = LANG_IDS.indexOf(langCode) >= 0 ? langCode : 'zh'
919
1218
  var tt = tOf(lang)
920
1219
  var label = (tt.kindLabels && tt.kindLabels[field]) || tt.samples.label
921
- var d = '{duration}'
922
- var u = '{usage}'
923
1220
  var prefix = SESSION_PREFIX[lang] || '会话'
924
1221
  var rest = String(label).slice(prefix.length) // 剥掉「会话」前缀,保留原因后缀(已完成/中止/…)
925
1222
  var titleTag = lang === 'en' ? '"{title}"' : '「{title}」'
926
- var head = prefix + titleTag + ((lang === 'en' || lang === 'ko') ? ' ' : '') + rest
927
- if (lang === 'en') return head + ' (took ' + d + ', used ' + u + ').'
928
- if (lang === 'ja') return head + '(所要 ' + d + '、消費 ' + u + ')。'
929
- if (lang === 'ko') return head + '(소요 ' + d + ', 소모 ' + u + ')。'
930
- if (lang === 'zh-tw') return head + '(用時 ' + d + ',消耗 ' + u + ')。'
931
- return head + '(用时 ' + d + ',消耗 ' + u + ')。'
1223
+ return prefix + titleTag + ((lang === 'en' || lang === 'ko') ? ' ' : '') + rest + (CLICK_SEE[lang] || ',请点击查看。')
932
1224
  }
933
1225
 
934
1226
  return function SettingsCard() {
@@ -966,15 +1258,31 @@ window.__ModuleLoader__.load({
966
1258
  var namingState = useState(false) // 另存为的名字输入行
967
1259
  var naming = namingState[0]
968
1260
  var setNaming = namingState[1]
1261
+ var renameTargetState = useState(null) // 重命名模式:非 null = 正在重命名的自定义预设 id
1262
+ var renameTarget = renameTargetState[0]
1263
+ var setRenameTarget = renameTargetState[1]
969
1264
  var nameState = useState('')
970
1265
  var newName = nameState[0]
971
1266
  var setName = nameState[1]
972
1267
  var permTickState = useState(0) // 通知权限状态刷新计数(请求授权后 setPermTick(+1) 重渲染)
973
1268
  var permTick = permTickState[0]
974
1269
  var setPermTick = permTickState[1]
975
- var titleTplOpenState = useState(false) // 「按原因定制标题」折叠区展开状态
976
- var titleTplOpen = titleTplOpenState[0]
977
- var setTitleTplOpen = titleTplOpenState[1]
1270
+ var titleFoldState = useState(false) // 「标题」折叠区(全局推送标题 + 按原因定制标题)展开状态
1271
+ var titleFoldOpen = titleFoldState[0]
1272
+ var setTitleFoldOpen = titleFoldState[1]
1273
+ var titleMenuState = useState(false) // 推送标题的「+ 插入信息」菜单展开状态
1274
+ var titleMenuOpen = titleMenuState[0]
1275
+ var setTitleMenuOpen = titleMenuState[1]
1276
+ var ttMenuState = useState(null) // 「按原因定制标题」某行的「+」插入菜单展开状态(null = 收起)
1277
+ var ttMenuFor = ttMenuState[0]
1278
+ var setTtMenuFor = ttMenuState[1]
1279
+ var ttHandles = useRef({}) // 按原因定制标题各行的 ChipEditor 句柄(插入信息到光标处)
1280
+ var tplOpenState = useState(false) // 「内容」折叠区展开状态(默认收起,保持面板简洁)
1281
+ var tplOpen = tplOpenState[0]
1282
+ var setTplOpen = tplOpenState[1]
1283
+ var previewState = useState(null) // 大图预览(lightbox):非空 = 正在预览该 data URI/URL
1284
+ var previewUrl = previewState[0]
1285
+ var setPreviewUrl = previewState[1]
978
1286
 
979
1287
  /** 用户手势内请求通知权限(Chromium 忽略非手势的自动请求,必须由点击触发);授权结果经重渲染刷新状态区。 */
980
1288
  function requestPermissionNow() {
@@ -1009,19 +1317,45 @@ window.__ModuleLoader__.load({
1009
1317
  }
1010
1318
 
1011
1319
  function setVal(key, x) {
1012
- var next = Object.assign({}, cur)
1013
- next[key] = x
1014
- setDraft(next)
1320
+ // 函数式更新:以**最新** draft 为基准(而非渲染闭包 cur)——
1321
+ // 图片上传等异步回调(FileReader/压缩)晚于点击事件执行,若用旧 cur
1322
+ // Object.assign 会把期间刚插入的 {image} 等模板更新覆盖掉(标签消失 bug)。
1323
+ setDraft(function (prev) {
1324
+ var base = prev === null ? value : prev
1325
+ var next = Object.assign({}, base)
1326
+ next[key] = x
1327
+ return next
1328
+ })
1015
1329
  setNote('')
1016
1330
  setJustSaved(false)
1017
1331
  }
1018
1332
 
1019
1333
  /** 载入内置或自定义预设到表单(下拉只负责载入,不跟踪表单状态)。 */
1020
1334
  function loadPreset(entry, custom) {
1335
+ var flat = flatOf(cur)
1021
1336
  var next = Object.assign({}, cur)
1022
1337
  next.language = entry.language
1023
1338
  next.titleTemplate = String(entry.titleTemplate ?? '')
1024
1339
  for (var j = 0; j < REASON_FIELDS.length; j++) next['tpl-' + REASON_FIELDS[j]] = entry.templates[REASON_FIELDS[j]]
1340
+ // 按原因定制标题(旧预设可能没有 → 空)
1341
+ var et = (entry.titleTemplates && typeof entry.titleTemplates === 'object') ? entry.titleTemplates : {}
1342
+ for (var t2 = 0; t2 < REASON_FIELDS.length; t2++) next['tt-' + REASON_FIELDS[t2]] = typeof et[REASON_FIELDS[t2]] === 'string' ? et[REASON_FIELDS[t2]] : ''
1343
+ // 通知媒体(大图/图标 + 完整预览版;旧预设缺失 → 清空)
1344
+ next.imageUrl = String(entry.imageUrl ?? '')
1345
+ next.iconUrl = String(entry.iconUrl ?? '')
1346
+ next['preview-imageUrl'] = String(entry.imagePreviewUrl ?? '')
1347
+ next['preview-iconUrl'] = String(entry.iconPreviewUrl ?? '')
1348
+ var ei = (entry.images && typeof entry.images === 'object') ? entry.images : {}
1349
+ var eic = (entry.icons && typeof entry.icons === 'object') ? entry.icons : {}
1350
+ var eip = (entry.imagePreviews && typeof entry.imagePreviews === 'object') ? entry.imagePreviews : {}
1351
+ var eicp = (entry.iconPreviews && typeof entry.iconPreviews === 'object') ? entry.iconPreviews : {}
1352
+ for (var t3 = 0; t3 < REASON_FIELDS.length; t3++) {
1353
+ var f3 = REASON_FIELDS[t3]
1354
+ next['img-' + f3] = typeof ei[f3] === 'string' ? ei[f3] : ''
1355
+ next['icon-' + f3] = typeof eic[f3] === 'string' ? eic[f3] : ''
1356
+ next['pv-img-' + f3] = typeof eip[f3] === 'string' ? eip[f3] : ''
1357
+ next['pv-icon-' + f3] = typeof eicp[f3] === 'string' ? eicp[f3] : ''
1358
+ }
1025
1359
  setDraft(next)
1026
1360
  setNote('')
1027
1361
  setJustSaved(false)
@@ -1041,12 +1375,34 @@ window.__ModuleLoader__.load({
1041
1375
  function saveAsCustom() {
1042
1376
  var name = newName.trim()
1043
1377
  if (!name) { setNote(t.presetNameRequired); return }
1378
+ var flat = flatOf(cur)
1379
+ var images = {}
1380
+ var icons = {}
1381
+ var imagePreviews = {}
1382
+ var iconPreviews = {}
1383
+ for (var si = 0; si < REASON_FIELDS.length; si++) {
1384
+ var sk = REASON_FIELDS[si]
1385
+ images[sk] = flat['img-' + sk]
1386
+ icons[sk] = flat['icon-' + sk]
1387
+ imagePreviews[sk] = flat['pv-img-' + sk]
1388
+ iconPreviews[sk] = flat['pv-icon-' + sk]
1389
+ }
1044
1390
  var cp = {
1045
1391
  id: 'cu-' + Date.now().toString(36),
1046
1392
  name: name,
1047
1393
  language: LANG_IDS.indexOf(cur.language) >= 0 ? cur.language : 'zh',
1048
- titleTemplate: flatOf(cur).titleTemplate,
1394
+ titleTemplate: flat.titleTemplate,
1395
+ titleTemplates: flat.titleTemplates,
1049
1396
  templates: flatTemplatesOf(cur),
1397
+ // 通知媒体(大图/图标 + 完整预览版)一并存入预设;默认预设保持留空
1398
+ imageUrl: flat.imageUrl,
1399
+ iconUrl: flat.iconUrl,
1400
+ imagePreviewUrl: flat['preview-imageUrl'],
1401
+ iconPreviewUrl: flat['preview-iconUrl'],
1402
+ images: images,
1403
+ icons: icons,
1404
+ imagePreviews: imagePreviews,
1405
+ iconPreviews: iconPreviews,
1050
1406
  }
1051
1407
  var next = customPresets.concat([cp])
1052
1408
  setCustomPresets(next)
@@ -1072,14 +1428,55 @@ window.__ModuleLoader__.load({
1072
1428
  log('custom preset deleted')
1073
1429
  }
1074
1430
 
1431
+ function renameCustom() {
1432
+ if (!renameTarget) return
1433
+ var name = newName.trim()
1434
+ if (!name) { setNote(t.presetNameRequired); return }
1435
+ var renamed = null
1436
+ // 未命名预设(自动保存产生、无名字)重命名时升级为具名预设:换用 cu- 前缀 id。
1437
+ // 否则 isUnnamedId(id) 恒真,下拉永远显示「未命名」,改名看似无效。
1438
+ var newId = isUnnamedId(renameTarget)
1439
+ ? 'cu-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6)
1440
+ : renameTarget
1441
+ var next = customPresets.map(function (cp) {
1442
+ if (cp.id === renameTarget) {
1443
+ renamed = Object.assign({}, cp, { name: name })
1444
+ if (newId !== renameTarget) renamed.id = newId
1445
+ return renamed
1446
+ }
1447
+ return cp
1448
+ })
1449
+ setCustomPresets(next)
1450
+ saveCustomPresets(next)
1451
+ setRenameTarget(null)
1452
+ setName('')
1453
+ setNote(t.customUpdated)
1454
+ log('custom preset renamed: ' + name)
1455
+ // 若重命名的是当前使用的预设 → 同步 activePreset(下拉显示新名字)
1456
+ if (activePreset && activePreset.id === renameTarget && renamed) {
1457
+ setActivePreset({ id: newId, name: name, custom: true, src: renamed })
1458
+ }
1459
+ }
1460
+
1075
1461
  function save() {
1076
1462
  var flat = flatOf(cur)
1077
1463
  var templates = {}
1078
1464
  var titleTemplates = {}
1465
+ var images = {}
1466
+ var icons = {}
1467
+ var imagePreviews = {}
1468
+ var iconPreviews = {}
1079
1469
  for (var i = 0; i < REASON_FIELDS.length; i++) {
1080
1470
  var k = REASON_FIELDS[i]
1081
1471
  templates[k] = flat['tpl-' + k]
1082
1472
  titleTemplates[k] = flat['tt-' + k]
1473
+ // 标签即数据开关:模板里没有 {image}/{icon} 时对应数据一并清空(删除标签即删除图片数据)
1474
+ var hasImg = String(flat['tpl-' + k]).indexOf('{image}') >= 0
1475
+ var hasIcon = String(flat['tpl-' + k]).indexOf('{icon}') >= 0
1476
+ images[k] = hasImg ? flat['img-' + k] : ''
1477
+ imagePreviews[k] = hasImg ? flat['pv-img-' + k] : ''
1478
+ icons[k] = hasIcon ? flat['icon-' + k] : ''
1479
+ iconPreviews[k] = hasIcon ? flat['pv-icon-' + k] : ''
1083
1480
  }
1084
1481
  setNote(t.saving)
1085
1482
  var tasks = [
@@ -1088,6 +1485,14 @@ window.__ModuleLoader__.load({
1088
1485
  scope.set('titleTemplate', flat.titleTemplate),
1089
1486
  scope.set('titleTemplates', titleTemplates),
1090
1487
  scope.set('pushMode', flat.pushMode),
1488
+ scope.set('imageUrl', flat.imageUrl),
1489
+ scope.set('iconUrl', flat.iconUrl),
1490
+ scope.set('imagePreviewUrl', flat['preview-imageUrl']),
1491
+ scope.set('iconPreviewUrl', flat['preview-iconUrl']),
1492
+ scope.set('images', images),
1493
+ scope.set('icons', icons),
1494
+ scope.set('imagePreviews', imagePreviews),
1495
+ scope.set('iconPreviews', iconPreviews),
1091
1496
  ]
1092
1497
  Promise.all(tasks).then(function () {
1093
1498
  // 预设自动同步:
@@ -1126,23 +1531,203 @@ window.__ModuleLoader__.load({
1126
1531
  })
1127
1532
  }
1128
1533
 
1534
+ /**
1535
+ * 本地图片 → 两份 data URI(canvas、JPEG 0.8),回调 cb({ media, full }):
1536
+ * - media:通知媒体版——maxSize 宽 + cropAspect>0 时**中心裁切(cover)**(Windows toast hero 约 16:9、
1537
+ * 图标 1:1),裁切后 = 通知里显示的图(所见即所得);cropAspect=0 时等比不裁切。小图不放大。
1538
+ * - full:完整预览版——等比缩放至最长边 1024px(不裁切,保原比例),供点击缩略图时 lightbox 查看原图
1539
+ * (不被裁切;小图不放大)。单张约 60~250KB。
1540
+ */
1541
+ function compressImageFile(file, cb, maxSize, cropAspect) {
1542
+ try {
1543
+ if (!file || String(file.type || '').indexOf('image/') !== 0) { cb(null); return }
1544
+ var reader = new FileReader()
1545
+ reader.onload = function () {
1546
+ var img = new Image()
1547
+ img.onload = function () {
1548
+ try {
1549
+ var w0 = img.width || 1
1550
+ var h0 = img.height || 1
1551
+ // 完整预览版:等比 1024(不裁切)
1552
+ var fscale = Math.min(1, 1024 / Math.max(w0, h0))
1553
+ var fw = Math.max(1, Math.round(w0 * fscale))
1554
+ var fh = Math.max(1, Math.round(h0 * fscale))
1555
+ var fcanvas = document.createElement('canvas')
1556
+ fcanvas.width = fw
1557
+ fcanvas.height = fh
1558
+ fcanvas.getContext('2d').drawImage(img, 0, 0, fw, fh)
1559
+ var full = fcanvas.toDataURL('image/jpeg', 0.8)
1560
+ // 通知媒体版:maxSize 宽 + 裁切(cropAspect>0)或等比(0)
1561
+ var MAX = maxSize || 512
1562
+ var ratio = cropAspect && cropAspect > 0 ? cropAspect : 0
1563
+ var TW, TH, sx, sy, sw, sh
1564
+ if (ratio > 0) {
1565
+ // cover 中心裁切到 ratio:目标宽 = min(MAX, 裁切后源宽)(小图不放大)
1566
+ var srcAspect = w0 / h0
1567
+ var dstAspect = ratio
1568
+ if (srcAspect > dstAspect) { sh = h0; sw = Math.round(h0 * dstAspect); sx = Math.round((w0 - sw) / 2); sy = 0 }
1569
+ else { sw = w0; sh = Math.round(w0 / dstAspect); sx = 0; sy = Math.round((h0 - sh) / 2) }
1570
+ TW = Math.max(1, Math.min(MAX, sw))
1571
+ TH = Math.max(1, Math.round(TW / ratio))
1572
+ } else {
1573
+ // 等比缩放(不裁切)
1574
+ var scale = Math.min(1, MAX / Math.max(w0, h0))
1575
+ TW = Math.max(1, Math.round(w0 * scale))
1576
+ TH = Math.max(1, Math.round(h0 * scale))
1577
+ sx = 0; sy = 0; sw = w0; sh = h0
1578
+ }
1579
+ var canvas = document.createElement('canvas')
1580
+ canvas.width = TW
1581
+ canvas.height = TH
1582
+ canvas.getContext('2d').drawImage(img, sx, sy, sw, sh, 0, 0, TW, TH)
1583
+ cb({ media: canvas.toDataURL('image/jpeg', 0.8), full: full })
1584
+ } catch (e) { cb(null) }
1585
+ }
1586
+ img.onerror = function () { cb(null) }
1587
+ img.src = String(reader.result || '')
1588
+ }
1589
+ reader.onerror = function () { cb(null) }
1590
+ reader.readAsDataURL(file)
1591
+ } catch (e) { cb(null) }
1592
+ }
1593
+
1594
+ /** 全局通知大图/图标的本地上传:压缩(裁切版 + 完整预览版)后写入 imageUrl/iconUrl(通知用)
1595
+ * 与 preview-imageUrl/preview-iconUrl(lightbox 查看原图用)草稿。 */
1596
+ function pickMediaUrl(key, maxSize, cropAspect) {
1597
+ try {
1598
+ var input = document.createElement('input')
1599
+ input.type = 'file'
1600
+ input.accept = 'image/*'
1601
+ input.onchange = function () {
1602
+ var file = input.files && input.files[0]
1603
+ compressImageFile(file, function (res) {
1604
+ input.remove()
1605
+ if (res) {
1606
+ setVal(key, res.media)
1607
+ setVal('preview-' + key, res.full)
1608
+ log('media uploaded for ' + key + ' (' + Math.round((res.media.length + res.full.length) / 1024) + 'KB)')
1609
+ } else {
1610
+ setNote(t.imageFailed)
1611
+ log('media upload failed for ' + key)
1612
+ }
1613
+ }, maxSize, cropAspect)
1614
+ }
1615
+ input.click()
1616
+ } catch (e) { /* noop */ }
1617
+ }
1618
+
1619
+ /** 通知媒体卡片(供「通知图片」行横排复用):label 在上 + 卡片在下。
1620
+ * 空态 = 圆角矩形 + 号(点击上传);有图 = 缩略图 + 右上角 ×(点击删除)。
1621
+ * 兼容旧数据:值可为 data URI 或 https URL(img 直接显示,加载失败隐藏留空矩形,× 仍可删除)。 */
1622
+ function mediaCard(key, maxSize, cropAspect, label) {
1623
+ var val = String(cur[key] ?? '')
1624
+ var hasMedia = val !== ''
1625
+ // 预览 = 完整原图(等比未裁切版,优先),无则回退裁切版/URL
1626
+ var pvSrc = previewOf(cur, key === 'iconUrl' ? 'iconUrl' : 'imageUrl') || val
1627
+ var square = cropAspect === 1
1628
+ // 两卡等高(64px):图标 1:1 = 64×64,大图 16:9 = 114×64
1629
+ var CARD_H = 64
1630
+ var w = square ? CARD_H + 'px' : Math.round(CARD_H * (16 / 9)) + 'px'
1631
+ var hh = CARD_H + 'px' // 注意:不可命名为 h(会遮蔽模块级 react.createElement)
1632
+ var cardBorder = '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.35))'
1633
+ return h('div', { style: { display: 'flex', flexDirection: 'column', gap: '6px', alignItems: 'flex-start' } },
1634
+ h('span', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-secondary, inherit)' } }, label),
1635
+ h('div', { style: { position: 'relative', width: w, height: hh } },
1636
+ hasMedia
1637
+ ? h('img', {
1638
+ src: val, alt: '', title: label,
1639
+ onClick: function () { setPreviewUrl(pvSrc) }, // 点击缩略图查看完整原图(等比未裁切)
1640
+ style: {
1641
+ width: '100%', height: '100%', objectFit: 'cover', borderRadius: '8px', display: 'block',
1642
+ border: cardBorder, background: 'rgba(127,127,127,0.12)', cursor: 'zoom-in',
1643
+ },
1644
+ onError: function (e) { e.target.style.visibility = 'hidden' },
1645
+ })
1646
+ : h('button', {
1647
+ type: 'button',
1648
+ title: square ? t.iconPickTitle : t.imagePickTitle,
1649
+ onClick: function () { pickMediaUrl(key, maxSize, cropAspect) },
1650
+ style: {
1651
+ width: '100%', height: '100%', cursor: 'pointer', padding: '0',
1652
+ fontSize: '26px', lineHeight: '1', color: 'var(--dsw-alias-label-tertiary, rgba(127,127,127,0.8))',
1653
+ borderRadius: '8px', border: '1px dashed var(--dsw-alias-border-l2, rgba(127,127,127,0.5))',
1654
+ background: 'rgba(127,127,127,0.06)',
1655
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
1656
+ },
1657
+ }, '+'),
1658
+ hasMedia
1659
+ ? h('button', {
1660
+ type: 'button', title: t.clearMedia,
1661
+ onClick: function () { setVal(key, '') },
1662
+ style: {
1663
+ position: 'absolute', top: '-7px', right: '-7px', width: '20px', height: '20px', padding: '0',
1664
+ borderRadius: '50%', border: 'none', cursor: 'pointer',
1665
+ background: 'rgba(20,22,28,0.75)', color: '#fff', fontSize: '12px', lineHeight: '20px', textAlign: 'center',
1666
+ },
1667
+ }, '×')
1668
+ : null,
1669
+ ),
1670
+ )
1671
+ }
1672
+
1673
+ /** 为某原因选择本地图片:文件选择 → 压缩 → 写入 img-<field> 草稿(保存时随设置文档持久化)。 */
1674
+ /** 为某原因上传媒体(大图 img / 图标 icon):压缩(裁切版 + 完整预览版)→ 写入 img-<field>/icon-<field>(通知用)
1675
+ * 与 pv-img-<field>/pv-icon-<field>(lightbox 查看原图用)草稿。 */
1676
+ function pickReasonMedia(field, key, maxSize, cropAspect) {
1677
+ try {
1678
+ var input = document.createElement('input')
1679
+ input.type = 'file'
1680
+ input.accept = 'image/*'
1681
+ input.onchange = function () {
1682
+ var file = input.files && input.files[0]
1683
+ compressImageFile(file, function (res) {
1684
+ input.remove()
1685
+ if (res) {
1686
+ setVal(key + '-' + field, res.media)
1687
+ setVal('pv-' + key + '-' + field, res.full)
1688
+ log(key + ' picked for ' + field + ' (' + Math.round((res.media.length + res.full.length) / 1024) + 'KB)')
1689
+ } else {
1690
+ setNote(t.imageFailed)
1691
+ log(key + ' pick failed for ' + field)
1692
+ }
1693
+ }, maxSize, cropAspect)
1694
+ }
1695
+ input.click()
1696
+ } catch (e) { /* noop */ }
1697
+ }
1698
+
1129
1699
  /** 发送测试通知:用当前模板(空模板用默认文案)渲染该原因的通知;标题按原因优先级。 */
1130
1700
  function sendTestNotice(field) {
1131
1701
  try {
1132
1702
  var samples = t.samples
1133
1703
  var titleTpl = titleTplOf(cur, field) || String(cur.titleTemplate || '').trim() || t.ttPlaceholders[field]
1134
- var title = String(titleTpl).replaceAll('{title}', samples.title).trim() || samples.title
1704
+ var title = String(titleTpl)
1705
+ .replace(/\{image\}/g, '').replace(/\{icon\}/g, '')
1706
+ .replaceAll('{title}', samples.title)
1707
+ .replaceAll('{duration}', samples.duration)
1708
+ .replaceAll('{usage}', samples.usage)
1709
+ .replaceAll('{error}', samples.error)
1710
+ .replaceAll('{cache}', samples.cache)
1711
+ .replaceAll('{tps}', samples.tps)
1712
+ .trim() || samples.title
1135
1713
  var tpl = templateOf(cur, field)
1136
1714
  var bodyTpl = tpl !== '' ? tpl : defaultHintOf(field, cur.language)
1137
1715
  var body = String(bodyTpl)
1138
1716
  .replace(/\{label\}/g, '')
1717
+ .replace(/\{image\}/g, '') // {image}/{icon} 是开关标签:正文只剥除,媒体由 notifyUser 按 data 附加
1718
+ .replace(/\{icon\}/g, '')
1139
1719
  .replaceAll('{title}', samples.title)
1140
1720
  .replaceAll('{duration}', samples.duration)
1141
1721
  .replaceAll('{usage}', samples.usage)
1142
1722
  .replaceAll('{error}', samples.error)
1143
1723
  .replaceAll('{cache}', samples.cache)
1144
1724
  .replaceAll('{tps}', samples.tps)
1145
- notifyUser(title, body, ['dual', 'system', 'toast'].indexOf(cur.pushMode) >= 0 ? cur.pushMode : 'dual')
1725
+ notifyUser(title, body, ['dual', 'system', 'toast'].indexOf(cur.pushMode) >= 0 ? cur.pushMode : 'dual', {
1726
+ iconUrl: String(cur.iconUrl || ''),
1727
+ imageUrl: String(cur.imageUrl || ''),
1728
+ imageData: imageOf(cur, field),
1729
+ iconData: iconOf(cur, field),
1730
+ })
1146
1731
  log('test notice sent: ' + field)
1147
1732
  } catch (e) {
1148
1733
  log('test notice failed: ' + (e && e.message ? e.message : e))
@@ -1160,6 +1745,14 @@ window.__ModuleLoader__.load({
1160
1745
  scope.set('titleTemplate', def.titleTemplate),
1161
1746
  scope.set('titleTemplates', def.titleTemplates),
1162
1747
  scope.set('pushMode', def.pushMode),
1748
+ scope.set('imageUrl', def.imageUrl),
1749
+ scope.set('iconUrl', def.iconUrl),
1750
+ scope.set('imagePreviewUrl', def.imagePreviewUrl),
1751
+ scope.set('iconPreviewUrl', def.iconPreviewUrl),
1752
+ scope.set('images', def.images),
1753
+ scope.set('icons', def.icons),
1754
+ scope.set('imagePreviews', def.imagePreviews),
1755
+ scope.set('iconPreviews', def.iconPreviews),
1163
1756
  ]
1164
1757
  Promise.all(tasks).then(function () {
1165
1758
  setDraft(null)
@@ -1182,6 +1775,13 @@ window.__ModuleLoader__.load({
1182
1775
  if (!ready) {
1183
1776
  body = h('div', { style: { padding: '12px 0', color: SUBTLE, fontSize: '13px' } }, snap.status === 'unavailable' ? t.unavailable : t.loading)
1184
1777
  } else {
1778
+ /** 行内小按钮(矩形、垂直居中):内容行的「+/-」与纸飞机、按原因定制标题的「+」共用。 */
1779
+ var ICON_BTN = {
1780
+ appearance: 'none', width: '28px', height: '28px', borderRadius: '6px', padding: '0', flex: 'none',
1781
+ border: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.35))',
1782
+ background: 'none', color: 'var(--dsw-alias-label-secondary, inherit)', cursor: 'pointer',
1783
+ fontSize: '16px', lineHeight: '1', display: 'flex', alignItems: 'center', justifyContent: 'center',
1784
+ }
1185
1785
  var rows = [
1186
1786
  h('div', {
1187
1787
  key: 'preset-row',
@@ -1207,12 +1807,20 @@ window.__ModuleLoader__.load({
1207
1807
  return darkOption({ value: p.id, key: p.id }, t.presets[p.labelKey] || p.labelKey)
1208
1808
  }),
1209
1809
  customPresets.map(function (cp) {
1210
- return darkOption({ value: cp.id, key: cp.id }, isUnnamedId(cp.id) ? unnamedDisplayName(cp.id, t) : cp.name)
1810
+ // 有名字显示名字(含重命名升级后的预设);无名字(自动未命名预设)才显示「未命名」
1811
+ return darkOption({ value: cp.id, key: cp.id }, cp.name ? cp.name : unnamedDisplayName(cp.id, t))
1211
1812
  })),
1212
1813
  h('button', {
1213
1814
  type: 'button', title: t.saveAsTitle, onClick: function () { setNaming(!naming); setName('') },
1214
1815
  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)' },
1215
1816
  }, t.saveAs),
1817
+ shownCustom
1818
+ ? h('button', {
1819
+ type: 'button', title: t.renameTitle,
1820
+ onClick: function () { setRenameTarget(shownEntry.id); setNaming(false); setName(shownEntry.name) },
1821
+ 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)' },
1822
+ }, t.rename)
1823
+ : null,
1216
1824
  shownCustom
1217
1825
  ? h('button', {
1218
1826
  type: 'button', title: t.removeTitle, onClick: deleteCustom,
@@ -1220,18 +1828,29 @@ window.__ModuleLoader__.load({
1220
1828
  }, t.remove)
1221
1829
  : null,
1222
1830
  ),
1223
- naming
1831
+ (naming || renameTarget)
1224
1832
  ? h('div', { style: { display: 'flex', gap: '6px', marginTop: '2px' } },
1225
1833
  h('input', {
1226
1834
  value: newName,
1227
1835
  placeholder: t.presetNamePlaceholder,
1228
1836
  autoFocus: true,
1229
- onKeyDown: function (e) { if (e.key === 'Enter') saveAsCustom(); if (e.key === 'Escape') { setNaming(false); setName('') } },
1837
+ onKeyDown: function (e) {
1838
+ if (e.key === 'Enter') { if (renameTarget) renameCustom(); else saveAsCustom() }
1839
+ if (e.key === 'Escape') { setNaming(false); setRenameTarget(null); setName('') }
1840
+ },
1230
1841
  onChange: function (e) { setName(e.target.value) },
1231
1842
  style: Object.assign({}, SELECT_STYLE, { flex: 1, fontSize: '13px' }),
1232
1843
  }),
1233
- 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),
1234
- 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),
1844
+ h('button', {
1845
+ type: 'button',
1846
+ onClick: function () { if (renameTarget) renameCustom(); else saveAsCustom() },
1847
+ 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)' },
1848
+ }, t.confirm),
1849
+ h('button', {
1850
+ type: 'button',
1851
+ onClick: function () { setNaming(false); setRenameTarget(null); setName('') },
1852
+ 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)' },
1853
+ }, t.cancel),
1235
1854
  )
1236
1855
  : null,
1237
1856
  ),
@@ -1249,112 +1868,215 @@ window.__ModuleLoader__.load({
1249
1868
  darkOption({ value: 'system', key: 'system' }, t.pushSystem),
1250
1869
  darkOption({ value: 'toast', key: 'toast' }, t.pushToast),
1251
1870
  ]), 'pushMode'),
1252
- h('div', { key: 'title-row', style: { display: 'flex', flexDirection: 'column', gap: '6px', padding: '12px 0', borderTop: DIVIDER } },
1253
- h('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' } },
1254
- h('div', { style: { fontSize: '13px', color: 'var(--dsw-alias-label-secondary, inherit)' } }, t.titleLabel),
1255
- h('button', {
1256
- type: 'button', title: t.insertTitle,
1257
- onClick: function () { setMenuFor(menuFor === 'title' ? null : 'title') },
1258
- 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)' },
1259
- }, t.insertInfo),
1260
- ),
1261
- h(ChipEditor, {
1262
- template: String(cur.titleTemplate ?? ''),
1263
- lang: cur.language,
1264
- hint: '',
1265
- placeholder: t.titleHint,
1266
- onChange: function (tpl) { setVal('titleTemplate', tpl) },
1267
- onReady: function (handle) {
1268
- if (handle) editorRefs.current.title = handle
1269
- else delete editorRefs.current.title
1270
- },
1271
- }),
1272
- menuFor === 'title'
1273
- ? 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))' } },
1274
- h('button', {
1275
- type: 'button', onClick: function () {
1276
- var handle = editorRefs.current.title
1277
- if (handle) handle.insertAtCaret('title')
1278
- else setVal('titleTemplate', String(cur.titleTemplate ?? '') + '{title}')
1279
- // 插入后保持菜单展开:方便连续插入多个标签,由用户自行折叠
1280
- },
1281
- 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)' },
1282
- }, '+ ' + t.tokens.title)
1283
- )
1284
- : null,
1871
+ // 通知媒体:自定义大图(Notification image)与小图标(Notification icon),仅系统通知通道生效;
1872
+ // 两张上传卡片并排一行(中间留白),上方是处理方式说明
1873
+ h('div', { key: 'media-note', style: { fontSize: '12px', color: 'var(--dsw-alias-label-tertiary, rgba(127,127,127,0.8))', lineHeight: 1.6, padding: '8px 0 0' } }, t.mediaNote),
1874
+ h('div', { key: 'media-row', style: { display: 'flex', alignItems: 'flex-start', gap: '36px', padding: '12px 0', borderTop: DIVIDER } },
1875
+ mediaCard('iconUrl', 128, 1, t.iconUrl),
1876
+ mediaCard('imageUrl', 512, 16 / 9, t.imageUrl),
1285
1877
  ),
1286
- // 按原因定制标题(折叠区,避免界面臃肿):留空 = 用上方全局模板或语言默认标题
1287
- h('div', { key: 'title-tpl', style: { display: 'flex', flexDirection: 'column', gap: '6px', padding: '12px 0', borderTop: DIVIDER } },
1878
+ // 标题折叠区(默认收起):全局推送标题 + 按原因定制标题
1879
+ h('div', { key: 'title-fold', style: { display: 'flex', flexDirection: 'column', gap: '6px', padding: '12px 0', borderTop: DIVIDER } },
1288
1880
  h('button', {
1289
1881
  type: 'button',
1290
- onClick: function () { setTitleTplOpen(!titleTplOpen) },
1291
- style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', appearance: 'none', background: 'none', border: 'none', cursor: 'pointer', padding: '0', font: 'inherit', textAlign: 'left' },
1882
+ onClick: function () { setTitleFoldOpen(!titleFoldOpen) },
1883
+ style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', appearance: 'none', background: 'none', border: 'none', cursor: 'pointer', padding: '8px 10px', borderRadius: '8px', font: 'inherit', textAlign: 'left', width: '100%' },
1292
1884
  },
1293
- h('span', { style: { fontSize: '13px', color: 'var(--dsw-alias-label-secondary, inherit)' } }, t.titleTplLabel),
1294
- h('span', { style: { fontSize: '12px', color: SUBTLE } }, titleTplOpen ? t.titleTplClose : t.titleTplOpen),
1885
+ h('span', { style: { fontSize: '13px', fontWeight: 600, color: 'var(--dsw-alias-label-secondary, inherit)' } }, t.titlesGroup),
1886
+ h('span', { style: { fontSize: '18px', lineHeight: '1', color: SUBTLE, display: 'inline-block', width: '18px', textAlign: 'center' } }, titleFoldOpen ? '▾' : '▸'),
1295
1887
  ),
1296
- titleTplOpen
1297
- ? REASON_FIELDS.map(function (field) {
1298
- return h('div', { key: 'tt-' + field, style: { display: 'flex', alignItems: 'center', gap: '8px' } },
1299
- h('span', { style: { width: '76px', flex: 'none', fontSize: '12px', color: 'var(--dsw-alias-label-tertiary, rgba(127,127,127,0.8))' } }, t.fields[field]),
1300
- h('input', {
1301
- value: titleTplOf(cur, field),
1302
- placeholder: t.ttPlaceholders[field],
1303
- onChange: function (e) { setVal('tt-' + field, e.target.value) },
1304
- style: Object.assign({}, SELECT_STYLE, { flex: 1, fontSize: '13px' }),
1305
- }),
1306
- )
1307
- })
1308
- : null,
1309
1888
  ),
1889
+ titleFoldOpen
1890
+ ? h('div', { key: 'title-fold-body', style: { display: 'flex', flexDirection: 'column', gap: '12px', padding: '12px 0 12px 12px', borderTop: DIVIDER, borderLeft: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.25))' } },
1891
+ h('div', { style: { display: 'flex', flexDirection: 'column', gap: '6px' } },
1892
+ h('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '8px' } },
1893
+ h('span', { style: { fontSize: '13px', color: 'var(--dsw-alias-label-secondary, inherit)' } }, t.titleLabel),
1894
+ h('button', {
1895
+ type: 'button', title: t.insertTitle,
1896
+ onClick: function () { setTitleMenuOpen(!titleMenuOpen) },
1897
+ 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)' },
1898
+ }, t.insertInfo),
1899
+ ),
1900
+ // 推送标题的信息插入菜单:与原因栏同款(可插 {title}{duration}{usage}{error}{cache}{tps},不含图片)
1901
+ titleMenuOpen
1902
+ ? h('div', {
1903
+ key: 'title-menu',
1904
+ style: {
1905
+ display: 'flex', flexWrap: 'wrap', gap: '6px', padding: '8px',
1906
+ borderRadius: '8px', border: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.35))',
1907
+ background: 'var(--dsw-alias-bg-layer-2, rgba(127,127,127,0.10))',
1908
+ },
1909
+ }, TOKEN_KEYS.map(function (k) {
1910
+ return h('button', {
1911
+ key: k, type: 'button',
1912
+ onClick: function () {
1913
+ var handle = editorRefs.current.__title
1914
+ if (handle) handle.insertAtCaret(k)
1915
+ else setVal('titleTemplate', String(cur.titleTemplate ?? '') + '{' + k + '}')
1916
+ // 插入后保持菜单展开:方便连续插入多个标签,由用户自行折叠
1917
+ },
1918
+ style: {
1919
+ appearance: 'none', font: 'inherit', fontSize: '12px', cursor: 'pointer', padding: '4px 10px',
1920
+ borderRadius: '999px', border: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.35))',
1921
+ background: 'none', color: 'var(--dsw-alias-label-secondary, inherit)',
1922
+ },
1923
+ }, '+ ' + t.tokens[k])
1924
+ }))
1925
+ : null,
1926
+ // 推送标题 = Chip 编辑器(标签化显示,与正文模板同款交互:光标处插入胶囊、点击移除)
1927
+ h(ChipEditor, {
1928
+ template: String(cur.titleTemplate ?? ''),
1929
+ lang: cur.language,
1930
+ placeholder: t.titleHint,
1931
+ imageData: '',
1932
+ iconData: '',
1933
+ imagePreview: '',
1934
+ iconPreview: '',
1935
+ onChange: function (tpl) { setVal('titleTemplate', tpl) },
1936
+ onReady: function (handle) {
1937
+ if (handle) editorRefs.current.__title = handle
1938
+ else delete editorRefs.current.__title
1939
+ },
1940
+ onPreview: function (src) { setPreviewUrl(src) },
1941
+ }),
1942
+ ),
1943
+ // 按原因定制标题:留空 = 用上方全局模板或语言默认标题;
1944
+ // 每行 Chip 编辑器(标签化显示,同推送标题)+ 「+」插入按钮(只插信息标签,不含图片/图标)
1945
+ h('div', { style: { display: 'flex', flexDirection: 'column', gap: '6px' } },
1946
+ h('span', { style: { fontSize: '13px', color: 'var(--dsw-alias-label-secondary, inherit)' } }, t.titleTplLabel),
1947
+ REASON_FIELDS.map(function (field) {
1948
+ return h('div', { key: 'tt-' + field, style: { display: 'flex', flexDirection: 'column', gap: '4px' } },
1949
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: '8px' } },
1950
+ h('span', { style: { width: '50px', flex: 'none', whiteSpace: 'nowrap', fontSize: '12px', color: 'var(--dsw-alias-label-tertiary, rgba(127,127,127,0.8))' } }, t.fields[field]),
1951
+ h('div', { style: { flex: 1, minWidth: 0 } },
1952
+ h(ChipEditor, {
1953
+ template: titleTplOf(cur, field),
1954
+ lang: cur.language,
1955
+ compact: true,
1956
+ blockMedia: true, // 只允许信息标签,{image}/{icon} 保持字面文本(发送时剥除)
1957
+ placeholder: t.ttPlaceholders[field],
1958
+ imageData: '',
1959
+ iconData: '',
1960
+ imagePreview: '',
1961
+ iconPreview: '',
1962
+ onChange: function (tpl) { setVal('tt-' + field, tpl) },
1963
+ onReady: function (handle) {
1964
+ if (handle) ttHandles.current[field] = handle
1965
+ else delete ttHandles.current[field]
1966
+ },
1967
+ }),
1968
+ ),
1969
+ h('button', {
1970
+ type: 'button', title: t.insertTitle,
1971
+ onClick: function () { setTtMenuFor(ttMenuFor === field ? null : field) },
1972
+ style: ICON_BTN,
1973
+ }, ttMenuFor === field
1974
+ ? h('svg', { width: '14', height: '14', viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: '2.5', strokeLinecap: 'round', style: { display: 'block' } },
1975
+ h('line', { x1: '5', y1: '12', x2: '19', y2: '12' }))
1976
+ : h('svg', { width: '14', height: '14', viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: '2.5', strokeLinecap: 'round', style: { display: 'block' } },
1977
+ h('line', { x1: '12', y1: '5', x2: '12', y2: '19' }),
1978
+ h('line', { x1: '5', y1: '12', x2: '19', y2: '12' }))),
1979
+ ),
1980
+ ttMenuFor === field
1981
+ ? h('div', {
1982
+ key: 'tt-menu-' + field,
1983
+ style: {
1984
+ display: 'flex', flexWrap: 'wrap', gap: '6px', padding: '8px',
1985
+ borderRadius: '8px', border: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.35))',
1986
+ background: 'var(--dsw-alias-bg-layer-2, rgba(127,127,127,0.10))',
1987
+ },
1988
+ }, TOKEN_KEYS.map(function (k) {
1989
+ return h('button', {
1990
+ key: k, type: 'button',
1991
+ onClick: function () {
1992
+ var handle = ttHandles.current[field]
1993
+ if (handle) handle.insertAtCaret(k)
1994
+ else setVal('tt-' + field, String(titleTplOf(cur, field) ?? '') + '{' + k + '}')
1995
+ },
1996
+ style: {
1997
+ appearance: 'none', font: 'inherit', fontSize: '12px', cursor: 'pointer', padding: '4px 10px',
1998
+ borderRadius: '999px', border: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.35))',
1999
+ background: 'none', color: 'var(--dsw-alias-label-secondary, inherit)',
2000
+ },
2001
+ }, '+ ' + t.tokens[k])
2002
+ }))
2003
+ : null,
2004
+ )
2005
+ }),
2006
+ ),
2007
+ )
2008
+ : null,
1310
2009
  ]
2010
+ // 内容折叠区(默认收起,保持面板简洁):展开后才是 5 条原因的正文模板编辑器
2011
+ rows.push(h('div', {
2012
+ key: 'tpl-fold',
2013
+ style: { display: 'flex', flexDirection: 'column', gap: '6px', padding: '12px 0', borderTop: DIVIDER },
2014
+ },
2015
+ h('button', {
2016
+ type: 'button',
2017
+ onClick: function () { setTplOpen(!tplOpen) },
2018
+ style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', appearance: 'none', background: 'none', border: 'none', cursor: 'pointer', padding: '8px 10px', borderRadius: '8px', font: 'inherit', textAlign: 'left', width: '100%' },
2019
+ },
2020
+ h('span', { style: { fontSize: '13px', fontWeight: 600, color: 'var(--dsw-alias-label-secondary, inherit)' } }, t.tplLabel),
2021
+ h('span', { style: { fontSize: '18px', lineHeight: '1', color: SUBTLE, display: 'inline-block', width: '18px', textAlign: 'center' } }, tplOpen ? '▾' : '▸'),
2022
+ ),
2023
+ ))
1311
2024
  // 模板(forEach:每条回调独立绑定字段,避免 var 闭包串写)
1312
2025
  // 每栏 = 标签行(左文案 + 右「+插入信息」)+ ChipEditor(文字+内联胶囊,光标处插入)
1313
2026
  // + 实时预览(正文普通文本、信息为示例值)
1314
- REASON_FIELDS.forEach(function (field) {
1315
- var tplValue = templateOf(cur, field)
1316
- // 预览:空模板时展示默认内容(与编辑器一致);否则展示当前模板
1317
- var previewTpl = tplValue === '' ? defaultHintOf(field, cur.language) : tplValue
1318
- var segments = parseTemplate(previewTpl)
1319
- rows.push(h('div', {
2027
+ if (tplOpen) {
2028
+ var tplRows = []
2029
+ REASON_FIELDS.forEach(function (field) {
2030
+ var tplValue = templateOf(cur, field)
2031
+ // 一行式:原因标签 + Chip 编辑器(紧凑内边距)+ 「+/−」插入菜单按钮 + 发送箭头按钮
2032
+ tplRows.push(h('div', {
1320
2033
  key: 'tpl-' + field,
1321
- style: { display: 'flex', flexDirection: 'column', gap: '6px', padding: '12px 0', borderTop: DIVIDER },
2034
+ style: { display: 'flex', flexDirection: 'column', gap: '4px', padding: '6px 0' },
1322
2035
  },
1323
- h('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '8px' } },
1324
- h('div', { style: { fontSize: '13px', color: 'var(--dsw-alias-label-secondary, inherit)' } }, t.fields[field]),
1325
- h('div', { style: { display: 'flex', gap: '6px' } },
1326
- h('button', {
1327
- type: 'button',
1328
- title: t.sendTestTitle,
1329
- onClick: function () { sendTestNotice(field) },
1330
- style: {
1331
- appearance: 'none', font: 'inherit', fontSize: '12px', cursor: 'pointer', padding: '3px 10px',
1332
- borderRadius: '999px', border: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.35))',
1333
- background: 'none', color: 'var(--dsw-alias-label-secondary, inherit)',
2036
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: '8px' } },
2037
+ h('span', { style: { width: '50px', flex: 'none', whiteSpace: 'nowrap', fontSize: '12px', color: 'var(--dsw-alias-label-tertiary, rgba(127,127,127,0.8))' } }, t.fields[field]),
2038
+ h('div', { style: { flex: 1, minWidth: 0 } },
2039
+ h(ChipEditor, {
2040
+ template: tplValue,
2041
+ lang: cur.language,
2042
+ compact: true,
2043
+ hint: defaultHintOf(field, cur.language), // 空模板(默认预设)时显示默认文案,所见即所推
2044
+ placeholder: t.placeholder, // 用户编辑后删空 显示占位提示(CSS 伪元素,不可选中/删除)
2045
+ imageData: imageOf(cur, field),
2046
+ iconData: iconOf(cur, field),
2047
+ imagePreview: previewOf(cur, 'img', field),
2048
+ iconPreview: previewOf(cur, 'icon', field),
2049
+ onChange: function (tpl) {
2050
+ setVal('tpl-' + field, tpl)
2051
+ // 标签即数据开关:模板中不再含 {image}/{icon} → 即时清除该原因已上传的图片/图标数据
2052
+ if (String(tpl).indexOf('{image}') === -1) setVal('img-' + field, '')
2053
+ if (String(tpl).indexOf('{icon}') === -1) setVal('icon-' + field, '')
1334
2054
  },
1335
- }, t.sendTest),
1336
- h('button', {
1337
- type: 'button',
1338
- title: t.insertTitle,
1339
- onClick: function () { setMenuFor(menuFor === field ? null : field) },
1340
- style: {
1341
- appearance: 'none', font: 'inherit', fontSize: '12px', cursor: 'pointer', padding: '3px 10px',
1342
- borderRadius: '999px', border: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.35))',
1343
- background: 'none', color: 'var(--dsw-alias-label-secondary, inherit)',
2055
+ onReady: function (handle) {
2056
+ if (handle) editorRefs.current[field] = handle
2057
+ else delete editorRefs.current[field]
1344
2058
  },
1345
- }, t.insertInfo),
2059
+ onPreview: function (src) { setPreviewUrl(src) },
2060
+ }),
1346
2061
  ),
2062
+ h('button', {
2063
+ type: 'button', title: t.insertTitle,
2064
+ onClick: function () { setMenuFor(menuFor === field ? null : field) },
2065
+ style: ICON_BTN,
2066
+ }, menuFor === field
2067
+ ? h('svg', { width: '14', height: '14', viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: '2.5', strokeLinecap: 'round', style: { display: 'block' } },
2068
+ h('line', { x1: '5', y1: '12', x2: '19', y2: '12' }))
2069
+ : h('svg', { width: '14', height: '14', viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: '2.5', strokeLinecap: 'round', style: { display: 'block' } },
2070
+ h('line', { x1: '12', y1: '5', x2: '12', y2: '19' }),
2071
+ h('line', { x1: '5', y1: '12', x2: '19', y2: '12' }))),
2072
+ h('button', {
2073
+ type: 'button', title: t.sendTestTitle,
2074
+ onClick: function () { sendTestNotice(field) },
2075
+ style: ICON_BTN,
2076
+ }, h('svg', { width: '15', height: '15', viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: '2', strokeLinecap: 'round', strokeLinejoin: 'round', style: { display: 'block' } },
2077
+ h('path', { d: 'M5 12h14' }),
2078
+ h('path', { d: 'm12 5 7 7-7 7' }))),
1347
2079
  ),
1348
- h(ChipEditor, {
1349
- template: tplValue,
1350
- lang: cur.language,
1351
- hint: defaultHintOf(field, cur.language),
1352
- onChange: function (tpl) { setVal('tpl-' + field, tpl) },
1353
- onReady: function (handle) {
1354
- if (handle) editorRefs.current[field] = handle
1355
- else delete editorRefs.current[field]
1356
- },
1357
- }),
1358
2080
  menuFor === field
1359
2081
  ? h('div', {
1360
2082
  key: 'menu-' + field,
@@ -1378,20 +2100,44 @@ window.__ModuleLoader__.load({
1378
2100
  background: 'none', color: 'var(--dsw-alias-label-secondary, inherit)',
1379
2101
  },
1380
2102
  }, '+ ' + t.tokens[k])
1381
- }))
2103
+ }).concat([
2104
+ h('button', {
2105
+ key: 'image', type: 'button', title: t.imagePickTitle,
2106
+ onClick: function () {
2107
+ // 插入 {image} 标签 + 弹文件选择(上传的图压缩后存 img-<field>,保存时随设置文档持久化);
2108
+ // 已有图再点 = 替换。渲染时 {image} 只作开关被剥除,图片本体不进会话日志
2109
+ var handle = editorRefs.current[field]
2110
+ if (handle) handle.insertAtCaret('image')
2111
+ else setVal('tpl-' + field, templateOf(cur, field) + '{image}')
2112
+ pickReasonMedia(field, 'img', 512, 16 / 9)
2113
+ },
2114
+ style: {
2115
+ appearance: 'none', font: 'inherit', fontSize: '12px', cursor: 'pointer', padding: '4px 10px',
2116
+ borderRadius: '999px', border: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.35))',
2117
+ background: 'none', color: 'var(--dsw-alias-label-secondary, inherit)',
2118
+ },
2119
+ }, '+ ' + t.tokens.image),
2120
+ h('button', {
2121
+ key: 'icon', type: 'button', title: t.iconPickTitle,
2122
+ onClick: function () {
2123
+ // 插入 {icon} 标签 + 弹文件选择(图标 128×128 方形裁切,存 icon-<field>)
2124
+ var handle = editorRefs.current[field]
2125
+ if (handle) handle.insertAtCaret('icon')
2126
+ else setVal('tpl-' + field, templateOf(cur, field) + '{icon}')
2127
+ pickReasonMedia(field, 'icon', 128, 1)
2128
+ },
2129
+ style: {
2130
+ appearance: 'none', font: 'inherit', fontSize: '12px', cursor: 'pointer', padding: '4px 10px',
2131
+ borderRadius: '999px', border: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.35))',
2132
+ background: 'none', color: 'var(--dsw-alias-label-secondary, inherit)',
2133
+ },
2134
+ }, '+ ' + t.tokens.icon),
2135
+ ]))
1382
2136
  : null,
1383
2137
  ))
1384
- rows.push(h('div', {
1385
- key: 'pv-' + field,
1386
- 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 },
1387
- }, h('span', { style: { opacity: 0.85 } }, t.preview), segments.map(function (seg, idx) {
1388
- // 预览 = 最终效果:信息直接以示例值流入正文,不做任何标签样式
1389
- if (seg.type === 'text') {
1390
- return seg.value === '' ? null : h('span', { key: 'tv-' + field + '-' + idx }, seg.value)
1391
- }
1392
- return h('span', { key: 'tk-' + field + '-' + idx }, t.samples[seg.key] || '')
1393
- })))
1394
2138
  })
2139
+ rows.push(h('div', { key: 'tpl-fold-body', style: { display: 'flex', flexDirection: 'column', padding: '12px 0 0 12px', borderTop: DIVIDER, borderLeft: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.25))' } }, tplRows))
2140
+ }
1395
2141
  // 通知权限状态区(footer 之上):授权状态 + 引导(Edge 自动屏蔽等场景一目了然)
1396
2142
  rows.push(notificationPermissionRow(t, requestPermissionNow))
1397
2143
  rows.push(h('div', {
@@ -1449,6 +2195,22 @@ window.__ModuleLoader__.load({
1449
2195
  chevron(open),
1450
2196
  ),
1451
2197
  open ? h('div', { style: { margin: '0 16px', paddingBottom: '8px' } }, body) : null,
2198
+ // 大图预览(lightbox):点击图片/图标缩略图时全屏查看,点击任意处关闭
2199
+ previewUrl
2200
+ ? h('div', {
2201
+ key: 'lightbox',
2202
+ onClick: function () { setPreviewUrl(null) },
2203
+ style: {
2204
+ position: 'fixed', inset: '0', zIndex: 9999, cursor: 'zoom-out',
2205
+ background: 'rgba(0,0,0,0.82)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '24px',
2206
+ },
2207
+ },
2208
+ h('img', {
2209
+ src: previewUrl, alt: '',
2210
+ style: { maxWidth: '92%', maxHeight: '92%', borderRadius: '8px', objectFit: 'contain', boxShadow: '0 8px 40px rgba(0,0,0,0.5)' },
2211
+ }),
2212
+ )
2213
+ : null,
1452
2214
  )
1453
2215
  }
1454
2216
  }