ZHIWEI666 commited on
Commit
75c356f
·
verified ·
1 Parent(s): 6dfad7a

Upload 103 files

Browse files
前端页面/auth/找回账号表单组件.js CHANGED
@@ -18,25 +18,27 @@ import { escapeHtml } from "../components/互动工具函数.js";
18
 
19
  export function renderRecoverForm(container, switchView) {
20
  container.innerHTML = `
21
- <div style="margin-bottom: 12px; font-size: 13px; color: #aaa; line-height: 1.6;">${t('auth.recover_desc')}</div>
22
-
23
- <div style="margin-bottom: 10px; padding: 10px; background: rgba(33, 150, 243, 0.1); border: 1px dashed #2196F3; border-radius: 4px;">
24
- <label style="display: block; margin-bottom: 5px; color: #2196F3; font-weight: bold;">${t('auth.security_verify')} (${t('auth.bound_email')}) <span style="color: #F44336;">*</span></label>
25
- <div style="display: flex; gap: 8px;">
26
- <input type="text" id="recover-email" placeholder="${t('auth.enter_bound_email')}" style="flex: 1; padding: 8px; background: #222; border: 1px solid #2196F3; color: #fff; border-radius: 4px; box-sizing: border-box;">
27
- <button id="btn-recover-send-code" style="padding: 0 15px; background: #2196F3; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold; white-space: nowrap; transition: 0.2s;">${t('auth.get_code')}</button>
28
- </div>
29
- <input type="text" id="recover-code" placeholder="${t('auth.enter_6_digit_code')}" maxlength="6" style="width: 100%; margin-top: 10px; padding: 8px; background: #222; border: 1px dashed #2196F3; color: #fff; border-radius: 4px; box-sizing: border-box;">
 
 
 
 
 
30
  </div>
31
-
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
 
39
- function startCountdown(btn, duration, resetColor) {
40
  let timeLeft = duration;
41
  btn.innerText = `${timeLeft}s ${t('auth.resend')}`;
42
  const timer = setInterval(() => {
@@ -44,7 +46,6 @@ export function renderRecoverForm(container, switchView) {
44
  if (timeLeft <= 0) {
45
  clearInterval(timer);
46
  btn.disabled = false;
47
- btn.style.background = resetColor;
48
  btn.innerText = t('auth.get_code');
49
  } else {
50
  btn.innerText = `${timeLeft}s ${t('auth.resend')}`;
@@ -69,7 +70,6 @@ export function renderRecoverForm(container, switchView) {
69
  if (!isEmail) return showToast(t('auth.invalid_email'), "error");
70
 
71
  btnSendCode.disabled = true;
72
- btnSendCode.style.background = "#555";
73
  btnSendCode.innerText = t('auth.sending');
74
 
75
  try {
@@ -79,13 +79,12 @@ export function renderRecoverForm(container, switchView) {
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');
90
  }
91
  };
@@ -115,15 +114,19 @@ export function renderRecoverForm(container, switchView) {
115
  if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null; }
116
 
117
  // 展示找回结果:账号 + 昵称,提供跳转重置密码入口
 
118
  const nickname = res.name ? `(${escapeHtml(res.name)})` : "";
 
119
  container.innerHTML = `
120
- <div style="padding: 15px; background: rgba(76, 175, 80, 0.1); border: 1px solid #4CAF50; border-radius: 6px; margin-bottom: 15px; text-align: center;">
121
- <div style="font-size: 14px; color: #4CAF50; font-weight: bold; margin-bottom: 8px;">✅ ${t('auth.recover_success')}</div>
122
- <div style="font-size: 12px; color: #aaa; margin-bottom: 4px;">${t('auth.your_account')}</div>
123
- <div style="font-size: 20px; font-weight: bold; color: #fff; letter-spacing: 1px; user-select: text;">${escapeHtml(res.account)}${nickname}</div>
 
 
 
 
124
  </div>
125
- <button id="btn-go-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.go_reset_password')}</button>
126
- <button id="btn-back-login" style="width: 100%; padding: 10px; background: #555; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold;">${t('auth.back_to_login')}</button>
127
  `;
128
 
129
  // 跳转重置密码表单,自动回填账号与邮箱
@@ -134,6 +137,7 @@ export function renderRecoverForm(container, switchView) {
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;
 
18
 
19
  export function renderRecoverForm(container, switchView) {
20
  container.innerHTML = `
21
+ <div class="auth-form">
22
+ <p class="auth-form__description">${t('auth.recover_desc')}</p>
23
+ <fieldset class="auth-form__security">
24
+ <legend>${t('auth.security_verify')}</legend>
25
+ <label for="recover-email">${t('auth.bound_email')} <span class="auth-form__required" aria-hidden="true">*</span></label>
26
+ <div class="auth-form__code-row">
27
+ <input type="email" id="recover-email" name="email" autocomplete="email" required placeholder="${t('auth.enter_bound_email')}" class="auth-form__input">
28
+ <button type="button" id="btn-recover-send-code" class="auth-form__button">${t('auth.get_code')}</button>
29
+ </div>
30
+ <label for="recover-code">${t('auth.verification_code')} <span class="auth-form__required" aria-hidden="true">*</span></label>
31
+ <input type="text" id="recover-code" name="verification-code" autocomplete="one-time-code" inputmode="numeric" required placeholder="${t('auth.enter_6_digit_code')}" maxlength="6" class="auth-form__input">
32
+ </fieldset>
33
+ <button type="button" id="btn-submit-recover" class="auth-form__button auth-form__primary">${t('auth.recover_account')}</button>
34
+ <div class="auth-form__footer"><button type="button" id="recover-to-login" class="auth-form__link">${t('auth.back_to_login')}</button></div>
35
  </div>
 
 
 
36
  `;
37
  const formScope = createAuthFormScope(container);
38
 
39
  container.querySelector("#recover-to-login").onclick = (e) => { e.preventDefault(); switchView("login"); };
40
 
41
+ function startCountdown(btn, duration) {
42
  let timeLeft = duration;
43
  btn.innerText = `${timeLeft}s ${t('auth.resend')}`;
44
  const timer = setInterval(() => {
 
46
  if (timeLeft <= 0) {
47
  clearInterval(timer);
48
  btn.disabled = false;
 
49
  btn.innerText = t('auth.get_code');
50
  } else {
51
  btn.innerText = `${timeLeft}s ${t('auth.resend')}`;
 
70
  if (!isEmail) return showToast(t('auth.invalid_email'), "error");
71
 
72
  btnSendCode.disabled = true;
 
73
  btnSendCode.innerText = t('auth.sending');
74
 
75
  try {
 
79
 
80
  showToast(t('auth.code_sent'), "success");
81
 
82
+ countdownTimer = startCountdown(btnSendCode, 60);
83
 
84
  } catch (err) {
85
  if (!formScope.current()) return;
86
  showToast(t('feedback.error'), "error");
87
  btnSendCode.disabled = false;
 
88
  btnSendCode.innerText = t('auth.get_code');
89
  }
90
  };
 
114
  if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null; }
115
 
116
  // 展示找回结果:账号 + 昵称,提供跳转重置密码入口
117
+ container.querySelectorAll('input').forEach(input => { input.value = ''; });
118
  const nickname = res.name ? `(${escapeHtml(res.name)})` : "";
119
+ const restoreResultFocus = container.contains(document.activeElement);
120
  container.innerHTML = `
121
+ <div class="auth-form">
122
+ <div class="auth-form__result" role="status">
123
+ <div class="auth-form__result-title">✅ ${t('auth.recover_success')}</div>
124
+ <div class="auth-form__description">${t('auth.your_account')}</div>
125
+ <div class="auth-form__account">${escapeHtml(res.account)}${nickname}</div>
126
+ </div>
127
+ <button type="button" id="btn-go-reset" class="auth-form__button auth-form__primary">${t('auth.go_reset_password')}</button>
128
+ <button type="button" id="btn-back-login" class="auth-form__button">${t('auth.back_to_login')}</button>
129
  </div>
 
 
130
  `;
131
 
132
  // 跳转重置密码表单,自动回填账号与邮箱
 
137
  switchView("reset", { prefill: { account: res.account, email: email } });
138
  };
139
  container.querySelector("#btn-back-login").onclick = () => { if (resultCurrent()) switchView("login"); };
140
+ if (restoreResultFocus && resultCurrent()) resultButton.focus();
141
 
142
  } catch (err) {
143
  if (!formScope.current()) return;
前端页面/auth/注册表单组件.js CHANGED
@@ -6,8 +6,6 @@ import { t } from "../components/用户体验增强.js";
6
  import { openImageCropper } from "../components/图片裁剪组件.js";
7
  import { PLACEHOLDERS } from "../core/全局配置.js";
8
 
9
- const INPUT_STYLE = 'width: 100%; padding: 8px; background: #333; border: 1px solid #555; color: #fff; border-radius: 4px; box-sizing: border-box;';
10
-
11
  const DEFAULT_AVATAR = PLACEHOLDERS.AVATAR;
12
 
13
  // 计算年龄工具函数
@@ -29,53 +27,46 @@ export function renderRegisterForm(container, switchView, onSuccessCallback) {
29
  const countryOptions = sortedCountries.map(c => `<option value="${c}">${c}</option>`).join("");
30
 
31
  container.innerHTML = `
32
- <div style="display: flex; gap: 10px; margin-bottom: 10px;">
33
- <div style="flex: 1;"><label style="display: block; margin-bottom: 5px;">${t('auth.account')} (>5${t('common.chars')}) <span style="color: #F44336;">*</span></label><input type="text" id="reg-account" required style="${INPUT_STYLE}"></div>
34
- <div style="flex: 1;"><label style="display: block; margin-bottom: 5px;">${t('auth.display_name')} <span style="color: #F44336;">*</span></label><input type="text" id="reg-name" required style="${INPUT_STYLE}"></div>
35
- </div>
36
- <div style="display: flex; gap: 10px; margin-bottom: 10px;">
37
- <div style="flex: 1;"><label style="display: block; margin-bottom: 5px;">${t('auth.set_password')} (≥6${t('common.chars')}) <span style="color: #F44336;">*</span></label><input type="password" id="reg-password" required style="${INPUT_STYLE}"></div>
38
- <div style="flex: 1;"><label style="display: block; margin-bottom: 5px;">${t('auth.confirm_password')} <span style="color: #F44336;">*</span></label><input type="password" id="reg-password-confirm" required style="${INPUT_STYLE}"></div>
39
- </div>
40
-
41
- <div style="margin-bottom: 10px; padding: 10px; background: rgba(76, 175, 80, 0.1); border: 1px dashed #4CAF50; border-radius: 4px;">
42
- <div style="display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 10px;">
43
- <div style="flex: 2; min-width: 180px;">
44
- <label style="display: block; margin-bottom: 5px; color: #4CAF50;">${t('auth.security_email')} (${t('auth.security_email_desc')}) <span style="color: #F44336;">*</span></label>
45
- <div style="display: flex; gap: 8px;">
46
- <input type="email" id="reg-email" required style="flex: 1; min-width: 0; padding: 8px; background: #222; border: 1px solid #4CAF50; color: #fff; border-radius: 4px; box-sizing: border-box;">
47
- <button id="btn-reg-send-code" style="flex-shrink: 0; padding: 0 12px; background: #4CAF50; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold; white-space: nowrap;">${t('auth.get_code')}</button>
48
- </div>
49
  </div>
50
- <div style="flex: 1; min-width: 100px;">
51
- <label style="display: block; margin-bottom: 5px; color: #4CAF50; white-space: nowrap;">${t('auth.verification_code')} <span style="color: #F44336;">*</span></label>
52
- <input type="text" id="reg-code" placeholder="6${t('common.digits')}" maxlength="6" required style="width: 100%; padding: 8px; background: #222; border: 1px solid #4CAF50; color: #fff; border-radius: 4px; box-sizing: border-box; text-align: center;">
 
 
 
 
 
53
  </div>
 
54
  </div>
55
- </div>
56
-
57
- <div style="margin-bottom: 10px; background: var(--comfy-input-bg); padding: 10px; border-radius: 4px; border: 1px dashed #555; display: flex; align-items: center; justify-content: space-between;">
58
- <div>
59
- <label style="display: block; margin-bottom: 5px;">${t('auth.avatar')}</label>
60
- <div style="display: flex; align-items: center; gap: 10px;"><img id="avatar-preview" src="${DEFAULT_AVATAR}" style="width: 40px; height: 40px; border-radius: 50%; object-fit: cover; border: 2px solid #444;"><input type="file" id="reg-avatar" accept="image/*" style="font-size: 12px; color: #aaa; max-width: 150px;"></div>
61
- <div id="avatar-error" style="color: #F44336; font-size: 12px; margin-top: 5px; display: none;">${t('auth.file_too_large')}</div>
62
- </div>
63
- <div><label style="display: block; margin-bottom: 5px;">${t('auth.gender')} <span style="color: #F44336;">*</span></label><select id="reg-gender" style="width: 100%; padding: 8px; background: #333; border: 1px solid #555; color: #fff; border-radius: 4px;"><option value="male">${t('auth.male')}</option><option value="female">${t('auth.female')}</option></select></div>
64
- </div>
65
- <div style="display: flex; gap: 10px; margin-bottom: 10px;">
66
- <div style="flex: 1;">
67
- <label style="display: block; margin-bottom: 5px;">${t('auth.birthday')}</label>
68
- <div style="display: flex; align-items: center; gap: 8px;">
69
- <input type="date" id="reg-birthday" style="flex: 1; padding: 8px; background: #333; border: 1px solid #555; color: #fff; border-radius: 4px;">
70
- <span id="reg-age-display" style="color: #888; font-size: 12px; white-space: nowrap;"></span>
71
  </div>
 
 
72
  </div>
73
- <div style="flex: 1;"><label style="display: block; margin-bottom: 5px;">${t('auth.country')}</label><select id="reg-country" style="width: 100%; padding: 8px; background: #333; border: 1px solid #555; color: #fff; border-radius: 4px;">${countryOptions}</select></div>
74
- <div style="flex: 1;"><label style="display: block; margin-bottom: 5px;">${t('auth.region')}</label><select id="reg-region" style="width: 100%; padding: 8px; background: #333; border: 1px solid #555; color: #fff; border-radius: 4px;"></select></div>
 
75
  </div>
76
- <div style="margin-bottom: 20px;"><label style="display: block; margin-bottom: 5px;">${t('auth.intro')} (${t('auth.intro_limit')})</label><textarea id="reg-intro" rows="2" maxlength="100" style="width: 100%; padding: 8px; background: #333; border: 1px solid #555; color: #fff; border-radius: 4px; resize: vertical;"></textarea></div>
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
 
@@ -93,7 +84,6 @@ export function renderRegisterForm(container, switchView, onSuccessCallback) {
93
  const age = calculateAge(birthdayInput.value);
94
  if (age !== null) {
95
  ageDisplay.textContent = `${age} ${t('auth.age_years')}`;
96
- ageDisplay.style.color = "#4CAF50";
97
  } else {
98
  ageDisplay.textContent = "";
99
  }
@@ -155,7 +145,7 @@ export function renderRegisterForm(container, switchView, onSuccessCallback) {
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();
@@ -173,7 +163,7 @@ export function renderRegisterForm(container, switchView, onSuccessCallback) {
173
 
174
  container.querySelector("#toggle-to-login").onclick = (e) => { e.preventDefault(); switchView("login"); };
175
 
176
- function startCountdown(btn, duration, resetColor) {
177
  let timeLeft = duration;
178
  btn.innerText = `${timeLeft}s ${t('auth.resend')}`;
179
  const timer = setInterval(() => {
@@ -181,7 +171,6 @@ export function renderRegisterForm(container, switchView, onSuccessCallback) {
181
  if (timeLeft <= 0) {
182
  clearInterval(timer);
183
  btn.disabled = false;
184
- btn.style.background = resetColor;
185
  btn.innerText = t('auth.get_code');
186
  } else {
187
  btn.innerText = `${timeLeft}s ${t('auth.resend')}`;
@@ -204,7 +193,6 @@ export function renderRegisterForm(container, switchView, onSuccessCallback) {
204
  if (!isEmail) return showToast(t('auth.invalid_email'), "error");
205
 
206
  btnRegSendCode.disabled = true;
207
- btnRegSendCode.style.background = "#555";
208
  btnRegSendCode.innerText = t('auth.sending');
209
 
210
  try {
@@ -213,13 +201,12 @@ export function renderRegisterForm(container, switchView, onSuccessCallback) {
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');
224
  }
225
  };
 
6
  import { openImageCropper } from "../components/图片裁剪组件.js";
7
  import { PLACEHOLDERS } from "../core/全局配置.js";
8
 
 
 
9
  const DEFAULT_AVATAR = PLACEHOLDERS.AVATAR;
10
 
11
  // 计算年龄工具函数
 
27
  const countryOptions = sortedCountries.map(c => `<option value="${c}">${c}</option>`).join("");
28
 
29
  container.innerHTML = `
30
+ <div class="auth-form">
31
+ <div class="auth-form__grid">
32
+ <div class="auth-form__field"><label for="reg-account">${t('auth.account')} (>5${t('common.chars')}) <span class="auth-form__required" aria-hidden="true">*</span></label><input type="text" id="reg-account" class="auth-form__input" name="username" autocomplete="username" required></div>
33
+ <div class="auth-form__field"><label for="reg-name">${t('auth.display_name')} <span class="auth-form__required" aria-hidden="true">*</span></label><input type="text" id="reg-name" class="auth-form__input" name="nickname" autocomplete="nickname" required></div>
34
+ </div>
35
+ <div class="auth-form__grid">
36
+ <div class="auth-form__field"><label for="reg-password">${t('auth.set_password')} (≥6${t('common.chars')}) <span class="auth-form__required" aria-hidden="true">*</span></label><input type="password" id="reg-password" class="auth-form__input" name="password" autocomplete="new-password" required></div>
37
+ <div class="auth-form__field"><label for="reg-password-confirm">${t('auth.confirm_password')} <span class="auth-form__required" aria-hidden="true">*</span></label><input type="password" id="reg-password-confirm" class="auth-form__input" name="confirm-password" autocomplete="new-password" required></div>
38
+ </div>
39
+ <fieldset class="auth-form__security">
40
+ <legend>${t('auth.security_verify')}</legend>
41
+ <label for="reg-email">${t('auth.security_email')} (${t('auth.security_email_desc')}) <span class="auth-form__required" aria-hidden="true">*</span></label>
42
+ <div class="auth-form__code-row">
43
+ <input type="email" id="reg-email" name="email" autocomplete="email" required class="auth-form__input">
44
+ <button type="button" id="btn-reg-send-code" class="auth-form__button">${t('auth.get_code')}</button>
 
 
45
  </div>
46
+ <label for="reg-code">${t('auth.verification_code')} <span class="auth-form__required" aria-hidden="true">*</span></label>
47
+ <input type="text" id="reg-code" name="verification-code" autocomplete="one-time-code" inputmode="numeric" placeholder="6${t('common.digits')}" maxlength="6" required class="auth-form__input">
48
+ </fieldset>
49
+ <div class="auth-form__grid auth-form__profile">
50
+ <div class="auth-form__field">
51
+ <label for="reg-avatar">${t('auth.avatar')}</label>
52
+ <div class="auth-form__avatar-row"><img id="avatar-preview" src="${DEFAULT_AVATAR}" alt="" class="auth-form__avatar"><input type="file" id="reg-avatar" accept="image/*" class="auth-form__file"></div>
53
+ <div id="avatar-error" class="auth-form__error" hidden>${t('auth.file_too_large')}</div>
54
  </div>
55
+ <div class="auth-form__field"><label for="reg-gender">${t('auth.gender')} <span class="auth-form__required" aria-hidden="true">*</span></label><select id="reg-gender" required class="auth-form__input"><option value="male">${t('auth.male')}</option><option value="female">${t('auth.female')}</option></select></div>
56
  </div>
57
+ <div class="auth-form__grid">
58
+ <div class="auth-form__field">
59
+ <label for="reg-birthday">${t('auth.birthday')}</label>
60
+ <input type="date" id="reg-birthday" name="bday" autocomplete="bday" class="auth-form__input">
61
+ <span id="reg-age-display" class="auth-form__age"></span>
 
 
 
 
 
 
 
 
 
 
 
62
  </div>
63
+ <div class="auth-form__field"><label for="reg-country">${t('auth.country')}</label><select id="reg-country" name="country-name" autocomplete="country-name" class="auth-form__input">${countryOptions}</select></div>
64
+ <div class="auth-form__field"><label for="reg-region">${t('auth.region')}</label><select id="reg-region" name="address-level1" autocomplete="address-level1" class="auth-form__input"></select></div>
65
  </div>
66
+ <div class="auth-form__field"><label for="reg-intro">${t('auth.intro')} (${t('auth.intro_limit')})</label><textarea id="reg-intro" rows="2" maxlength="100" class="auth-form__input auth-form__textarea"></textarea></div>
67
+ <button type="button" id="btn-submit-register" class="auth-form__button auth-form__primary">${t('auth.register_submit')}</button>
68
+ <div class="auth-form__footer">${t('auth.has_account')} <button type="button" id="toggle-to-login" class="auth-form__link">${t('auth.back_to_login')}</button></div>
69
  </div>
 
 
 
70
  `;
71
  const formScope = createAuthFormScope(container);
72
 
 
84
  const age = calculateAge(birthdayInput.value);
85
  if (age !== null) {
86
  ageDisplay.textContent = `${age} ${t('auth.age_years')}`;
 
87
  } else {
88
  ageDisplay.textContent = "";
89
  }
 
145
  reader.onload = (event) => {
146
  const result = event.target.result;
147
  if (currentAvatar() && typeof result === 'string' && result) {
148
+ avatarError.hidden = true;
149
  selectedAvatarFile = croppedFile; avatarPreview.src = result;
150
  }
151
  releaseReader();
 
163
 
164
  container.querySelector("#toggle-to-login").onclick = (e) => { e.preventDefault(); switchView("login"); };
165
 
166
+ function startCountdown(btn, duration) {
167
  let timeLeft = duration;
168
  btn.innerText = `${timeLeft}s ${t('auth.resend')}`;
169
  const timer = setInterval(() => {
 
171
  if (timeLeft <= 0) {
172
  clearInterval(timer);
173
  btn.disabled = false;
 
174
  btn.innerText = t('auth.get_code');
175
  } else {
176
  btn.innerText = `${timeLeft}s ${t('auth.resend')}`;
 
193
  if (!isEmail) return showToast(t('auth.invalid_email'), "error");
194
 
195
  btnRegSendCode.disabled = true;
 
196
  btnRegSendCode.innerText = t('auth.sending');
197
 
198
  try {
 
201
  if (response?.status !== 'success') throw new Error('Invalid verification response');
202
  showToast(t('auth.code_sent'), "success");
203
 
204
+ regCountdownTimer = startCountdown(btnRegSendCode, 60);
205
 
206
  } catch (err) {
207
  if (!formScope.current()) return;
208
  showToast(t('feedback.error'), "error");
209
  btnRegSendCode.disabled = false;
 
210
  btnRegSendCode.innerText = t('auth.get_code');
211
  }
212
  };
前端页面/auth/用户注册登录组件.js CHANGED
@@ -8,19 +8,11 @@ import { t } from "../components/用户体验增强.js";
8
  export function createAuthView(onSuccessCallback) {
9
  const container = document.createElement("div");
10
  let disposed = false, generation = 0, mounted = false, busy = false;
11
- let observer;
12
- // 🔧 自适应高度:flex 填充剩余空间,内容超出时在容器内部滚动(与主界面保持一致)
13
- Object.assign(container.style, {
14
- display: "flex", flexDirection: "column", gap: "15px", color: "#ccc",
15
- fontSize: "14px", padding: "15px", flex: "1", minHeight: "0", boxSizing: "border-box", overflowY: "auto",
16
- background: "var(--comfy-menu-bg)"
17
- });
18
-
19
  container.innerHTML = `
20
- <button id="btn-back-auth" style="background: var(--comfy-input-bg); border: 1px solid #555; color: #fff; padding: 6px 14px; border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: bold; display: flex; align-items: center; gap: 6px; box-shadow: 0 2px 4px rgba(0,0,0,0.2); margin-bottom: 15px; width: fit-content; transition: 0.2s;" onmouseover="this.style.background='#4CAF50'; this.style.borderColor='#4CAF50'" onmouseout="this.style.background='var(--comfy-input-bg)'; this.style.borderColor='#555'">
21
- ⬅ ${t('common.back')}
22
- </button>
23
- <div style="text-align:center; font-weight:bold; font-size:16px; margin-bottom:10px; color:#fff;">🔐 ${t('auth.title')}</div>
24
  <div id="auth-form-container"></div>
25
  `;
26
 
@@ -32,6 +24,8 @@ export function createAuthView(onSuccessCallback) {
32
  const formContainer = container.querySelector("#auth-form-container");
33
 
34
  const releaseForm = () => {
 
 
35
  formContainer.querySelectorAll('input, textarea').forEach(input => { input.value = ''; });
36
  const cleanup = formContainer._onCleanup; formContainer._onCleanup = null;
37
  try { cleanup?.(); } catch (_) {}
@@ -43,6 +37,7 @@ export function createAuthView(onSuccessCallback) {
43
  };
44
  const switchView = (viewState, options = {}) => {
45
  if (disposed) return;
 
46
  releaseForm(); generation++; busy = false;
47
  const currentGeneration = generation;
48
  const current = () => !disposed && generation === currentGeneration && container.isConnected;
@@ -70,6 +65,22 @@ export function createAuthView(onSuccessCallback) {
70
  } else if (viewState === "recover") {
71
  renderRecoverForm(formContainer, switchView);
72
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  };
74
 
75
  switchView("login");
 
8
  export function createAuthView(onSuccessCallback) {
9
  const container = document.createElement("div");
10
  let disposed = false, generation = 0, mounted = false, busy = false;
11
+ let observer, keyboardSubmit;
12
+ container.className = "auth-view";
 
 
 
 
 
 
13
  container.innerHTML = `
14
+ <button type="button" id="btn-back-auth" class="auth-view__back">⬅ ${t('common.back')}</button>
15
+ <h2 class="auth-view__title">🔐 ${t('auth.title')}</h2>
 
 
16
  <div id="auth-form-container"></div>
17
  `;
18
 
 
24
  const formContainer = container.querySelector("#auth-form-container");
25
 
26
  const releaseForm = () => {
27
+ if (keyboardSubmit) formContainer.removeEventListener('keydown', keyboardSubmit);
28
+ keyboardSubmit = null;
29
  formContainer.querySelectorAll('input, textarea').forEach(input => { input.value = ''; });
30
  const cleanup = formContainer._onCleanup; formContainer._onCleanup = null;
31
  try { cleanup?.(); } catch (_) {}
 
37
  };
38
  const switchView = (viewState, options = {}) => {
39
  if (disposed) return;
40
+ const restoreFocus = container.isConnected && formContainer.contains(document.activeElement);
41
  releaseForm(); generation++; busy = false;
42
  const currentGeneration = generation;
43
  const current = () => !disposed && generation === currentGeneration && container.isConnected;
 
65
  } else if (viewState === "recover") {
66
  renderRecoverForm(formContainer, switchView);
67
  }
68
+ const submitButton = formContainer.querySelector('button[id^="btn-submit"]');
69
+ if (submitButton) {
70
+ keyboardSubmit = event => {
71
+ const input = event.target;
72
+ if (!current() || event.defaultPrevented || event.key !== 'Enter' || event.isComposing
73
+ || event.keyCode === 229 || event.repeat || event.ctrlKey || event.altKey || event.metaKey || event.shiftKey
74
+ || !(input instanceof HTMLInputElement) || !['text', 'email', 'password'].includes(input.type)
75
+ || input.disabled || input.readOnly || !formContainer.contains(input)) return;
76
+ event.preventDefault();
77
+ if (!submitButton.disabled && formContainer.contains(submitButton)) submitButton.click();
78
+ };
79
+ formContainer.addEventListener('keydown', keyboardSubmit);
80
+ }
81
+ if (restoreFocus && current()) {
82
+ formContainer.querySelector('input:not([disabled]), button:not([disabled])')?.focus();
83
+ }
84
  };
85
 
86
  switchView("login");
前端页面/auth/登录表单组件.js CHANGED
@@ -1,24 +1,24 @@
1
  import { showToast } from "../components/UI交互提示组件.js";
2
  import { t } from "../components/用户体验增强.js";
3
 
4
- const INPUT_STYLE = 'width: 100%; padding: 8px; background: #333; border: 1px solid #555; color: #fff; border-radius: 4px; box-sizing: border-box;';
5
-
6
  export function renderLoginForm(container, switchView, onSuccessCallback) {
7
  container.innerHTML = `
8
- <div style="margin-bottom: 10px;"><label style="display: block; margin-bottom: 5px;">${t('auth.account')} <span style="color: #F44336;">*</span></label><input type="text" id="login-account" style="${INPUT_STYLE}"></div>
9
- <div style="margin-bottom: 15px;"><label style="display: block; margin-bottom: 5px;">${t('auth.password')} <span style="color: #F44336;">*</span></label><input type="password" id="login-password" style="${INPUT_STYLE}"></div>
10
- <div style="margin-bottom: 20px; display: flex; align-items: center; justify-content: space-between;">
11
- <div style="display: flex; align-items: center; gap: 8px;">
12
- <input type="checkbox" id="login-remember" checked style="cursor: pointer;">
13
- <label for="login-remember" style="font-size: 12px; color: #aaa; cursor: pointer;">${t('auth.remember_me')}</label>
14
- </div>
15
- <div style="display: flex; align-items: center; gap: 10px;">
16
- <a href="#" id="toggle-to-recover" style="color: #2196F3; text-decoration: underline; font-weight: bold; font-size: 12px; cursor: pointer; transition: opacity 0.2s;" onmouseover="this.style.opacity=0.8" onmouseout="this.style.opacity=1">🔎 ${t('auth.forgot_account')}</a>
17
- <a href="#" id="toggle-to-reset" style="color: #FF9800; text-decoration: underline; font-weight: bold; font-size: 12px; cursor: pointer; transition: opacity 0.2s;" onmouseover="this.style.opacity=0.8" onmouseout="this.style.opacity=1">🔑 ${t('auth.forgot_password')}</a>
 
 
18
  </div>
 
 
19
  </div>
20
- <button id="btn-submit-login" style="width: 100%; padding: 10px; background: #4CAF50; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold; margin-bottom: 10px;">${t('auth.login')}</button>
21
- <div style="text-align: center; font-size: 12px;"><span>${t('auth.no_account')} <a href="#" id="toggle-to-register" style="color: #4CAF50; text-decoration: none; font-weight: bold;">${t('auth.register_now')}</a></span></div>
22
  `;
23
 
24
  container.querySelector("#toggle-to-register").onclick = (e) => { e.preventDefault(); switchView("register"); };
 
1
  import { showToast } from "../components/UI交互提示组件.js";
2
  import { t } from "../components/用户体验增强.js";
3
 
 
 
4
  export function renderLoginForm(container, switchView, onSuccessCallback) {
5
  container.innerHTML = `
6
+ <div class="auth-login">
7
+ <div class="auth-login__field"><label for="login-account">${t('auth.account')} <span class="auth-login__required" aria-hidden="true">*</span></label><input type="text" id="login-account" name="username" autocomplete="username" required class="auth-login__input"></div>
8
+ <div class="auth-login__field"><label for="login-password">${t('auth.password')} <span class="auth-login__required" aria-hidden="true">*</span></label><input type="password" id="login-password" name="password" autocomplete="current-password" required class="auth-login__input"></div>
9
+ <div class="auth-login__options">
10
+ <div class="auth-login__remember">
11
+ <input type="checkbox" id="login-remember" checked>
12
+ <label for="login-remember">${t('auth.remember_me')}</label>
13
+ </div>
14
+ <div class="auth-login__actions">
15
+ <button type="button" id="toggle-to-recover" class="auth-login__link">🔎 ${t('auth.forgot_account')}</button>
16
+ <button type="button" id="toggle-to-reset" class="auth-login__link">🔑 ${t('auth.forgot_password')}</button>
17
+ </div>
18
  </div>
19
+ <button type="button" id="btn-submit-login" class="auth-login__submit">${t('auth.login')}</button>
20
+ <div class="auth-login__signup"><span>${t('auth.no_account')}</span> <button type="button" id="toggle-to-register" class="auth-login__link">${t('auth.register_now')}</button></div>
21
  </div>
 
 
22
  `;
23
 
24
  container.querySelector("#toggle-to-register").onclick = (e) => { e.preventDefault(); switchView("register"); };
前端页面/auth/重置密码表单组件.js CHANGED
@@ -3,27 +3,26 @@ import { api } from "../core/网络请求API.js";
3
  import { showToast } from "../components/UI交互提示组件.js";
4
  import { t } from "../components/用户体验增强.js";
5
 
6
- const INPUT_STYLE = 'width: 100%; padding: 8px; background: #333; border: 1px solid #555; color: #fff; border-radius: 4px; box-sizing: border-box;';
7
-
8
  export function renderResetForm(container, switchView, onSuccessCallback, prefill = null) {
9
  container.innerHTML = `
10
- <div style="margin-bottom: 10px;"><label style="display: block; margin-bottom: 5px;">${t('auth.current_account')} <span style="color: #F44336;">*</span></label><input type="text" id="reset-account" placeholder="${t('auth.enter_account')}" style="${INPUT_STYLE}"></div>
11
-
12
- <div style="margin-bottom: 10px; padding: 10px; background: rgba(33, 150, 243, 0.1); border: 1px dashed #2196F3; border-radius: 4px;">
13
- <label style="display: block; margin-bottom: 5px; color: #2196F3; font-weight: bold;">${t('auth.security_verify')} (${t('auth.bound_email')}) <span style="color: #F44336;">*</span></label>
14
- <div style="display: flex; gap: 8px;">
15
- <input type="text" id="reset-verify" placeholder="${t('auth.enter_bound_email')}" style="flex: 1; padding: 8px; background: #222; border: 1px solid #2196F3; color: #fff; border-radius: 4px; box-sizing: border-box;">
16
- <button id="btn-send-code" style="padding: 0 15px; background: #2196F3; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold; white-space: nowrap; transition: 0.2s;">${t('auth.get_code')}</button>
17
- </div>
18
- <input type="text" id="reset-code" placeholder="${t('auth.enter_6_digit_code')}" maxlength="6" style="width: 100%; margin-top: 10px; padding: 8px; background: #222; border: 1px dashed #2196F3; color: #fff; border-radius: 4px; box-sizing: border-box;">
 
 
 
 
 
 
 
 
19
  </div>
20
-
21
- <div style="margin-bottom: 10px;"><label style="display: block; margin-bottom: 5px;">${t('auth.old_password')} (${t('auth.old_password_tip')}) </label><input type="password" id="reset-old-password" placeholder="${t('auth.old_password_placeholder')}" style="${INPUT_STYLE}"></div>
22
- <div style="margin-bottom: 10px;"><label style="display: block; margin-bottom: 5px;">${t('auth.new_password')} (≥6${t('common.chars')}) <span style="color: #F44336;">*</span></label><input type="password" id="reset-new-password" style="${INPUT_STYLE}"></div>
23
- <div style="margin-bottom: 15px;"><label style="display: block; margin-bottom: 5px;">${t('auth.confirm_new_password')} <span style="color: #F44336;">*</span></label><input type="password" id="reset-confirm-password" style="${INPUT_STYLE}"></div>
24
-
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
 
@@ -35,7 +34,7 @@ export function renderResetForm(container, switchView, onSuccessCallback, prefil
35
  if (prefill.email) container.querySelector("#reset-verify").value = prefill.email;
36
  }
37
 
38
- function startCountdown(btn, duration, resetColor) {
39
  let timeLeft = duration;
40
  btn.innerText = `${timeLeft}s ${t('auth.resend')}`;
41
  const timer = setInterval(() => {
@@ -43,7 +42,6 @@ export function renderResetForm(container, switchView, onSuccessCallback, prefil
43
  if (timeLeft <= 0) {
44
  clearInterval(timer);
45
  btn.disabled = false;
46
- btn.style.background = resetColor;
47
  btn.innerText = t('auth.get_code');
48
  } else {
49
  btn.innerText = `${timeLeft}s ${t('auth.resend')}`;
@@ -75,7 +73,6 @@ export function renderResetForm(container, switchView, onSuccessCallback, prefil
75
  const contactType = "email";
76
 
77
  btnSendCode.disabled = true;
78
- btnSendCode.style.background = "#555";
79
  btnSendCode.innerText = t('auth.sending');
80
 
81
  try {
@@ -85,13 +82,12 @@ export function renderResetForm(container, switchView, onSuccessCallback, prefil
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');
96
  }
97
  };
 
3
  import { showToast } from "../components/UI交互提示组件.js";
4
  import { t } from "../components/用户体验增强.js";
5
 
 
 
6
  export function renderResetForm(container, switchView, onSuccessCallback, prefill = null) {
7
  container.innerHTML = `
8
+ <div class="auth-form">
9
+ <div class="auth-form__field"><label for="reset-account">${t('auth.current_account')} <span class="auth-form__required" aria-hidden="true">*</span></label><input type="text" id="reset-account" class="auth-form__input" name="username" autocomplete="username" required></div>
10
+ <fieldset class="auth-form__security">
11
+ <legend>${t('auth.security_verify')}</legend>
12
+ <label for="reset-verify">${t('auth.bound_email')} <span class="auth-form__required" aria-hidden="true">*</span></label>
13
+ <div class="auth-form__code-row">
14
+ <input type="email" id="reset-verify" name="email" autocomplete="email" required placeholder="${t('auth.enter_bound_email')}" class="auth-form__input">
15
+ <button type="button" id="btn-send-code" class="auth-form__button">${t('auth.get_code')}</button>
16
+ </div>
17
+ <label for="reset-code">${t('auth.verification_code')} <span class="auth-form__required" aria-hidden="true">*</span></label>
18
+ <input type="text" id="reset-code" name="verification-code" autocomplete="one-time-code" inputmode="numeric" required placeholder="${t('auth.enter_6_digit_code')}" maxlength="6" class="auth-form__input">
19
+ </fieldset>
20
+ <div class="auth-form__field"><label for="reset-old-password">${t('auth.old_password')} (${t('auth.old_password_tip')})</label><input type="password" id="reset-old-password" class="auth-form__input" name="old-password" autocomplete="current-password" placeholder="${t('auth.old_password_placeholder')}"></div>
21
+ <div class="auth-form__field"><label for="reset-new-password">${t('auth.new_password')} (≥6${t('common.chars')}) <span class="auth-form__required" aria-hidden="true">*</span></label><input type="password" id="reset-new-password" class="auth-form__input" name="new-password" autocomplete="new-password" required></div>
22
+ <div class="auth-form__field"><label for="reset-confirm-password">${t('auth.confirm_new_password')} <span class="auth-form__required" aria-hidden="true">*</span></label><input type="password" id="reset-confirm-password" class="auth-form__input" name="confirm-password" autocomplete="new-password" required></div>
23
+ <button type="button" id="btn-submit-reset" class="auth-form__button auth-form__primary">${t('auth.change_password')}</button>
24
+ <div class="auth-form__footer"><button type="button" id="toggle-to-login" class="auth-form__link">${t('auth.back_to_login')}</button></div>
25
  </div>
 
 
 
 
 
 
 
26
  `;
27
  const formScope = createAuthFormScope(container);
28
 
 
34
  if (prefill.email) container.querySelector("#reset-verify").value = prefill.email;
35
  }
36
 
37
+ function startCountdown(btn, duration) {
38
  let timeLeft = duration;
39
  btn.innerText = `${timeLeft}s ${t('auth.resend')}`;
40
  const timer = setInterval(() => {
 
42
  if (timeLeft <= 0) {
43
  clearInterval(timer);
44
  btn.disabled = false;
 
45
  btn.innerText = t('auth.get_code');
46
  } else {
47
  btn.innerText = `${timeLeft}s ${t('auth.resend')}`;
 
73
  const contactType = "email";
74
 
75
  btnSendCode.disabled = true;
 
76
  btnSendCode.innerText = t('auth.sending');
77
 
78
  try {
 
82
 
83
  showToast(t('auth.code_sent'), "success");
84
 
85
+ countdownTimer = startCountdown(btnSendCode, 60);
86
 
87
  } catch (err) {
88
  if (!formScope.current()) return;
89
  showToast(t('feedback.error'), "error");
90
  btnSendCode.disabled = false;
 
91
  btnSendCode.innerText = t('auth.get_code');
92
  }
93
  };
前端页面/components/性能优化工具.js CHANGED
@@ -231,6 +231,7 @@ export function createPaginationLoader(options) {
231
  } = options;
232
  const translate = options.t;
233
  const autoFill = options.autoFill;
 
234
 
235
  let currentPage = 1; // 首屏已渲染第1页,loadMore前先递增,从第2页开始加载
236
  let isLoading = false;
@@ -294,6 +295,7 @@ export function createPaginationLoader(options) {
294
  // 加载下一页
295
  const _loadNextPage = async () => {
296
  if (stopped || isLoading || !hasMore) return;
 
297
 
298
  isLoading = true;
299
  loadingIndicator.style.display = "block";
@@ -372,6 +374,9 @@ export function createPaginationLoader(options) {
372
  if (debounceTimer) clearTimeout(debounceTimer);
373
  loadingIndicator.style.display = "none";
374
  retryIndicator.style.display = "none";
 
 
 
375
  };
376
 
377
  // 重置状态
 
231
  } = options;
232
  const translate = options.t;
233
  const autoFill = options.autoFill;
234
+ const canLoad = options.canLoad;
235
 
236
  let currentPage = 1; // 首屏已渲染第1页,loadMore前先递增,从第2页开始加载
237
  let isLoading = false;
 
295
  // 加载下一页
296
  const _loadNextPage = async () => {
297
  if (stopped || isLoading || !hasMore) return;
298
+ if (typeof canLoad === "function" && !canLoad()) return;
299
 
300
  isLoading = true;
301
  loadingIndicator.style.display = "block";
 
374
  if (debounceTimer) clearTimeout(debounceTimer);
375
  loadingIndicator.style.display = "none";
376
  retryIndicator.style.display = "none";
377
+ loadingIndicator.remove();
378
+ endIndicator.remove();
379
+ retryIndicator.remove();
380
  };
381
 
382
  // 重置状态
前端页面/components/用户体验_国际化.js CHANGED
@@ -57,6 +57,7 @@ const translations = {
57
  'install.error_package_too_large': '下载包超过本地安全上限,已停止安装。',
58
  'install.error_download': '下载多次中断。请检查网络后重试。',
59
  'install.error_target_occupied': '旧版本文件仍被占用。关闭 ComfyUI 后再重试更新。',
 
60
  'install.error_invalid_archive': '下载内容不是有效安装包,或压缩包结构不受支持。',
61
  'install.error_generic': '安装失败。请检查本地目录和网络后重试。',
62
  'install.diagnostics': '查看安装过程记录',
@@ -1763,6 +1764,7 @@ const translations = {
1763
  'install.error_package_too_large': 'The download exceeded the local safety limit. Installation was stopped.',
1764
  'install.error_download': 'The download failed repeatedly. Check the network before retrying.',
1765
  'install.error_target_occupied': 'Files from the previous version are in use. Close ComfyUI before retrying the update.',
 
1766
  'install.error_invalid_archive': 'The download is not a valid package or its archive structure is unsupported.',
1767
  'install.error_generic': 'Installation failed. Check the local folder and network before retrying.',
1768
  'install.diagnostics': 'View installation log',
 
57
  'install.error_package_too_large': '下载包超过本地安全上限,已停止安装。',
58
  'install.error_download': '下载多次中断。请检查网络后重试。',
59
  'install.error_target_occupied': '旧版本文件仍被占用。关闭 ComfyUI 后再重试更新。',
60
+ 'install.error_permission_denied': '无法修改安装目录。请检查文件占用和目录写入权限后重试。',
61
  'install.error_invalid_archive': '下载内容不是有效安装包,或压缩包结构不受支持。',
62
  'install.error_generic': '安装失败。请检查本地目录和网络后重试。',
63
  'install.diagnostics': '查看安装过程记录',
 
1764
  'install.error_package_too_large': 'The download exceeded the local safety limit. Installation was stopped.',
1765
  'install.error_download': 'The download failed repeatedly. Check the network before retrying.',
1766
  'install.error_target_occupied': 'Files from the previous version are in use. Close ComfyUI before retrying the update.',
1767
+ 'install.error_permission_denied': 'The installation folder could not be modified. Check for files in use and folder write permissions before retrying.',
1768
  'install.error_invalid_archive': 'The download is not a valid package or its archive structure is unsupported.',
1769
  'install.error_generic': 'Installation failed. Check the local folder and network before retrying.',
1770
  'install.diagnostics': 'View installation log',
前端页面/components/顶部广告组件.js CHANGED
@@ -30,6 +30,7 @@ let bannerContainer = null;
30
  // 本地缓存配置
31
  const BANNER_CACHE_KEY = CACHE.LOCAL_KEYS.BANNER_CONFIG;
32
  let bannerSyncGeneration = 0;
 
33
  let hasActiveConfig = false;
34
  // 保留已下载的横幅节点,提交时直接显示,避免重新赋值 src 再次联网。
35
  let activeBannerImage = null;
@@ -78,28 +79,42 @@ function readBannerCache() {
78
  }
79
 
80
  /** 确认新配置引用的图片均可下载后,再允许替换当前横幅。 */
81
- function preloadImage(url) {
82
  if (!url) return Promise.resolve();
83
  return new Promise((resolve, reject) => {
 
 
 
 
84
  const image = new Image();
 
 
85
  const finish = (error) => {
 
 
86
  clearTimeout(timer);
 
87
  image.onload = null;
88
  image.onerror = null;
89
- if (error) reject(error);
90
- else resolve(image);
 
 
91
  };
92
- const timer = setTimeout(() => {
93
- finish(new Error("Banner image loading timed out"));
94
- image.removeAttribute("src");
95
- }, API.BACKGROUND_TIMEOUT);
96
  image.onload = () => finish();
97
  image.onerror = () => finish(new Error("Banner image download failed"));
98
- image.src = url;
 
 
 
 
99
  });
100
  }
101
 
102
- async function prepareBannerConfig(data) {
103
  // 只有明确的 null 表示服务端关闭广告;缺失或异常数据视为更新失败。
104
  if (data === null) return { config: { ...BANNER_CONFIG }, image: null };
105
  const config = validateBannerConfig(data);
@@ -108,8 +123,8 @@ async function prepareBannerConfig(data) {
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)
113
  ]);
114
  return { config, image };
115
  }
@@ -120,6 +135,9 @@ async function prepareBannerConfig(data) {
120
  */
121
  async function syncBannerInBackground() {
122
  const generation = ++bannerSyncGeneration;
 
 
 
123
  try {
124
  // 绕过请求层缓存,确保拿到的确实是后台新配置。
125
  const res = await api.getPublicBannerConfig({
@@ -127,7 +145,7 @@ async function syncBannerInBackground() {
127
  });
128
  if (res && res.status === "success") {
129
  if (generation !== bannerSyncGeneration) return;
130
- const prepared = await prepareBannerConfig(res.data);
131
  const nextConfig = prepared.config;
132
  // 图片加载期间可能又发起了一次刷新,只让最新请求提交结果。
133
  if (generation !== bannerSyncGeneration) return;
@@ -155,7 +173,10 @@ async function syncBannerInBackground() {
155
  }
156
  } catch (e) {
157
  // 网络失败或图片下载失败:不写缓存、不替换当前已显示的缓存横幅。
158
- console.warn("后台更新广告配置失败(保留当前内容)");
 
 
 
159
  }
160
  }
161
 
 
30
  // 本地缓存配置
31
  const BANNER_CACHE_KEY = CACHE.LOCAL_KEYS.BANNER_CONFIG;
32
  let bannerSyncGeneration = 0;
33
+ let bannerSyncController = null;
34
  let hasActiveConfig = false;
35
  // 保留已下载的横幅节点,提交时直接显示,避免重新赋值 src 再次联网。
36
  let activeBannerImage = null;
 
79
  }
80
 
81
  /** 确认新配置引用的图片均可下载后,再允许替换当前横幅。 */
82
+ function preloadImage(url, signal) {
83
  if (!url) return Promise.resolve();
84
  return new Promise((resolve, reject) => {
85
+ if (signal.aborted) {
86
+ reject(signal.reason);
87
+ return;
88
+ }
89
  const image = new Image();
90
+ let settled = false;
91
+ let timer = null;
92
  const finish = (error) => {
93
+ if (settled) return;
94
+ settled = true;
95
  clearTimeout(timer);
96
+ signal.removeEventListener("abort", onAbort);
97
  image.onload = null;
98
  image.onerror = null;
99
+ if (error) {
100
+ image.removeAttribute("src");
101
+ reject(error);
102
+ } else resolve(image);
103
  };
104
+ const onAbort = () => finish(signal.reason);
105
+ signal.addEventListener("abort", onAbort, { once: true });
106
+ timer = setTimeout(() => finish(new Error("Banner image loading timed out")), API.BANNER_IMAGE_TIMEOUT);
 
107
  image.onload = () => finish();
108
  image.onerror = () => finish(new Error("Banner image download failed"));
109
+ try {
110
+ image.src = url;
111
+ } catch (error) {
112
+ finish(error);
113
+ }
114
  });
115
  }
116
 
117
+ async function prepareBannerConfig(data, signal) {
118
  // 只有明确的 null 表示服务端关闭广告;缺失或异常数据视为更新失败。
119
  if (data === null) return { config: { ...BANNER_CONFIG }, image: null };
120
  const config = validateBannerConfig(data);
 
123
  config.bannerImage = getCompleteImageUrl(config.bannerImage);
124
  config.detailImage = getCompleteImageUrl(config.detailImage);
125
  const [image] = await Promise.all([
126
+ preloadImage(config.bannerImage, signal),
127
+ preloadImage(config.detailImage, signal)
128
  ]);
129
  return { config, image };
130
  }
 
135
  */
136
  async function syncBannerInBackground() {
137
  const generation = ++bannerSyncGeneration;
138
+ bannerSyncController?.abort();
139
+ const controller = new AbortController();
140
+ bannerSyncController = controller;
141
  try {
142
  // 绕过请求层缓存,确保拿到的确实是后台新配置。
143
  const res = await api.getPublicBannerConfig({
 
145
  });
146
  if (res && res.status === "success") {
147
  if (generation !== bannerSyncGeneration) return;
148
+ const prepared = await prepareBannerConfig(res.data, controller.signal);
149
  const nextConfig = prepared.config;
150
  // 图片加载期间可能又发起了一次刷新,只让最新请求提交结果。
151
  if (generation !== bannerSyncGeneration) return;
 
173
  }
174
  } catch (e) {
175
  // 网络失败或图片下载失败:不写缓存、不替换当前已显示的缓存横幅。
176
+ if (!controller.signal.aborted) console.warn("后台更新广告配置失败(保留当前内容)");
177
+ } finally {
178
+ controller.abort();
179
+ if (bannerSyncController === controller) bannerSyncController = null;
180
  }
181
  }
182
 
前端页面/core/全局配置.js CHANGED
@@ -18,8 +18,10 @@
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'],
25
  CONDITIONAL_ETAG_PATTERN: /^W\/"rk-[a-f0-9]{64}"$/,
@@ -61,6 +63,7 @@ export const API = {
61
  // 云端不通时会把前台请求(详情页、点赞、轮询)全部挤到排队,表现为「切哪个界面都要等」。
62
  // 故一旦本地已有该请求的缓存(含过期),单次超时压到 10s 且不再重试,最坏耗时 10s
63
  BACKGROUND_TIMEOUT: 10000,
 
64
  WORKFLOW_DOWNLOAD_TIMEOUT: 120000, // 工作流流式下载预算(2 分钟)
65
  BACKGROUND_RETRIES: 0,
66
 
 
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, BANNER_IMAGE_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, ENDPOINT_ERROR_SCOPE_HEADER: string, ENDPOINT_ERROR_SCOPE_VALUE: string }} */
22
  export const API = {
23
+ ENDPOINT_ERROR_SCOPE_HEADER: "X-Ranking-Error-Scope",
24
+ ENDPOINT_ERROR_SCOPE_VALUE: "endpoint",
25
  // 仅公开榜单使用由正文派生的条件验证器,私有响应仍禁止留存。
26
  PUBLIC_CONDITIONAL_GET_PATHS: ['/api/items', '/api/creators', '/api/creators/search', '/api/posts', '/api/prompts', '/api/tasks'],
27
  CONDITIONAL_ETAG_PATTERN: /^W\/"rk-[a-f0-9]{64}"$/,
 
63
  // 云端不通时会把前台请求(详情页、点赞、轮询)全部挤到排队,表现为「切哪个界面都要等」。
64
  // 故一旦本地已有该请求的缓存(含过期),单次超时压到 10s 且不再重试,最坏耗时 10s
65
  BACKGROUND_TIMEOUT: 10000,
66
+ BANNER_IMAGE_TIMEOUT: 75000,
67
  WORKFLOW_DOWNLOAD_TIMEOUT: 120000, // 工作流流式下载预算(2 分钟)
68
  BACKGROUND_RETRIES: 0,
69
 
前端页面/core/网络请求_基础设施.js CHANGED
@@ -459,6 +459,7 @@ async function request(endpoint, options = {}) {
459
  }
460
  }
461
  let data;
 
462
  try {
463
  data = validatedData !== undefined ? validatedData
464
  : response.status === 204 ? {} : await response.json();
@@ -466,11 +467,12 @@ async function request(endpoint, options = {}) {
466
  // 错误响应的正文不可读时仍按 HTTP 状态处理,不能用缓存掩盖 401/403。
467
  if (!response.ok && (error instanceof SyntaxError || error instanceof TypeError || (timedOut && error.name === 'AbortError'))) {
468
  data = {};
 
469
  } else {
470
  throw error;
471
  }
472
  }
473
- return { response, data, validated: validatedData !== undefined };
474
  } catch (error) {
475
  transportFailed = error instanceof TypeError;
476
  throw error;
@@ -516,6 +518,15 @@ async function request(endpoint, options = {}) {
516
  throw error;
517
  }
518
 
 
 
 
 
 
 
 
 
 
519
  // Hugging Face Space / 网关不可用通常以 502、503 或 504 响应,
520
  // 这类响应证明请求到达了代理层,但云端当前无法提供服务。
521
  // 与超时/连接失败一样共享冷却,避免切换榜单时每个 endpoint
 
459
  }
460
  }
461
  let data;
462
+ let bodyConfirmed = true;
463
  try {
464
  data = validatedData !== undefined ? validatedData
465
  : response.status === 204 ? {} : await response.json();
 
467
  // 错误响应的正文不可读时仍按 HTTP 状态处理,不能用缓存掩盖 401/403。
468
  if (!response.ok && (error instanceof SyntaxError || error instanceof TypeError || (timedOut && error.name === 'AbortError'))) {
469
  data = {};
470
+ bodyConfirmed = false;
471
  } else {
472
  throw error;
473
  }
474
  }
475
+ return { response, data, bodyConfirmed, validated: validatedData !== undefined };
476
  } catch (error) {
477
  transportFailed = error instanceof TypeError;
478
  throw error;
 
518
  throw error;
519
  }
520
 
521
+ // 应用明确标记且正文可读的503只影响当前接口,仍允许其它榜单联网。
522
+ if (response.status === 503 && result.bodyConfirmed &&
523
+ response.headers?.get?.(API.ENDPOINT_ERROR_SCOPE_HEADER) === API.ENDPOINT_ERROR_SCOPE_VALUE) {
524
+ lastError = new Error(errorMsg);
525
+ lastError.status = response.status;
526
+ networkLevelFailure = false;
527
+ break;
528
+ }
529
+
530
  // Hugging Face Space / 网关不可用通常以 502、503 或 504 响应,
531
  // 这类响应证明请求到达了代理层,但云端当前无法提供服务。
532
  // 与超时/连接失败一样共享冷却,避免切换榜单时每个 endpoint
前端页面/market/资源安装引擎.js CHANGED
@@ -85,6 +85,7 @@ function _getInstallErrorMessage(result) {
85
  process_guard_unavailable: 'install.error_process_guard',
86
  download_failed: 'install.error_download',
87
  target_occupied: 'install.error_target_occupied',
 
88
  invalid_archive: 'install.error_invalid_archive',
89
  install_failed: 'install.error_generic'
90
  };
 
85
  process_guard_unavailable: 'install.error_process_guard',
86
  download_failed: 'install.error_download',
87
  target_occupied: 'install.error_target_occupied',
88
+ permission_denied: 'install.error_permission_denied',
89
  invalid_archive: 'install.error_invalid_archive',
90
  install_failed: 'install.error_generic'
91
  };
前端页面/post/讨论区组件.js CHANGED
@@ -61,10 +61,10 @@ function _matchesSearch(post, keyword) {
61
  */
62
  function _renderEmptyState(t) {
63
  return `
64
- <div style="grid-column: span 2; text-align: center; padding: 60px 20px; color: #666;">
65
- <div style="font-size: 48px; margin-bottom: 15px; opacity: 0.5;">📝</div>
66
- <div style="font-size: 14px; margin-bottom: 8px;">${t('post.no_posts')}</div>
67
- <div style="font-size: 12px; color: #888;">${t('post.be_first')}</div>
68
  </div>
69
  `;
70
  }
@@ -73,16 +73,13 @@ function _renderEmptyState(t) {
73
  * 🖼️ 生成头像 HTML 字符串
74
  * @param {string} avatar - 头像 URL
75
  * @param {string} name - 用户名(用于首字母回退)
76
- * @param {number} size - 头像尺寸(px)
77
  */
78
- function _generateAvatarHtml(avatar, name, size) {
79
  const initial = (name || 'U')[0].toUpperCase();
80
- // 🔒 XSS防护:avatar / name 均来自他人可修改的资料(昵称首字母可能是 < ),写入 HTML 前必须转义
81
  if (avatar) {
82
- return `<img class="swr-avatar" alt="" src="${escapeHtml(avatar)}" style="width: ${size}px; height: ${size}px; border-radius: 50%; object-fit: cover; border: 1px solid #444; background: #333;">`;
83
  }
84
- const fontSize = Math.max(9, Math.round(size * 0.5));
85
- return `<div class="swr-avatar" style="width: ${size}px; height: ${size}px; border-radius: 50%; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); display: flex; align-items: center; justify-content: center; color: white; font-size: ${fontSize}px; font-weight: bold;">${escapeHtml(initial)}</div>`;
86
  }
87
 
88
  /**
@@ -95,31 +92,21 @@ export function createPostsView(currentUser, keyword = "") {
95
  const searchKeyword = (keyword || "").substring(0, 100).toLowerCase();
96
 
97
  const container = document.createElement("div");
98
- Object.assign(container.style, {
99
- display: "flex",
100
- flexDirection: "column",
101
- gap: "0",
102
- color: "#ccc",
103
- fontSize: "14px",
104
- padding: "0",
105
- overflowY: "auto",
106
- flex: "1",
107
- boxSizing: "border-box"
108
- });
109
 
110
  container.innerHTML = `
111
  <!-- 🎯 瀑布流容器(发布按钮已移至顶部统一发布按钮) -->
112
- <div id="posts-grid" style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; padding: 15px; overflow-y: auto; flex: 1;">
113
  <!-- 加载占位 -->
114
- <div id="posts-loading" style="grid-column: span 2; text-align: center; padding: 40px; color: #888;">
115
- <div style="font-size: 24px; margin-bottom: 10px;">⏳</div>
116
  ${t('post.loading')}
117
  </div>
118
  </div>
119
 
120
  <!-- 加载更多 -->
121
- <div id="load-more-wrapper" style="display: none; padding: 15px; text-align: center; border-top: 1px solid var(--border-color, #333);">
122
- <button id="btn-load-more" style="background: var(--comfy-input-bg); border: 1px solid #555; color: #fff; padding: 10px 30px; border-radius: 6px; cursor: pointer; font-size: 13px; transition: 0.2s;" onmouseover="this.style.background='#444'" onmouseout="this.style.background='var(--comfy-input-bg)'">
123
  ${t('post.load_more')}
124
  </button>
125
  </div>
@@ -128,13 +115,13 @@ export function createPostsView(currentUser, keyword = "") {
128
  // 🔌 绑定事件
129
 
130
  // 加载帖子数据
131
- let currentPage = 1;
132
  let currentSort = "latest";
133
  let allPostsData = []; // 全量数据缓存
134
  let isLoadingFromNetwork = false;
135
  let listViewGeneration = 0;
136
  let networkRequestGeneration = 0;
137
  let disposed = false;
 
138
  const cardNavigation = createCardNavigationScope(window, () => !disposed, {
139
  captureSession: captureRequestSession,
140
  storageKeys: [CACHE.LEGACY_KEYS.TOKEN, CACHE.LEGACY_KEYS.USER]
@@ -203,6 +190,7 @@ export function createPostsView(currentUser, keyword = "") {
203
 
204
  currentPaginator = createPaginationLoader({
205
  container: scrollContainer,
 
206
  loadMore: loadMorePosts,
207
  pageSize: PAGE_SIZE,
208
  threshold: 200,
@@ -262,7 +250,6 @@ export function createPostsView(currentUser, keyword = "") {
262
  if (sort) {
263
  listViewGeneration++;
264
  currentSort = sort;
265
- currentPage = 1;
266
 
267
  // 🚀 重置分页器
268
  if (currentPaginator) {
@@ -307,30 +294,27 @@ export function createPostsView(currentUser, keyword = "") {
307
  // 创建瀑布流式骨架屏
308
  for (let i = 0; i < 4; i++) {
309
  const skeletonCard = document.createElement("div");
310
- skeletonCard.style.cssText = "background: var(--comfy-input-bg); border-radius: 10px; overflow: hidden; animation: pulse 1.5s infinite;";
 
311
  skeletonCard.innerHTML = `
312
- <div style="width: 100%; padding-top: 120%; background: linear-gradient(90deg, #2a2a2a 25%, #333 50%, #2a2a2a 75%); background-size: 200% 100%; animation: shimmer 1.5s infinite;"></div>
313
- <div style="padding: 10px;">
314
- <div style="height: 14px; background: #333; border-radius: 4px; margin-bottom: 8px;"></div>
315
- <div style="height: 12px; background: #333; border-radius: 4px; width: 60%;"></div>
316
  </div>
317
  `;
318
  postsGrid.appendChild(skeletonCard);
319
  }
320
- // 添加动画样式
321
- if (!document.getElementById("posts-skeleton-style")) {
322
- const style = document.createElement("style");
323
- style.id = "posts-skeleton-style";
324
- style.textContent = `
325
- @keyframes shimmer { 0% { background-position: -200% 0; } 100% { background-position: 200% 0; } }
326
- @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.7; } }
327
- `;
328
- document.head.appendChild(style);
329
- }
330
  };
331
 
332
  const loadPosts = async (page = 1, append = false) => {
333
  if (disposed) return;
 
334
  const viewGeneration = !append && page === 1 ? ++listViewGeneration : listViewGeneration;
335
  const requestedSort = currentSort;
336
  const cacheKey = `${CACHE_KEY_PREFIX}_${requestedSort}`;
@@ -370,6 +354,7 @@ export function createPostsView(currentUser, keyword = "") {
370
  return;
371
  }
372
  const posts = requireListResponseData(res);
 
373
  const total = res.total || 0;
374
  isLoadingFromNetwork = false;
375
 
@@ -459,6 +444,7 @@ export function createPostsView(currentUser, keyword = "") {
459
 
460
  // 从缓存渲染帖子列表
461
  const renderPostsFromCache = (posts, deferSearchScan = false) => {
 
462
  postsGrid.innerHTML = "";
463
 
464
  // 🔍 搜索过滤
@@ -577,8 +563,7 @@ export function createPostsView(currentUser, keyword = "") {
577
  // 加载更多按钮
578
  loadMoreBtn.onclick = () => {
579
  if (disposed) return;
580
- currentPage++;
581
- loadPosts(currentPage, true);
582
  };
583
 
584
  // 初始加载
@@ -615,15 +600,7 @@ export function createPostsView(currentUser, keyword = "") {
615
  */
616
  function createPostCard(post, currentUser = currentUserCache, isCurrent = () => true, captureNavigation = () => isCurrent) {
617
  const card = document.createElement("div");
618
- card.className = "business-list-card";
619
- Object.assign(card.style, {
620
- background: "var(--comfy-menu-bg)",
621
- borderRadius: "8px",
622
- overflow: "hidden",
623
- cursor: "pointer",
624
- transition: "transform 0.2s, box-shadow 0.2s",
625
- border: "1px solid var(--border-color, #333)"
626
- });
627
 
628
  // 格式化时间
629
  const timeStr = formatTime(post.created_at);
@@ -633,39 +610,35 @@ function createPostCard(post, currentUser = currentUserCache, isCurrent = () =>
633
 
634
  card.innerHTML = `
635
  <!-- 封面图 -->
636
- <div style="position: relative; width: 100%; padding-top: 100%; background: #111;">
637
  <img src="${escapeHtml(post.cover_image || 'data:image/svg+xml,...')}"
638
- style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: cover;"
639
  alt="">
640
  ${isVideo ? `
641
  <!-- 视频播放按钮 -->
642
- <div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
643
- width: 48px; height: 48px; background: rgba(0,0,0,0.6); border-radius: 50%;
644
- display: flex; align-items: center; justify-content: center; pointer-events: none;">
645
- <div style="width: 0; height: 0; border-left: 16px solid rgba(255,255,255,0.9);
646
- border-top: 10px solid transparent; border-bottom: 10px solid transparent;
647
- margin-left: 4px;"></div>
648
  </div>
649
  ` : ''}
650
  </div>
651
 
652
  <!-- 内容区 -->
653
- <div style="padding: 10px;">
654
  <!-- 标题 -->
655
  <button type="button" class="business-list-card__title">
656
  ${escapeHtml(post.title)}
657
  </button>
658
 
659
  <!-- 作者信息(SWR 缓存头像) -->
660
- <div class="business-list-card__author" style="display: flex; align-items: center; gap: 6px; margin-bottom: 6px;"></div>
661
 
662
  <!-- 互动数据 -->
663
- <div style="display: flex; align-items: center; gap: 12px; font-size: 11px; color: var(--rk-muted, var(--descrip-text, #888));">
664
  <span>❤️ ${escapeHtml(post.likes || 0)}</span>
665
  <span>🔖 ${escapeHtml(post.favorites || 0)}</span>
666
  <span>🔥 ${escapeHtml(post.views || 0)}</span>
667
- <span style="color: #FFD700;">★ ${(typeof post.rating_avg === 'number' && Number.isFinite(post.rating_avg) ? post.rating_avg : 0).toFixed(1)}</span>
668
- <span style="margin-left: auto; font-size: 10px;">${escapeHtml(timeStr)}</span>
669
  </div>
670
  </div>
671
  `;
@@ -683,9 +656,9 @@ function createPostCard(post, currentUser = currentUserCache, isCurrent = () =>
683
  const initial = (name || 'U')[0].toUpperCase();
684
 
685
  // 渲染初始头像
686
- const avatarHtml = _generateAvatarHtml(avatar, name, 18);
687
 
688
- authorContainer.innerHTML = `${avatarHtml}<span class="swr-name" style="font-size: 11px; color: var(--rk-muted, var(--descrip-text, #999)); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${escapeHtml(name)}</span>`;
689
 
690
  // 后台静默校对
691
  getProfileWithSWR(account, api.getUserProfile, (profile) => {
@@ -696,7 +669,7 @@ function createPostCard(post, currentUser = currentUserCache, isCurrent = () =>
696
  if (avatarEl.tagName === 'IMG') {
697
  avatarEl.src = profile.avatar;
698
  } else {
699
- avatarEl.outerHTML = _generateAvatarHtml(profile.avatar, profile.name || name, 18);
700
  }
701
  }
702
  if (nameEl && profile.name) {
 
61
  */
62
  function _renderEmptyState(t) {
63
  return `
64
+ <div class="posts-empty" role="status">
65
+ <div class="posts-empty__icon" aria-hidden="true">📝</div>
66
+ <div class="posts-empty__title">${t('post.no_posts')}</div>
67
+ <div class="posts-empty__hint">${t('post.be_first')}</div>
68
  </div>
69
  `;
70
  }
 
73
  * 🖼️ 生成头像 HTML 字符串
74
  * @param {string} avatar - 头像 URL
75
  * @param {string} name - 用户名(用于首字母回退)
 
76
  */
77
+ function _generateAvatarHtml(avatar, name) {
78
  const initial = (name || 'U')[0].toUpperCase();
 
79
  if (avatar) {
80
+ return `<img class="swr-avatar posts-card__avatar" alt="" src="${escapeHtml(avatar)}">`;
81
  }
82
+ return `<div class="swr-avatar posts-card__avatar posts-card__avatar--initial" aria-hidden="true">${escapeHtml(initial)}</div>`;
 
83
  }
84
 
85
  /**
 
92
  const searchKeyword = (keyword || "").substring(0, 100).toLowerCase();
93
 
94
  const container = document.createElement("div");
95
+ container.className = "posts-view";
 
 
 
 
 
 
 
 
 
 
96
 
97
  container.innerHTML = `
98
  <!-- 🎯 瀑布流容器(发布按钮已移至顶部统一发布按钮) -->
99
+ <div id="posts-grid" class="posts-grid">
100
  <!-- 加载占位 -->
101
+ <div id="posts-loading" class="posts-loading" role="status">
102
+ <div class="posts-loading__icon" aria-hidden="true">⏳</div>
103
  ${t('post.loading')}
104
  </div>
105
  </div>
106
 
107
  <!-- 加载更多 -->
108
+ <div id="load-more-wrapper" class="posts-more" style="display: none;">
109
+ <button type="button" id="btn-load-more" class="posts-more__button">
110
  ${t('post.load_more')}
111
  </button>
112
  </div>
 
115
  // 🔌 绑定事件
116
 
117
  // 加载帖子数据
 
118
  let currentSort = "latest";
119
  let allPostsData = []; // 全量数据缓存
120
  let isLoadingFromNetwork = false;
121
  let listViewGeneration = 0;
122
  let networkRequestGeneration = 0;
123
  let disposed = false;
124
+ let firstPageReady = false;
125
  const cardNavigation = createCardNavigationScope(window, () => !disposed, {
126
  captureSession: captureRequestSession,
127
  storageKeys: [CACHE.LEGACY_KEYS.TOKEN, CACHE.LEGACY_KEYS.USER]
 
190
 
191
  currentPaginator = createPaginationLoader({
192
  container: scrollContainer,
193
+ canLoad: () => !disposed && firstPageReady && !isLoadingFromNetwork,
194
  loadMore: loadMorePosts,
195
  pageSize: PAGE_SIZE,
196
  threshold: 200,
 
250
  if (sort) {
251
  listViewGeneration++;
252
  currentSort = sort;
 
253
 
254
  // 🚀 重置分页器
255
  if (currentPaginator) {
 
294
  // 创建瀑布流式骨架屏
295
  for (let i = 0; i < 4; i++) {
296
  const skeletonCard = document.createElement("div");
297
+ skeletonCard.className = "posts-skeleton";
298
+ skeletonCard.setAttribute('aria-hidden', 'true');
299
  skeletonCard.innerHTML = `
300
+ <div class="posts-skeleton__cover"></div>
301
+ <div class="posts-card__body">
302
+ <div class="posts-skeleton__line"></div>
303
+ <div class="posts-skeleton__line posts-skeleton__line--short"></div>
304
  </div>
305
  `;
306
  postsGrid.appendChild(skeletonCard);
307
  }
308
+ const status = document.createElement('div');
309
+ status.className = 'posts-loading';
310
+ status.setAttribute('role', 'status');
311
+ status.textContent = t('post.loading');
312
+ postsGrid.appendChild(status);
 
 
 
 
 
313
  };
314
 
315
  const loadPosts = async (page = 1, append = false) => {
316
  if (disposed) return;
317
+ if (!append && page === 1) firstPageReady = false;
318
  const viewGeneration = !append && page === 1 ? ++listViewGeneration : listViewGeneration;
319
  const requestedSort = currentSort;
320
  const cacheKey = `${CACHE_KEY_PREFIX}_${requestedSort}`;
 
354
  return;
355
  }
356
  const posts = requireListResponseData(res);
357
+ if (page === 1) firstPageReady = true;
358
  const total = res.total || 0;
359
  isLoadingFromNetwork = false;
360
 
 
444
 
445
  // 从缓存渲染帖子列表
446
  const renderPostsFromCache = (posts, deferSearchScan = false) => {
447
+ firstPageReady = true;
448
  postsGrid.innerHTML = "";
449
 
450
  // 🔍 搜索过滤
 
563
  // 加载更多按钮
564
  loadMoreBtn.onclick = () => {
565
  if (disposed) return;
566
+ currentPaginator?.load();
 
567
  };
568
 
569
  // 初始加载
 
600
  */
601
  function createPostCard(post, currentUser = currentUserCache, isCurrent = () => true, captureNavigation = () => isCurrent) {
602
  const card = document.createElement("div");
603
+ card.className = "business-list-card posts-card";
 
 
 
 
 
 
 
 
604
 
605
  // 格式化时间
606
  const timeStr = formatTime(post.created_at);
 
610
 
611
  card.innerHTML = `
612
  <!-- 封面图 -->
613
+ <div class="posts-card__cover">
614
  <img src="${escapeHtml(post.cover_image || 'data:image/svg+xml,...')}"
615
+ class="posts-card__image"
616
  alt="">
617
  ${isVideo ? `
618
  <!-- 视频播放按钮 -->
619
+ <div class="posts-card__play" aria-hidden="true">
620
+ <div class="posts-card__play-icon"></div>
 
 
 
 
621
  </div>
622
  ` : ''}
623
  </div>
624
 
625
  <!-- 内容区 -->
626
+ <div class="posts-card__body">
627
  <!-- 标题 -->
628
  <button type="button" class="business-list-card__title">
629
  ${escapeHtml(post.title)}
630
  </button>
631
 
632
  <!-- 作者信息(SWR 缓存头像) -->
633
+ <div class="business-list-card__author posts-card__author"></div>
634
 
635
  <!-- 互动数据 -->
636
+ <div class="posts-card__stats">
637
  <span>❤️ ${escapeHtml(post.likes || 0)}</span>
638
  <span>🔖 ${escapeHtml(post.favorites || 0)}</span>
639
  <span>🔥 ${escapeHtml(post.views || 0)}</span>
640
+ <span class="posts-card__rating">★ ${(typeof post.rating_avg === 'number' && Number.isFinite(post.rating_avg) ? post.rating_avg : 0).toFixed(1)}</span>
641
+ <span class="posts-card__time">${escapeHtml(timeStr)}</span>
642
  </div>
643
  </div>
644
  `;
 
656
  const initial = (name || 'U')[0].toUpperCase();
657
 
658
  // 渲染初始头像
659
+ const avatarHtml = _generateAvatarHtml(avatar, name);
660
 
661
+ authorContainer.innerHTML = `${avatarHtml}<span class="swr-name posts-card__name">${escapeHtml(name)}</span>`;
662
 
663
  // 后台静默校对
664
  getProfileWithSWR(account, api.getUserProfile, (profile) => {
 
669
  if (avatarEl.tagName === 'IMG') {
670
  avatarEl.src = profile.avatar;
671
  } else {
672
+ avatarEl.outerHTML = _generateAvatarHtml(profile.avatar, profile.name || name);
673
  }
674
  }
675
  if (nameEl && profile.name) {
前端页面/prompt/提示词组件.js CHANGED
@@ -164,7 +164,6 @@ export function createPromptsView(currentUser, keyword = "") {
164
  `;
165
 
166
  // 🔌 状态与元素引用
167
- let currentPage = 1;
168
  let currentSort = "latest";
169
  let currentType = "image"; // 当前模块:image / video / music
170
  let currentCategory = null; // 当前分类(null = 全部)
@@ -174,6 +173,7 @@ export function createPromptsView(currentUser, keyword = "") {
174
  let networkRequestGeneration = 0;
175
  let currentPaginator = null;
176
  let disposed = false;
 
177
  const cardNavigation = createCardNavigationScope(window, () => !disposed, {
178
  captureSession: captureRequestSession,
179
  storageKeys: [CACHE.LEGACY_KEYS.TOKEN, CACHE.LEGACY_KEYS.USER]
@@ -223,7 +223,6 @@ export function createPromptsView(currentUser, keyword = "") {
223
  chip.onclick = () => {
224
  if (disposed || currentCategory === value) return;
225
  currentCategory = value;
226
- currentPage = 1;
227
  allPromptsData = [];
228
  resetPagination();
229
  loadCategoriesAndReload();
@@ -332,6 +331,7 @@ export function createPromptsView(currentUser, keyword = "") {
332
 
333
  currentPaginator = createPaginationLoader({
334
  container: scrollContainer,
 
335
  loadMore: loadMorePrompts,
336
  pageSize: PAGE_SIZE,
337
  threshold: 200,
@@ -378,7 +378,6 @@ export function createPromptsView(currentUser, keyword = "") {
378
  if (sort) {
379
  listViewGeneration++;
380
  currentSort = sort;
381
- currentPage = 1;
382
 
383
  // 🚀 重置分页器
384
  resetPagination();
@@ -443,6 +442,7 @@ export function createPromptsView(currentUser, keyword = "") {
443
 
444
  const loadPrompts = async (page = 1, append = false) => {
445
  if (disposed) return;
 
446
  const viewGeneration = !append && page === 1 ? ++listViewGeneration : listViewGeneration;
447
  const requestedType = currentType;
448
  const requestedCategory = currentCategory;
@@ -483,6 +483,7 @@ export function createPromptsView(currentUser, keyword = "") {
483
  return;
484
  }
485
  const prompts = requireListResponseData(res);
 
486
  const total = res.total || 0;
487
  isLoadingFromNetwork = false;
488
 
@@ -571,6 +572,7 @@ export function createPromptsView(currentUser, keyword = "") {
571
 
572
  // 从缓存渲染提示词列表
573
  const renderPromptsFromCache = (prompts, deferSearchScan = false) => {
 
574
  promptsGrid.innerHTML = "";
575
 
576
  // 🔍 搜索过滤
@@ -693,7 +695,6 @@ export function createPromptsView(currentUser, keyword = "") {
693
  if (disposed || btn.dataset.type === currentType) return;
694
  currentType = btn.dataset.type;
695
  currentCategory = null;
696
- currentPage = 1;
697
  allPromptsData = [];
698
  syncTypeTabState();
699
  resetPagination();
@@ -704,8 +705,7 @@ export function createPromptsView(currentUser, keyword = "") {
704
  // 加载更多按钮
705
  loadMoreBtn.onclick = () => {
706
  if (disposed) return;
707
- currentPage++;
708
- loadPrompts(currentPage, true);
709
  };
710
 
711
  // 初始加载
 
164
  `;
165
 
166
  // 🔌 状态与元素引用
 
167
  let currentSort = "latest";
168
  let currentType = "image"; // 当前模块:image / video / music
169
  let currentCategory = null; // 当前分类(null = 全部)
 
173
  let networkRequestGeneration = 0;
174
  let currentPaginator = null;
175
  let disposed = false;
176
+ let firstPageReady = false;
177
  const cardNavigation = createCardNavigationScope(window, () => !disposed, {
178
  captureSession: captureRequestSession,
179
  storageKeys: [CACHE.LEGACY_KEYS.TOKEN, CACHE.LEGACY_KEYS.USER]
 
223
  chip.onclick = () => {
224
  if (disposed || currentCategory === value) return;
225
  currentCategory = value;
 
226
  allPromptsData = [];
227
  resetPagination();
228
  loadCategoriesAndReload();
 
331
 
332
  currentPaginator = createPaginationLoader({
333
  container: scrollContainer,
334
+ canLoad: () => !disposed && firstPageReady && !isLoadingFromNetwork,
335
  loadMore: loadMorePrompts,
336
  pageSize: PAGE_SIZE,
337
  threshold: 200,
 
378
  if (sort) {
379
  listViewGeneration++;
380
  currentSort = sort;
 
381
 
382
  // 🚀 重置分页器
383
  resetPagination();
 
442
 
443
  const loadPrompts = async (page = 1, append = false) => {
444
  if (disposed) return;
445
+ if (!append && page === 1) firstPageReady = false;
446
  const viewGeneration = !append && page === 1 ? ++listViewGeneration : listViewGeneration;
447
  const requestedType = currentType;
448
  const requestedCategory = currentCategory;
 
483
  return;
484
  }
485
  const prompts = requireListResponseData(res);
486
+ if (page === 1) firstPageReady = true;
487
  const total = res.total || 0;
488
  isLoadingFromNetwork = false;
489
 
 
572
 
573
  // 从缓存渲染提示词列表
574
  const renderPromptsFromCache = (prompts, deferSearchScan = false) => {
575
+ firstPageReady = true;
576
  promptsGrid.innerHTML = "";
577
 
578
  // 🔍 搜索过滤
 
695
  if (disposed || btn.dataset.type === currentType) return;
696
  currentType = btn.dataset.type;
697
  currentCategory = null;
 
698
  allPromptsData = [];
699
  syncTypeTabState();
700
  resetPagination();
 
705
  // 加载更多按钮
706
  loadMoreBtn.onclick = () => {
707
  if (disposed) return;
708
+ currentPaginator?.load();
 
709
  };
710
 
711
  // 初始加载
前端页面/styles/主题基础.css CHANGED
@@ -1609,3 +1609,92 @@ button.ranking-rating__star { cursor: pointer; min-width: 28px; min-height: 28px
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; } }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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; } }
1612
+
1613
+ .auth-view { display: flex; flex-direction: column; gap: 15px; flex: 1; min-width: 0; min-height: 0; padding: 15px; box-sizing: border-box; overflow-y: auto; background: var(--rk-bg, var(--comfy-menu-bg, #202020)); color: var(--rk-text, var(--fg-color, #ddd)); font-size: 14px; }
1614
+ .auth-view__back { align-self: flex-start; max-width: 100%; min-height: 36px; padding: 6px 14px; border: 1px solid var(--rk-border, var(--border-color, #555)); 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; }
1615
+ .auth-view .auth-view__title { margin: 0 0 10px; text-align: center; font-size: 16px; color: inherit; overflow-wrap: anywhere; }
1616
+ .auth-login { display: flex; flex-direction: column; gap: 15px; min-width: 0; }
1617
+ .auth-login__field { display: flex; flex-direction: column; gap: 5px; min-width: 0; }
1618
+ .auth-login__field label { overflow-wrap: anywhere; }
1619
+ .auth-login__required { color: var(--rk-danger, #ef5350); }
1620
+ .auth-login__input { display: block; width: 100%; min-width: 0; min-height: 40px; box-sizing: border-box; padding: 8px; border: 1px solid var(--rk-border, var(--border-color, #555)); border-radius: var(--rk-radius-sm, 6px); background: var(--rk-panel, var(--comfy-input-bg, #292929)); color: inherit; font: inherit; }
1621
+ .auth-login__options, .auth-login__actions, .auth-login__remember { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; min-width: 0; }
1622
+ .auth-login__options { justify-content: space-between; }
1623
+ .auth-login__remember { min-height: 36px; }
1624
+ .auth-login__remember input { margin: 0; cursor: pointer; }
1625
+ .auth-login__remember label { padding-block: 8px; cursor: pointer; font-size: 12px; overflow-wrap: anywhere; }
1626
+ .auth-login__actions { gap: 4px; }
1627
+ .auth-login__link { min-height: 36px; max-width: 100%; padding: 4px; border: 1px solid transparent; border-radius: var(--rk-radius-sm, 6px); background: transparent; color: inherit; font: inherit; font-size: 12px; font-weight: bold; text-decoration: underline; text-underline-offset: 3px; cursor: pointer; overflow-wrap: anywhere; }
1628
+ .auth-login__submit { width: 100%; min-height: 40px; padding: 10px; 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; }
1629
+ .auth-login__submit:disabled { opacity: .7; cursor: wait; }
1630
+ .auth-login__signup { text-align: center; font-size: 12px; overflow-wrap: anywhere; }
1631
+ .auth-view__back:hover, .auth-login__link:hover, .auth-login__submit:hover { border-color: var(--rk-focus, #8ab4ff); }
1632
+ .auth-view__back:focus-visible, .auth-login button:focus-visible, .auth-login input:focus-visible { outline: 2px solid var(--rk-focus, #8ab4ff); outline-offset: 2px; }
1633
+
1634
+ .auth-form { display: flex; flex-direction: column; gap: 14px; min-width: 0; }
1635
+ .auth-form__field { display: flex; flex-direction: column; gap: 5px; min-width: 0; }
1636
+ .auth-form label, .auth-form legend { overflow-wrap: anywhere; }
1637
+ .auth-form__required { color: var(--rk-danger, #ef5350); }
1638
+ .auth-form__description { margin: 0; color: var(--rk-muted, var(--descrip-text, #a4a4a4)); font-size: 13px; line-height: 1.6; overflow-wrap: anywhere; }
1639
+ .auth-form__security { display: flex; flex-direction: column; gap: 8px; min-width: 0; margin: 0; padding: 10px; border: 1px dashed var(--rk-border, var(--border-color, #555)); border-radius: var(--rk-radius-sm, 6px); }
1640
+ .auth-form__security legend { max-width: 100%; box-sizing: border-box; padding: 0 4px; font-weight: bold; }
1641
+ .auth-form__code-row { display: flex; flex-wrap: wrap; gap: 8px; min-width: 0; }
1642
+ .auth-form__code-row .auth-form__input { flex: 1 1 140px; }
1643
+ .auth-form__input { width: 100%; min-width: 0; min-height: 40px; box-sizing: border-box; padding: 8px; border: 1px solid var(--rk-border, var(--border-color, #555)); border-radius: var(--rk-radius-sm, 6px); background: var(--rk-panel, var(--comfy-input-bg, #292929)); color: inherit; font: inherit; }
1644
+ .auth-form__input::placeholder { color: var(--rk-muted, var(--descrip-text, #a4a4a4)); opacity: 1; }
1645
+ .auth-form__button { min-height: 40px; max-width: 100%; padding: 8px 12px; border: 1px solid var(--rk-border, var(--border-color, #555)); 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; }
1646
+ .auth-form__primary { width: 100%; border-color: var(--rk-accent, #3b82f6); }
1647
+ .auth-form__button:disabled { opacity: .7; cursor: wait; }
1648
+ .auth-form__footer { text-align: center; font-size: 12px; }
1649
+ .auth-form__link { min-height: 36px; max-width: 100%; padding: 4px; border: 1px solid transparent; border-radius: var(--rk-radius-sm, 6px); background: transparent; color: inherit; font: inherit; text-decoration: underline; text-underline-offset: 3px; cursor: pointer; overflow-wrap: anywhere; }
1650
+ .auth-form__button:hover, .auth-form__link:hover { border-color: var(--rk-focus, #8ab4ff); }
1651
+ .auth-form button:focus-visible, .auth-form input:focus-visible, .auth-form select:focus-visible, .auth-form textarea:focus-visible { outline: 2px solid var(--rk-focus, #8ab4ff); outline-offset: 2px; }
1652
+ .auth-form__result { display: flex; flex-direction: column; gap: 8px; min-width: 0; padding: 15px; border: 1px solid var(--rk-success, #4caf50); border-radius: var(--rk-radius-sm, 6px); text-align: center; }
1653
+ .auth-form__result-title { font-weight: bold; overflow-wrap: anywhere; }
1654
+ .auth-form__account { font-size: 20px; font-weight: bold; letter-spacing: 1px; user-select: text; overflow-wrap: anywhere; }
1655
+
1656
+ .auth-form__grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 160px), 1fr)); gap: 12px; min-width: 0; }
1657
+ .auth-form__profile { padding: 10px; border: 1px dashed var(--rk-border, var(--border-color, #555)); border-radius: var(--rk-radius-sm, 6px); background: var(--rk-panel, var(--comfy-input-bg, #292929)); }
1658
+ .auth-form__avatar-row { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; min-width: 0; }
1659
+ .auth-form__avatar { width: 40px; height: 40px; border-radius: 50%; object-fit: cover; border: 2px solid var(--rk-border, var(--border-color, #555)); box-sizing: border-box; }
1660
+ .auth-form__file { width: 100%; min-width: 0; max-width: 100%; min-height: 36px; color: inherit; font: inherit; font-size: 12px; }
1661
+ .auth-form__file::file-selector-button { min-height: 36px; max-width: 100%; padding: 4px; border: 1px solid var(--rk-border, var(--border-color, #555)); border-radius: var(--rk-radius-sm, 6px); background: var(--rk-bg, var(--comfy-menu-bg, #202020)); color: inherit; font: inherit; cursor: pointer; }
1662
+ .auth-form__error { color: var(--rk-danger, #ef5350); font-size: 12px; overflow-wrap: anywhere; }
1663
+ .auth-form__age { color: var(--rk-muted, var(--descrip-text, #a4a4a4)); font-size: 12px; overflow-wrap: anywhere; }
1664
+ .auth-form__textarea { resize: vertical; }
1665
+
1666
+ .posts-view { container-type: inline-size; container-name: ranking-posts; display: flex; flex-direction: column; flex: 1; min-width: 0; min-height: 0; overflow-y: auto; color: var(--rk-text, var(--fg-color, #ddd)); font-size: 14px; box-sizing: border-box; }
1667
+ .posts-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); align-content: start; gap: 12px; padding: 15px; min-width: 0; min-height: 0; overflow-y: auto; flex: 1; }
1668
+ .posts-loading, .posts-empty { grid-column: 1 / -1; text-align: center; padding: 40px 12px; color: var(--rk-muted, var(--descrip-text, #a4a4a4)); overflow-wrap: anywhere; }
1669
+ .posts-loading__icon { font-size: 24px; margin-bottom: 10px; }
1670
+ .posts-empty { padding: 60px 20px; }
1671
+ .posts-empty__icon { font-size: 48px; margin-bottom: 15px; }
1672
+ .posts-empty__title { font-size: 14px; margin-bottom: 8px; }
1673
+ .posts-empty__hint { font-size: 12px; }
1674
+ .posts-more { padding: 15px; text-align: center; border-top: 1px solid var(--rk-border, var(--border-color, #555)); }
1675
+ .posts-more__button { min-height: 36px; max-width: 100%; padding: 10px 30px; border: 1px solid var(--rk-border, var(--border-color, #555)); border-radius: 6px; background: var(--rk-panel, var(--comfy-input-bg, #292929)); color: inherit; font: inherit; font-size: 13px; overflow-wrap: anywhere; cursor: pointer; }
1676
+ .posts-more__button:hover { border-color: var(--rk-focus, #8ab4ff); }
1677
+ .posts-more__button:focus-visible, .posts-card .business-list-card__title:focus-visible { outline: 2px solid var(--rk-focus, #8ab4ff); outline-offset: -2px; }
1678
+ .posts-card { border: 1px solid var(--rk-border, var(--border-color, #555)); border-radius: 8px; background: var(--rk-panel, var(--comfy-menu-bg, #292929)); overflow: hidden; cursor: pointer; transition: transform .2s, box-shadow .2s; }
1679
+ .posts-card__cover { position: relative; width: 100%; padding-top: 100%; background: var(--rk-panel, var(--comfy-input-bg, #292929)); }
1680
+ .posts-card__image { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
1681
+ .posts-card__play { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 48px; height: 48px; border-radius: 50%; background: rgba(0,0,0,.6); display: flex; align-items: center; justify-content: center; pointer-events: none; }
1682
+ .posts-card__play-icon { width: 0; height: 0; border-left: 16px solid white; border-top: 10px solid transparent; border-bottom: 10px solid transparent; margin-left: 4px; }
1683
+ .posts-card__body { padding: 10px; min-width: 0; }
1684
+ .posts-card .business-list-card__title { min-height: 28px; }
1685
+ .posts-card__author { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; min-width: 0; }
1686
+ .posts-card__avatar { width: 18px; height: 18px; flex: 0 0 18px; border: 1px solid var(--rk-border, var(--border-color, #555)); border-radius: 50%; object-fit: cover; background: var(--rk-panel, var(--comfy-input-bg, #292929)); box-sizing: border-box; }
1687
+ .posts-card__avatar--initial { display: flex; align-items: center; justify-content: center; color: var(--rk-text, var(--fg-color, #ddd)); font-size: 9px; font-weight: bold; }
1688
+ .posts-card__name { font-size: 11px; color: var(--rk-muted, var(--descrip-text, #a4a4a4)); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1689
+ .posts-card__stats { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 12px; min-width: 0; font-size: 11px; color: var(--rk-muted, var(--descrip-text, #a4a4a4)); overflow-wrap: anywhere; }
1690
+ .posts-card__stats > span { min-width: 0; max-width: 100%; }
1691
+ .posts-card__rating { color: inherit; }
1692
+ .posts-card__time { margin-left: auto; font-size: 10px; }
1693
+ .posts-skeleton { border-radius: 10px; background: var(--rk-panel, var(--comfy-input-bg, #292929)); overflow: hidden; animation: ranking-post-pulse 1.5s infinite; }
1694
+ .posts-skeleton__cover { width: 100%; padding-top: 120%; background: var(--rk-border, var(--border-color, #555)); }
1695
+ .posts-skeleton__line { height: 14px; background: var(--rk-border, var(--border-color, #555)); border-radius: 4px; margin-bottom: 8px; }
1696
+ .posts-skeleton__line--short { height: 12px; width: 60%; margin-bottom: 0; }
1697
+ @keyframes ranking-post-pulse { 0%, 100% { opacity: 1; } 50% { opacity: .7; } }
1698
+ @container ranking-posts (max-width: 280px) { .posts-grid { grid-template-columns: minmax(0, 1fr); padding: 10px; } }
1699
+ @media (prefers-reduced-motion: reduce) { .posts-skeleton { animation: none; } }
1700
+ @media (forced-colors: active) { .posts-card__play { background: Canvas; border: 1px solid CanvasText; } .posts-card__play-icon { border-left-color: CanvasText; } }
前端页面/task/任务榜组件.js CHANGED
@@ -119,7 +119,6 @@ export function createTasksView(currentUser, keyword = "") {
119
  const loadMoreWrapper = container.querySelector("#load-more-wrapper");
120
  const loadMoreBtn = container.querySelector("#btn-load-more");
121
 
122
- let currentPage = 1;
123
  let currentStatus = "";
124
  let currentSort = "latest";
125
  let allTasksData = []; // 全量数据缓存
@@ -127,6 +126,7 @@ export function createTasksView(currentUser, keyword = "") {
127
  let listViewGeneration = 0;
128
  let networkRequestGeneration = 0;
129
  let disposed = false;
 
130
  const cardNavigation = createCardNavigationScope(window, () => !disposed, {
131
  captureSession: captureRequestSession,
132
  storageKeys: [CACHE.LEGACY_KEYS.TOKEN, CACHE.LEGACY_KEYS.USER]
@@ -157,7 +157,6 @@ export function createTasksView(currentUser, keyword = "") {
157
  // 状态变化时,需要重新加载数据(因为后端按状态过滤)
158
  if (status !== undefined && status !== currentStatus) {
159
  currentStatus = status;
160
- currentPage = 1;
161
  if (paginator) paginator.reset();
162
  loadTasks(1, false);
163
  return;
@@ -166,7 +165,6 @@ export function createTasksView(currentUser, keyword = "") {
166
  // 排序变化时,本地排序优先
167
  if (sort) {
168
  currentSort = sort;
169
- currentPage = 1;
170
  if (paginator) paginator.reset();
171
 
172
  // 🚀 本地排序优先:已有数据时直接本地排序渲染
@@ -236,6 +234,7 @@ export function createTasksView(currentUser, keyword = "") {
236
  // 加载任务列表(支持缓存优先)
237
  const loadTasks = async (page = 1, append = false) => {
238
  if (disposed) return;
 
239
  const viewGeneration = !append && page === 1 ? ++listViewGeneration : listViewGeneration;
240
  const requestedStatus = currentStatus;
241
  const requestedSort = currentSort;
@@ -272,6 +271,7 @@ export function createTasksView(currentUser, keyword = "") {
272
  return;
273
  }
274
  const tasks = requireListResponseData(res);
 
275
  const total = res.total || 0;
276
  isLoadingFromNetwork = false;
277
 
@@ -352,6 +352,7 @@ export function createTasksView(currentUser, keyword = "") {
352
 
353
  // 从缓存渲染任务列表
354
  const renderTasksFromCache = (tasks) => {
 
355
  tasksList.innerHTML = "";
356
 
357
  // 🔍 搜索过滤
@@ -464,8 +465,7 @@ export function createTasksView(currentUser, keyword = "") {
464
  // 加载更多(原有按钮,将被自动分页替代)
465
  loadMoreBtn.onclick = () => {
466
  if (disposed) return;
467
- currentPage++;
468
- loadTasks(currentPage, true);
469
  };
470
 
471
  // 初始加载
@@ -479,6 +479,7 @@ export function createTasksView(currentUser, keyword = "") {
479
  const scrollContainer = container.closest('.sidebar-scroll-container') || container;
480
  paginator = createPaginationLoader({
481
  container: scrollContainer,
 
482
  autoFill: () => Boolean(searchKeyword) && !allTasksData.some(task => _filterTasksByKeyword([task], searchKeyword).length),
483
  loadMore: async (page, pageSize, { isCurrent = () => true } = {}) => {
484
  if (disposed || !isCurrent()) return [];
 
119
  const loadMoreWrapper = container.querySelector("#load-more-wrapper");
120
  const loadMoreBtn = container.querySelector("#btn-load-more");
121
 
 
122
  let currentStatus = "";
123
  let currentSort = "latest";
124
  let allTasksData = []; // 全量数据缓存
 
126
  let listViewGeneration = 0;
127
  let networkRequestGeneration = 0;
128
  let disposed = false;
129
+ let firstPageReady = false;
130
  const cardNavigation = createCardNavigationScope(window, () => !disposed, {
131
  captureSession: captureRequestSession,
132
  storageKeys: [CACHE.LEGACY_KEYS.TOKEN, CACHE.LEGACY_KEYS.USER]
 
157
  // 状态变化时,需要重新加载数据(因为后端按状态过滤)
158
  if (status !== undefined && status !== currentStatus) {
159
  currentStatus = status;
 
160
  if (paginator) paginator.reset();
161
  loadTasks(1, false);
162
  return;
 
165
  // 排序变化时,本地排序优先
166
  if (sort) {
167
  currentSort = sort;
 
168
  if (paginator) paginator.reset();
169
 
170
  // 🚀 本地排序优先:已有数据时直接本地排序渲染
 
234
  // 加载任务列表(支持缓存优先)
235
  const loadTasks = async (page = 1, append = false) => {
236
  if (disposed) return;
237
+ if (!append && page === 1) firstPageReady = false;
238
  const viewGeneration = !append && page === 1 ? ++listViewGeneration : listViewGeneration;
239
  const requestedStatus = currentStatus;
240
  const requestedSort = currentSort;
 
271
  return;
272
  }
273
  const tasks = requireListResponseData(res);
274
+ if (page === 1) firstPageReady = true;
275
  const total = res.total || 0;
276
  isLoadingFromNetwork = false;
277
 
 
352
 
353
  // 从缓存渲染任务列表
354
  const renderTasksFromCache = (tasks) => {
355
+ firstPageReady = true;
356
  tasksList.innerHTML = "";
357
 
358
  // 🔍 搜索过滤
 
465
  // 加载更多(原有按钮,将被自动分页替代)
466
  loadMoreBtn.onclick = () => {
467
  if (disposed) return;
468
+ paginator?.load();
 
469
  };
470
 
471
  // 初始加载
 
479
  const scrollContainer = container.closest('.sidebar-scroll-container') || container;
480
  paginator = createPaginationLoader({
481
  container: scrollContainer,
482
+ canLoad: () => !disposed && firstPageReady && !isLoadingFromNetwork,
483
  autoFill: () => Boolean(searchKeyword) && !allTasksData.some(task => _filterTasksByKeyword([task], searchKeyword).length),
484
  loadMore: async (page, pageSize, { isCurrent = () => true } = {}) => {
485
  if (disposed || !isCurrent()) return [];