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