ZHIWEI666 commited on
Commit
b518712
·
verified ·
1 Parent(s): 5259637

Upload 98 files

Browse files
前端页面/auth/找回账号表单组件.js CHANGED
@@ -1,3 +1,4 @@
 
1
  // 前端页面/auth/找回账号表单组件.js
2
  // ==========================================
3
  // 🔎 找回账号表单组件
@@ -31,6 +32,7 @@ export function renderRecoverForm(container, switchView) {
31
  <button id="btn-submit-recover" style="width: 100%; padding: 10px; background: #FF9800; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold; margin-bottom: 10px;">${t('auth.recover_account')}</button>
32
  <div style="text-align: center; font-size: 12px;"><a href="#" id="recover-to-login" style="color: #2196F3; text-decoration: none;">${t('auth.back_to_login')}</a></div>
33
  `;
 
34
 
35
  container.querySelector("#recover-to-login").onclick = (e) => { e.preventDefault(); switchView("login"); };
36
 
@@ -56,10 +58,11 @@ export function renderRecoverForm(container, switchView) {
56
  let countdownTimer = null;
57
 
58
  // 🔧 注册清理函数:视图切换时清除倒计时定时器,防止内存泄漏
59
- container._onCleanup = () => { if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null; } };
60
 
61
  btnSendCode.onclick = async (e) => {
62
  e.preventDefault();
 
63
 
64
  const emailInput = container.querySelector("#recover-email").value.trim();
65
  const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(emailInput);
@@ -70,14 +73,17 @@ export function renderRecoverForm(container, switchView) {
70
  btnSendCode.innerText = t('auth.sending');
71
 
72
  try {
73
- await api.sendVerifyCode(emailInput, "email", "recover");
 
 
74
 
75
  showToast(t('auth.code_sent'), "success");
76
 
77
  countdownTimer = startCountdown(btnSendCode, 60, "#2196F3");
78
 
79
  } catch (err) {
80
- showToast(err.message || t('feedback.error'), "error");
 
81
  btnSendCode.disabled = false;
82
  btnSendCode.style.background = "#2196F3";
83
  btnSendCode.innerText = t('auth.get_code');
@@ -87,6 +93,7 @@ export function renderRecoverForm(container, switchView) {
87
  // 提交找回:验证码核验成功后展示账号
88
  const btnSubmit = container.querySelector("#btn-submit-recover");
89
  btnSubmit.onclick = async () => {
 
90
  const email = container.querySelector("#recover-email").value.trim();
91
  const code = container.querySelector("#recover-code").value.trim();
92
 
@@ -98,7 +105,10 @@ export function renderRecoverForm(container, switchView) {
98
 
99
  try {
100
  const res = await api.recoverAccount(email, code);
 
101
 
 
 
102
  showToast(t('auth.recover_success'), "success");
103
 
104
  // 🔧 展示结果前清除倒计时,避免定时器继续作用于已替换的 DOM
@@ -117,13 +127,17 @@ export function renderRecoverForm(container, switchView) {
117
  `;
118
 
119
  // 跳转重置密码表单,自动回填账号与邮箱
120
- container.querySelector("#btn-go-reset").onclick = () => {
 
 
 
121
  switchView("reset", { prefill: { account: res.account, email: email } });
122
  };
123
- container.querySelector("#btn-back-login").onclick = () => switchView("login");
124
 
125
  } catch (err) {
126
- showToast(err.message || t('feedback.error'), "error");
 
127
  btnSubmit.disabled = false;
128
  btnSubmit.innerText = t('auth.recover_account');
129
  }
 
1
+ import { createAuthFormScope } from "./认证表单生命周期.js";
2
  // 前端页面/auth/找回账号表单组件.js
3
  // ==========================================
4
  // 🔎 找回账号表单组件
 
32
  <button id="btn-submit-recover" style="width: 100%; padding: 10px; background: #FF9800; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold; margin-bottom: 10px;">${t('auth.recover_account')}</button>
33
  <div style="text-align: center; font-size: 12px;"><a href="#" id="recover-to-login" style="color: #2196F3; text-decoration: none;">${t('auth.back_to_login')}</a></div>
34
  `;
35
+ const formScope = createAuthFormScope(container);
36
 
37
  container.querySelector("#recover-to-login").onclick = (e) => { e.preventDefault(); switchView("login"); };
38
 
 
58
  let countdownTimer = null;
59
 
60
  // 🔧 注册清理函数:视图切换时清除倒计时定时器,防止内存泄漏
61
+ formScope.addCleanup(() => { clearInterval(countdownTimer); countdownTimer = null; });
62
 
63
  btnSendCode.onclick = async (e) => {
64
  e.preventDefault();
65
+ if (!formScope.current() || btnSendCode.disabled) return;
66
 
67
  const emailInput = container.querySelector("#recover-email").value.trim();
68
  const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(emailInput);
 
73
  btnSendCode.innerText = t('auth.sending');
74
 
75
  try {
76
+ const response = await api.sendVerifyCode(emailInput, "email", "recover");
77
+ if (!formScope.current()) return;
78
+ if (response?.status !== 'success') throw new Error('Invalid verification response');
79
 
80
  showToast(t('auth.code_sent'), "success");
81
 
82
  countdownTimer = startCountdown(btnSendCode, 60, "#2196F3");
83
 
84
  } catch (err) {
85
+ if (!formScope.current()) return;
86
+ showToast(t('feedback.error'), "error");
87
  btnSendCode.disabled = false;
88
  btnSendCode.style.background = "#2196F3";
89
  btnSendCode.innerText = t('auth.get_code');
 
93
  // 提交找回:验证码核验成功后展示账号
94
  const btnSubmit = container.querySelector("#btn-submit-recover");
95
  btnSubmit.onclick = async () => {
96
+ if (!formScope.current() || btnSubmit.disabled) return;
97
  const email = container.querySelector("#recover-email").value.trim();
98
  const code = container.querySelector("#recover-code").value.trim();
99
 
 
105
 
106
  try {
107
  const res = await api.recoverAccount(email, code);
108
+ if (!formScope.current()) return;
109
 
110
+ if (res?.status !== 'success' || typeof res.account !== 'string' || !res.account.trim()
111
+ || (res.name != null && typeof res.name !== 'string')) throw new Error('Invalid recovery response');
112
  showToast(t('auth.recover_success'), "success");
113
 
114
  // 🔧 展示结果前清除倒计时,避免定时器继续作用于已替换的 DOM
 
127
  `;
128
 
129
  // 跳转重置密码表单,自动回填账号与邮箱
130
+ const resultButton = container.querySelector("#btn-go-reset");
131
+ const resultCurrent = () => formScope.alive() && container.contains(resultButton);
132
+ resultButton.onclick = () => {
133
+ if (!resultCurrent()) return;
134
  switchView("reset", { prefill: { account: res.account, email: email } });
135
  };
136
+ container.querySelector("#btn-back-login").onclick = () => { if (resultCurrent()) switchView("login"); };
137
 
138
  } catch (err) {
139
+ if (!formScope.current()) return;
140
+ showToast(t('feedback.error'), "error");
141
  btnSubmit.disabled = false;
142
  btnSubmit.innerText = t('auth.recover_account');
143
  }
前端页面/auth/注册表单组件.js CHANGED
@@ -1,3 +1,4 @@
 
1
  import { api } from "../core/网络请求API.js";
2
  import { regionData, getSortedCountries } from "./国家地区数据.js";
3
  import { showToast } from "../components/UI交互提示组件.js";
@@ -76,6 +77,7 @@ export function renderRegisterForm(container, switchView, onSuccessCallback) {
76
  <button id="btn-submit-register" style="width: 100%; padding: 10px; background: #2196F3; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold; margin-bottom: 10px;">${t('auth.register_submit')}</button>
77
  <div style="text-align: center; font-size: 12px;">${t('auth.has_account')} <a href="#" id="toggle-to-login" style="color: #2196F3; text-decoration: none;">${t('auth.back_to_login')}</a></div>
78
  `;
 
79
 
80
  const genderSelect = container.querySelector("#reg-gender");
81
  const avatarPreview = container.querySelector("#avatar-preview");
@@ -104,36 +106,69 @@ export function renderRegisterForm(container, switchView, onSuccessCallback) {
104
  countrySelect.onchange = updateRegionOptions;
105
  updateRegionOptions();
106
 
107
- let selectedAvatarFile = null;
 
 
 
 
 
 
 
108
  genderSelect.onchange = (e) => { if (!selectedAvatarFile) avatarPreview.src = DEFAULT_AVATAR; };
109
 
110
  avatarInput.onchange = async (e) => {
 
111
  const file = e.target.files[0];
112
  if (!file) return;
 
 
 
 
 
113
  // 📱 移动端适配:手机拍照原图普遍超过 3MB,不再用原始大小拦截,
114
  // 统一先走 1:1 裁剪压缩管线(输出 ≤512×512 JPG),与个人设置头像链路一致;
115
  // 用户取消裁剪时静默还原默认头像预览。
116
  avatarInput.value = "";
117
  let croppedFile;
118
  try {
119
- croppedFile = await openImageCropper(file, 1, t('auth.avatar'), 1);
120
  } catch (err) {
 
 
121
  showToast(t('auth.file_too_large'), "warning");
122
  selectedAvatarFile = null;
123
  avatarPreview.src = DEFAULT_AVATAR;
124
  return;
125
  }
 
 
126
  if (!croppedFile) {
127
  selectedAvatarFile = null;
128
  avatarPreview.src = DEFAULT_AVATAR;
129
  return;
130
  }
131
- avatarError.style.display = "none";
132
- selectedAvatarFile = croppedFile;
133
- const reader = new FileReader();
134
- reader.onload = (event) => avatarPreview.src = event.target.result;
135
- reader.onerror = () => showToast(t('auth.file_too_large'), "warning");
136
- reader.readAsDataURL(croppedFile);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  };
138
 
139
  container.querySelector("#toggle-to-login").onclick = (e) => { e.preventDefault(); switchView("login"); };
@@ -158,9 +193,11 @@ export function renderRegisterForm(container, switchView, onSuccessCallback) {
158
  // 发送注册验证码逻辑
159
  const btnRegSendCode = container.querySelector("#btn-reg-send-code");
160
  let regCountdownTimer = null;
 
161
 
162
  btnRegSendCode.onclick = async (e) => {
163
  e.preventDefault();
 
164
  const emailInput = container.querySelector("#reg-email").value.trim();
165
  const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(emailInput);
166
 
@@ -171,13 +208,16 @@ export function renderRegisterForm(container, switchView, onSuccessCallback) {
171
  btnRegSendCode.innerText = t('auth.sending');
172
 
173
  try {
174
- await api.sendVerifyCode(emailInput, "email", "register");
 
 
175
  showToast(t('auth.code_sent'), "success");
176
 
177
  regCountdownTimer = startCountdown(btnRegSendCode, 60, "#4CAF50");
178
 
179
  } catch (err) {
180
- showToast(err.message || t('feedback.error'), "error");
 
181
  btnRegSendCode.disabled = false;
182
  btnRegSendCode.style.background = "#4CAF50";
183
  btnRegSendCode.innerText = t('auth.get_code');
@@ -217,4 +257,4 @@ export function renderRegisterForm(container, switchView, onSuccessCallback) {
217
 
218
  if (onSuccessCallback) onSuccessCallback(formData);
219
  };
220
- }
 
1
+ import { createAuthFormScope } from "./认证表单生命周期.js";
2
  import { api } from "../core/网络请求API.js";
3
  import { regionData, getSortedCountries } from "./国家地区数据.js";
4
  import { showToast } from "../components/UI交互提示组件.js";
 
77
  <button id="btn-submit-register" style="width: 100%; padding: 10px; background: #2196F3; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold; margin-bottom: 10px;">${t('auth.register_submit')}</button>
78
  <div style="text-align: center; font-size: 12px;">${t('auth.has_account')} <a href="#" id="toggle-to-login" style="color: #2196F3; text-decoration: none;">${t('auth.back_to_login')}</a></div>
79
  `;
80
+ const formScope = createAuthFormScope(container);
81
 
82
  const genderSelect = container.querySelector("#reg-gender");
83
  const avatarPreview = container.querySelector("#avatar-preview");
 
106
  countrySelect.onchange = updateRegionOptions;
107
  updateRegionOptions();
108
 
109
+ let selectedAvatarFile = null, avatarGeneration = 0, avatarReader = null, avatarCropController = null;
110
+ const stopAvatarReader = () => {
111
+ const reader = avatarReader; avatarReader = null;
112
+ if (!reader) return;
113
+ reader.onload = reader.onerror = null;
114
+ if (reader.readyState === FileReader.LOADING) reader.abort();
115
+ };
116
+ formScope.addCleanup(() => { avatarGeneration++; avatarCropController?.abort(); avatarCropController = null; stopAvatarReader(); selectedAvatarFile = null; });
117
  genderSelect.onchange = (e) => { if (!selectedAvatarFile) avatarPreview.src = DEFAULT_AVATAR; };
118
 
119
  avatarInput.onchange = async (e) => {
120
+ if (!formScope.current()) return;
121
  const file = e.target.files[0];
122
  if (!file) return;
123
+ const token = ++avatarGeneration;
124
+ const currentAvatar = () => formScope.current() && token === avatarGeneration;
125
+ stopAvatarReader();
126
+ avatarCropController?.abort();
127
+ const cropController = new AbortController(); avatarCropController = cropController;
128
  // 📱 移动端适配:手机拍照原图普遍超过 3MB,不再用原始大小拦截,
129
  // 统一先走 1:1 裁剪压缩管线(输出 ≤512×512 JPG),与个人设置头像链路一致;
130
  // 用户取消裁剪时静默还原默认头像预览。
131
  avatarInput.value = "";
132
  let croppedFile;
133
  try {
134
+ croppedFile = await openImageCropper(file, 1, t('auth.avatar'), 1, { signal: cropController.signal });
135
  } catch (err) {
136
+ if (avatarCropController === cropController) avatarCropController = null;
137
+ if (!currentAvatar()) return;
138
  showToast(t('auth.file_too_large'), "warning");
139
  selectedAvatarFile = null;
140
  avatarPreview.src = DEFAULT_AVATAR;
141
  return;
142
  }
143
+ if (avatarCropController === cropController) avatarCropController = null;
144
+ if (!currentAvatar()) return;
145
  if (!croppedFile) {
146
  selectedAvatarFile = null;
147
  avatarPreview.src = DEFAULT_AVATAR;
148
  return;
149
  }
150
+ const reader = new FileReader(); avatarReader = reader;
151
+ const releaseReader = () => {
152
+ reader.onload = reader.onerror = null;
153
+ if (avatarReader === reader) avatarReader = null;
154
+ };
155
+ reader.onload = (event) => {
156
+ const result = event.target.result;
157
+ if (currentAvatar() && typeof result === 'string' && result) {
158
+ avatarError.style.display = "none";
159
+ selectedAvatarFile = croppedFile; avatarPreview.src = result;
160
+ }
161
+ releaseReader();
162
+ };
163
+ reader.onerror = () => {
164
+ if (currentAvatar()) showToast(t('auth.file_too_large'), "warning");
165
+ releaseReader();
166
+ };
167
+ try { reader.readAsDataURL(croppedFile); }
168
+ catch (_) {
169
+ releaseReader();
170
+ if (currentAvatar()) showToast(t('auth.file_too_large'), "warning");
171
+ }
172
  };
173
 
174
  container.querySelector("#toggle-to-login").onclick = (e) => { e.preventDefault(); switchView("login"); };
 
193
  // 发送注册验证码逻辑
194
  const btnRegSendCode = container.querySelector("#btn-reg-send-code");
195
  let regCountdownTimer = null;
196
+ formScope.addCleanup(() => { clearInterval(regCountdownTimer); regCountdownTimer = null; });
197
 
198
  btnRegSendCode.onclick = async (e) => {
199
  e.preventDefault();
200
+ if (!formScope.current() || btnRegSendCode.disabled) return;
201
  const emailInput = container.querySelector("#reg-email").value.trim();
202
  const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(emailInput);
203
 
 
208
  btnRegSendCode.innerText = t('auth.sending');
209
 
210
  try {
211
+ const response = await api.sendVerifyCode(emailInput, "email", "register");
212
+ if (!formScope.current()) return;
213
+ if (response?.status !== 'success') throw new Error('Invalid verification response');
214
  showToast(t('auth.code_sent'), "success");
215
 
216
  regCountdownTimer = startCountdown(btnRegSendCode, 60, "#4CAF50");
217
 
218
  } catch (err) {
219
+ if (!formScope.current()) return;
220
+ showToast(t('feedback.error'), "error");
221
  btnRegSendCode.disabled = false;
222
  btnRegSendCode.style.background = "#4CAF50";
223
  btnRegSendCode.innerText = t('auth.get_code');
 
257
 
258
  if (onSuccessCallback) onSuccessCallback(formData);
259
  };
260
+ }
前端页面/auth/认证表单生命周期.js ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** 表单的所属DOM与清理范围;只丢弃迟到结果,不重放验证码等写请求。 */
2
+ export function createAuthFormScope(container) {
3
+ const anchor = container.firstElementChild;
4
+ let disposed = false;
5
+ const cleanups = new Set();
6
+ const alive = () => !disposed && container.isConnected;
7
+ container._onCleanup = () => {
8
+ if (disposed) return;
9
+ disposed = true;
10
+ for (const cleanup of cleanups) { try { cleanup(); } catch (_) {} }
11
+ cleanups.clear();
12
+ };
13
+ return {
14
+ alive,
15
+ current: () => alive() && anchor?.parentElement === container,
16
+ addCleanup(cleanup) {
17
+ if (disposed) { try { cleanup(); } catch (_) {} return; }
18
+ cleanups.add(cleanup);
19
+ }
20
+ };
21
+ }
前端页面/auth/重置密码表单组件.js CHANGED
@@ -1,3 +1,4 @@
 
1
  import { api } from "../core/网络请求API.js";
2
  import { showToast } from "../components/UI交互提示组件.js";
3
  import { t } from "../components/用户体验增强.js";
@@ -24,6 +25,7 @@ export function renderResetForm(container, switchView, onSuccessCallback, prefil
24
  <button id="btn-submit-reset" style="width: 100%; padding: 10px; background: #FF9800; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold; margin-bottom: 10px;">${t('auth.change_password')}</button>
25
  <div style="text-align: center; font-size: 12px;"><a href="#" id="toggle-to-login" style="color: #2196F3; text-decoration: none;">${t('auth.back_to_login')}</a></div>
26
  `;
 
27
 
28
  container.querySelector("#toggle-to-login").onclick = (e) => { e.preventDefault(); switchView("login"); };
29
 
@@ -55,10 +57,11 @@ export function renderResetForm(container, switchView, onSuccessCallback, prefil
55
  let countdownTimer = null;
56
 
57
  // 🔧 注册清理函数:视图切换时清除倒计时定时器,防止内存泄漏
58
- container._onCleanup = () => { if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null; } };
59
 
60
  btnSendCode.onclick = async (e) => {
61
  e.preventDefault();
 
62
 
63
  // 先抓取上方输入的账号,没填就不让发验证码
64
  const accountInput = container.querySelector("#reset-account").value.trim();
@@ -76,14 +79,17 @@ export function renderResetForm(container, switchView, onSuccessCallback, prefil
76
  btnSendCode.innerText = t('auth.sending');
77
 
78
  try {
79
- await api.sendVerifyCode(verifyInput, contactType, "reset", accountInput);
 
 
80
 
81
  showToast(t('auth.code_sent'), "success");
82
 
83
  countdownTimer = startCountdown(btnSendCode, 60, "#2196F3");
84
 
85
  } catch (err) {
86
- showToast(err.message || t('feedback.error'), "error");
 
87
  btnSendCode.disabled = false;
88
  btnSendCode.style.background = "#2196F3";
89
  btnSendCode.innerText = t('auth.get_code');
@@ -116,4 +122,4 @@ export function renderResetForm(container, switchView, onSuccessCallback, prefil
116
  };
117
  if (onSuccessCallback) onSuccessCallback(formData);
118
  };
119
- }
 
1
+ import { createAuthFormScope } from "./认证表单生命周期.js";
2
  import { api } from "../core/网络请求API.js";
3
  import { showToast } from "../components/UI交互提示组件.js";
4
  import { t } from "../components/用户体验增强.js";
 
25
  <button id="btn-submit-reset" style="width: 100%; padding: 10px; background: #FF9800; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold; margin-bottom: 10px;">${t('auth.change_password')}</button>
26
  <div style="text-align: center; font-size: 12px;"><a href="#" id="toggle-to-login" style="color: #2196F3; text-decoration: none;">${t('auth.back_to_login')}</a></div>
27
  `;
28
+ const formScope = createAuthFormScope(container);
29
 
30
  container.querySelector("#toggle-to-login").onclick = (e) => { e.preventDefault(); switchView("login"); };
31
 
 
57
  let countdownTimer = null;
58
 
59
  // 🔧 注册清理函数:视图切换时清除倒计时定时器,防止内存泄漏
60
+ formScope.addCleanup(() => { clearInterval(countdownTimer); countdownTimer = null; });
61
 
62
  btnSendCode.onclick = async (e) => {
63
  e.preventDefault();
64
+ if (!formScope.current() || btnSendCode.disabled) return;
65
 
66
  // 先抓取上方输入的账号,没填就不让发验证码
67
  const accountInput = container.querySelector("#reset-account").value.trim();
 
79
  btnSendCode.innerText = t('auth.sending');
80
 
81
  try {
82
+ const response = await api.sendVerifyCode(verifyInput, contactType, "reset", accountInput);
83
+ if (!formScope.current()) return;
84
+ if (response?.status !== 'success') throw new Error('Invalid verification response');
85
 
86
  showToast(t('auth.code_sent'), "success");
87
 
88
  countdownTimer = startCountdown(btnSendCode, 60, "#2196F3");
89
 
90
  } catch (err) {
91
+ if (!formScope.current()) return;
92
+ showToast(t('feedback.error'), "error");
93
  btnSendCode.disabled = false;
94
  btnSendCode.style.background = "#2196F3";
95
  btnSendCode.innerText = t('auth.get_code');
 
122
  };
123
  if (onSuccessCallback) onSuccessCallback(formData);
124
  };
125
+ }
前端页面/components/全局设置组件.js CHANGED
@@ -12,6 +12,7 @@ import { showToast } from "./UI交互提示组件.js";
12
  import { setLanguage, getLanguage, t } from "./用户体验增强.js";
13
  import { clearAllCache } from "./性能优化工具.js";
14
  import { IS_WEB_MODE } from "../core/全局配置.js";
 
15
 
16
  // 🔧 设置项 localStorage Key
17
  const SETTINGS_KEY = "ComfyCommunity_Settings";
@@ -76,6 +77,7 @@ function formatSize(bytes) {
76
  function getBrowserCacheStats() {
77
  let totalSize = 0;
78
  let count = 0;
 
79
  try {
80
  for (let i = 0; i < localStorage.length; i++) {
81
  const key = localStorage.key(i);
@@ -87,8 +89,8 @@ function getBrowserCacheStats() {
87
  }
88
  }
89
  }
90
- } catch (e) {}
91
- return { count, totalSize };
92
  }
93
 
94
  /**
@@ -509,28 +511,54 @@ export function createSettingsView() {
509
 
510
  // 📊 缓存统计面板
511
  const cacheStatsPanel = container.querySelector("#cache-stats-panel");
 
 
 
 
 
 
 
 
 
 
 
 
 
512
 
513
  async function loadCacheStats() {
 
 
 
 
514
  // 📱 Web 模式:无本地磁盘缓存,仅统计浏览器缓存
515
  if (IS_WEB_MODE) {
 
516
  const browserStats = getBrowserCacheStats();
 
 
 
 
 
517
  cacheStatsPanel.innerHTML = `
518
  <div style="padding: 10px 12px; background: rgba(0,0,0,0.2); border-radius: 4px; border-left: 3px solid #9C27B0; color: #aaa; font-size: 13px;">
519
  <span style="color: #ccc;">${t('settings.cache_browser')}:</span>${browserStats.count} ${t('settings.files')} · ${formatSize(browserStats.totalSize)}
520
  </div>
521
  `;
 
522
  return;
523
  }
524
  try {
525
  const [diskRes, browserStats] = await Promise.all([
526
- fetch("/community_hub/cache/stats").then(r => r.ok ? r.json() : null).catch(() => null),
527
  Promise.resolve(getBrowserCacheStats())
528
  ]);
 
 
529
 
530
- const imageCount = diskRes?.image_count || 0;
531
- const imageSize = diskRes?.image_size || 0;
532
- const videoCount = diskRes?.video_count || 0;
533
- const videoSize = diskRes?.video_size || 0;
534
  const browserCount = browserStats.count;
535
  const browserSize = browserStats.totalSize;
536
 
@@ -542,6 +570,7 @@ export function createSettingsView() {
542
  ${t('settings.no_cache')}
543
  </div>
544
  `;
 
545
  return;
546
  }
547
 
@@ -556,12 +585,16 @@ export function createSettingsView() {
556
  <span style="color: #ccc;">${t('settings.cache_browser')}:</span>${browserCount} ${t('settings.files')} · ${formatSize(browserSize)}
557
  </div>
558
  `;
 
559
  } catch (e) {
 
560
  cacheStatsPanel.innerHTML = `
561
  <div style="padding: 10px 12px; background: rgba(0,0,0,0.2); border-radius: 4px; border-left: 3px solid #9C27B0; color: #888; font-size: 13px;">
562
- ${t('settings.no_cache')}
563
  </div>
564
- `;
 
 
565
  }
566
  }
567
 
@@ -569,31 +602,34 @@ export function createSettingsView() {
569
  const btnClearBrowser = container.querySelector("#btn-clear-browser-cache");
570
  const btnClearDisk = container.querySelector("#btn-clear-disk-cache");
571
  const btnClearAll = container.querySelector("#btn-clear-all-cache");
 
 
572
 
573
  async function handleClearCache(btn, action) {
 
 
574
  const originalText = btn.textContent;
575
  btn.textContent = t('settings.cache_clearing');
576
- btn.disabled = true;
577
 
578
  try {
579
  if (action === 'browser' || action === 'all') {
580
- clearAllCache();
581
  }
582
  if (action === 'disk' || action === 'all') {
583
- await fetch("/community_hub/cache/clear", {
584
- method: "POST",
585
- headers: { "Content-Type": "application/json" },
586
- body: JSON.stringify({ target: "all" })
587
- });
588
  }
 
589
  await loadCacheStats();
590
- showToast(t('settings.cache_cleared'), "success");
591
  } catch (e) {
592
- showToast(t('settings.cache_cleared'), "success");
 
593
  await loadCacheStats();
594
  } finally {
595
  btn.textContent = originalText;
596
- btn.disabled = false;
 
597
  }
598
  }
599
 
 
12
  import { setLanguage, getLanguage, t } from "./用户体验增强.js";
13
  import { clearAllCache } from "./性能优化工具.js";
14
  import { IS_WEB_MODE } from "../core/全局配置.js";
15
+ import { getDiskCacheStats, clearDiskCache } from './缓存管理请求.js';
16
 
17
  // 🔧 设置项 localStorage Key
18
  const SETTINGS_KEY = "ComfyCommunity_Settings";
 
77
  function getBrowserCacheStats() {
78
  let totalSize = 0;
79
  let count = 0;
80
+ let available = true;
81
  try {
82
  for (let i = 0; i < localStorage.length; i++) {
83
  const key = localStorage.key(i);
 
89
  }
90
  }
91
  }
92
+ } catch (e) { available = false; }
93
+ return { count, totalSize, available };
94
  }
95
 
96
  /**
 
511
 
512
  // 📊 缓存统计面板
513
  const cacheStatsPanel = container.querySelector("#cache-stats-panel");
514
+ cacheStatsPanel.setAttribute('role', 'status');
515
+ cacheStatsPanel.setAttribute('aria-live', 'polite');
516
+ let statsGeneration = 0;
517
+ let statsController = null;
518
+ let lastStatsHTML = null;
519
+ let cacheViewDisposed = false;
520
+ container._cleanup = () => {
521
+ if (cacheViewDisposed) return;
522
+ cacheViewDisposed = true;
523
+ statsGeneration++;
524
+ statsController?.abort();
525
+ statsController = null;
526
+ };
527
 
528
  async function loadCacheStats() {
529
+ const generation = ++statsGeneration;
530
+ statsController?.abort();
531
+ const controller = new AbortController();
532
+ statsController = controller;
533
  // 📱 Web 模式:无本地磁盘缓存,仅统计浏览器缓存
534
  if (IS_WEB_MODE) {
535
+ statsController = null;
536
  const browserStats = getBrowserCacheStats();
537
+ if (!browserStats.available) {
538
+ const status = lastStatsHTML === null ? t('settings.cache_stats_unavailable') : t('settings.cache_stats_stale');
539
+ cacheStatsPanel.innerHTML = `<div>${status}</div>` + (lastStatsHTML || '');
540
+ return;
541
+ }
542
  cacheStatsPanel.innerHTML = `
543
  <div style="padding: 10px 12px; background: rgba(0,0,0,0.2); border-radius: 4px; border-left: 3px solid #9C27B0; color: #aaa; font-size: 13px;">
544
  <span style="color: #ccc;">${t('settings.cache_browser')}:</span>${browserStats.count} ${t('settings.files')} · ${formatSize(browserStats.totalSize)}
545
  </div>
546
  `;
547
+ lastStatsHTML = cacheStatsPanel.innerHTML;
548
  return;
549
  }
550
  try {
551
  const [diskRes, browserStats] = await Promise.all([
552
+ getDiskCacheStats({ signal: controller.signal }),
553
  Promise.resolve(getBrowserCacheStats())
554
  ]);
555
+ if (generation !== statsGeneration || cacheViewDisposed || !container.isConnected) return;
556
+ if (!browserStats.available) throw new Error('cache_stats_unavailable');
557
 
558
+ const imageCount = diskRes.image_count;
559
+ const imageSize = diskRes.image_size;
560
+ const videoCount = diskRes.video_count;
561
+ const videoSize = diskRes.video_size;
562
  const browserCount = browserStats.count;
563
  const browserSize = browserStats.totalSize;
564
 
 
570
  ${t('settings.no_cache')}
571
  </div>
572
  `;
573
+ lastStatsHTML = cacheStatsPanel.innerHTML;
574
  return;
575
  }
576
 
 
585
  <span style="color: #ccc;">${t('settings.cache_browser')}:</span>${browserCount} ${t('settings.files')} · ${formatSize(browserSize)}
586
  </div>
587
  `;
588
+ lastStatsHTML = cacheStatsPanel.innerHTML;
589
  } catch (e) {
590
+ if (generation !== statsGeneration || cacheViewDisposed || !container.isConnected) return;
591
  cacheStatsPanel.innerHTML = `
592
  <div style="padding: 10px 12px; background: rgba(0,0,0,0.2); border-radius: 4px; border-left: 3px solid #9C27B0; color: #888; font-size: 13px;">
593
+ ${lastStatsHTML === null ? t('settings.cache_stats_unavailable') : t('settings.cache_stats_stale')}
594
  </div>
595
+ ` + (lastStatsHTML || '');
596
+ } finally {
597
+ if (statsController === controller) statsController = null;
598
  }
599
  }
600
 
 
602
  const btnClearBrowser = container.querySelector("#btn-clear-browser-cache");
603
  const btnClearDisk = container.querySelector("#btn-clear-disk-cache");
604
  const btnClearAll = container.querySelector("#btn-clear-all-cache");
605
+ const clearButtons = [btnClearBrowser, btnClearDisk, btnClearAll].filter(Boolean);
606
+ let clearingCache = false;
607
 
608
  async function handleClearCache(btn, action) {
609
+ if (clearingCache || cacheViewDisposed || !container.isConnected) return;
610
+ clearingCache = true;
611
  const originalText = btn.textContent;
612
  btn.textContent = t('settings.cache_clearing');
613
+ clearButtons.forEach(button => { button.disabled = true; button.setAttribute('aria-busy', 'true'); });
614
 
615
  try {
616
  if (action === 'browser' || action === 'all') {
617
+ if (!clearAllCache()) throw new Error('cache_operation_unconfirmed');
618
  }
619
  if (action === 'disk' || action === 'all') {
620
+ await clearDiskCache();
 
 
 
 
621
  }
622
+ if (cacheViewDisposed || !container.isConnected) return;
623
  await loadCacheStats();
624
+ if (!cacheViewDisposed && container.isConnected) showToast(t('settings.cache_cleared'), "success");
625
  } catch (e) {
626
+ if (cacheViewDisposed || !container.isConnected) return;
627
+ showToast(t('settings.cache_clear_unconfirmed'), "error");
628
  await loadCacheStats();
629
  } finally {
630
  btn.textContent = originalText;
631
+ clearingCache = false;
632
+ clearButtons.forEach(button => { button.disabled = false; button.removeAttribute('aria-busy'); });
633
  }
634
  }
635
 
前端页面/components/图片裁剪组件.js CHANGED
@@ -9,7 +9,8 @@
9
  // 🔧 P3优化:事件监听器生命周期管理,防止内存泄漏
10
  // ==========================================
11
 
12
- import { escapeHtml } from "../core/全局配置.js";
 
13
  // 🔧 i18n:头部操作提示走词典(用户体验_国际化.js 是零依赖叶子模块,引入不成环)
14
  import { t } from "./用户体验_国际化.js";
15
 
@@ -35,8 +36,19 @@ function _isTouchDevice() {
35
  * @param {number} maxSizeMB - 最大输出大小(MB)
36
  * @returns {Promise<File|null>} - 裁剪后的文件或 null(取消)
37
  */
38
- export function openImageCropper(file, aspectRatio = 16/9, title = t('crop.title'), maxSizeMB = 3) {
39
- return new Promise((resolve) => {
 
 
 
 
 
 
 
 
 
 
 
40
  const previousFocus = document.activeElement;
41
  const dialogId = `image-crop-dialog-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
42
  // 创建遮罩层
@@ -121,10 +133,17 @@ export function openImageCropper(file, aspectRatio = 16/9, title = t('crop.title
121
  // 加载图片
122
  const reader = new FileReader();
123
  reader.onload = (e) => {
124
- img.src = e.target.result;
125
  img.onload = () => {
 
126
  imgNaturalWidth = img.naturalWidth;
127
  imgNaturalHeight = img.naturalHeight;
 
 
 
 
 
 
128
 
129
  // 计算初始缩放(确保图片填满裁剪区域)
130
  const scaleX = containerWidth / imgNaturalWidth;
@@ -134,8 +153,9 @@ export function openImageCropper(file, aspectRatio = 16/9, title = t('crop.title
134
  // 居中显示
135
  updateImagePosition();
136
  };
 
137
  };
138
- reader.readAsDataURL(file);
139
 
140
  // 更新图片位置
141
  function updateImagePosition() {
@@ -348,7 +368,7 @@ export function openImageCropper(file, aspectRatio = 16/9, title = t('crop.title
348
  });
349
  buttonContainer.innerHTML = `
350
  <button id="btn-crop-cancel" type="button" style="padding: 10px 20px; background: transparent; border: 1px solid #555; color: #ccc; border-radius: 6px; cursor: pointer; font-size: 13px;">${escapeHtml(t('common.cancel'))}</button>
351
- <button id="btn-crop-confirm" type="button" style="padding: 10px 30px; background: #4CAF50; border: none; color: white; border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: bold;">✂️ ${escapeHtml(t('crop.confirm'))}</button>
352
  `;
353
 
354
  // 组装DOM
@@ -362,7 +382,9 @@ export function openImageCropper(file, aspectRatio = 16/9, title = t('crop.title
362
  modal.querySelector("#btn-crop-cancel").focus();
363
 
364
  // 绑定缩放滑块事件
365
- setTimeout(() => {
 
 
366
  const slider = modal.querySelector("#crop-scale-slider");
367
  const scaleValue = modal.querySelector("#crop-scale-value");
368
 
@@ -385,27 +407,41 @@ export function openImageCropper(file, aspectRatio = 16/9, title = t('crop.title
385
 
386
  // 确认裁剪按钮
387
  modal.querySelector("#btn-crop-confirm").onclick = async () => {
 
388
  const btn = modal.querySelector("#btn-crop-confirm");
 
389
  btn.textContent = `⏳ ${t('crop.processing')}`;
390
  btn.disabled = true;
391
 
 
392
  try {
393
  const croppedFile = await cropAndCompress();
 
394
  cleanup();
395
  resolve(croppedFile);
396
  } catch (err) {
 
 
397
  btn.textContent = t('crop.process_failed');
398
  btn.disabled = false;
399
- console.error("裁剪失败:", err);
400
  }
401
  };
402
  }, 0);
403
 
404
  // 🔧 P3优化:统一清理函数,确保所有事件监听器被移除
405
- let isCleanedUp = false;
406
- function cleanup() {
407
  if (isCleanedUp) return; // 防止重复清理
408
  isCleanedUp = true;
 
 
 
 
 
 
 
 
 
409
  document.removeEventListener("mousemove", handleMouseMove);
410
  document.removeEventListener("mouseup", handleMouseUp);
411
  document.removeEventListener("keydown", handleKeyDown);
@@ -414,7 +450,7 @@ export function openImageCropper(file, aspectRatio = 16/9, title = t('crop.title
414
  cropContainer.removeEventListener("touchend", handleTouchEnd);
415
  cropContainer.removeEventListener("touchcancel", handleTouchEnd);
416
  overlay.remove();
417
- if (previousFocus instanceof HTMLElement && previousFocus.isConnected) previousFocus.focus();
418
  }
419
 
420
  // 点击遮罩关闭
@@ -425,78 +461,103 @@ export function openImageCropper(file, aspectRatio = 16/9, title = t('crop.title
425
  }
426
  };
427
 
 
 
 
 
 
 
 
 
 
 
 
 
428
  // 裁剪并压缩
429
  async function cropAndCompress() {
430
- const canvas = document.createElement("canvas");
431
- const ctx = canvas.getContext("2d");
432
-
433
- // 输出尺寸(根据比例)
434
- let outputWidth, outputHeight;
435
- if (aspectRatio === 1) {
436
- // 1:1 正方形(头像,512x512)
437
- outputWidth = outputHeight = 512;
438
- } else if (aspectRatio > 1) {
439
- // 16:9 横向
440
- outputWidth = Math.min(1920, containerWidth * 2);
441
- outputHeight = outputWidth / aspectRatio;
442
- } else {
443
- // 9:16 纵向
444
- outputHeight = Math.min(1920, containerHeight * 2);
445
- outputWidth = outputHeight * aspectRatio;
446
- }
 
 
447
 
448
- canvas.width = outputWidth;
449
- canvas.height = outputHeight;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
450
 
451
- // 计算裁剪区域在原图上的位置
452
- const scaledWidth = imgNaturalWidth * scale;
453
- const scaledHeight = imgNaturalHeight * scale;
454
- const imgLeft = (containerWidth - scaledWidth) / 2 + offsetX;
455
- const imgTop = (containerHeight - scaledHeight) / 2 + offsetY;
456
-
457
- // 裁剪区域相对于缩放后图片的位置
458
- const cropX = -imgLeft / scale;
459
- const cropY = -imgTop / scale;
460
- const cropW = containerWidth / scale;
461
- const cropH = containerHeight / scale;
462
-
463
- // 绘制到 canvas
464
- ctx.drawImage(
465
- img,
466
- cropX, cropY, cropW, cropH, // 源图裁剪区域
467
- 0, 0, outputWidth, outputHeight // 目标canvas
468
- );
469
-
470
- // 压缩并转换为 Blob
471
- let quality = 0.92;
472
- let blob = await canvasToBlob(canvas, quality);
473
-
474
- // 如果超过限制大小,逐步降低质量
475
- while (blob.size > maxSizeMB * 1024 * 1024 && quality > 0.3) {
476
- quality -= 0.1;
477
- blob = await canvasToBlob(canvas, quality);
478
- }
479
 
480
- // 如果仍然超过限制,缩小尺寸
481
- if (blob.size > maxSizeMB * 1024 * 1024) {
482
- const scaleFactor = Math.sqrt((maxSizeMB * 1024 * 1024) / blob.size) * 0.9;
483
- canvas.width = outputWidth * scaleFactor;
484
- canvas.height = outputHeight * scaleFactor;
485
- ctx.drawImage(img, cropX, cropY, cropW, cropH, 0, 0, canvas.width, canvas.height);
486
- blob = await canvasToBlob(canvas, 0.85);
 
 
 
 
487
  }
488
-
489
- // 生成文件名
490
- const baseName = file.name.replace(/\.[^/.]+$/, "");
491
- const fileName = `${baseName}_cropped.jpg`;
492
-
493
- return new File([blob], fileName, { type: "image/jpeg" });
494
  }
495
 
496
  // Canvas 转 Blob 辅助函数
497
  function canvasToBlob(canvas, quality) {
498
  return new Promise((resolve) => {
499
- canvas.toBlob((blob) => resolve(blob), "image/jpeg", quality);
 
500
  });
501
  }
502
  });
 
9
  // 🔧 P3优化:事件监听器生命周期管理,防止内存泄漏
10
  // ==========================================
11
 
12
+ import { validateImageFile } from "../market/图片文件检查.js";
13
+ import { IMAGE_PROCESS, escapeHtml } from "../core/全局配置.js";
14
  // 🔧 i18n:头部操作提示走词典(用户体验_国际化.js 是零依赖叶子模块,引入不成环)
15
  import { t } from "./用户体验_国际化.js";
16
 
 
36
  * @param {number} maxSizeMB - 最大输出大小(MB)
37
  * @returns {Promise<File|null>} - 裁剪后的文件或 null(取消)
38
  */
39
+ export async function openImageCropper(file, aspectRatio = 16/9, title = t('crop.title'), maxSizeMB = 3, { signal } = {}) {
40
+ if (signal?.aborted) return null;
41
+ try { await validateImageFile(file, { signal }); }
42
+ catch (error) { if (signal?.aborted || error?.name === 'AbortError') return null; throw error; }
43
+ if (signal?.aborted) return null;
44
+ return new Promise((resolve, reject) => {
45
+ let isCleanedUp = false, bindTimer = null, activeCanvas = null;
46
+ let removalObserver = null, decodeTimer = null, encodeTimer = null;
47
+ const fail = () => {
48
+ if (isCleanedUp) return;
49
+ cleanup(false); reject(new Error('Crop processing unavailable'));
50
+ };
51
+ const onAbort = () => { cleanup(false); resolve(null); };
52
  const previousFocus = document.activeElement;
53
  const dialogId = `image-crop-dialog-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
54
  // 创建遮罩层
 
133
  // 加载图片
134
  const reader = new FileReader();
135
  reader.onload = (e) => {
136
+ if (isCleanedUp) return;
137
  img.onload = () => {
138
+ if (isCleanedUp) return;
139
  imgNaturalWidth = img.naturalWidth;
140
  imgNaturalHeight = img.naturalHeight;
141
+ if (!Number.isSafeInteger(imgNaturalWidth) || !Number.isSafeInteger(imgNaturalHeight)
142
+ || imgNaturalWidth <= 0 || imgNaturalHeight <= 0
143
+ || Math.max(imgNaturalWidth, imgNaturalHeight) > IMAGE_PROCESS.MAX_SOURCE_EDGE
144
+ || imgNaturalWidth * imgNaturalHeight > IMAGE_PROCESS.MAX_PIXELS) { fail(); return; }
145
+ clearTimeout(decodeTimer); decodeTimer = null;
146
+ modal.querySelector('#btn-crop-confirm').disabled = false;
147
 
148
  // 计算初始缩放(确保图片填满裁剪区域)
149
  const scaleX = containerWidth / imgNaturalWidth;
 
153
  // 居中显示
154
  updateImagePosition();
155
  };
156
+ img.src = e.target.result;
157
  };
158
+ reader.onerror = fail; img.onerror = fail;
159
 
160
  // 更新图片位置
161
  function updateImagePosition() {
 
368
  });
369
  buttonContainer.innerHTML = `
370
  <button id="btn-crop-cancel" type="button" style="padding: 10px 20px; background: transparent; border: 1px solid #555; color: #ccc; border-radius: 6px; cursor: pointer; font-size: 13px;">${escapeHtml(t('common.cancel'))}</button>
371
+ <button id="btn-crop-confirm" type="button" disabled style="padding: 10px 30px; background: #4CAF50; border: none; color: white; border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: bold;">✂️ ${escapeHtml(t('crop.confirm'))}</button>
372
  `;
373
 
374
  // 组装DOM
 
382
  modal.querySelector("#btn-crop-cancel").focus();
383
 
384
  // 绑定缩放滑块事件
385
+ bindTimer = setTimeout(() => {
386
+ bindTimer = null;
387
+ if (isCleanedUp) return;
388
  const slider = modal.querySelector("#crop-scale-slider");
389
  const scaleValue = modal.querySelector("#crop-scale-value");
390
 
 
407
 
408
  // 确认裁剪按钮
409
  modal.querySelector("#btn-crop-confirm").onclick = async () => {
410
+ if (isCleanedUp) return;
411
  const btn = modal.querySelector("#btn-crop-confirm");
412
+ if (btn.disabled) return;
413
  btn.textContent = `⏳ ${t('crop.processing')}`;
414
  btn.disabled = true;
415
 
416
+ encodeTimer = setTimeout(fail, IMAGE_PROCESS.TIMEOUT);
417
  try {
418
  const croppedFile = await cropAndCompress();
419
+ if (isCleanedUp) return;
420
  cleanup();
421
  resolve(croppedFile);
422
  } catch (err) {
423
+ if (isCleanedUp) return;
424
+ clearTimeout(encodeTimer); encodeTimer = null;
425
  btn.textContent = t('crop.process_failed');
426
  btn.disabled = false;
427
+ console.error("[RanKing] Crop processing failed");
428
  }
429
  };
430
  }, 0);
431
 
432
  // 🔧 P3优化:统一清理函数,确保所有事件监听器被移除
433
+ function cleanup(restoreFocus = true) {
 
434
  if (isCleanedUp) return; // 防止重复清理
435
  isCleanedUp = true;
436
+ const ownedFocus = modal.contains(document.activeElement);
437
+ clearTimeout(bindTimer); bindTimer = null;
438
+ clearTimeout(decodeTimer); clearTimeout(encodeTimer); decodeTimer = encodeTimer = null;
439
+ removalObserver?.disconnect(); removalObserver = null;
440
+ signal?.removeEventListener('abort', onAbort);
441
+ reader.onload = reader.onerror = null;
442
+ if (reader.readyState === FileReader.LOADING) { try { reader.abort(); } catch (_) { /* 继续释放其余所属资源 */ } }
443
+ img.onload = img.onerror = null; img.removeAttribute('src');
444
+ if (activeCanvas) { activeCanvas.width = activeCanvas.height = 0; activeCanvas = null; }
445
  document.removeEventListener("mousemove", handleMouseMove);
446
  document.removeEventListener("mouseup", handleMouseUp);
447
  document.removeEventListener("keydown", handleKeyDown);
 
450
  cropContainer.removeEventListener("touchend", handleTouchEnd);
451
  cropContainer.removeEventListener("touchcancel", handleTouchEnd);
452
  overlay.remove();
453
+ if (restoreFocus && ownedFocus && previousFocus instanceof HTMLElement && previousFocus.isConnected) previousFocus.focus();
454
  }
455
 
456
  // 点击遮罩关闭
 
461
  }
462
  };
463
 
464
+ signal?.addEventListener('abort', onAbort, { once: true });
465
+ if (signal?.aborted) onAbort();
466
+ if (!isCleanedUp) {
467
+ removalObserver = new MutationObserver(() => { if (!overlay.isConnected || !modal.isConnected) onAbort(); });
468
+ removalObserver.observe(document.body, { childList: true, subtree: true });
469
+ if (!overlay.isConnected || !modal.isConnected) onAbort();
470
+ }
471
+ if (!isCleanedUp) {
472
+ decodeTimer = setTimeout(fail, IMAGE_PROCESS.TIMEOUT);
473
+ try { reader.readAsDataURL(file); } catch (_) { fail(); }
474
+ }
475
+
476
  // 裁剪并压缩
477
  async function cropAndCompress() {
478
+ if (isCleanedUp) throw new Error('Crop closed');
479
+ const canvas = document.createElement("canvas"); activeCanvas = canvas;
480
+ try {
481
+ const ctx = canvas.getContext("2d");
482
+
483
+ // 输出尺寸(根据比例)
484
+ let outputWidth, outputHeight;
485
+ if (aspectRatio === 1) {
486
+ // 1:1 正方形(头像,512x512)
487
+ outputWidth = outputHeight = 512;
488
+ } else if (aspectRatio > 1) {
489
+ // 16:9 横向
490
+ outputWidth = Math.min(1920, containerWidth * 2);
491
+ outputHeight = outputWidth / aspectRatio;
492
+ } else {
493
+ // 9:16 纵向
494
+ outputHeight = Math.min(1920, containerHeight * 2);
495
+ outputWidth = outputHeight * aspectRatio;
496
+ }
497
 
498
+ canvas.width = outputWidth;
499
+ canvas.height = outputHeight;
500
+
501
+ // 计算裁剪区域在原图上的位置
502
+ const scaledWidth = imgNaturalWidth * scale;
503
+ const scaledHeight = imgNaturalHeight * scale;
504
+ const imgLeft = (containerWidth - scaledWidth) / 2 + offsetX;
505
+ const imgTop = (containerHeight - scaledHeight) / 2 + offsetY;
506
+
507
+ // 裁剪区域相对于缩放后图片的位置
508
+ const cropX = -imgLeft / scale;
509
+ const cropY = -imgTop / scale;
510
+ const cropW = containerWidth / scale;
511
+ const cropH = containerHeight / scale;
512
+
513
+ // 绘制到 canvas
514
+ ctx.drawImage(
515
+ img,
516
+ cropX, cropY, cropW, cropH, // 源图裁剪区域
517
+ 0, 0, outputWidth, outputHeight // 目标canvas
518
+ );
519
+
520
+ // 压缩并转换为 Blob
521
+ let quality = 0.92;
522
+ let blob = await canvasToBlob(canvas, quality);
523
+ if (isCleanedUp || !blob) throw new Error('Crop output unavailable');
524
+
525
+ // 如果超过限制大小,逐步降低质量
526
+ while (blob.size > maxSizeMB * 1024 * 1024 && quality > 0.3) {
527
+ quality -= 0.1;
528
+ blob = await canvasToBlob(canvas, quality);
529
+ if (isCleanedUp || !blob) throw new Error('Crop output unavailable');
530
+ }
531
 
532
+ // 如果仍然超过限制,缩小尺寸
533
+ if (blob.size > maxSizeMB * 1024 * 1024) {
534
+ const scaleFactor = Math.sqrt((maxSizeMB * 1024 * 1024) / blob.size) * 0.9;
535
+ canvas.width = outputWidth * scaleFactor;
536
+ canvas.height = outputHeight * scaleFactor;
537
+ ctx.drawImage(img, cropX, cropY, cropW, cropH, 0, 0, canvas.width, canvas.height);
538
+ blob = await canvasToBlob(canvas, 0.85);
539
+ if (isCleanedUp || !blob) throw new Error('Crop output unavailable');
540
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
541
 
542
+ if (!Number.isSafeInteger(blob.size) || blob.size <= 0 || blob.size > maxSizeMB * 1024 * 1024) {
543
+ throw new Error('Crop output exceeds budget');
544
+ }
545
+ // 生成文件名
546
+ const baseName = file.name.replace(/\.[^/.]+$/, "");
547
+ const fileName = `${baseName}_cropped.jpg`;
548
+
549
+ return new File([blob], fileName, { type: "image/jpeg" });
550
+ } finally {
551
+ canvas.width = canvas.height = 0;
552
+ if (activeCanvas === canvas) activeCanvas = null;
553
  }
 
 
 
 
 
 
554
  }
555
 
556
  // Canvas 转 Blob 辅助函数
557
  function canvasToBlob(canvas, quality) {
558
  return new Promise((resolve) => {
559
+ if (isCleanedUp) throw new Error('Crop closed');
560
+ canvas.toBlob((blob) => resolve(isCleanedUp ? null : blob), "image/jpeg", quality);
561
  });
562
  }
563
  });
