@telosmaylx/dsh-session-notify 0.1.12 → 0.1.16

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
@@ -52,13 +52,13 @@ window.__ModuleLoader__.load({
52
52
  imagePickTitle: '选择本地图片(自动压缩为通知大图)', iconPickTitle: '选择本地图片(自动压缩为通知图标)', imageFailed: '图片读取失败',
53
53
  clearMedia: '清除',
54
54
  mediaNote: '本地上传会自动处理:大图按通知显示比例 16:9 居中裁切(512×288)、图标 1:1 方形裁切(128×128),裁切后即通知中显示的效果',
55
- presets: { custom: '自定义预设', unnamed: '未命名预设', default: '默认', playful: '轻松风格 🎉', formal: '正式简洁', detail: '详细报告' },
55
+ presets: { custom: '自定义预设', unnamed: '未命名预设', default: '默认', kaomoji: '颜文字', felyne: '艾露猫', neko: '猫娘', deepseekChan: 'DeepSeek 娘' },
56
56
  placeholder: '留空则使用默认文案',
57
57
  insertInfo: '+ 插入信息', insertTitle: '在光标处插入信息', sendTestTitle: '发送测试通知(当前模板渲染)',
58
58
  skipSubagents: '跳过子代理会话',
59
59
  pushMode: '推送方式', pushDual: '系统通知 + 页内提示', pushSystem: '仅系统通知', pushToast: '仅页内提示',
60
60
  imageUrl: '通知大图', iconUrl: '通知图标',
61
- discard: '放弃', save: '保存', saving: '保存中…', saved: '已保存 ✓ · 刷新页面后生效', saveFailed: '保存失败:', refresh: '点击刷新', reset: '重置', resetDone: '已重置为默认值 · 刷新页面后生效',
61
+ discard: '放弃', save: '保存', saving: '保存中…', saved: '已保存 ✓ · 刷新页面后生效', customDeleteConfirm: '确定删除该自定义预设?此操作不可恢复。', presetDeletedKeepApplied: '已应用的配置不受影响;如需恢复默认请点「重置」', presetNameExists: '已存在同名预设,请换一个名称', saveDuringEdit: '已保存,但保存期间有新修改未保存。', noChange: '当前配置与默认一致,无需保存', saveFailed: '保存失败:', refresh: '点击刷新', reset: '重置', resetDone: '已重置为默认值 · 刷新页面后生效',
62
62
  permTitle: '通知权限', permGranted: '已授权:系统通知可用', permDefault: '尚未授权', permDenied: '已被浏览器屏蔽', permUnsupported: '当前环境不支持系统通知',
63
63
  permDefaultHint: '点击「请求授权」;若浏览器未弹出询问,请用地址栏权限图标手动允许', permDeniedHint: '浏览器阻止了本网站的通知:点击地址栏左侧权限图标 → 网站设置 → 通知 → 允许,然后刷新页面', permUnsupportedHint: '系统通知需要 HTTPS 或 localhost 访问;可改用「仅页内提示」', permRequest: '请求授权',
64
64
  saveAs: '新增', rename: '重命名', update: '修改', remove: '删除', presetNamePlaceholder: '预设名称', confirm: '确定', cancel: '取消', customSaved: '自定义预设已保存', customUpdated: '自定义预设已更新', customDeleted: '自定义预设已删除', saveAsTitle: '将当前配置另存为自定义预设', renameTitle: '重命名当前自定义预设', updateTitle: '更新当前自定义预设', removeTitle: '删除当前自定义预设', fromPreset: '来自:', modifiedSuffix: ' · 已修改',
@@ -76,13 +76,13 @@ window.__ModuleLoader__.load({
76
76
  imagePickTitle: '選擇本機圖片(自動壓縮為通知大圖)', iconPickTitle: '選擇本機圖片(自動壓縮為通知圖示)', imageFailed: '圖片讀取失敗',
77
77
  clearMedia: '清除',
78
78
  mediaNote: '本機上傳會自動處理:大圖按通知顯示比例 16:9 居中裁切(512×288)、圖示 1:1 方形裁切(128×128),裁切後即通知中顯示的效果',
79
- presets: { custom: '自訂預設', unnamed: '未命名預設', default: '預設', playful: '輕鬆風格 🎉', formal: '正式簡潔', detail: '詳細報告' },
79
+ presets: { custom: '自訂預設', unnamed: '未命名預設', default: '預設', kaomoji: '顏文字', felyne: '艾露貓', neko: '貓娘', deepseekChan: 'DeepSeek 娘' },
80
80
  placeholder: '留空則使用預設文案',
81
81
  insertInfo: '+ 插入資訊', insertTitle: '在游標處插入資訊', sendTestTitle: '傳送測試通知(目前範本渲染)',
82
82
  skipSubagents: '跳過子代理會話',
83
83
  pushMode: '推送方式', pushDual: '系統通知 + 頁內提示', pushSystem: '僅系統通知', pushToast: '僅頁內提示',
84
84
  imageUrl: '通知大圖', iconUrl: '通知圖示',
85
- discard: '放棄', save: '儲存', saving: '儲存中…', saved: '已儲存 ✓ · 重新整理頁面後生效', saveFailed: '儲存失敗:', refresh: '點擊重新整理', reset: '重置', resetDone: '已重置為預設值 · 重新整理頁面後生效',
85
+ discard: '放棄', save: '儲存', saving: '儲存中…', saved: '已儲存 ✓ · 重新整理頁面後生效', customDeleteConfirm: '確定刪除該自訂預設?此操作不可復原。', presetDeletedKeepApplied: '已套用的設定不受影響;如需恢復預設請點「重設」', presetNameExists: '已存在同名預設,請換一個名稱', saveDuringEdit: '已儲存,但儲存期間有新修改未儲存。', noChange: '目前設定與預設一致,無需儲存', saveFailed: '儲存失敗:', refresh: '點擊重新整理', reset: '重置', resetDone: '已重置為預設值 · 重新整理頁面後生效',
86
86
  permTitle: '通知權限', permGranted: '已授權:系統通知可用', permDefault: '尚未授權', permDenied: '已被瀏覽器封鎖', permUnsupported: '目前環境不支援系統通知',
87
87
  permDefaultHint: '點擊「請求授權」;若瀏覽器未彈出詢問,請用網址列權限圖示手動允許', permDeniedHint: '瀏覽器封鎖了本網站的通知:點擊網址列左側權限圖示 → 網站設定 → 通知 → 允許,然後重新整理頁面', permUnsupportedHint: '系統通知需要 HTTPS 或 localhost 存取;可改用「僅頁內提示」', permRequest: '請求授權',
88
88
  saveAs: '新增', rename: '重新命名', update: '修改', remove: '刪除', presetNamePlaceholder: '預設名稱', confirm: '確定', cancel: '取消', customSaved: '自訂預設已儲存', customUpdated: '自訂預設已更新', customDeleted: '自訂預設已刪除', saveAsTitle: '將當前設定另存為自訂預設', renameTitle: '重新命名目前自訂預設', updateTitle: '更新目前自訂預設', removeTitle: '刪除目前自訂預設', fromPreset: '來自:', modifiedSuffix: ' · 已修改',
@@ -100,13 +100,13 @@ window.__ModuleLoader__.load({
100
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
101
  clearMedia: 'Clear',
102
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',
103
- presets: { custom: 'Custom preset', unnamed: 'Untitled preset', default: 'Default', playful: 'Playful 🎉', formal: 'Formal', detail: 'Detailed report' },
103
+ presets: { custom: 'Custom preset', unnamed: 'Untitled preset', default: 'Default', kaomoji: 'Kaomoji', felyne: 'Felyne (MH cat)', neko: 'Neko girl', deepseekChan: 'DeepSeek-chan' },
104
104
  placeholder: 'Leave empty to use the default text',
105
105
  insertInfo: '+ Insert info', insertTitle: 'Insert info at cursor', sendTestTitle: 'Send test notification (rendered from current template)',
106
106
  skipSubagents: 'Skip subagent sessions',
107
107
  pushMode: 'Push channel', pushDual: 'System + in-page', pushSystem: 'System only', pushToast: 'In-page only',
108
108
  imageUrl: 'Notification image', iconUrl: 'Notification icon',
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',
109
+ discard: 'Discard', save: 'Save', saving: 'Saving…', saved: 'Saved ✓ · refresh page to apply', customDeleteConfirm: 'Delete this custom preset? This cannot be undone.', presetDeletedKeepApplied: 'The applied configuration is unaffected; to restore defaults, click Reset', presetNameExists: 'A preset with this name already exists; please use another name', saveDuringEdit: 'Saved, but there are unsaved changes made during saving.', noChange: 'Current config matches the default; nothing to save.', saveFailed: 'Save failed: ', refresh: 'Refresh', reset: 'Reset', resetDone: 'Reset to defaults ✓ · refresh page to apply',
110
110
  permTitle: 'Notification permission', permGranted: 'Granted: system notifications available', permDefault: 'Not yet granted', permDenied: 'Blocked by browser', permUnsupported: 'System notifications not supported here',
111
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',
112
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',
@@ -124,13 +124,13 @@ window.__ModuleLoader__.load({
124
124
  imagePickTitle: 'ローカル画像を選択(通知用に自動圧縮)', iconPickTitle: 'ローカル画像を選択(通知アイコン用に自動圧縮)', imageFailed: '画像の読み込みに失敗しました',
125
125
  clearMedia: 'クリア',
126
126
  mediaNote: 'ローカルアップロードは自動処理されます:大きな画像は通知表示比率 16:9(512×288)に中央クロップ、アイコンは 1:1 正方形(128×128)。クロップ後の見た目がそのまま通知に表示されます',
127
- presets: { custom: 'カスタムプリセット', unnamed: '未命名プリセット', default: 'デフォルト', playful: 'カジュアル 🎉', formal: 'フォーマル', detail: '詳細レポート' },
127
+ presets: { custom: 'カスタムプリセット', unnamed: '未命名プリセット', default: 'デフォルト', kaomoji: '顔文字', felyne: 'アイルー', neko: 'ネコ娘', deepseekChan: 'DeepSeekちゃん' },
128
128
  placeholder: '空欄の場合はデフォルトの文面を使用',
129
129
  insertInfo: '+ 情報を挿入', insertTitle: 'カーソル位置に情報を挿入', sendTestTitle: 'テスト通知を送信(現在のテンプレートで描画)',
130
130
  skipSubagents: 'サブエージェントセッションをスキップ',
131
131
  pushMode: '通知チャネル', pushDual: 'システム+ページ内', pushSystem: 'システムのみ', pushToast: 'ページ内のみ',
132
132
  imageUrl: '通知イメージ', iconUrl: '通知アイコン',
133
- discard: '破棄', save: '保存', saving: '保存中…', saved: '保存しました ✓ · ページを再読み込み', saveFailed: '保存失敗:', refresh: '再読み込み', reset: 'リセット', resetDone: 'デフォルトに戻しました ✓ · ページを再読み込み',
133
+ discard: '破棄', save: '保存', saving: '保存中…', saved: '保存しました ✓ · ページを再読み込み', customDeleteConfirm: 'このカスタムプリセットを削除しますか?この操作は取り消せません。', presetDeletedKeepApplied: '適用済みの設定には影響しません。デフォルトに戻すには「リセット」をクリック', presetNameExists: '同名のプリセットが既に存在します。別の名前を付けてください', saveDuringEdit: '保存しましたが、保存中に新しい変更があります(未保存)。', noChange: '現在の設定はデフォルトと同じです。保存の必要はありません', saveFailed: '保存失敗:', refresh: '再読み込み', reset: 'リセット', resetDone: 'デフォルトに戻しました ✓ · ページを再読み込み',
134
134
  permTitle: '通知権限', permGranted: '許可済み:システム通知が利用可能', permDefault: '未許可', permDenied: 'ブラウザがブロック', permUnsupported: 'この環境ではシステム通知を利用できません',
135
135
  permDefaultHint: '「許可をリクエスト」をクリック。ブラウザに確認が出ない場合は、アドレスバーの権限アイコンから手動で許可', permDeniedHint: 'このサイトの通知がブロックされています:アドレスバー左の権限アイコン → サイト設定 → 通知 → 許可 → ページを再読み込み', permUnsupportedHint: 'システム通知には HTTPS または localhost が必要です。「ページ内のみ」を推奨', permRequest: '許可をリクエスト',
136
136
  saveAs: '新規', rename: '名前を変更', update: '編集', remove: '削除', presetNamePlaceholder: 'プリセット名', confirm: '決定', cancel: 'キャンセル', customSaved: 'カスタムプリセットを保存しました', customUpdated: 'カスタムプリセットを更新しました', customDeleted: 'カスタムプリセットを削除しました', saveAsTitle: '現在の設定をカスタムプリセットとして保存', renameTitle: '現在のカスタムプリセットの名前を変更', updateTitle: '現在のカスタムプリセットを更新', removeTitle: '現在のカスタムプリセットを削除', fromPreset: '由来:', modifiedSuffix: ' · 変更あり',
@@ -148,13 +148,13 @@ window.__ModuleLoader__.load({
148
148
  imagePickTitle: '로컬 이미지 선택(알림용으로 자동 압축)', iconPickTitle: '로컬 이미지 선택(알림 아이콘용으로 자동 압축)', imageFailed: '이미지를 읽지 못했습니다',
149
149
  clearMedia: '지우기',
150
150
  mediaNote: '로컬 업로드는 자동 처리됩니다: 큰 이미지는 알림 표시 비율 16:9(512×288) 중앙 크롭, 아이콘은 1:1 정사각(128×128). 크롭 후 모습이 그대로 알림에 표시됩니다',
151
- presets: { custom: '사용자 지정 프리셋', unnamed: '이름 없는 프리셋', default: '기본', playful: '캐주얼 🎉', formal: '정식', detail: '상세 보고' },
151
+ presets: { custom: '사용자 지정 프리셋', unnamed: '이름 없는 프리셋', default: '기본', kaomoji: '카오모지', felyne: '아이루', neko: '네코무스메', deepseekChan: 'DeepSeek짱' },
152
152
  placeholder: '비우면 기본 문구 사용',
153
153
  insertInfo: '+ 정보 삽입', insertTitle: '커서 위치에 정보 삽입', sendTestTitle: '테스트 알림 보내기 (현재 템플릿으로 렌더링)',
154
154
  skipSubagents: '하위 에이전트 세션 건너뛰기',
155
155
  pushMode: '알림 채널', pushDual: '시스템+페이지 내', pushSystem: '시스템만', pushToast: '페이지 내만',
156
156
  imageUrl: '알림 이미지', iconUrl: '알림 아이콘',
157
- discard: '포기', save: '저장', saving: '저장 중…', saved: '저장됨 ✓ · 새로고침 후 적용', saveFailed: '저장 실패: ', refresh: '새로고침', reset: '초기화', resetDone: '기본값으로 초기화됨 ✓ · 새로고침 후 적용',
157
+ discard: '포기', save: '저장', saving: '저장 중…', saved: '저장됨 ✓ · 새로고침 후 적용', customDeleteConfirm: '이 사용자 지정 프리셋을 삭제할까요? 이 작업은 되돌릴 수 없습니다.', presetDeletedKeepApplied: '적용된 구성에는 영향이 없습니다. 기본값으로 되돌리려면 「초기화」를 클릭하세요', presetNameExists: '같은 이름의 프리셋이 이미 있습니다. 다른 이름을 사용하세요', saveDuringEdit: '저장되었지만, 저장 중에 새로 수정된 내용이 있어 아직 저장되지 않았습니다.', noChange: '현재 설정이 기본값과 동일합니다. 저장할 필요가 없습니다', saveFailed: '저장 실패: ', refresh: '새로고침', reset: '초기화', resetDone: '기본값으로 초기화됨 ✓ · 새로고침 후 적용',
158
158
  permTitle: '알림 권한', permGranted: '허용됨: 시스템 알림 사용 가능', permDefault: '아직 허용되지 않음', permDenied: '브라우저에서 차단됨', permUnsupported: '이 환경에서는 시스템 알림을 사용할 수 없음',
159
159
  permDefaultHint: '「권한 요청」클릭. 브라우저에 확인이 없으면 주소 표시줄 권한 아이콘에서 수동 허용', permDeniedHint: '이 사이트의 알림이 차단되었습니다: 주소 표시줄 왼쪽 권한 아이콘 → 사이트 설정 → 알림 → 허용 후 새로고침', permUnsupportedHint: '시스템 알림은 HTTPS 또는 localhost가 필요합니다. 「페이지 내만」사용 권장', permRequest: '권한 요청',
160
160
  saveAs: '추가', rename: '이름 변경', update: '수정', remove: '삭제', presetNamePlaceholder: '프리셋 이름', confirm: '확인', cancel: '취소', customSaved: '사용자 지정 프리셋 저장됨', customUpdated: '사용자 지정 프리셋 업데이트됨', customDeleted: '사용자 지정 프리셋 삭제됨', saveAsTitle: '현재 구성을 사용자 지정 프리셋으로 저장', renameTitle: '현재 사용자 지정 프리셋 이름 변경', updateTitle: '현재 사용자 지정 프리셋 업데이트', removeTitle: '현재 사용자 지정 프리셋 삭제', fromPreset: '출처: ', modifiedSuffix: ' · 수정됨',
@@ -274,7 +274,12 @@ window.__ModuleLoader__.load({
274
274
  if (st && st.value && typeof st.value.titleTemplate === 'string' && st.value.titleTemplate.trim() !== '') tpl = st.value.titleTemplate
275
275
  }
276
276
  } catch (e) { /* 默认 */ }
277
- var out = String(tpl).replaceAll('{title}', name).trim()
277
+ // P1-5:与宿主 renderTitle 对齐——替换全部信息位,缺数据时替换为空串(剥除),避免 {duration} 等字面量泄漏到通知标题
278
+ var out = String(tpl)
279
+ .replace(/\{image\}/g, '').replace(/\{icon\}/g, '').replace(/\{label\}/g, '')
280
+ .replaceAll('{title}', name)
281
+ .replace(/\{duration\}/g, '').replace(/\{usage\}/g, '').replace(/\{error\}/g, '').replace(/\{cache\}/g, '').replace(/\{tps\}/g, '')
282
+ .trim()
278
283
  if (out === '') out = '任务已完成'
279
284
  return out + (suffix || '')
280
285
  }
@@ -571,9 +576,231 @@ window.__ModuleLoader__.load({
571
576
  * 注意:{label} 已从插入菜单移除(旧模板中的 {label} 仍兼容渲染/预览)。 */
572
577
  var TOKEN_KEYS = ['title', 'duration', 'usage', 'error', 'cache', 'tps']
573
578
 
574
- /** 系统预设:仅保留「默认」(作为基线,其余以自定义预设承载)。 */
579
+ /** 系统预设:「默认」为基线 + 4 套风格预设(简体中文文案,颜文字/口癖跨语言不自然,故固定 zh;切换语言仅界面翻译,模板保留)。 */
575
580
  var PRESETS = [
576
581
  { id: 'default', labelKey: 'default', language: 'zh', titleTemplate: '', titleTemplates: { completed: '', error: '', aborted: '', blocked: '', 'max-tokens': '' }, templates: { completed: '', error: '', aborted: '', blocked: '', 'max-tokens': '' } },
582
+ // 风格内置预设:i18n[语言] 存放各语言文案(zh 为用户编辑版);language 为 null → 载入不强制切换界面语言,
583
+ // 载入/切换语言时自动取当前语言的文案(缺语言回退 zh)
584
+ {
585
+ id: 'kaomoji', labelKey: 'kaomoji', language: null,
586
+ i18n: {
587
+ zh: {
588
+ titleTemplates: { completed: '任务完成 (^▽^)', error: '任务出错 (;д;)', aborted: '任务中止 (;ω;)', blocked: '任务被阻塞 (・_・;)', 'max-tokens': '任务到上限 ( ̄▽ ̄)' },
589
+ templates: {
590
+ completed: '会话「{title}」已完成 (๑•̀ㅂ•́)و✧,请点击查看~',
591
+ error: '会话「{title}」出错啦 (。•́︿•̀。),请点击查看。',
592
+ aborted: '会话「{title}」被中止了 (;ω;),请点击查看。',
593
+ blocked: '会话「{title}」被阻塞了 (・_・;),请点击查看。',
594
+ 'max-tokens': '会话「{title}」达到上限了 ( ̄▽ ̄)ゞ,建议拆分任务,请点击查看。',
595
+ },
596
+ },
597
+ 'zh-tw': {
598
+ titleTemplates: { completed: '任務完成 (^▽^)', error: '任務出錯 (;д;)', aborted: '任務中止 (;ω;)', blocked: '任務被阻塞 (・_・;)', 'max-tokens': '任務到上限 ( ̄▽ ̄)' },
599
+ templates: {
600
+ completed: '會話「{title}」已完成 (๑•̀ㅂ•́)و✧,請點擊查看~',
601
+ error: '會話「{title}」出錯啦 (。•́︿•̀。),請點擊查看。',
602
+ aborted: '會話「{title}」被中止了 (;ω;),請點擊查看。',
603
+ blocked: '會話「{title}」被阻塞了 (・_・;),請點擊查看。',
604
+ 'max-tokens': '會話「{title}」達到上限了 ( ̄▽ ̄)ゞ,建議拆分任務,請點擊查看。',
605
+ },
606
+ },
607
+ en: {
608
+ titleTemplates: { completed: 'Task done (^▽^)', error: 'Task failed (;д;)', aborted: 'Task aborted (;ω;)', blocked: 'Task blocked (・_・;)', 'max-tokens': 'Task hit the cap ( ̄▽ ̄)' },
609
+ templates: {
610
+ completed: 'Session "{title}" completed (๑•̀ㅂ•́)و✧ — click to view~',
611
+ error: 'Session "{title}" failed (。•́︿•̀。) — click to view.',
612
+ aborted: 'Session "{title}" was aborted (;ω;) — click to view.',
613
+ blocked: 'Session "{title}" was blocked (・_・;) — click to view.',
614
+ 'max-tokens': 'Session "{title}" hit the cap ( ̄▽ ̄)ゞ — consider splitting the task. Click to view.',
615
+ },
616
+ },
617
+ ja: {
618
+ titleTemplates: { completed: 'タスク完了 (^▽^)', error: 'タスクエラー (;д;)', aborted: 'タスク中止 (;ω;)', blocked: 'タスクブロック (・_・;)', 'max-tokens': '上限到達 ( ̄▽ ̄)' },
619
+ templates: {
620
+ completed: 'セッション「{title}」完了 (๑•̀ㅂ•́)و✧ — クリックして表示~',
621
+ error: 'セッション「{title}」エラー (。•́︿•̀。) — クリックして表示。',
622
+ aborted: 'セッション「{title}」が中止されました (;ω;) — クリックして表示。',
623
+ blocked: 'セッション「{title}」がブロックされました (・_・;) — クリックして表示。',
624
+ 'max-tokens': 'セッション「{title}」が上限に到達 ( ̄▽ ̄)ゞ — タスク分割をおすすめ。クリックして表示。',
625
+ },
626
+ },
627
+ ko: {
628
+ titleTemplates: { completed: '작업 완료 (^▽^)', error: '작업 오류 (;д;)', aborted: '작업 중단 (;ω;)', blocked: '작업 차단 (・_・;)', 'max-tokens': '한도 도달 ( ̄▽ ̄)' },
629
+ templates: {
630
+ completed: '세션 "{title}" 완료 (๑•̀ㅂ•́)و✧ — 클릭하여 확인~',
631
+ error: '세션 "{title}" 오류 (。•́︿•̀。) — 클릭하여 확인.',
632
+ aborted: '세션 "{title}" 중단됨 (;ω;) — 클릭하여 확인.',
633
+ blocked: '세션 "{title}" 차단됨 (・_・;) — 클릭하여 확인.',
634
+ 'max-tokens': '세션 "{title}" 한도 도달 ( ̄▽ ̄)ゞ — 작업 분할 권장. 클릭하여 확인.',
635
+ },
636
+ },
637
+ },
638
+ },
639
+ {
640
+ id: 'felyne', labelKey: 'felyne', language: null,
641
+ i18n: {
642
+ zh: {
643
+ titleTemplates: { completed: '喵~任务完成', error: '喵…任务出错', aborted: '喵?任务中止', blocked: '喵…任务被挡住', 'max-tokens': '喵喵 任务到上限' },
644
+ templates: {
645
+ completed: '喵~任务「{title}」完成啦!老大快去看看吧喵~',
646
+ error: '喵……任务「{title}」出错了喵,老大快来看看喵。',
647
+ aborted: '喵?!任务「{title}」中止了喵。',
648
+ blocked: '喵……任务「{title}」遇到困难了喵。',
649
+ 'max-tokens': '喵喵,会话「{title}」到上限啦喵!拆开任务再试喵~',
650
+ },
651
+ },
652
+ 'zh-tw': {
653
+ titleTemplates: { completed: '喵~任務完成', error: '喵…任務出錯', aborted: '喵?任務中止', blocked: '喵…任務被擋住', 'max-tokens': '喵喵 任務到上限' },
654
+ templates: {
655
+ completed: '喵~任務「{title}」完成啦!老大快去看看吧喵~',
656
+ error: '喵……任務「{title}」出錯了喵,老大快來看看喵。',
657
+ aborted: '喵?!任務「{title}」中止了喵。',
658
+ blocked: '喵……任務「{title}」遇到困難了喵。',
659
+ 'max-tokens': '喵喵,會話「{title}」到上限啦喵!拆開任務再試喵~',
660
+ },
661
+ },
662
+ en: {
663
+ titleTemplates: { completed: 'Meow~ Task done', error: 'Meow… Task failed', aborted: 'Meow? Task aborted', blocked: 'Meow… Task stuck', 'max-tokens': 'Meow meow, cap reached' },
664
+ templates: {
665
+ completed: 'Meow~ Task "{title}" done! Boss, go check it out meow~',
666
+ error: 'Meow…… Task "{title}" failed meow. Boss, come take a look meow.',
667
+ aborted: 'Meow?! Task "{title}" was aborted meow.',
668
+ blocked: 'Meow…… Task "{title}" hit a snag meow.',
669
+ 'max-tokens': 'Meow meow, session "{title}" hit the cap meow! Split the task and try again meow~',
670
+ },
671
+ },
672
+ ja: {
673
+ titleTemplates: { completed: 'にゃ~タスク完了', error: 'にゃ…タスクエラー', aborted: 'にゃ?タスク中止', blocked: 'にゃ…タスク詰まり', 'max-tokens': 'にゃにゃ 上限到達' },
674
+ templates: {
675
+ completed: 'にゃ~タスク「{title}」完了だよ!ボス、見に行ってにゃ~',
676
+ error: 'にゃ……タスク「{title}」エラーにゃ。ボス、見に来てにゃ。',
677
+ aborted: 'にゃ?!タスク「{title}」中止にゃ。',
678
+ blocked: 'にゃ……タスク「{title}」詰まっちゃったにゃ。',
679
+ 'max-tokens': 'にゃにゃ、セッション「{title}」上限に到達にゃ!タスクを分けて再挑戦にゃ~',
680
+ },
681
+ },
682
+ ko: {
683
+ titleTemplates: { completed: '냐~ 작업 완료', error: '냐… 작업 오류', aborted: '냐? 작업 중단', blocked: '냐… 작업 막힘', 'max-tokens': '냐냐 한도 도달' },
684
+ templates: {
685
+ completed: '냐~ 작업 "{title}" 완료다옹! 사장님 얼른 보러 가주세요 냐~',
686
+ error: '냐…… 작업 "{title}" 오류냐옹. 사장님 이리 와서 보세요 냐.',
687
+ aborted: '냐?! 작업 "{title}" 중단됐다냐옹.',
688
+ blocked: '냐…… 작업 "{title}" 막혔다냐옹.',
689
+ 'max-tokens': '냐냐, 세션 "{title}" 한도 도달했다냐옹! 작업을 나눠 다시 시도하세요 냐~',
690
+ },
691
+ },
692
+ },
693
+ },
694
+ {
695
+ id: 'neko', labelKey: 'neko', language: null,
696
+ i18n: {
697
+ zh: {
698
+ titleTemplates: { completed: '主人,任务完成喵~', error: '主人…任务出错喵', aborted: '喵!任务中止', blocked: '主人,任务被挡住喵', 'max-tokens': '主人,任务到上限喵' },
699
+ templates: {
700
+ completed: '主人~会话「{title}」已经完成啦喵~,快来看看喵!',
701
+ error: '主人……会话「{title}」出错了喵呜,人家好担心呢……',
702
+ aborted: '喵!会话「{title}」被中止了喵。',
703
+ blocked: '主人,会话「{title}」被挡住了喵呜。',
704
+ 'max-tokens': '主人~会话「{title}」到上限了喵……,要不要拆开任务喵?',
705
+ },
706
+ },
707
+ 'zh-tw': {
708
+ titleTemplates: { completed: '主人,任務完成喵~', error: '主人…任務出錯喵', aborted: '喵!任務中止', blocked: '主人,任務被擋住喵', 'max-tokens': '主人,任務到上限喵' },
709
+ templates: {
710
+ completed: '主人~會話「{title}」已經完成啦喵~,快來看看喵!',
711
+ error: '主人……會話「{title}」出錯了喵嗚,人家好擔心呢……',
712
+ aborted: '喵!會話「{title}」被中止了喵。',
713
+ blocked: '主人,會話「{title}」被擋住了喵嗚。',
714
+ 'max-tokens': '主人~會話「{title}」到上限了喵……,要不要拆開任務喵?',
715
+ },
716
+ },
717
+ en: {
718
+ titleTemplates: { completed: 'Master, task done nya~', error: 'Master… task failed nya', aborted: 'Nya! Task aborted', blocked: 'Master, task stuck nya', 'max-tokens': 'Master, cap reached nya' },
719
+ templates: {
720
+ completed: 'Master~ session "{title}" is done nya~, come check it out nya!',
721
+ error: 'Master…… session "{title}" failed nya…, I\'m so worried……',
722
+ aborted: 'Nya! Session "{title}" was aborted nya.',
723
+ blocked: 'Master, session "{title}" got stuck nya…….',
724
+ 'max-tokens': 'Master~ session "{title}" hit the cap nya……, want to split the task nya?',
725
+ },
726
+ },
727
+ ja: {
728
+ titleTemplates: { completed: 'ご主人様、タスク完了にゃ~', error: 'ご主人様…タスクエラーにゃ', aborted: 'にゃ!タスク中止', blocked: 'ご主人様、タスク詰まりにゃ', 'max-tokens': 'ご主人様、上限にゃ' },
729
+ templates: {
730
+ completed: 'ご主人様~セッション「{title}」完了にゃ~、見てみてにゃ!',
731
+ error: 'ご主人様……セッション「{title}」エラーにゃう、心配です……',
732
+ aborted: 'にゃ!セッション「{title}」中止にゃ。',
733
+ blocked: 'ご主人様、セッション「{title}」詰まりにゃう……。',
734
+ 'max-tokens': 'ご主人様~セッション「{title}」上限にゃ……、タスク分けるにゃ?',
735
+ },
736
+ },
737
+ ko: {
738
+ titleTemplates: { completed: '주인님, 작업 완료냐~', error: '주인님… 작업 오류냐', aborted: '냐! 작업 중단', blocked: '주인님, 작업 막힘냐', 'max-tokens': '주인님, 한도 도달냐' },
739
+ templates: {
740
+ completed: '주인님~ 세션 "{title}" 완료됐냐~, 얼른 보러 오세요 냐!',
741
+ error: '주인님…… 세션 "{title}" 오류났냐옹, 걱정돼요……',
742
+ aborted: '냐! 세션 "{title}" 중단됐냐옹.',
743
+ blocked: '주인님, 세션 "{title}" 막혔냐옹…….',
744
+ 'max-tokens': '주인님~ 세션 "{title}" 한도 도달냐옹……, 작업 나눠볼까 냐?',
745
+ },
746
+ },
747
+ },
748
+ },
749
+ {
750
+ id: 'deepseek-chan', labelKey: 'deepseekChan', language: null,
751
+ i18n: {
752
+ zh: {
753
+ titleTemplates: { completed: '任务完成~', error: '任务出错…', aborted: '任务中止', blocked: '任务阻塞', 'max-tokens': '任务到上限' },
754
+ templates: {
755
+ completed: '深酱报告!会话「{title}」已完成啦~,请点击查看~',
756
+ error: '深酱道歉……会话「{title}」出错了呢,非常抱歉!',
757
+ aborted: '深酱遗憾……会话「{title}」被中止了。',
758
+ blocked: '深酱呜咽……会话「{title}」被阻塞了。',
759
+ 'max-tokens': '深酱提示!会话「{title}」达到输出上限了~,建议拆分任务哦!',
760
+ },
761
+ },
762
+ 'zh-tw': {
763
+ titleTemplates: { completed: '任務完成~', error: '任務出錯…', aborted: '任務中止', blocked: '任務阻塞', 'max-tokens': '任務到上限' },
764
+ templates: {
765
+ completed: '深醬報告!會話「{title}」已完成啦~,請點擊查看~',
766
+ error: '深醬道歉……會話「{title}」出錯了呢,非常抱歉!',
767
+ aborted: '深醬遺憾……會話「{title}」被中止了。',
768
+ blocked: '深醬嗚咽……會話「{title}」被阻塞了。',
769
+ 'max-tokens': '深醬提示!會話「{title}」達到輸出上限了~,建議拆分任務哦!',
770
+ },
771
+ },
772
+ en: {
773
+ titleTemplates: { completed: 'Task done~', error: 'Task failed…', aborted: 'Task aborted', blocked: 'Task blocked', 'max-tokens': 'Cap reached' },
774
+ templates: {
775
+ completed: 'Shen-chan reporting! Session "{title}" is done~, click to view~',
776
+ error: 'Shen-chan apologizes…… session "{title}" failed, so sorry!',
777
+ aborted: 'Shen-chan regrets…… session "{title}" was aborted.',
778
+ blocked: 'Shen-chan whimpers…… session "{title}" was blocked.',
779
+ 'max-tokens': 'Shen-chan tip! Session "{title}" hit the output cap~, consider splitting the task!',
780
+ },
781
+ },
782
+ ja: {
783
+ titleTemplates: { completed: 'タスク完了~', error: 'タスクエラー…', aborted: 'タスク中止', blocked: 'タスクブロック', 'max-tokens': '上限到達' },
784
+ templates: {
785
+ completed: '深ちゃん報告!セッション「{title}」完了~、クリックして表示~',
786
+ error: '深ちゃん謝罪……セッション「{title}」エラー、ごめんなさい!',
787
+ aborted: '深ちゃん残念……セッション「{title}」中止。',
788
+ blocked: '深ちゃんしくしく……セッション「{title}」ブロック。',
789
+ 'max-tokens': '深ちゃんお知らせ!セッション「{title}」上限到達~、タスク分割おすすめ!',
790
+ },
791
+ },
792
+ ko: {
793
+ titleTemplates: { completed: '작업 완료~', error: '작업 오류…', aborted: '작업 중단', blocked: '작업 차단', 'max-tokens': '한도 도달' },
794
+ templates: {
795
+ completed: '심짱 보고! 세션 "{title}" 완료~, 클릭하여 확인~',
796
+ error: '심짱 사과…… 세션 "{title}" 오류났어요, 정말 죄송해요!',
797
+ aborted: '심짱 아쉬움…… 세션 "{title}" 중단됐어요.',
798
+ blocked: '심짱 훌쩍…… 세션 "{title}" 차단됐어요.',
799
+ 'max-tokens': '심짱 알림! 세션 "{title}" 출력 한도 도달~, 작업 분할 추천!',
800
+ },
801
+ },
802
+ },
803
+ },
577
804
  ]
578
805
 
579
806
  /** 重置目标:与宿主 DEFAULT_SETTINGS 一致(设置面板可编辑字段子集;语言保留当前值)。 */
@@ -643,11 +870,26 @@ window.__ModuleLoader__.load({
643
870
  /** 表单(语言+模板)与某预设内容是否一致(用于「已修改」指示)。 */
644
871
  function matchesPreset(cur, p) {
645
872
  if (!p) return false
646
- if (p.language !== (LANG_IDS.indexOf(cur.language) >= 0 ? cur.language : 'zh')) return false
873
+ var lang = LANG_IDS.indexOf(cur.language) >= 0 ? cur.language : 'zh'
874
+ // 内置预设 language 为 null → 任意语言均可匹配(多语言风格预设);default 等固定语言预设按原规则
875
+ if (p.language !== null && p.language !== undefined && p.language !== lang) return false
647
876
  if (String(cur.titleTemplate ?? '') !== String(p.titleTemplate ?? '')) return false
877
+ // 通知媒体参与匹配:上传了全局大图/图标的配置 ≠ 无图的默认/旧预设(否则保存后重开面板会误匹配默认)
878
+ if (String(cur.imageUrl ?? '') !== String(p.imageUrl ?? '')) return false
879
+ if (String(cur.iconUrl ?? '') !== String(p.iconUrl ?? '')) return false
880
+ var loc = p.i18n ? (p.i18n[lang] || p.i18n.zh || p) : p
881
+ var pt = (loc.templates && typeof loc.templates === 'object') ? loc.templates : {}
882
+ var ptt = (loc.titleTemplates && typeof loc.titleTemplates === 'object') ? loc.titleTemplates : {}
883
+ var pimg = (p.images && typeof p.images === 'object') ? p.images : {}
884
+ var picon = (p.icons && typeof p.icons === 'object') ? p.icons : {}
648
885
  for (var j = 0; j < REASON_FIELDS.length; j++) {
649
- if (templateOf(cur, REASON_FIELDS[j]) !== String(p.templates[REASON_FIELDS[j]] ?? '').trim()) return false
650
- if (titleTplOf(cur, REASON_FIELDS[j]) !== String((p.titleTemplates && p.titleTemplates[REASON_FIELDS[j]]) ?? '').trim()) return false
886
+ var f = REASON_FIELDS[j]
887
+ if (templateOf(cur, f) !== String(pt[f] ?? '').trim()) return false
888
+ if (titleTplOf(cur, f) !== String(ptt[f] ?? '').trim()) return false
889
+ // 按原因图片/图标数据也参与匹配(P1-4:用 imageOf/iconOf 兼容扁平草稿键与宿主嵌套结构,
890
+ // 否则刷新后(宿主 value 为嵌套 images/icons)扁平键恒空 → 匹配失败 → 下拉显示「自定义」)
891
+ if (imageOf(cur, f) !== String(pimg[f] ?? '')) return false
892
+ if (iconOf(cur, f) !== String(picon[f] ?? '')) return false
651
893
  }
652
894
  return true
653
895
  }
@@ -881,6 +1123,11 @@ window.__ModuleLoader__.load({
881
1123
  var editor = editorRef.current
882
1124
  var next = el.nextSibling
883
1125
  var prev = el.previousSibling
1126
+ // contentEditable 中标签/文本之间常有空文本节点或 <br>(换行占位)。
1127
+ // 跳过它们定位光标,让光标落在真实内容旁(可视位置正确)。
1128
+ // 注意:连环删标签的真正根因在 onKeyDown 找胶囊时跳过了非空文本节点,见那里。
1129
+ while (next && ((next.nodeType === 3 && next.textContent === '') || (next.nodeType === 1 && next.tagName === 'BR'))) next = next.nextSibling
1130
+ while (prev && ((prev.nodeType === 3 && prev.textContent === '') || (prev.nodeType === 1 && prev.tagName === 'BR'))) prev = prev.previousSibling
884
1131
  el.remove()
885
1132
  if (editor) {
886
1133
  var range = document.createRange()
@@ -1177,7 +1424,12 @@ window.__ModuleLoader__.load({
1177
1424
  // 光标停在元素容器内(如胶囊前):按子节点序号取当前一个
1178
1425
  else if (cc.nodeType === 1 && range.startOffset < cc.childNodes.length) target = cc.childNodes[range.startOffset]
1179
1426
  }
1180
- while (target && target.nodeType !== 1) target = e.key === 'Backspace' ? target.previousSibling : target.nextSibling
1427
+ // 只跳过「空文本节点」找胶囊;一遇到非空文本必须停——
1428
+ // 此时光标紧邻的是真实文字,应交给浏览器删字符。
1429
+ // 若跳过文字继续找胶囊,连按 Back 会把标签全删光、删完标签才轮到文字
1430
+ //(例:【A】123【B】456【C】末尾按 3 下:修复前删掉 C、B、A 得 "123456";
1431
+ // 修复后删掉 C、"6"、"5" 得 【A】123【B】4)。
1432
+ while (target && target.nodeType === 3 && target.textContent === '') target = e.key === 'Backspace' ? target.previousSibling : target.nextSibling
1181
1433
  if (target && target.hasAttribute && target.hasAttribute('data-tok')) {
1182
1434
  e.preventDefault()
1183
1435
  removeChipAt(target)
@@ -1239,6 +1491,9 @@ window.__ModuleLoader__.load({
1239
1491
  var draftState = useState(null)
1240
1492
  var draft = draftState[0]
1241
1493
  var setDraft = draftState[1]
1494
+ // 最新 draft 引用:保存回调等异步路径读取「此刻」的草稿(闭包里的 draft 是触发时快照,P0-3)
1495
+ var draftRef = useRef(null)
1496
+ draftRef.current = draft
1242
1497
  var noteState = useState('')
1243
1498
  var note = noteState[0]
1244
1499
  var setNote = noteState[1]
@@ -1277,7 +1532,7 @@ window.__ModuleLoader__.load({
1277
1532
  var ttMenuFor = ttMenuState[0]
1278
1533
  var setTtMenuFor = ttMenuState[1]
1279
1534
  var ttHandles = useRef({}) // 按原因定制标题各行的 ChipEditor 句柄(插入信息到光标处)
1280
- var tplOpenState = useState(false) // 「内容」折叠区展开状态(默认收起,保持面板简洁)
1535
+ var tplOpenState = useState(true) // 「内容」折叠区展开状态(默认展开:打开面板即可编辑各原因正文)
1281
1536
  var tplOpen = tplOpenState[0]
1282
1537
  var setTplOpen = tplOpenState[1]
1283
1538
  var previewState = useState(null) // 大图预览(lightbox):非空 = 正在预览该 data URI/URL
@@ -1334,11 +1589,15 @@ window.__ModuleLoader__.load({
1334
1589
  function loadPreset(entry, custom) {
1335
1590
  var flat = flatOf(cur)
1336
1591
  var next = Object.assign({}, cur)
1337
- next.language = entry.language
1592
+ // 多语言风格预设:按当前语言取文案;language null/空 → 保留当前界面语言(不强制切换)
1593
+ var lang = LANG_IDS.indexOf(cur.language) >= 0 ? cur.language : 'zh'
1594
+ var loc = entry.i18n ? (entry.i18n[lang] || entry.i18n.zh || entry) : entry
1595
+ next.language = entry.language || cur.language
1338
1596
  next.titleTemplate = String(entry.titleTemplate ?? '')
1339
- for (var j = 0; j < REASON_FIELDS.length; j++) next['tpl-' + REASON_FIELDS[j]] = entry.templates[REASON_FIELDS[j]]
1597
+ var lt = (loc.templates && typeof loc.templates === 'object') ? loc.templates : {}
1598
+ for (var j = 0; j < REASON_FIELDS.length; j++) next['tpl-' + REASON_FIELDS[j]] = typeof lt[REASON_FIELDS[j]] === 'string' ? lt[REASON_FIELDS[j]] : ''
1340
1599
  // 按原因定制标题(旧预设可能没有 → 空)
1341
- var et = (entry.titleTemplates && typeof entry.titleTemplates === 'object') ? entry.titleTemplates : {}
1600
+ var et = (loc.titleTemplates && typeof loc.titleTemplates === 'object') ? loc.titleTemplates : {}
1342
1601
  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
1602
  // 通知媒体(大图/图标 + 完整预览版;旧预设缺失 → 清空)
1344
1603
  next.imageUrl = String(entry.imageUrl ?? '')
@@ -1375,6 +1634,10 @@ window.__ModuleLoader__.load({
1375
1634
  function saveAsCustom() {
1376
1635
  var name = newName.trim()
1377
1636
  if (!name) { setNote(t.presetNameRequired); return }
1637
+ // P2-2:同名预设提示(下拉同名条目无法区分)
1638
+ for (var ni = 0; ni < customPresets.length; ni++) {
1639
+ if (String(customPresets[ni].name ?? '').trim() === name) { setNote(t.presetNameExists); return }
1640
+ }
1378
1641
  var flat = flatOf(cur)
1379
1642
  var images = {}
1380
1643
  var icons = {}
@@ -1388,7 +1651,8 @@ window.__ModuleLoader__.load({
1388
1651
  iconPreviews[sk] = flat['pv-icon-' + sk]
1389
1652
  }
1390
1653
  var cp = {
1391
- id: 'cu-' + Date.now().toString(36),
1654
+ // P2-1:加随机后缀,避免同毫秒两次另存为 id 冲突(与 renameCustom 一致)
1655
+ id: 'cu-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
1392
1656
  name: name,
1393
1657
  language: LANG_IDS.indexOf(cur.language) >= 0 ? cur.language : 'zh',
1394
1658
  titleTemplate: flat.titleTemplate,
@@ -1416,15 +1680,21 @@ window.__ModuleLoader__.load({
1416
1680
 
1417
1681
  function deleteCustom() {
1418
1682
  if (!shownCustom || !shownEntry) return
1683
+ // P0-1:误点即永久丢失(预设含 5 原因模板 + 图片 data URI)→ 删除前确认
1684
+ if (!window.confirm(t.customDeleteConfirm)) return
1419
1685
  var next = customPresets.filter(function (cp) { return cp.id !== shownEntry.id })
1420
1686
  setCustomPresets(next)
1421
1687
  saveCustomPresets(next)
1422
1688
  setActivePreset(null)
1423
- // 删除的是当前使用的预设 → 表单恢复默认预设内容(presetIdOf 自动匹配「默认」)
1689
+ // 删除的是当前使用的预设:表单恢复为**宿主已保存值**(而非默认值)——
1690
+ // 避免「表单变默认 → dirty → 用户误点保存 → 宿主配置被覆盖为默认」的不可恢复覆盖(P0-2);
1691
+ // 宿主值含当前语言,天然满足「删除保留语言」(P1-1)
1424
1692
  if (shownEntry.id === (activePreset && activePreset.id)) {
1425
- setDraft(Object.assign({}, DEFAULT_FLAT))
1693
+ setDraft(Object.assign({}, value))
1694
+ setNote(t.presetDeletedKeepApplied) // 「已应用配置不受影响」提示
1695
+ } else {
1696
+ setNote(t.customDeleted)
1426
1697
  }
1427
- setNote(t.customDeleted)
1428
1698
  log('custom preset deleted')
1429
1699
  }
1430
1700
 
@@ -1494,40 +1764,81 @@ window.__ModuleLoader__.load({
1494
1764
  scope.set('imagePreviews', imagePreviews),
1495
1765
  scope.set('iconPreviews', iconPreviews),
1496
1766
  ]
1497
- Promise.all(tasks).then(function () {
1767
+ // P1-3:串行写 scope 字段(失败即停,明确已写数量;不再 Promise.all 静默部分成功)
1768
+ var serialDone = 0
1769
+ var chain = Promise.resolve()
1770
+ for (var si2 = 0; si2 < tasks.length; si2++) {
1771
+ ;(function (task) {
1772
+ chain = chain.then(function () { return task }).then(function () { serialDone++ })
1773
+ })(tasks[si2])
1774
+ }
1775
+ chain.then(function () {
1776
+ // P0-3:保存快照——回调使用触发保存时的值,避免保存期间用户操作(删预设/编辑)导致闭包陈旧竞态
1777
+ var snapCustom = customPresets.slice()
1778
+ var snapShownCustom = shownCustom
1779
+ var snapShownEntry = shownEntry
1780
+ var snapFlat = flatOf(cur)
1498
1781
  // 预设自动同步:
1499
1782
  // - 来源是自定义预设(含未命名预设)→ 更新它;
1500
1783
  // - 来源是默认/空白 → 【若已有未命名预设则继续编号新建】未命名预设 2/3…
1501
- var lang = LANG_IDS.indexOf(cur.language) >= 0 ? cur.language : 'zh'
1784
+ var lang = LANG_IDS.indexOf(snapFlat.language) >= 0 ? snapFlat.language : 'zh'
1502
1785
  var targetId
1503
1786
  var entry
1504
1787
  var list
1505
1788
  var found = null
1506
- if (shownCustom && shownEntry) {
1507
- targetId = shownEntry.id
1508
- entry = Object.assign({}, shownEntry, { language: lang, titleTemplate: flat.titleTemplate, titleTemplates: titleTemplates, templates: templates })
1509
- list = customPresets.map(function (cp) {
1789
+ // 通知媒体随预设一并保存(全局 + 按原因):否则保存后的新/更新预设丢图,
1790
+ // 且重开面板时 presetIdOf 会误匹配无图的默认预设
1791
+ var media = {
1792
+ imageUrl: String(snapFlat.imageUrl ?? ''),
1793
+ iconUrl: String(snapFlat.iconUrl ?? ''),
1794
+ imagePreviewUrl: String(snapFlat['preview-imageUrl'] ?? ''),
1795
+ iconPreviewUrl: String(snapFlat['preview-iconUrl'] ?? ''),
1796
+ images: images,
1797
+ icons: icons,
1798
+ imagePreviews: imagePreviews,
1799
+ iconPreviews: iconPreviews,
1800
+ }
1801
+ if (snapShownCustom && snapShownEntry) {
1802
+ targetId = snapShownEntry.id
1803
+ entry = Object.assign({}, snapShownEntry, { language: lang, titleTemplate: snapFlat.titleTemplate, titleTemplates: titleTemplates, templates: templates }, media)
1804
+ list = snapCustom.map(function (cp) {
1510
1805
  if (cp.id === targetId) { found = Object.assign({}, cp, entry); return found }
1511
1806
  return cp
1512
1807
  })
1513
1808
  if (!found) { list = list.concat([entry]); found = entry }
1514
1809
  } else {
1515
- var n = nextUnnamedNumber(customPresets)
1810
+ // P2-6:当前配置与默认完全一致(未做任何修改)→ 保存无意义,跳过重复新建
1811
+ if (presetIdOf(cur, customPresets) === 'default') {
1812
+ setNote(t.noChange)
1813
+ setDraft(null)
1814
+ setJustSaved(true)
1815
+ log('settings saved (matches default); no new preset created')
1816
+ return
1817
+ }
1818
+ var n = nextUnnamedNumber(snapCustom)
1516
1819
  targetId = n === 1 ? 'unnamed' : 'unnamed-' + n
1517
- entry = { id: targetId, name: null, auto: true, language: lang, titleTemplate: flat.titleTemplate, titleTemplates: titleTemplates, templates: templates }
1518
- list = customPresets.concat([entry])
1820
+ entry = Object.assign({ id: targetId, name: null, auto: true, language: lang, titleTemplate: snapFlat.titleTemplate, titleTemplates: titleTemplates, templates: templates }, media)
1821
+ list = snapCustom.concat([entry])
1519
1822
  found = entry
1520
1823
  }
1521
1824
  setCustomPresets(list)
1522
1825
  saveCustomPresets(list)
1523
1826
  setActivePreset({ id: targetId, custom: true, src: found })
1524
- setDraft(null)
1525
- setNote(t.saved)
1526
- setJustSaved(true)
1527
- log('settings saved; preset synced: ' + targetId)
1827
+ // P0-3:保存期间用户是否又改了草稿?改了则保留草稿并提示(不清空用户输入)
1828
+ var nowDraft = draftRef.current
1829
+ if (nowDraft !== null && !sameFlat(flatOf(nowDraft), snapFlat)) {
1830
+ setNote(t.saveDuringEdit)
1831
+ setJustSaved(true)
1832
+ log('settings saved; preset synced: ' + targetId + ' (edits during save kept)')
1833
+ } else {
1834
+ setDraft(null)
1835
+ setNote(t.saved)
1836
+ setJustSaved(true)
1837
+ log('settings saved; preset synced: ' + targetId + ' (wrote ' + serialDone + '/' + tasks.length + ')')
1838
+ }
1528
1839
  }).catch(function (e) {
1529
- setNote(t.saveFailed + (e && e.message ? e.message : e))
1530
- log('settings save failed: ' + (e && e.message ? e.message : e))
1840
+ setNote(t.saveFailed + (e && e.message ? e.message : e) + '(已写入 ' + serialDone + '/' + tasks.length + ' 项)')
1841
+ log('settings save failed at ' + serialDone + '/' + tasks.length + ': ' + (e && e.message ? e.message : e))
1531
1842
  })
1532
1843
  }
1533
1844
 
@@ -1658,7 +1969,8 @@ window.__ModuleLoader__.load({
1658
1969
  hasMedia
1659
1970
  ? h('button', {
1660
1971
  type: 'button', title: t.clearMedia,
1661
- onClick: function () { setVal(key, '') },
1972
+ // P1-2:裁切版与预览版一并清除,避免 preview-* 残留脏 data URI(与 save() 按原因路径一致)
1973
+ onClick: function () { setVal(key, ''); setVal('preview-' + key, '') },
1662
1974
  style: {
1663
1975
  position: 'absolute', top: '-7px', right: '-7px', width: '20px', height: '20px', padding: '0',
1664
1976
  borderRadius: '50%', border: 'none', cursor: 'pointer',
@@ -1754,14 +2066,22 @@ window.__ModuleLoader__.load({
1754
2066
  scope.set('imagePreviews', def.imagePreviews),
1755
2067
  scope.set('iconPreviews', def.iconPreviews),
1756
2068
  ]
1757
- Promise.all(tasks).then(function () {
2069
+ // P1-3:串行写(与 save() 一致,失败即停并报告已写数量)
2070
+ var serialDone = 0
2071
+ var chain = Promise.resolve()
2072
+ for (var si3 = 0; si3 < tasks.length; si3++) {
2073
+ ;(function (task) {
2074
+ chain = chain.then(function () { return task }).then(function () { serialDone++ })
2075
+ })(tasks[si3])
2076
+ }
2077
+ chain.then(function () {
1758
2078
  setDraft(null)
1759
2079
  setNote(t.resetDone)
1760
2080
  setJustSaved(true)
1761
2081
  log('settings reset to defaults (language kept: ' + lang + ')')
1762
2082
  }).catch(function (e) {
1763
- setNote(t.saveFailed + (e && e.message ? e.message : e))
1764
- log('settings reset failed: ' + (e && e.message ? e.message : e))
2083
+ setNote(t.saveFailed + (e && e.message ? e.message : e) + '(已写入 ' + serialDone + '/' + tasks.length + ' 项)')
2084
+ log('settings reset failed at ' + serialDone + '/' + tasks.length + ': ' + (e && e.message ? e.message : e))
1765
2085
  })
1766
2086
  }
1767
2087
 
@@ -2160,8 +2480,9 @@ window.__ModuleLoader__.load({
2160
2480
  : null,
2161
2481
  ),
2162
2482
  h('button', {
2163
- type: 'button', disabled: note === t.saving, onClick: resetAll,
2164
- style: Object.assign({}, GHOST, { opacity: note === t.saving ? 0.4 : 1 }),
2483
+ // P2-5:无修改(!dirty)时禁用重置,避免无意义写库
2484
+ type: 'button', disabled: !dirty || note === t.saving, onClick: resetAll,
2485
+ style: Object.assign({}, GHOST, { opacity: (!dirty || note === t.saving) ? 0.4 : 1 }),
2165
2486
  }, t.reset),
2166
2487
  h('button', {
2167
2488
  type: 'button', disabled: !dirty || note === t.saving, onClick: discard,