Spaces:
Running
Running
Upload 103 files
Browse files- 前端页面/auth/找回账号表单组件.js +27 -23
- 前端页面/auth/注册表单组件.js +37 -50
- 前端页面/auth/用户注册登录组件.js +23 -12
- 前端页面/auth/登录表单组件.js +14 -14
- 前端页面/auth/重置密码表单组件.js +19 -23
- 前端页面/components/性能优化工具.js +5 -0
- 前端页面/components/用户体验_国际化.js +2 -0
- 前端页面/components/顶部广告组件.js +34 -13
- 前端页面/core/全局配置.js +4 -1
- 前端页面/core/网络请求_基础设施.js +12 -1
- 前端页面/market/资源安装引擎.js +1 -0
- 前端页面/post/讨论区组件.js +43 -70
- 前端页面/prompt/提示词组件.js +6 -6
- 前端页面/styles/主题基础.css +89 -0
- 前端页面/task/任务榜组件.js +6 -5
前端页面/auth/找回账号表单组件.js
CHANGED
|
@@ -18,25 +18,27 @@ import { escapeHtml } from "../components/互动工具函数.js";
|
|
| 18 |
|
| 19 |
export function renderRecoverForm(container, switchView) {
|
| 20 |
container.innerHTML = `
|
| 21 |
-
<div
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
<
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
| 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
|
| 121 |
-
<div
|
| 122 |
-
|
| 123 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 33 |
-
<div
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
<div
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
<
|
| 44 |
-
|
| 45 |
-
<
|
| 46 |
-
|
| 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 |
-
<
|
| 51 |
-
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
</div>
|
|
|
|
| 54 |
</div>
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 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
|
| 74 |
-
<
|
|
|
|
| 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.
|
| 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
|
| 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
|
| 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 |
-
|
| 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"
|
| 21 |
-
|
| 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
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
<div
|
| 12 |
-
<
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
<
|
| 17 |
-
|
|
|
|
|
|
|
| 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
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
<
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
| 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)
|
| 90 |
-
|
|
|
|
|
|
|
| 91 |
};
|
| 92 |
-
const
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
}, API.BACKGROUND_TIMEOUT);
|
| 96 |
image.onload = () => finish();
|
| 97 |
image.onerror = () => finish(new Error("Banner image download failed"));
|
| 98 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 65 |
-
<div
|
| 66 |
-
<div
|
| 67 |
-
<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
|
| 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)}"
|
| 83 |
}
|
| 84 |
-
|
| 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 |
-
|
| 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"
|
| 113 |
<!-- 加载占位 -->
|
| 114 |
-
<div id="posts-loading"
|
| 115 |
-
<div
|
| 116 |
${t('post.loading')}
|
| 117 |
</div>
|
| 118 |
</div>
|
| 119 |
|
| 120 |
<!-- 加载更多 -->
|
| 121 |
-
<div id="load-more-wrapper" style="display: none;
|
| 122 |
-
<button id="btn-load-more"
|
| 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.
|
|
|
|
| 311 |
skeletonCard.innerHTML = `
|
| 312 |
-
<div
|
| 313 |
-
<div
|
| 314 |
-
<div
|
| 315 |
-
<div
|
| 316 |
</div>
|
| 317 |
`;
|
| 318 |
postsGrid.appendChild(skeletonCard);
|
| 319 |
}
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 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 |
-
|
| 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
|
| 637 |
<img src="${escapeHtml(post.cover_image || 'data:image/svg+xml,...')}"
|
| 638 |
-
|
| 639 |
alt="">
|
| 640 |
${isVideo ? `
|
| 641 |
<!-- 视频播放按钮 -->
|
| 642 |
-
<div
|
| 643 |
-
|
| 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
|
| 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
|
| 661 |
|
| 662 |
<!-- 互动数据 -->
|
| 663 |
-
<div
|
| 664 |
<span>❤️ ${escapeHtml(post.likes || 0)}</span>
|
| 665 |
<span>🔖 ${escapeHtml(post.favorites || 0)}</span>
|
| 666 |
<span>🔥 ${escapeHtml(post.views || 0)}</span>
|
| 667 |
-
<span
|
| 668 |
-
<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
|
| 687 |
|
| 688 |
-
authorContainer.innerHTML = `${avatarHtml}<span class="swr-name
|
| 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
|
| 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 |
-
|
| 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 |
-
|
| 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 [];
|