前端页面/components/用户体验_国际化.js CHANGED
@@ -237,6 +237,9 @@ const translations = {
237
  'settings.clear_all_cache': '清除全部缓存',
238
  'settings.cache_clearing': '清理中...',
239
  'settings.cache_cleared': '缓存已清除',
 
 
 
240
  'settings.files': '个文件',
241
  'settings.no_cache': '无缓存',
242
 
@@ -545,6 +548,8 @@ const translations = {
545
  'social.unfollow': '取消关注',
546
  'social.send_message': '发消息',
547
  'social.tip_author': '赞赏作者',
 
 
548
  'social.say_something': '说点什么...',
549
  'social.reply': '回复',
550
  'social.reply_to': '回复',
@@ -655,6 +660,8 @@ const translations = {
655
  'creator.tip_login_required': '请先登录',
656
  'creator.message_board': '💬 创作者留言板',
657
  'creator.visit_profile': '访问 TA 的主页',
 
 
658
 
659
  // 通用补充
660
  'common.back': '返回',
@@ -867,6 +874,12 @@ const translations = {
867
  'task.delete_confirm_desc': '删除后无法恢复,确定要删除这个任务吗?',
868
  'task.delete_success': '任务已删除',
869
  'task.delete_failed': '删除失败',
 
 
 
 
 
 
870
  'task.cancel_apply': '撤回申请',
871
  'task.apply_task': '🙋 申请接单',
872
  'task.submit_result': '📤 提交成果',
@@ -1607,6 +1620,7 @@ const translations = {
1607
  'settings_form.crop_ui_bg': '裁剪界面背景',
1608
  'settings_form.uploading': '上传中...',
1609
  'settings_form.avatar_process_failed': '头像处理失败,请重新选择图片后重试。',
 
1610
  'settings_form.banner_selected': '背景图已选择,点击底部保存设置后才会上传。',
1611
  'settings_form.ui_bg_applied': '界面背景设置成功,已立即生效!',
1612
  'settings_form.ui_bg_cleared': '已清除界面背景,已立即生效。',
@@ -1929,6 +1943,9 @@ const translations = {
1929
  'settings.clear_all_cache': 'Clear All Cache',
1930
  'settings.cache_clearing': 'Clearing...',
1931
  'settings.cache_cleared': 'Cache Cleared',
 
 
 
1932
  'settings.files': 'files',
1933
  'settings.no_cache': 'No Cache',
1934
 
@@ -2238,6 +2255,8 @@ const translations = {
2238
  'social.unfollow': 'Unfollow',
2239
  'social.send_message': 'Send Message',
2240
  'social.tip_author': 'Tip Author',
 
 
2241
  'social.say_something': 'Say something...',
2242
  'social.reply_to': 'Reply to',
2243
  'social.comment_deleted': 'This comment has been deleted',
@@ -2345,6 +2364,8 @@ const translations = {
2345
  'creator.tip_login_required': 'Please login first',
2346
  'creator.message_board': '💬 Creator Message Board',
2347
  'creator.visit_profile': 'Visit profile',
 
 
2348
  'common.back': 'Back',
2349
 
2350
  // Market
@@ -2544,6 +2565,12 @@ const translations = {
2544
  'task.delete_confirm_desc': 'This action cannot be undone. Are you sure you want to delete this task?',
2545
  'task.delete_success': 'Task deleted',
2546
  'task.delete_failed': 'Failed to delete',
 
 
 
 
 
 
2547
  'task.cancel_apply': 'Withdraw',
2548
  'task.apply_task': '🙋 Apply',
2549
  'task.submit_result': '📤 Submit Result',
@@ -3261,6 +3288,7 @@ const translations = {
3261
  'settings_form.crop_ui_bg': 'Crop interface background',
3262
  'settings_form.uploading': 'Uploading...',
3263
  'settings_form.avatar_process_failed': 'Could not process the avatar. Choose another image and try again.',
 
3264
  'settings_form.banner_selected': 'Banner selected. Save settings below to upload it.',
3265
  'settings_form.ui_bg_applied': 'Interface background updated.',
3266
  'settings_form.ui_bg_cleared': 'Interface background cleared.',
 
237
  'settings.clear_all_cache': '清除全部缓存',
238
  'settings.cache_clearing': '清理中...',
239
  'settings.cache_cleared': '缓存已清除',
240
+ 'settings.cache_clear_unconfirmed': '缓存清理未确认完成,部分缓存可能已清除。请刷新统计后再试。',
241
+ 'settings.cache_stats_unavailable': '暂时无法读取缓存统计',
242
+ 'settings.cache_stats_stale': '暂时无法刷新,仍显示上次统计',
243
  'settings.files': '个文件',
244
  'settings.no_cache': '无缓存',
245
 
 
548
  'social.unfollow': '取消关注',
549
  'social.send_message': '发消息',
550
  'social.tip_author': '赞赏作者',
551
+ 'social.comment_refresh_required': '评论操作已完成,请重新打开当前页面更新显示后再操作。',
552
+ 'social.comment_unconfirmed': '评论操作结果尚未确认,请重新打开当前页面核对后再操作。',
553
  'social.say_something': '说点什么...',
554
  'social.reply': '回复',
555
  'social.reply_to': '回复',
 
660
  'creator.tip_login_required': '请先登录',
661
  'creator.message_board': '💬 创作者留言板',
662
  'creator.visit_profile': '访问 TA 的主页',
663
+ 'creator.expand_details': '展开详情',
664
+ 'creator.collapse_details': '收起详情',
665
 
666
  // 通用补充
667
  'common.back': '返回',
 
874
  'task.delete_confirm_desc': '删除后无法恢复,确定要删除这个任务吗?',
875
  'task.delete_success': '任务已删除',
876
  'task.delete_failed': '删除失败',
877
+ 'task.delete_unconfirmed': '删除结果尚未确认,请先关闭弹窗并核对任务状态,避免重复操作。',
878
+ 'task.delete_pending': '删除结果待确认',
879
+ 'task.operation_unconfirmed': '操作结果尚未确认,请先关闭弹窗并核对任务和钱包状态,避免重复操作。',
880
+ 'task.operation_pending': '操作结果待确认',
881
+ 'task.comment_unconfirmed': '评论操作结果尚未确认,请先重新打开任务核对评论,避免重复操作。',
882
+ 'task.comment_pending': '评论结果待确认',
883
  'task.cancel_apply': '撤回申请',
884
  'task.apply_task': '🙋 申请接单',
885
  'task.submit_result': '📤 提交成果',
 
1620
  'settings_form.crop_ui_bg': '裁剪界面背景',
1621
  'settings_form.uploading': '上传中...',
1622
  'settings_form.avatar_process_failed': '头像处理失败,请重新选择图片后重试。',
1623
+ 'settings_form.image_process_failed': '图片处理失败,请重新选择图片后重试。',
1624
  'settings_form.banner_selected': '背景图已选择,点击底部保存设置后才会上传。',
1625
  'settings_form.ui_bg_applied': '界面背景设置成功,已立即生效!',
1626
  'settings_form.ui_bg_cleared': '已清除界面背景,已立即生效。',
 
1943
  'settings.clear_all_cache': 'Clear All Cache',
1944
  'settings.cache_clearing': 'Clearing...',
1945
  'settings.cache_cleared': 'Cache Cleared',
1946
+ 'settings.cache_clear_unconfirmed': 'Cache clearing was not confirmed. Some cache may have been removed. Refresh the statistics before trying again.',
1947
+ 'settings.cache_stats_unavailable': 'Cache statistics are temporarily unavailable',
1948
+ 'settings.cache_stats_stale': 'Refresh failed. Showing the previous statistics.',
1949
  'settings.files': 'files',
1950
  'settings.no_cache': 'No Cache',
1951
 
 
2255
  'social.unfollow': 'Unfollow',
2256
  'social.send_message': 'Send Message',
2257
  'social.tip_author': 'Tip Author',
2258
+ 'social.comment_refresh_required': 'The comment action completed. Reopen this page to update the display before another action.',
2259
+ 'social.comment_unconfirmed': 'The comment result is unconfirmed. Reopen this page to check before another action.',
2260
  'social.say_something': 'Say something...',
2261
  'social.reply_to': 'Reply to',
2262
  'social.comment_deleted': 'This comment has been deleted',
 
2364
  'creator.tip_login_required': 'Please login first',
2365
  'creator.message_board': '💬 Creator Message Board',
2366
  'creator.visit_profile': 'Visit profile',
2367
+ 'creator.expand_details': 'Show details',
2368
+ 'creator.collapse_details': 'Hide details',
2369
  'common.back': 'Back',
2370
 
2371
  // Market
 
2565
  'task.delete_confirm_desc': 'This action cannot be undone. Are you sure you want to delete this task?',
2566
  'task.delete_success': 'Task deleted',
2567
  'task.delete_failed': 'Failed to delete',
2568
+ 'task.delete_unconfirmed': 'Deletion is unconfirmed. Close this dialog and check the task status before trying again.',
2569
+ 'task.delete_pending': 'Deletion unconfirmed',
2570
+ 'task.operation_unconfirmed': 'The result is unconfirmed. Close this dialog and check the task and wallet before trying again.',
2571
+ 'task.operation_pending': 'Result unconfirmed',
2572
+ 'task.comment_unconfirmed': 'The comment result is unconfirmed. Reopen the task and check its comments before trying again.',
2573
+ 'task.comment_pending': 'Comment result unconfirmed',
2574
  'task.cancel_apply': 'Withdraw',
2575
  'task.apply_task': '🙋 Apply',
2576
  'task.submit_result': '📤 Submit Result',
 
3288
  'settings_form.crop_ui_bg': 'Crop interface background',
3289
  'settings_form.uploading': 'Uploading...',
3290
  'settings_form.avatar_process_failed': 'Could not process the avatar. Choose another image and try again.',
3291
+ 'settings_form.image_process_failed': 'Could not process the image. Choose another image and try again.',
3292
  'settings_form.banner_selected': 'Banner selected. Save settings below to upload it.',
3293
  'settings_form.ui_bg_applied': 'Interface background updated.',
3294
  'settings_form.ui_bg_cleared': 'Interface background cleared.',
前端页面/components/缓存管理请求.js ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // 本机缓存运维请求:单次提交、有界响应;不参与云端冷却或榜单缓存。
2
+ import { API } from '../core/全局配置.js';
3
+
4
+ async function readSmallJSON(response, controller) {
5
+ const declared = response.headers.get('Content-Length');
6
+ if (declared !== null && (!/^\d+$/.test(declared) || Number(declared) > API.LOCAL_CACHE_RESPONSE_MAX_BYTES)) {
7
+ throw new Error('cache_response_invalid');
8
+ }
9
+ const reader = response.body?.getReader();
10
+ if (!reader) throw new Error('cache_response_invalid');
11
+ const chunks = [];
12
+ let size = 0, complete = false;
13
+ try {
14
+ while (true) {
15
+ const chunk = await reader.read();
16
+ if (chunk.done) { complete = true; break; }
17
+ if (!chunk.value.byteLength) continue;
18
+ size += chunk.value.byteLength;
19
+ if (size > API.LOCAL_CACHE_RESPONSE_MAX_BYTES) throw new Error('cache_response_invalid');
20
+ chunks.push(chunk.value);
21
+ }
22
+ const body = new Uint8Array(size);
23
+ let offset = 0;
24
+ for (const chunk of chunks) { body.set(chunk, offset); offset += chunk.byteLength; }
25
+ return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(body));
26
+ } finally {
27
+ if (!complete) {
28
+ controller.abort();
29
+ try { await reader.cancel(); } catch {}
30
+ }
31
+ reader.releaseLock();
32
+ }
33
+ }
34
+
35
+ async function requestCache(action, { signal } = {}) {
36
+ const controller = new AbortController();
37
+ const abort = () => controller.abort();
38
+ signal?.addEventListener('abort', abort, { once: true });
39
+ if (signal?.aborted) controller.abort();
40
+ const timer = setTimeout(abort, action === 'stats' ? API.LOCAL_CACHE_STATS_TIMEOUT : API.LOCAL_CACHE_CLEAR_TIMEOUT);
41
+ try {
42
+ if (controller.signal.aborted) throw new Error('cache_operation_unconfirmed');
43
+ const response = await fetch('/community_hub/cache/'+action, {
44
+ method: action === 'stats' ? 'GET' : 'POST',
45
+ headers: action === 'stats' ? {} : { 'Content-Type': 'application/json' },
46
+ body: action === 'stats' ? undefined : JSON.stringify({ target: 'all' }),
47
+ cache: 'no-store', signal: controller.signal,
48
+ });
49
+ if (!response.ok) throw new Error('cache_operation_unconfirmed');
50
+ const data = await readSmallJSON(response, controller);
51
+ const fields = action === 'stats' ? ['image_count', 'image_size', 'video_count', 'video_size'] : ['cleared_count', 'freed_size'];
52
+ if (!data || typeof data !== 'object' || Array.isArray(data)
53
+ || fields.some(key => !Number.isSafeInteger(data[key]) || data[key] < 0)) {
54
+ throw new Error('cache_response_invalid');
55
+ }
56
+ return Object.fromEntries(fields.map(key => [key, data[key]]));
57
+ } catch {
58
+ throw new Error('cache_operation_unconfirmed');
59
+ } finally {
60
+ clearTimeout(timer);
61
+ signal?.removeEventListener('abort', abort);
62
+ controller.abort();
63
+ }
64
+ }
65
+
66
+ export const getDiskCacheStats = options => requestCache('stats', options);
67
+ export const clearDiskCache = options => requestCache('clear', options);
前端页面/components/顶部广告组件.js CHANGED
@@ -2,6 +2,7 @@
2
 
3
  import { api } from "../core/网络请求API.js";
4
  import { API, CACHE } from "../core/全局配置.js";
 
5
  import { t } from "./用户体验_国际化.js";
6
 
7
  // ========== 广告配置数据(默认值 / fallback) ==========
@@ -54,17 +55,22 @@ function validateBannerConfig(data) {
54
  * @returns {Object|null} data 为缓存内容,无缓存时为 null
55
  */
56
  function readBannerCache() {
 
57
  try {
58
- const cached = localStorage.getItem(BANNER_CACHE_KEY);
59
- if (cached) {
60
- const { data } = JSON.parse(cached);
61
- return validateBannerConfig(data);
62
- }
63
- } catch (parseErr) {
64
- // 缓存数据损坏,清除
 
 
 
 
65
  try {
66
  localStorage.removeItem(BANNER_CACHE_KEY);
67
- } catch (storageErr) {
68
  console.warn("广告缓存不可清理,继续使用默认显示流程");
69
  }
70
  }
@@ -98,6 +104,9 @@ async function prepareBannerConfig(data) {
98
  if (data === null) return { config: { ...BANNER_CONFIG }, image: null };
99
  const config = validateBannerConfig(data);
100
  if (!config.enabled) return { config, image: null };
 
 
 
101
  const [image] = await Promise.all([
102
  preloadImage(config.bannerImage),
103
  preloadImage(config.detailImage)
 
2
 
3
  import { api } from "../core/网络请求API.js";
4
  import { API, CACHE } from "../core/全局配置.js";
5
+ import { getCompleteImageUrl } from "../core/网络请求_图片代理.js";
6
  import { t } from "./用户体验_国际化.js";
7
 
8
  // ========== 广告配置数据(默认值 / fallback) ==========
 
55
  * @returns {Object|null} data 为缓存内容,无缓存时为 null
56
  */
57
  function readBannerCache() {
58
+ let cached;
59
  try {
60
+ cached = localStorage.getItem(BANNER_CACHE_KEY);
61
+ } catch (storageError) {
62
+ // 无法读取不代表缓存损坏;保留未确认的数据,等待存储恢复。
63
+ return null;
64
+ }
65
+ if (!cached) return null;
66
+ try {
67
+ const { data } = JSON.parse(cached);
68
+ return validateBannerConfig(data);
69
+ } catch (parseError) {
70
+ // 只有已经读到且确认无效的配置才清除。
71
  try {
72
  localStorage.removeItem(BANNER_CACHE_KEY);
73
+ } catch (storageError) {
74
  console.warn("广告缓存不可清理,继续使用默认显示流程");
75
  }
76
  }
 
104
  if (data === null) return { config: { ...BANNER_CONFIG }, image: null };
105
  const config = validateBannerConfig(data);
106
  if (!config.enabled) return { config, image: null };
107
+ // 新配置在本地缓存中保留已下载的代理地址;旧缓存直链仍按原流程立即显示。
108
+ config.bannerImage = getCompleteImageUrl(config.bannerImage);
109
+ config.detailImage = getCompleteImageUrl(config.detailImage);
110
  const [image] = await Promise.all([
111
  preloadImage(config.bannerImage),
112
  preloadImage(config.detailImage)
前端页面/core/侧边栏主程序.js CHANGED
@@ -46,9 +46,30 @@ try {
46
  // option 的 value 是独立字面量,排序/筛选逻辑不受影响
47
  const noTr = (txt) => `${txt}\u200b`;
48
 
 
 
49
  const Store = {
50
- save(key, value) { localStorage.setItem(`ComfyCommunitySidebar_${key}`, value); },
51
- load(key, defaultValue) { return localStorage.getItem(`ComfyCommunitySidebar_${key}`) || defaultValue; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  };
53
 
54
  // 工具背景图本地存储管理(账号隔离)
@@ -61,7 +82,10 @@ const BackgroundStore = {
61
  console.warn("⚠️ 界面背景保存失败(存储空间不足或不可用):", e.message);
62
  }
63
  },
64
- load() { return localStorage.getItem(getBackgroundKey()) || null; },
 
 
 
65
  clear() {
66
  try {
67
  localStorage.removeItem(getBackgroundKey());
 
46
  // option 的 value 是独立字面量,排序/筛选逻辑不受影响
47
  const noTr = (txt) => `${txt}\u200b`;
48
 
49
+ const sidebarPreferenceMemory = new Map();
50
+ const pendingSidebarPreferences = new Set();
51
  const Store = {
52
+ save(key, value) {
53
+ const saved = String(value);
54
+ sidebarPreferenceMemory.set(key, saved);
55
+ try {
56
+ localStorage.setItem(`ComfyCommunitySidebar_${key}`, saved);
57
+ pendingSidebarPreferences.delete(key);
58
+ } catch {
59
+ pendingSidebarPreferences.add(key);
60
+ }
61
+ },
62
+ load(key, defaultValue) {
63
+ if (pendingSidebarPreferences.has(key)) return sidebarPreferenceMemory.get(key) || defaultValue;
64
+ try {
65
+ const saved = localStorage.getItem(`ComfyCommunitySidebar_${key}`);
66
+ if (saved === null) sidebarPreferenceMemory.delete(key);
67
+ else sidebarPreferenceMemory.set(key, saved);
68
+ return saved || defaultValue;
69
+ } catch {
70
+ return sidebarPreferenceMemory.get(key) || defaultValue;
71
+ }
72
+ }
73
  };
74
 
75
  // 工具背景图本地存储管理(账号隔离)
 
82
  console.warn("⚠️ 界面背景保存失败(存储空间不足或不可用):", e.message);
83
  }
84
  },
85
+ load() {
86
+ try { return localStorage.getItem(getBackgroundKey()) || null; }
87
+ catch { return null; }
88
+ },
89
  clear() {
90
  try {
91
  localStorage.removeItem(getBackgroundKey());
前端页面/core/全局配置.js CHANGED
@@ -18,7 +18,7 @@
18
  // 🌐 API 配置
19
  // ==========================================
20
 
21
- /** @type {{ BASE_URL: string, TIMEOUT: number, VIDEO_UPLOAD_TIMEOUT: number, VIDEO_UPLOAD_RESPONSE_MAX_CHARS: number, MAX_RETRIES: number, RETRY_DELAY: number, LIST_RETRIES: number, SEARCH_TIMEOUT: number, SEARCH_RETRIES: number, PROFILE_RESOURCE_TIMEOUT: number, BACKGROUND_TIMEOUT: number, BACKGROUND_RETRIES: number, WORKFLOW_DOWNLOAD_TIMEOUT: number, CLOUD_DOWN_COOLDOWN: number, SEARCH_COMPONENT_ID: string, IMAGE_PROXY_PATH: string, PRIVATE_GET_PATTERNS: RegExp[], PUBLIC_CONDITIONAL_GET_PATHS: string[], CONDITIONAL_ETAG_PATTERN: RegExp }} */
22
  export const API = {
23
  // 仅公开榜单使用由正文派生的条件验证器,私有响应仍禁止留存。
24
  PUBLIC_CONDITIONAL_GET_PATHS: ['/api/items', '/api/creators', '/api/creators/search', '/api/posts', '/api/prompts', '/api/tasks'],
@@ -31,6 +31,9 @@ export const API = {
31
  TIMEOUT: 30000,
32
  VIDEO_UPLOAD_TIMEOUT: 300000,
33
  VIDEO_UPLOAD_RESPONSE_MAX_CHARS: 65536,
 
 
 
34
 
35
  // GET 请求重试次数(实际尝试 MAX_RETRIES + 1 次)
36
  // 🔧 修复:此前本值为 3 且全库无人读取,真正的重试次数硬编码在 网络请求_基础设施.js 中为 2,
@@ -50,6 +53,7 @@ export const API = {
50
  SEARCH_TIMEOUT: 10000,
51
  SEARCH_RETRIES: 1,
52
  PROFILE_RESOURCE_TIMEOUT: 5000,
 
53
 
54
  // 🐢 后台补新预算(仅作用于 GET,由 网络请求_基础设施.js 按「本地是否已有可兜底数据」判定)
55
  // 七大列表视图都是「本地数据先上屏 → 再发请求补新」,此时界面上已经有内容,
@@ -75,6 +79,9 @@ export const API = {
75
  SEARCH_COMPONENT_ID: "sidebar-search",
76
  // 上传相对路径的云端图片读取入口。
77
  IMAGE_PROXY_PATH: "/api/image_proxy",
 
 
 
78
  // 身份相关或私有 GET 禁止通用响应缓存;公开列表仍使用原缓存策略。
79
  PRIVATE_GET_PATTERNS: [
80
  /^\/api\/(wallet|messages|chats|admin|disputes|users)(?:\/|$)/,
@@ -760,4 +767,4 @@ export default {
760
  export const PROMPT_LIMITS = Object.freeze({ MAX_PRICE: 10000 });
761
 
762
  // 任务定价边界,与云端 task_publication/task_terms 对应。
763
- export const TASK_LIMITS = Object.freeze({ MAX_PRICE: 2147483647, MAX_REFERENCE_IMAGES: 6, DEPOSIT_RATIOS: Object.freeze([10, 20, 30, 50]) });
 
18
  // 🌐 API 配置
19
  // ==========================================
20
 
21
+ /** @type {{ BASE_URL: string, TIMEOUT: number, VIDEO_UPLOAD_TIMEOUT: number, VIDEO_UPLOAD_RESPONSE_MAX_CHARS: number, LOCAL_CACHE_STATS_TIMEOUT: number, LOCAL_CACHE_CLEAR_TIMEOUT: number, LOCAL_CACHE_RESPONSE_MAX_BYTES: number, MAX_RETRIES: number, RETRY_DELAY: number, LIST_RETRIES: number, SEARCH_TIMEOUT: number, SEARCH_RETRIES: number, PROFILE_RESOURCE_TIMEOUT: number, CREATOR_CHART_SCRIPT_URL: string, BACKGROUND_TIMEOUT: number, BACKGROUND_RETRIES: number, WORKFLOW_DOWNLOAD_TIMEOUT: number, CLOUD_DOWN_COOLDOWN: number, SEARCH_COMPONENT_ID: string, IMAGE_PROXY_PATH: string, LOCAL_IMAGE_PROXY_PREFIX: string, COMPLETE_IMAGE_QUERY_SUFFIX: string, PRIVATE_GET_PATTERNS: RegExp[], PUBLIC_CONDITIONAL_GET_PATHS: string[], CONDITIONAL_ETAG_PATTERN: RegExp }} */
22
  export const API = {
23
  // 仅公开榜单使用由正文派生的条件验证器,私有响应仍禁止留存。
24
  PUBLIC_CONDITIONAL_GET_PATHS: ['/api/items', '/api/creators', '/api/creators/search', '/api/posts', '/api/prompts', '/api/tasks'],
 
31
  TIMEOUT: 30000,
32
  VIDEO_UPLOAD_TIMEOUT: 300000,
33
  VIDEO_UPLOAD_RESPONSE_MAX_CHARS: 65536,
34
+ LOCAL_CACHE_STATS_TIMEOUT: 10000,
35
+ LOCAL_CACHE_CLEAR_TIMEOUT: 60000,
36
+ LOCAL_CACHE_RESPONSE_MAX_BYTES: 8192,
37
 
38
  // GET 请求重试次数(实际尝试 MAX_RETRIES + 1 次)
39
  // 🔧 修复:此前本值为 3 且全库无人读取,真正的重试次数硬编码在 网络请求_基础设施.js 中为 2,
 
53
  SEARCH_TIMEOUT: 10000,
54
  SEARCH_RETRIES: 1,
55
  PROFILE_RESOURCE_TIMEOUT: 5000,
56
+ CREATOR_CHART_SCRIPT_URL: "https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js",
57
 
58
  // 🐢 后台补新预算(仅作用于 GET,由 网络请求_基础设施.js 按「本地是否已有可兜底数据」判定)
59
  // 七大列表视图都是「本地数据先上屏 → 再发请求补新」,此时界面上已经有内容,
 
79
  SEARCH_COMPONENT_ID: "sidebar-search",
80
  // 上传相对路径的云端图片读取入口。
81
  IMAGE_PROXY_PATH: "/api/image_proxy",
82
+ // 本地广告预载要求真实图片,熔断占位图不能作为下载成功。
83
+ LOCAL_IMAGE_PROXY_PREFIX: "/community_hub/image?url=",
84
+ COMPLETE_IMAGE_QUERY_SUFFIX: "&require_complete=1",
85
  // 身份相关或私有 GET 禁止通用响应缓存;公开列表仍使用原缓存策略。
86
  PRIVATE_GET_PATTERNS: [
87
  /^\/api\/(wallet|messages|chats|admin|disputes|users)(?:\/|$)/,
 
767
  export const PROMPT_LIMITS = Object.freeze({ MAX_PRICE: 10000 });
768
 
769
  // 任务定价边界,与云端 task_publication/task_terms 对应。
770
+ export const TASK_LIMITS = Object.freeze({ MAX_PRICE: 2147483647, MAX_REFERENCE_IMAGES: 6, DEPOSIT_RATIOS: Object.freeze([10, 20, 30, 50]), TIP_AMOUNTS: Object.freeze([10, 50, 100, 500]) });
前端页面/core/网络请求_业务API.js CHANGED
@@ -59,13 +59,14 @@ const api = {
59
 
60
  async getUserProfile(account, options = {}) { return request(`/api/users/${account}`, options); },
61
  async updateUserProfile(account, data) { return request(`/api/users/${account}`, { method: "PUT", body: data }); },
 
62
  async updatePrivacy(account, privacy) { return request(`/api/users/${account}/privacy`, { method: "PUT", body: privacy }); },
63
  async toggleFollow(userId, targetAccount, isActive) { return request("/api/users/follow", { method: "POST", body: { user_id: userId, target_account: targetAccount, is_active: isActive } }); },
64
  async getCreators(sort, limit, options = {}) { return request(`/api/creators?sort=${sort}&limit=${limit}`, options); },
65
  // 🔍 带上取消分组 ID:用户继续输入时,上一次仍在途的搜索请求可被 requestCancelManager 撤销,
66
  // 不再占用并发额度与超时预算(调用时机见 侧边栏数据引擎.js 的创作者搜索分支)
67
  async searchCreators(keyword, sort, limit = 50, options = {}) { return request(`/api/creators/search?keyword=${encodeURIComponent(keyword)}&sort=${sort}&limit=${limit}`, { componentId: API.SEARCH_COMPONENT_ID, ...options }); },
68
- async getCreatorDetails(account) { return request(`/api/creators/${account}/details`); },
69
  async uploadFile(file, fileType, timeout = 120000) {
70
  const formData = new FormData();
71
  formData.append("file", file);
 
59
 
60
  async getUserProfile(account, options = {}) { return request(`/api/users/${account}`, options); },
61
  async updateUserProfile(account, data) { return request(`/api/users/${account}`, { method: "PUT", body: data }); },
62
+ async updateUserSettings(account, data) { return request(`/api/users/${account}/settings`, { method: "PUT", body: data }); },
63
  async updatePrivacy(account, privacy) { return request(`/api/users/${account}/privacy`, { method: "PUT", body: privacy }); },
64
  async toggleFollow(userId, targetAccount, isActive) { return request("/api/users/follow", { method: "POST", body: { user_id: userId, target_account: targetAccount, is_active: isActive } }); },
65
  async getCreators(sort, limit, options = {}) { return request(`/api/creators?sort=${sort}&limit=${limit}`, options); },
66
  // 🔍 带上取消分组 ID:用户继续输入时,上一次仍在途的搜索请求可被 requestCancelManager 撤销,
67
  // 不再占用并发额度与超时预算(调用时机见 侧边栏数据引擎.js 的创作者搜索分支)
68
  async searchCreators(keyword, sort, limit = 50, options = {}) { return request(`/api/creators/search?keyword=${encodeURIComponent(keyword)}&sort=${sort}&limit=${limit}`, { componentId: API.SEARCH_COMPONENT_ID, ...options }); },
69
+ async getCreatorDetails(account, options = {}) { return request(`/api/creators/${account}/details`, options); },
70
  async uploadFile(file, fileType, timeout = 120000) {
71
  const formData = new FormData();
72
  formData.append("file", file);
前端页面/core/网络请求_图片代理.js CHANGED
@@ -36,12 +36,26 @@ function _unwrapProxy(url, prefix) {
36
  let result = url;
37
  let guard = 0;
38
  while (result.startsWith(prefix) && guard++ < 10) {
39
- try { result = decodeURIComponent(result.replace(prefix, '')); }
 
 
 
 
 
 
40
  catch (e) { break; }
41
  }
42
  return result;
43
  }
44
 
 
 
 
 
 
 
 
 
45
  // 🚀 导出图片代理函数,供其他组件在缓存读取后调用
46
  export function proxyImages(obj) {
47
  if (!obj) return obj;
 
36
  let result = url;
37
  let guard = 0;
38
  while (result.startsWith(prefix) && guard++ < 10) {
39
+ try {
40
+ let encoded = result.slice(prefix.length);
41
+ if (prefix === API.LOCAL_IMAGE_PROXY_PREFIX && encoded.endsWith(API.COMPLETE_IMAGE_QUERY_SUFFIX)) {
42
+ encoded = encoded.slice(0, -API.COMPLETE_IMAGE_QUERY_SUFFIX.length);
43
+ }
44
+ result = decodeURIComponent(encoded);
45
+ }
46
  catch (e) { break; }
47
  }
48
  return result;
49
  }
50
 
51
+ /** 广告预载复用磁盘缓存;只有真实图片成功,才允许更新已展示的广告。 */
52
+ export function getCompleteImageUrl(url) {
53
+ if (!url || typeof url !== 'string') return url;
54
+ const original = _unwrapProxy(url, API.LOCAL_IMAGE_PROXY_PREFIX);
55
+ if (IS_WEB_MODE || !original.startsWith('http')) return original;
56
+ return API.LOCAL_IMAGE_PROXY_PREFIX + encodeURIComponent(original) + API.COMPLETE_IMAGE_QUERY_SUFFIX;
57
+ }
58
+
59
  // 🚀 导出图片代理函数,供其他组件在缓存读取后调用
60
  export function proxyImages(obj) {
61
  if (!obj) return obj;
前端页面/core/网络请求_缓存管理.js CHANGED
@@ -424,7 +424,10 @@ export function clearAllCache() {
424
  }
425
  }
426
  keysToRemove.forEach(k => localStorage.removeItem(k));
427
- } catch {}
 
 
 
428
  }
429
 
430
  // LRU 顺序更新
 
424
  }
425
  }
426
  keysToRemove.forEach(k => localStorage.removeItem(k));
427
+ return true;
428
+ } catch {
429
+ return false;
430
+ }
431
  }
432
 
433
  // LRU 顺序更新
前端页面/market/列表卡片组件.js CHANGED
@@ -365,8 +365,11 @@ export function createItemCard(itemData, currentUser = null, contextType = null)
365
  detailView.appendChild(refundArea);
366
 
367
  let isRendered = false;
 
368
  const toggleButton = summaryView.querySelector('.card-summary__toggle');
369
  const setExpanded = (expanded) => {
 
 
370
  if (!expanded && detailView.contains(document.activeElement)) toggleButton.focus();
371
  detailView.hidden = !expanded;
372
  detailView.style.display = expanded ? "block" : "none";
@@ -414,12 +417,14 @@ export function createItemCard(itemData, currentUser = null, contextType = null)
414
 
415
  // 🚀 核心修改:传入一个回调函数,当组件内部发送/删除留言时,外部的数字能实时响应刷新!
416
  // 🚀 P0安全修复:传递 itemData.author 作为 contentAuthor,使内容作者可以删除其内容下的评论
417
- const commentUI = createCommentSection(itemData.id, itemData.commentsData || [], currentUser, (newCount) => {
418
- const badge = summaryView.querySelector(".card-comment-count");
419
- if (badge) badge.innerText = `💬 ${newCount}`;
420
- }, itemData.author);
421
-
422
- commentsContainer.appendChild(commentUI);
 
 
423
  detailView.appendChild(commentsContainer);
424
 
425
  if (currentUser && currentUser.account === itemData.author) {
@@ -450,6 +455,7 @@ export function createItemCard(itemData, currentUser = null, contextType = null)
450
  // 🖼️ 启用图片懒加载(跳过 data URL 和已处理的)
451
  lazyLoadImages(card);
452
 
 
453
  card.appendChild(summaryView);
454
  card.appendChild(detailView);
455
  return card;
 
365
  detailView.appendChild(refundArea);
366
 
367
  let isRendered = false;
368
+ let commentUI = null;
369
  const toggleButton = summaryView.querySelector('.card-summary__toggle');
370
  const setExpanded = (expanded) => {
371
+ if (expanded) mountComments();
372
+ commentUI?._setActive?.(expanded);
373
  if (!expanded && detailView.contains(document.activeElement)) toggleButton.focus();
374
  detailView.hidden = !expanded;
375
  detailView.style.display = expanded ? "block" : "none";
 
417
 
418
  // 🚀 核心修改:传入一个回调函数,当组件内部发送/删除留言时,外部的数字能实时响应刷新!
419
  // 🚀 P0安全修复:传递 itemData.author 作为 contentAuthor,使内容作者可以删除其内容下的评论
420
+ function mountComments() {
421
+ if (commentUI) return;
422
+ commentUI = createCommentSection(itemData.id, itemData.commentsData || [], currentUser, (newCount) => {
423
+ const badge = summaryView.querySelector(".card-comment-count");
424
+ if (badge) badge.innerText = `💬 ${newCount}`;
425
+ }, itemData.author);
426
+ commentsContainer.appendChild(commentUI);
427
+ }
428
  detailView.appendChild(commentsContainer);
429
 
430
  if (currentUser && currentUser.account === itemData.author) {
 
455
  // 🖼️ 启用图片懒加载(跳过 data URL 和已处理的)
456
  lazyLoadImages(card);
457
 
458
+ card._cleanup = () => commentUI?._cleanup?.();
459
  card.appendChild(summaryView);
460
  card.appendChild(detailView);
461
  return card;
前端页面/market/创作者卡片组件.js CHANGED
@@ -11,36 +11,80 @@
11
  import { openOtherUserProfileModal } from "../profile/个人中心视图.js";
12
  import { createCommentSection } from "../social/评论与互动组件.js";
13
  import { renderTipLevelHTML, getTipLevelRuleShort } from "../components/打赏等级工具.js";
14
- import { getBannerCacheKey, PLACEHOLDERS, getCachedProfile, getProfileWithSWR, escapeHtml } from "../core/全局配置.js";
15
  import { api } from "../core/网络请求_业务API.js";
 
 
 
16
  import { getSettings } from "../components/全局设置组件.js";
17
  import { t } from "../components/用户体验增强.js";
18
  import { showToast } from "../components/UI交互提示组件.js";
19
  import { openTipModal } from "../profile/个人中心_赞赏组件.js";
20
 
21
- // ==========================================
22
- // 📌 模块级常量
23
- // ==========================================
24
-
25
- // 打赏按钮统一样式(通过 CSS 类控制 hover,避免内联事件)
26
- const _TIP_BUTTON_STYLE = 'padding: 4px 10px; background: linear-gradient(135deg, #FF9800, #F57C00); color: #fff; border: none; border-radius: 4px; font-size: 11px; cursor: pointer; transition: 0.2s;';
27
-
28
- // section 标签统一样式
29
- const _SECTION_LABEL_STYLE = 'font-size: 12px; font-weight: bold; margin-bottom: 8px; color: #aaa;';
30
-
31
- // ==========================================
32
- // 📌 模块级状态:当前展开的卡片
33
- // ==========================================
34
  let currentExpandedCard = null;
35
 
36
- // 注入打赏按钮 hover 样式(模块加载时执行一次)
37
- (function _injectTipBtnStyle() {
38
- if (document.getElementById('creator-tip-btn-style')) return;
39
- const style = document.createElement('style');
40
- style.id = 'creator-tip-btn-style';
41
- style.textContent = '.creator-tip-btn:hover { opacity: 0.8; }';
42
- document.head.appendChild(style);
43
- })();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
 
45
  /**
46
  * 处理打赏创作者按钮点击
@@ -49,18 +93,19 @@ let currentExpandedCard = null;
49
  * @param {Function} onTipSuccess - 打赏成功回调
50
  */
51
  async function handleTipCreator(creatorData, currentUser, onTipSuccess, parentCurrent) {
 
52
  // 检查用户是否登录
53
  if (!currentUser) {
54
  showToast(t('creator.tip_login_required'), "warning");
55
  return;
56
  }
57
-
58
  // 不能给自己打赏
59
  if (currentUser.account === creatorData.account) {
60
  showToast(t('creator.cannot_tip_self'), "warning");
61
  return;
62
  }
63
-
64
  // 打开打赏弹窗
65
  await openTipModal(currentUser, {
66
  account: creatorData.account,
@@ -71,15 +116,30 @@ async function handleTipCreator(creatorData, currentUser, onTipSuccess, parentCu
71
  }, null, { isCurrent: parentCurrent });
72
  }
73
 
 
74
  function loadECharts() {
75
- return new Promise((resolve, reject) => {
76
- if (window.echarts) { resolve(window.echarts); return; }
 
77
  const script = document.createElement("script");
78
- script.src = "https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js";
79
- script.onload = () => resolve(window.echarts);
80
- script.onerror = () => reject(new Error("ECharts load failed"));
 
 
 
 
 
 
 
 
 
 
 
 
81
  document.head.appendChild(script);
82
- });
 
83
  }
84
 
85
  /**
@@ -87,9 +147,9 @@ function loadECharts() {
87
  */
88
  function _renderTipBoardEntry(entry, idx, rankIcon, levelHtml, userHtml, containerId = '') {
89
  const idAttr = containerId ? ` id="${escapeHtml(containerId)}"` : '';
90
- return `<div${idAttr} style="display:flex;align-items:center;justify-content:space-between;padding:6px 8px;background:${idx % 2 === 0 ? '#222' : 'var(--comfy-menu-bg)'};border-radius:4px;margin-bottom:4px;">` +
91
- `<div style="display:flex;align-items:center;gap:8px;"><span style="font-size:14px;min-width:24px;">${rankIcon}</span>${userHtml}</div>` +
92
- `<div style="display:flex;align-items:center;gap:6px;">${levelHtml}<span style="color:#4CAF50;font-weight:bold;font-size:11px;">${entry.amount}</span></div></div>`;
93
  }
94
 
95
  /**
@@ -101,110 +161,110 @@ function _renderTipBoardEntry(entry, idx, rankIcon, levelHtml, userHtml, contain
101
  * @param {object} currentUser - 当前登录用户
102
  * @param {Function} onTipSuccess - 打赏成功回调
103
  */
104
- export function createTipBoardSection(tipBoard = [], title = "🎁 赞赏贡献榜", onUserClick = null, creatorData = null, currentUser = null, onTipSuccess = null) {
105
  const container = document.createElement("div");
106
- Object.assign(container.style, {
107
- background: "#1a1d2e", border: "1px solid #2d334a", borderRadius: "6px",
108
- padding: "10px", marginTop: "10px"
109
- });
110
 
111
  // 等级规则说明
112
  const levelRuleHtml = `
113
- <div style="font-size: 11px; color: #888; margin-bottom: 8px; padding: 4px 8px; background: rgba(255,255,255,0.03); border-radius: 4px;">
114
  📊 ${t('creator.tip_level_rule')}:${getTipLevelRuleShort()}
115
  </div>
116
  `;
117
 
118
  if (!tipBoard || tipBoard.length === 0) {
119
  container.innerHTML = `
120
- <div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px;">
121
- <div style="font-size: 12px; font-weight: bold; color: #FF9800; display: flex; align-items: center; gap: 6px;">
122
  ${escapeHtml(title)}
123
- <span style="font-size: 10px; color: #888; font-weight: normal;">(${t('creator.tip_board.count', {count: 0})})</span>
124
  </div>
125
- ${creatorData ? `<button id="btn-tip-creator-empty" class="creator-tip-btn" style="${_TIP_BUTTON_STYLE}">${t('creator.tip_this_creator')}</button>` : ''}
126
  </div>
127
  ${levelRuleHtml}
128
- <div style="color: #666; font-size: 12px; text-align: center; padding: 15px 0;">${t('creator.no_tips')}</div>
129
  `;
130
-
131
  // 绑定打赏按钮事件(空榜单时)
132
  if (creatorData) {
133
  const tipBtn = container.querySelector('#btn-tip-creator-empty');
134
  if (tipBtn) {
135
- tipBtn.onclick = async () => await handleTipCreator(creatorData, currentUser, onTipSuccess, () => container.isConnected);
136
  }
137
  }
138
-
139
  return container;
140
  }
141
 
142
  // 榜单前10名
143
  const top10 = tipBoard.slice(0, 10);
144
- const rankColors = ["#FFD700", "#C0C0C0", "#CD7F32"]; // 金银铜
145
 
146
  // 🚀 使用统一的打赏等级工具渲染星星/月亮/太阳
147
  let listHtml = top10.map((entry, idx) => {
148
- const rankColor = rankColors[idx] || "#888";
149
- const rankIcon = idx < 3 ? ["🥇", "🥈", "🥉"][idx] : `<span style="color:${rankColor}">#${idx + 1}</span>`;
150
-
151
  // 使用统一等级工具获取等级图标
152
  const levelHtml = renderTipLevelHTML(entry.amount, true);
153
-
154
  if (entry.is_anon) {
155
- const userHtml = `<span style="color: #888; font-style: italic; font-size: 13px;">${t('creator.anonymous')}</span>`;
156
  return _renderTipBoardEntry(entry, idx, rankIcon, levelHtml, userHtml);
157
  }
158
-
159
  // 🚀 SWR 模式:非匿名用户显示头像+用户名
160
  const containerId = `creator-tip-board-item-${entry.account}-${idx}-${Date.now()}`;
161
-
162
  // 从缓存获取初始数据(0延迟渲染)
163
  const cached = getCachedProfile(entry.account);
164
  const avatarUrl = cached?.avatar || cached?.avatarDataUrl || PLACEHOLDERS.AVATAR_SMALL;
165
  const userName = cached?.name || entry.account;
166
-
167
  // 后台静默校对并更新 DOM
168
  setTimeout(() => {
 
169
  getProfileWithSWR(entry.account, api.getUserProfile, (profile) => {
170
  const itemContainer = document.getElementById(containerId);
171
- if (!itemContainer) return;
172
-
173
  const avatarImg = itemContainer.querySelector('.tip-board-avatar');
174
  const nameSpan = itemContainer.querySelector('.tip-board-name');
175
-
176
- if (avatarImg && profile.avatar) {
177
  avatarImg.src = profile.avatar;
178
  }
179
- if (nameSpan && profile.name) {
180
  nameSpan.textContent = profile.name;
 
 
181
  }
182
  });
183
  }, 0);
184
-
185
  // 🔒 XSS防护:昵称/头像均来自用户可修改的资料,写入 HTML 前必须转义
186
  // (下方 SWR 刷新路径用的是 .src / .textContent,本身就是安全的)
187
- const userHtml = `<span class="tip-board-user" data-account="${escapeHtml(entry.account)}" data-anon="false" style="display: flex; align-items: center; gap: 6px; color: #4CAF50; cursor: pointer; font-size: 13px;"><img class="tip-board-avatar" src="${escapeHtml(avatarUrl)}" style="width: 20px; height: 20px; border-radius: 50%; object-fit: cover; flex-shrink: 0; background: var(--comfy-input-bg);"><span class="tip-board-name">${escapeHtml(userName)}</span></span>`;
 
188
  return _renderTipBoardEntry(entry, idx, rankIcon, levelHtml, userHtml, containerId);
189
  }).join("");
190
 
191
  container.innerHTML = `
192
- <div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px;">
193
- <div style="font-size: 12px; font-weight: bold; color: #FF9800; display: flex; align-items: center; gap: 6px;">
194
  ${escapeHtml(title)}
195
- <span style="font-size: 10px; color: #888; font-weight: normal;">(${t('creator.tip_board.count', {count: tipBoard.length})})</span>
196
  </div>
197
- ${creatorData ? `<button id="btn-tip-creator" class="creator-tip-btn" style="${_TIP_BUTTON_STYLE}">${t('creator.tip_this_creator')}</button>` : ''}
198
  </div>
199
  ${levelRuleHtml}
200
- <div style="max-height: 200px; overflow-y: auto;">${listHtml}</div>
201
  `;
202
 
203
  // 绑定打赏按钮事件
204
  if (creatorData) {
205
  const tipBtn = container.querySelector('#btn-tip-creator');
206
  if (tipBtn) {
207
- tipBtn.onclick = async () => await handleTipCreator(creatorData, currentUser, onTipSuccess, () => container.isConnected);
208
  }
209
  }
210
 
@@ -214,7 +274,7 @@ export function createTipBoardSection(tipBoard = [], title = "🎁 赞赏贡献
214
  if (el.dataset.anon === "false") {
215
  el.onclick = (e) => {
216
  e.stopPropagation();
217
- onUserClick(el.dataset.account);
218
  };
219
  }
220
  });
@@ -226,96 +286,125 @@ export function createTipBoardSection(tipBoard = [], title = "🎁 赞赏贡献
226
  export function createCreatorCard(creatorData, currentUser = null) {
227
  const card = document.createElement("div");
228
  card.setAttribute("data-item-id", creatorData.account);
229
- Object.assign(card.style, {
230
- backgroundColor: "var(--comfy-input-bg, #2b2b2b)", borderRadius: "8px",
231
- marginBottom: "12px", border: "1px solid #444", color: "#fff", fontFamily: "sans-serif",
232
- overflow: "hidden" // 🖼️ 背景图需要隐藏溢出
233
- });
234
-
235
  const chartContainerId = `creator-chart-${Math.random().toString(36).substr(2, 9)}`;
236
  const summaryView = document.createElement("div");
237
- summaryView.style.cursor = "pointer";
238
 
239
  const avatarSrc = creatorData.avatar || PLACEHOLDERS.AVATAR;
240
-
241
  // ⚙️ 读取设置:是否显示创作者背景图
242
  const settings = getSettings();
243
  const showBanner = settings.showCreatorBanner;
244
-
245
  // 🖼️ 个人资料卡背景图:优先使用本地缓存(与个人资料界面共用同一份缓存)
246
  const cacheKey = getBannerCacheKey(creatorData.account);
247
- const cachedBanner = localStorage.getItem(cacheKey);
248
- const bannerUrl = cachedBanner || creatorData.bannerUrl; // 优先用本地缓存
249
- const hasBanner = showBanner && bannerUrl && bannerUrl.trim() !== ''; // ⚙️ 受设置控制
250
-
 
 
251
  // 🖼️ 全覆盖背景图样式
252
  // 在使用 bannerUrl 之前添加安全过滤
253
  const safeBannerUrl = (bannerUrl || '').replace(/['"()\\]/g, '');
254
- const cardBgStyle = hasBanner
255
- ? `background-image: url(${safeBannerUrl}); background-size: cover; background-position: center;`
256
- : '';
257
-
 
258
  summaryView.innerHTML = `
259
- <div style="${cardBgStyle} position: relative; padding: 10px; border-radius: 8px;">
260
- ${hasBanner ? '<div style="position: absolute; inset: 0; background: rgba(0,0,0,0.2); border-radius: 8px;"></div>' : ''}
261
- <div style="position: relative; z-index: 1;">
262
- <div style="display: flex; align-items: center; gap: 15px; margin-bottom: 10px; padding: 5px 0;">
263
- <img class="creator-avatar-link" src="${escapeHtml(avatarSrc)}" title="${t('creator.visit_profile')}" style="width: 50px; height: 50px; border-radius: 50%; border: 2px solid ${hasBanner ? 'rgba(255,255,255,0.8)' : '#555'}; object-fit: cover; cursor: pointer; transition: 0.2s; ${hasBanner ? 'box-shadow: 0 2px 8px rgba(0,0,0,0.3);' : ''}" onmouseover="this.style.borderColor='#4CAF50'" onmouseout="this.style.borderColor='${hasBanner ? 'rgba(255,255,255,0.8)' : '#555'}'">
264
- <div class="creator-name-link" title="${t('creator.visit_profile')}" style="font-weight: bold; font-size: 16px; cursor: pointer; transition: 0.2s; color: #FFD700; text-shadow: 0 1px 4px rgba(0,0,0,0.8), 0 0 8px rgba(255,215,0,0.3);" onmouseover="this.style.color='#FFA500'" onmouseout="this.style.color='#FFD700'">${escapeHtml(creatorData.name)}</div>
265
- <div data-stat="downloads" style="font-size: 12px; color: #FF6B6B; margin-left: auto; text-shadow: 0 1px 3px rgba(0,0,0,0.6);">${t('creator.usage_count', {count: creatorData.downloads || 0})}</div>
266
- </div>
267
- <div style="font-size: 12px; color: ${hasBanner ? '#ccc' : '#aaa'}; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; margin-bottom: 10px;">${escapeHtml(creatorData.shortDesc && creatorData.shortDesc !== "null" ? creatorData.shortDesc : t('profile.no_intro'))}</div>
268
- <div style="background: rgba(34,34,34,${hasBanner ? '0.8' : '1'}); border-radius: 6px; padding: 8px 10px; border: 1px dashed #555;">
269
- <div style="display: flex; gap: 15px; font-size: 12px; color: #eee; justify-content: space-between; margin-bottom: 8px;">
270
- <span data-stat="likes" style="color: #FF5722;">👍 ${t('creator.stats.likes')}: <strong>${creatorData.likes}</strong></span>
271
- <span data-stat="favorites" style="color: #FFC107;">🔖 ${t('creator.stats.favorites')}: <strong>${creatorData.favorites}</strong></span>
272
- <span data-stat="followers" style="color: #4CAF50;">👥 ${t('creator.stats.followers')}: <strong>${creatorData.followers}</strong></span>
273
- </div>
274
- <div style="display: flex; gap: 15px; font-size: 12px; color: #ccc; justify-content: center; border-top: 1px solid var(--border-color, #333); padding-top: 8px;">
275
- <span data-stat="toolsCount" style="color: #2196F3;">🛠️ ${t('creator.output.tools')}: <strong>${creatorData.toolsCount}</strong> ${t('creator.output.unit')}</span>
276
- <span data-stat="skillsCount" style="color: #7C4DFF;">⚡ ${t('creator.output.skills')}: <strong>${creatorData.skillsCount || 0}</strong> ${t('creator.output.unit')}</span>
277
- <span data-stat="appsCount" style="color: #9C27B0;">📦 ${t('creator.output.apps')}: <strong>${creatorData.appsCount}</strong> ${t('creator.output.unit')}</span>
278
- </div>
279
- </div>
280
- </div>
281
  </div>
 
282
  `;
 
283
 
284
- const jumpToProfile = (e) => {
285
- e.stopPropagation();
286
- openOtherUserProfileModal(creatorData.account, currentUser);
287
  };
288
  summaryView.querySelector('.creator-avatar-link').onclick = jumpToProfile;
289
  summaryView.querySelector('.creator-name-link').onclick = jumpToProfile;
290
 
291
  const detailView = document.createElement("div");
292
- Object.assign(detailView.style, { display: "none", marginTop: "12px", paddingTop: "12px", borderTop: "1px dashed #555" });
293
-
294
- // 🔄 延迟渲染:初始只显示 loading 状态
295
- detailView.innerHTML = `
296
- <div id="detail-content-${chartContainerId}" style="display: none;">
297
- <div style="${_SECTION_LABEL_STYLE}">${t('creator.chart.download_trend')}</div>
298
- <div id="${chartContainerId}" style="width: 100%; height: 180px; background: #222; border-radius: 4px; margin-bottom: 15px;"></div>
299
- <div id="tipboard-${chartContainerId}"></div>
300
- <div style="${_SECTION_LABEL_STYLE} margin-bottom: 6px; margin-top: 15px;">${t('creator.message_board')}</div>
301
- <div id="board-${chartContainerId}" style="height: 250px; border: 1px solid #444; border-radius: 4px; overflow: hidden;"></div>
302
- </div>
303
- <div id="detail-loading-${chartContainerId}" style="text-align: center; padding: 40px 20px; color: #888;">
304
- <div style="font-size: 14px;">⏳ ${t('creator.loading_details')}</div>
305
- </div>
306
- <div id="detail-error-${chartContainerId}" style="display: none; text-align: center; padding: 40px 20px; color: #F44336;">
307
- <div style="font-size: 14px; margin-bottom: 12px;">❌ ${t('creator.load_failed')}</div>
308
- <button id="btn-retry-${chartContainerId}" style="padding: 6px 16px; background: #2196F3; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px;">${t('creator.retry')}</button>
309
- </div>
310
- `;
 
 
 
 
 
 
 
 
311
 
312
  // 状态标记
 
313
  let isDetailLoaded = false;
314
  let isChartRendered = false;
315
  let chartInstance = null;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
316
 
317
  // 🔄 加载详情数据
318
  async function loadCreatorDetails() {
 
 
319
  const loadingEl = detailView.querySelector(`#detail-loading-${chartContainerId}`);
320
  const contentEl = detailView.querySelector(`#detail-content-${chartContainerId}`);
321
  const errorEl = detailView.querySelector(`#detail-error-${chartContainerId}`);
@@ -333,9 +422,20 @@ export function createCreatorCard(creatorData, currentUser = null) {
333
  contentEl.style.display = 'none';
334
  errorEl.style.display = 'none';
335
 
 
 
336
  try {
337
- const response = await api.getCreatorDetails(creatorData.account);
338
- if (response.status === "success" && response.data) {
 
 
 
 
 
 
 
 
 
339
  // 缓存详情数据到 creatorData 对象
340
  creatorData._detailData = response.data;
341
  creatorData.trendData = response.data.trendData;
@@ -347,31 +447,36 @@ export function createCreatorCard(creatorData, currentUser = null) {
347
  contentEl.style.display = 'block';
348
  renderDetailContent(response.data);
349
  } else {
350
- throw new Error(response.detail || response.error || '加载失败');
351
  }
352
  } catch (err) {
353
- console.error('加载创作者详情失败:', err);
354
  loadingEl.style.display = 'none';
355
  errorEl.style.display = 'block';
356
-
357
  // 绑定重试按钮
358
  const retryBtn = errorEl.querySelector(`#btn-retry-${chartContainerId}`);
359
  if (retryBtn) {
360
  retryBtn.onclick = () => loadCreatorDetails();
361
  }
 
 
 
362
  }
363
  }
364
 
365
  // 🎨 渲染详情内容(图表、赞赏榜、留言板)
366
  function renderDetailContent(detailData) {
367
  const contentEl = detailView.querySelector(`#detail-content-${chartContainerId}`);
368
-
369
  // 🎁 添加赞赏贡献总榜
370
  const tipBoardContainer = contentEl.querySelector(`#tipboard-${chartContainerId}`);
371
  tipBoardContainer.innerHTML = ''; // 清空之前的内容
372
-
373
  // 打赏成功后的回调函数 - 刷新创作者数据
 
374
  const onTipSuccess = () => {
 
375
  // 触发重新加载创作者数据
376
  if (window.refreshCreatorData) {
377
  window.refreshCreatorData(creatorData.account);
@@ -380,41 +485,49 @@ export function createCreatorCard(creatorData, currentUser = null) {
380
  creatorData._detailData = null;
381
  isDetailLoaded = false;
382
  };
383
-
384
  const tipBoardUI = createTipBoardSection(
385
- detailData.tip_board,
386
  t('creator.tip_board.title'),
387
- (account) => openOtherUserProfileModal(account, currentUser),
388
  creatorData,
389
  currentUser,
390
- onTipSuccess
 
391
  );
392
  tipBoardContainer.appendChild(tipBoardUI);
393
 
394
  // 💬 添加留言板
395
  const boardContainer = contentEl.querySelector(`#board-${chartContainerId}`);
396
- boardContainer.innerHTML = ''; // 清空之前的内容
397
- // 🚀 P0安全修复:传递创作者账号作为 contentAuthor,使创作者可以删除其主页下的评论
398
- const commentUI = createCommentSection(creatorData.account, detailData.commentsData || [], currentUser, null, creatorData.account);
399
- boardContainer.appendChild(commentUI);
 
 
400
 
401
  // 📊 渲染趋势图表
402
  renderTrendChart(detailData.trendData);
403
  }
404
 
405
  // 📊 渲染趋势图表
406
- function _initTrendChart(chartDom, trendData) {
 
407
  loadECharts().then(echarts => {
408
- chartDom.innerHTML = "";
409
- chartInstance = echarts.init(chartDom, 'dark', { backgroundColor: 'transparent' });
410
-
 
 
 
 
411
  const data = trendData || { months: [], tools: [], skills: [], apps: [], recommends: [] };
412
 
413
  chartInstance.setOption({
414
  tooltip: { trigger: 'axis', textStyle: { fontSize: 11 } },
415
  grid: { top: 10, bottom: 20, left: 35, right: 10, containLabel: true },
416
- xAxis: { type: 'category', data: data.months, axisLabel: { color: '#888', fontSize: 10 }, axisLine: { lineStyle: { color: '#444' } } },
417
- yAxis: { type: 'value', splitLine: { lineStyle: { color: '#333', type: 'dashed' } }, axisLabel: { color: '#888', fontSize: 10 }, minInterval: 1 },
418
  series: [
419
  { name: t('creator.chart.series.tools'), type: 'line', data: data.tools, smooth: true, itemStyle: { color: '#4CAF50' }, lineStyle: { width: 2, type: 'dashed' } },
420
  { name: t('creator.chart.series.skills'), type: 'line', data: data.skills || [], smooth: true, itemStyle: { color: '#7C4DFF' }, lineStyle: { width: 2, type: 'dashed' } },
@@ -423,90 +536,115 @@ export function createCreatorCard(creatorData, currentUser = null) {
423
  ]
424
  });
425
  isChartRendered = true;
426
-
427
  // 初始化完成后延迟调用resize确保正确渲染
428
- setTimeout(() => {
429
- if (chartInstance) chartInstance.resize();
 
430
  }, 300);
431
- }).catch(err => {
432
- chartDom.innerHTML = `<div style="text-align:center; line-height:180px; color:#F44336;">${t('creator.chart.load_failed')}</div>`;
433
- });
 
 
434
  }
435
 
436
  function renderTrendChart(trendData) {
437
- if (isChartRendered) return;
 
 
 
 
438
  const chartDom = detailView.querySelector(`#${chartContainerId}`);
439
- if (!chartDom) return;
440
-
441
- chartDom.innerHTML = `<div style="text-align:center; line-height:180px; color:#888;">${t('creator.chart.loading')}</div>`;
442
-
443
  // 使用 ResizeObserver 等待容器获得有效宽度后再渲染
444
  if (chartDom.clientWidth === 0) {
445
- const observer = new ResizeObserver((entries) => {
 
446
  for (const entry of entries) {
447
  if (entry.contentRect.width > 0) {
448
- observer.disconnect();
449
- _initTrendChart(chartDom, trendData);
 
450
  return;
451
  }
452
  }
453
  });
454
- observer.observe(chartDom);
455
-
456
  // 安全超时:5秒后如果仍未获得宽度,放弃并清理
457
- setTimeout(() => {
458
- observer.disconnect();
 
459
  if (chartDom.clientWidth === 0) {
460
  chartDom.innerHTML = '';
461
  }
462
  }, 5000);
463
  return;
464
  }
465
-
466
- _initTrendChart(chartDom, trendData);
467
  }
468
 
469
- // 🔄 点击展开/收起
470
- summaryView.onclick = () => {
471
- const isHidden = detailView.style.display === "none";
472
-
473
- if (isHidden) {
474
- // 展开当前卡片
475
- // 1. 如果有其他卡片已展开,先关闭它
476
- if (currentExpandedCard && currentExpandedCard !== card) {
477
- const otherDetailView = currentExpandedCard.querySelector('[id^="detail-content-"]')?.parentElement;
478
- if (otherDetailView) {
479
- otherDetailView.style.display = "none";
480
- }
481
  }
482
-
483
- // 2. 展开当前卡片
484
- detailView.style.display = "block";
485
  currentExpandedCard = card;
486
-
487
- // 3. 加载详情数据
488
- loadCreatorDetails();
489
  } else {
490
- // 收起当前卡片
491
- detailView.style.display = "none";
492
- if (currentExpandedCard === card) {
493
- currentExpandedCard = null;
494
- }
495
  }
 
 
 
 
 
 
 
 
496
  };
497
-
498
- const resizeHandler = () => { if (chartInstance) chartInstance.resize(); };
499
- window.addEventListener('resize', resizeHandler);
500
-
501
- card._cleanup = () => {
502
- window.removeEventListener('resize', resizeHandler);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
503
  if (chartInstance) {
504
  chartInstance.dispose();
505
  chartInstance = null;
506
  }
507
- };
 
 
 
 
 
 
508
 
509
  card.appendChild(summaryView);
510
  card.appendChild(detailView);
511
  return card;
512
- }
 
11
  import { openOtherUserProfileModal } from "../profile/个人中心视图.js";
12
  import { createCommentSection } from "../social/评论与互动组件.js";
13
  import { renderTipLevelHTML, getTipLevelRuleShort } from "../components/打赏等级工具.js";
14
+ import { getBannerCacheKey, PLACEHOLDERS, getCachedProfile, getProfileWithSWR, escapeHtml, CACHE, API } from "../core/全局配置.js";
15
  import { api } from "../core/网络请求_业务API.js";
16
+ import { captureRequestSession, requestCancelManager } from "../core/网络请求_基础设施.js";
17
+ import { getUser, eventBus, EVENTS } from "../core/状态管理.js";
18
+ import { createTaskViewScope, subscribeTaskSessionChanges } from "../task/任务视图生命周期.js";
19
  import { getSettings } from "../components/全局设置组件.js";
20
  import { t } from "../components/用户体验增强.js";
21
  import { showToast } from "../components/UI交互提示组件.js";
22
  import { openTipModal } from "../profile/个人中心_赞赏组件.js";
23
 
24
+ // 当前展开卡片由原生展开按钮与鼠标摘要入口共同管理。
 
 
 
 
 
 
 
 
 
 
 
 
25
  let currentExpandedCard = null;
26
 
27
+ // 身份事件共用底层订阅;每张卡片仍独立捕获会话并决定是否撤销。
28
+ const creatorSessionSubscribers = new Set();
29
+ let stopCreatorSessionSubscription = null;
30
+ let creatorSessionDispatchDepth = 0;
31
+ function releaseCreatorSessionSubscription() {
32
+ if (creatorSessionSubscribers.size || creatorSessionDispatchDepth) return;
33
+ const stop = stopCreatorSessionSubscription;
34
+ stopCreatorSessionSubscription = null;
35
+ stop?.();
36
+ }
37
+ function subscribeCreatorSessionChanges(notify) {
38
+ const entry = { notify };
39
+ creatorSessionSubscribers.add(entry);
40
+ if (!stopCreatorSessionSubscription) {
41
+ try {
42
+ stopCreatorSessionSubscription = subscribeTaskSessionChanges(force => {
43
+ creatorSessionDispatchDepth++;
44
+ try {
45
+ // 本轮新建的卡片不接收旧事件,已清理的卡片不再被通知。
46
+ for (const current of [...creatorSessionSubscribers]) {
47
+ if (!creatorSessionSubscribers.has(current)) continue;
48
+ try { current.notify(force); }
49
+ catch (_) { console.warn('创作者会话通知失败,继续通知其它卡片。'); }
50
+ }
51
+ } finally {
52
+ creatorSessionDispatchDepth--;
53
+ releaseCreatorSessionSubscription();
54
+ }
55
+ }, { target: window, eventBus, events: EVENTS,
56
+ userKey: CACHE.LEGACY_KEYS.USER, tokenKey: CACHE.LEGACY_KEYS.TOKEN });
57
+ } catch (error) {
58
+ creatorSessionSubscribers.delete(entry);
59
+ throw error;
60
+ }
61
+ }
62
+ return () => {
63
+ creatorSessionSubscribers.delete(entry);
64
+ releaseCreatorSessionSubscription();
65
+ };
66
+ }
67
+
68
+ // 所有卡片共用一个移除观察器;不为未展开的卡片逐个扫描整页。
69
+ const mountedCreatorCards = new Map();
70
+ let creatorRemovalObserver = null;
71
+ function trackCreatorRemoval(card, dispose) {
72
+ mountedCreatorCards.set(card, dispose);
73
+ if (!creatorRemovalObserver) {
74
+ creatorRemovalObserver = new MutationObserver(() => {
75
+ for (const [node, cleanup] of mountedCreatorCards) {
76
+ if (!node.isConnected) cleanup();
77
+ }
78
+ });
79
+ creatorRemovalObserver.observe(document.body, { childList: true, subtree: true });
80
+ }
81
+ return () => {
82
+ mountedCreatorCards.delete(card);
83
+ if (!mountedCreatorCards.size) {
84
+ creatorRemovalObserver?.disconnect(); creatorRemovalObserver = null;
85
+ }
86
+ };
87
+ }
88
 
89
  /**
90
  * 处理打赏创作者按钮点击
 
93
  * @param {Function} onTipSuccess - 打赏成功回调
94
  */
95
  async function handleTipCreator(creatorData, currentUser, onTipSuccess, parentCurrent) {
96
+ if (parentCurrent && !parentCurrent()) return;
97
  // 检查用户是否登录
98
  if (!currentUser) {
99
  showToast(t('creator.tip_login_required'), "warning");
100
  return;
101
  }
102
+
103
  // 不能给自己打赏
104
  if (currentUser.account === creatorData.account) {
105
  showToast(t('creator.cannot_tip_self'), "warning");
106
  return;
107
  }
108
+
109
  // 打开打赏弹窗
110
  await openTipModal(currentUser, {
111
  account: creatorData.account,
 
116
  }, null, { isCurrent: parentCurrent });
117
  }
118
 
119
+ let echartsLoading = null, echartsLoadFailures = 0;
120
  function loadECharts() {
121
+ if (typeof window.echarts?.init === 'function') return Promise.resolve(window.echarts);
122
+ if (echartsLoading) return echartsLoading;
123
+ echartsLoading = new Promise((resolve, reject) => {
124
  const script = document.createElement("script");
125
+ let settled = false;
126
+ const finish = (error) => {
127
+ if (settled) return;
128
+ settled = true; clearTimeout(timer);
129
+ script.onload = script.onerror = null; script.remove();
130
+ if (error) { echartsLoadFailures++; reject(new Error('ECharts load failed')); }
131
+ else { echartsLoadFailures = 0; resolve(window.echarts); }
132
+ };
133
+ const timer = setTimeout(() => finish(true), API.PROFILE_RESOURCE_TIMEOUT);
134
+ const scriptURL = new URL(API.CREATOR_CHART_SCRIPT_URL);
135
+ // 移除script不保证终止原下载;失败后的读取不能复用未结束的旧URL。
136
+ if (echartsLoadFailures) scriptURL.searchParams.set('_ranking_retry', `${Date.now()}-${echartsLoadFailures}`);
137
+ script.src = scriptURL.href;
138
+ script.onload = () => finish(typeof window.echarts?.init !== 'function');
139
+ script.onerror = () => finish(true);
140
  document.head.appendChild(script);
141
+ }).finally(() => { echartsLoading = null; });
142
+ return echartsLoading;
143
  }
144
 
145
  /**
 
147
  */
148
  function _renderTipBoardEntry(entry, idx, rankIcon, levelHtml, userHtml, containerId = '') {
149
  const idAttr = containerId ? ` id="${escapeHtml(containerId)}"` : '';
150
+ return `<div${idAttr} class="creator-tip-entry">` +
151
+ `<div class="creator-tip-entry__identity"><span class="creator-tip-entry__rank" aria-hidden="true">${rankIcon}</span>${userHtml}</div>` +
152
+ `<div class="creator-tip-entry__value">${levelHtml}<span class="creator-tip-entry__amount">${escapeHtml(entry.amount)}</span></div></div>`;
153
  }
154
 
155
  /**
 
161
  * @param {object} currentUser - 当前登录用户
162
  * @param {Function} onTipSuccess - 打赏成功回调
163
  */
164
+ export function createTipBoardSection(tipBoard = [], title = t('creator.tip_board.title'), onUserClick = null, creatorData = null, currentUser = null, onTipSuccess = null, parentCurrent = null) {
165
  const container = document.createElement("div");
166
+ const current = () => container.isConnected && (!parentCurrent || parentCurrent());
167
+ container.className = 'creator-tip-board';
 
 
168
 
169
  // 等级规则说明
170
  const levelRuleHtml = `
171
+ <div class="creator-tip-board__rule">
172
  📊 ${t('creator.tip_level_rule')}:${getTipLevelRuleShort()}
173
  </div>
174
  `;
175
 
176
  if (!tipBoard || tipBoard.length === 0) {
177
  container.innerHTML = `
178
+ <div class="creator-tip-board__header">
179
+ <div class="creator-tip-board__title">
180
  ${escapeHtml(title)}
181
+ <span class="creator-tip-board__count">(${t('creator.tip_board.count', {count: 0})})</span>
182
  </div>
183
+ ${creatorData ? `<button id="btn-tip-creator-empty" type="button" class="creator-tip-btn">${t('creator.tip_this_creator')}</button>` : ''}
184
  </div>
185
  ${levelRuleHtml}
186
+ <div class="creator-tip-board__empty">${t('creator.no_tips')}</div>
187
  `;
188
+
189
  // 绑定打赏按钮事件(空榜单时)
190
  if (creatorData) {
191
  const tipBtn = container.querySelector('#btn-tip-creator-empty');
192
  if (tipBtn) {
193
+ tipBtn.onclick = async () => await handleTipCreator(creatorData, currentUser, onTipSuccess, current);
194
  }
195
  }
196
+
197
  return container;
198
  }
199
 
200
  // 榜单前10名
201
  const top10 = tipBoard.slice(0, 10);
 
202
 
203
  // 🚀 使用统一的打赏等级工具渲染星星/月亮/太阳
204
  let listHtml = top10.map((entry, idx) => {
205
+ const rankIcon = idx < 3 ? ["🥇", "🥈", "🥉"][idx] : `#${idx + 1}`;
206
+
 
207
  // 使用统一等级工具获取等级图标
208
  const levelHtml = renderTipLevelHTML(entry.amount, true);
209
+
210
  if (entry.is_anon) {
211
+ const userHtml = `<span class="creator-tip-entry__anonymous">${t('creator.anonymous')}</span>`;
212
  return _renderTipBoardEntry(entry, idx, rankIcon, levelHtml, userHtml);
213
  }
214
+
215
  // 🚀 SWR 模式:非匿名用户显示头像+用户名
216
  const containerId = `creator-tip-board-item-${entry.account}-${idx}-${Date.now()}`;
217
+
218
  // 从缓存获取初始数据(0延迟渲染)
219
  const cached = getCachedProfile(entry.account);
220
  const avatarUrl = cached?.avatar || cached?.avatarDataUrl || PLACEHOLDERS.AVATAR_SMALL;
221
  const userName = cached?.name || entry.account;
222
+
223
  // 后台静默校对并更新 DOM
224
  setTimeout(() => {
225
+ if (!current()) return;
226
  getProfileWithSWR(entry.account, api.getUserProfile, (profile) => {
227
  const itemContainer = document.getElementById(containerId);
228
+ if (!current() || !itemContainer || !profile || typeof profile !== 'object' || Array.isArray(profile) || (profile.account != null && profile.account !== entry.account)) return;
229
+
230
  const avatarImg = itemContainer.querySelector('.tip-board-avatar');
231
  const nameSpan = itemContainer.querySelector('.tip-board-name');
232
+
233
+ if (avatarImg && typeof profile.avatar === 'string' && profile.avatar) {
234
  avatarImg.src = profile.avatar;
235
  }
236
+ if (nameSpan && typeof profile.name === 'string' && profile.name) {
237
  nameSpan.textContent = profile.name;
238
+ const userControl = itemContainer.querySelector('.tip-board-user');
239
+ if (userControl) userControl.setAttribute('aria-label', `${t('creator.visit_profile')}: ${profile.name}`);
240
  }
241
  });
242
  }, 0);
243
+
244
  // 🔒 XSS防护:昵称/头像均来自用户可修改的资料,写入 HTML 前必须转义
245
  // (下方 SWR 刷新路径用的是 .src / .textContent,本身就是安全的)
246
+ const userTag = onUserClick ? 'button' : 'span';
247
+ const userHtml = `<${userTag} ${onUserClick ? 'type="button"' : ''} class="tip-board-user" data-account="${escapeHtml(entry.account)}" data-anon="false" aria-label="${escapeHtml(`${t('creator.visit_profile')}: ${userName}`)}"><img class="tip-board-avatar" src="${escapeHtml(avatarUrl)}" alt=""><span class="tip-board-name">${escapeHtml(userName)}</span></${userTag}>`;
248
  return _renderTipBoardEntry(entry, idx, rankIcon, levelHtml, userHtml, containerId);
249
  }).join("");
250
 
251
  container.innerHTML = `
252
+ <div class="creator-tip-board__header">
253
+ <div class="creator-tip-board__title">
254
  ${escapeHtml(title)}
255
+ <span class="creator-tip-board__count">(${t('creator.tip_board.count', {count: tipBoard.length})})</span>
256
  </div>
257
+ ${creatorData ? `<button id="btn-tip-creator" type="button" class="creator-tip-btn">${t('creator.tip_this_creator')}</button>` : ''}
258
  </div>
259
  ${levelRuleHtml}
260
+ <div class="creator-tip-board__list">${listHtml}</div>
261
  `;
262
 
263
  // 绑定打赏按钮事件
264
  if (creatorData) {
265
  const tipBtn = container.querySelector('#btn-tip-creator');
266
  if (tipBtn) {
267
+ tipBtn.onclick = async () => await handleTipCreator(creatorData, currentUser, onTipSuccess, current);
268
  }
269
  }
270
 
 
274
  if (el.dataset.anon === "false") {
275
  el.onclick = (e) => {
276
  e.stopPropagation();
277
+ if (current()) onUserClick(el.dataset.account);
278
  };
279
  }
280
  });
 
286
  export function createCreatorCard(creatorData, currentUser = null) {
287
  const card = document.createElement("div");
288
  card.setAttribute("data-item-id", creatorData.account);
289
+ card.className = 'creator-card';
 
 
 
 
 
290
  const chartContainerId = `creator-chart-${Math.random().toString(36).substr(2, 9)}`;
291
  const summaryView = document.createElement("div");
292
+ summaryView.className = 'creator-card__summary';
293
 
294
  const avatarSrc = creatorData.avatar || PLACEHOLDERS.AVATAR;
295
+
296
  // ⚙️ 读取设置:是否显示创作者背景图
297
  const settings = getSettings();
298
  const showBanner = settings.showCreatorBanner;
299
+
300
  // 🖼️ 个人资料卡背景图:优先使用本地缓存(与个人资料界面共用同一份缓存)
301
  const cacheKey = getBannerCacheKey(creatorData.account);
302
+ let cachedBanner = null;
303
+ try { cachedBanner = localStorage.getItem(cacheKey); } catch (_) { /* 可选装饰缓存不可用时继续构建卡片。 */ }
304
+ const validBanner = value => typeof value === 'string' && value.trim() ? value : '';
305
+ const bannerUrl = validBanner(cachedBanner) || validBanner(creatorData.bannerUrl);
306
+ const hasBanner = !!(showBanner && bannerUrl); // 有效本地缓存优先,受显示设置控制
307
+
308
  // 🖼️ 全覆盖背景图样式
309
  // 在使用 bannerUrl 之前添加安全过滤
310
  const safeBannerUrl = (bannerUrl || '').replace(/['"()\\]/g, '');
311
+ summaryView.classList.toggle('creator-card__summary--banner', !!hasBanner);
312
+ if (hasBanner) summaryView.style.backgroundImage = `url("${safeBannerUrl}")`;
313
+ const profileName = typeof creatorData.name === 'string' && creatorData.name.trim() ? creatorData.name : creatorData.account;
314
+ const profileLabel = `${t('creator.visit_profile')}: ${profileName}`;
315
+ const stat = (field, label, value, unit = '') => `<div class="creator-card__stat" data-stat="${field}"><span>${escapeHtml(label)}</span><strong class="creator-card__stat-value"><span class="${/^\d+$/.test(String(value)) ? 'creator-card__number' : ''}">${escapeHtml(value)}</span>${unit ? ` <small>${escapeHtml(unit)}</small>` : ''}</strong></div>`;
316
  summaryView.innerHTML = `
317
+ <div class="creator-card__identity">
318
+ <button type="button" class="creator-avatar-link" aria-label="${escapeHtml(profileLabel)}"><img src="${escapeHtml(avatarSrc)}" alt=""></button>
319
+ <button type="button" class="creator-name-link" aria-label="${escapeHtml(profileLabel)}">${escapeHtml(creatorData.name)}</button>
320
+ </div>
321
+ <div class="creator-card__usage" data-stat="downloads">${escapeHtml(t('creator.usage_count', {count: creatorData.downloads || 0}))}</div>
322
+ <p class="creator-card__intro">${escapeHtml(creatorData.shortDesc && creatorData.shortDesc !== "null" ? creatorData.shortDesc : t('profile.no_intro'))}</p>
323
+ <div class="creator-card__statistics">
324
+ <div class="creator-card__stats">${stat('likes',t('creator.stats.likes'),creatorData.likes)}${stat('favorites',t('creator.stats.favorites'),creatorData.favorites)}${stat('followers',t('creator.stats.followers'),creatorData.followers)}</div>
325
+ <div class="creator-card__outputs">${stat('toolsCount',t('creator.output.tools'),creatorData.toolsCount,t('creator.output.unit'))}${stat('skillsCount',t('creator.output.skills'),creatorData.skillsCount || 0,t('creator.output.unit'))}${stat('appsCount',t('creator.output.apps'),creatorData.appsCount,t('creator.output.unit'))}</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
326
  </div>
327
+ <button type="button" class="creator-card__toggle" aria-expanded="false" aria-controls="detail-${chartContainerId}">${escapeHtml(t('creator.expand_details'))}<span aria-hidden="true">⌄</span></button>
328
  `;
329
+ const toggleButton = summaryView.querySelector('.creator-card__toggle');
330
 
331
+ const jumpToProfile = (e) => {
332
+ e.stopPropagation();
333
+ if (scope.isCurrent() && card.isConnected) openOtherUserProfileModal(creatorData.account, currentUser);
334
  };
335
  summaryView.querySelector('.creator-avatar-link').onclick = jumpToProfile;
336
  summaryView.querySelector('.creator-name-link').onclick = jumpToProfile;
337
 
338
  const detailView = document.createElement("div");
339
+ detailView.className = "creator-card__detail";
340
+ detailView.id = `detail-${chartContainerId}`;
341
+ detailView.hidden = true;
342
+ detailView.style.display = "none";
343
+
344
+ // 首次展开才挂载详情骨架;折叠保留节点和原评论实例。
345
+ let detailMounted = false;
346
+ function mountDetails() {
347
+ if (detailMounted) return;
348
+ detailView.innerHTML = `
349
+ <div id="detail-content-${chartContainerId}" style="display: none;">
350
+ <div class="creator-card__section-label">${t('creator.chart.download_trend')}</div>
351
+ <div id="${chartContainerId}" class="creator-card__chart"></div>
352
+ <div id="tipboard-${chartContainerId}"></div>
353
+ <div class="creator-card__section-label creator-card__section-label--comments">${t('creator.message_board')}</div>
354
+ <div id="board-${chartContainerId}" class="creator-card__comments"></div>
355
+ </div>
356
+ <div id="detail-loading-${chartContainerId}" class="creator-card__status" role="status">
357
+ <div class="creator-card__status-copy">⏳ ${t('creator.loading_details')}</div>
358
+ </div>
359
+ <div id="detail-error-${chartContainerId}" class="creator-card__status creator-card__status--error" role="alert" style="display: none;">
360
+ <div class="creator-card__status-copy">❌ ${t('creator.load_failed')}</div>
361
+ <button id="btn-retry-${chartContainerId}" type="button" class="creator-card__retry">${t('creator.retry')}</button>
362
+ </div>
363
+ `;
364
+ detailMounted = true;
365
+ }
366
 
367
  // 状态标记
368
+ let commentUI = null;
369
  let isDetailLoaded = false;
370
  let isChartRendered = false;
371
  let chartInstance = null;
372
+ let generation = 0, detailPending = false, chartPending = false, detailRead = null;
373
+ let widthObserver = null, widthTimer = null, resizeTimer = null;
374
+ let stopTrackingRemoval = null, resizeSubscribed = false;
375
+ let constructing = true;
376
+ const matches = captureRequestSession();
377
+ const account = currentUser?.account || null;
378
+ const sessionMatches = () => matches() && (getUser()?.account || null) === account;
379
+ const componentId = chartContainerId;
380
+ const scope = createTaskViewScope({
381
+ sessionMatches, connected: () => constructing || card.isConnected,
382
+ subscribe: subscribeCreatorSessionChanges,
383
+ cancelReads: () => requestCancelManager.cancelAll(componentId),
384
+ invalidate: () => {
385
+ detailView.replaceChildren();
386
+ const notice = document.createElement('div');
387
+ notice.setAttribute('role', 'status'); notice.textContent = t('auth.session_changed');
388
+ detailView.append(notice); detailView.hidden = false; detailView.style.display = 'block';
389
+ toggleButton.disabled = true; toggleButton.setAttribute('aria-expanded', 'true');
390
+ }
391
+ });
392
+ const current = token => scope.isCurrent() && card.isConnected && detailView.style.display !== 'none' && token === generation;
393
+ const clearChartWork = () => {
394
+ widthObserver?.disconnect(); widthObserver = null;
395
+ clearTimeout(widthTimer); clearTimeout(resizeTimer); widthTimer = resizeTimer = null;
396
+ chartPending = false;
397
+ };
398
+ const pauseDetails = () => {
399
+ generation++; detailPending = false;
400
+ requestCancelManager.cancelAll(componentId);
401
+ clearChartWork(); commentUI?._setActive?.(false);
402
+ };
403
 
404
  // 🔄 加载详情数据
405
  async function loadCreatorDetails() {
406
+ const token = generation;
407
+ if (!current(token) || detailPending) return;
408
  const loadingEl = detailView.querySelector(`#detail-loading-${chartContainerId}`);
409
  const contentEl = detailView.querySelector(`#detail-content-${chartContainerId}`);
410
  const errorEl = detailView.querySelector(`#detail-error-${chartContainerId}`);
 
422
  contentEl.style.display = 'none';
423
  errorEl.style.display = 'none';
424
 
425
+ detailPending = true;
426
+ let ownedRead = null;
427
  try {
428
+ // 等待已取消的旧读取完成退出,避免网络层去重复用旧Promise。
429
+ if (detailRead) { try { await detailRead; } catch (_) {} }
430
+ if (!current(token)) return;
431
+ ownedRead = api.getCreatorDetails(creatorData.account, { componentId });
432
+ detailRead = ownedRead;
433
+ const response = await ownedRead;
434
+ if (!current(token)) return;
435
+ if (response?.status === "success" && response.data && typeof response.data === 'object' && !Array.isArray(response.data)
436
+ && response.data.account === creatorData.account
437
+ && Array.isArray(response.data.commentsData) && Array.isArray(response.data.tip_board)
438
+ && response.data.trendData && typeof response.data.trendData === 'object' && !Array.isArray(response.data.trendData)) {
439
  // 缓存详情数据到 creatorData 对象
440
  creatorData._detailData = response.data;
441
  creatorData.trendData = response.data.trendData;
 
447
  contentEl.style.display = 'block';
448
  renderDetailContent(response.data);
449
  } else {
450
+ throw new Error('Invalid creator details');
451
  }
452
  } catch (err) {
453
+ if (!current(token)) return;
454
  loadingEl.style.display = 'none';
455
  errorEl.style.display = 'block';
456
+
457
  // 绑定重试按钮
458
  const retryBtn = errorEl.querySelector(`#btn-retry-${chartContainerId}`);
459
  if (retryBtn) {
460
  retryBtn.onclick = () => loadCreatorDetails();
461
  }
462
+ } finally {
463
+ if (ownedRead && detailRead === ownedRead) detailRead = null;
464
+ if (current(token)) detailPending = false;
465
  }
466
  }
467
 
468
  // 🎨 渲染详情内容(图表、赞赏榜、留言板)
469
  function renderDetailContent(detailData) {
470
  const contentEl = detailView.querySelector(`#detail-content-${chartContainerId}`);
471
+
472
  // 🎁 添加赞赏贡献总榜
473
  const tipBoardContainer = contentEl.querySelector(`#tipboard-${chartContainerId}`);
474
  tipBoardContainer.innerHTML = ''; // 清空之前的内容
475
+
476
  // 打赏成功后的回调函数 - 刷新创作者数据
477
+ const tipGeneration = generation;
478
  const onTipSuccess = () => {
479
+ if (!current(tipGeneration)) return;
480
  // 触发重新加载创作者数据
481
  if (window.refreshCreatorData) {
482
  window.refreshCreatorData(creatorData.account);
 
485
  creatorData._detailData = null;
486
  isDetailLoaded = false;
487
  };
488
+
489
  const tipBoardUI = createTipBoardSection(
490
+ detailData.tip_board,
491
  t('creator.tip_board.title'),
492
+ (account) => { if (current(tipGeneration)) openOtherUserProfileModal(account, currentUser); },
493
  creatorData,
494
  currentUser,
495
+ onTipSuccess,
496
+ () => current(tipGeneration)
497
  );
498
  tipBoardContainer.appendChild(tipBoardUI);
499
 
500
  // 💬 添加留言板
501
  const boardContainer = contentEl.querySelector(`#board-${chartContainerId}`);
502
+ if (!commentUI) {
503
+ boardContainer.innerHTML = '';
504
+ commentUI = createCommentSection(creatorData.account, detailData.commentsData || [], currentUser, null, creatorData.account);
505
+ boardContainer.appendChild(commentUI);
506
+ }
507
+ commentUI._setActive?.(detailView.style.display !== "none");
508
 
509
  // 📊 渲染趋势图表
510
  renderTrendChart(detailData.trendData);
511
  }
512
 
513
  // 📊 渲染趋势图表
514
+ function _initTrendChart(chartDom, trendData, token) {
515
+ if (!current(token)) return;
516
  loadECharts().then(echarts => {
517
+ if (!current(token) || !chartDom.isConnected) return;
518
+ chartDom.innerHTML = "";
519
+ const theme = getComputedStyle(card);
520
+ const textColor = theme.getPropertyValue('--rk-text').trim() || theme.color;
521
+ const borderColor = theme.getPropertyValue('--rk-border').trim() || theme.borderColor;
522
+ chartInstance = echarts.init(chartDom, null, { backgroundColor: 'transparent' });
523
+
524
  const data = trendData || { months: [], tools: [], skills: [], apps: [], recommends: [] };
525
 
526
  chartInstance.setOption({
527
  tooltip: { trigger: 'axis', textStyle: { fontSize: 11 } },
528
  grid: { top: 10, bottom: 20, left: 35, right: 10, containLabel: true },
529
+ xAxis: { type: 'category', data: data.months, axisLabel: { color: textColor, fontSize: 10 }, axisLine: { lineStyle: { color: borderColor } } },
530
+ yAxis: { type: 'value', splitLine: { lineStyle: { color: borderColor, type: 'dashed' } }, axisLabel: { color: textColor, fontSize: 10 }, minInterval: 1 },
531
  series: [
532
  { name: t('creator.chart.series.tools'), type: 'line', data: data.tools, smooth: true, itemStyle: { color: '#4CAF50' }, lineStyle: { width: 2, type: 'dashed' } },
533
  { name: t('creator.chart.series.skills'), type: 'line', data: data.skills || [], smooth: true, itemStyle: { color: '#7C4DFF' }, lineStyle: { width: 2, type: 'dashed' } },
 
536
  ]
537
  });
538
  isChartRendered = true;
539
+
540
  // 初始化完成后延迟调用resize确保正确渲染
541
+ resizeTimer = setTimeout(() => {
542
+ resizeTimer = null;
543
+ if (current(token) && chartInstance) chartInstance.resize();
544
  }, 300);
545
+ }).catch(() => {
546
+ if (!current(token)) return;
547
+ chartInstance?.dispose(); chartInstance = null; isChartRendered = false;
548
+ chartDom.innerHTML = `<div class="creator-card__chart-status creator-card__chart-status--error" role="status">${t('creator.chart.load_failed')}</div>`;
549
+ }).finally(() => { if (current(token)) chartPending = false; });
550
  }
551
 
552
  function renderTrendChart(trendData) {
553
+ const token = generation;
554
+ if (!current(token)) return;
555
+ if (isChartRendered) { chartInstance?.resize(); return; }
556
+ if (chartPending) return;
557
+ chartPending = true;
558
  const chartDom = detailView.querySelector(`#${chartContainerId}`);
559
+ if (!chartDom) { chartPending = false; return; }
560
+
561
+ chartDom.innerHTML = `<div class="creator-card__chart-status" role="status">${t('creator.chart.loading')}</div>`;
562
+
563
  // 使用 ResizeObserver 等待容器获得有效宽度后再渲染
564
  if (chartDom.clientWidth === 0) {
565
+ widthObserver = new ResizeObserver((entries) => {
566
+ if (!current(token)) return;
567
  for (const entry of entries) {
568
  if (entry.contentRect.width > 0) {
569
+ widthObserver?.disconnect(); widthObserver = null;
570
+ clearTimeout(widthTimer); widthTimer = null;
571
+ _initTrendChart(chartDom, trendData, token);
572
  return;
573
  }
574
  }
575
  });
576
+ widthObserver.observe(chartDom);
577
+
578
  // 安全超时:5秒后如果仍未获得宽度,放弃并清理
579
+ widthTimer = setTimeout(() => {
580
+ if (!current(token)) return;
581
+ widthObserver?.disconnect(); widthObserver = null; widthTimer = null; chartPending = false;
582
  if (chartDom.clientWidth === 0) {
583
  chartDom.innerHTML = '';
584
  }
585
  }, 5000);
586
  return;
587
  }
588
+
589
+ _initTrendChart(chartDom, trendData, token);
590
  }
591
 
592
+ function setExpanded(expanded) {
593
+ if (!scope.isCurrent()) return;
594
+ if (expanded) {
595
+ mountDetails();
596
+ if (!resizeSubscribed) {
597
+ window.addEventListener('resize', resizeHandler); resizeSubscribed = true;
 
 
 
 
 
 
598
  }
599
+ if (currentExpandedCard && currentExpandedCard !== card) currentExpandedCard._closeDetails?.();
600
+ detailView.hidden = false; detailView.style.display = "block";
 
601
  currentExpandedCard = card;
 
 
 
602
  } else {
603
+ pauseDetails(); detailView.hidden = true; detailView.style.display = "none";
604
+ if (currentExpandedCard === card) currentExpandedCard = null;
 
 
 
605
  }
606
+ toggleButton.setAttribute('aria-expanded', String(expanded));
607
+ toggleButton.firstChild.textContent = t(expanded ? 'creator.collapse_details' : 'creator.expand_details');
608
+ if (expanded) loadCreatorDetails();
609
+ }
610
+ const toggleDetails = () => setExpanded(detailView.hidden);
611
+ summaryView.onclick = event => {
612
+ if (event.target.closest('button') && !event.target.closest('.creator-card__toggle')) return;
613
+ toggleDetails();
614
  };
615
+ card._closeDetails = () => setExpanded(false);
616
+ const keyHandler = event => {
617
+ if (event.key !== 'Escape' || detailView.hidden || !card.contains(document.activeElement)) return;
618
+ event.preventDefault(); event.stopPropagation();
619
+ setExpanded(false); toggleButton.focus();
620
+ };
621
+ card.addEventListener('keydown', keyHandler);
622
+
623
+ const resizeHandler = () => { if (current(generation) && chartInstance) chartInstance.resize(); };
624
+
625
+ card._pauseComments = () => commentUI?._setActive?.(false);
626
+ card._pauseDetails = pauseDetails;
627
+ scope.addCleanup(() => {
628
+ pauseDetails(); stopTrackingRemoval?.(); stopTrackingRemoval = null;
629
+ summaryView.onclick = null; card.removeEventListener('keydown', keyHandler);
630
+ toggleButton.disabled = true;
631
+ commentUI?._cleanup?.();
632
+ if (currentExpandedCard === card) currentExpandedCard = null;
633
+ if (resizeSubscribed) window.removeEventListener('resize', resizeHandler);
634
+ resizeSubscribed = false;
635
  if (chartInstance) {
636
  chartInstance.dispose();
637
  chartInstance = null;
638
  }
639
+ });
640
+ card._cleanup = () => scope.dispose();
641
+ queueMicrotask(() => {
642
+ constructing = false;
643
+ if (!scope.isCurrent()) return;
644
+ stopTrackingRemoval = trackCreatorRemoval(card, () => scope.dispose());
645
+ });
646
 
647
  card.appendChild(summaryView);
648
  card.appendChild(detailView);
649
  return card;
650
+ }
前端页面/market/资源安装引擎.js CHANGED
@@ -92,6 +92,25 @@ function _getInstallErrorMessage(result) {
92
  return errorKey ? t(errorKey) : t('install.error_generic');
93
  }
94
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  const INSTALL_STAGE_KEYS = {
96
  queued: 'install.stage_queued',
97
  validate: 'install.stage_validate',
@@ -607,7 +626,7 @@ export function setupResourceInstall(btnUse, itemData, currentUser, inlineStatus
607
  );
608
 
609
  if (!isCurrent()) return;
610
- if (result.cancelRequested) {
611
  phase = "unknown"; btnUse.removeAttribute("aria-busy");
612
  const message = t('install.cancel_outcome_uncertain');
613
  progress.error(message);
@@ -615,7 +634,16 @@ export function setupResourceInstall(btnUse, itemData, currentUser, inlineStatus
615
  return;
616
  }
617
 
618
- if (result.status === "success") {
 
 
 
 
 
 
 
 
 
619
  progress.complete(t('install.complete'));
620
  // 根据后端返回的 message 判断是否为自更新场景
621
  const isSelfUpdate = result.message && result.message.includes("重启") && result.message.includes("生效");
@@ -671,9 +699,17 @@ export function setupResourceInstall(btnUse, itemData, currentUser, inlineStatus
671
  if (!isCurrent()) return;
672
  const data = await res.json();
673
  if (!isCurrent()) return;
674
- if (!res.ok || data?.status !== "success") {
675
- const isLinkError = typeof data?.error === "string" && data.error.includes('不是有效的 Git');
676
- const safeMessage = isLinkError ? t('install.error_invalid_repository') : t('install.error_generic');
 
 
 
 
 
 
 
 
677
  inlineStatusBox.innerHTML = isLinkError
678
  ? `<span class="resource-install__message resource-install__message--error">❌ ${escapeHtml(safeMessage)}</span><br><button id="btn-open-source-link" type="button" class="resource-install__button resource-install__source">🔗 ${escapeHtml(t('install.open_source'))}</button>`
679
  : `<span class="resource-install__message resource-install__message--error">❌ ${escapeHtml(t('install.failed_for_item', { title: itemData.title, message: safeMessage }))}</span>`;
@@ -718,7 +754,7 @@ export function setupResourceInstall(btnUse, itemData, currentUser, inlineStatus
718
  } catch(err2) {
719
  if (!isCurrent()) return;
720
  phase = "unknown"; btnUse.removeAttribute("aria-busy");
721
- inlineStatusBox.innerHTML = `<span class="resource-install__message resource-install__message--error">❌ ${escapeHtml(t('workflow.local_connection_failed', { message: t('common.network_error_retry') }))}</span>`;
722
  }
723
  }
724
  } catch (_) {
 
92
  return errorKey ? t(errorKey) : t('install.error_generic');
93
  }
94
 
95
+ /** 流式与旧式安装共用终态确认;旧式响应另核查HTTP状态。 */
96
+ function _getInstallOutcome(data, response = null) {
97
+ if (!data || typeof data !== "object" || Array.isArray(data)) return "unknown";
98
+ if (Object.hasOwn(data, "outcomeUnknown") && data.outcomeUnknown !== false) return "unknown";
99
+ if (data.message !== undefined && typeof data.message !== "string") return "unknown";
100
+ if (data.status === "success") {
101
+ return (!response || response.ok) && !Object.hasOwn(data, "error") && !Object.hasOwn(data, "error_code")
102
+ ? "success" : "unknown";
103
+ }
104
+ if ((!response && data.status !== "error") || (data.status !== undefined && data.status !== "error")) return "unknown";
105
+ if (data.error_code !== undefined && (typeof data.error_code !== "string" || !data.error_code.trim())) return "unknown";
106
+ if (data.error !== undefined && (typeof data.error !== "string" || !data.error.trim())) return "unknown";
107
+ if (data.status === "error" || typeof data.error_code === "string") return "error";
108
+ // 无错误码的旧响应仅接受明确客户端拒绝或成功HTTP中的旧式error字段。
109
+ // 服务器/网关5xx本身不能证明磁盘发布没有发生。
110
+ if (typeof data.error === "string" && response && (response.ok || (response.status >= 400 && response.status < 500))) return "error";
111
+ return "unknown";
112
+ }
113
+
114
  const INSTALL_STAGE_KEYS = {
115
  queued: 'install.stage_queued',
116
  validate: 'install.stage_validate',
 
626
  );
627
 
628
  if (!isCurrent()) return;
629
+ if (result?.cancelRequested) {
630
  phase = "unknown"; btnUse.removeAttribute("aria-busy");
631
  const message = t('install.cancel_outcome_uncertain');
632
  progress.error(message);
 
634
  return;
635
  }
636
 
637
+ const outcome = _getInstallOutcome(result);
638
+ if (outcome === "unknown") {
639
+ phase = "unknown"; btnUse.removeAttribute("aria-busy");
640
+ const message = t('install.connection_uncertain');
641
+ progress.error(message);
642
+ progress.showDiagnostics(diagnosticEvents);
643
+ showToast(message, "warning");
644
+ return;
645
+ }
646
+ if (outcome === "success") {
647
  progress.complete(t('install.complete'));
648
  // 根据后端返回的 message 判断是否为自更新场景
649
  const isSelfUpdate = result.message && result.message.includes("重启") && result.message.includes("生效");
 
699
  if (!isCurrent()) return;
700
  const data = await res.json();
701
  if (!isCurrent()) return;
702
+ const outcome = _getInstallOutcome(data, res);
703
+ if (outcome === "unknown") {
704
+ phase = "unknown"; btnUse.removeAttribute("aria-busy");
705
+ inlineStatusBox.textContent = t('install.connection_uncertain');
706
+ showToast(t('install.connection_uncertain'), "warning");
707
+ return;
708
+ }
709
+ if (outcome === "error") {
710
+ const isLinkError = data?.error_code === 'invalid_repository'
711
+ || (typeof data?.error === "string" && data.error.includes('不是有效的 Git'));
712
+ const safeMessage = isLinkError ? t('install.error_invalid_repository') : _getInstallErrorMessage(data);
713
  inlineStatusBox.innerHTML = isLinkError
714
  ? `<span class="resource-install__message resource-install__message--error">❌ ${escapeHtml(safeMessage)}</span><br><button id="btn-open-source-link" type="button" class="resource-install__button resource-install__source">🔗 ${escapeHtml(t('install.open_source'))}</button>`
715
  : `<span class="resource-install__message resource-install__message--error">❌ ${escapeHtml(t('install.failed_for_item', { title: itemData.title, message: safeMessage }))}</span>`;
 
754
  } catch(err2) {
755
  if (!isCurrent()) return;
756
  phase = "unknown"; btnUse.removeAttribute("aria-busy");
757
+ inlineStatusBox.innerHTML = `<span class="resource-install__message resource-install__message--error">❌ ${escapeHtml(t('install.connection_uncertain'))}</span>`;
758
  }
759
  }
760
  } catch (_) {
前端页面/profile/个人设置表单组件.js CHANGED
@@ -4,7 +4,7 @@ import { regionData, getSortedCountries } from "../auth/国家地区数据.js";
4
  import { showToast } from "../components/UI交互提示组件.js";
5
  import { uploadFile } from "../market/发布内容_提交引擎.js";
6
  import { openImageCropper } from "../components/图片裁剪组件.js";
7
- import { CACHE, getBackgroundKey, getBannerCacheKey, PLACEHOLDERS, escapeHtml } from "../core/全局配置.js";
8
  import { t } from "../components/用户体验增强.js";
9
  import { normalizeProfileData, mergeStoredProfile } from "./个人资料工具.js";
10
 
@@ -30,8 +30,8 @@ function calculateAge(birthDate) {
30
  }
31
 
32
  // 图像上传处理通用函数:绑定触发按钮 → 文件选择 → 裁剪 → 回调
33
- function _createImageUploadHandler(inputEl, triggerEl, cropRatio, cropTitle, qualityLevel, onCropped, current) {
34
- let busy = false;
35
  triggerEl.onclick = () => { if (current() && !busy) inputEl.click(); };
36
  inputEl.onchange = async (e) => {
37
  if (!current() || busy) return;
@@ -39,25 +39,47 @@ function _createImageUploadHandler(inputEl, triggerEl, cropRatio, cropTitle, qua
39
  if (!file) return;
40
  inputEl.value = "";
41
  busy = true;
 
 
 
42
  try {
43
- const croppedFile = await openImageCropper(file, cropRatio, cropTitle, qualityLevel);
44
- if (!current() || !croppedFile) return;
45
- await onCropped(croppedFile);
46
  } catch (_) {
47
- if (current()) showToast(t('settings_form.avatar_process_failed'), 'error');
48
- } finally { busy = false; }
49
  };
 
50
  }
51
 
52
  // FileReader读取图片并预览到目标元素,可选保存到localStorage
53
- function _readAndPreviewImage(file, previewElement, cacheKey, current, readers) {
 
54
  return new Promise((resolve) => {
55
  const reader = new FileReader();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  readers.add(reader);
57
- const finish = value => { readers.delete(reader); resolve(value); };
58
  reader.onload = (ev) => {
59
- if (!current() || !previewElement.isConnected) { finish(null); return; }
 
60
  const base64 = ev.target.result;
 
61
  if (cacheKey) {
62
  try {
63
  localStorage.setItem(cacheKey, base64);
@@ -84,12 +106,12 @@ function _readAndPreviewImage(file, previewElement, cacheKey, current, readers)
84
  previewElement.style.backgroundPosition = "center";
85
  finish(base64);
86
  };
87
- reader.onerror = () => {
88
- if (current()) showToast(t('settings_form.file_read_failed'), "error");
89
- finish(null);
90
- };
91
- reader.onabort = () => finish(null);
92
- reader.readAsDataURL(file);
93
  });
94
  }
95
 
@@ -99,12 +121,14 @@ export function createSettingsForm(initialUserData, onCancelCallback, onSaveSucc
99
  container.className = 'ranking-settings-form';
100
  const sessionMatches = captureRequestSession();
101
  let disposed = false, saving = false;
102
- const readers = new Set();
103
  const current = () => !disposed && container.isConnected && sessionMatches() && (options.isCurrent?.() ?? true);
104
  container._cleanup = () => {
105
  if (disposed) return;
106
  disposed = true;
107
- for (const reader of readers) reader.abort();
 
 
108
  readers.clear();
109
  container.querySelectorAll('input, textarea').forEach(input => { input.value = ''; });
110
  pendingBannerFile = pendingBannerDataUrl = null;
@@ -392,27 +416,32 @@ export function createSettingsForm(initialUserData, onCancelCallback, onSaveSucc
392
  } finally {
393
  if (current()) { btnTrigger.innerText = originalText; btnTrigger.disabled = false; }
394
  }
395
- }, current
396
  );
397
 
 
 
 
 
 
398
  // 个人资料卡背景图上传逻辑(16:9 裁剪)
399
  // 🚀 优化:裁剪后只预览,点击保存时才上传到云端
400
- _createImageUploadHandler(
401
  settingBannerInput, container.querySelector("#btn-trigger-banner"),
402
  16/9, t('settings_form.crop_banner'), 3,
403
- async (croppedFile) => {
404
- if (!current()) return;
405
- pendingBannerFile = croppedFile;
406
- const dataUrl = await _readAndPreviewImage(croppedFile, bannerPreview, null, current, readers);
407
- if (!current()) return;
408
  if (dataUrl) {
 
409
  pendingBannerDataUrl = dataUrl;
410
  showToast(t('settings_form.banner_selected'), "info");
411
  }
412
- }, current
413
  );
414
  container.querySelector("#btn-clear-banner").onclick = () => {
415
- if (!current()) return;
416
  // 🔧 清除必须传空串而非 null:云端 PUT /api/users/{account} 的更新循环只写非 None 值
417
  // (见 router_users_profile.py「遍历请求中的字段,只更新非空值」),传 null 等同于
418
  // 「本次没改这个字段」而被跳过,云端旧图残留 —— 表现为点清除并保存后提示成功,
@@ -420,6 +449,7 @@ export function createSettingsForm(initialUserData, onCancelCallback, onSaveSucc
420
  // 且下游 hasBanner / bannerImageUrl 等判断都按 falsy 正确处理。
421
  // 注意上方 `let bannerUrl = userData.bannerUrl || null` 的初值仍保持 null:那是「本次未涉及该字段」
422
  // 的语义,可防止 userData 恰好缺 bannerUrl 时把云端图误清掉。
 
423
  bannerUrl = "";
424
  pendingBannerFile = null;
425
  pendingBannerDataUrl = null;
@@ -433,20 +463,21 @@ export function createSettingsForm(initialUserData, onCancelCallback, onSaveSucc
433
  };
434
 
435
  // 界面背景上传逻辑(9:16 裁剪,仅本地)
436
- _createImageUploadHandler(
437
  settingUiBgInput, container.querySelector("#btn-trigger-ui-bg"),
438
  9/16, t('settings_form.crop_ui_bg'), 3,
439
- async (croppedFile) => {
440
- const dataUrl = await _readAndPreviewImage(croppedFile, uiBgPreview, getBackgroundKey(userData.account), current, readers);
441
- if (!current()) return;
442
  if (dataUrl) {
443
  window.dispatchEvent(new CustomEvent("comfy-sidebar-bg-update"));
444
  showToast(t('settings_form.ui_bg_applied'), "success");
445
  }
446
- }, current
447
  );
448
  container.querySelector("#btn-clear-ui-bg").onclick = () => {
449
- if (!current()) return;
 
450
  // 使用账号区分键清除
451
  if (!_safeLocalStorageRemove(getBackgroundKey(userData.account))) {
452
  showToast(t('settings_form.local_storage_unavailable'), "error");
@@ -462,57 +493,20 @@ export function createSettingsForm(initialUserData, onCancelCallback, onSaveSucc
462
  container.querySelector("#btn-cancel-settings").onclick = () => { if (current() && onCancelCallback) onCancelCallback(); };
463
 
464
  container.querySelector("#btn-save-settings").onclick = async (e) => {
465
- if (!current() || saving) return;
466
  saving = true;
467
  const btn = e.currentTarget;
468
  btn.innerHTML = `⏳ ${escapeHtml(t('settings_form.saving'))}`;
469
- btn.disabled = true;
 
 
 
 
 
 
 
470
 
471
  try {
472
- // 🚀 优化:如果有待上传的背景图,先上传
473
- if (pendingBannerFile) {
474
- btn.innerHTML = `⏳ ${escapeHtml(t('settings_form.uploading_banner'))}`;
475
- try {
476
- const cloudImageUrl = await uploadFile(pendingBannerFile, "banner");
477
- if (!current()) return;
478
- if (cloudImageUrl) {
479
- bannerUrl = cloudImageUrl;
480
- // 保存到本地缓存
481
- if (pendingBannerDataUrl) {
482
- const bannerCacheKey = getBannerCacheKey(userData.account);
483
- try {
484
- localStorage.setItem(bannerCacheKey, pendingBannerDataUrl);
485
- } catch (storageErr) {
486
- // 配额溢出:清理其他用户的旧 banner 缓存后重试
487
- if (storageErr.name === "QuotaExceededError") {
488
- try {
489
- for (let i = localStorage.length - 1; i >= 0; i--) {
490
- const key = localStorage.key(i);
491
- if (key && key.startsWith("ComfyRanking_ProfileBannerCache_") && key !== bannerCacheKey) {
492
- localStorage.removeItem(key);
493
- }
494
- }
495
- localStorage.setItem(bannerCacheKey, pendingBannerDataUrl);
496
- } catch (_) {
497
- // 仍然失败则放弃本地缓存(不影响云端保存)
498
- }
499
- }
500
- }
501
- }
502
- }
503
- pendingBannerFile = null;
504
- pendingBannerDataUrl = null;
505
- } catch (uploadErr) {
506
- if (!current()) return;
507
- saving = false;
508
- showToast(t('settings_form.banner_upload_failed'), "error");
509
- btn.innerHTML = escapeHtml(t('settings_form.save_all'));
510
- btn.disabled = false;
511
- return;
512
- }
513
- }
514
-
515
- btn.innerHTML = `⏳ ${escapeHtml(t('settings_form.saving_settings'))}`;
516
  const birthday = birthdayInput.value || null;
517
  const updateData = {
518
  name: container.querySelector("#setting-name").value.trim(),
@@ -525,21 +519,68 @@ export function createSettingsForm(initialUserData, onCancelCallback, onSaveSucc
525
  avatarDataUrl: avatarDataUrl,
526
  bannerUrl: bannerUrl
527
  };
528
-
529
- const res = await api.updateUserProfile(userData.account, updateData);
530
- if (!current()) return;
531
- if (res?.status !== 'success' || !res.data || typeof res.data !== 'object' || Array.isArray(res.data) || res.data.account !== userData.account) throw new Error('Invalid settings response');
532
-
533
- // 收集隐私设置并发送到后端
534
  const privacySettings = {
535
  follows: container.querySelector("#hide-follows").checked,
536
  likes: container.querySelector("#hide-likes").checked,
537
  favorites: container.querySelector("#hide-favorites").checked,
538
  downloads: container.querySelector("#hide-downloads").checked
539
  };
540
- const privacyResponse = await api.updatePrivacy(userData.account, privacySettings);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
541
  if (!current()) return;
542
- if (privacyResponse?.status !== 'success') throw new Error('Invalid privacy response');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
543
 
544
  const newUserData = normalizeProfileData({ ...userData, ...res.data, privacy: privacySettings }, userData.account, true);
545
 
@@ -558,7 +599,7 @@ export function createSettingsForm(initialUserData, onCancelCallback, onSaveSucc
558
  if (onSaveSuccessCallback) onSaveSuccessCallback(newUserData);
559
  } catch (err) {
560
  if (!current()) return;
561
- saving = false;
562
  showToast(t('settings_form.settings_save_failed'), "error");
563
  btn.innerHTML = escapeHtml(t('settings_form.save_all'));
564
  btn.disabled = false;
 
4
  import { showToast } from "../components/UI交互提示组件.js";
5
  import { uploadFile } from "../market/发布内容_提交引擎.js";
6
  import { openImageCropper } from "../components/图片裁剪组件.js";
7
+ import { CACHE, IMAGE_PROCESS, getBackgroundKey, getBannerCacheKey, PLACEHOLDERS, escapeHtml } from "../core/全局配置.js";
8
  import { t } from "../components/用户体验增强.js";
9
  import { normalizeProfileData, mergeStoredProfile } from "./个人资料工具.js";
10
 
 
30
  }
31
 
32
  // 图像上传处理通用函数:绑定触发按钮 → 文件选择 → 裁剪 → 回调
33
+ function _createImageUploadHandler(inputEl, triggerEl, cropRatio, cropTitle, qualityLevel, onCropped, current, cropControllers, refreshSave) {
34
+ let busy = false, generation = 0, activeController = null;
35
  triggerEl.onclick = () => { if (current() && !busy) inputEl.click(); };
36
  inputEl.onchange = async (e) => {
37
  if (!current() || busy) return;
 
39
  if (!file) return;
40
  inputEl.value = "";
41
  busy = true;
42
+ const token = ++generation;
43
+ const operationCurrent = () => current() && token === generation;
44
+ const controller = new AbortController(); activeController = controller; cropControllers.add(controller); refreshSave();
45
  try {
46
+ const croppedFile = await openImageCropper(file, cropRatio, cropTitle, qualityLevel, { signal: controller.signal });
47
+ if (!operationCurrent() || !croppedFile) return;
48
+ await onCropped(croppedFile, operationCurrent, controller.signal);
49
  } catch (_) {
50
+ if (operationCurrent()) showToast(t('settings_form.image_process_failed'), 'error');
51
+ } finally { cropControllers.delete(controller); if (activeController === controller) activeController = null; busy = false; refreshSave(); }
52
  };
53
+ return () => { generation++; try { activeController?.abort(); } catch (_) {} };
54
  }
55
 
56
  // FileReader读取图片并预览到目标元素,可选保存到localStorage
57
+ function _readAndPreviewImage(file, previewElement, cacheKey, current, readers, signal) {
58
+ if (!current() || signal?.aborted) return Promise.resolve(null);
59
  return new Promise((resolve) => {
60
  const reader = new FileReader();
61
+ let settled = false, timer = null;
62
+ const finish = value => {
63
+ if (settled) return;
64
+ settled = true;
65
+ clearTimeout(timer); signal?.removeEventListener('abort', cancel);
66
+ reader.onload = reader.onerror = reader.onabort = null;
67
+ readers.delete(reader);
68
+ if (reader.readyState === FileReader.LOADING) { try { reader.abort(); } catch (_) {} }
69
+ resolve(value);
70
+ };
71
+ const cancel = () => finish(null);
72
+ const fail = () => {
73
+ if (settled) return;
74
+ if (current() && !signal?.aborted) showToast(t('settings_form.file_read_failed'), "error");
75
+ finish(null);
76
+ };
77
  readers.add(reader);
 
78
  reader.onload = (ev) => {
79
+ if (settled) return;
80
+ if (!current() || signal?.aborted || !previewElement.isConnected) { finish(null); return; }
81
  const base64 = ev.target.result;
82
+ if (typeof base64 !== 'string' || !base64.startsWith('data:image/') || !base64.includes(',')) { fail(); return; }
83
  if (cacheKey) {
84
  try {
85
  localStorage.setItem(cacheKey, base64);
 
106
  previewElement.style.backgroundPosition = "center";
107
  finish(base64);
108
  };
109
+ reader.onerror = fail;
110
+ reader.onabort = cancel;
111
+ signal?.addEventListener('abort', cancel, { once: true });
112
+ if (signal?.aborted) { cancel(); return; }
113
+ timer = setTimeout(fail, IMAGE_PROCESS.TIMEOUT);
114
+ try { reader.readAsDataURL(file); } catch (_) { fail(); }
115
  });
116
  }
117
 
 
121
  container.className = 'ranking-settings-form';
122
  const sessionMatches = captureRequestSession();
123
  let disposed = false, saving = false;
124
+ const readers = new Set(), cropControllers = new Set();
125
  const current = () => !disposed && container.isConnected && sessionMatches() && (options.isCurrent?.() ?? true);
126
  container._cleanup = () => {
127
  if (disposed) return;
128
  disposed = true;
129
+ for (const controller of cropControllers) { try { controller.abort(); } catch (_) {} }
130
+ cropControllers.clear();
131
+ for (const reader of readers) { try { reader.abort(); } catch (_) {} }
132
  readers.clear();
133
  container.querySelectorAll('input, textarea').forEach(input => { input.value = ''; });
134
  pendingBannerFile = pendingBannerDataUrl = null;
 
416
  } finally {
417
  if (current()) { btnTrigger.innerText = originalText; btnTrigger.disabled = false; }
418
  }
419
+ }, () => current() && !saving, cropControllers, refreshSaveAvailability
420
  );
421
 
422
+ function refreshSaveAvailability() {
423
+ const saveButton = container.querySelector('#btn-save-settings');
424
+ if (saveButton) saveButton.disabled = saving || cropControllers.size > 0;
425
+ }
426
+
427
  // 个人资料卡背景图上传逻辑(16:9 裁剪)
428
  // 🚀 优化:裁剪后只预览,点击保存时才上传到云端
429
+ const cancelBannerSelection = _createImageUploadHandler(
430
  settingBannerInput, container.querySelector("#btn-trigger-banner"),
431
  16/9, t('settings_form.crop_banner'), 3,
432
+ async (croppedFile, operationCurrent, signal) => {
433
+ if (!operationCurrent()) return;
434
+ const dataUrl = await _readAndPreviewImage(croppedFile, bannerPreview, null, operationCurrent, readers, signal);
435
+ if (!operationCurrent()) return;
 
436
  if (dataUrl) {
437
+ pendingBannerFile = croppedFile;
438
  pendingBannerDataUrl = dataUrl;
439
  showToast(t('settings_form.banner_selected'), "info");
440
  }
441
+ }, () => current() && !saving, cropControllers, refreshSaveAvailability
442
  );
443
  container.querySelector("#btn-clear-banner").onclick = () => {
444
+ if (!current() || saving) return;
445
  // 🔧 清除必须传空串而非 null:云端 PUT /api/users/{account} 的更新循环只写非 None 值
446
  // (见 router_users_profile.py「遍历请求中的字段,只更新非空值」),传 null 等同于
447
  // 「本次没改这个字段」而被跳过,云端旧图残留 —— 表现为点清除并保存后提示成功,
 
449
  // 且下游 hasBanner / bannerImageUrl 等判断都按 falsy 正确处理。
450
  // 注意上方 `let bannerUrl = userData.bannerUrl || null` 的初值仍保持 null:那是「本次未涉及该字段」
451
  // 的语义,可防止 userData 恰好缺 bannerUrl 时把云端图误清掉。
452
+ cancelBannerSelection();
453
  bannerUrl = "";
454
  pendingBannerFile = null;
455
  pendingBannerDataUrl = null;
 
463
  };
464
 
465
  // 界面背景上传逻辑(9:16 裁剪,仅本地)
466
+ const cancelUiBgSelection = _createImageUploadHandler(
467
  settingUiBgInput, container.querySelector("#btn-trigger-ui-bg"),
468
  9/16, t('settings_form.crop_ui_bg'), 3,
469
+ async (croppedFile, operationCurrent, signal) => {
470
+ const dataUrl = await _readAndPreviewImage(croppedFile, uiBgPreview, getBackgroundKey(userData.account), operationCurrent, readers, signal);
471
+ if (!operationCurrent()) return;
472
  if (dataUrl) {
473
  window.dispatchEvent(new CustomEvent("comfy-sidebar-bg-update"));
474
  showToast(t('settings_form.ui_bg_applied'), "success");
475
  }
476
+ }, () => current() && !saving, cropControllers, refreshSaveAvailability
477
  );
478
  container.querySelector("#btn-clear-ui-bg").onclick = () => {
479
+ if (!current() || saving) return;
480
+ cancelUiBgSelection();
481
  // 使用账号区分键清除
482
  if (!_safeLocalStorageRemove(getBackgroundKey(userData.account))) {
483
  showToast(t('settings_form.local_storage_unavailable'), "error");
 
493
  container.querySelector("#btn-cancel-settings").onclick = () => { if (current() && onCancelCallback) onCancelCallback(); };
494
 
495
  container.querySelector("#btn-save-settings").onclick = async (e) => {
496
+ if (!current() || saving || cropControllers.size > 0) return;
497
  saving = true;
498
  const btn = e.currentTarget;
499
  btn.innerHTML = `⏳ ${escapeHtml(t('settings_form.saving'))}`;
500
+ const lockedControls = Array.from(container.querySelectorAll('input, select, textarea, button'))
501
+ .filter(control => control.id !== 'btn-cancel-settings')
502
+ .map(control => [control, control.disabled]);
503
+ for (const [control] of lockedControls) control.disabled = true;
504
+ const unlock = () => {
505
+ for (const [control, disabled] of lockedControls) control.disabled = disabled;
506
+ saving = false; refreshSaveAvailability();
507
+ };
508
 
509
  try {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
510
  const birthday = birthdayInput.value || null;
511
  const updateData = {
512
  name: container.querySelector("#setting-name").value.trim(),
 
519
  avatarDataUrl: avatarDataUrl,
520
  bannerUrl: bannerUrl
521
  };
 
 
 
 
 
 
522
  const privacySettings = {
523
  follows: container.querySelector("#hide-follows").checked,
524
  likes: container.querySelector("#hide-likes").checked,
525
  favorites: container.querySelector("#hide-favorites").checked,
526
  downloads: container.querySelector("#hide-downloads").checked
527
  };
528
+
529
+ const bannerDataToCache = pendingBannerDataUrl;
530
+ // 🚀 优化:如果有待上传的背景图,先上传
531
+ if (pendingBannerFile) {
532
+ btn.innerHTML = `⏳ ${escapeHtml(t('settings_form.uploading_banner'))}`;
533
+ try {
534
+ const cloudImageUrl = await uploadFile(pendingBannerFile, "banner", { isCurrent: current, silent: true });
535
+ if (!current()) return;
536
+ if (typeof cloudImageUrl !== "string" || !cloudImageUrl.trim()) throw new Error("Invalid banner upload");
537
+ bannerUrl = cloudImageUrl;
538
+ updateData.bannerUrl = cloudImageUrl;
539
+ pendingBannerFile = null;
540
+ } catch (uploadErr) {
541
+ if (!current()) return;
542
+ unlock();
543
+ showToast(t('settings_form.banner_upload_failed'), "error");
544
+ btn.innerHTML = escapeHtml(t('settings_form.save_all'));
545
+ btn.disabled = false;
546
+ return;
547
+ }
548
+ }
549
+
550
+ btn.innerHTML = `⏳ ${escapeHtml(t('settings_form.saving_settings'))}`;
551
+
552
+
553
+ const res = await api.updateUserSettings(userData.account, { ...updateData, privacy: privacySettings });
554
  if (!current()) return;
555
+ if (res?.status !== 'success' || !res.data || typeof res.data !== 'object' || Array.isArray(res.data) || res.data.account !== userData.account) throw new Error('Invalid settings response');
556
+
557
+ if (!res.data.privacy || Object.keys(privacySettings).some(key => res.data.privacy[key] !== privacySettings[key])) {
558
+ throw new Error('Invalid settings privacy confirmation');
559
+ }
560
+
561
+ // 保存到本地缓存
562
+ if (bannerDataToCache) {
563
+ const bannerCacheKey = getBannerCacheKey(userData.account);
564
+ try {
565
+ localStorage.setItem(bannerCacheKey, bannerDataToCache);
566
+ } catch (storageErr) {
567
+ // 配额溢出:清理其他用户的旧 banner 缓存后重试
568
+ if (storageErr.name === "QuotaExceededError") {
569
+ try {
570
+ for (let i = localStorage.length - 1; i >= 0; i--) {
571
+ const key = localStorage.key(i);
572
+ if (key && key.startsWith("ComfyRanking_ProfileBannerCache_") && key !== bannerCacheKey) {
573
+ localStorage.removeItem(key);
574
+ }
575
+ }
576
+ localStorage.setItem(bannerCacheKey, bannerDataToCache);
577
+ } catch (_) {
578
+ // 仍然失败则放弃本地缓存(不影响云端保存)
579
+ }
580
+ }
581
+ }
582
+ }
583
+ pendingBannerDataUrl = null;
584
 
585
  const newUserData = normalizeProfileData({ ...userData, ...res.data, privacy: privacySettings }, userData.account, true);
586
 
 
599
  if (onSaveSuccessCallback) onSaveSuccessCallback(newUserData);
600
  } catch (err) {
601
  if (!current()) return;
602
+ unlock();
603
  showToast(t('settings_form.settings_save_failed'), "error");
604
  btn.innerHTML = escapeHtml(t('settings_form.save_all'));
605
  btn.disabled = false;
前端页面/social/评论与互动组件.js CHANGED
@@ -6,31 +6,70 @@ import { showToast, showConfirm } from "../components/UI交互提示组件.js";
6
  // 🧹 P2归一:escapeHtml 局部副本已移除,改用统一版
7
  import { escapeHtml } from "../components/互动工具函数.js";
8
 
 
 
 
 
 
 
9
  export { setupToggleButton } from "./互动按钮.js";
10
 
11
  // 🚀 核心修改:在参数末尾接收外部传进来的 onCountChange 回调函数
12
  // 🚀 P0安全修复:新增 contentAuthor 参数用于判断内容作者权限
13
  export function createCommentSection(itemId, commentsData, currentUser, onCountChange, contentAuthor = null) {
14
  const container = document.createElement("div");
15
- Object.assign(container.style, {
16
- display: "flex", flexDirection: "column", height: "100%", backgroundColor: "var(--comfy-menu-bg)", borderRadius: "4px"
17
- });
18
 
19
  const listArea = document.createElement("div");
20
- Object.assign(listArea.style, { flex: "1", overflowY: "auto", padding: "10px", fontSize: "12px", color: "#ccc" });
21
 
22
  const inputArea = document.createElement("div");
23
- Object.assign(inputArea.style, { display: "flex", padding: "8px", borderTop: "1px solid var(--border-color, #333)", backgroundColor: "var(--comfy-input-bg)", gap: "8px" });
24
 
25
- let currentReplyTarget = null;
 
 
 
 
 
 
 
26
 
27
  const inputField = document.createElement("input");
28
- Object.assign(inputField.style, { flex: "1", padding: "6px 10px", borderRadius: "15px", border: "1px solid #555", backgroundColor: "var(--comfy-menu-bg)", color: "#fff", outline: "none" });
29
  inputField.placeholder = t('social.say_something');
 
30
 
31
  const submitBtn = document.createElement("button");
32
  submitBtn.innerText = t('common.send');
33
- Object.assign(submitBtn.style, { padding: "4px 12px", borderRadius: "15px", border: "none", backgroundColor: "#4CAF50", color: "#fff", cursor: "pointer", fontWeight: "bold" });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
 
35
  // 🚀 核心新增:封装一个统计算法,每次发生数据变动时向外抛出真实的评论数量
36
  const triggerCountUpdate = () => {
@@ -49,24 +88,23 @@ export function createCommentSection(itemId, commentsData, currentUser, onCountC
49
 
50
  function renderCommentItem(comment, isSubReply = false) {
51
  const itemDiv = document.createElement("div");
52
- Object.assign(itemDiv.style, { display: "flex", gap: "8px", marginBottom: "12px", paddingLeft: isSubReply ? "35px" : "0px" });
53
 
54
  if (comment.isDeleted) {
55
  if (!comment.replies || comment.replies.length === 0) return null;
56
  itemDiv.innerHTML = `
57
- <div style="width: ${isSubReply ? '24px' : '32px'}; height: ${isSubReply ? '24px' : '32px'}; border-radius: 50%; background: var(--comfy-input-bg); display: flex; align-items: center; justify-content: center; font-size: 10px; color: #555;">✖</div>
58
- <div style="flex: 1; background: var(--comfy-input-bg); padding: 6px 10px; border-radius: 4px; color: #666; font-style: italic; border: 1px dashed #444;">${t('social.comment_deleted')}</div>
59
  `;
60
  } else {
61
- const avatarSize = isSubReply ? "24px" : "32px";
62
- const replyLabel = comment.replyToUserName ? `<span style="color: #888;">${t('social.reply_to')} <span style="color: #2196F3;">@${escapeHtml(comment.replyToUserName)}</span>:</span>` : "";
63
 
64
  // 🚀 P0安全修复:权限判断 - 评论作者、内容作者或管理员可删除
65
  const isCommentAuthor = currentUser && currentUser.account === comment.author;
66
  const isContentAuthor = currentUser && contentAuthor && currentUser.account === contentAuthor;
67
  const isAdminUser = currentUser && isAdmin(currentUser.account);
68
  const canDelete = isCommentAuthor || isContentAuthor || isAdminUser;
69
- const deleteBtnHtml = canDelete ? `<span class="delete-btn" style="color: #F44336; cursor: pointer; margin-left: 10px; font-size: 10px;">${t('common.delete')}</span>` : "";
70
 
71
  // 🚀 SWR 缓存优先获取头像
72
  const account = comment.author;
@@ -75,55 +113,81 @@ export function createCommentSection(itemId, commentsData, currentUser, onCountC
75
  const avatarSrc = avatar || DEFAULT_AVATAR_SVG;
76
 
77
  itemDiv.innerHTML = `
78
- <img class="swr-avatar" src="${escapeHtml(avatarSrc)}" style="width: ${avatarSize}; height: ${avatarSize}; border-radius: 50%; object-fit: cover; background: var(--comfy-input-bg);">
79
- <div style="flex: 1;">
80
- <div style="display: flex; justify-content: space-between; margin-bottom: 2px;">
81
- <span class="swr-name" style="color: #aaa; font-weight: bold; font-size: 11px;">${escapeHtml(comment.authorName || comment.author)}</span>
82
  </div>
83
- <div style="line-height: 1.4; word-break: break-all;">${replyLabel}${escapeHtml(comment.content)}</div>
84
- <div style="display: flex; gap: 15px; margin-top: 4px; font-size: 10px; color: #777;">
85
- <span class="reply-btn" style="cursor: pointer; color: #aaa;">${t('social.reply')}</span>${deleteBtnHtml}
86
  </div>
87
  </div>
88
  `;
89
 
90
  // 🚀 后台SWR校对头像
 
91
  getProfileWithSWR(account, api.getUserProfile, (profile) => {
92
  const avatarEl = itemDiv.querySelector('.swr-avatar');
93
  const nameEl = itemDiv.querySelector('.swr-name');
94
- if (avatarEl && profile.avatar) {
 
 
95
  avatarEl.src = profile.avatar;
96
  }
97
- if (nameEl && profile.name) {
98
  nameEl.textContent = profile.name;
99
  }
100
  });
101
 
102
  itemDiv.querySelector(".reply-btn").onclick = () => {
 
103
  currentReplyTarget = {
104
  account: comment.author, name: comment.authorName || comment.author,
105
  parentId: isSubReply ? comment.parentId : comment.id
106
  };
107
  inputField.placeholder = `${t('social.reply_to')} @${currentReplyTarget.name}:`;
 
108
  inputField.focus();
109
  };
110
 
111
  if (canDelete) {
112
  itemDiv.querySelector(".delete-btn").onclick = async () => {
113
- const confirmed = await showConfirm(t('social.delete_comment_confirm'), {
114
- title: t('social.delete_comment_title'),
115
- confirmText: t('common.confirm'),
116
- cancelText: t('common.cancel'),
117
- type: 'danger'
118
- });
119
- if (!confirmed) return;
120
  try {
121
- // 🚀 P0安全修复:删除评论不再传递author,后端从JWT中获取
122
- await api.deleteComment(itemId, comment.id);
 
 
 
 
 
 
 
 
123
  comment.isDeleted = true;
124
- renderList();
 
 
125
  triggerCountUpdate();
126
- } catch(e) { showToast(t('feedback.delete_failed') + t('feedback.retry_suffix'), "error"); }
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  };
128
  }
129
  }
@@ -133,7 +197,7 @@ export function createCommentSection(itemId, commentsData, currentUser, onCountC
133
  function renderList() {
134
  listArea.innerHTML = "";
135
  if (!commentsData || commentsData.length === 0) {
136
- listArea.innerHTML = `<div style="text-align:center; padding: 20px; color:#666;">${t('social.no_comments')}</div>`;
137
  return;
138
  }
139
 
@@ -149,24 +213,41 @@ export function createCommentSection(itemId, commentsData, currentUser, onCountC
149
  });
150
  }
151
  });
 
 
 
 
152
  listArea.scrollTop = listArea.scrollHeight;
153
  }
154
 
155
  submitBtn.onclick = async () => {
 
156
  if (!currentUser) return showToast(t('feedback.login_required'), "warning");
157
  const text = inputField.value.trim();
158
  if (!text) return;
159
 
160
- submitBtn.disabled = true;
 
 
 
 
161
  try {
162
  const parentId = currentReplyTarget ? currentReplyTarget.parentId : null;
163
  const replyToAcc = currentReplyTarget ? currentReplyTarget.account : null;
164
 
 
165
  const res = await api.postComment(itemId, currentUser.account, text, replyToAcc, parentId);
 
 
 
 
 
 
166
  const newComment = res.data;
167
 
168
  if (parentId) {
169
  let targetParent = commentsData.find(c => c.id === parentId);
 
170
  if (targetParent) {
171
  if (!targetParent.replies) targetParent.replies = [];
172
  targetParent.replies.push(newComment);
@@ -175,16 +256,67 @@ export function createCommentSection(itemId, commentsData, currentUser, onCountC
175
  commentsData.push(newComment);
176
  }
177
 
178
- inputField.value = ""; inputField.placeholder = t('social.say_something');
 
 
179
  currentReplyTarget = null;
180
  renderList();
181
  triggerCountUpdate();
182
- } catch (e) {
183
- showToast(t('feedback.send_failed_retry'), "error");
 
 
 
184
  } finally {
185
- submitBtn.disabled = false;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
  }
187
  };
 
 
 
 
 
 
 
188
 
189
  inputArea.appendChild(inputField); inputArea.appendChild(submitBtn);
190
  container.appendChild(listArea); container.appendChild(inputArea);
 
6
  // 🧹 P2归一:escapeHtml 局部副本已移除,改用统一版
7
  import { escapeHtml } from "../components/互动工具函数.js";
8
 
9
+ import { captureRequestSession } from "../core/网络请求_基础设施.js";
10
+ import { getUser, eventBus, EVENTS } from "../core/状态管理.js";
11
+ import { CACHE } from "../core/全局配置.js";
12
+ import { createTaskViewScope, subscribeTaskSessionChanges } from "../task/任务视图生命周期.js";
13
+ import { isConfirmedCommentResponse, isConfirmedCommentDeletion, isDefiniteCommentRejection } from "./评论数据工具.js";
14
+
15
  export { setupToggleButton } from "./互动按钮.js";
16
 
17
  // 🚀 核心修改:在参数末尾接收外部传进来的 onCountChange 回调函数
18
  // 🚀 P0安全修复:新增 contentAuthor 参数用于判断内容作者权限
19
  export function createCommentSection(itemId, commentsData, currentUser, onCountChange, contentAuthor = null) {
20
  const container = document.createElement("div");
21
+ container.className = "comment-section";
 
 
22
 
23
  const listArea = document.createElement("div");
24
+ listArea.className = "comment-section__list";
25
 
26
  const inputArea = document.createElement("div");
27
+ inputArea.className = "comment-section__composer";
28
 
29
+ let currentReplyTarget = null;
30
+ let sending = false, deleting = false, blocked = false, scope = null, constructing = true, observer;
31
+ let active = true, generation = 0, writePending = false;
32
+ const account = currentUser?.account || null;
33
+ const matches = captureRequestSession();
34
+ const sessionMatches = () => matches() && (getUser()?.account || null) === account;
35
+ const current = () => active && (scope ? scope.isCurrent() : sessionMatches()) && container.isConnected;
36
+ const profileCurrent = () => active && (scope ? scope.isCurrent() : sessionMatches()) && (constructing || container.isConnected);
37
 
38
  const inputField = document.createElement("input");
39
+ inputField.className = "comment-section__input";
40
  inputField.placeholder = t('social.say_something');
41
+ inputField.setAttribute('aria-label', inputField.placeholder);
42
 
43
  const submitBtn = document.createElement("button");
44
  submitBtn.innerText = t('common.send');
45
+ submitBtn.className = "comment-section__send";
46
+ submitBtn.type = "button";
47
+
48
+ const notice = document.createElement('div');
49
+ notice.className = 'comment-section__empty';
50
+ notice.setAttribute('role', 'alert');
51
+ function setControls() {
52
+ const busy = sending || deleting;
53
+ submitBtn.disabled = busy || blocked;
54
+ inputField.readOnly = busy || blocked;
55
+ submitBtn.setAttribute('aria-busy', String(busy));
56
+ inputArea.setAttribute('aria-busy', String(busy));
57
+ listArea.querySelectorAll('.reply-btn,.delete-btn').forEach(button => { button.disabled = busy || blocked; });
58
+ }
59
+ function unconfirmed() {
60
+ blocked = true;
61
+ notice.textContent = t('social.comment_unconfirmed');
62
+ inputArea.append(notice);
63
+ setControls();
64
+ showToast(t('social.comment_unconfirmed'), 'warning');
65
+ }
66
+
67
+ function refreshRequired() {
68
+ blocked = true;
69
+ notice.textContent = t('social.comment_refresh_required');
70
+ inputArea.append(notice); setControls();
71
+ showToast(t('social.comment_refresh_required'), 'warning');
72
+ }
73
 
74
  // 🚀 核心新增:封装一个统计算法,每次发生数据变动时向外抛出真实的评论数量
75
  const triggerCountUpdate = () => {
 
88
 
89
  function renderCommentItem(comment, isSubReply = false) {
90
  const itemDiv = document.createElement("div");
91
+ itemDiv.className = "comment-section__item" + (isSubReply ? " comment-section__item--reply" : "");
92
 
93
  if (comment.isDeleted) {
94
  if (!comment.replies || comment.replies.length === 0) return null;
95
  itemDiv.innerHTML = `
96
+ <div class="comment-section__avatar comment-section__deleted-icon" aria-hidden="true">✖</div>
97
+ <div class="comment-section__deleted">${t('social.comment_deleted')}</div>
98
  `;
99
  } else {
100
+ const replyLabel = comment.replyToUserName ? `<span class="comment-section__reply-label">${t('social.reply_to')} <span class="comment-section__reply-name">@${escapeHtml(comment.replyToUserName)}</span>: </span>` : "";
 
101
 
102
  // 🚀 P0安全修复:权限判断 - 评论作者、内容作者或管理员可删除
103
  const isCommentAuthor = currentUser && currentUser.account === comment.author;
104
  const isContentAuthor = currentUser && contentAuthor && currentUser.account === contentAuthor;
105
  const isAdminUser = currentUser && isAdmin(currentUser.account);
106
  const canDelete = isCommentAuthor || isContentAuthor || isAdminUser;
107
+ const deleteBtnHtml = canDelete ? `<button type="button" class="delete-btn comment-section__action comment-section__action--delete">${t('common.delete')}</button>` : "";
108
 
109
  // 🚀 SWR 缓存优先获取头像
110
  const account = comment.author;
 
113
  const avatarSrc = avatar || DEFAULT_AVATAR_SVG;
114
 
115
  itemDiv.innerHTML = `
116
+ <img class="swr-avatar comment-section__avatar" src="${escapeHtml(avatarSrc)}" alt="">
117
+ <div class="comment-section__body">
118
+ <div class="comment-section__header">
119
+ <span class="swr-name comment-section__name">${escapeHtml(comment.authorName || comment.author)}</span>
120
  </div>
121
+ <div class="comment-section__content">${replyLabel}${escapeHtml(comment.content)}</div>
122
+ <div class="comment-section__actions">
123
+ <button type="button" class="reply-btn comment-section__action" ${sending ? "disabled" : ""}>${t('social.reply')}</button>${deleteBtnHtml}
124
  </div>
125
  </div>
126
  `;
127
 
128
  // 🚀 后台SWR校对头像
129
+ const profileGeneration = generation;
130
  getProfileWithSWR(account, api.getUserProfile, (profile) => {
131
  const avatarEl = itemDiv.querySelector('.swr-avatar');
132
  const nameEl = itemDiv.querySelector('.swr-name');
133
+ if (profileGeneration !== generation || !profileCurrent() || !profile || typeof profile !== 'object' || Array.isArray(profile)
134
+ || (profile.account != null && profile.account !== account)) return;
135
+ if (avatarEl && typeof profile.avatar === 'string' && profile.avatar) {
136
  avatarEl.src = profile.avatar;
137
  }
138
+ if (nameEl && typeof profile.name === 'string' && profile.name) {
139
  nameEl.textContent = profile.name;
140
  }
141
  });
142
 
143
  itemDiv.querySelector(".reply-btn").onclick = () => {
144
+ if (!current() || sending || deleting || blocked) return;
145
  currentReplyTarget = {
146
  account: comment.author, name: comment.authorName || comment.author,
147
  parentId: isSubReply ? comment.parentId : comment.id
148
  };
149
  inputField.placeholder = `${t('social.reply_to')} @${currentReplyTarget.name}:`;
150
+ inputField.setAttribute('aria-label', inputField.placeholder);
151
  inputField.focus();
152
  };
153
 
154
  if (canDelete) {
155
  itemDiv.querySelector(".delete-btn").onclick = async () => {
156
+ if (!current() || sending || deleting || blocked) return;
157
+ const operationGeneration = generation;
158
+ const startedWithFocus = container.contains(document.activeElement);
159
+ deleting = true; setControls();
160
+ let requested = false, confirmedWrite = false;
 
 
161
  try {
162
+ const confirmed = await showConfirm(t('social.delete_comment_confirm'), {
163
+ title: t('social.delete_comment_title'), confirmText: t('common.confirm'),
164
+ cancelText: t('common.cancel'), type: 'danger', signal: confirmationAbort.signal
165
+ });
166
+ if (!confirmed || !current() || operationGeneration !== generation) return;
167
+ requested = true; writePending = true;
168
+ const response = await api.deleteComment(itemId, comment.id);
169
+ if (!current() || operationGeneration !== generation) return;
170
+ if (!isConfirmedCommentDeletion(response)) { unconfirmed(); return; }
171
+ confirmedWrite = true;
172
  comment.isDeleted = true;
173
+ const restoreFocus = startedWithFocus && (container.contains(document.activeElement) || document.activeElement === document.body);
174
+ renderList();
175
+ if (restoreFocus) inputField.focus();
176
  triggerCountUpdate();
177
+ } catch (error) {
178
+ if (!current() || operationGeneration !== generation) return;
179
+ if (confirmedWrite) refreshRequired();
180
+ else if (!requested || isDefiniteCommentRejection(error)) showToast(t('feedback.delete_failed') + t('feedback.retry_suffix'), 'error');
181
+ else unconfirmed();
182
+ } finally {
183
+ if (current() && operationGeneration === generation) {
184
+ deleting = false; writePending = false; setControls();
185
+ if (startedWithFocus && document.activeElement === document.body) {
186
+ const trigger = itemDiv.querySelector('.delete-btn');
187
+ (trigger?.isConnected && !trigger.disabled ? trigger : inputField).focus();
188
+ }
189
+ }
190
+ }
191
  };
192
  }
193
  }
 
197
  function renderList() {
198
  listArea.innerHTML = "";
199
  if (!commentsData || commentsData.length === 0) {
200
+ listArea.innerHTML = `<div class="comment-section__empty">${t('social.no_comments')}</div>`;
201
  return;
202
  }
203
 
 
213
  });
214
  }
215
  });
216
+ if (!listArea.firstElementChild) {
217
+ listArea.innerHTML = `<div class="comment-section__empty">${t('social.no_comments')}</div>`;
218
+ }
219
+ setControls();
220
  listArea.scrollTop = listArea.scrollHeight;
221
  }
222
 
223
  submitBtn.onclick = async () => {
224
+ if (!current() || sending || deleting || blocked) return;
225
  if (!currentUser) return showToast(t('feedback.login_required'), "warning");
226
  const text = inputField.value.trim();
227
  if (!text) return;
228
 
229
+ const operationGeneration = generation;
230
+ sending = true;
231
+ const submittedValue = inputField.value;
232
+ setControls();
233
+ let confirmedWrite = false;
234
  try {
235
  const parentId = currentReplyTarget ? currentReplyTarget.parentId : null;
236
  const replyToAcc = currentReplyTarget ? currentReplyTarget.account : null;
237
 
238
+ writePending = true;
239
  const res = await api.postComment(itemId, currentUser.account, text, replyToAcc, parentId);
240
+ if (!current() || operationGeneration !== generation) return;
241
+ if (!isConfirmedCommentResponse(res, account, replyToAcc)
242
+ || commentsData.some(c => c.id === res.data.id || (c.replies || []).some(r => r.id === res.data.id))) {
243
+ unconfirmed(); return;
244
+ }
245
+ confirmedWrite = true;
246
  const newComment = res.data;
247
 
248
  if (parentId) {
249
  let targetParent = commentsData.find(c => c.id === parentId);
250
+ if (!targetParent) { refreshRequired(); return; }
251
  if (targetParent) {
252
  if (!targetParent.replies) targetParent.replies = [];
253
  targetParent.replies.push(newComment);
 
256
  commentsData.push(newComment);
257
  }
258
 
259
+ if (inputField.value === submittedValue) inputField.value = "";
260
+ inputField.placeholder = t('social.say_something');
261
+ inputField.setAttribute('aria-label', inputField.placeholder);
262
  currentReplyTarget = null;
263
  renderList();
264
  triggerCountUpdate();
265
+ } catch (error) {
266
+ if (!current() || operationGeneration !== generation) return;
267
+ if (confirmedWrite) refreshRequired();
268
+ else if (isDefiniteCommentRejection(error)) showToast(t('feedback.send_failed_retry'), 'error');
269
+ else unconfirmed();
270
  } finally {
271
+ if (current() && operationGeneration === generation) { writePending = false; sending = false; setControls(); }
272
+ }
273
+ };
274
+
275
+ let confirmationAbort = new AbortController();
276
+ scope = createTaskViewScope({
277
+ sessionMatches, connected: () => constructing || container.isConnected,
278
+ subscribe: notify => subscribeTaskSessionChanges(notify, { target: window, eventBus, events: EVENTS,
279
+ userKey: CACHE.LEGACY_KEYS.USER, tokenKey: CACHE.LEGACY_KEYS.TOKEN }),
280
+ cancelReads: () => {},
281
+ invalidate: () => { notice.textContent = t('auth.session_changed'); container.append(notice); }
282
+ });
283
+ scope.addCleanup(() => {
284
+ active = false; generation++;
285
+ observer?.disconnect(); confirmationAbort.abort();
286
+ inputField.value = ''; currentReplyTarget = null; commentsData = [];
287
+ blocked = true; sending = deleting = false; setControls(); container.replaceChildren();
288
+ });
289
+ container._setActive = value => {
290
+ const next = !!value;
291
+ if (next === active) return;
292
+ if (next && !scope.isCurrent()) return;
293
+ generation++;
294
+ active = next;
295
+ if (!next) {
296
+ if (writePending) {
297
+ blocked = true;
298
+ notice.textContent = t('social.comment_unconfirmed');
299
+ inputArea.append(notice);
300
+ }
301
+ confirmationAbort.abort();
302
+ inputField.value = ''; currentReplyTarget = null;
303
+ inputField.placeholder = t('social.say_something');
304
+ inputField.setAttribute('aria-label', inputField.placeholder);
305
+ sending = deleting = writePending = false;
306
+ } else confirmationAbort = new AbortController();
307
+ setControls();
308
+ if (!active) {
309
+ submitBtn.disabled = true; inputField.readOnly = true;
310
+ listArea.querySelectorAll('.reply-btn,.delete-btn').forEach(button => { button.disabled = true; });
311
  }
312
  };
313
+ container._cleanup = () => scope.dispose();
314
+ queueMicrotask(() => {
315
+ constructing = false;
316
+ if (!scope.isCurrent()) return;
317
+ observer = new MutationObserver(() => { if (!container.isConnected) scope.dispose(); });
318
+ observer.observe(document.body, { childList: true, subtree: true });
319
+ });
320
 
321
  inputArea.appendChild(inputField); inputArea.appendChild(submitBtn);
322
  container.appendChild(listArea); container.appendChild(inputArea);
前端页面/social/评论数据工具.js ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // 通用评论接口的确认边界;不依赖宿主、存储或网络。
2
+ export function isConfirmedCommentResponse(response, account, replyTo = null) {
3
+ const data = response?.data;
4
+ return typeof account === 'string' && account.trim().length > 0
5
+ && response?.status === 'success' && !!data && typeof data === 'object' && !Array.isArray(data)
6
+ && typeof data.id === 'string' && data.id.trim().length > 0
7
+ && data.author === account && typeof data.content === 'string' && data.content.trim().length > 0
8
+ && data.isDeleted === false && Array.isArray(data.replies) && data.replies.length === 0
9
+ && data.replyToUser === replyTo
10
+ && ['authorName', 'avatar'].every(key => data[key] === undefined || typeof data[key] === 'string')
11
+ && (data.replyToUserName == null || typeof data.replyToUserName === 'string');
12
+ }
13
+
14
+ export function isConfirmedCommentDeletion(response) {
15
+ return response?.status === 'success';
16
+ }
17
+
18
+ export function isDefiniteCommentRejection(error) {
19
+ return Number.isInteger(error?.status) && error.status >= 400 && error.status < 500 && error.status !== 408;
20
+ }
前端页面/styles/主题基础.css CHANGED
@@ -822,6 +822,7 @@
822
 
823
  .comfy-ranking-root .hub-controls {
824
  display: flex;
 
825
  align-items: center;
826
  gap: var(--rk-space-2);
827
  width: 100%;
@@ -847,7 +848,7 @@
847
 
848
  .comfy-ranking-root .hub-search-input {
849
  flex: 1 1 100px;
850
- min-width: 0;
851
  padding: 6px 10px;
852
  background: var(--rk-panel);
853
  }
@@ -1494,3 +1495,117 @@ button.ranking-rating__star { cursor: pointer; min-width: 28px; min-height: 28px
1494
  .ranking-publish-secondary:hover { border-color: var(--rk-accent, #667eea); }
1495
  .ranking-publish-form :is(#video-name,#audio-name) { overflow-wrap: anywhere; }
1496
  @media (prefers-reduced-motion: reduce) { .ranking-publish-form, .ranking-publish-form * { transition: none !important; animation: none !important; } }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
822
 
823
  .comfy-ranking-root .hub-controls {
824
  display: flex;
825
+ flex-wrap: wrap;
826
  align-items: center;
827
  gap: var(--rk-space-2);
828
  width: 100%;
 
848
 
849
  .comfy-ranking-root .hub-search-input {
850
  flex: 1 1 100px;
851
+ min-width: 80px;
852
  padding: 6px 10px;
853
  background: var(--rk-panel);
854
  }
 
1495
  .ranking-publish-secondary:hover { border-color: var(--rk-accent, #667eea); }
1496
  .ranking-publish-form :is(#video-name,#audio-name) { overflow-wrap: anywhere; }
1497
  @media (prefers-reduced-motion: reduce) { .ranking-publish-form, .ranking-publish-form * { transition: none !important; animation: none !important; } }
1498
+
1499
+ /* Public comments: fixed-height embeds retain their own scroll area. */
1500
+ .comment-section { display: flex; flex-direction: column; height: 100%; min-height: 0; min-width: 0; border-radius: var(--rk-radius-sm, 6px); background: var(--rk-bg, var(--comfy-menu-bg, #202020)); color: var(--rk-text, var(--fg-color, #f1f1f1)); font-size: 12px; }
1501
+ .comment-section__list { flex: 1 1 auto; min-height: 0; min-width: 0; overflow-y: auto; padding: 10px; }
1502
+ .comment-section__composer { display: flex; flex-wrap: wrap; flex: 0 0 auto; min-width: 0; padding: 8px; gap: 8px; border-top: 1px solid var(--rk-border, var(--border-color, #454545)); background: var(--rk-panel, var(--comfy-input-bg, #292929)); }
1503
+ .comment-section__input { flex: 1 1 120px; min-width: 0; width: 0; max-width: 100%; box-sizing: border-box; min-height: 32px; padding: 6px 10px; border: 1px solid var(--rk-border, var(--border-color, #454545)); border-radius: var(--rk-radius-sm, 6px); background: var(--rk-bg, var(--comfy-menu-bg, #202020)); color: inherit; font: inherit; }
1504
+ .comment-section__input::placeholder { color: var(--rk-muted, var(--descrip-text, #a4a4a4)); opacity: 1; }
1505
+ .comment-section__input[readonly] { cursor: wait; }
1506
+ .comment-section__send { min-height: 32px; max-width: 100%; padding: 4px 12px; border: 1px solid var(--rk-success, #4caf50); border-radius: var(--rk-radius-sm, 6px); background: var(--rk-panel, var(--comfy-input-bg, #292929)); color: inherit; font: inherit; font-weight: bold; cursor: pointer; overflow-wrap: anywhere; }
1507
+ .comment-section__item { display: flex; gap: 8px; margin-bottom: 12px; min-width: 0; }
1508
+ .comment-section__item--reply { padding-left: 24px; }
1509
+ .comment-section__avatar { flex: 0 0 32px; width: 32px; height: 32px; border-radius: 50%; object-fit: cover; background: var(--rk-panel, var(--comfy-input-bg, #292929)); }
1510
+ .comment-section__item--reply .comment-section__avatar { flex-basis: 24px; width: 24px; height: 24px; }
1511
+ .comment-section__body { flex: 1; min-width: 0; }
1512
+ .comment-section__header { display: flex; margin-bottom: 2px; min-width: 0; }
1513
+ .comment-section__name { font-weight: bold; font-size: 11px; overflow-wrap: anywhere; }
1514
+ .comment-section__content { line-height: 1.5; overflow-wrap: anywhere; }
1515
+ .comment-section__reply-label { color: var(--rk-muted, var(--descrip-text, #a4a4a4)); }
1516
+ .comment-section__reply-name { color: var(--rk-text, var(--fg-color, #f1f1f1)); }
1517
+ .comment-section__actions { display: flex; flex-wrap: wrap; gap: 4px 8px; margin-top: 4px; }
1518
+ .comment-section__action { min-height: 24px; padding: 2px 6px; border: 1px solid transparent; border-radius: var(--rk-radius-sm, 6px); background: transparent; color: inherit; font: inherit; font-size: 11px; cursor: pointer; overflow-wrap: anywhere; }
1519
+ .comment-section__action--delete { border-color: var(--rk-danger, #ef5350); }
1520
+ .comment-section__action:hover, .comment-section__send:hover { border-color: var(--rk-focus, #8ab4ff); background: var(--rk-bg, var(--comfy-menu-bg, #202020)); }
1521
+ .comment-section__action:focus-visible, .comment-section__send:focus-visible, .comment-section__input:focus-visible { outline: 2px solid var(--rk-focus, #8ab4ff); outline-offset: 2px; }
1522
+ .comment-section__action:disabled, .comment-section__send:disabled { opacity: .7; cursor: wait; }
1523
+ .comment-section__deleted-icon { display: flex; align-items: center; justify-content: center; color: var(--rk-muted, var(--descrip-text, #a4a4a4)); font-size: 10px; }
1524
+ .comment-section__deleted { flex: 1; min-width: 0; padding: 6px 10px; border: 1px dashed var(--rk-border, var(--border-color, #454545)); border-radius: var(--rk-radius-sm, 6px); background: var(--rk-panel, var(--comfy-input-bg, #292929)); color: var(--rk-muted, var(--descrip-text, #a4a4a4)); font-style: italic; overflow-wrap: anywhere; }
1525
+ .comment-section__empty { padding: 20px 8px; text-align: center; color: var(--rk-muted, var(--descrip-text, #a4a4a4)); overflow-wrap: anywhere; }
1526
+ /* Creator cards: compact identity, clear statistics and separate native actions. */
1527
+ .creator-card { container-type:inline-size; container-name:ranking-creator; min-width:0; margin-bottom:12px; border:1px solid var(--rk-border,var(--border-color,#454545)); border-radius:var(--rk-radius-md,10px); background:var(--rk-panel,var(--comfy-input-bg,#292929)); color:var(--rk-text,var(--fg-color,#f1f1f1)); font:inherit; overflow:hidden; }
1528
+ .creator-card *, .creator-tip-board * { box-sizing:border-box; }
1529
+ .creator-card__summary { position:relative; padding:12px; cursor:pointer; background-position:center; background-size:cover; }
1530
+ .creator-card__summary--banner::before { content:""; position:absolute; inset:0; background:rgb(0 0 0 / 76%); }
1531
+ .creator-card__summary--banner { color:#fff; }
1532
+ .creator-card__summary > * { position:relative; }
1533
+ .creator-card__identity { display:grid; grid-template-columns:50px minmax(0,1fr); align-items:center; gap:12px; }
1534
+ .creator-avatar-link, .creator-name-link, .tip-board-user { padding:0; border:0; background:transparent; color:inherit; font:inherit; cursor:pointer; text-align:left; min-width:0; }
1535
+ .creator-avatar-link { width:50px; height:50px; border-radius:50%; }
1536
+ .creator-avatar-link img { width:100%; height:100%; border-radius:50%; object-fit:cover; border:2px solid var(--rk-border,#555); }
1537
+ .creator-name-link { font-size:16px; font-weight:700; line-height:1.4; overflow-wrap:anywhere; }
1538
+ .creator-card__usage { margin:8px 0; color:var(--rk-muted,var(--descrip-text,#a4a4a4)); font-size:12px; overflow-wrap:anywhere; }
1539
+ .creator-card__intro { margin:0 0 12px; color:var(--rk-muted,var(--descrip-text,#a4a4a4)); font-size:12px; line-height:1.6; overflow-wrap:anywhere; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; }
1540
+ .creator-card__statistics { border:1px solid var(--rk-border,#454545); border-radius:6px; background:var(--rk-bg,var(--comfy-menu-bg,#202020)); padding:8px; }
1541
+ .creator-card__stats, .creator-card__outputs { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; }
1542
+ .creator-card__outputs { margin-top:8px; padding-top:8px; border-top:1px solid var(--rk-border,#454545); }
1543
+ .creator-card__stat { min-width:0; display:flex; flex-direction:column; gap:4px; font-size:11px; line-height:1.4; overflow-wrap:anywhere; }
1544
+ .creator-card__stat > span { color:var(--rk-muted,var(--descrip-text,#a4a4a4)); }
1545
+ .creator-card__stat strong { font-size:13px; font-variant-numeric:tabular-nums; }
1546
+ .creator-card__stat-value { display:flex; flex-wrap:wrap; align-items:baseline; gap:3px; }
1547
+ .creator-card__stat-value > span { min-width:0; }
1548
+ .creator-card__number { white-space:nowrap; }
1549
+ .creator-card__stat small { font-weight:400; font-size:10px; }
1550
+ .creator-card__toggle { width:100%; min-height:36px; display:flex; align-items:center; justify-content:space-between; gap:8px; margin-top:10px; padding:6px 10px; border:1px solid var(--rk-border,#454545); border-radius:6px; background:var(--rk-panel,var(--comfy-input-bg,#292929)); color:var(--rk-text,var(--fg-color,#f1f1f1)); font:inherit; font-size:12px; cursor:pointer; }
1551
+ .creator-card__toggle[aria-expanded="true"] > span { transform:rotate(180deg); }
1552
+ .creator-card button:hover, .creator-tip-board button:hover { text-decoration:underline; }
1553
+ .creator-card button:focus-visible, .creator-tip-board button:focus-visible { outline:2px solid var(--rk-focus,#8ab4ff); outline-offset:2px; }
1554
+ .creator-card button:disabled { cursor:default; opacity:.65; }
1555
+ .creator-card__summary--banner .creator-card__usage, .creator-card__summary--banner .creator-card__intro { color:#eee; }
1556
+ .creator-card__summary--banner .creator-card__statistics { color:var(--rk-text,var(--fg-color,#f1f1f1)); }
1557
+ .creator-card__detail { padding:12px; border-top:1px solid var(--rk-border,#454545); }
1558
+ .creator-card__detail[hidden] { display:none !important; }
1559
+ .creator-card__section-label { font-size:12px; font-weight:700; margin-bottom:8px; }
1560
+ .creator-card__section-label--comments { margin-top:16px; }
1561
+ .creator-card__chart { width:100%; height:180px; background:var(--rk-bg,var(--comfy-menu-bg,#202020)); border-radius:6px; margin-bottom:12px; }
1562
+ .creator-card__comments { height:250px; border:1px solid var(--rk-border,#454545); border-radius:6px; overflow:hidden; }
1563
+ .creator-card__status { padding:32px 12px; text-align:center; color:var(--rk-muted,var(--descrip-text,#a4a4a4)); }
1564
+ .creator-card__status--error, .creator-card__chart-status--error { color:var(--rk-danger,#ef5350); }
1565
+ .creator-card__status-copy { font-size:13px; line-height:1.6; margin-bottom:12px; }
1566
+ .creator-card__retry, .creator-tip-btn { min-height:36px; padding:6px 12px; border:1px solid var(--rk-border,#454545); border-radius:6px; color:var(--rk-text,var(--fg-color,#f1f1f1)); background:var(--rk-panel,var(--comfy-input-bg,#292929)); font:inherit; font-size:12px; cursor:pointer; }
1567
+ .creator-card__chart-status { display:grid; place-items:center; height:100%; padding:12px; text-align:center; font-size:12px; }
1568
+ .creator-tip-board { padding:10px; margin-top:10px; border:1px solid var(--rk-border,#454545); border-radius:6px; background:var(--rk-bg,var(--comfy-menu-bg,#202020)); color:var(--rk-text,var(--fg-color,#f1f1f1)); min-width:0; }
1569
+ .creator-tip-board__header { display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between; gap:8px; margin-bottom:8px; }
1570
+ .creator-tip-board__title { font-size:12px; font-weight:700; min-width:0; overflow-wrap:anywhere; }
1571
+ .creator-tip-board__count { font-size:11px; font-weight:400; color:var(--rk-muted,var(--descrip-text,#a4a4a4)); }
1572
+ .creator-tip-board__rule { font-size:11px; line-height:1.6; color:var(--rk-muted,var(--descrip-text,#a4a4a4)); padding:6px 0; margin-bottom:6px; overflow-wrap:anywhere; }
1573
+ .creator-tip-board__empty { padding:15px 0; text-align:center; color:var(--rk-muted,var(--descrip-text,#a4a4a4)); font-size:12px; }
1574
+ .creator-tip-board__list { max-height:200px; overflow-y:auto; }
1575
+ .creator-tip-entry { display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between; gap:6px; padding:7px 6px; border-radius:4px; font-size:12px; }
1576
+ .creator-tip-entry:nth-child(odd) { background:var(--rk-panel,var(--comfy-input-bg,#292929)); }
1577
+ .creator-tip-entry__identity { display:flex; align-items:center; gap:6px; min-width:0; flex:1 1 100px; }
1578
+ .creator-tip-entry__rank { width:24px; flex-shrink:0; }
1579
+ .tip-board-user { display:flex; align-items:center; gap:6px; min-height:32px; line-height:1.4; }
1580
+ .tip-board-avatar { width:20px; height:20px; border-radius:50%; object-fit:cover; flex-shrink:0; }
1581
+ .tip-board-name { min-width:0; overflow-wrap:anywhere; }
1582
+ .creator-tip-entry__value { display:flex; align-items:center; gap:6px; min-width:0; max-width:100%; margin-left:auto; }
1583
+ .creator-tip-entry__amount { font-weight:700; font-variant-numeric:tabular-nums; overflow-wrap:anywhere; }
1584
+ .creator-tip-entry__anonymous { color:var(--rk-muted,var(--descrip-text,#a4a4a4)); font-style:italic; }
1585
+ @container ranking-creator (max-width:280px) { .creator-card__summary, .creator-card__detail { padding:10px; } .creator-card__stats, .creator-card__outputs { grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; } .creator-card__stat strong { font-size:12px; } .creator-tip-entry__identity { flex-basis:100%; } .creator-tip-entry__value { margin-left:30px; } }
1586
+ @media (prefers-reduced-motion:reduce) { .creator-card *, .creator-tip-board * { transition:none !important; animation:none !important; } }
1587
+
1588
+ /* 任务申诉发起弹窗:局部主题与原生控件状态。 */
1589
+ .task-dispute { min-width: 0; color: var(--rk-text, var(--fg-color, #f1f1f1)); font: inherit; font-size: 14px; line-height: 1.5; overflow-wrap: anywhere; }
1590
+ .task-dispute .task-dispute__field { min-width: 0; margin-bottom: 16px; }
1591
+ .task-dispute .task-dispute__label { display: block; margin-bottom: 8px; font-weight: 600; }
1592
+ .task-dispute .task-dispute__required { color: var(--rk-danger, #ef5350); }
1593
+ .task-dispute .task-dispute__reason { display: block; width: 100%; min-width: 0; height: 120px; min-height: 120px; padding: 12px; box-sizing: border-box; border: 1px solid var(--rk-border, var(--border-color, #454545)); border-radius: var(--rk-radius-sm, 6px); background: var(--rk-panel, var(--comfy-input-bg, #292929)); color: inherit; font: inherit; line-height: 1.5; resize: vertical; }
1594
+ .task-dispute .task-dispute__reason::placeholder { color: inherit; opacity: .7; }
1595
+ .task-dispute .task-dispute__reason[readonly] { border-style: dashed; }
1596
+ .task-dispute .task-dispute__reason[aria-invalid="true"] { border-color: var(--rk-danger, #ef5350); border-width: 2px; }
1597
+ .task-dispute .task-dispute__upload { display: block; width: 100%; min-width: 0; min-height: 64px; padding: 16px 12px; box-sizing: border-box; border: 2px dashed var(--rk-border, var(--border-color, #454545)); border-radius: var(--rk-radius-sm, 6px); background: var(--rk-panel, var(--comfy-input-bg, #292929)); color: inherit; font: inherit; line-height: 1.5; overflow-wrap: anywhere; cursor: pointer; transition: border-color .15s ease; }
1598
+ .task-dispute .task-dispute__file { display: none; }
1599
+ .task-dispute .task-dispute__preview { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; min-width: 0; }
1600
+ .task-dispute .task-dispute__evidence { position: relative; flex: 0 0 72px; width: 72px; height: 72px; }
1601
+ .task-dispute .task-dispute__image { display: block; width: 72px; height: 72px; object-fit: cover; border-radius: var(--rk-radius-sm, 6px); }
1602
+ .task-dispute .task-dispute__remove { position: absolute; top: 4px; right: 4px; display: grid; place-items: center; width: 28px; height: 28px; min-width: 28px; min-height: 28px; padding: 0; box-sizing: border-box; border: 1px solid var(--rk-danger, #ef5350); border-radius: var(--rk-radius-sm, 6px); background: var(--rk-panel, var(--comfy-input-bg, #292929)); color: inherit; font: inherit; font-size: 18px; line-height: 1; cursor: pointer; }
1603
+ .task-dispute .task-dispute__notice { min-width: 0; margin-bottom: 16px; padding: 12px; border: 1px solid var(--rk-border, var(--border-color, #454545)); border-left: 3px solid var(--rk-warning, #ff9800); border-radius: var(--rk-radius-sm, 6px); background: var(--rk-panel, var(--comfy-input-bg, #292929)); }
1604
+ .task-dispute .task-dispute__notice-copy { font-size: 13px; }
1605
+ .task-dispute .task-dispute__notice-list { margin: 8px 0 0 16px; padding: 0; line-height: 1.6; }
1606
+ .task-dispute .task-dispute__notice-list li + li { margin-top: 4px; }
1607
+ .task-dispute .task-dispute__submit { display: block; width: 100%; min-width: 0; min-height: 44px; padding: 12px; box-sizing: border-box; border: 1px solid var(--rk-danger, #ef5350); border-radius: var(--rk-radius-sm, 6px); background: var(--rk-panel, var(--comfy-input-bg, #292929)); color: inherit; font: inherit; font-size: 15px; font-weight: 700; line-height: 1.5; overflow-wrap: anywhere; cursor: pointer; transition: border-color .15s ease; }
1608
+ .task-dispute button:disabled { cursor: default; opacity: .65; }
1609
+ .task-dispute button:focus-visible, .task-dispute textarea:focus-visible { outline: 2px solid var(--rk-focus, var(--comfy-focus-color, #8ab4ff)); outline-offset: 3px; }
1610
+ @media (hover: hover) { .task-dispute .task-dispute__upload:hover:not(:disabled), .task-dispute .task-dispute__submit:hover:not(:disabled), .task-dispute .task-dispute__remove:hover:not(:disabled) { border-color: var(--rk-focus, var(--comfy-focus-color, #8ab4ff)); } }
1611
+ @media (prefers-reduced-motion: reduce) { .task-dispute *, .task-dispute *::before, .task-dispute *::after { transition: none !important; animation: none !important; } }
前端页面/task/任务视图生命周期.js CHANGED
@@ -1,59 +1,98 @@
1
- /** 会话与路由范围;取消读取,不重放已经提交的业务操作。 */
2
- export function createTaskViewScope({ sessionMatches, connected, subscribe, cancelReads, invalidate }) {
3
- let disposed = false;
4
- let unsubscribe = () => {};
5
- const cleanups = new Set();
6
- const dispose = () => {
7
- if (disposed) return;
8
- disposed = true;
9
- // 一项清理失败不能阻止其它弹窗、读取与订阅释放。
10
- try { unsubscribe(); } catch (_) {}
11
- try { cancelReads(); } catch (_) {}
12
- for (const cleanup of cleanups) {
13
- try { cleanup(); } catch (_) {}
14
- }
15
- cleanups.clear();
16
- };
17
- const revoke = () => {
18
- if (disposed) return;
19
- dispose();
20
- invalidate();
21
- };
22
- unsubscribe = subscribe(force => {
23
- if (force || !sessionMatches()) revoke();
24
- });
25
- return {
26
- dispose,
27
- isCurrent() {
28
- if (disposed) return false;
29
- if (!sessionMatches()) { revoke(); return false; }
30
- if (!connected()) { dispose(); return false; }
31
- return true;
32
- },
33
- addCleanup(cleanup) {
34
- if (disposed) { cleanup(); return () => {}; }
35
- cleanups.add(cleanup);
36
- return () => cleanups.delete(cleanup);
37
- }
38
- };
39
- }
40
-
41
- /** 同域登录事件及跨标签身份记录变化,任务与申诉视图使用相同语义。 */
42
- export function subscribeTaskSessionChanges(notify, { target, eventBus, events, userKey, tokenKey }) {
43
- const handlers = [
44
- ["comfy-user-login", () => notify(true)],
45
- ["comfy-user-logout", () => notify(true)],
46
- ["comfy-ranking-auth-expired", () => notify(true)],
47
- ["storage", event => {
48
- if (event.key === null || event.key === userKey) notify(true);
49
- else if (event.key === tokenKey) notify(false);
50
- }]
51
- ];
52
- for (const [name, handler] of handlers) target.addEventListener(name, handler);
53
- const offLogin = eventBus.on(events.USER_LOGIN, () => notify(true));
54
- const offLogout = eventBus.on(events.USER_LOGOUT, () => notify(true));
55
- return () => {
56
- for (const [name, handler] of handlers) target.removeEventListener(name, handler);
57
- offLogin(); offLogout();
58
- };
59
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** 会话与路由范围;取消读取,不重放已经提交的业务操作。 */
2
+ export function createTaskViewScope({ sessionMatches, connected, subscribe, cancelReads, invalidate }) {
3
+ let disposed = false;
4
+ let unsubscribe = () => {};
5
+ const cleanups = new Set();
6
+ const dispose = () => {
7
+ if (disposed) return;
8
+ disposed = true;
9
+ // 一项清理失败不能阻止其它弹窗、读取与订阅释放。
10
+ try { unsubscribe(); } catch (_) {}
11
+ try { cancelReads(); } catch (_) {}
12
+ for (const cleanup of cleanups) {
13
+ try { cleanup(); } catch (_) {}
14
+ }
15
+ cleanups.clear();
16
+ };
17
+ const revoke = () => {
18
+ if (disposed) return;
19
+ dispose();
20
+ invalidate();
21
+ };
22
+ try {
23
+ unsubscribe = subscribe(force => {
24
+ if (force || !sessionMatches()) revoke();
25
+ });
26
+ if (typeof unsubscribe !== "function") throw new Error("Invalid view subscription");
27
+ // 注册期间同步撤销时,补收刚返回的取消订阅句柄。
28
+ if (disposed) { try { unsubscribe(); } catch (_) {} }
29
+ } catch (error) {
30
+ dispose();
31
+ throw error;
32
+ }
33
+ return {
34
+ dispose,
35
+ isCurrent() {
36
+ if (disposed) return false;
37
+ if (!sessionMatches()) { revoke(); return false; }
38
+ if (!connected()) { dispose(); return false; }
39
+ return true;
40
+ },
41
+ addCleanup(cleanup) {
42
+ if (disposed) { cleanup(); return () => {}; }
43
+ cleanups.add(cleanup);
44
+ return () => cleanups.delete(cleanup);
45
+ }
46
+ };
47
+ }
48
+
49
+ /** 同域登录事件及跨标签身份记录变化,任务与申诉视图使用相同语义。 */
50
+ export function subscribeTaskSessionChanges(notify, { target, eventBus, events, userKey, tokenKey }) {
51
+ let stopped = false;
52
+ const cleanups = [];
53
+ const notifyCurrent = force => { if (!stopped) notify(force); };
54
+ const unsubscribe = () => {
55
+ if (stopped) return;
56
+ stopped = true;
57
+ // 一项平台/总线清理失败不阻止其它监听释放,旧回调也不再通知。
58
+ for (const cleanup of cleanups.splice(0)) {
59
+ try { cleanup(); }
60
+ catch (_) { console.warn('会话监听清理失败,继续释放其它监听。'); }
61
+ }
62
+ };
63
+ const handlers = [
64
+ ["comfy-user-login", () => notifyCurrent(true)],
65
+ ["comfy-user-logout", () => notifyCurrent(true)],
66
+ ["comfy-ranking-auth-expired", () => notifyCurrent(true)],
67
+ ["storage", event => {
68
+ if (event.key === null || event.key === userKey) notifyCurrent(true);
69
+ else if (event.key === tokenKey) notifyCurrent(false);
70
+ }]
71
+ ];
72
+ const subscribeBus = name => {
73
+ const handler = () => notifyCurrent(true);
74
+ let off;
75
+ cleanups.push(() => {
76
+ if (typeof off === "function") {
77
+ try { off(); return; }
78
+ catch (_) { console.warn('会话总线清理失败,尝试释放原监听。'); }
79
+ }
80
+ // on中途失败或返回坏句柄时,仍能精确回收已登记的原回调。
81
+ if (typeof eventBus.off === "function") eventBus.off(name, handler);
82
+ });
83
+ off = eventBus.on(name, handler);
84
+ if (typeof off !== "function") throw new Error("Invalid session subscription");
85
+ };
86
+ try {
87
+ for (const [name, handler] of handlers) {
88
+ cleanups.push(() => target.removeEventListener(name, handler));
89
+ target.addEventListener(name, handler);
90
+ }
91
+ subscribeBus(events.USER_LOGIN);
92
+ subscribeBus(events.USER_LOGOUT);
93
+ return unsubscribe;
94
+ } catch (error) {
95
+ unsubscribe();
96
+ throw error;
97
+ }
98
+ }
前端页面/task/任务详情组件.js CHANGED
@@ -12,7 +12,7 @@ import { eventBus, EVENTS } from "../core/状态管理.js";
12
  import { createTaskViewScope, subscribeTaskSessionChanges } from "./任务视图生命周期.js";
13
  import { showToast } from "../components/UI交互提示组件.js";
14
  import { t } from "../components/用户体验增强.js";
15
- import { API, CACHE, PLACEHOLDERS, getCachedProfile, getProfileWithSWR, isAdmin, IS_WEB_MODE } from "../core/全局配置.js";
16
  import { removeCache, findInListCache, requireListResponseData } from "../components/性能优化工具.js";
17
  import { globalModal } from "../components/全局弹窗管理器.js";
18
  import { compressImageForUpload } from "../market/发布内容_提交引擎.js";
@@ -23,6 +23,7 @@ import { recordView, handleToggleLike, handleToggleFavorite, renderTipBoardHTML
23
  let taskViewSequence = 0;
24
  const taskScopes = new WeakMap();
25
  const modalScopes = new WeakMap();
 
26
 
27
  function openTaskModal(scope, title, content, options) {
28
  if (scope && !scope.isCurrent()) return;
@@ -323,8 +324,8 @@ ${escapeHtml(task.description)}</div>
323
 
324
  <!-- 评论输入框 -->
325
  <div style="display: flex; gap: 10px; margin-bottom: 15px;">
326
- <input type="text" id="comment-input" placeholder="${t('post.comment_placeholder')}" style="flex: 1; padding: 10px 12px; background: var(--comfy-input-bg); border: 1px solid #444; border-radius: 20px; color: #fff; font-size: 13px; outline: none;" onfocus="this.style.borderColor='#4CAF50'" onblur="this.style.borderColor='#444'">
327
- <button id="btn-send-comment" style="background: #4CAF50; border: none; color: #fff; padding: 10px 20px; border-radius: 20px; cursor: pointer; font-size: 13px; font-weight: bold; transition: 0.2s;" onmouseover="this.style.background='#45a049'" onmouseout="this.style.background='#4CAF50'">
328
  ${t('common.send')}
329
  </button>
330
  </div>
@@ -435,8 +436,10 @@ ${escapeHtml(task.description)}</div>
435
  /**
436
  * 🔧 通用确认对话框工厂函数
437
  */
438
- function _showConfirmDialog({ scope, title, icon, message, description, confirmText, confirmStyle, loadingText, errorText, width, onConfirm }) {
 
439
  const content = document.createElement("div");
 
440
  content.style.color = "#ccc";
441
  // 🔒 XSS防护:message/description 可能嵌入其他用户的账号或昵称
442
  // (如 t('task.confirm_assign', { assignee })),写入 innerHTML 前必须转义。
@@ -453,24 +456,36 @@ function _showConfirmDialog({ scope, title, icon, message, description, confirmT
453
  </div>
454
  `;
455
 
456
- content.querySelector("#dialog-cancel").onclick = () => closeTaskModal(content);
457
-
458
- content.querySelector("#dialog-confirm").onclick = async () => {
459
- const confirmBtn = content.querySelector("#dialog-confirm");
 
 
 
 
460
  confirmBtn.disabled = true;
461
  confirmBtn.textContent = loadingText || `⏳ ${t('common.confirming')}...`;
462
-
463
  try {
464
- if (!taskModalCurrent(content)) return;
465
- await onConfirm(content);
 
 
 
466
  } catch (err) {
467
- if (!taskModalCurrent(content)) return;
468
- showToast(errorText || t('common.error'), "error");
469
- confirmBtn.disabled = false;
470
- confirmBtn.textContent = confirmText;
 
 
 
 
 
471
  }
472
  };
473
-
474
  openTaskModal(scope, title, content, { width: width || "350px" });
475
  }
476
 
@@ -478,6 +493,7 @@ function _showConfirmDialog({ scope, title, icon, message, description, confirmT
478
  * 👤 显示接受任务申请确认对话框
479
  */
480
  function showAssignConfirmDialog(task, assignee, contentEl, currentUser) {
 
481
  _showConfirmDialog({
482
  scope: taskScopes.get(task),
483
  title: t('task.choose_assignee'),
@@ -487,61 +503,97 @@ function showAssignConfirmDialog(task, assignee, contentEl, currentUser) {
487
  confirmText: t('common.confirm'),
488
  confirmStyle: 'background: #4CAF50',
489
  errorText: t('task.assign_failed'),
490
- onConfirm: async (content) => {
491
- if (!taskModalCurrent(content)) return;
492
- await api.assignTask(task.id, assignee);
493
- if (!taskModalCurrent(content)) return;
494
  showToast(t('task.assign_success'), "success");
495
  closeTaskModal(content);
496
- loadTaskDetail(contentEl.closest("div"), task.id, currentUser);
497
  }
498
  });
499
  }
500
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
501
  /**
502
  * 🙋 显示申请接单对话框
503
  */
504
  function showApplyDialog(task, currentUser) {
 
505
  const content = document.createElement("div");
506
  content.style.color = "#ccc";
507
  content.innerHTML = `
508
  <div style="margin-bottom: 15px;">
509
- <div style="color: #888; font-size: 12px; margin-bottom: 8px;">${t('task.apply_message_prompt')}</div>
510
  <textarea id="apply-message" rows="4" placeholder="${t('task.apply_message_placeholder')}" style="width: 100%; padding: 10px; background: var(--comfy-input-bg); border: 1px solid #444; border-radius: 6px; color: #fff; resize: none; box-sizing: border-box; font-size: 13px;"></textarea>
511
  </div>
512
  <div style="display: flex; gap: 10px;">
513
- <button id="apply-cancel" style="flex: 1; background: var(--comfy-input-bg); border: 1px solid #555; color: #fff; padding: 10px; border-radius: 6px; cursor: pointer;">${t('common.cancel')}</button>
514
- <button id="apply-confirm" style="flex: 1; background: #FF9800; border: none; color: #fff; padding: 10px; border-radius: 6px; cursor: pointer; font-weight: bold;">${t('common.confirm_submit')}</button>
515
  </div>
516
  `;
517
 
518
- content.querySelector("#apply-cancel").onclick = () => closeTaskModal(content);
519
-
520
- content.querySelector("#apply-confirm").onclick = async () => {
521
- const message = content.querySelector("#apply-message").value;
522
- const confirmBtn = content.querySelector("#apply-confirm");
523
- confirmBtn.disabled = true;
524
- confirmBtn.textContent = `⏳ ${t('common.submitting')}...`;
525
-
526
- try {
527
- if (!taskModalCurrent(content)) return;
528
- await api.applyTask(task.id, message);
529
- if (!taskModalCurrent(content)) return;
530
- showToast(t('task.apply_success'), "success");
531
- closeTaskModal(content);
532
- // 重新加载任务详情
533
  const container = taskScopes.get(task)?.root;
534
- if (container && currentUser) {
535
- loadTaskDetail(container, task.id, currentUser);
536
- }
537
- } catch (err) {
538
- if (!taskModalCurrent(content)) return;
539
- showToast(t('task.apply_failed'), "error");
540
- confirmBtn.disabled = false;
541
- confirmBtn.textContent = t('common.confirm_submit');
542
  }
543
- };
544
-
545
  openTaskModal(taskScopes.get(task), t('task.apply_task'), content, { width: "400px" });
546
  }
547
 
@@ -549,6 +601,7 @@ function showApplyDialog(task, currentUser) {
549
  * ↩️ 显示撤回申请确认对话框
550
  */
551
  function showCancelApplyConfirmDialog(task, currentUser) {
 
552
  _showConfirmDialog({
553
  scope: taskScopes.get(task),
554
  title: t('task.cancel_apply'),
@@ -558,15 +611,13 @@ function showCancelApplyConfirmDialog(task, currentUser) {
558
  confirmText: t('common.confirm'),
559
  confirmStyle: 'background: #FF9800',
560
  errorText: t('task.cancel_apply_failed'),
561
- onConfirm: async (content) => {
562
- if (!taskModalCurrent(content)) return;
563
- await api.cancelApplyTask(task.id);
564
- if (!taskModalCurrent(content)) return;
565
  showToast(t('task.apply_cancelled'), "success");
566
  closeTaskModal(content);
567
  const container = taskScopes.get(task)?.root;
568
  if (container && currentUser) {
569
- loadTaskDetail(container, task.id, currentUser);
570
  }
571
  }
572
  });
@@ -576,6 +627,7 @@ function showCancelApplyConfirmDialog(task, currentUser) {
576
  * ❌ 显示取消任务确认对话框
577
  */
578
  function showCancelTaskConfirmDialog(task) {
 
579
  _showConfirmDialog({
580
  scope: taskScopes.get(task),
581
  title: t('task.cancel_task'),
@@ -586,10 +638,9 @@ function showCancelTaskConfirmDialog(task) {
586
  confirmStyle: 'background: #F44336',
587
  loadingText: `⏳ ${t('common.cancelling')}...`,
588
  errorText: t('task.cancel_task_failed'),
589
- onConfirm: async (content) => {
590
- if (!taskModalCurrent(content)) return;
591
- await api.cancelTask(task.id);
592
- if (!taskModalCurrent(content)) return;
593
  showToast(t('task.task_cancelled'), "success");
594
  closeTaskModal(content);
595
  window.dispatchEvent(new CustomEvent("comfy-route-back"));
@@ -601,6 +652,7 @@ function showCancelTaskConfirmDialog(task) {
601
  * ✅ 显示验收通过确认对话框
602
  */
603
  function showAcceptConfirmDialog(task, currentUser) {
 
604
  _showConfirmDialog({
605
  scope: taskScopes.get(task),
606
  title: t('task.accept_work'),
@@ -610,15 +662,13 @@ function showAcceptConfirmDialog(task, currentUser) {
610
  confirmText: t('task.accept_work'),
611
  confirmStyle: 'background: #4CAF50',
612
  errorText: t('common.operation_failed'),
613
- onConfirm: async (content) => {
614
- if (!taskModalCurrent(content)) return;
615
- await api.acceptTask(task.id, true);
616
- if (!taskModalCurrent(content)) return;
617
  showToast(t('task.accept_success'), "success");
618
  closeTaskModal(content);
619
  const container = taskScopes.get(task)?.root;
620
  if (container && currentUser) {
621
- loadTaskDetail(container, task.id, currentUser);
622
  }
623
  }
624
  });
@@ -628,51 +678,36 @@ function showAcceptConfirmDialog(task, currentUser) {
628
  * ❌ 显示验收拒绝对话框
629
  */
630
  function showRejectDialog(task, currentUser) {
 
631
  const content = document.createElement("div");
632
  content.style.color = "#ccc";
633
  content.innerHTML = `
634
  <div style="margin-bottom: 15px;">
635
- <div style="color: #888; font-size: 12px; margin-bottom: 8px;">${t('task.reject_reason_prompt')}</div>
636
- <textarea id="reject-feedback" rows="4" placeholder="${t('task.reject_reason_placeholder')}" style="width: 100%; padding: 10px; background: var(--comfy-input-bg); border: 1px solid #444; border-radius: 6px; color: #fff; resize: none; box-sizing: border-box; font-size: 13px;"></textarea>
637
  </div>
638
  <div style="display: flex; gap: 10px;">
639
- <button id="reject-cancel" style="flex: 1; background: var(--comfy-input-bg); border: 1px solid #555; color: #fff; padding: 10px; border-radius: 6px; cursor: pointer;">${t('common.cancel')}</button>
640
- <button id="reject-confirm" style="flex: 1; background: #F44336; border: none; color: #fff; padding: 10px; border-radius: 6px; cursor: pointer; font-weight: bold;">${t('task.reject_work')}</button>
641
  </div>
642
  `;
643
 
644
- content.querySelector("#reject-cancel").onclick = () => closeTaskModal(content);
645
-
646
- content.querySelector("#reject-confirm").onclick = async () => {
647
- const feedback = content.querySelector("#reject-feedback").value.trim();
648
- if (!feedback) {
649
- showToast(t('task.reject_reason_required'), "warning");
650
- return;
651
- }
652
-
653
- const confirmBtn = content.querySelector("#reject-confirm");
654
- confirmBtn.disabled = true;
655
- confirmBtn.textContent = `⏳ ${t('common.submitting')}...`;
656
-
657
- try {
658
- if (!taskModalCurrent(content)) return;
659
- await api.acceptTask(task.id, false, feedback);
660
- if (!taskModalCurrent(content)) return;
661
- showToast(t('task.work_returned'), "success");
662
- closeTaskModal(content);
663
- // 重新加载任务详情
664
  const container = taskScopes.get(task)?.root;
665
- if (container && currentUser) {
666
- loadTaskDetail(container, task.id, currentUser);
667
- }
668
- } catch (err) {
669
- if (!taskModalCurrent(content)) return;
670
- showToast(t('common.operation_failed'), "error");
671
- confirmBtn.disabled = false;
672
- confirmBtn.textContent = t('task.reject_work');
673
  }
674
- };
675
-
676
  openTaskModal(taskScopes.get(task), t('task.reject_work'), content, { width: "400px" });
677
  }
678
 
@@ -680,7 +715,10 @@ function showRejectDialog(task, currentUser) {
680
  * 🗑️ 显示删除评论确认对话框
681
  */
682
  function showDeleteCommentConfirmDialog(taskId, comment, container, currentUser) {
 
 
683
  const content = document.createElement("div");
 
684
  content.style.color = "#ccc";
685
  content.innerHTML = `
686
  <div style="text-align: center;">
@@ -688,34 +726,43 @@ function showDeleteCommentConfirmDialog(taskId, comment, container, currentUser)
688
  <div style="color: #fff; font-size: 14px; margin-bottom: 8px;">${t('post.delete_comment_confirm')}</div>
689
  <div style="color: #888; font-size: 12px; margin-bottom: 20px;">${t('post.delete_comment_warning')}</div>
690
  <div style="display: flex; gap: 10px;">
691
- <button id="delete-comment-cancel" style="flex: 1; background: var(--comfy-input-bg); border: 1px solid #555; color: #fff; padding: 10px; border-radius: 6px; cursor: pointer;">${t('common.cancel')}</button>
692
- <button id="delete-comment-confirm" style="flex: 1; background: #F44336; border: none; color: #fff; padding: 10px; border-radius: 6px; cursor: pointer; font-weight: bold;">${t('common.delete')}</button>
693
  </div>
694
  </div>
695
  `;
696
 
697
- content.querySelector("#delete-comment-cancel").onclick = () => closeTaskModal(content);
698
-
699
- content.querySelector("#delete-comment-confirm").onclick = async () => {
700
- const confirmBtn = content.querySelector("#delete-comment-confirm");
 
 
 
701
  confirmBtn.disabled = true;
702
- confirmBtn.textContent = `⏳ ${t('common.deleting')}...`;
703
-
704
  try {
705
- if (!taskModalCurrent(content)) return;
706
- await api.deleteComment(taskId, comment.id);
707
- if (!taskModalCurrent(content)) return;
 
708
  showToast(t('post.delete_comment_success'), "success");
709
  closeTaskModal(content);
710
- loadTaskComments(container, taskId, currentUser);
711
  } catch (err) {
712
- if (!taskModalCurrent(content)) return;
713
- showToast(t('post.delete_comment_failed') + ": " + err.message, "error");
714
- confirmBtn.disabled = false;
715
- confirmBtn.textContent = t('common.delete');
 
 
 
 
 
716
  }
717
  };
718
-
719
  openTaskModal(container._taskScope, t('common.delete'), content, { width: "350px" });
720
  }
721
 
@@ -853,87 +900,97 @@ function bindActionEvents(container, task, currentUser) {
853
  * 📤 显示提交成果对话框
854
  */
855
  function showSubmitDialog(task, currentUser) {
 
 
856
  const content = document.createElement("div");
 
857
  content.style.color = "#ccc";
858
  content.innerHTML = `
859
  <div style="margin-bottom: 15px;">
860
- <label style="display: block; color: #ccc; font-size: 13px; margin-bottom: 5px;">${t('task.deliverable_images')} *</label>
861
- <input type="file" id="deliverable-files" multiple accept="image/*" style="width: 100%; padding: 8px; background: var(--comfy-input-bg); border: 1px solid #555; border-radius: 6px; color: #fff;">
862
- <div style="font-size: 11px; color: #888; margin-top: 4px;">${t('task.support_multiple_images')}</div>
863
  </div>
864
 
865
  <div style="margin-bottom: 20px;">
866
- <label style="display: block; color: #ccc; font-size: 13px; margin-bottom: 5px;">${t('task.note_optional')}</label>
867
  <textarea id="submit-note" rows="3" placeholder="${t('task.note_placeholder')}" style="width: 100%; padding: 8px; background: var(--comfy-input-bg); border: 1px solid #555; border-radius: 6px; color: #fff; resize: none; box-sizing: border-box;"></textarea>
868
  </div>
869
 
870
  <div style="display: flex; gap: 10px;">
871
- <button id="btn-cancel-modal" style="flex: 1; padding: 10px; background: var(--comfy-input-bg); border: 1px solid #555; color: #fff; border-radius: 6px; cursor: pointer;">${t('common.cancel')}</button>
872
- <button id="btn-confirm-submit" style="flex: 1; padding: 10px; background: #4CAF50; border: none; color: #fff; border-radius: 6px; cursor: pointer; font-weight: bold;">${t('common.confirm_submit')}</button>
873
  </div>
874
  `;
875
 
876
- content.querySelector("#btn-cancel-modal").onclick = () => closeTaskModal(content);
877
-
878
- content.querySelector("#btn-confirm-submit").onclick = async () => {
879
- const files = content.querySelector("#deliverable-files").files;
880
- const note = content.querySelector("#submit-note").value;
881
-
882
- if (!files || files.length === 0) {
 
 
 
 
 
 
 
 
 
883
  showToast(t('task.please_upload_deliverables'), "warning");
884
  return;
885
  }
886
-
 
 
 
 
 
 
 
 
887
  try {
888
- const confirmBtn = content.querySelector("#btn-confirm-submit");
889
- confirmBtn.disabled = true;
890
- confirmBtn.textContent = `⏳ ${t('common.uploading')}...`;
891
-
892
- // 上传交付图片
893
  const deliverables = [];
894
  for (let i = 0; i < files.length; i++) {
895
  confirmBtn.textContent = `⏳ ${t('common.upload_progress', { current: i + 1, total: files.length })}...`;
896
- try {
897
- if (!taskModalCurrent(content)) return;
898
- const processedFile = await compressImageForUpload(files[i]);
899
- if (!taskModalCurrent(content)) return;
900
- const res = await api.uploadFile(processedFile, "task");
901
- if (!taskModalCurrent(content)) return;
902
- deliverables.push(res.url);
903
- } catch (uploadErr) {
904
- console.error("图片上传失败:", uploadErr);
905
  }
 
906
  }
907
-
908
- if (deliverables.length === 0) {
909
- showToast(t('task.upload_failed_retry'), "error");
910
- confirmBtn.disabled = false;
911
- confirmBtn.textContent = t('common.confirm_submit');
912
- return;
913
- }
914
-
915
- // 提交成果
916
  confirmBtn.textContent = `⏳ ${t('common.submitting')}...`;
917
- if (!taskModalCurrent(content)) return;
918
- await api.submitTask(task.id, deliverables, note || null);
919
- if (!taskModalCurrent(content)) return;
920
-
 
 
921
  showToast(t('task.submit_success_waiting'), "success");
922
  closeTaskModal(content);
923
- // 重新加载任务详情
924
  const container = taskScopes.get(task)?.root;
925
- if (container && currentUser) {
926
- loadTaskDetail(container, task.id, currentUser);
927
- }
928
  } catch (err) {
929
- if (!taskModalCurrent(content)) return;
930
- showToast(t('task.submit_failed'), "error");
931
- const confirmBtn = content.querySelector("#btn-confirm-submit");
932
- confirmBtn.disabled = false;
933
- confirmBtn.textContent = t('common.confirm_submit');
 
 
 
 
 
 
934
  }
935
  };
936
-
937
  openTaskModal(taskScopes.get(task), `📤 ${t('task.submit_work')}`, content, { width: "450px" });
938
  }
939
 
@@ -982,45 +1039,63 @@ function bindTaskInteractionEvents(container, task, currentUser) {
982
  * 🎁 显示任务打赏对话框
983
  */
984
  function showTaskTipDialog(task, currentUser, container) {
 
 
985
  const content = document.createElement("div");
 
986
  content.style.color = "#ccc";
987
  content.innerHTML = `
988
  <div style="display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; margin-bottom: 15px;">
989
- <button class="tip-amount" data-amount="10" style="background: var(--comfy-input-bg); border: 1px solid #555; color: #fff; padding: 10px 20px; border-radius: 6px; cursor: pointer;">10 ${t('task.points')}</button>
990
- <button class="tip-amount" data-amount="50" style="background: var(--comfy-input-bg); border: 1px solid #555; color: #fff; padding: 10px 20px; border-radius: 6px; cursor: pointer;">50 ${t('task.points')}</button>
991
- <button class="tip-amount" data-amount="100" style="background: var(--comfy-input-bg); border: 1px solid #555; color: #fff; padding: 10px 20px; border-radius: 6px; cursor: pointer;">100 ${t('task.points')}</button>
992
- <button class="tip-amount" data-amount="500" style="background: var(--comfy-input-bg); border: 1px solid #555; color: #fff; padding: 10px 20px; border-radius: 6px; cursor: pointer;">500 ${t('task.points')}</button>
993
  </div>
994
  <div style="display: flex; gap: 10px;">
995
- <button id="tip-cancel" style="flex: 1; background: var(--comfy-input-bg); border: 1px solid #555; color: #fff; padding: 10px; border-radius: 6px; cursor: pointer;">${t('common.cancel')}</button>
996
  </div>
997
  `;
998
 
999
  openTaskModal(taskScopes.get(task), `🎁 ${t('post.tip_dialog_title')}`, content, { width: "300px" });
1000
 
1001
- // 取消按钮
1002
- content.querySelector("#tip-cancel").onclick = () => closeTaskModal(content);
1003
-
1004
- // 打赏金额按钮
1005
- content.querySelectorAll(".tip-amount").forEach(btn => {
 
1006
  btn.onclick = async () => {
1007
- const amount = parseInt(btn.dataset.amount);
 
 
 
 
 
 
 
 
 
 
 
 
 
1008
  try {
1009
- if (!taskModalCurrent(content)) return;
1010
- await api.tipTask(task.id, amount, false);
1011
- if (!taskModalCurrent(content)) return;
 
1012
  showToast(t('post.tip_success', { amount }), "success");
1013
  closeTaskModal(content);
1014
- // 刷新页面
1015
- loadTaskDetail(container.closest("div"), task.id, currentUser);
1016
- } catch (err) {
1017
- if (!taskModalCurrent(content)) return;
1018
- showToast(t('post.tip_failed') + ": " + err.message, "error");
 
 
 
 
1019
  }
1020
  };
1021
  });
1022
  }
1023
-
1024
  /**
1025
  * 🎁 渲染打赏榜单 - 使用公共工具函数
1026
  */
@@ -1044,16 +1119,36 @@ function _renderCommentAvatar(name, avatarUrl) {
1044
  */
1045
  async function loadTaskComments(container, taskId, currentUser) {
1046
  const commentsList = container.querySelector("#comments-list");
1047
- const scope = container._taskScope;
1048
- const isCurrent = () => (!scope || scope.isCurrent()) && commentsList?.isConnected
1049
- && container.querySelector("#comments-list") === commentsList;
1050
- if (!isCurrent()) return;
1051
  const commentInput = container.querySelector("#comment-input");
1052
  const sendBtn = container.querySelector("#btn-send-comment");
1053
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1054
  try {
1055
  const res = await api.getTaskComments(taskId, { componentId: scope?.componentId, noCache: true, timeout: API.BACKGROUND_TIMEOUT, retries: API.BACKGROUND_RETRIES });
1056
- if (!isCurrent()) return;
1057
  let comments = requireListResponseData(res);
1058
  comments = proxyImages(comments);
1059
 
@@ -1114,6 +1209,7 @@ async function loadTaskComments(container, taskId, currentUser) {
1114
  // 绑定删除评论按钮事件
1115
  commentsList.querySelectorAll(".btn-delete-comment").forEach(btn => {
1116
  btn.onclick = () => {
 
1117
  const idx = parseInt(btn.dataset.idx);
1118
  const comment = comments[idx];
1119
  if (!comment) return;
@@ -1122,7 +1218,7 @@ async function loadTaskComments(container, taskId, currentUser) {
1122
  });
1123
  }
1124
  } catch (err) {
1125
- if (!isCurrent()) return;
1126
  commentsList.innerHTML = `
1127
  <div style="text-align: center; padding: 20px; color: #F44336; font-size: 12px;">
1128
  ${t('post.comment_load_failed')}
@@ -1130,48 +1226,64 @@ async function loadTaskComments(container, taskId, currentUser) {
1130
  `;
1131
  }
1132
 
1133
- // 发送评论
1134
- if (sendBtn) {
 
 
 
 
 
 
 
 
 
1135
  sendBtn.onclick = async () => {
1136
- if (!isCurrent()) return;
1137
- if (!currentUser) {
1138
- showToast(t('auth.login_required'), "warning");
1139
- return;
1140
- }
1141
- const content = commentInput.value.trim();
1142
- if (!content) {
1143
  showToast(t('post.comment_required'), "warning");
1144
  return;
1145
  }
 
 
 
 
 
 
1146
  try {
1147
- sendBtn.disabled = true;
1148
- sendBtn.textContent = t('post.comment_sending');
1149
- await api.addTaskComment(taskId, content);
1150
  if (!isCurrent()) return;
 
 
 
1151
  commentInput.value = "";
1152
  showToast(t('post.comment_success'), "success");
1153
- loadTaskComments(container, taskId, currentUser);
 
1154
  } catch (err) {
1155
- if (isCurrent()) showToast(t('post.comment_failed'), "error");
 
 
 
1156
  } finally {
1157
  if (!isCurrent()) return;
1158
- sendBtn.disabled = false;
1159
- sendBtn.textContent = t('common.send');
 
 
1160
  }
1161
  };
1162
- }
1163
-
1164
- // 回车发送
1165
- if (commentInput) {
1166
- commentInput.onkeydown = (e) => {
1167
- if (e.key === "Enter" && !e.shiftKey) {
1168
- e.preventDefault();
1169
- sendBtn?.click();
1170
  }
1171
  };
1172
  }
1173
  }
1174
-
1175
  /**
1176
  * 🕐 格式化时间
1177
  */
@@ -1201,7 +1313,10 @@ function formatDeadline(deadline) {
1201
  * 🗑️ 显示删除任务确认对话框
1202
  */
1203
  function showDeleteTaskConfirm(task, contentEl) {
 
 
1204
  const content = document.createElement("div");
 
1205
  content.style.color = "#ccc";
1206
  content.innerHTML = `
1207
  <div style="text-align: center;">
@@ -1215,30 +1330,44 @@ function showDeleteTaskConfirm(task, contentEl) {
1215
  </div>
1216
  `;
1217
 
1218
- content.querySelector("#delete-cancel").onclick = () => closeTaskModal(content);
1219
-
1220
- content.querySelector("#delete-confirm").onclick = async () => {
 
 
 
 
 
 
 
 
 
 
 
 
1221
  try {
1222
- const confirmBtn = content.querySelector("#delete-confirm");
1223
- confirmBtn.disabled = true;
1224
- confirmBtn.textContent = t('common.deleting');
1225
-
1226
- if (!taskModalCurrent(content)) return;
1227
- await api.cancelTask(task.id);
1228
- if (!taskModalCurrent(content)) return;
1229
  showToast(t('task.delete_success'), "success");
1230
  closeTaskModal(content);
1231
-
1232
  window.dispatchEvent(new CustomEvent("comfy-route-back"));
1233
  } catch (err) {
1234
- if (!taskModalCurrent(content)) return;
1235
- showToast(t('task.delete_failed'), "error");
1236
- const confirmBtn = content.querySelector("#delete-confirm");
1237
- confirmBtn.disabled = false;
1238
- confirmBtn.textContent = t('common.delete');
 
 
 
 
1239
  }
1240
  };
1241
-
1242
  openTaskModal(taskScopes.get(task), t('common.delete'), content, { width: "300px" });
1243
  }
1244
 
@@ -1247,26 +1376,26 @@ function showDeleteTaskConfirm(task, contentEl) {
1247
  */
1248
  function showDisputeDialog(task, currentUser) {
1249
  const content = document.createElement("div");
1250
- content.style.color = "#ccc";
1251
  content.innerHTML = `
1252
- <div style="margin-bottom: 16px;">
1253
- <label style="display: block; color: #ccc; margin-bottom: 8px; font-size: 14px;">${t('dispute.reason')} <span style="color: #f44;">*</span></label>
1254
- <textarea id="dispute-reason" placeholder="${t('dispute.reason_placeholder')}" style="width: 100%; height: 120px; background: var(--comfy-input-bg); border: 1px solid #444; border-radius: 8px; color: #fff; padding: 12px; resize: none; box-sizing: border-box; font-size: 14px; line-height: 1.5;"></textarea>
1255
  </div>
1256
 
1257
- <div style="margin-bottom: 16px;">
1258
- <label style="display: block; color: #ccc; margin-bottom: 8px; font-size: 14px;">${t('dispute.evidence_optional')}</label>
1259
- <div id="evidence-upload-area" style="border: 2px dashed #444; border-radius: 8px; padding: 20px; text-align: center; cursor: pointer; color: #888; transition: all 0.2s;">
1260
  📷 ${t('dispute.click_upload_evidence')}
1261
- </div>
1262
- <input type="file" id="evidence-file-input" accept="image/*" multiple style="display: none;">
1263
- <div id="evidence-preview" style="display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px;"></div>
1264
  </div>
1265
 
1266
- <div style="background: rgba(255,152,0,0.1); border: 1px solid rgba(255,152,0,0.3); border-radius: 8px; padding: 12px; margin-bottom: 16px;">
1267
- <div style="color: #FF9800; font-size: 13px;">
1268
  ⚠️ ${t('dispute.notice_title')}:
1269
- <ul style="margin: 8px 0 0 16px; padding: 0; line-height: 1.6;">
1270
  <li>${t('dispute.notice_1')}</li>
1271
  <li>${t('dispute.notice_2')}</li>
1272
  <li>${t('dispute.notice_3')}</li>
@@ -1274,7 +1403,7 @@ function showDisputeDialog(task, currentUser) {
1274
  </div>
1275
  </div>
1276
 
1277
- <button id="btn-submit-dispute" style="width: 100%; padding: 14px; background: linear-gradient(135deg, #F44336, #D32F2F); border: none; border-radius: 8px; color: #fff; font-size: 15px; font-weight: bold; cursor: pointer;">
1278
  ${t('dispute.submit')}
1279
  </button>
1280
  `;
@@ -1284,12 +1413,35 @@ function showDisputeDialog(task, currentUser) {
1284
  const fileInput = content.querySelector("#evidence-file-input");
1285
  const preview = content.querySelector("#evidence-preview");
1286
  const evidenceFiles = [];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1287
 
1288
- uploadArea.onclick = () => fileInput.click();
1289
- uploadArea.onmouseover = () => { uploadArea.style.borderColor = "#2196F3"; uploadArea.style.color = "#2196F3"; };
1290
- uploadArea.onmouseout = () => { uploadArea.style.borderColor = "#444"; uploadArea.style.color = "#888"; };
1291
 
1292
  fileInput.onchange = (e) => {
 
1293
  for (const file of e.target.files) {
1294
  if (evidenceFiles.length >= 6) {
1295
  showToast(t('dispute.max_6_images'), "warning");
@@ -1297,19 +1449,27 @@ function showDisputeDialog(task, currentUser) {
1297
  }
1298
  evidenceFiles.push(file);
1299
  }
 
1300
  renderPreview();
1301
  };
1302
 
1303
  function renderPreview() {
1304
- preview.innerHTML = evidenceFiles.map((file, idx) => `
1305
- <div style="position: relative;">
1306
- <img src="${URL.createObjectURL(file)}" style="width: 60px; height: 60px; object-fit: cover; border-radius: 6px;">
1307
- <button data-idx="${idx}" style="position: absolute; top: -6px; right: -6px; width: 18px; height: 18px; background: #f44; color: #fff; border: none; border-radius: 50%; cursor: pointer; font-size: 12px; line-height: 18px;">×</button>
 
 
 
 
 
1308
  </div>
1309
- `).join("");
 
1310
 
1311
  preview.querySelectorAll("button").forEach(btn => {
1312
  btn.onclick = () => {
 
1313
  evidenceFiles.splice(parseInt(btn.dataset.idx), 1);
1314
  renderPreview();
1315
  };
@@ -1317,36 +1477,50 @@ function showDisputeDialog(task, currentUser) {
1317
  }
1318
 
1319
  // 提交申诉
1320
- content.querySelector("#btn-submit-dispute").onclick = async () => {
1321
- const reason = content.querySelector("#dispute-reason").value.trim();
 
1322
  if (!reason) {
 
 
1323
  showToast(t('dispute.please_enter_reason'), "warning");
1324
  return;
1325
  }
1326
 
1327
- const btn = content.querySelector("#btn-submit-dispute");
1328
- btn.disabled = true;
 
 
1329
  btn.textContent = `•• ${t('common.submitting')}...`;
1330
 
1331
  try {
1332
  // 上传证据图片
1333
  const evidenceUrls = [];
1334
- for (let i = 0; i < evidenceFiles.length; i++) {
1335
- btn.textContent = `•• ${t('common.upload_image_progress', { current: i + 1, total: evidenceFiles.length })}...`;
1336
  if (!taskModalCurrent(content)) return;
1337
- const processedFile = await compressImageForUpload(evidenceFiles[i]);
1338
  if (!taskModalCurrent(content)) return;
1339
  const res = await api.uploadFile(processedFile, "dispute");
1340
  if (!taskModalCurrent(content)) return;
 
 
 
1341
  evidenceUrls.push(res.url);
1342
  }
1343
 
1344
  // 提交申诉
1345
  btn.textContent = `•• ${t('dispute.submitting')}...`;
1346
  if (!taskModalCurrent(content)) return;
1347
- await api.disputeTask(task.id, reason, evidenceUrls);
1348
  if (!taskModalCurrent(content)) return;
1349
 
 
 
 
 
 
 
1350
  showToast(t('dispute.submit_success'), "success");
1351
  closeTaskModal(content);
1352
  // 重新加载任务详情
@@ -1357,8 +1531,9 @@ function showDisputeDialog(task, currentUser) {
1357
  } catch (err) {
1358
  if (!taskModalCurrent(content)) return;
1359
  showToast(t('dispute.submit_failed'), "error");
1360
- btn.disabled = false;
1361
  btn.textContent = t('dispute.submit');
 
 
1362
  }
1363
  };
1364
 
 
12
  import { createTaskViewScope, subscribeTaskSessionChanges } from "./任务视图生命周期.js";
13
  import { showToast } from "../components/UI交互提示组件.js";
14
  import { t } from "../components/用户体验增强.js";
15
+ import { API, CACHE, PLACEHOLDERS, TASK_LIMITS, getCachedProfile, getProfileWithSWR, isAdmin, IS_WEB_MODE } from "../core/全局配置.js";
16
  import { removeCache, findInListCache, requireListResponseData } from "../components/性能优化工具.js";
17
  import { globalModal } from "../components/全局弹窗管理器.js";
18
  import { compressImageForUpload } from "../market/发布内容_提交引擎.js";
 
23
  let taskViewSequence = 0;
24
  const taskScopes = new WeakMap();
25
  const modalScopes = new WeakMap();
26
+ const taskCommentStates = new WeakMap();
27
 
28
  function openTaskModal(scope, title, content, options) {
29
  if (scope && !scope.isCurrent()) return;
 
324
 
325
  <!-- 评论输入框 -->
326
  <div style="display: flex; gap: 10px; margin-bottom: 15px;">
327
+ <input type="text" id="comment-input" aria-label="${escapeHtml(t('post.comment_placeholder'))}" aria-required="true" required placeholder="${escapeHtml(t('post.comment_placeholder'))}" style="flex: 1; padding: 10px 12px; background: var(--comfy-input-bg); border: 1px solid #444; border-radius: 20px; color: #fff; font-size: 13px; outline: none;" onfocus="this.style.borderColor='#4CAF50'" onblur="this.style.borderColor='#444'">
328
+ <button type="button" id="btn-send-comment" style="background: #4CAF50; border: none; color: #fff; padding: 10px 20px; border-radius: 20px; cursor: pointer; font-size: 13px; font-weight: bold; transition: 0.2s;" onmouseover="this.style.background='#45a049'" onmouseout="this.style.background='#4CAF50'">
329
  ${t('common.send')}
330
  </button>
331
  </div>
 
436
  /**
437
  * 🔧 通用确认对话框工厂函数
438
  */
439
+ function _showConfirmDialog({ scope, title, icon, message, description, confirmText, confirmStyle, loadingText, errorText, width, onConfirm, onSuccess, validateResult = result => result?.status === "success" }) {
440
+ let phase = "idle";
441
  const content = document.createElement("div");
442
+ content._cleanup = () => { phase = "closed"; };
443
  content.style.color = "#ccc";
444
  // 🔒 XSS防护:message/description 可能嵌入其他用户的账号或昵称
445
  // (如 t('task.confirm_assign', { assignee })),写入 innerHTML 前必须转义。
 
456
  </div>
457
  `;
458
 
459
+ content.querySelector("#dialog-cancel").onclick = () => {
460
+ if (taskModalCurrent(content)) closeTaskModal(content);
461
+ };
462
+
463
+ const confirmBtn = content.querySelector("#dialog-confirm");
464
+ confirmBtn.onclick = async () => {
465
+ if (phase !== "idle" || !taskModalCurrent(content)) return;
466
+ phase = "submitting";
467
  confirmBtn.disabled = true;
468
  confirmBtn.textContent = loadingText || `⏳ ${t('common.confirming')}...`;
469
+ content.setAttribute("aria-busy", "true");
470
  try {
471
+ const result = await onConfirm(content);
472
+ if (!taskModalCurrent(content) || phase === "closed") return;
473
+ if (!validateResult(result)) throw new Error("Unconfirmed task operation");
474
+ phase = "committed";
475
+ await onSuccess(content);
476
  } catch (err) {
477
+ if (!taskModalCurrent(content) || phase === "closed" || phase === "committed") return;
478
+ const rejected = [400, 401, 403, 404, 409, 422, 429].includes(err?.status);
479
+ phase = rejected ? "idle" : "unconfirmed";
480
+ showToast(rejected ? errorText : t('task.operation_unconfirmed'), rejected ? "error" : "warning");
481
+ } finally {
482
+ if (!taskModalCurrent(content) || phase === "closed") return;
483
+ content.removeAttribute("aria-busy");
484
+ confirmBtn.disabled = phase !== "idle";
485
+ confirmBtn.textContent = phase === "unconfirmed" ? t('task.operation_pending') : confirmText;
486
  }
487
  };
488
+
489
  openTaskModal(scope, title, content, { width: width || "350px" });
490
  }
491
 
 
493
  * 👤 显示接受任务申请确认对话框
494
  */
495
  function showAssignConfirmDialog(task, assignee, contentEl, currentUser) {
496
+ const taskId = task.id;
497
  _showConfirmDialog({
498
  scope: taskScopes.get(task),
499
  title: t('task.choose_assignee'),
 
503
  confirmText: t('common.confirm'),
504
  confirmStyle: 'background: #4CAF50',
505
  errorText: t('task.assign_failed'),
506
+ onConfirm: () => api.assignTask(taskId, assignee),
507
+ onSuccess: async (content) => {
 
 
508
  showToast(t('task.assign_success'), "success");
509
  closeTaskModal(content);
510
+ loadTaskDetail(contentEl.closest("div"), taskId, currentUser);
511
  }
512
  });
513
  }
514
 
515
+ /** 申请与退回文字表单:固定输入快照,未知结果不重新开放写请求。 */
516
+ function bindTaskTextSubmission({ content, taskId, input, button, confirmText, errorText, successText, trimValue, requiredText, submit, onSuccess }) {
517
+ let phase = "idle";
518
+ content._cleanup = () => { phase = "closed"; };
519
+ input.addEventListener("input", () => {
520
+ if (phase === "idle" && taskModalCurrent(content)) input.removeAttribute("aria-invalid");
521
+ });
522
+ button.onclick = async () => {
523
+ if (phase !== "idle" || !taskModalCurrent(content)) return;
524
+ const value = trimValue ? input.value.trim() : input.value;
525
+ if (requiredText && !value) {
526
+ input.setAttribute("aria-invalid", "true");
527
+ input.focus();
528
+ showToast(requiredText, "warning");
529
+ return;
530
+ }
531
+ if (typeof taskId !== "string" || !taskId.trim()) {
532
+ showToast(errorText, "error");
533
+ return;
534
+ }
535
+ phase = "submitting";
536
+ button.disabled = true;
537
+ input.readOnly = true;
538
+ button.textContent = `⏳ ${t('common.submitting')}...`;
539
+ content.setAttribute("aria-busy", "true");
540
+ try {
541
+ const result = await submit(taskId, value);
542
+ if (!taskModalCurrent(content) || phase === "closed") return;
543
+ if (result?.status !== "success") throw new Error("Unconfirmed task text submission");
544
+ phase = "committed";
545
+ showToast(successText, "success");
546
+ closeTaskModal(content);
547
+ await onSuccess();
548
+ } catch (err) {
549
+ if (!taskModalCurrent(content) || phase === "closed" || phase === "committed") return;
550
+ const rejected = [400, 401, 403, 404, 409, 422, 429].includes(err?.status);
551
+ phase = rejected ? "idle" : "unconfirmed";
552
+ showToast(rejected ? errorText : t('task.operation_unconfirmed'), rejected ? "error" : "warning");
553
+ } finally {
554
+ if (!taskModalCurrent(content) || phase === "closed") return;
555
+ content.removeAttribute("aria-busy");
556
+ button.disabled = phase !== "idle";
557
+ input.readOnly = phase !== "idle";
558
+ button.textContent = phase === "unconfirmed" ? t('task.operation_pending') : confirmText;
559
+ }
560
+ };
561
+ }
562
+
563
  /**
564
  * 🙋 显示申请接单对话框
565
  */
566
  function showApplyDialog(task, currentUser) {
567
+ const taskId = task.id;
568
  const content = document.createElement("div");
569
  content.style.color = "#ccc";
570
  content.innerHTML = `
571
  <div style="margin-bottom: 15px;">
572
+ <label for="apply-message" style="display: block; color: #888; font-size: 12px; margin-bottom: 8px;">${t('task.apply_message_prompt')}</label>
573
  <textarea id="apply-message" rows="4" placeholder="${t('task.apply_message_placeholder')}" style="width: 100%; padding: 10px; background: var(--comfy-input-bg); border: 1px solid #444; border-radius: 6px; color: #fff; resize: none; box-sizing: border-box; font-size: 13px;"></textarea>
574
  </div>
575
  <div style="display: flex; gap: 10px;">
576
+ <button type="button" id="apply-cancel" style="flex: 1; background: var(--comfy-input-bg); border: 1px solid #555; color: #fff; padding: 10px; border-radius: 6px; cursor: pointer;">${t('common.cancel')}</button>
577
+ <button type="button" id="apply-confirm" style="flex: 1; background: #FF9800; border: none; color: #fff; padding: 10px; border-radius: 6px; cursor: pointer; font-weight: bold;">${t('common.confirm_submit')}</button>
578
  </div>
579
  `;
580
 
581
+ content.querySelector("#apply-cancel").onclick = () => {
582
+ if (taskModalCurrent(content)) closeTaskModal(content);
583
+ };
584
+ bindTaskTextSubmission({
585
+ content, taskId,
586
+ input: content.querySelector("#apply-message"),
587
+ button: content.querySelector("#apply-confirm"),
588
+ confirmText: t('common.confirm_submit'), errorText: t('task.apply_failed'), successText: t('task.apply_success'),
589
+ trimValue: false, requiredText: null,
590
+ submit: (id, value) => api.applyTask(id, value),
591
+ onSuccess: () => {
 
 
 
 
592
  const container = taskScopes.get(task)?.root;
593
+ if (container && currentUser) return loadTaskDetail(container, taskId, currentUser);
 
 
 
 
 
 
 
594
  }
595
+ });
596
+
597
  openTaskModal(taskScopes.get(task), t('task.apply_task'), content, { width: "400px" });
598
  }
599
 
 
601
  * ↩️ 显示撤回申请确认对话框
602
  */
603
  function showCancelApplyConfirmDialog(task, currentUser) {
604
+ const taskId = task.id;
605
  _showConfirmDialog({
606
  scope: taskScopes.get(task),
607
  title: t('task.cancel_apply'),
 
611
  confirmText: t('common.confirm'),
612
  confirmStyle: 'background: #FF9800',
613
  errorText: t('task.cancel_apply_failed'),
614
+ onConfirm: () => api.cancelApplyTask(taskId),
615
+ onSuccess: async (content) => {
 
 
616
  showToast(t('task.apply_cancelled'), "success");
617
  closeTaskModal(content);
618
  const container = taskScopes.get(task)?.root;
619
  if (container && currentUser) {
620
+ loadTaskDetail(container, taskId, currentUser);
621
  }
622
  }
623
  });
 
627
  * ❌ 显示取消任务确认对话框
628
  */
629
  function showCancelTaskConfirmDialog(task) {
630
+ const taskId = task.id;
631
  _showConfirmDialog({
632
  scope: taskScopes.get(task),
633
  title: t('task.cancel_task'),
 
638
  confirmStyle: 'background: #F44336',
639
  loadingText: `⏳ ${t('common.cancelling')}...`,
640
  errorText: t('task.cancel_task_failed'),
641
+ onConfirm: () => api.cancelTask(taskId),
642
+ validateResult: result => result?.status === "success" && Number.isSafeInteger(result.refunded_amount) && result.refunded_amount >= 0,
643
+ onSuccess: async (content) => {
 
644
  showToast(t('task.task_cancelled'), "success");
645
  closeTaskModal(content);
646
  window.dispatchEvent(new CustomEvent("comfy-route-back"));
 
652
  * ✅ 显示验收通过确认对话框
653
  */
654
  function showAcceptConfirmDialog(task, currentUser) {
655
+ const taskId = task.id;
656
  _showConfirmDialog({
657
  scope: taskScopes.get(task),
658
  title: t('task.accept_work'),
 
662
  confirmText: t('task.accept_work'),
663
  confirmStyle: 'background: #4CAF50',
664
  errorText: t('common.operation_failed'),
665
+ onConfirm: () => api.acceptTask(taskId, true),
666
+ onSuccess: async (content) => {
 
 
667
  showToast(t('task.accept_success'), "success");
668
  closeTaskModal(content);
669
  const container = taskScopes.get(task)?.root;
670
  if (container && currentUser) {
671
+ loadTaskDetail(container, taskId, currentUser);
672
  }
673
  }
674
  });
 
678
  * ❌ 显示验收拒绝对话框
679
  */
680
  function showRejectDialog(task, currentUser) {
681
+ const taskId = task.id;
682
  const content = document.createElement("div");
683
  content.style.color = "#ccc";
684
  content.innerHTML = `
685
  <div style="margin-bottom: 15px;">
686
+ <label for="reject-feedback" style="display: block; color: #888; font-size: 12px; margin-bottom: 8px;">${t('task.reject_reason_prompt')}</label>
687
+ <textarea aria-required="true" id="reject-feedback" rows="4" placeholder="${t('task.reject_reason_placeholder')}" style="width: 100%; padding: 10px; background: var(--comfy-input-bg); border: 1px solid #444; border-radius: 6px; color: #fff; resize: none; box-sizing: border-box; font-size: 13px;"></textarea>
688
  </div>
689
  <div style="display: flex; gap: 10px;">
690
+ <button type="button" id="reject-cancel" style="flex: 1; background: var(--comfy-input-bg); border: 1px solid #555; color: #fff; padding: 10px; border-radius: 6px; cursor: pointer;">${t('common.cancel')}</button>
691
+ <button type="button" id="reject-confirm" style="flex: 1; background: #F44336; border: none; color: #fff; padding: 10px; border-radius: 6px; cursor: pointer; font-weight: bold;">${t('task.reject_work')}</button>
692
  </div>
693
  `;
694
 
695
+ content.querySelector("#reject-cancel").onclick = () => {
696
+ if (taskModalCurrent(content)) closeTaskModal(content);
697
+ };
698
+ bindTaskTextSubmission({
699
+ content, taskId,
700
+ input: content.querySelector("#reject-feedback"),
701
+ button: content.querySelector("#reject-confirm"),
702
+ confirmText: t('task.reject_work'), errorText: t('common.operation_failed'), successText: t('task.work_returned'),
703
+ trimValue: true, requiredText: t('task.reject_reason_required'),
704
+ submit: (id, value) => api.acceptTask(id, false, value),
705
+ onSuccess: () => {
 
 
 
 
 
 
 
 
 
706
  const container = taskScopes.get(task)?.root;
707
+ if (container && currentUser) return loadTaskDetail(container, taskId, currentUser);
 
 
 
 
 
 
 
708
  }
709
+ });
710
+
711
  openTaskModal(taskScopes.get(task), t('task.reject_work'), content, { width: "400px" });
712
  }
713
 
 
715
  * 🗑️ 显示删除评论确认对话框
716
  */
717
  function showDeleteCommentConfirmDialog(taskId, comment, container, currentUser) {
718
+ const commentId = comment.id;
719
+ let phase = "idle";
720
  const content = document.createElement("div");
721
+ content._cleanup = () => { phase = "closed"; };
722
  content.style.color = "#ccc";
723
  content.innerHTML = `
724
  <div style="text-align: center;">
 
726
  <div style="color: #fff; font-size: 14px; margin-bottom: 8px;">${t('post.delete_comment_confirm')}</div>
727
  <div style="color: #888; font-size: 12px; margin-bottom: 20px;">${t('post.delete_comment_warning')}</div>
728
  <div style="display: flex; gap: 10px;">
729
+ <button type="button" id="delete-comment-cancel" style="flex: 1; background: var(--comfy-input-bg); border: 1px solid #555; color: #fff; padding: 10px; border-radius: 6px; cursor: pointer;">${t('common.cancel')}</button>
730
+ <button type="button" id="delete-comment-confirm" style="flex: 1; background: #F44336; border: none; color: #fff; padding: 10px; border-radius: 6px; cursor: pointer; font-weight: bold;">${t('common.delete')}</button>
731
  </div>
732
  </div>
733
  `;
734
 
735
+ content.querySelector("#delete-comment-cancel").onclick = () => {
736
+ if (taskModalCurrent(content)) closeTaskModal(content);
737
+ };
738
+ const confirmBtn = content.querySelector("#delete-comment-confirm");
739
+ confirmBtn.onclick = async () => {
740
+ if (phase !== "idle" || !taskModalCurrent(content)) return;
741
+ phase = "submitting";
742
  confirmBtn.disabled = true;
743
+ confirmBtn.textContent = t('common.deleting');
744
+ content.setAttribute("aria-busy", "true");
745
  try {
746
+ const result = await api.deleteComment(taskId, commentId);
747
+ if (!taskModalCurrent(content) || phase === "closed") return;
748
+ if (result?.status !== "success") throw new Error("Unconfirmed task comment deletion");
749
+ phase = "committed";
750
  showToast(t('post.delete_comment_success'), "success");
751
  closeTaskModal(content);
752
+ await loadTaskComments(container, taskId, currentUser);
753
  } catch (err) {
754
+ if (!taskModalCurrent(content) || phase === "closed" || phase === "committed") return;
755
+ const rejected = [400, 401, 403, 404, 409, 422, 429].includes(err?.status);
756
+ phase = rejected ? "idle" : "unconfirmed";
757
+ showToast(t(rejected ? 'post.delete_comment_failed' : 'task.comment_unconfirmed'), rejected ? "error" : "warning");
758
+ } finally {
759
+ if (!taskModalCurrent(content) || phase === "closed") return;
760
+ content.removeAttribute("aria-busy");
761
+ confirmBtn.disabled = phase !== "idle";
762
+ confirmBtn.textContent = t(phase === "unconfirmed" ? 'task.comment_pending' : 'common.delete');
763
  }
764
  };
765
+
766
  openTaskModal(container._taskScope, t('common.delete'), content, { width: "350px" });
767
  }
768
 
 
900
  * 📤 显示提交成果对话框
901
  */
902
  function showSubmitDialog(task, currentUser) {
903
+ const taskId = task.id;
904
+ let phase = "idle";
905
  const content = document.createElement("div");
906
+ content._cleanup = () => { phase = "closed"; };
907
  content.style.color = "#ccc";
908
  content.innerHTML = `
909
  <div style="margin-bottom: 15px;">
910
+ <label for="deliverable-files" style="display: block; color: #ccc; font-size: 13px; margin-bottom: 5px;">${t('task.deliverable_images')} <span aria-hidden="true">*</span></label>
911
+ <input type="file" aria-required="true" aria-describedby="deliverable-hint" id="deliverable-files" multiple accept="image/*" style="width: 100%; padding: 8px; background: var(--comfy-input-bg); border: 1px solid #555; border-radius: 6px; color: #fff;">
912
+ <div id="deliverable-hint" style="font-size: 11px; color: #888; margin-top: 4px;">${t('task.support_multiple_images')}</div>
913
  </div>
914
 
915
  <div style="margin-bottom: 20px;">
916
+ <label for="submit-note" style="display: block; color: #ccc; font-size: 13px; margin-bottom: 5px;">${t('task.note_optional')}</label>
917
  <textarea id="submit-note" rows="3" placeholder="${t('task.note_placeholder')}" style="width: 100%; padding: 8px; background: var(--comfy-input-bg); border: 1px solid #555; border-radius: 6px; color: #fff; resize: none; box-sizing: border-box;"></textarea>
918
  </div>
919
 
920
  <div style="display: flex; gap: 10px;">
921
+ <button type="button" id="btn-cancel-modal" style="flex: 1; padding: 10px; background: var(--comfy-input-bg); border: 1px solid #555; color: #fff; border-radius: 6px; cursor: pointer;">${t('common.cancel')}</button>
922
+ <button type="button" id="btn-confirm-submit" style="flex: 1; padding: 10px; background: #4CAF50; border: none; color: #fff; border-radius: 6px; cursor: pointer; font-weight: bold;">${t('common.confirm_submit')}</button>
923
  </div>
924
  `;
925
 
926
+ content.querySelector("#btn-cancel-modal").onclick = () => {
927
+ if (taskModalCurrent(content)) closeTaskModal(content);
928
+ };
929
+ const filesInput = content.querySelector("#deliverable-files");
930
+ const noteInput = content.querySelector("#submit-note");
931
+ const confirmBtn = content.querySelector("#btn-confirm-submit");
932
+ filesInput.addEventListener("change", () => {
933
+ if (phase === "idle" && taskModalCurrent(content)) filesInput.removeAttribute("aria-invalid");
934
+ });
935
+ confirmBtn.onclick = async () => {
936
+ if (phase !== "idle" || !taskModalCurrent(content)) return;
937
+ const files = Array.from(filesInput.files || []);
938
+ const note = noteInput.value;
939
+ if (!files.length) {
940
+ filesInput.setAttribute("aria-invalid", "true");
941
+ filesInput.focus();
942
  showToast(t('task.please_upload_deliverables'), "warning");
943
  return;
944
  }
945
+ if (typeof taskId !== "string" || !taskId.trim()) {
946
+ showToast(t('task.submit_failed'), "error");
947
+ return;
948
+ }
949
+ phase = "submitting";
950
+ filesInput.disabled = confirmBtn.disabled = true;
951
+ noteInput.readOnly = true;
952
+ content.setAttribute("aria-busy", "true");
953
+ let saveAttempted = false;
954
  try {
 
 
 
 
 
955
  const deliverables = [];
956
  for (let i = 0; i < files.length; i++) {
957
  confirmBtn.textContent = `⏳ ${t('common.upload_progress', { current: i + 1, total: files.length })}...`;
958
+ if (!taskModalCurrent(content) || phase === "closed") return;
959
+ const processedFile = await compressImageForUpload(files[i]);
960
+ if (!taskModalCurrent(content) || phase === "closed") return;
961
+ const response = await api.uploadFile(processedFile, "task");
962
+ if (!taskModalCurrent(content) || phase === "closed") return;
963
+ if (response?.status !== "success" || typeof response.url !== "string" || !response.url.trim()) {
964
+ throw new Error("Unconfirmed task image upload");
 
 
965
  }
966
+ deliverables.push(response.url);
967
  }
 
 
 
 
 
 
 
 
 
968
  confirmBtn.textContent = `⏳ ${t('common.submitting')}...`;
969
+ if (!taskModalCurrent(content) || phase === "closed") return;
970
+ saveAttempted = true;
971
+ const result = await api.submitTask(taskId, deliverables, note || null);
972
+ if (!taskModalCurrent(content) || phase === "closed") return;
973
+ if (result?.status !== "success") throw new Error("Unconfirmed task delivery");
974
+ phase = "committed";
975
  showToast(t('task.submit_success_waiting'), "success");
976
  closeTaskModal(content);
 
977
  const container = taskScopes.get(task)?.root;
978
+ if (container && currentUser) await loadTaskDetail(container, taskId, currentUser);
 
 
979
  } catch (err) {
980
+ if (!taskModalCurrent(content) || phase === "closed" || phase === "committed") return;
981
+ const unknown = saveAttempted && ![400, 401, 403, 404, 409, 422, 429].includes(err?.status);
982
+ phase = unknown ? "unconfirmed" : "idle";
983
+ showToast(t(unknown ? 'task.operation_unconfirmed' : (saveAttempted ? 'task.submit_failed' : 'task.upload_failed_retry')),
984
+ unknown ? "warning" : "error");
985
+ } finally {
986
+ if (!taskModalCurrent(content) || phase === "closed") return;
987
+ content.removeAttribute("aria-busy");
988
+ filesInput.disabled = confirmBtn.disabled = phase !== "idle";
989
+ noteInput.readOnly = phase !== "idle";
990
+ confirmBtn.textContent = t(phase === "unconfirmed" ? 'task.operation_pending' : 'common.confirm_submit');
991
  }
992
  };
993
+
994
  openTaskModal(taskScopes.get(task), `📤 ${t('task.submit_work')}`, content, { width: "450px" });
995
  }
996
 
 
1039
  * 🎁 显示任务打赏对话框
1040
  */
1041
  function showTaskTipDialog(task, currentUser, container) {
1042
+ const taskId = task.id;
1043
+ let phase = "idle";
1044
  const content = document.createElement("div");
1045
+ content._cleanup = () => { phase = "closed"; };
1046
  content.style.color = "#ccc";
1047
  content.innerHTML = `
1048
  <div style="display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; margin-bottom: 15px;">
1049
+ ${TASK_LIMITS.TIP_AMOUNTS.map(amount => `<button type="button" class="tip-amount" data-amount="${amount}" style="background: var(--comfy-input-bg); border: 1px solid #555; color: #fff; padding: 10px 20px; border-radius: 6px; cursor: pointer;">${amount} ${t('task.points')}</button>`).join("")}
 
 
 
1050
  </div>
1051
  <div style="display: flex; gap: 10px;">
1052
+ <button type="button" id="tip-cancel" style="flex: 1; background: var(--comfy-input-bg); border: 1px solid #555; color: #fff; padding: 10px; border-radius: 6px; cursor: pointer;">${t('common.cancel')}</button>
1053
  </div>
1054
  `;
1055
 
1056
  openTaskModal(taskScopes.get(task), `🎁 ${t('post.tip_dialog_title')}`, content, { width: "300px" });
1057
 
1058
+ content.querySelector("#tip-cancel").onclick = () => {
1059
+ if (taskModalCurrent(content)) closeTaskModal(content);
1060
+ };
1061
+ const amountButtons = Array.from(content.querySelectorAll(".tip-amount"));
1062
+ amountButtons.forEach(btn => {
1063
+ const preset = btn.dataset.amount;
1064
  btn.onclick = async () => {
1065
+ if (phase !== "idle" || !taskModalCurrent(content)) return;
1066
+ const rawAmount = btn.dataset.amount;
1067
+ const amount = typeof rawAmount === "string" && /^\d+$/.test(rawAmount) ? Number(rawAmount) : NaN;
1068
+ if (rawAmount !== preset || !Number.isSafeInteger(amount) || !TASK_LIMITS.TIP_AMOUNTS.includes(amount)) {
1069
+ showToast(t('tip.integer_amount'), "warning");
1070
+ return;
1071
+ }
1072
+ if (typeof taskId !== "string" || !taskId.trim()) {
1073
+ showToast(t('post.tip_failed'), "error");
1074
+ return;
1075
+ }
1076
+ phase = "submitting";
1077
+ amountButtons.forEach(button => { button.disabled = true; });
1078
+ content.setAttribute("aria-busy", "true");
1079
  try {
1080
+ const result = await api.tipTask(taskId, amount, false);
1081
+ if (!taskModalCurrent(content) || phase === "closed") return;
1082
+ if (result?.status !== "success") throw new Error("Unconfirmed task tip");
1083
+ phase = "committed";
1084
  showToast(t('post.tip_success', { amount }), "success");
1085
  closeTaskModal(content);
1086
+ await loadTaskDetail(container.closest("div"), taskId, currentUser);
1087
+ } catch (_) {
1088
+ if (!taskModalCurrent(content) || phase === "closed" || phase === "committed") return;
1089
+ phase = "unconfirmed";
1090
+ showToast(t('tip.unknown_result'), "warning");
1091
+ } finally {
1092
+ if (!taskModalCurrent(content) || phase === "closed") return;
1093
+ content.removeAttribute("aria-busy");
1094
+ amountButtons.forEach(button => { button.disabled = phase !== "idle"; });
1095
  }
1096
  };
1097
  });
1098
  }
 
1099
  /**
1100
  * 🎁 渲染打赏榜单 - 使用公共工具函数
1101
  */
 
1119
  */
1120
  async function loadTaskComments(container, taskId, currentUser) {
1121
  const commentsList = container.querySelector("#comments-list");
 
 
 
 
1122
  const commentInput = container.querySelector("#comment-input");
1123
  const sendBtn = container.querySelector("#btn-send-comment");
1124
+ const scope = container._taskScope;
1125
+ const account = typeof currentUser === "string" ? currentUser : currentUser?.account;
1126
+ let state = taskCommentStates.get(container);
1127
+ if (!state || state.list !== commentsList || state.input !== commentInput || state.button !== sendBtn
1128
+ || state.taskId !== taskId || state.account !== account || state.scope !== scope) {
1129
+ state?.dispose();
1130
+ state = { list: commentsList, input: commentInput, button: sendBtn, taskId, account, scope, phase: "idle", generation: 0, bound: false };
1131
+ state.dispose = () => {
1132
+ state.phase = "closed";
1133
+ state.release?.();
1134
+ if (commentInput) { commentInput.value = ""; commentInput.readOnly = true; }
1135
+ if (sendBtn) { sendBtn.disabled = true; sendBtn.removeAttribute("aria-busy"); }
1136
+ if (taskCommentStates.get(container) === state) taskCommentStates.delete(container);
1137
+ };
1138
+ taskCommentStates.set(container, state);
1139
+ state.release = scope?.addCleanup?.(state.dispose);
1140
+ }
1141
+ const isCurrent = () => state.phase !== "closed" && taskCommentStates.get(container) === state
1142
+ && (!scope || scope.isCurrent()) && commentsList?.isConnected
1143
+ && container.querySelector("#comments-list") === commentsList
1144
+ && container.querySelector("#comment-input") === commentInput && container.querySelector("#btn-send-comment") === sendBtn;
1145
+ if (!isCurrent()) return;
1146
+ const generation = ++state.generation;
1147
+ const readCurrent = () => isCurrent() && generation === state.generation;
1148
+
1149
  try {
1150
  const res = await api.getTaskComments(taskId, { componentId: scope?.componentId, noCache: true, timeout: API.BACKGROUND_TIMEOUT, retries: API.BACKGROUND_RETRIES });
1151
+ if (!readCurrent()) return;
1152
  let comments = requireListResponseData(res);
1153
  comments = proxyImages(comments);
1154
 
 
1209
  // 绑定删除评论按钮事件
1210
  commentsList.querySelectorAll(".btn-delete-comment").forEach(btn => {
1211
  btn.onclick = () => {
1212
+ if (!readCurrent() || !btn.isConnected) return;
1213
  const idx = parseInt(btn.dataset.idx);
1214
  const comment = comments[idx];
1215
  if (!comment) return;
 
1218
  });
1219
  }
1220
  } catch (err) {
1221
+ if (!readCurrent()) return;
1222
  commentsList.innerHTML = `
1223
  <div style="text-align: center; padding: 20px; color: #F44336; font-size: 12px;">
1224
  ${t('post.comment_load_failed')}
 
1226
  `;
1227
  }
1228
 
1229
+ // 同一组输入/按钮只绑定一次;列表刷新不重新开放在途或未知提交。
1230
+ if (sendBtn && commentInput && !state.bound && isCurrent()) {
1231
+ state.bound = true;
1232
+ sendBtn.disabled = false;
1233
+ sendBtn.removeAttribute("aria-busy");
1234
+ sendBtn.textContent = t('common.send');
1235
+ commentInput.readOnly = false;
1236
+ commentInput.removeAttribute("aria-invalid");
1237
+ commentInput.oninput = () => {
1238
+ if (state.phase === "idle" && isCurrent() && commentInput.value.trim()) commentInput.removeAttribute("aria-invalid");
1239
+ };
1240
  sendBtn.onclick = async () => {
1241
+ if (state.phase !== "idle" || !isCurrent()) return;
1242
+ if (!account) { showToast(t('auth.login_required'), "warning"); return; }
1243
+ const value = commentInput.value.trim();
1244
+ if (!value) {
1245
+ commentInput.setAttribute("aria-invalid", "true");
1246
+ commentInput.focus();
 
1247
  showToast(t('post.comment_required'), "warning");
1248
  return;
1249
  }
1250
+ commentInput.removeAttribute("aria-invalid");
1251
+ state.phase = "submitting";
1252
+ sendBtn.disabled = true;
1253
+ sendBtn.setAttribute("aria-busy", "true");
1254
+ commentInput.readOnly = true;
1255
+ sendBtn.textContent = t('post.comment_sending');
1256
  try {
1257
+ const result = await api.addTaskComment(taskId, value);
 
 
1258
  if (!isCurrent()) return;
1259
+ if (result?.status !== "success" || typeof result.data?.id !== "string" || !result.data.id.trim()
1260
+ || result.data.author !== account || result.data.content !== value) throw new Error("Unconfirmed task comment");
1261
+ state.phase = "committed";
1262
  commentInput.value = "";
1263
  showToast(t('post.comment_success'), "success");
1264
+ await loadTaskComments(container, taskId, currentUser);
1265
+ if (isCurrent()) state.phase = "idle";
1266
  } catch (err) {
1267
+ if (!isCurrent() || state.phase === "committed") return;
1268
+ const rejected = [400, 401, 403, 404, 409, 422, 429].includes(err?.status);
1269
+ state.phase = rejected ? "idle" : "unconfirmed";
1270
+ showToast(t(rejected ? 'post.comment_failed' : 'task.comment_unconfirmed'), rejected ? "error" : "warning");
1271
  } finally {
1272
  if (!isCurrent()) return;
1273
+ sendBtn.removeAttribute("aria-busy");
1274
+ sendBtn.disabled = state.phase !== "idle";
1275
+ commentInput.readOnly = state.phase !== "idle";
1276
+ sendBtn.textContent = t(state.phase === "unconfirmed" ? 'task.comment_pending' : 'common.send');
1277
  }
1278
  };
1279
+ commentInput.onkeydown = event => {
1280
+ if (event.key === "Enter" && !event.shiftKey && !event.isComposing && event.keyCode !== 229 && !event.repeat) {
1281
+ event.preventDefault();
1282
+ sendBtn.click();
 
 
 
 
1283
  }
1284
  };
1285
  }
1286
  }
 
1287
  /**
1288
  * 🕐 格式化时间
1289
  */
 
1313
  * 🗑️ 显示删除任务确认对话框
1314
  */
1315
  function showDeleteTaskConfirm(task, contentEl) {
1316
+ const taskId = task.id;
1317
+ let phase = "idle";
1318
  const content = document.createElement("div");
1319
+ content._cleanup = () => { phase = "closed"; };
1320
  content.style.color = "#ccc";
1321
  content.innerHTML = `
1322
  <div style="text-align: center;">
 
1330
  </div>
1331
  `;
1332
 
1333
+ content.querySelector("#delete-cancel").onclick = () => {
1334
+ if (taskModalCurrent(content)) closeTaskModal(content);
1335
+ };
1336
+
1337
+ const confirmBtn = content.querySelector("#delete-confirm");
1338
+ confirmBtn.onclick = async () => {
1339
+ if (phase !== "idle" || !taskModalCurrent(content)) return;
1340
+ if (typeof taskId !== "string" || !taskId.trim()) {
1341
+ showToast(t('task.delete_failed'), "error");
1342
+ return;
1343
+ }
1344
+ phase = "submitting";
1345
+ confirmBtn.disabled = true;
1346
+ confirmBtn.textContent = t('common.deleting');
1347
+ content.setAttribute("aria-busy", "true");
1348
  try {
1349
+ const result = await api.cancelTask(taskId);
1350
+ if (!taskModalCurrent(content) || phase === "closed") return;
1351
+ if (result?.status !== "success" || !Number.isSafeInteger(result.refunded_amount) || result.refunded_amount < 0) {
1352
+ throw new Error("Unconfirmed task cancellation");
1353
+ }
1354
+ phase = "committed";
 
1355
  showToast(t('task.delete_success'), "success");
1356
  closeTaskModal(content);
 
1357
  window.dispatchEvent(new CustomEvent("comfy-route-back"));
1358
  } catch (err) {
1359
+ if (!taskModalCurrent(content) || phase === "closed" || phase === "committed") return;
1360
+ const rejected = [400, 401, 403, 404, 409, 422, 429].includes(err?.status);
1361
+ phase = rejected ? "idle" : "unconfirmed";
1362
+ showToast(t(rejected ? 'task.delete_failed' : 'task.delete_unconfirmed'), rejected ? "error" : "warning");
1363
+ } finally {
1364
+ if (!taskModalCurrent(content) || phase === "closed") return;
1365
+ content.removeAttribute("aria-busy");
1366
+ confirmBtn.disabled = phase !== "idle";
1367
+ confirmBtn.textContent = t(phase === "unconfirmed" ? 'task.delete_pending' : 'common.delete');
1368
  }
1369
  };
1370
+
1371
  openTaskModal(taskScopes.get(task), t('common.delete'), content, { width: "300px" });
1372
  }
1373
 
 
1376
  */
1377
  function showDisputeDialog(task, currentUser) {
1378
  const content = document.createElement("div");
1379
+ content.className = "task-dispute";
1380
  content.innerHTML = `
1381
+ <div class="task-dispute__field">
1382
+ <label for="dispute-reason" class="task-dispute__label">${t('dispute.reason')} <span aria-hidden="true" class="task-dispute__required">*</span></label>
1383
+ <textarea id="dispute-reason" aria-required="true" placeholder="${t('dispute.reason_placeholder')}" class="task-dispute__reason"></textarea>
1384
  </div>
1385
 
1386
+ <div class="task-dispute__field">
1387
+ <label for="evidence-file-input" class="task-dispute__label">${t('dispute.evidence_optional')}</label>
1388
+ <button type="button" id="evidence-upload-area" class="task-dispute__upload">
1389
  📷 ${t('dispute.click_upload_evidence')}
1390
+ </button>
1391
+ <input type="file" aria-label="${t('dispute.evidence_optional')}" id="evidence-file-input" accept="image/*" multiple class="task-dispute__file">
1392
+ <div id="evidence-preview" class="task-dispute__preview"></div>
1393
  </div>
1394
 
1395
+ <div class="task-dispute__notice">
1396
+ <div class="task-dispute__notice-copy">
1397
  ⚠️ ${t('dispute.notice_title')}:
1398
+ <ul class="task-dispute__notice-list">
1399
  <li>${t('dispute.notice_1')}</li>
1400
  <li>${t('dispute.notice_2')}</li>
1401
  <li>${t('dispute.notice_3')}</li>
 
1403
  </div>
1404
  </div>
1405
 
1406
+ <button id="btn-submit-dispute" class="task-dispute__submit">
1407
  ${t('dispute.submit')}
1408
  </button>
1409
  `;
 
1413
  const fileInput = content.querySelector("#evidence-file-input");
1414
  const preview = content.querySelector("#evidence-preview");
1415
  const evidenceFiles = [];
1416
+ const previewUrls = new Set();
1417
+ let closed = false, submitting = false;
1418
+ const reasonInput = content.querySelector("#dispute-reason");
1419
+ const submitButton = content.querySelector("#btn-submit-dispute");
1420
+ const setSubmitting = active => {
1421
+ submitting = active;
1422
+ content.setAttribute("aria-busy", String(active));
1423
+ reasonInput.readOnly = active;
1424
+ uploadArea.disabled = active;
1425
+ fileInput.disabled = active;
1426
+ submitButton.disabled = active;
1427
+ preview.querySelectorAll("button").forEach(button => { button.disabled = active; });
1428
+ };
1429
+ reasonInput.addEventListener("input", () => reasonInput.removeAttribute("aria-invalid"));
1430
+ const releasePreviews = () => {
1431
+ for (const url of previewUrls) URL.revokeObjectURL(url);
1432
+ previewUrls.clear();
1433
+ };
1434
+ content._cleanup = () => {
1435
+ if (closed) return;
1436
+ closed = true;
1437
+ releasePreviews();
1438
+ evidenceFiles.length = 0;
1439
+ };
1440
 
1441
+ uploadArea.onclick = () => { if (!closed && !submitting && taskModalCurrent(content)) fileInput.click(); };
 
 
1442
 
1443
  fileInput.onchange = (e) => {
1444
+ if (closed || submitting || !taskModalCurrent(content)) return;
1445
  for (const file of e.target.files) {
1446
  if (evidenceFiles.length >= 6) {
1447
  showToast(t('dispute.max_6_images'), "warning");
 
1449
  }
1450
  evidenceFiles.push(file);
1451
  }
1452
+ fileInput.value = "";
1453
  renderPreview();
1454
  };
1455
 
1456
  function renderPreview() {
1457
+ releasePreviews();
1458
+ if (closed) return;
1459
+ preview.innerHTML = evidenceFiles.map((file, idx) => {
1460
+ const url = URL.createObjectURL(file);
1461
+ previewUrls.add(url);
1462
+ return `
1463
+ <div class="task-dispute__evidence">
1464
+ <img alt="" src="${url}" class="task-dispute__image">
1465
+ <button type="button" aria-label="${escapeHtml(t('dispute.remove_evidence', { index: idx + 1 }))}" data-idx="${idx}" class="task-dispute__remove">×</button>
1466
  </div>
1467
+ `;
1468
+ }).join("");
1469
 
1470
  preview.querySelectorAll("button").forEach(btn => {
1471
  btn.onclick = () => {
1472
+ if (closed || submitting || !taskModalCurrent(content)) return;
1473
  evidenceFiles.splice(parseInt(btn.dataset.idx), 1);
1474
  renderPreview();
1475
  };
 
1477
  }
1478
 
1479
  // 提交申诉
1480
+ submitButton.onclick = async () => {
1481
+ if (closed || submitting || !taskModalCurrent(content)) return;
1482
+ const reason = reasonInput.value.trim();
1483
  if (!reason) {
1484
+ reasonInput.setAttribute("aria-invalid", "true");
1485
+ reasonInput.focus();
1486
  showToast(t('dispute.please_enter_reason'), "warning");
1487
  return;
1488
  }
1489
 
1490
+ reasonInput.removeAttribute("aria-invalid");
1491
+ const files = evidenceFiles.slice();
1492
+ const btn = submitButton;
1493
+ setSubmitting(true);
1494
  btn.textContent = `•• ${t('common.submitting')}...`;
1495
 
1496
  try {
1497
  // 上传证据图片
1498
  const evidenceUrls = [];
1499
+ for (let i = 0; i < files.length; i++) {
1500
+ btn.textContent = `•• ${t('common.upload_image_progress', { current: i + 1, total: files.length })}...`;
1501
  if (!taskModalCurrent(content)) return;
1502
+ const processedFile = await compressImageForUpload(files[i]);
1503
  if (!taskModalCurrent(content)) return;
1504
  const res = await api.uploadFile(processedFile, "dispute");
1505
  if (!taskModalCurrent(content)) return;
1506
+ if (res?.status !== "success" || typeof res.url !== "string" || !res.url.trim()) {
1507
+ throw new Error("Invalid evidence upload response");
1508
+ }
1509
  evidenceUrls.push(res.url);
1510
  }
1511
 
1512
  // 提交申诉
1513
  btn.textContent = `•• ${t('dispute.submitting')}...`;
1514
  if (!taskModalCurrent(content)) return;
1515
+ const response = await api.disputeTask(task.id, reason, evidenceUrls);
1516
  if (!taskModalCurrent(content)) return;
1517
 
1518
+ const dispute = response?.data;
1519
+ if (response?.status !== "success" || typeof dispute?.id !== "string" || !dispute.id.trim()
1520
+ || dispute.task_id !== task.id || dispute.initiator !== currentUser?.account) {
1521
+ throw new Error("Invalid task dispute response");
1522
+ }
1523
+
1524
  showToast(t('dispute.submit_success'), "success");
1525
  closeTaskModal(content);
1526
  // 重新加载任务详情
 
1531
  } catch (err) {
1532
  if (!taskModalCurrent(content)) return;
1533
  showToast(t('dispute.submit_failed'), "error");
 
1534
  btn.textContent = t('dispute.submit');
1535
+ } finally {
1536
+ if (!closed && taskModalCurrent(content)) setSubmitting(false);
1537
  }
1538
  };
1539