Spaces:
Running
Running
前端优化
Browse files- 前端页面/components/UI交互提示组件.js +38 -111
- 前端页面/components/互动工具函数.js +8 -14
- 前端页面/components/全局设置组件.js +1 -1
- 前端页面/components/图片展示测试.js +4 -1
- 前端页面/components/图片沙盒组件.js +6 -3
- 前端页面/components/图片裁剪组件.js +27 -8
- 前端页面/components/性能优化工具.js +38 -2
- 前端页面/components/打赏等级工具.js +7 -5
- 前端页面/components/用户体验_国际化.js +159 -6
- 前端页面/components/视频播放器组件.js +14 -14
- 前端页面/components/顶部导航组件.js +11 -11
- 前端页面/core/侧边栏主程序.js +7 -7
- 前端页面/core/侧边栏数据引擎.js +162 -62
- 前端页面/core/全局配置.js +51 -9
- 前端页面/core/状态管理.js +16 -6
- 前端页面/core/网络请求API.js +4 -0
- 前端页面/core/网络请求_业务API.js +4 -1
- 前端页面/core/网络请求_图片代理.js +2 -1
- 前端页面/core/网络请求_基础设施.js +65 -15
- 前端页面/core/网络请求_缓存管理.js +69 -18
- 前端页面/market/列表卡片组件.js +8 -5
- 前端页面/market/创作者卡片组件.js +11 -9
- 前端页面/market/发布内容_UI模板.js +3 -2
- 前端页面/market/发布内容_提交引擎.js +7 -4
- 前端页面/market/发布内容组件.js +2 -1
- 前端页面/market/资源安装引擎.js +30 -19
- 前端页面/market/资源详情页面组件.js +8 -6
- 前端页面/market/资金与钱包_充值组件.js +4 -4
- 前端页面/market/资金与钱包_提现组件.js +1 -1
- 前端页面/post/发布帖子组件.js +31 -29
- 前端页面/post/帖子详情组件.js +8 -9
- 前端页面/post/讨论区组件.js +10 -9
- 前端页面/profile/个人中心_UI模板.js +4 -2
- 前端页面/profile/个人中心视图.js +1 -1
- 前端页面/profile/个人列表组件.js +51 -47
- 前端页面/profile/个人设置表单组件.js +7 -7
- 前端页面/prompt/发布提示词组件.js +8 -10
- 前端页面/prompt/提示词组件.js +12 -9
- 前端页面/prompt/提示词详情组件.js +7 -6
- 前端页面/social/私信聊天组件.js +21 -19
- 前端页面/social/评论与互动组件.js +3 -3
- 前端页面/social/通知中心组件.js +5 -3
- 前端页面/task/任务榜组件.js +9 -8
- 前端页面/task/任务详情组件.js +24 -22
- 前端页面/task/发布任务组件.js +12 -9
- 前端页面/task/申诉详情组件.js +20 -13
前端页面/components/UI交互提示组件.js
CHANGED
|
@@ -9,12 +9,13 @@
|
|
| 9 |
// ==========================================
|
| 10 |
// 🎯 P1体验优化:
|
| 11 |
// - 统一错误处理与重试机制
|
| 12 |
-
// - 网络状态检测与离线提示
|
| 13 |
// - 按钮加载状态
|
| 14 |
// - 全局加载遮罩
|
|
|
|
| 15 |
// ===========================================
|
| 16 |
|
| 17 |
import { t } from "./用户体验增强.js";
|
|
|
|
| 18 |
|
| 19 |
// ==========================================
|
| 20 |
// 📢 Toast 消息提示
|
|
@@ -72,9 +73,11 @@ export function showToast(message, type = "info", duration = 3000) {
|
|
| 72 |
wordBreak: "break-word"
|
| 73 |
});
|
| 74 |
|
|
|
|
|
|
|
| 75 |
toast.innerHTML = `
|
| 76 |
<span style="font-size: 16px;">${icons[type] || icons.info}</span>
|
| 77 |
-
<span style="flex: 1;">${message}</span>
|
| 78 |
<span class="toast-close" style="cursor: pointer; opacity: 0.7; font-size: 18px; margin-left: 8px;">×</span>
|
| 79 |
`;
|
| 80 |
|
|
@@ -192,6 +195,7 @@ function _closeDialog(overlay, box, hasYTranslate = true, callback) {
|
|
| 192 |
* @param {string} options.confirmText - 确认按钮文字
|
| 193 |
* @param {string} options.cancelText - 取消按钮文字
|
| 194 |
* @param {string} options.type - 类型: 'warning', 'danger', 'info'
|
|
|
|
| 195 |
* @returns {Promise<boolean>}
|
| 196 |
*/
|
| 197 |
export function showConfirm(message, options = {}) {
|
|
@@ -199,7 +203,8 @@ export function showConfirm(message, options = {}) {
|
|
| 199 |
title = "操作确认",
|
| 200 |
confirmText = "确认执行",
|
| 201 |
cancelText = "取消",
|
| 202 |
-
type = "warning"
|
|
|
|
| 203 |
} = options;
|
| 204 |
|
| 205 |
const typeConfig = {
|
|
@@ -216,12 +221,12 @@ export function showConfirm(message, options = {}) {
|
|
| 216 |
box.innerHTML = `
|
| 217 |
<div style="font-size: 18px; font-weight: bold; color: ${config.color}; display: flex; align-items: center; gap: 10px;">
|
| 218 |
<span style="font-size: 24px;">${config.icon}</span>
|
| 219 |
-
<span>${title}</span>
|
| 220 |
</div>
|
| 221 |
-
<div style="font-size: 14px; color: #ccc; line-height: 1.7; padding: 8px 0;">${message}</div>
|
| 222 |
<div style="display: flex; gap: 12px; justify-content: flex-end; margin-top: 8px;">
|
| 223 |
-
<button id="ui-btn-cancel" style="padding: 10px 20px; background: transparent; border: 1px solid #555; color: #aaa; border-radius: 6px; cursor: pointer; transition: 0.2s; font-size: 14px;">${cancelText}</button>
|
| 224 |
-
<button id="ui-btn-confirm" style="padding: 10px 20px; background: ${config.btnColor}; border: none; color: #fff; border-radius: 6px; cursor: pointer; font-weight: bold; box-shadow: 0 2px 8px ${config.btnColor}44; transition: 0.2s; font-size: 14px;">${confirmText}</button>
|
| 225 |
</div>
|
| 226 |
`;
|
| 227 |
|
|
@@ -265,13 +270,15 @@ export function showConfirm(message, options = {}) {
|
|
| 265 |
* @param {string} message - 错误信息
|
| 266 |
* @param {Function} retryFn - 重试函数
|
| 267 |
* @param {Object} options - 配置选项
|
|
|
|
| 268 |
* @returns {Promise<boolean>} - 用户选择重试返回true
|
| 269 |
*/
|
| 270 |
export function showRetryDialog(message, retryFn, options = {}) {
|
| 271 |
const {
|
| 272 |
title = "操作失败",
|
| 273 |
retryText = "🔄 重试",
|
| 274 |
-
cancelText = "关闭"
|
|
|
|
| 275 |
} = options;
|
| 276 |
|
| 277 |
return new Promise((resolve) => {
|
|
@@ -291,14 +298,14 @@ export function showRetryDialog(message, retryFn, options = {}) {
|
|
| 291 |
box.innerHTML = `
|
| 292 |
<div style="text-align: center;">
|
| 293 |
<div style="font-size: 48px; margin-bottom: 16px;">😕</div>
|
| 294 |
-
<div style="font-size: 20px; font-weight: bold; color: #F44336;">${title}</div>
|
| 295 |
</div>
|
| 296 |
<div style="font-size: 14px; color: #bbb; line-height: 1.7; text-align: center; padding: 0 10px; background: #1a1a1a; border-radius: 8px; padding: 16px;">
|
| 297 |
-
${message}
|
| 298 |
</div>
|
| 299 |
<div style="display: flex; gap: 12px; justify-content: center;">
|
| 300 |
-
<button id="ui-btn-cancel" style="flex: 1; padding: 12px 20px; background: #333; border: 1px solid #555; color: #aaa; border-radius: 8px; cursor: pointer; transition: 0.2s; font-size: 14px;">${cancelText}</button>
|
| 301 |
-
<button id="ui-btn-retry" style="flex: 1; padding: 12px 20px; background: linear-gradient(135deg, #2196F3, #1976D2); border: none; color: #fff; border-radius: 8px; cursor: pointer; font-weight: bold; transition: 0.2s; font-size: 14px;">${retryText}</button>
|
| 302 |
</div>
|
| 303 |
`;
|
| 304 |
|
|
@@ -327,9 +334,15 @@ export function showRetryDialog(message, retryFn, options = {}) {
|
|
| 327 |
close();
|
| 328 |
resolve(true);
|
| 329 |
} catch (error) {
|
| 330 |
-
// 重试失败,更新错误信息
|
| 331 |
-
|
| 332 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 333 |
retryBtn.disabled = false;
|
| 334 |
}
|
| 335 |
};
|
|
@@ -338,87 +351,15 @@ export function showRetryDialog(message, retryFn, options = {}) {
|
|
| 338 |
|
| 339 |
|
| 340 |
// ==========================================
|
| 341 |
-
// 🌐 网络状态检测
|
| 342 |
// ==========================================
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
/
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
export function initNetworkStatusListener() {
|
| 351 |
-
window.addEventListener("online", () => {
|
| 352 |
-
isOffline = false;
|
| 353 |
-
hideOfflineBanner();
|
| 354 |
-
showToast("网络已恢复", "success");
|
| 355 |
-
});
|
| 356 |
-
|
| 357 |
-
window.addEventListener("offline", () => {
|
| 358 |
-
isOffline = true;
|
| 359 |
-
showOfflineBanner();
|
| 360 |
-
});
|
| 361 |
-
|
| 362 |
-
// 初始状态检测
|
| 363 |
-
if (!navigator.onLine) {
|
| 364 |
-
showOfflineBanner();
|
| 365 |
-
}
|
| 366 |
-
}
|
| 367 |
-
|
| 368 |
-
function showOfflineBanner() {
|
| 369 |
-
if (offlineBanner) return;
|
| 370 |
-
|
| 371 |
-
offlineBanner = document.createElement("div");
|
| 372 |
-
Object.assign(offlineBanner.style, {
|
| 373 |
-
position: "fixed",
|
| 374 |
-
bottom: "0",
|
| 375 |
-
left: "0",
|
| 376 |
-
right: "0",
|
| 377 |
-
background: "linear-gradient(135deg, #FF5722, #E64A19)",
|
| 378 |
-
color: "#fff",
|
| 379 |
-
padding: "12px 20px",
|
| 380 |
-
display: "flex",
|
| 381 |
-
alignItems: "center",
|
| 382 |
-
justifyContent: "center",
|
| 383 |
-
gap: "12px",
|
| 384 |
-
fontSize: "14px",
|
| 385 |
-
fontWeight: "500",
|
| 386 |
-
zIndex: "9999",
|
| 387 |
-
boxShadow: "0 -4px 20px rgba(0,0,0,0.3)",
|
| 388 |
-
transform: "translateY(100%)",
|
| 389 |
-
transition: "transform 0.3s ease"
|
| 390 |
-
});
|
| 391 |
-
|
| 392 |
-
offlineBanner.innerHTML = `
|
| 393 |
-
<span style="font-size: 18px;">📡</span>
|
| 394 |
-
<span>网络连接已断开,部分功能可能不可用</span>
|
| 395 |
-
<button onclick="location.reload()" style="background: rgba(255,255,255,0.2); border: none; color: white; padding: 6px 14px; border-radius: 4px; cursor: pointer; font-size: 12px;">刷新重试</button>
|
| 396 |
-
`;
|
| 397 |
-
|
| 398 |
-
document.body.appendChild(offlineBanner);
|
| 399 |
-
|
| 400 |
-
requestAnimationFrame(() => {
|
| 401 |
-
offlineBanner.style.transform = "translateY(0)";
|
| 402 |
-
});
|
| 403 |
-
}
|
| 404 |
-
|
| 405 |
-
function hideOfflineBanner() {
|
| 406 |
-
if (!offlineBanner) return;
|
| 407 |
-
|
| 408 |
-
offlineBanner.style.transform = "translateY(100%)";
|
| 409 |
-
setTimeout(() => {
|
| 410 |
-
offlineBanner?.remove();
|
| 411 |
-
offlineBanner = null;
|
| 412 |
-
}, 300);
|
| 413 |
-
}
|
| 414 |
-
|
| 415 |
-
/**
|
| 416 |
-
* 检查网络状态
|
| 417 |
-
* @returns {boolean} - 是否在线
|
| 418 |
-
*/
|
| 419 |
-
export function checkOnline() {
|
| 420 |
-
return navigator.onLine;
|
| 421 |
-
}
|
| 422 |
|
| 423 |
|
| 424 |
// ==========================================
|
|
@@ -463,7 +404,7 @@ export function showLoading(message = "加载中...") {
|
|
| 463 |
|
| 464 |
loadingOverlay.innerHTML = `
|
| 465 |
<div class="loading-spinner" style="width: 48px; height: 48px; border: 4px solid var(--border-color, #333); border-top-color: #2196F3; border-radius: 50%; animation: spin 0.8s linear infinite;"></div>
|
| 466 |
-
<div class="loading-text" style="color: #fff; font-size: 14px; font-weight: 500;">${message}</div>
|
| 467 |
`;
|
| 468 |
|
| 469 |
// 添加旋转动画样式
|
|
@@ -531,7 +472,7 @@ export function setButtonLoading(button, loading, loadingText = "处理中...")
|
|
| 531 |
button.disabled = true;
|
| 532 |
button.style.opacity = "0.7";
|
| 533 |
button.style.cursor = "not-allowed";
|
| 534 |
-
button.innerHTML = `<span class="btn-spinner"></span>${loadingText}`;
|
| 535 |
} else {
|
| 536 |
// 恢复原始状态
|
| 537 |
button.disabled = button._originalDisabled || false;
|
|
@@ -624,20 +565,6 @@ export async function withFeedback(asyncFn, options = {}) {
|
|
| 624 |
}
|
| 625 |
|
| 626 |
|
| 627 |
-
// ==========================================
|
| 628 |
-
// 🚀 自动初始化
|
| 629 |
-
// ==========================================
|
| 630 |
-
|
| 631 |
-
// 页面加载时初始化网络状态监听
|
| 632 |
-
if (typeof window !== "undefined") {
|
| 633 |
-
if (document.readyState === "loading") {
|
| 634 |
-
document.addEventListener("DOMContentLoaded", initNetworkStatusListener);
|
| 635 |
-
} else {
|
| 636 |
-
initNetworkStatusListener();
|
| 637 |
-
}
|
| 638 |
-
}
|
| 639 |
-
|
| 640 |
-
|
| 641 |
// ==========================================
|
| 642 |
// 📦 安装进度条
|
| 643 |
// ==========================================
|
|
|
|
| 9 |
// ==========================================
|
| 10 |
// 🎯 P1体验优化:
|
| 11 |
// - 统一错误处理与重试机制
|
|
|
|
| 12 |
// - 按钮加载状态
|
| 13 |
// - 全局加载遮罩
|
| 14 |
+
// 🌐 网络状态检测与离线横幅已收敛至 core/状态管理.js(见下方说明),本模块不再负责
|
| 15 |
// ===========================================
|
| 16 |
|
| 17 |
import { t } from "./用户体验增强.js";
|
| 18 |
+
import { escapeHtml } from "../core/全局配置.js";
|
| 19 |
|
| 20 |
// ==========================================
|
| 21 |
// 📢 Toast 消息提示
|
|
|
|
| 73 |
wordBreak: "break-word"
|
| 74 |
});
|
| 75 |
|
| 76 |
+
// 提示文本统一转义:message 常携带昵称/标题/后端报错等用户可控内容,
|
| 77 |
+
// 全库无任何调用方故意向 showToast 传 HTML 标签,故无条件转义不会改变现有显示效果。
|
| 78 |
toast.innerHTML = `
|
| 79 |
<span style="font-size: 16px;">${icons[type] || icons.info}</span>
|
| 80 |
+
<span style="flex: 1;">${escapeHtml(message)}</span>
|
| 81 |
<span class="toast-close" style="cursor: pointer; opacity: 0.7; font-size: 18px; margin-left: 8px;">×</span>
|
| 82 |
`;
|
| 83 |
|
|
|
|
| 195 |
* @param {string} options.confirmText - 确认按钮文字
|
| 196 |
* @param {string} options.cancelText - 取消按钮文字
|
| 197 |
* @param {string} options.type - 类型: 'warning', 'danger', 'info'
|
| 198 |
+
* @param {boolean} options.html - 是否把 message 当作可信 HTML 渲染(默认 false,即按纯文本转义)
|
| 199 |
* @returns {Promise<boolean>}
|
| 200 |
*/
|
| 201 |
export function showConfirm(message, options = {}) {
|
|
|
|
| 203 |
title = "操作确认",
|
| 204 |
confirmText = "确认执行",
|
| 205 |
cancelText = "取消",
|
| 206 |
+
type = "warning",
|
| 207 |
+
html = false
|
| 208 |
} = options;
|
| 209 |
|
| 210 |
const typeConfig = {
|
|
|
|
| 221 |
box.innerHTML = `
|
| 222 |
<div style="font-size: 18px; font-weight: bold; color: ${config.color}; display: flex; align-items: center; gap: 10px;">
|
| 223 |
<span style="font-size: 24px;">${config.icon}</span>
|
| 224 |
+
<span>${escapeHtml(title)}</span>
|
| 225 |
</div>
|
| 226 |
+
<div style="font-size: 14px; color: #ccc; line-height: 1.7; padding: 8px 0;">${html ? message : escapeHtml(message)}</div>
|
| 227 |
<div style="display: flex; gap: 12px; justify-content: flex-end; margin-top: 8px;">
|
| 228 |
+
<button id="ui-btn-cancel" style="padding: 10px 20px; background: transparent; border: 1px solid #555; color: #aaa; border-radius: 6px; cursor: pointer; transition: 0.2s; font-size: 14px;">${escapeHtml(cancelText)}</button>
|
| 229 |
+
<button id="ui-btn-confirm" style="padding: 10px 20px; background: ${config.btnColor}; border: none; color: #fff; border-radius: 6px; cursor: pointer; font-weight: bold; box-shadow: 0 2px 8px ${config.btnColor}44; transition: 0.2s; font-size: 14px;">${escapeHtml(confirmText)}</button>
|
| 230 |
</div>
|
| 231 |
`;
|
| 232 |
|
|
|
|
| 270 |
* @param {string} message - 错误信息
|
| 271 |
* @param {Function} retryFn - 重试函数
|
| 272 |
* @param {Object} options - 配置选项
|
| 273 |
+
* @param {boolean} options.html - 是否把 message 当作可信 HTML 渲染(默认 false,即按纯文本转义)
|
| 274 |
* @returns {Promise<boolean>} - 用户选择重试返回true
|
| 275 |
*/
|
| 276 |
export function showRetryDialog(message, retryFn, options = {}) {
|
| 277 |
const {
|
| 278 |
title = "操作失败",
|
| 279 |
retryText = "🔄 重试",
|
| 280 |
+
cancelText = "关闭",
|
| 281 |
+
html = false
|
| 282 |
} = options;
|
| 283 |
|
| 284 |
return new Promise((resolve) => {
|
|
|
|
| 298 |
box.innerHTML = `
|
| 299 |
<div style="text-align: center;">
|
| 300 |
<div style="font-size: 48px; margin-bottom: 16px;">😕</div>
|
| 301 |
+
<div style="font-size: 20px; font-weight: bold; color: #F44336;">${escapeHtml(title)}</div>
|
| 302 |
</div>
|
| 303 |
<div style="font-size: 14px; color: #bbb; line-height: 1.7; text-align: center; padding: 0 10px; background: #1a1a1a; border-radius: 8px; padding: 16px;">
|
| 304 |
+
${html ? message : escapeHtml(message)}
|
| 305 |
</div>
|
| 306 |
<div style="display: flex; gap: 12px; justify-content: center;">
|
| 307 |
+
<button id="ui-btn-cancel" style="flex: 1; padding: 12px 20px; background: #333; border: 1px solid #555; color: #aaa; border-radius: 8px; cursor: pointer; transition: 0.2s; font-size: 14px;">${escapeHtml(cancelText)}</button>
|
| 308 |
+
<button id="ui-btn-retry" style="flex: 1; padding: 12px 20px; background: linear-gradient(135deg, #2196F3, #1976D2); border: none; color: #fff; border-radius: 8px; cursor: pointer; font-weight: bold; transition: 0.2s; font-size: 14px;">${escapeHtml(retryText)}</button>
|
| 309 |
</div>
|
| 310 |
`;
|
| 311 |
|
|
|
|
| 334 |
close();
|
| 335 |
resolve(true);
|
| 336 |
} catch (error) {
|
| 337 |
+
// 重试失败,更新错误信息(与初次渲染保持一致:默认纯文本,仅 html=true 时按 HTML 渲染)
|
| 338 |
+
const errText = error.message || t('common.retry_failed');
|
| 339 |
+
const errBox = box.querySelector("div[style*='background: #1a1a1a']");
|
| 340 |
+
if (html) {
|
| 341 |
+
errBox.innerHTML = errText;
|
| 342 |
+
} else {
|
| 343 |
+
errBox.textContent = errText;
|
| 344 |
+
}
|
| 345 |
+
retryBtn.innerHTML = escapeHtml(retryText);
|
| 346 |
retryBtn.disabled = false;
|
| 347 |
}
|
| 348 |
};
|
|
|
|
| 351 |
|
| 352 |
|
| 353 |
// ==========================================
|
| 354 |
+
// 🌐 网络状态检测(已移除)
|
| 355 |
// ==========================================
|
| 356 |
+
// 本模块曾自行维护一份 isOffline 状态并常驻显示底部「网���连接已断开」横幅,
|
| 357 |
+
// 与 状态管理.js 的网络状态重复,且一条 online/offline 事件需喂多份状态。
|
| 358 |
+
// 现统一收敛:
|
| 359 |
+
// - 网络状态唯一数据源:core/状态管理.js 的 isOnline()
|
| 360 |
+
// - 离线反馈改由列表层承担:缓存命中渲染后弹 xxx.network_cache toast
|
| 361 |
+
// (「网络异常,已加载本地缓存」),比常驻横幅更贴合实际可用状态
|
| 362 |
+
// 原导出的 initNetworkStatusListener / checkOnline 全库无调用方,一并移除。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 363 |
|
| 364 |
|
| 365 |
// ==========================================
|
|
|
|
| 404 |
|
| 405 |
loadingOverlay.innerHTML = `
|
| 406 |
<div class="loading-spinner" style="width: 48px; height: 48px; border: 4px solid var(--border-color, #333); border-top-color: #2196F3; border-radius: 50%; animation: spin 0.8s linear infinite;"></div>
|
| 407 |
+
<div class="loading-text" style="color: #fff; font-size: 14px; font-weight: 500;">${escapeHtml(message)}</div>
|
| 408 |
`;
|
| 409 |
|
| 410 |
// 添加旋转动画样式
|
|
|
|
| 472 |
button.disabled = true;
|
| 473 |
button.style.opacity = "0.7";
|
| 474 |
button.style.cursor = "not-allowed";
|
| 475 |
+
button.innerHTML = `<span class="btn-spinner"></span>${escapeHtml(loadingText)}`;
|
| 476 |
} else {
|
| 477 |
// 恢复原始状态
|
| 478 |
button.disabled = button._originalDisabled || false;
|
|
|
|
| 565 |
}
|
| 566 |
|
| 567 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 568 |
// ==========================================
|
| 569 |
// 📦 安装进度条
|
| 570 |
// ==========================================
|
前端页面/components/互动工具函数.js
CHANGED
|
@@ -8,6 +8,8 @@
|
|
| 8 |
|
| 9 |
import { showToast } from "./UI交互提示组件.js";
|
| 10 |
import { t } from "./用户体验增强.js";
|
|
|
|
|
|
|
| 11 |
|
| 12 |
// 开发模式检测,仅在本地环境输出调试日志
|
| 13 |
const DEBUG = location.hostname === 'localhost' || location.hostname === '127.0.0.1';
|
|
@@ -103,7 +105,7 @@ export function renderInteractionButtonsHTML(data, currentAccount, options = {})
|
|
| 103 |
|
| 104 |
const tipBtn = showTip ? `
|
| 105 |
<button id="btn-tip" style="background: #333; border: 1px solid #555; color: #fff; padding: 8px 16px; border-radius: 20px; cursor: pointer; font-size: 13px; display: flex; align-items: center; gap: 6px; transition: 0.2s;">
|
| 106 |
-
💰 ${tipText}
|
| 107 |
</button>
|
| 108 |
` : '';
|
| 109 |
|
|
@@ -279,20 +281,12 @@ export { renderTipBoardHTML } from './打赏等级工具.js';
|
|
| 279 |
// ==========================================
|
| 280 |
// 🔒 HTML转义(通用工具)
|
| 281 |
// ==========================================
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
|
| 283 |
-
/
|
| 284 |
-
* HTML转义(统一版:各组件局部副本已归一到此)
|
| 285 |
-
* @param {*} str - 原始字符串(非字符串会先强制转换,兼容数字等入参)
|
| 286 |
-
* @returns {string} 转义后的字符串
|
| 287 |
-
*/
|
| 288 |
-
export function escapeHtml(str) {
|
| 289 |
-
if (str === null || str === undefined || str === "") return "";
|
| 290 |
-
return String(str).replace(/&/g, "&")
|
| 291 |
-
.replace(/</g, "<")
|
| 292 |
-
.replace(/>/g, ">")
|
| 293 |
-
.replace(/"/g, """)
|
| 294 |
-
.replace(/'/g, "'");
|
| 295 |
-
}
|
| 296 |
|
| 297 |
/**
|
| 298 |
* 读取用户设置中的列表缓存 TTL(统一版:讨论区/任务榜/提示词组件局部副本已归一到此)
|
|
|
|
| 8 |
|
| 9 |
import { showToast } from "./UI交互提示组件.js";
|
| 10 |
import { t } from "./用户体验增强.js";
|
| 11 |
+
// 本模块内部也需要转义(下方 renderInteractionButtonsHTML);re-export 不会绑定本地标识符,故单独 import
|
| 12 |
+
import { escapeHtml } from "../core/全局配置.js";
|
| 13 |
|
| 14 |
// 开发模式检测,仅在本地环境输出调试日志
|
| 15 |
const DEBUG = location.hostname === 'localhost' || location.hostname === '127.0.0.1';
|
|
|
|
| 105 |
|
| 106 |
const tipBtn = showTip ? `
|
| 107 |
<button id="btn-tip" style="background: #333; border: 1px solid #555; color: #fff; padding: 8px 16px; border-radius: 20px; cursor: pointer; font-size: 13px; display: flex; align-items: center; gap: 6px; transition: 0.2s;">
|
| 108 |
+
💰 ${escapeHtml(tipText)}
|
| 109 |
</button>
|
| 110 |
` : '';
|
| 111 |
|
|
|
|
| 281 |
// ==========================================
|
| 282 |
// 🔒 HTML转义(通用工具)
|
| 283 |
// ==========================================
|
| 284 |
+
// 实现已下沉至 core/全局配置.js(零依赖的底层叶子模块),此处以 re-export 方式
|
| 285 |
+
// 对外暴露同名函数,既有调用方(个人列表组件.js 等)无需改动。
|
| 286 |
+
// 下沉原因:UI交互提示组件.js / 打赏等级工具.js 需要转义,而本文件反向依赖
|
| 287 |
+
// 这两者(导入 showToast、re-export renderTipBoardHTML),若仍在此定义会形成循环依赖。
|
| 288 |
|
| 289 |
+
export { escapeHtml } from "../core/全局配置.js";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 290 |
|
| 291 |
/**
|
| 292 |
* 读取用户设置中的列表缓存 TTL(统一版:讨论区/任务榜/提示词组件局部副本已归一到此)
|
前端页面/components/全局设置组件.js
CHANGED
|
@@ -430,7 +430,7 @@ export function createSettingsView() {
|
|
| 430 |
setLanguage(newLang);
|
| 431 |
|
| 432 |
const langName = LANGUAGE_OPTIONS.find(opt => opt.value === newLang)?.label || newLang;
|
| 433 |
-
showToast(`🌐 ${t('settings.language_changed')
|
| 434 |
|
| 435 |
// 🔄 通过路由系统刷新,保持 activeInlineView 同步
|
| 436 |
setTimeout(() => {
|
|
|
|
| 430 |
setLanguage(newLang);
|
| 431 |
|
| 432 |
const langName = LANGUAGE_OPTIONS.find(opt => opt.value === newLang)?.label || newLang;
|
| 433 |
+
showToast(`🌐 ${t('settings.language_changed')} ${langName.split(' / ')[0]}`, "success");
|
| 434 |
|
| 435 |
// 🔄 通过路由系统刷新,保持 activeInlineView 同步
|
| 436 |
setTimeout(() => {
|
前端页面/components/图片展示测试.js
CHANGED
|
@@ -6,6 +6,8 @@
|
|
| 6 |
// 接口:openImageViewer(imageUrls, startIndex, options)
|
| 7 |
// ==========================================
|
| 8 |
|
|
|
|
|
|
|
| 9 |
// ==========================================
|
| 10 |
// 🎨 CSS样式定义(CSS-in-JS模式)
|
| 11 |
// ==========================================
|
|
@@ -785,7 +787,8 @@ class CyberImageViewer {
|
|
| 785 |
for (let i = 0; i < maxThumbs; i++) {
|
| 786 |
const thumb = document.createElement('div');
|
| 787 |
thumb.className = 'cyber-thumb' + (i === this.currentIndex ? ' active' : '');
|
| 788 |
-
|
|
|
|
| 789 |
thumb.addEventListener('click', (e) => {
|
| 790 |
this._stopAutoPlay(); // 手动切换时停止自动播放
|
| 791 |
this.goTo(i, e.clientX, e.clientY);
|
|
|
|
| 6 |
// 接口:openImageViewer(imageUrls, startIndex, options)
|
| 7 |
// ==========================================
|
| 8 |
|
| 9 |
+
import { escapeHtml } from "../core/全局配置.js";
|
| 10 |
+
|
| 11 |
// ==========================================
|
| 12 |
// 🎨 CSS样式定义(CSS-in-JS模式)
|
| 13 |
// ==========================================
|
|
|
|
| 787 |
for (let i = 0; i < maxThumbs; i++) {
|
| 788 |
const thumb = document.createElement('div');
|
| 789 |
thumb.className = 'cyber-thumb' + (i === this.currentIndex ? ' active' : '');
|
| 790 |
+
// 🔒 XSS防护:图片 URL 来自其他用户发布的内容,写入 innerHTML 前必须转义
|
| 791 |
+
thumb.innerHTML = `<img src="${escapeHtml(this.imageUrls[i])}" alt="">`;
|
| 792 |
thumb.addEventListener('click', (e) => {
|
| 793 |
this._stopAutoPlay(); // 手动切换时停止自动播放
|
| 794 |
this.goTo(i, e.clientX, e.clientY);
|
前端页面/components/图片沙盒组件.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
| 12 |
// ==========================================
|
| 13 |
|
| 14 |
import { t } from "./用户体验增强.js";
|
|
|
|
| 15 |
import { openImageViewer } from "./图片展示测试.js";
|
| 16 |
|
| 17 |
// 🔧 通用SVG占位图生成函数
|
|
@@ -77,8 +78,10 @@ export function getCoverSandboxHTML(imageSource, lazyLoad = true) {
|
|
| 77 |
const isLocalProxy = firstUrl && firstUrl.startsWith('/community_hub/image?url=');
|
| 78 |
const shouldLazy = lazyLoad && !isLocalProxy;
|
| 79 |
|
|
|
|
|
|
|
| 80 |
const imgSrc = shouldLazy ? getPlaceholderSVG() : firstUrl;
|
| 81 |
-
const dataAttr = shouldLazy ? `data-src="${firstUrl}"` : '';
|
| 82 |
const lazyClass = shouldLazy ? 'lazy-image lazy-loading' : '';
|
| 83 |
|
| 84 |
// 🖼️ 多图模式:显示左右切换按钮和计数器
|
|
@@ -101,8 +104,8 @@ export function getCoverSandboxHTML(imageSource, lazyLoad = true) {
|
|
| 101 |
|
| 102 |
return `
|
| 103 |
<div style="font-size: 12px; font-weight: bold; margin-bottom: 6px; color: #aaa;">🖼️ ${t('image.gallery')}${hasMultiple ? ` (${imageUrls.length})` : ''}</div>
|
| 104 |
-
<div class="img-viewport" data-images='${JSON.stringify(imageUrls)}' data-current="0" style="position: relative; width: 100%; height: 260px; background: #111; border: 2px dashed #666; border-radius: 6px; overflow: hidden; display: flex; align-items: center; justify-content: center; margin-bottom: 12px; cursor: grab; user-select: none; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none;" oncontextmenu="return false;">
|
| 105 |
-
<img class="target-img ${lazyClass}" src="${imgSrc}" ${dataAttr} draggable="false" style="max-width: 100%; max-height: 100%; object-fit: contain; transform-origin: center; transition: transform 0.05s linear, opacity 0.3s ease; ${protectionStyles}">
|
| 106 |
${navButtons}
|
| 107 |
<div style="position: absolute; top: 10px; right: 10px; display: flex; gap: 5px; opacity: 0.3; transition: 0.3s; z-index: 10;" onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=0.3">
|
| 108 |
<button class="btn-zoom-out" style="background: #333; color: #fff; border: 1px solid #555; width: 28px; height: 28px; border-radius: 4px; cursor: pointer; font-weight: bold;">-</button>
|
|
|
|
| 12 |
// ==========================================
|
| 13 |
|
| 14 |
import { t } from "./用户体验增强.js";
|
| 15 |
+
import { escapeHtml } from "../core/全局配置.js";
|
| 16 |
import { openImageViewer } from "./图片展示测试.js";
|
| 17 |
|
| 18 |
// 🔧 通用SVG占位图生成函数
|
|
|
|
| 78 |
const isLocalProxy = firstUrl && firstUrl.startsWith('/community_hub/image?url=');
|
| 79 |
const shouldLazy = lazyLoad && !isLocalProxy;
|
| 80 |
|
| 81 |
+
// 🔒 XSS防护:图片 URL 来自其他用户发布的内容,写入 HTML 属性前必须转义
|
| 82 |
+
// (上方 startsWith 代理判定用的是原始值,不受影响;浏览器解码后 dataset 仍为原 URL)
|
| 83 |
const imgSrc = shouldLazy ? getPlaceholderSVG() : firstUrl;
|
| 84 |
+
const dataAttr = shouldLazy ? `data-src="${escapeHtml(firstUrl)}"` : '';
|
| 85 |
const lazyClass = shouldLazy ? 'lazy-image lazy-loading' : '';
|
| 86 |
|
| 87 |
// 🖼️ 多图模式:显示左右切换按钮和计数器
|
|
|
|
| 104 |
|
| 105 |
return `
|
| 106 |
<div style="font-size: 12px; font-weight: bold; margin-bottom: 6px; color: #aaa;">🖼️ ${t('image.gallery')}${hasMultiple ? ` (${imageUrls.length})` : ''}</div>
|
| 107 |
+
<div class="img-viewport" data-images='${escapeHtml(JSON.stringify(imageUrls))}' data-current="0" style="position: relative; width: 100%; height: 260px; background: #111; border: 2px dashed #666; border-radius: 6px; overflow: hidden; display: flex; align-items: center; justify-content: center; margin-bottom: 12px; cursor: grab; user-select: none; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none;" oncontextmenu="return false;">
|
| 108 |
+
<img class="target-img ${lazyClass}" src="${escapeHtml(imgSrc)}" ${dataAttr} draggable="false" style="max-width: 100%; max-height: 100%; object-fit: contain; transform-origin: center; transition: transform 0.05s linear, opacity 0.3s ease; ${protectionStyles}">
|
| 109 |
${navButtons}
|
| 110 |
<div style="position: absolute; top: 10px; right: 10px; display: flex; gap: 5px; opacity: 0.3; transition: 0.3s; z-index: 10;" onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=0.3">
|
| 111 |
<button class="btn-zoom-out" style="background: #333; color: #fff; border: 1px solid #555; width: 28px; height: 28px; border-radius: 4px; cursor: pointer; font-weight: bold;">-</button>
|
前端页面/components/图片裁剪组件.js
CHANGED
|
@@ -9,6 +9,10 @@
|
|
| 9 |
// 🔧 P3优化:事件监听器生命周期管理,防止内存泄漏
|
| 10 |
// ==========================================
|
| 11 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
// 辅助函数:将 value 限制在 [min, max] 范围内
|
| 13 |
function _clamp(value, min, max) {
|
| 14 |
return Math.max(min, Math.min(max, value));
|
|
@@ -59,11 +63,11 @@ export function openImageCropper(file, aspectRatio = 16/9, title = "裁剪图片
|
|
| 59 |
let ratioText = '16:9';
|
| 60 |
if (aspectRatio === 1) ratioText = '1:1';
|
| 61 |
else if (aspectRatio < 1) ratioText = '9:16';
|
| 62 |
-
// 📱 触控设备与鼠标设备的操作提示区分(移动端无滚轮/拖动概念)
|
| 63 |
-
const gestureHint = _isTouchDevice() ? '
|
| 64 |
header.innerHTML = `
|
| 65 |
-
<span style="font-size: 16px; font-weight: bold; color: #fff;">✂️ ${title}</span>
|
| 66 |
-
<span style="font-size: 12px; color: #888;">
|
| 67 |
`;
|
| 68 |
|
| 69 |
// 裁剪区域容器
|
|
@@ -203,6 +207,9 @@ export function openImageCropper(file, aspectRatio = 16/9, title = "裁剪图片
|
|
| 203 |
let pinchCenterY = 0;
|
| 204 |
let pinchStartOffsetX = 0;
|
| 205 |
let pinchStartOffsetY = 0;
|
|
|
|
|
|
|
|
|
|
| 206 |
|
| 207 |
// 获取缩放边界(与滚轮缩放共用同一规则)
|
| 208 |
function _getScaleLimits() {
|
|
@@ -234,9 +241,14 @@ export function openImageCropper(file, aspectRatio = 16/9, title = "裁剪图片
|
|
| 234 |
isDragging = false;
|
| 235 |
pinchStartDist = _getTouchDistance(e.touches);
|
| 236 |
pinchStartScale = scale;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 237 |
const mid = _getTouchMidPoint(e.touches);
|
| 238 |
-
pinchCenterX = mid.x;
|
| 239 |
-
pinchCenterY = mid.y;
|
| 240 |
pinchStartOffsetX = offsetX;
|
| 241 |
pinchStartOffsetY = offsetY;
|
| 242 |
}
|
|
@@ -255,10 +267,17 @@ export function openImageCropper(file, aspectRatio = 16/9, title = "裁剪图片
|
|
| 255 |
const newScale = _clamp(pinchStartScale * (_getTouchDistance(e.touches) / pinchStartDist), minScale, maxScale);
|
| 256 |
const ratio = newScale / pinchStartScale;
|
| 257 |
const mid = _getTouchMidPoint(e.touches);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 258 |
const centerX = containerWidth / 2;
|
| 259 |
const centerY = containerHeight / 2;
|
| 260 |
-
|
| 261 |
-
|
|
|
|
|
|
|
| 262 |
scale = newScale;
|
| 263 |
updateImagePosition();
|
| 264 |
}
|
|
|
|
| 9 |
// 🔧 P3优化:事件监听器生命周期管理,防止内存泄漏
|
| 10 |
// ==========================================
|
| 11 |
|
| 12 |
+
import { escapeHtml } from "../core/全局配置.js";
|
| 13 |
+
// 🔧 i18n:头部操作提示走词典(用户体验_国际化.js 是零依赖叶子模块,引入不成环)
|
| 14 |
+
import { t } from "./用户体验_国际化.js";
|
| 15 |
+
|
| 16 |
// 辅助函数:将 value 限制在 [min, max] 范围内
|
| 17 |
function _clamp(value, min, max) {
|
| 18 |
return Math.max(min, Math.min(max, value));
|
|
|
|
| 63 |
let ratioText = '16:9';
|
| 64 |
if (aspectRatio === 1) ratioText = '1:1';
|
| 65 |
else if (aspectRatio < 1) ratioText = '9:16';
|
| 66 |
+
// 📱 触控设备与鼠标设备的操作提示区分(移动端无滚轮/拖动概念);文案走词典,随界面语言切换
|
| 67 |
+
const gestureHint = _isTouchDevice() ? t('crop.gesture_hint_touch') : t('crop.gesture_hint_mouse');
|
| 68 |
header.innerHTML = `
|
| 69 |
+
<span style="font-size: 16px; font-weight: bold; color: #fff;">✂️ ${escapeHtml(title)}</span>
|
| 70 |
+
<span style="font-size: 12px; color: #888;">${escapeHtml(t('crop.ratio_label'))} ${ratioText} | ${escapeHtml(gestureHint)}</span>
|
| 71 |
`;
|
| 72 |
|
| 73 |
// 裁剪区域容器
|
|
|
|
| 207 |
let pinchCenterY = 0;
|
| 208 |
let pinchStartOffsetX = 0;
|
| 209 |
let pinchStartOffsetY = 0;
|
| 210 |
+
// 🔧 手势起始时容器的视口原点(含 border),用于把 clientX/Y 换算成与 img.style.left 同源的容器内坐标
|
| 211 |
+
let pinchOriginX = 0;
|
| 212 |
+
let pinchOriginY = 0;
|
| 213 |
|
| 214 |
// 获取缩放边界(与滚轮缩放共用同一规则)
|
| 215 |
function _getScaleLimits() {
|
|
|
|
| 241 |
isDragging = false;
|
| 242 |
pinchStartDist = _getTouchDistance(e.touches);
|
| 243 |
pinchStartScale = scale;
|
| 244 |
+
// 🔧 clientX 是视口坐标,offsetX/containerWidth 是容器内坐标,两者必须同原点才能参与锚点计算;
|
| 245 |
+
// clientLeft/clientTop 为 2px 边框宽度(img 的 absolute 定位原点在 padding box 左上角)
|
| 246 |
+
const rect = cropContainer.getBoundingClientRect();
|
| 247 |
+
pinchOriginX = rect.left + cropContainer.clientLeft;
|
| 248 |
+
pinchOriginY = rect.top + cropContainer.clientTop;
|
| 249 |
const mid = _getTouchMidPoint(e.touches);
|
| 250 |
+
pinchCenterX = mid.x - pinchOriginX;
|
| 251 |
+
pinchCenterY = mid.y - pinchOriginY;
|
| 252 |
pinchStartOffsetX = offsetX;
|
| 253 |
pinchStartOffsetY = offsetY;
|
| 254 |
}
|
|
|
|
| 267 |
const newScale = _clamp(pinchStartScale * (_getTouchDistance(e.touches) / pinchStartDist), minScale, maxScale);
|
| 268 |
const ratio = newScale / pinchStartScale;
|
| 269 |
const mid = _getTouchMidPoint(e.touches);
|
| 270 |
+
// 换算到容器内坐标(与 handleTouchStart 同原点,一次捏合手势期间弹窗不移动)
|
| 271 |
+
const relX = mid.x - pinchOriginX;
|
| 272 |
+
const relY = mid.y - pinchOriginY;
|
| 273 |
+
// 图片 left = (W - s·imgW)/2 + o,故锚点不动的条件解出:
|
| 274 |
+
// o1 = (p1 - p0) + ratio·(o0 - d) + d,其中 d = p0 - W/2 为起始手指相对容器中心的偏移
|
| 275 |
const centerX = containerWidth / 2;
|
| 276 |
const centerY = containerHeight / 2;
|
| 277 |
+
const anchorX = pinchCenterX - centerX;
|
| 278 |
+
const anchorY = pinchCenterY - centerY;
|
| 279 |
+
offsetX = (relX - pinchCenterX) + (pinchStartOffsetX - anchorX) * ratio + anchorX;
|
| 280 |
+
offsetY = (relY - pinchCenterY) + (pinchStartOffsetY - anchorY) * ratio + anchorY;
|
| 281 |
scale = newScale;
|
| 282 |
updateImagePosition();
|
| 283 |
}
|
前端页面/components/性能优化工具.js
CHANGED
|
@@ -5,8 +5,10 @@
|
|
| 5 |
// 作用:提供分页加载、图片懒加载、缓存管理等性能优化工具
|
| 6 |
// 关联文件:
|
| 7 |
// - 全局配置.js (配置中心)
|
|
|
|
| 8 |
// - 列表卡片组件.js (使用分页加载)
|
| 9 |
// - 创作者卡片组件.js (使用分页加载)
|
|
|
|
| 10 |
// - 所有图片渲染场景 (使用懒加载)
|
| 11 |
// ==========================================
|
| 12 |
// 🏗️ P2架构优化:使用配置中心常量
|
|
@@ -14,6 +16,9 @@
|
|
| 14 |
// ==========================================
|
| 15 |
|
| 16 |
import { PAGINATION } from "../core/全局配置.js";
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
// ==========================================
|
| 19 |
// 🔒 P0安全优化:敏感数据脱敏工具
|
|
@@ -195,7 +200,7 @@ export function maskAmount(amount, hide = false) {
|
|
| 195 |
// ==========================================
|
| 196 |
// 缓存实现已统一迁移到 ../core/网络请求_缓存管理.js
|
| 197 |
// 此处保持向后兼容重导出,调用方无需修改 import 路径
|
| 198 |
-
export { setCache, getCache, getCacheWithMeta, removeCache, clearAllCache, CACHE_TTL, getRecommendedTTL, setSmartCache } from '../core/网络请求_缓存管理.js';
|
| 199 |
|
| 200 |
|
| 201 |
// ==========================================
|
|
@@ -1236,9 +1241,40 @@ const eventManager = {
|
|
| 1236 |
export { eventManager };
|
| 1237 |
|
| 1238 |
// ==========================================
|
| 1239 |
-
// 🔍 列表缓存
|
| 1240 |
// ==========================================
|
| 1241 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1242 |
/**
|
| 1243 |
* 🔍 从列表缓存中查找指定项(详情页离线回退用)
|
| 1244 |
* @param {string} prefix - 缓存Key前缀(如 "TasksCache_"、"PostsCache_")
|
|
|
|
| 5 |
// 作用:提供分页加载、图片懒加载、缓存管理等性能优化工具
|
| 6 |
// 关联文件:
|
| 7 |
// - 全局配置.js (配置中心)
|
| 8 |
+
// - 网络请求_缓存管理.js (列表缓存工具 readListCache/writeListCache 的底层实现)
|
| 9 |
// - 列表卡片组件.js (使用分页加载)
|
| 10 |
// - 创作者卡片组件.js (使用分页加载)
|
| 11 |
+
// - 任务榜组件.js / 讨论区组件.js / 提示词组件.js (使用列表缓存工具)
|
| 12 |
// - 所有图片渲染场景 (使用懒加载)
|
| 13 |
// ==========================================
|
| 14 |
// 🏗️ P2架构优化:使用配置中心常量
|
|
|
|
| 16 |
// ==========================================
|
| 17 |
|
| 18 |
import { PAGINATION } from "../core/全局配置.js";
|
| 19 |
+
// 📦 列表缓存工具需要本地可用的缓存实现;下方「向后兼容重导出」那行是纯转发、
|
| 20 |
+
// 不会生成本地绑定,故此处必须单独 import(勿误删,否则 readListCache 运行时报错)
|
| 21 |
+
import { setCache, getCacheWithMeta } from "../core/网络请求_缓存管理.js";
|
| 22 |
|
| 23 |
// ==========================================
|
| 24 |
// 🔒 P0安全优化:敏感数据脱敏工具
|
|
|
|
| 200 |
// ==========================================
|
| 201 |
// 缓存实现已统一迁移到 ../core/网络请求_缓存管理.js
|
| 202 |
// 此处保持向后兼容重导出,调用方无需修改 import 路径
|
| 203 |
+
export { setCache, getCache, getCacheWithMeta, removeCache, removeCacheByPrefix, clearAllCache, CACHE_TTL, getRecommendedTTL, setSmartCache } from '../core/网络请求_缓存管理.js';
|
| 204 |
|
| 205 |
|
| 206 |
// ==========================================
|
|
|
|
| 1241 |
export { eventManager };
|
| 1242 |
|
| 1243 |
// ==========================================
|
| 1244 |
+
// 🔍 列表缓存工具(列表视图缓存优先 / 详情页离线回退用)
|
| 1245 |
// ==========================================
|
| 1246 |
|
| 1247 |
+
/**
|
| 1248 |
+
* 📴 读取列表首屏缓存(离线容灾口径,任务榜/讨论区/提示词统一走这里)
|
| 1249 |
+
*
|
| 1250 |
+
* 口径说明(原三处各自实现 getCacheWithMeta(key, true) + found && length>0,
|
| 1251 |
+
* 抄写三份容易在维护中走偏,故归一到本函数):
|
| 1252 |
+
* - 忽略过期:第二个参数传 true,缓存过期也算命中——断网/云端不通时首屏仍要能出内容,
|
| 1253 |
+
* 新鲜度交由调用方的 silentRefresh 后台补齐,不在此处拦
|
| 1254 |
+
* - 空列表不算命中:云端确实返回过 0 条时同样落到网络分支重新拉取,
|
| 1255 |
+
* 避免用户新建第一条内容后被旧的空缓存挡住
|
| 1256 |
+
* - 返回值是写入时的原始数据,调用方自行 proxyImages(与改造前完全一致)
|
| 1257 |
+
*
|
| 1258 |
+
* @param {string} cacheKey - 完整缓存 Key(含各自的筛选参数)
|
| 1259 |
+
* @returns {Array|null} 命中返回数组,未命中/空列表返回 null
|
| 1260 |
+
*/
|
| 1261 |
+
export function readListCache(cacheKey) {
|
| 1262 |
+
const { value } = getCacheWithMeta(cacheKey, true); // true = 忽略过期
|
| 1263 |
+
if (!Array.isArray(value) || value.length === 0) return null;
|
| 1264 |
+
return value;
|
| 1265 |
+
}
|
| 1266 |
+
|
| 1267 |
+
/**
|
| 1268 |
+
* 💾 回写列表首屏缓存(持久化到 localStorage,保证重启后离线仍可展示)
|
| 1269 |
+
* @param {string} cacheKey - 完整缓存 Key
|
| 1270 |
+
* @param {Array} items - 原始数据(未经图片代理)
|
| 1271 |
+
* @param {number} ttl - 有效期(毫秒),由调用方传 getCacheTTL()
|
| 1272 |
+
*/
|
| 1273 |
+
export function writeListCache(cacheKey, items, ttl) {
|
| 1274 |
+
if (!Array.isArray(items)) return;
|
| 1275 |
+
setCache(cacheKey, items, ttl, true); // true = 持久化 localStorage
|
| 1276 |
+
}
|
| 1277 |
+
|
| 1278 |
/**
|
| 1279 |
* 🔍 从列表缓存中查找指定项(详情页离线回退用)
|
| 1280 |
* @param {string} prefix - 缓存Key前缀(如 "TasksCache_"、"PostsCache_")
|
前端页面/components/打赏等级工具.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
| 11 |
// ===========================================
|
| 12 |
|
| 13 |
import { t } from "./用户体验增强.js";
|
| 14 |
-
import { getCachedProfile, getProfileWithSWR, PLACEHOLDERS } from "../core/全局配置.js";
|
| 15 |
import { api } from "../core/网络请求_业务API.js";
|
| 16 |
|
| 17 |
/**
|
|
@@ -218,13 +218,15 @@ export function renderTipBoardItemHTML(tipData, rank, size = 'normal') {
|
|
| 218 |
});
|
| 219 |
}, 0);
|
| 220 |
|
|
|
|
|
|
|
| 221 |
return `
|
| 222 |
-
<div id="${containerId}" style="display:flex; justify-content:space-between; padding:${cfg.padding}px 0; font-size:${cfg.font}px; align-items:center; border-bottom:1px dashed #333;">
|
| 223 |
<span style="display:flex; align-items:center; gap:6px;">
|
| 224 |
${badgeHtml}
|
| 225 |
<span style="display:flex; align-items:center; gap:6px;">
|
| 226 |
-
<img class="tip-board-avatar" src="${avatarUrl}" style="width:${cfg.avatar}px; height:${cfg.avatar}px; border-radius:50%; object-fit:cover; flex-shrink:0; background:#333;">
|
| 227 |
-
<span class="tip-board-name" style="color:#ddd;">${userName}</span>
|
| 228 |
</span>
|
| 229 |
</span>
|
| 230 |
<span style="display:flex; align-items:center; gap:6px;">
|
|
@@ -245,7 +247,7 @@ export function renderTipBoardItemHTML(tipData, rank, size = 'normal') {
|
|
| 245 |
*/
|
| 246 |
export function renderTipBoardHTML(boardData, maxShow = 5, emptyText = "暂无打赏,快来成为首个赞赏人吧!", size = 'normal') {
|
| 247 |
if (!boardData || boardData.length === 0) {
|
| 248 |
-
return `<div style="color:#666; font-size:12px; text-align:center; padding:12px 0;">${emptyText}</div>`;
|
| 249 |
}
|
| 250 |
|
| 251 |
return boardData.slice(0, maxShow).map((t, i) => renderTipBoardItemHTML(t, i, size)).join('');
|
|
|
|
| 11 |
// ===========================================
|
| 12 |
|
| 13 |
import { t } from "./用户体验增强.js";
|
| 14 |
+
import { getCachedProfile, getProfileWithSWR, PLACEHOLDERS, escapeHtml } from "../core/全局配置.js";
|
| 15 |
import { api } from "../core/网络请求_业务API.js";
|
| 16 |
|
| 17 |
/**
|
|
|
|
| 218 |
});
|
| 219 |
}, 0);
|
| 220 |
|
| 221 |
+
// 🔒 XSS防护:昵称/头像来自用户可修改的资料,写入 HTML 前必须转义
|
| 222 |
+
// (上方 SWR 刷新路径用的是 .src / .textContent,本身就是安全的)
|
| 223 |
return `
|
| 224 |
+
<div id="${escapeHtml(containerId)}" style="display:flex; justify-content:space-between; padding:${cfg.padding}px 0; font-size:${cfg.font}px; align-items:center; border-bottom:1px dashed #333;">
|
| 225 |
<span style="display:flex; align-items:center; gap:6px;">
|
| 226 |
${badgeHtml}
|
| 227 |
<span style="display:flex; align-items:center; gap:6px;">
|
| 228 |
+
<img class="tip-board-avatar" src="${escapeHtml(avatarUrl)}" style="width:${cfg.avatar}px; height:${cfg.avatar}px; border-radius:50%; object-fit:cover; flex-shrink:0; background:#333;">
|
| 229 |
+
<span class="tip-board-name" style="color:#ddd;">${escapeHtml(userName)}</span>
|
| 230 |
</span>
|
| 231 |
</span>
|
| 232 |
<span style="display:flex; align-items:center; gap:6px;">
|
|
|
|
| 247 |
*/
|
| 248 |
export function renderTipBoardHTML(boardData, maxShow = 5, emptyText = "暂无打赏,快来成为首个赞赏人吧!", size = 'normal') {
|
| 249 |
if (!boardData || boardData.length === 0) {
|
| 250 |
+
return `<div style="color:#666; font-size:12px; text-align:center; padding:12px 0;">${escapeHtml(emptyText)}</div>`;
|
| 251 |
}
|
| 252 |
|
| 253 |
return boardData.slice(0, maxShow).map((t, i) => renderTipBoardItemHTML(t, i, size)).join('');
|
前端页面/components/用户体验_国际化.js
CHANGED
|
@@ -31,6 +31,9 @@ const translations = {
|
|
| 31 |
'common.sending': '发送中...',
|
| 32 |
'common.retrying': '重试中...',
|
| 33 |
'common.retry_failed': '重试失败,请稍后再试',
|
|
|
|
|
|
|
|
|
|
| 34 |
'common.more': '更多',
|
| 35 |
'common.back': '返回',
|
| 36 |
'common.search': '搜索',
|
|
@@ -308,6 +311,7 @@ const translations = {
|
|
| 308 |
'wallet.recharge.custom_placeholder': '请输入自定义金额 (1~10000)',
|
| 309 |
'wallet.recharge.max_amount_exceeded': '单次充值金额不得超过10000元',
|
| 310 |
'wallet.recharge.processing': '处理中...',
|
|
|
|
| 311 |
|
| 312 |
// 提现相关
|
| 313 |
'wallet.withdraw.title': '💳 收益提现中心',
|
|
@@ -326,6 +330,7 @@ const translations = {
|
|
| 326 |
'wallet.withdraw.resend_in': '{seconds}s 后重发',
|
| 327 |
'wallet.withdraw.confirm': '确认提现 (金额将转入冻结审核)',
|
| 328 |
'wallet.withdraw.submitting': '⏳ 提交中...',
|
|
|
|
| 329 |
'wallet.withdraw.no_balance': '您的可提现收益为 0,快去发布优质插件赚取积分吧!',
|
| 330 |
'wallet.withdraw.min_amount': '最低提现额度为 1 积分!',
|
| 331 |
'wallet.withdraw.fill_account': '请完整填写收款人支付宝账号与真实姓名!',
|
|
@@ -393,8 +398,6 @@ const translations = {
|
|
| 393 |
'feedback.loading': '处理中...',
|
| 394 |
'feedback.success': '操作成功',
|
| 395 |
'feedback.error': '操作失败,请重试',
|
| 396 |
-
'feedback.network_error': '网络连接失败',
|
| 397 |
-
'feedback.offline': '当前处于离线状态',
|
| 398 |
'feedback.timeout': '请求超时',
|
| 399 |
'feedback.saved': '已保存',
|
| 400 |
'feedback.logged_out': '已退出登录',
|
|
@@ -419,6 +422,7 @@ const translations = {
|
|
| 419 |
'social.reply': '回复',
|
| 420 |
'social.reply_to': '回复',
|
| 421 |
'social.comment_deleted': '此条评论已由用户删除',
|
|
|
|
| 422 |
'social.delete_comment_confirm': '确定要删除这条评论吗?删除后将无法恢复。',
|
| 423 |
'social.no_comments': '暂无留言,来抢沙发吧~',
|
| 424 |
|
|
@@ -486,6 +490,7 @@ const translations = {
|
|
| 486 |
'chat.date_day': '日',
|
| 487 |
'chat.load_more': '加载更早的消息',
|
| 488 |
'chat.msg_too_long': '消息不能超1000字',
|
|
|
|
| 489 |
'chat.load_list_failed': '加载对话列表失败',
|
| 490 |
'chat.load_history_failed': '加载消息记录失败',
|
| 491 |
'chat.load_failed_hint': '消息加载失败',
|
|
@@ -588,6 +593,12 @@ const translations = {
|
|
| 588 |
'item.tip_board_title': '该作品赞赏贡献榜 (TOP 10)',
|
| 589 |
'item.owned_reinstall': '已拥有 (点击重新覆盖安装)',
|
| 590 |
'item.owned_purchased': '已购买 (请在电脑端安装)',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 591 |
'item.update_available': '发现新版本 (点击静默热更新)',
|
| 592 |
'item.get_now': '立即获取使用',
|
| 593 |
'item.free': '完全免费',
|
|
@@ -600,7 +611,13 @@ const translations = {
|
|
| 600 |
// 反馈补充
|
| 601 |
'feedback.deleted': '已成功删除',
|
| 602 |
'feedback.delete_failed': '删除失败',
|
|
|
|
| 603 |
'feedback.cache_fallback': '⚠️ 网络连接失败,展示的是缓存数据',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 604 |
|
| 605 |
// 通用
|
| 606 |
'common.processing': '处理中',
|
|
@@ -815,6 +832,11 @@ const translations = {
|
|
| 815 |
'post.network_cache': '网络异常,已加载本地缓存',
|
| 816 |
'post.login_first': '请先登录后再发布作品',
|
| 817 |
'post.video_tag': '视频',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 818 |
|
| 819 |
// 帖子详情
|
| 820 |
'post.sort_latest': '最新',
|
|
@@ -1056,6 +1078,8 @@ const translations = {
|
|
| 1056 |
'common.uploading': '上传中',
|
| 1057 |
'common.upload_progress': '上传 ({current}/{total})',
|
| 1058 |
'common.submitting': '提交中',
|
|
|
|
|
|
|
| 1059 |
'common.upload_image_progress': '上传图片 ({current}/{total})',
|
| 1060 |
'common.unknown_task': '未知任务',
|
| 1061 |
|
|
@@ -1070,15 +1094,20 @@ const translations = {
|
|
| 1070 |
'task.confirm_assign': '确定选择 {assignee} 作为接单者吗?',
|
| 1071 |
'task.confirm_deposit': '将从您的账户扣除订金 {amount} 积分',
|
| 1072 |
'task.apply_message_prompt': '请输入申请留言(可选):',
|
|
|
|
| 1073 |
'task.confirm_cancel_apply': '确定撤回申请吗?',
|
|
|
|
| 1074 |
'task.apply_cancelled': '已撤回申请',
|
| 1075 |
'task.cancel_apply_failed': '撤回失败',
|
| 1076 |
'task.confirm_cancel_task': '确定取消任务吗?此操作不可撤销。',
|
|
|
|
| 1077 |
'task.task_cancelled': '任务已取消',
|
| 1078 |
'task.cancel_task_failed': '取消失败',
|
| 1079 |
'task.confirm_accept': '确定验收通过吗?',
|
| 1080 |
'task.confirm_pay_remaining': '将支付尾款 {amount} 积分给接单者',
|
| 1081 |
'task.reject_reason_prompt': '请输入不通过的原因:',
|
|
|
|
|
|
|
| 1082 |
'task.work_returned': '已退回修改',
|
| 1083 |
'task.submit_work': '提交成果',
|
| 1084 |
'task.deliverable_images': '交付图片',
|
|
@@ -1270,6 +1299,8 @@ const translations = {
|
|
| 1270 |
// 个人列表
|
| 1271 |
'profile.privacy_hidden': '由于作者的隐私设置,该列表不对外公开',
|
| 1272 |
'profile.no_following': '暂无关注的人',
|
|
|
|
|
|
|
| 1273 |
'profile.no_collected': '还没有收藏任何内容',
|
| 1274 |
'profile.go_discover': '去榜单页面或讨论区发现精彩内容吧!',
|
| 1275 |
'profile.collected_items': '收藏的资源',
|
|
@@ -1426,6 +1457,9 @@ const translations = {
|
|
| 1426 |
'common.sending': 'Sending...',
|
| 1427 |
'common.retrying': 'Retrying...',
|
| 1428 |
'common.retry_failed': 'Retry failed, please try again later',
|
|
|
|
|
|
|
|
|
|
| 1429 |
'common.more': 'More',
|
| 1430 |
'common.back': 'Back',
|
| 1431 |
'common.search': 'Search',
|
|
@@ -1698,6 +1732,7 @@ const translations = {
|
|
| 1698 |
'wallet.recharge.custom_placeholder': 'Enter custom amount (1~10000)',
|
| 1699 |
'wallet.recharge.max_amount_exceeded': 'Single recharge cannot exceed 10000',
|
| 1700 |
'wallet.recharge.processing': 'Processing...',
|
|
|
|
| 1701 |
|
| 1702 |
// Withdraw
|
| 1703 |
'wallet.withdraw.title': '💳 Earnings Withdrawal Center',
|
|
@@ -1716,6 +1751,7 @@ const translations = {
|
|
| 1716 |
'wallet.withdraw.resend_in': 'Resend in {seconds}s',
|
| 1717 |
'wallet.withdraw.confirm': 'Confirm Withdrawal (Funds Will Be Frozen for Review)',
|
| 1718 |
'wallet.withdraw.submitting': '⏳ Submitting...',
|
|
|
|
| 1719 |
'wallet.withdraw.no_balance': 'Your withdrawable balance is 0. Publish quality plugins to earn points!',
|
| 1720 |
'wallet.withdraw.min_amount': 'Minimum withdrawal is 1 point!',
|
| 1721 |
'wallet.withdraw.fill_account': 'Please fill in both Alipay account and real name!',
|
|
@@ -1783,8 +1819,6 @@ const translations = {
|
|
| 1783 |
'feedback.loading': 'Processing...',
|
| 1784 |
'feedback.success': 'Success',
|
| 1785 |
'feedback.error': 'Failed, please retry',
|
| 1786 |
-
'feedback.network_error': 'Network error',
|
| 1787 |
-
'feedback.offline': 'Currently offline',
|
| 1788 |
'feedback.timeout': 'Request timeout',
|
| 1789 |
'feedback.saved': 'Saved',
|
| 1790 |
'feedback.logged_out': 'Logged out',
|
|
@@ -1808,6 +1842,7 @@ const translations = {
|
|
| 1808 |
'social.say_something': 'Say something...',
|
| 1809 |
'social.reply_to': 'Reply to',
|
| 1810 |
'social.comment_deleted': 'This comment has been deleted',
|
|
|
|
| 1811 |
'social.delete_comment_confirm': 'Are you sure you want to delete this comment? This cannot be undone.',
|
| 1812 |
'social.no_comments': 'No comments yet. Be the first!',
|
| 1813 |
|
|
@@ -1873,6 +1908,7 @@ const translations = {
|
|
| 1873 |
'chat.date_day': '',
|
| 1874 |
'chat.load_more': 'Load earlier messages',
|
| 1875 |
'chat.msg_too_long': 'Message cannot exceed 1000 characters',
|
|
|
|
| 1876 |
'chat.load_list_failed': 'Failed to load conversations',
|
| 1877 |
'chat.load_history_failed': 'Failed to load messages',
|
| 1878 |
'chat.load_failed_hint': 'Message loading failed',
|
|
@@ -1973,6 +2009,12 @@ const translations = {
|
|
| 1973 |
'item.tip_board_title': 'Top Supporters (TOP 10)',
|
| 1974 |
'item.owned_reinstall': 'Owned (Click to reinstall)',
|
| 1975 |
'item.owned_purchased': 'Purchased (Install on PC)',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1976 |
'item.update_available': 'Update available (Click for silent hot update)',
|
| 1977 |
'item.get_now': 'Get Now',
|
| 1978 |
'item.free': 'Free',
|
|
@@ -1981,7 +2023,11 @@ const translations = {
|
|
| 1981 |
'time.hours_later': ' hours effective',
|
| 1982 |
'feedback.deleted': 'Successfully deleted',
|
| 1983 |
'feedback.delete_failed': 'Delete failed',
|
|
|
|
| 1984 |
'feedback.cache_fallback': '⚠️ Network failed, showing cached data',
|
|
|
|
|
|
|
|
|
|
| 1985 |
'common.processing': 'Processing',
|
| 1986 |
'common.got_it': 'Got it',
|
| 1987 |
'confirm.delete_title': 'Confirm Delete',
|
|
@@ -2127,6 +2173,63 @@ const translations = {
|
|
| 2127 |
'task.publish_success': 'Task published!',
|
| 2128 |
'task.publish_failed': 'Publishing failed, please retry',
|
| 2129 |
'task.max_images': 'Max 6 images',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2130 |
'task.view_total': '🔥 Total Views',
|
| 2131 |
'task.view_today': '📅 Today Views',
|
| 2132 |
'task.save_changes': 'Save Changes',
|
|
@@ -2174,6 +2277,26 @@ const translations = {
|
|
| 2174 |
'dispute.notice_2': 'The other party will respond to your dispute',
|
| 2175 |
'dispute.notice_3': 'Please provide valid evidence',
|
| 2176 |
'dispute.max_images': 'Max 6 images',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2177 |
|
| 2178 |
// Posts / Forum
|
| 2179 |
'post.title': 'Forum',
|
|
@@ -2186,6 +2309,12 @@ const translations = {
|
|
| 2186 |
'post.network_cache': 'Network error, loaded from cache',
|
| 2187 |
'post.login_first': 'Please login to create posts',
|
| 2188 |
'post.video_tag': 'Video',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2189 |
'post.sort_latest': 'Latest',
|
| 2190 |
'post.sort_likes': '👍 Most Liked',
|
| 2191 |
'post.sort_favorites': '🔖 Most Favorited',
|
|
@@ -2214,6 +2343,13 @@ const translations = {
|
|
| 2214 |
'post.comment_failed': 'Comment failed',
|
| 2215 |
'post.tip_board_title': '🎁 Top Tippers',
|
| 2216 |
'post.no_tips': '🎁 No tips yet',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2217 |
|
| 2218 |
// ✨ Prompt Market
|
| 2219 |
'nav.prompts': 'Prompts',
|
|
@@ -2417,6 +2553,8 @@ const translations = {
|
|
| 2417 |
'common.uploading': 'Uploading',
|
| 2418 |
'common.upload_progress': 'Upload ({current}/{total})',
|
| 2419 |
'common.submitting': 'Submitting',
|
|
|
|
|
|
|
| 2420 |
'common.upload_image_progress': 'Uploading ({current}/{total})',
|
| 2421 |
'common.unknown_task': 'Unknown Task',
|
| 2422 |
|
|
@@ -2518,6 +2656,8 @@ const translations = {
|
|
| 2518 |
'profile.task_earnings': 'Task Earnings',
|
| 2519 |
'profile.privacy_hidden': 'This list is private due to the author\'s privacy settings',
|
| 2520 |
'profile.no_following': 'Not following anyone',
|
|
|
|
|
|
|
| 2521 |
'profile.no_collected': 'No favorites yet',
|
| 2522 |
'profile.go_discover': 'Go discover content in rankings or forum!',
|
| 2523 |
'profile.collected_items': 'Favorited Resources',
|
|
@@ -2664,7 +2804,17 @@ const translations = {
|
|
| 2664 |
*/
|
| 2665 |
export function t(key, params = {}) {
|
| 2666 |
const lang = translations[currentLang] || translations['zh-CN'];
|
| 2667 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2668 |
|
| 2669 |
// 处理插值 {name}
|
| 2670 |
Object.entries(params).forEach(([k, v]) => {
|
|
@@ -2680,7 +2830,10 @@ export function t(key, params = {}) {
|
|
| 2680 |
*/
|
| 2681 |
export function tIfExists(key, fallback = '') {
|
| 2682 |
const lang = translations[currentLang] || translations['zh-CN'];
|
| 2683 |
-
|
|
|
|
|
|
|
|
|
|
| 2684 |
}
|
| 2685 |
|
| 2686 |
/**
|
|
|
|
| 31 |
'common.sending': '发送中...',
|
| 32 |
'common.retrying': '重试中...',
|
| 33 |
'common.retry_failed': '重试失败,请稍后再试',
|
| 34 |
+
'common.no_more': '没有更多了',
|
| 35 |
+
'common.parse_error': '响应解析失败',
|
| 36 |
+
'common.network_error_retry': '网络连接失败,请稍后重试',
|
| 37 |
'common.more': '更多',
|
| 38 |
'common.back': '返回',
|
| 39 |
'common.search': '搜索',
|
|
|
|
| 311 |
'wallet.recharge.custom_placeholder': '请输入自定义金额 (1~10000)',
|
| 312 |
'wallet.recharge.max_amount_exceeded': '单次充值金额不得超过10000元',
|
| 313 |
'wallet.recharge.processing': '处理中...',
|
| 314 |
+
'wallet.recharge.timeout': '支付查询超时,如已支付请稍后刷新查看余额',
|
| 315 |
|
| 316 |
// 提现相关
|
| 317 |
'wallet.withdraw.title': '💳 收益提现中心',
|
|
|
|
| 330 |
'wallet.withdraw.resend_in': '{seconds}s 后重发',
|
| 331 |
'wallet.withdraw.confirm': '确认提现 (金额将转入冻结审核)',
|
| 332 |
'wallet.withdraw.submitting': '⏳ 提交中...',
|
| 333 |
+
'wallet.withdraw.timeout': '提现请求超时,请检查网络后重试',
|
| 334 |
'wallet.withdraw.no_balance': '您的可提现收益为 0,快去发布优质插件赚取积分吧!',
|
| 335 |
'wallet.withdraw.min_amount': '最低提现额度为 1 积分!',
|
| 336 |
'wallet.withdraw.fill_account': '请完整填写收款人支付宝账号与真实姓名!',
|
|
|
|
| 398 |
'feedback.loading': '处理中...',
|
| 399 |
'feedback.success': '操作成功',
|
| 400 |
'feedback.error': '操作失败,请重试',
|
|
|
|
|
|
|
| 401 |
'feedback.timeout': '请求超时',
|
| 402 |
'feedback.saved': '已保存',
|
| 403 |
'feedback.logged_out': '已退出登录',
|
|
|
|
| 422 |
'social.reply': '回复',
|
| 423 |
'social.reply_to': '回复',
|
| 424 |
'social.comment_deleted': '此条评论已由用户删除',
|
| 425 |
+
'social.delete_comment_title': '删除评论',
|
| 426 |
'social.delete_comment_confirm': '确定要删除这条评论吗?删除后将无法恢复。',
|
| 427 |
'social.no_comments': '暂无留言,来抢沙发吧~',
|
| 428 |
|
|
|
|
| 490 |
'chat.date_day': '日',
|
| 491 |
'chat.load_more': '加载更早的消息',
|
| 492 |
'chat.msg_too_long': '消息不能超1000字',
|
| 493 |
+
'chat.send_failed': '发送失败: {msg}',
|
| 494 |
'chat.load_list_failed': '加载对话列表失败',
|
| 495 |
'chat.load_history_failed': '加载消息记录失败',
|
| 496 |
'chat.load_failed_hint': '消息加载失败',
|
|
|
|
| 593 |
'item.tip_board_title': '该作品赞赏贡献榜 (TOP 10)',
|
| 594 |
'item.owned_reinstall': '已拥有 (点击重新覆盖安装)',
|
| 595 |
'item.owned_purchased': '已购买 (请在电脑端安装)',
|
| 596 |
+
'web.publish_not_supported': '⚠️ 网页版暂不支持发布/编辑功能',
|
| 597 |
+
'web.install_on_desktop': '请在电脑端进行安装',
|
| 598 |
+
'web.purchase_success_desktop': '购买成功!请在电脑端进行安装',
|
| 599 |
+
'crop.ratio_label': '比例',
|
| 600 |
+
'crop.gesture_hint_touch': '单指拖动调整位置 | 双指捏合或滑块缩放',
|
| 601 |
+
'crop.gesture_hint_mouse': '拖动调整位置 | 滚轮缩放',
|
| 602 |
'item.update_available': '发现新版本 (点击静默热更新)',
|
| 603 |
'item.get_now': '立即获取使用',
|
| 604 |
'item.free': '完全免费',
|
|
|
|
| 611 |
// 反馈补充
|
| 612 |
'feedback.deleted': '已成功删除',
|
| 613 |
'feedback.delete_failed': '删除失败',
|
| 614 |
+
'feedback.retry_suffix': ',请重试',
|
| 615 |
'feedback.cache_fallback': '⚠️ 网络连接失败,展示的是缓存数据',
|
| 616 |
+
// 🔧 新增:以下三条原为散落在各组件里的硬编码中文,现收口到词典以支持多语言
|
| 617 |
+
'feedback.offline_no_cache': '网络已断开,且无本地缓存',
|
| 618 |
+
'feedback.search_local_result': '⚠️ 网络不可用,显示本地搜索结果',
|
| 619 |
+
// 🔍 搜索请求被新关键词取代时抛出的错误消息(详见 网络请求_基础设施.js 的主动取消分支)
|
| 620 |
+
'feedback.request_cancelled': '请求已取消',
|
| 621 |
|
| 622 |
// 通用
|
| 623 |
'common.processing': '处理中',
|
|
|
|
| 832 |
'post.network_cache': '网络异常,已加载本地缓存',
|
| 833 |
'post.login_first': '请先登录后再发布作品',
|
| 834 |
'post.video_tag': '视频',
|
| 835 |
+
'post.images_unit': '张',
|
| 836 |
+
'post.delete_comment_confirm': '确定删除这条评论吗?',
|
| 837 |
+
'post.delete_comment_warning': '删除后无法恢复',
|
| 838 |
+
'post.delete_comment_success': '评论已删除',
|
| 839 |
+
'post.delete_comment_failed': '删除失败',
|
| 840 |
|
| 841 |
// 帖子详情
|
| 842 |
'post.sort_latest': '最新',
|
|
|
|
| 1078 |
'common.uploading': '上传中',
|
| 1079 |
'common.upload_progress': '上传 ({current}/{total})',
|
| 1080 |
'common.submitting': '提交中',
|
| 1081 |
+
'common.confirming': '确认中',
|
| 1082 |
+
'common.cancelling': '取消中',
|
| 1083 |
'common.upload_image_progress': '上传图片 ({current}/{total})',
|
| 1084 |
'common.unknown_task': '未知任务',
|
| 1085 |
|
|
|
|
| 1094 |
'task.confirm_assign': '确定选择 {assignee} 作为接单者吗?',
|
| 1095 |
'task.confirm_deposit': '将从您的账户扣除订金 {amount} 积分',
|
| 1096 |
'task.apply_message_prompt': '请输入申请留言(可选):',
|
| 1097 |
+
'task.apply_message_placeholder': '简单说明你的优势与预计交付时间...',
|
| 1098 |
'task.confirm_cancel_apply': '确定撤回申请吗?',
|
| 1099 |
+
'task.cancel_apply_warning': '撤回后你的申请将从候选列表中移除',
|
| 1100 |
'task.apply_cancelled': '已撤回申请',
|
| 1101 |
'task.cancel_apply_failed': '撤回失败',
|
| 1102 |
'task.confirm_cancel_task': '确定取消任务吗?此操作不可撤销。',
|
| 1103 |
+
'task.cancel_task_warning': '取消后已冻结的订金将退回你的账户',
|
| 1104 |
'task.task_cancelled': '任务已取消',
|
| 1105 |
'task.cancel_task_failed': '取消失败',
|
| 1106 |
'task.confirm_accept': '确定验收通过吗?',
|
| 1107 |
'task.confirm_pay_remaining': '将支付尾款 {amount} 积分给接单者',
|
| 1108 |
'task.reject_reason_prompt': '请输入不通过的原因:',
|
| 1109 |
+
'task.reject_reason_placeholder': '请说明需要修改的具体问题...',
|
| 1110 |
+
'task.reject_reason_required': '请输入拒绝原因',
|
| 1111 |
'task.work_returned': '已退回修改',
|
| 1112 |
'task.submit_work': '提交成果',
|
| 1113 |
'task.deliverable_images': '交付图片',
|
|
|
|
| 1299 |
// 个人列表
|
| 1300 |
'profile.privacy_hidden': '由于作者的隐私设置,该列表不对外公开',
|
| 1301 |
'profile.no_following': '暂无关注的人',
|
| 1302 |
+
'profile.no_followers': '暂无粉丝',
|
| 1303 |
+
'profile.load_more': '加载更多 ({count})',
|
| 1304 |
'profile.no_collected': '还没有收藏任何内容',
|
| 1305 |
'profile.go_discover': '去榜单页面或讨论区发现精彩内容吧!',
|
| 1306 |
'profile.collected_items': '收藏的资源',
|
|
|
|
| 1457 |
'common.sending': 'Sending...',
|
| 1458 |
'common.retrying': 'Retrying...',
|
| 1459 |
'common.retry_failed': 'Retry failed, please try again later',
|
| 1460 |
+
'common.no_more': 'No more items',
|
| 1461 |
+
'common.parse_error': 'Failed to parse response',
|
| 1462 |
+
'common.network_error_retry': 'Network connection failed, please try again later',
|
| 1463 |
'common.more': 'More',
|
| 1464 |
'common.back': 'Back',
|
| 1465 |
'common.search': 'Search',
|
|
|
|
| 1732 |
'wallet.recharge.custom_placeholder': 'Enter custom amount (1~10000)',
|
| 1733 |
'wallet.recharge.max_amount_exceeded': 'Single recharge cannot exceed 10000',
|
| 1734 |
'wallet.recharge.processing': 'Processing...',
|
| 1735 |
+
'wallet.recharge.timeout': 'Payment check timed out. If you have already paid, refresh later to see your balance',
|
| 1736 |
|
| 1737 |
// Withdraw
|
| 1738 |
'wallet.withdraw.title': '💳 Earnings Withdrawal Center',
|
|
|
|
| 1751 |
'wallet.withdraw.resend_in': 'Resend in {seconds}s',
|
| 1752 |
'wallet.withdraw.confirm': 'Confirm Withdrawal (Funds Will Be Frozen for Review)',
|
| 1753 |
'wallet.withdraw.submitting': '⏳ Submitting...',
|
| 1754 |
+
'wallet.withdraw.timeout': 'Withdrawal request timed out. Check your network and try again',
|
| 1755 |
'wallet.withdraw.no_balance': 'Your withdrawable balance is 0. Publish quality plugins to earn points!',
|
| 1756 |
'wallet.withdraw.min_amount': 'Minimum withdrawal is 1 point!',
|
| 1757 |
'wallet.withdraw.fill_account': 'Please fill in both Alipay account and real name!',
|
|
|
|
| 1819 |
'feedback.loading': 'Processing...',
|
| 1820 |
'feedback.success': 'Success',
|
| 1821 |
'feedback.error': 'Failed, please retry',
|
|
|
|
|
|
|
| 1822 |
'feedback.timeout': 'Request timeout',
|
| 1823 |
'feedback.saved': 'Saved',
|
| 1824 |
'feedback.logged_out': 'Logged out',
|
|
|
|
| 1842 |
'social.say_something': 'Say something...',
|
| 1843 |
'social.reply_to': 'Reply to',
|
| 1844 |
'social.comment_deleted': 'This comment has been deleted',
|
| 1845 |
+
'social.delete_comment_title': 'Delete Comment',
|
| 1846 |
'social.delete_comment_confirm': 'Are you sure you want to delete this comment? This cannot be undone.',
|
| 1847 |
'social.no_comments': 'No comments yet. Be the first!',
|
| 1848 |
|
|
|
|
| 1908 |
'chat.date_day': '',
|
| 1909 |
'chat.load_more': 'Load earlier messages',
|
| 1910 |
'chat.msg_too_long': 'Message cannot exceed 1000 characters',
|
| 1911 |
+
'chat.send_failed': 'Send failed: {msg}',
|
| 1912 |
'chat.load_list_failed': 'Failed to load conversations',
|
| 1913 |
'chat.load_history_failed': 'Failed to load messages',
|
| 1914 |
'chat.load_failed_hint': 'Message loading failed',
|
|
|
|
| 2009 |
'item.tip_board_title': 'Top Supporters (TOP 10)',
|
| 2010 |
'item.owned_reinstall': 'Owned (Click to reinstall)',
|
| 2011 |
'item.owned_purchased': 'Purchased (Install on PC)',
|
| 2012 |
+
'web.publish_not_supported': '⚠️ Publishing and editing are not available in the web version',
|
| 2013 |
+
'web.install_on_desktop': 'Please install it in the desktop version',
|
| 2014 |
+
'web.purchase_success_desktop': 'Purchase successful! Please install it in the desktop version',
|
| 2015 |
+
'crop.ratio_label': 'Ratio',
|
| 2016 |
+
'crop.gesture_hint_touch': 'Drag with one finger to reposition | Pinch or use the slider to zoom',
|
| 2017 |
+
'crop.gesture_hint_mouse': 'Drag to reposition | Scroll to zoom',
|
| 2018 |
'item.update_available': 'Update available (Click for silent hot update)',
|
| 2019 |
'item.get_now': 'Get Now',
|
| 2020 |
'item.free': 'Free',
|
|
|
|
| 2023 |
'time.hours_later': ' hours effective',
|
| 2024 |
'feedback.deleted': 'Successfully deleted',
|
| 2025 |
'feedback.delete_failed': 'Delete failed',
|
| 2026 |
+
'feedback.retry_suffix': ', please try again',
|
| 2027 |
'feedback.cache_fallback': '⚠️ Network failed, showing cached data',
|
| 2028 |
+
'feedback.offline_no_cache': 'Network disconnected, and no local cache available',
|
| 2029 |
+
'feedback.search_local_result': '⚠️ Network unavailable, showing local search results',
|
| 2030 |
+
'feedback.request_cancelled': 'Request cancelled',
|
| 2031 |
'common.processing': 'Processing',
|
| 2032 |
'common.got_it': 'Got it',
|
| 2033 |
'confirm.delete_title': 'Confirm Delete',
|
|
|
|
| 2173 |
'task.publish_success': 'Task published!',
|
| 2174 |
'task.publish_failed': 'Publishing failed, please retry',
|
| 2175 |
'task.max_images': 'Max 6 images',
|
| 2176 |
+
// 🔧 补齐:以下 50 条 zh-CN 已有、en-US 缺失,导致英文界面下任务榜退回中文显示
|
| 2177 |
+
'task.description': 'Task Description',
|
| 2178 |
+
'task.reference_images': 'Reference Images',
|
| 2179 |
+
'task.reference_link': 'Reference Link',
|
| 2180 |
+
'task.assignee': 'Assignee',
|
| 2181 |
+
'task.note': 'Note',
|
| 2182 |
+
// 中文量词「人」,英文不用量词(Applicants (5)),故刻意留空;t() 已修为尊重空值
|
| 2183 |
+
'task.applicants_count': '',
|
| 2184 |
+
'task.choose_assignee': 'Select',
|
| 2185 |
+
'task.confirm_assign': 'Confirm selecting {assignee} as the assignee?',
|
| 2186 |
+
'task.confirm_deposit': 'A deposit of {amount} credits will be deducted from your account',
|
| 2187 |
+
'task.apply_message_prompt': 'Enter your application message (optional):',
|
| 2188 |
+
'task.apply_message_placeholder': 'Briefly describe your strengths and estimated delivery time...',
|
| 2189 |
+
'task.confirm_cancel_apply': 'Withdraw your application?',
|
| 2190 |
+
'task.cancel_apply_warning': 'Your application will be removed from the candidate list',
|
| 2191 |
+
'task.apply_cancelled': 'Application withdrawn',
|
| 2192 |
+
'task.confirm_cancel_task': 'Cancel this task? This cannot be undone.',
|
| 2193 |
+
'task.cancel_task_warning': 'The frozen deposit will be refunded to your account after cancellation',
|
| 2194 |
+
'task.task_cancelled': 'Task cancelled',
|
| 2195 |
+
'task.cancel_task_failed': 'Cancellation failed',
|
| 2196 |
+
'task.confirm_accept': 'Approve and accept the delivery?',
|
| 2197 |
+
'task.confirm_pay_remaining': 'The remaining {amount} credits will be paid to the assignee',
|
| 2198 |
+
'task.reject_reason_prompt': 'Enter the reason for rejection:',
|
| 2199 |
+
'task.reject_reason_placeholder': 'Describe the specific issues that need revision...',
|
| 2200 |
+
'task.reject_reason_required': 'Please enter a reason for rejection',
|
| 2201 |
+
'task.work_returned': 'Returned for revision',
|
| 2202 |
+
'task.submit_work': 'Submit Work',
|
| 2203 |
+
'task.deliverable_images': 'Delivery Images',
|
| 2204 |
+
'task.support_multiple_images': 'Multiple images supported',
|
| 2205 |
+
'task.note_optional': 'Note (optional)',
|
| 2206 |
+
'task.note_placeholder': 'Describe the delivered content...',
|
| 2207 |
+
'task.please_upload_deliverables': 'Please upload delivery images',
|
| 2208 |
+
'task.upload_failed_retry': 'Image upload failed, please retry',
|
| 2209 |
+
'task.submit_success_waiting': 'Work submitted, awaiting acceptance',
|
| 2210 |
+
'task.start_dispute': 'File Dispute',
|
| 2211 |
+
'task.accept_work': 'Accept Work',
|
| 2212 |
+
'task.reject_work': 'Reject Work',
|
| 2213 |
+
'task.task_title': 'Task Title',
|
| 2214 |
+
'task.description_placeholder': 'Describe the requirements, delivery criteria, references...',
|
| 2215 |
+
'task.reference_images_optional': 'Reference images (optional, max 6)',
|
| 2216 |
+
'task.add_reference_image': 'Add reference image',
|
| 2217 |
+
'task.reference_link_optional': 'Reference link (optional)',
|
| 2218 |
+
'task.min_10_credits': 'Minimum 10 credits',
|
| 2219 |
+
'task.deposit_deducted_on_assign': 'Deducted on assignment',
|
| 2220 |
+
'task.price_description': 'Price Breakdown',
|
| 2221 |
+
'task.deposit_on_assign': 'Deposit (deducted when assigning)',
|
| 2222 |
+
'task.remaining_on_accept': 'Balance (deducted on acceptance)',
|
| 2223 |
+
'task.publish_task': 'Publish Task',
|
| 2224 |
+
'task.manage_in_task_list': 'After publishing, manage your tasks in the Task Board',
|
| 2225 |
+
'task.please_enter_title': 'Please enter a task title',
|
| 2226 |
+
'task.please_enter_description': 'Please enter a task description',
|
| 2227 |
+
'task.price_min_10': 'Task price cannot be lower than 10 credits',
|
| 2228 |
+
'task.please_select_deadline': 'Please select a deadline',
|
| 2229 |
+
'task.uploading_images': 'Uploading images',
|
| 2230 |
+
'task.upload_progress': 'Uploading images ({current}/{total})',
|
| 2231 |
+
'task.publishing': 'Publishing',
|
| 2232 |
+
'task.max_6_images': 'Max 6 images',
|
| 2233 |
'task.view_total': '🔥 Total Views',
|
| 2234 |
'task.view_today': '📅 Today Views',
|
| 2235 |
'task.save_changes': 'Save Changes',
|
|
|
|
| 2277 |
'dispute.notice_2': 'The other party will respond to your dispute',
|
| 2278 |
'dispute.notice_3': 'Please provide valid evidence',
|
| 2279 |
'dispute.max_images': 'Max 6 images',
|
| 2280 |
+
// 🔧 补齐:以下 19 条 zh-CN 已有、en-US 缺失,导致英文界面下申诉流程退回中文显示
|
| 2281 |
+
'dispute.start': 'File Dispute',
|
| 2282 |
+
'dispute.reason': 'Dispute Reason',
|
| 2283 |
+
'dispute.reason_placeholder': 'Please describe your reason in detail...',
|
| 2284 |
+
'dispute.evidence_optional': 'Evidence images (optional, max 6)',
|
| 2285 |
+
'dispute.click_upload_evidence': 'Click to upload evidence images',
|
| 2286 |
+
'dispute.submit': 'Submit Dispute',
|
| 2287 |
+
'dispute.submit_success': 'Dispute submitted, awaiting response',
|
| 2288 |
+
'dispute.submit_failed': 'Dispute failed',
|
| 2289 |
+
'dispute.max_6_images': 'Max 6 images',
|
| 2290 |
+
'dispute.please_enter_reason': 'Please enter a dispute reason',
|
| 2291 |
+
'dispute.get_detail_failed': 'Failed to load dispute details',
|
| 2292 |
+
'dispute.related_task': 'Related Task',
|
| 2293 |
+
'dispute.task_id': 'Task ID',
|
| 2294 |
+
'dispute.click_upload_evidence_optional': 'Click to upload evidence images (optional)',
|
| 2295 |
+
'dispute.resolution_result': 'Arbitration Result',
|
| 2296 |
+
'dispute.upload_failed': 'Upload failed',
|
| 2297 |
+
'dispute.please_enter_response': 'Please enter your response',
|
| 2298 |
+
'dispute.response_submitted': 'Response submitted',
|
| 2299 |
+
'dispute.split_result': 'Split by mutual agreement',
|
| 2300 |
|
| 2301 |
// Posts / Forum
|
| 2302 |
'post.title': 'Forum',
|
|
|
|
| 2309 |
'post.network_cache': 'Network error, loaded from cache',
|
| 2310 |
'post.login_first': 'Please login to create posts',
|
| 2311 |
'post.video_tag': 'Video',
|
| 2312 |
+
// 中文量词「张」,英文不用量词(🖼️ 5),故刻意留空;t() 已修为尊重空值
|
| 2313 |
+
'post.images_unit': '',
|
| 2314 |
+
'post.delete_comment_confirm': 'Delete this comment?',
|
| 2315 |
+
'post.delete_comment_warning': 'This cannot be undone',
|
| 2316 |
+
'post.delete_comment_success': 'Comment deleted',
|
| 2317 |
+
'post.delete_comment_failed': 'Delete failed',
|
| 2318 |
'post.sort_latest': 'Latest',
|
| 2319 |
'post.sort_likes': '👍 Most Liked',
|
| 2320 |
'post.sort_favorites': '🔖 Most Favorited',
|
|
|
|
| 2343 |
'post.comment_failed': 'Comment failed',
|
| 2344 |
'post.tip_board_title': '🎁 Top Tippers',
|
| 2345 |
'post.no_tips': '🎁 No tips yet',
|
| 2346 |
+
// 🔧 补齐:以下 6 条 zh-CN 已有、en-US 缺失,导致英文界面下讨论区退回中文显示
|
| 2347 |
+
'post.uploading_images': 'Uploading images',
|
| 2348 |
+
'post.edit_title': 'Edit Post',
|
| 2349 |
+
'post.delete_confirm_title': 'Confirm Deletion',
|
| 2350 |
+
'post.delete_confirm_desc': 'This cannot be undone. Delete this post?',
|
| 2351 |
+
'post.delete_success': 'Deleted successfully',
|
| 2352 |
+
'post.delete_failed': 'Deletion failed',
|
| 2353 |
|
| 2354 |
// ✨ Prompt Market
|
| 2355 |
'nav.prompts': 'Prompts',
|
|
|
|
| 2553 |
'common.uploading': 'Uploading',
|
| 2554 |
'common.upload_progress': 'Upload ({current}/{total})',
|
| 2555 |
'common.submitting': 'Submitting',
|
| 2556 |
+
'common.confirming': 'Confirming',
|
| 2557 |
+
'common.cancelling': 'Cancelling',
|
| 2558 |
'common.upload_image_progress': 'Uploading ({current}/{total})',
|
| 2559 |
'common.unknown_task': 'Unknown Task',
|
| 2560 |
|
|
|
|
| 2656 |
'profile.task_earnings': 'Task Earnings',
|
| 2657 |
'profile.privacy_hidden': 'This list is private due to the author\'s privacy settings',
|
| 2658 |
'profile.no_following': 'Not following anyone',
|
| 2659 |
+
'profile.no_followers': 'No followers yet',
|
| 2660 |
+
'profile.load_more': 'Load More ({count})',
|
| 2661 |
'profile.no_collected': 'No favorites yet',
|
| 2662 |
'profile.go_discover': 'Go discover content in rankings or forum!',
|
| 2663 |
'profile.collected_items': 'Favorited Resources',
|
|
|
|
| 2804 |
*/
|
| 2805 |
export function t(key, params = {}) {
|
| 2806 |
const lang = translations[currentLang] || translations['zh-CN'];
|
| 2807 |
+
// 🔧 修复:原实现 lang[key] || zh[key] || key 会把「刻意留空的词条」当成缺失又退回中文。
|
| 2808 |
+
// 中文量词/助词在英文里没有对应,必须留空:
|
| 2809 |
+
// chat.date_day 9月8日 → 9/8
|
| 2810 |
+
// chat.chatting_suffix 与 X 聊天 → Chatting with X
|
| 2811 |
+
// creator.output.unit 产出插件 12 个 → Plugins: 12
|
| 2812 |
+
// profile.transactions_unit 5笔 → 5
|
| 2813 |
+
// 旧逻辑下英文界面实际显示为 9/8日、Chatting with X 聊天、Plugins: 12 个、(5笔)。
|
| 2814 |
+
// 现改为:词条存在即采用(含空串),仅在真正缺键时才回退到中文。
|
| 2815 |
+
let text = Object.prototype.hasOwnProperty.call(lang, key)
|
| 2816 |
+
? lang[key]
|
| 2817 |
+
: (translations['zh-CN'][key] || key);
|
| 2818 |
|
| 2819 |
// 处理插值 {name}
|
| 2820 |
Object.entries(params).forEach(([k, v]) => {
|
|
|
|
| 2830 |
*/
|
| 2831 |
export function tIfExists(key, fallback = '') {
|
| 2832 |
const lang = translations[currentLang] || translations['zh-CN'];
|
| 2833 |
+
// 🔧 与 t() 同口径:词条存在即采用(含刻意留空),仅在两级都缺键时用 fallback
|
| 2834 |
+
if (Object.prototype.hasOwnProperty.call(lang, key)) return lang[key];
|
| 2835 |
+
if (Object.prototype.hasOwnProperty.call(translations['zh-CN'], key)) return translations['zh-CN'][key];
|
| 2836 |
+
return fallback;
|
| 2837 |
}
|
| 2838 |
|
| 2839 |
/**
|
前端页面/components/视频播放器组件.js
CHANGED
|
@@ -317,7 +317,7 @@ function _updatePlayButton(btnPlay, video) {
|
|
| 317 |
if (!btnPlay) return;
|
| 318 |
const playing = !video.paused && !video.ended;
|
| 319 |
btnPlay.textContent = getPlayIcon(playing);
|
| 320 |
-
btnPlay.title = playing ?
|
| 321 |
}
|
| 322 |
|
| 323 |
/**
|
|
@@ -394,14 +394,14 @@ export function getVideoPlayerHTML(videoUrl, posterUrl, options = {}) {
|
|
| 394 |
<div class="video-progress-tooltip">0:00</div>
|
| 395 |
</div>
|
| 396 |
<div class="video-controls-row">
|
| 397 |
-
<button class="video-btn-play" title="${t('video.play')
|
| 398 |
<span class="video-time">0:00 / 0:00</span>
|
| 399 |
<div class="video-volume-wrap">
|
| 400 |
-
<button class="video-btn-volume" title="${t('video.volume')
|
| 401 |
<input type="range" class="video-volume-slider" min="0" max="100" value="100">
|
| 402 |
</div>
|
| 403 |
<div class="video-speed-wrap">
|
| 404 |
-
<button class="video-btn-speed" title="${t('video.speed')
|
| 405 |
<div class="video-speed-menu">
|
| 406 |
<div data-speed="0.5">0.5x</div>
|
| 407 |
<div data-speed="1" class="active">1x</div>
|
|
@@ -410,7 +410,7 @@ export function getVideoPlayerHTML(videoUrl, posterUrl, options = {}) {
|
|
| 410 |
<div data-speed="2">2x</div>
|
| 411 |
</div>
|
| 412 |
</div>
|
| 413 |
-
<button class="video-btn-fullscreen" title="${t('video.fullscreen')
|
| 414 |
</div>
|
| 415 |
</div>
|
| 416 |
</div>
|
|
@@ -444,10 +444,10 @@ export function setupVideoPlayerEvents(container) {
|
|
| 444 |
if (!video) return;
|
| 445 |
|
| 446 |
// 设置按钮文字(fallback)
|
| 447 |
-
if (btnPlay) btnPlay.title = t('video.play')
|
| 448 |
-
if (btnVolume) btnVolume.title = t('video.volume')
|
| 449 |
-
if (btnSpeed) btnSpeed.title = t('video.speed')
|
| 450 |
-
if (btnFullscreen) btnFullscreen.title = t('video.fullscreen')
|
| 451 |
|
| 452 |
let isDraggingProgress = false;
|
| 453 |
let wasPlayingBeforeDrag = false;
|
|
@@ -824,7 +824,7 @@ export function openFullscreenVideo(videoUrl, posterUrl, options = {}) {
|
|
| 824 |
const startRate = options.playbackRate || 1;
|
| 825 |
|
| 826 |
overlay.innerHTML = `
|
| 827 |
-
<button class="fs-video-close" title="${t('video.close')
|
| 828 |
<video preload="metadata" poster="${safePoster}" playsinline
|
| 829 |
style="width:100vw;height:100vh;object-fit:contain;">
|
| 830 |
<source src="${safeVideo}">
|
|
@@ -837,14 +837,14 @@ export function openFullscreenVideo(videoUrl, posterUrl, options = {}) {
|
|
| 837 |
<div class="video-progress-tooltip">0:00</div>
|
| 838 |
</div>
|
| 839 |
<div class="video-controls-row">
|
| 840 |
-
<button class="video-btn-play" title="${t('video.play')
|
| 841 |
<span class="video-time">0:00 / 0:00</span>
|
| 842 |
<div class="video-volume-wrap">
|
| 843 |
-
<button class="video-btn-volume" title="${t('video.volume')
|
| 844 |
<input type="range" class="video-volume-slider" min="0" max="100" value="100">
|
| 845 |
</div>
|
| 846 |
<div class="video-speed-wrap">
|
| 847 |
-
<button class="video-btn-speed" title="${t('video.speed')
|
| 848 |
<div class="video-speed-menu">
|
| 849 |
<div data-speed="0.5">0.5x</div>
|
| 850 |
<div data-speed="1" class="active">1x</div>
|
|
@@ -853,7 +853,7 @@ export function openFullscreenVideo(videoUrl, posterUrl, options = {}) {
|
|
| 853 |
<div data-speed="2">2x</div>
|
| 854 |
</div>
|
| 855 |
</div>
|
| 856 |
-
<button class="video-btn-fullscreen" title="${t('video.exit_fullscreen')
|
| 857 |
</div>
|
| 858 |
</div>
|
| 859 |
`;
|
|
|
|
| 317 |
if (!btnPlay) return;
|
| 318 |
const playing = !video.paused && !video.ended;
|
| 319 |
btnPlay.textContent = getPlayIcon(playing);
|
| 320 |
+
btnPlay.title = playing ? t('video.pause') : t('video.play');
|
| 321 |
}
|
| 322 |
|
| 323 |
/**
|
|
|
|
| 394 |
<div class="video-progress-tooltip">0:00</div>
|
| 395 |
</div>
|
| 396 |
<div class="video-controls-row">
|
| 397 |
+
<button class="video-btn-play" title="${t('video.play')}">${getPlayIcon(false)}</button>
|
| 398 |
<span class="video-time">0:00 / 0:00</span>
|
| 399 |
<div class="video-volume-wrap">
|
| 400 |
+
<button class="video-btn-volume" title="${t('video.volume')}">🔊</button>
|
| 401 |
<input type="range" class="video-volume-slider" min="0" max="100" value="100">
|
| 402 |
</div>
|
| 403 |
<div class="video-speed-wrap">
|
| 404 |
+
<button class="video-btn-speed" title="${t('video.speed')}">1x</button>
|
| 405 |
<div class="video-speed-menu">
|
| 406 |
<div data-speed="0.5">0.5x</div>
|
| 407 |
<div data-speed="1" class="active">1x</div>
|
|
|
|
| 410 |
<div data-speed="2">2x</div>
|
| 411 |
</div>
|
| 412 |
</div>
|
| 413 |
+
<button class="video-btn-fullscreen" title="${t('video.fullscreen')}">⛶</button>
|
| 414 |
</div>
|
| 415 |
</div>
|
| 416 |
</div>
|
|
|
|
| 444 |
if (!video) return;
|
| 445 |
|
| 446 |
// 设置按钮文字(fallback)
|
| 447 |
+
if (btnPlay) btnPlay.title = t('video.play');
|
| 448 |
+
if (btnVolume) btnVolume.title = t('video.volume');
|
| 449 |
+
if (btnSpeed) btnSpeed.title = t('video.speed');
|
| 450 |
+
if (btnFullscreen) btnFullscreen.title = t('video.fullscreen');
|
| 451 |
|
| 452 |
let isDraggingProgress = false;
|
| 453 |
let wasPlayingBeforeDrag = false;
|
|
|
|
| 824 |
const startRate = options.playbackRate || 1;
|
| 825 |
|
| 826 |
overlay.innerHTML = `
|
| 827 |
+
<button class="fs-video-close" title="${t('video.close')}">✕</button>
|
| 828 |
<video preload="metadata" poster="${safePoster}" playsinline
|
| 829 |
style="width:100vw;height:100vh;object-fit:contain;">
|
| 830 |
<source src="${safeVideo}">
|
|
|
|
| 837 |
<div class="video-progress-tooltip">0:00</div>
|
| 838 |
</div>
|
| 839 |
<div class="video-controls-row">
|
| 840 |
+
<button class="video-btn-play" title="${t('video.play')}">▶</button>
|
| 841 |
<span class="video-time">0:00 / 0:00</span>
|
| 842 |
<div class="video-volume-wrap">
|
| 843 |
+
<button class="video-btn-volume" title="${t('video.volume')}">🔊</button>
|
| 844 |
<input type="range" class="video-volume-slider" min="0" max="100" value="100">
|
| 845 |
</div>
|
| 846 |
<div class="video-speed-wrap">
|
| 847 |
+
<button class="video-btn-speed" title="${t('video.speed')}">1x</button>
|
| 848 |
<div class="video-speed-menu">
|
| 849 |
<div data-speed="0.5">0.5x</div>
|
| 850 |
<div data-speed="1" class="active">1x</div>
|
|
|
|
| 853 |
<div data-speed="2">2x</div>
|
| 854 |
</div>
|
| 855 |
</div>
|
| 856 |
+
<button class="video-btn-fullscreen" title="${t('video.exit_fullscreen')}">⛶</button>
|
| 857 |
</div>
|
| 858 |
</div>
|
| 859 |
`;
|
前端页面/components/顶部导航组件.js
CHANGED
|
@@ -7,7 +7,7 @@ import { showToast } from "./UI交互提示组件.js";
|
|
| 7 |
import { showAboutInfo } from "./关于插件组件.js";
|
| 8 |
import { openNotificationCenter, loadUnreadCount } from "../social/通知中心组件.js";
|
| 9 |
import { openSettingsPage } from "./全局设置组件.js"; // ⚙️ 新增
|
| 10 |
-
import { CACHE } from "../core/全局配置.js";
|
| 11 |
import { t } from "./用户体验增强.js"; // 🌐 多语言支持
|
| 12 |
import { createTopBanner } from "./顶部广告组件.js";
|
| 13 |
import { uploadFile } from "../market/发布内容_提交引擎.js"; // 🖼️ 带压缩管线的统一上传包装器
|
|
@@ -42,7 +42,7 @@ export function createTopNav() {
|
|
| 42 |
});
|
| 43 |
|
| 44 |
const titleSpan = document.createElement("div");
|
| 45 |
-
titleSpan.innerHTML = `<strong style='color:#4CAF50;'>ComfyUI</strong> ${t('nav.community')
|
| 46 |
|
| 47 |
Object.assign(titleSpan.style, { fontSize: "16px", cursor: "pointer", transition: "opacity 0.2s" });
|
| 48 |
titleSpan.title = "查看关于本插件与作者信息";
|
|
@@ -105,7 +105,7 @@ export function createTopNav() {
|
|
| 105 |
// 避免每次 createTopNav 重复 addEventListener 导致闭包累积与轮询失效
|
| 106 |
|
| 107 |
chatEntryBtn.onclick = () => {
|
| 108 |
-
if (!currentUser) return showToast(t('auth.login_required')
|
| 109 |
openChatModal(currentUser);
|
| 110 |
};
|
| 111 |
|
|
@@ -114,7 +114,7 @@ export function createTopNav() {
|
|
| 114 |
|
| 115 |
const updateUserButtonState = () => {
|
| 116 |
if (currentUser) {
|
| 117 |
-
userActionBtn.innerHTML = `👤 ${currentUser.name || currentUser.account || t('common.unknown_user')
|
| 118 |
userActionBtn.style.backgroundColor = "#2196F3";
|
| 119 |
userActionBtn.style.borderColor = "#2196F3";
|
| 120 |
userActionBtn.onclick = () => openUserProfileModal(currentUser);
|
|
@@ -138,12 +138,12 @@ export function createTopNav() {
|
|
| 138 |
let userData; let token; let isRemember = false;
|
| 139 |
|
| 140 |
if (formData.type === "reset") {
|
| 141 |
-
userActionBtn.innerHTML = `⏳ ${t('auth.resetting_password')
|
| 142 |
|
| 143 |
// 🚀 核心修复:停止错误地拆解字段导致 undefined,直接将包含所有数据的 formData 对象完整传给 API 装甲!
|
| 144 |
await api.resetPassword(formData);
|
| 145 |
|
| 146 |
-
showToast(t('auth.password_reset_success')
|
| 147 |
window.dispatchEvent(new CustomEvent("comfy-route-back")); updateUserButtonState(); return;
|
| 148 |
}
|
| 149 |
if (formData.type === "register") {
|
|
@@ -153,9 +153,9 @@ export function createTopNav() {
|
|
| 153 |
delete formData.avatarFile;
|
| 154 |
delete formData.avatarDataUrl; // base64 预览值会被后端拒绝,不随注册提交
|
| 155 |
|
| 156 |
-
userActionBtn.innerHTML = `⏳ ${t('auth.registering')
|
| 157 |
await api.register(formData);
|
| 158 |
-
showToast(t('auth.register_success') +
|
| 159 |
// 注册后自动登录,默认保持登录
|
| 160 |
const res = await api.login(formData.account, formData.password, true);
|
| 161 |
userData = { account: formData.account, name: formData.name, avatar: res.avatar, ...res };
|
|
@@ -163,7 +163,7 @@ export function createTopNav() {
|
|
| 163 |
|
| 164 |
// 🖼️ 登录成功后再上传头像(此时请求可携带 Authorization)
|
| 165 |
if (pendingAvatarFile && token) {
|
| 166 |
-
userActionBtn.innerHTML = `⏳ ${t('auth.uploading_avatar')
|
| 167 |
// 提前写入 token,确保上传与资料更新请求能通过认证
|
| 168 |
localStorage.setItem("ComfyCommunity_Token", token);
|
| 169 |
try {
|
|
@@ -174,11 +174,11 @@ export function createTopNav() {
|
|
| 174 |
userData = { ...userData, ...(updateRes.data || {}) };
|
| 175 |
if (userData.avatarDataUrl) userData.avatar = userData.avatarDataUrl;
|
| 176 |
} else {
|
| 177 |
-
showToast(t('auth.avatar_set_failed')
|
| 178 |
}
|
| 179 |
} catch (avatarErr) {
|
| 180 |
console.warn("注册后设置头像失败:", avatarErr.message || avatarErr);
|
| 181 |
-
showToast(t('auth.avatar_set_failed')
|
| 182 |
}
|
| 183 |
}
|
| 184 |
} else if (formData.type === "login") {
|
|
|
|
| 7 |
import { showAboutInfo } from "./关于插件组件.js";
|
| 8 |
import { openNotificationCenter, loadUnreadCount } from "../social/通知中心组件.js";
|
| 9 |
import { openSettingsPage } from "./全局设置组件.js"; // ⚙️ 新增
|
| 10 |
+
import { CACHE, escapeHtml } from "../core/全局配置.js";
|
| 11 |
import { t } from "./用户体验增强.js"; // 🌐 多语言支持
|
| 12 |
import { createTopBanner } from "./顶部广告组件.js";
|
| 13 |
import { uploadFile } from "../market/发布内容_提交引擎.js"; // 🖼️ 带压缩管线的统一上传包装器
|
|
|
|
| 42 |
});
|
| 43 |
|
| 44 |
const titleSpan = document.createElement("div");
|
| 45 |
+
titleSpan.innerHTML = `<strong style='color:#4CAF50;'>ComfyUI</strong> ${t('nav.community')}`;
|
| 46 |
|
| 47 |
Object.assign(titleSpan.style, { fontSize: "16px", cursor: "pointer", transition: "opacity 0.2s" });
|
| 48 |
titleSpan.title = "查看关于本插件与作者信息";
|
|
|
|
| 105 |
// 避免每次 createTopNav 重复 addEventListener 导致闭包累积与轮询失效
|
| 106 |
|
| 107 |
chatEntryBtn.onclick = () => {
|
| 108 |
+
if (!currentUser) return showToast(t('auth.login_required'), "warning");
|
| 109 |
openChatModal(currentUser);
|
| 110 |
};
|
| 111 |
|
|
|
|
| 114 |
|
| 115 |
const updateUserButtonState = () => {
|
| 116 |
if (currentUser) {
|
| 117 |
+
userActionBtn.innerHTML = `👤 ${escapeHtml(currentUser.name || currentUser.account || t('common.unknown_user'))}`;
|
| 118 |
userActionBtn.style.backgroundColor = "#2196F3";
|
| 119 |
userActionBtn.style.borderColor = "#2196F3";
|
| 120 |
userActionBtn.onclick = () => openUserProfileModal(currentUser);
|
|
|
|
| 138 |
let userData; let token; let isRemember = false;
|
| 139 |
|
| 140 |
if (formData.type === "reset") {
|
| 141 |
+
userActionBtn.innerHTML = `⏳ ${t('auth.resetting_password')}`;
|
| 142 |
|
| 143 |
// 🚀 核心修复:停止错误地拆解字段导致 undefined,直接将包含所有数据的 formData 对象完整传给 API 装甲!
|
| 144 |
await api.resetPassword(formData);
|
| 145 |
|
| 146 |
+
showToast(t('auth.password_reset_success'), "success");
|
| 147 |
window.dispatchEvent(new CustomEvent("comfy-route-back")); updateUserButtonState(); return;
|
| 148 |
}
|
| 149 |
if (formData.type === "register") {
|
|
|
|
| 153 |
delete formData.avatarFile;
|
| 154 |
delete formData.avatarDataUrl; // base64 预览值会被后端拒绝,不随注册提交
|
| 155 |
|
| 156 |
+
userActionBtn.innerHTML = `⏳ ${t('auth.registering')}`;
|
| 157 |
await api.register(formData);
|
| 158 |
+
showToast(t('auth.register_success') + t('auth.auto_login'), "success");
|
| 159 |
// 注册后自动登录,默认保持登录
|
| 160 |
const res = await api.login(formData.account, formData.password, true);
|
| 161 |
userData = { account: formData.account, name: formData.name, avatar: res.avatar, ...res };
|
|
|
|
| 163 |
|
| 164 |
// 🖼️ 登录成功后再上传头像(此时请求可携带 Authorization)
|
| 165 |
if (pendingAvatarFile && token) {
|
| 166 |
+
userActionBtn.innerHTML = `⏳ ${t('auth.uploading_avatar')}`;
|
| 167 |
// 提前写入 token,确保上传与资料更新请求能通过认证
|
| 168 |
localStorage.setItem("ComfyCommunity_Token", token);
|
| 169 |
try {
|
|
|
|
| 174 |
userData = { ...userData, ...(updateRes.data || {}) };
|
| 175 |
if (userData.avatarDataUrl) userData.avatar = userData.avatarDataUrl;
|
| 176 |
} else {
|
| 177 |
+
showToast(t('auth.avatar_set_failed'), "warning");
|
| 178 |
}
|
| 179 |
} catch (avatarErr) {
|
| 180 |
console.warn("注册后设置头像失败:", avatarErr.message || avatarErr);
|
| 181 |
+
showToast(t('auth.avatar_set_failed'), "warning");
|
| 182 |
}
|
| 183 |
}
|
| 184 |
} else if (formData.type === "login") {
|
前端页面/core/侧边栏主程序.js
CHANGED
|
@@ -125,7 +125,7 @@ export function buildSidebarDOM() {
|
|
| 125 |
<option value="downloads">${noTr(t('market.downloads'))}</option>
|
| 126 |
<option value="likes">${noTr(t('market.like'))}</option>
|
| 127 |
<option value="favorites">${noTr(t('market.favorites'))}</option>
|
| 128 |
-
<option value="tips">💰 ${noTr(t('market.tips_ranking')
|
| 129 |
<option value="views">${noTr(t('market.views'))}</option>
|
| 130 |
<option value="daily_views">${noTr(t('market.daily_views'))}</option>
|
| 131 |
<option value="rating">${noTr(t('market.rating'))}</option>
|
|
@@ -156,7 +156,7 @@ export function buildSidebarDOM() {
|
|
| 156 |
<option value="tips">${noTr(t('post.sort_tips'))}</option>
|
| 157 |
<option value="views">${noTr(t('post.sort_views'))}</option>
|
| 158 |
<option value="daily_views">${noTr(t('post.sort_daily_views'))}</option>
|
| 159 |
-
<option value="rating">${noTr(t('post.sort_rating')
|
| 160 |
</select>
|
| 161 |
<!-- 🧩 提示词排序控件(专用) -->
|
| 162 |
<select id="prompts-sort-select" style="display: none; background: var(--comfy-input-bg); color: white; border: 1px solid #555; border-radius: 4px; outline: none; padding: 6px; width: 140px; flex-shrink: 0;">
|
|
@@ -300,13 +300,13 @@ export function buildSidebarDOM() {
|
|
| 300 |
// 🔴 修复:编辑时先获取详情API数据,确保 has_private_token 等字段完整(列表缓存可能缺少该字段)
|
| 301 |
_onSidebarWindow("comfy-route-edit-publish", async (e) => {
|
| 302 |
// 📱 Web 模式中央兜底:发布族功能仅本地 ComfyUI 可用
|
| 303 |
-
if (IS_WEB_MODE) return showToast(
|
| 304 |
const { itemData, currentUser } = e.detail;
|
| 305 |
|
| 306 |
// 先展示加载状态,避免用户感知延迟
|
| 307 |
const loadingView = document.createElement("div");
|
| 308 |
loadingView.style.cssText = "display:flex;flex-direction:column;align-items:center;justify-content:center;height:400px;color:#888;font-size:14px;";
|
| 309 |
-
loadingView.innerHTML = `<div style=\"font-size:24px;margin-bottom:10px;\">⏳</div><div>${t('common.loading')
|
| 310 |
showInlineView(loadingView);
|
| 311 |
|
| 312 |
// 从详情API获取完整数据(确保 has_private_token、netdisk_password 等字段不缺失)
|
|
@@ -331,7 +331,7 @@ export function buildSidebarDOM() {
|
|
| 331 |
|
| 332 |
// 监听进入任务编辑页面的请求
|
| 333 |
_onSidebarWindow("comfy-route-edit-task", (e) => {
|
| 334 |
-
if (IS_WEB_MODE) return showToast(
|
| 335 |
const { taskData, currentUser } = e.detail;
|
| 336 |
try {
|
| 337 |
const view = createPublishTaskView(currentUser, taskData);
|
|
@@ -343,7 +343,7 @@ export function buildSidebarDOM() {
|
|
| 343 |
|
| 344 |
// 监听进入帖子编辑页面的请求
|
| 345 |
_onSidebarWindow("comfy-route-edit-post", (e) => {
|
| 346 |
-
if (IS_WEB_MODE) return showToast(
|
| 347 |
const { postData, currentUser } = e.detail;
|
| 348 |
const view = createPublishPostView(currentUser, postData);
|
| 349 |
showInlineView(view);
|
|
@@ -351,7 +351,7 @@ export function buildSidebarDOM() {
|
|
| 351 |
|
| 352 |
// 监听进入提示词编辑页面的请求
|
| 353 |
_onSidebarWindow("comfy-route-edit-prompt", (e) => {
|
| 354 |
-
if (IS_WEB_MODE) return showToast(
|
| 355 |
const { promptData, currentUser } = e.detail;
|
| 356 |
const view = createPublishPromptView(currentUser, promptData);
|
| 357 |
showInlineView(view);
|
|
|
|
| 125 |
<option value="downloads">${noTr(t('market.downloads'))}</option>
|
| 126 |
<option value="likes">${noTr(t('market.like'))}</option>
|
| 127 |
<option value="favorites">${noTr(t('market.favorites'))}</option>
|
| 128 |
+
<option value="tips">💰 ${noTr(t('market.tips_ranking'))}</option>
|
| 129 |
<option value="views">${noTr(t('market.views'))}</option>
|
| 130 |
<option value="daily_views">${noTr(t('market.daily_views'))}</option>
|
| 131 |
<option value="rating">${noTr(t('market.rating'))}</option>
|
|
|
|
| 156 |
<option value="tips">${noTr(t('post.sort_tips'))}</option>
|
| 157 |
<option value="views">${noTr(t('post.sort_views'))}</option>
|
| 158 |
<option value="daily_views">${noTr(t('post.sort_daily_views'))}</option>
|
| 159 |
+
<option value="rating">${noTr(t('post.sort_rating'))}</option>
|
| 160 |
</select>
|
| 161 |
<!-- 🧩 提示词排序控件(专用) -->
|
| 162 |
<select id="prompts-sort-select" style="display: none; background: var(--comfy-input-bg); color: white; border: 1px solid #555; border-radius: 4px; outline: none; padding: 6px; width: 140px; flex-shrink: 0;">
|
|
|
|
| 300 |
// 🔴 修复:编辑时先获取详情API数据,确保 has_private_token 等字段完整(列表缓存可能缺少该字段)
|
| 301 |
_onSidebarWindow("comfy-route-edit-publish", async (e) => {
|
| 302 |
// 📱 Web 模式中央兜底:发布族功能仅本地 ComfyUI 可用
|
| 303 |
+
if (IS_WEB_MODE) return showToast(t('web.publish_not_supported'), "warning");
|
| 304 |
const { itemData, currentUser } = e.detail;
|
| 305 |
|
| 306 |
// 先展示加载状态,避免用户感知延迟
|
| 307 |
const loadingView = document.createElement("div");
|
| 308 |
loadingView.style.cssText = "display:flex;flex-direction:column;align-items:center;justify-content:center;height:400px;color:#888;font-size:14px;";
|
| 309 |
+
loadingView.innerHTML = `<div style=\"font-size:24px;margin-bottom:10px;\">⏳</div><div>${t('common.loading')}</div>`;
|
| 310 |
showInlineView(loadingView);
|
| 311 |
|
| 312 |
// 从详情API获取完整数据(确保 has_private_token、netdisk_password 等字段不缺失)
|
|
|
|
| 331 |
|
| 332 |
// 监听进入任务编辑页面的请求
|
| 333 |
_onSidebarWindow("comfy-route-edit-task", (e) => {
|
| 334 |
+
if (IS_WEB_MODE) return showToast(t('web.publish_not_supported'), "warning");
|
| 335 |
const { taskData, currentUser } = e.detail;
|
| 336 |
try {
|
| 337 |
const view = createPublishTaskView(currentUser, taskData);
|
|
|
|
| 343 |
|
| 344 |
// 监听进入帖子编辑页面的请求
|
| 345 |
_onSidebarWindow("comfy-route-edit-post", (e) => {
|
| 346 |
+
if (IS_WEB_MODE) return showToast(t('web.publish_not_supported'), "warning");
|
| 347 |
const { postData, currentUser } = e.detail;
|
| 348 |
const view = createPublishPostView(currentUser, postData);
|
| 349 |
showInlineView(view);
|
|
|
|
| 351 |
|
| 352 |
// 监听进入提示词编辑页面的请求
|
| 353 |
_onSidebarWindow("comfy-route-edit-prompt", (e) => {
|
| 354 |
+
if (IS_WEB_MODE) return showToast(t('web.publish_not_supported'), "warning");
|
| 355 |
const { promptData, currentUser } = e.detail;
|
| 356 |
const view = createPublishPromptView(currentUser, promptData);
|
| 357 |
showInlineView(view);
|
前端页面/core/侧边栏数据引擎.js
CHANGED
|
@@ -18,6 +18,9 @@
|
|
| 18 |
|
| 19 |
import { api } from "./网络请求API.js";
|
| 20 |
import { proxyImages } from "./网络请求API.js";
|
|
|
|
|
|
|
|
|
|
| 21 |
import { createItemCard } from "../market/列表卡片组件.js";
|
| 22 |
import { createCreatorCard } from "../market/创作者卡片组件.js";
|
| 23 |
import {
|
|
@@ -29,6 +32,7 @@ import {
|
|
| 29 |
} from "../components/性能优化工具.js";
|
| 30 |
import { applyViewportAnimations, getAnimationTypeForTab } from "../components/动画音效引擎.js";
|
| 31 |
import { t } from "../components/用户体验增强.js";
|
|
|
|
| 32 |
|
| 33 |
// 💬 讨论区组件(动态导入)
|
| 34 |
let postsViewModule = null;
|
|
@@ -354,15 +358,25 @@ export async function loadSidebarContent({
|
|
| 354 |
state.loader = null; // 清除引用,防止旧分页器状态残留
|
| 355 |
}
|
| 356 |
|
| 357 |
-
// ==========
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 358 |
const renderBatch = (dataArray, append = false) => {
|
| 359 |
if (renderToken !== getRenderToken()) return;
|
| 360 |
|
| 361 |
-
//
|
| 362 |
-
|
| 363 |
-
if (keyword && tab !== "creators") {
|
| 364 |
-
displayData = _filterBySearch(dataArray, keyword);
|
| 365 |
-
}
|
| 366 |
|
| 367 |
// 首次渲染清空容器
|
| 368 |
if (!append) {
|
|
@@ -449,11 +463,8 @@ export async function loadSidebarContent({
|
|
| 449 |
const start = (page - 1) * size;
|
| 450 |
const end = start + size;
|
| 451 |
|
| 452 |
-
// 从全量数据中截取(
|
| 453 |
-
|
| 454 |
-
if (keyword && tab !== "creators") {
|
| 455 |
-
dataSlice = _filterBySearch(state.allData, keyword);
|
| 456 |
-
}
|
| 457 |
|
| 458 |
const batch = dataSlice.slice(start, end);
|
| 459 |
|
|
@@ -465,25 +476,35 @@ export async function loadSidebarContent({
|
|
| 465 |
};
|
| 466 |
|
| 467 |
// ========== 尝试从缓存加载 ==========
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 468 |
// 获取完整缓存信息(包含过期状态)
|
| 469 |
const { value: cachedData, expired: isCacheExpired, found: hasCacheData } = getCacheWithMeta(cacheKey, true);
|
| 470 |
|
| 471 |
// 有缓存时直接渲染缓存(搜索时也使用缓存,renderBatch 会处理 keyword 过滤)
|
| 472 |
// 🔍 创作者Tab有keyword时不使用缓存,每次都从后端搜索
|
| 473 |
-
//
|
| 474 |
-
|
|
|
|
|
|
|
| 475 |
if (!force && hasCacheData && !skipCache) {
|
| 476 |
// 🚀 缓存数据也需要过一遍图片代理,确保新字段也被处理
|
| 477 |
const proxiedData = proxyImages(cachedData);
|
| 478 |
state.allData = proxiedData;
|
| 479 |
state.isFullyLoaded = true;
|
|
|
|
|
|
|
| 480 |
|
| 481 |
-
// 首屏渲染(仅第一页)
|
| 482 |
-
const
|
| 483 |
-
renderBatch(
|
| 484 |
|
| 485 |
// 如果有更多数据,启动分页加载器
|
| 486 |
-
if (
|
| 487 |
_setupPaginationLoader(contentArea, state, pageSize, loadMoreData, keyword, tab);
|
| 488 |
}
|
| 489 |
|
|
@@ -531,15 +552,22 @@ export async function loadSidebarContent({
|
|
| 531 |
|
| 532 |
// 完整重新渲染(替代之前只更新数字的方案)
|
| 533 |
contentArea.innerHTML = "";
|
| 534 |
-
|
| 535 |
-
|
|
|
|
|
|
|
| 536 |
|
| 537 |
-
if (
|
| 538 |
_setupPaginationLoader(contentArea, state, pageSize, loadMoreData, keyword, savedTab);
|
| 539 |
}
|
| 540 |
} else {
|
| 541 |
-
//
|
| 542 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 543 |
}
|
| 544 |
} catch (err) {
|
| 545 |
console.warn(`⚠️ ${savedTab}_${savedSort} 后台刷新失败:`, err);
|
|
@@ -565,12 +593,12 @@ export async function loadSidebarContent({
|
|
| 565 |
state.isFullyLoaded = true;
|
| 566 |
state.displayedCount = 0; // 重置已显示计数,因为数据重新排序了
|
| 567 |
|
| 568 |
-
// 首屏渲染
|
| 569 |
-
const
|
| 570 |
-
renderBatch(
|
| 571 |
|
| 572 |
// 启动分页加载器
|
| 573 |
-
if (
|
| 574 |
_setupPaginationLoader(contentArea, state, pageSize, loadMoreData, keyword, tab);
|
| 575 |
}
|
| 576 |
|
|
@@ -580,10 +608,45 @@ export async function loadSidebarContent({
|
|
| 580 |
return; // 本地排序后直接返回,不需要后台刷新(数据是同一批)
|
| 581 |
}
|
| 582 |
|
| 583 |
-
// ==========
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 587 |
|
| 588 |
// ========== 从网络加载数据 ==========
|
| 589 |
try {
|
|
@@ -597,6 +660,11 @@ export async function loadSidebarContent({
|
|
| 597 |
} else if (tab === "creators") {
|
| 598 |
// 🔍 创作者搜索:有 keyword 时调用后端搜索API,否则获取列表
|
| 599 |
if (keyword) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 600 |
response = await api.searchCreators(keyword, sort);
|
| 601 |
realData = response.data || [];
|
| 602 |
realData = proxyImages(realData); // 确保图片走本地缓存代理
|
|
@@ -622,37 +690,67 @@ export async function loadSidebarContent({
|
|
| 622 |
saveCreatorsToSessionStorage(realData);
|
| 623 |
}
|
| 624 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 625 |
// 更新状态
|
| 626 |
state.allData = realData;
|
| 627 |
-
state.isFullyLoaded = true;
|
| 628 |
|
| 629 |
-
// 渲染首屏
|
| 630 |
-
|
| 631 |
-
|
|
|
|
| 632 |
|
| 633 |
// 启动分页加载器
|
| 634 |
-
if (
|
| 635 |
_setupPaginationLoader(contentArea, state, pageSize, loadMoreData, keyword, tab);
|
| 636 |
}
|
| 637 |
|
| 638 |
} catch (error) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 639 |
console.error("数据加载失败:", error);
|
| 640 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 641 |
// 🚀 回退到任何可用缓存(包括过期的)
|
| 642 |
// 🔍 创作者Tab搜索场景跳过此分支:renderBatch 对创作者不做本地过滤,
|
| 643 |
// 直接渲染全量缓存会导致搜索结果未过滤,改由下方本地搜索降级分支处理
|
| 644 |
if (hasCacheData && cachedData && !(tab === "creators" && keyword)) {
|
| 645 |
console.warn(`📴 网络失败,降级显示${isCacheExpired ? '过期' : ''}缓存`);
|
| 646 |
state.allData = cachedData;
|
| 647 |
-
|
|
|
|
|
|
|
|
|
|
| 648 |
|
| 649 |
// 如果是过期缓存,显示提示
|
|
|
|
| 650 |
if (isCacheExpired) {
|
| 651 |
-
|
| 652 |
-
toastDiv.style.cssText = 'position:fixed; top:20px; left:50%; transform:translateX(-50%); background:#FF9800; color:white; padding:10px 20px; border-radius:4px; z-index:10000; font-size:14px;';
|
| 653 |
-
toastDiv.textContent = '⚠️ 网络连接失败,展示的是上次缓存的数据';
|
| 654 |
-
document.body.appendChild(toastDiv);
|
| 655 |
-
setTimeout(() => toastDiv.remove(), 3000);
|
| 656 |
}
|
| 657 |
return;
|
| 658 |
}
|
|
@@ -691,11 +789,8 @@ export async function loadSidebarContent({
|
|
| 691 |
</div>
|
| 692 |
`;
|
| 693 |
|
| 694 |
-
|
| 695 |
-
|
| 696 |
-
toastDiv.textContent = '⚠️ 网络不可用,显示本地搜索结果';
|
| 697 |
-
document.body.appendChild(toastDiv);
|
| 698 |
-
setTimeout(() => toastDiv.remove(), 3000);
|
| 699 |
return;
|
| 700 |
}
|
| 701 |
}
|
|
@@ -703,26 +798,24 @@ export async function loadSidebarContent({
|
|
| 703 |
// 按当前排序排序
|
| 704 |
displayData = sortDataLocally(displayData, tab, sort);
|
| 705 |
state.allData = displayData;
|
| 706 |
-
|
|
|
|
|
|
|
|
|
|
| 707 |
|
| 708 |
// 启动分页加载器
|
| 709 |
-
if (
|
| 710 |
_setupPaginationLoader(contentArea, state, pageSize, loadMoreData, keyword, tab);
|
| 711 |
}
|
| 712 |
|
| 713 |
-
|
| 714 |
-
|
| 715 |
-
toastDiv.textContent = keyword
|
| 716 |
-
? '⚠️ 网络不可用,显示本地搜索结果'
|
| 717 |
-
: '⚠️ 网络连接失败,展示的是本地缓存的数据';
|
| 718 |
-
document.body.appendChild(toastDiv);
|
| 719 |
-
setTimeout(() => toastDiv.remove(), 3000);
|
| 720 |
return;
|
| 721 |
} else if (keyword) {
|
| 722 |
contentArea.innerHTML = '';
|
| 723 |
const emptyDiv = document.createElement('div');
|
| 724 |
emptyDiv.style.cssText = 'text-align:center; padding: 40px 20px; color:#888;';
|
| 725 |
-
emptyDiv.textContent = `🔌 ${t('common.network_error_retry')
|
| 726 |
contentArea.appendChild(emptyDiv);
|
| 727 |
return;
|
| 728 |
}
|
|
@@ -767,24 +860,24 @@ function _setupPaginationLoader(contentArea, state, pageSize, loadMoreData, keyw
|
|
| 767 |
// 添加手动加载按钮作为 fallback
|
| 768 |
let nextPage = 2;
|
| 769 |
const loadMoreBtn = document.createElement('button');
|
| 770 |
-
loadMoreBtn.textContent = t('task.load_more')
|
| 771 |
loadMoreBtn.style.cssText = 'width:100%;padding:10px;margin-top:10px;cursor:pointer;border:1px solid #555;border-radius:6px;background:transparent;color:inherit;';
|
| 772 |
loadMoreBtn.onclick = async () => {
|
| 773 |
loadMoreBtn.disabled = true;
|
| 774 |
-
loadMoreBtn.textContent = t('common.loading')
|
| 775 |
try {
|
| 776 |
const result = await loadMoreData(nextPage, pageSize);
|
| 777 |
if (result && result.length > 0) {
|
| 778 |
nextPage++;
|
| 779 |
-
loadMoreBtn.textContent = t('task.load_more')
|
| 780 |
} else {
|
| 781 |
-
loadMoreBtn.textContent = t('common.no_more')
|
| 782 |
loadMoreBtn.style.cursor = 'default';
|
| 783 |
loadMoreBtn.onclick = null;
|
| 784 |
}
|
| 785 |
} catch (err) {
|
| 786 |
console.error("手动加载更多失败:", err);
|
| 787 |
-
loadMoreBtn.textContent = t('task.load_failed')
|
| 788 |
}
|
| 789 |
loadMoreBtn.disabled = false;
|
| 790 |
};
|
|
@@ -793,6 +886,8 @@ function _setupPaginationLoader(contentArea, state, pageSize, loadMoreData, keyw
|
|
| 793 |
}
|
| 794 |
|
| 795 |
// 计算数据总量(考虑搜索过滤,创作者Tab有keyword时不进行本地过滤,因为后端已过滤)
|
|
|
|
|
|
|
| 796 |
const getTotalDataCount = () => {
|
| 797 |
if (!keyword) return state.allData.length;
|
| 798 |
// 创作者Tab后端搜索已过滤,直接返回全部数据
|
|
@@ -853,7 +948,8 @@ function _shouldUpdateData(oldData, newData) {
|
|
| 853 |
if (oldData.length !== newData.length) return true;
|
| 854 |
|
| 855 |
// 检查ID顺序是否一致(检测排序变化、新增/删除)
|
| 856 |
-
|
|
|
|
| 857 |
for (let i = 0; i < checkCount; i++) {
|
| 858 |
const oldId = oldData[i].id || oldData[i].account;
|
| 859 |
const newId = newData[i].id || newData[i].account;
|
|
@@ -864,6 +960,10 @@ function _shouldUpdateData(oldData, newData) {
|
|
| 864 |
for (const field of keyFields) {
|
| 865 |
if ((oldData[i][field] || 0) !== (newData[i][field] || 0)) return true;
|
| 866 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 867 |
}
|
| 868 |
return false;
|
| 869 |
}
|
|
|
|
| 18 |
|
| 19 |
import { api } from "./网络请求API.js";
|
| 20 |
import { proxyImages } from "./网络请求API.js";
|
| 21 |
+
// 🔍 搜索时撤销上一次在途请求所需的取消管理器与分组 ID
|
| 22 |
+
import { requestCancelManager } from "./网络请求API.js";
|
| 23 |
+
import { API } from "./全局配置.js";
|
| 24 |
import { createItemCard } from "../market/列表卡片组件.js";
|
| 25 |
import { createCreatorCard } from "../market/创作者卡片组件.js";
|
| 26 |
import {
|
|
|
|
| 32 |
} from "../components/性能优化工具.js";
|
| 33 |
import { applyViewportAnimations, getAnimationTypeForTab } from "../components/动画音效引擎.js";
|
| 34 |
import { t } from "../components/用户体验增强.js";
|
| 35 |
+
import { showToast } from "../components/UI交互提示组件.js"; // 🔧 降级提示改走统一组件(原为手写 div)
|
| 36 |
|
| 37 |
// 💬 讨论区组件(动态导入)
|
| 38 |
let postsViewModule = null;
|
|
|
|
| 358 |
state.loader = null; // 清除引用,防止旧分页器状态残留
|
| 359 |
}
|
| 360 |
|
| 361 |
+
// ========== 🔍 搜索过滤统一入口 ==========
|
| 362 |
+
// 🔧 修复:过滤职责原先分裂在三处且口径不一致 —— loadMoreData 与分页器的
|
| 363 |
+
// getTotalDataCount 都是「先过滤再切片」(正确),而各首屏分支是「先切 20 条再过滤」,
|
| 364 |
+
// renderBatch 内部又对切好的批次再过滤一次。后果不只是首屏条数偏少:首屏与分页
|
| 365 |
+
// 处于不同的数据空间(首屏在「原始前 20 条」里找匹配,分页在「过滤后全集」里按 20
|
| 366 |
+
// 步长切片),排在第 20 条之后的匹配项会永久丢失,甚至首屏直接显示「没有搜索到相关内容」。
|
| 367 |
+
// 现统一收口到本函数:所有取数路径都先过滤、再切片,renderBatch 只负责渲染。
|
| 368 |
+
// 创作者Tab的 keyword 由后端搜索接口过滤,此处不做本地过滤(与原口径一致)。
|
| 369 |
+
const _applySearchScope = (dataArray) => {
|
| 370 |
+
if (!keyword || tab === "creators") return dataArray;
|
| 371 |
+
return _filterBySearch(dataArray, keyword);
|
| 372 |
+
};
|
| 373 |
+
|
| 374 |
+
// ========== 渲染函数:渲染一批数据(只渲染,过滤由 _applySearchScope 统一负责)==========
|
| 375 |
const renderBatch = (dataArray, append = false) => {
|
| 376 |
if (renderToken !== getRenderToken()) return;
|
| 377 |
|
| 378 |
+
// 🔧 数据已由调用方经 _applySearchScope 过滤,本函数只负责渲染(不再二次过滤)
|
| 379 |
+
const displayData = dataArray;
|
|
|
|
|
|
|
|
|
|
| 380 |
|
| 381 |
// 首次渲染清空容器
|
| 382 |
if (!append) {
|
|
|
|
| 463 |
const start = (page - 1) * size;
|
| 464 |
const end = start + size;
|
| 465 |
|
| 466 |
+
// 从全量数据中截取(统一走 _applySearchScope,与首屏处于同一数据空间)
|
| 467 |
+
const dataSlice = _applySearchScope(state.allData);
|
|
|
|
|
|
|
|
|
|
| 468 |
|
| 469 |
const batch = dataSlice.slice(start, end);
|
| 470 |
|
|
|
|
| 476 |
};
|
| 477 |
|
| 478 |
// ========== 尝试从缓存加载 ==========
|
| 479 |
+
// 📌 本处的缓存口径与 任务榜/讨论区/提示词(统一走 性能优化工具.js 的
|
| 480 |
+
// readListCache,命中后一律调 silentRefresh 后台补新)**刻意不同**,不是历史遗留,勿顺手归一:
|
| 481 |
+
// - 这里缓存的是全量数据(items 200 条 / creators 500 条),分页完全在本地做,
|
| 482 |
+
// 所以缓存命中即等于「数据完整」,只在 expired 时才值得再发一次网络请求;
|
| 483 |
+
// - 那三个列表只缓存第一页,命中后数据必然不完整,故一律后台 silentRefresh 补新。
|
| 484 |
+
// 若强行统一成「总是后台刷新」,插件榜会在每次 Tab 切换时多发一次 200 条的全量请求。
|
| 485 |
// 获取完整缓存信息(包含过期状态)
|
| 486 |
const { value: cachedData, expired: isCacheExpired, found: hasCacheData } = getCacheWithMeta(cacheKey, true);
|
| 487 |
|
| 488 |
// 有缓存时直接渲染缓存(搜索时也使用缓存,renderBatch 会处理 keyword 过滤)
|
| 489 |
// 🔍 创作者Tab有keyword时不使用缓存,每次都从后端搜索
|
| 490 |
+
// 🔧 修复:原实现还把「刚清空搜索框」也列为跳过缓存的理由,但 cacheKey 不含 keyword、
|
| 491 |
+
// 搜索结果也从不写入该键(见下方 isCreatorSearch 判断),缓存里存的始终是干净的全量列表,
|
| 492 |
+
// 清空搜索框后完全可以直接用缓存上屏;强制联网只会让用户在弱网下白等一整个重试预算
|
| 493 |
+
const skipCache = tab === "creators" && !!keyword;
|
| 494 |
if (!force && hasCacheData && !skipCache) {
|
| 495 |
// 🚀 缓存数据也需要过一遍图片代理,确保新字段也被处理
|
| 496 |
const proxiedData = proxyImages(cachedData);
|
| 497 |
state.allData = proxiedData;
|
| 498 |
state.isFullyLoaded = true;
|
| 499 |
+
// 缓存是全量列表而非搜索结果,清除搜索标记,避免下次加载仍被当作搜索结果处理
|
| 500 |
+
if (tab === "creators" && !keyword) state.isSearchResult = false;
|
| 501 |
|
| 502 |
+
// 首屏渲染(仅第一页)—— 先过滤再切片,与 loadMoreData 同一数据空间
|
| 503 |
+
const scopedData = _applySearchScope(proxiedData);
|
| 504 |
+
renderBatch(scopedData.slice(0, pageSize), false);
|
| 505 |
|
| 506 |
// 如果有更多数据,启动分页加载器
|
| 507 |
+
if (scopedData.length > pageSize) {
|
| 508 |
_setupPaginationLoader(contentArea, state, pageSize, loadMoreData, keyword, tab);
|
| 509 |
}
|
| 510 |
|
|
|
|
| 552 |
|
| 553 |
// 完整重新渲染(替代之前只更新数字的方案)
|
| 554 |
contentArea.innerHTML = "";
|
| 555 |
+
// 🔧 先过滤再切片:搜索场景下静默刷新同样会触发,
|
| 556 |
+
// 原实现在 newData 前 20 条里过滤,会漏掉靠后的匹配项
|
| 557 |
+
const scopedNewData = _applySearchScope(newData);
|
| 558 |
+
renderBatch(scopedNewData.slice(0, pageSize), false);
|
| 559 |
|
| 560 |
+
if (scopedNewData.length > pageSize) {
|
| 561 |
_setupPaginationLoader(contentArea, state, pageSize, loadMoreData, keyword, savedTab);
|
| 562 |
}
|
| 563 |
} else {
|
| 564 |
+
// 关键字段无变化 → 无需重渲染。但 newData 才是刚从云端取回的权威数据,
|
| 565 |
+
// 缓存必须回写 newData:若仍回写旧的 state.allData,等于把旧数据固化并
|
| 566 |
+
// 续期(续期后 isCacheExpired 转 false,下次连静默刷新都不再触发),
|
| 567 |
+
// 导致缓存中的旧版本号被长期锁死、卡片徽章无法出现。
|
| 568 |
+
// 注意此处刻意不更新 state.allData:首屏已按旧数据渲染,保持二者一致可
|
| 569 |
+
// 避免分页加载与已渲染卡片错位;新数据在下次进入读取缓存时生效
|
| 570 |
+
setCache(cacheKey, newData, getCacheExpireTime(), true);
|
| 571 |
}
|
| 572 |
} catch (err) {
|
| 573 |
console.warn(`⚠️ ${savedTab}_${savedSort} 后台刷新失败:`, err);
|
|
|
|
| 593 |
state.isFullyLoaded = true;
|
| 594 |
state.displayedCount = 0; // 重置已显示计数,因为数据重新排序了
|
| 595 |
|
| 596 |
+
// 首屏渲染(先过滤再切片)
|
| 597 |
+
const scopedSorted = _applySearchScope(locallySorted);
|
| 598 |
+
renderBatch(scopedSorted.slice(0, pageSize), false);
|
| 599 |
|
| 600 |
// 启动分页加载器
|
| 601 |
+
if (scopedSorted.length > pageSize) {
|
| 602 |
_setupPaginationLoader(contentArea, state, pageSize, loadMoreData, keyword, tab);
|
| 603 |
}
|
| 604 |
|
|
|
|
| 608 |
return; // 本地排序后直接返回,不需要后台刷新(数据是同一批)
|
| 609 |
}
|
| 610 |
|
| 611 |
+
// ========== 🚀 缓存先行:阻塞前先用本地已有数据上屏 ==========
|
| 612 |
+
// 走到这里说明上面两条快路径都没命中:要么是 force 强制刷新(刻意跳过缓存),
|
| 613 |
+
// 要么是本次会话还没有该 tab 的完整数据。弱网下一次列表请求最长要烧满整个重试预算,
|
| 614 |
+
// 期间用户只能盯骨架屏;此刻若本地已有可用数据,就先渲染首屏,网络请求照常往下走,
|
| 615 |
+
// 回来后按 renderToken + _shouldUpdateData 决定是否重渲染(与上方静默刷新同口径)。
|
| 616 |
+
// 🔍 创作者搜索场景不参与:renderBatch 对创作者不做本地过滤,先上屏全量列表会混入非搜索结果
|
| 617 |
+
let renderedInstant = false;
|
| 618 |
+
if (!isCreatorSearching) {
|
| 619 |
+
// 可立即上屏的数据:当前排序的列表缓存(force 时被跳过)→ 创作者 sessionStorage 降级副本
|
| 620 |
+
let instantData = (hasCacheData && Array.isArray(cachedData) && cachedData.length > 0) ? cachedData : null;
|
| 621 |
+
if (!instantData && tab === "creators") {
|
| 622 |
+
const sessionData = getCreatorsFromSessionStorage();
|
| 623 |
+
if (Array.isArray(sessionData) && sessionData.length > 0) instantData = sessionData;
|
| 624 |
+
}
|
| 625 |
+
if (instantData) {
|
| 626 |
+
console.log(`🚀 ${tab}_${sort} 缓存先行:先用本地数据渲染首屏,网络请求转后台`);
|
| 627 |
+
// 🚀 与上方缓存命中分支同口径:本地数据也过一遍图片代理(proxyImages 内部会先剥除
|
| 628 |
+
// 已有代理前缀,幂等),确保代理规则新增的字段同样被处理
|
| 629 |
+
// sessionStorage 副本的顺序不保证与当前排序一致,统一本地排一次(对已排序数据幂等)
|
| 630 |
+
const sortedData = sortDataLocally(proxyImages(instantData), tab, sort);
|
| 631 |
+
state.allData = sortedData;
|
| 632 |
+
state.isFullyLoaded = true;
|
| 633 |
+
state.isSearchResult = false;
|
| 634 |
+
// 🔧 先过滤再切片(tools/apps/recommends 带 keyword 时同样会进入本分支)
|
| 635 |
+
const scopedInstant = _applySearchScope(sortedData);
|
| 636 |
+
renderBatch(scopedInstant.slice(0, pageSize), false);
|
| 637 |
+
if (scopedInstant.length > pageSize) {
|
| 638 |
+
_setupPaginationLoader(contentArea, state, pageSize, loadMoreData, keyword, tab);
|
| 639 |
+
}
|
| 640 |
+
renderedInstant = true;
|
| 641 |
+
}
|
| 642 |
+
}
|
| 643 |
+
|
| 644 |
+
// ========== 显示加载骨架屏(已有本地数据上屏时跳过,避免闪屏)==========
|
| 645 |
+
if (!renderedInstant) {
|
| 646 |
+
contentArea.innerHTML = "";
|
| 647 |
+
const skeleton = createSkeleton(tab === "creators" ? "list" : "card", 3);
|
| 648 |
+
contentArea.appendChild(skeleton);
|
| 649 |
+
}
|
| 650 |
|
| 651 |
// ========== 从网络加载数据 ==========
|
| 652 |
try {
|
|
|
|
| 660 |
} else if (tab === "creators") {
|
| 661 |
// 🔍 创作者搜索:有 keyword 时调用后端搜索API,否则获取列表
|
| 662 |
if (keyword) {
|
| 663 |
+
// 🔍 先撤销上一次仍在途的搜索请求:用户继续输入后,上一次的关键词已经作废,
|
| 664 |
+
// 但它的请求还会继续占用并发额度(全局最多 6 个)与最长 10 秒的超时预算,
|
| 665 |
+
// 把最新这次搜索挤到后面排队,弱网下表现为「一直在转圈」。
|
| 666 |
+
// 只撤销搜索分组,不影响列表、详情、点赞等任何其它请求
|
| 667 |
+
requestCancelManager.cancelAll(API.SEARCH_COMPONENT_ID);
|
| 668 |
response = await api.searchCreators(keyword, sort);
|
| 669 |
realData = response.data || [];
|
| 670 |
realData = proxyImages(realData); // 确保图片走本地缓存代理
|
|
|
|
| 690 |
saveCreatorsToSessionStorage(realData);
|
| 691 |
}
|
| 692 |
|
| 693 |
+
state.isFullyLoaded = true;
|
| 694 |
+
|
| 695 |
+
// 🚀 缓存先行场景:网络数据与已上屏的本地数据无实质差异时不重渲染,避免清屏重画
|
| 696 |
+
// 打断用户已经滚动的阅读位置(与上方后台静默刷新同口径)。缓存已在上面回写权威新数据,
|
| 697 |
+
// 此处刻意不更新 state.allData,保持与已渲染卡片一致,防止分页加载错位
|
| 698 |
+
if (renderedInstant && !_shouldUpdateData(state.allData, realData)) {
|
| 699 |
+
return;
|
| 700 |
+
}
|
| 701 |
+
|
| 702 |
// 更新状态
|
| 703 |
state.allData = realData;
|
|
|
|
| 704 |
|
| 705 |
+
// 渲染首屏(先过滤再切片:tools/apps/recommends 的 keyword 是本地搜索,
|
| 706 |
+
// realData 是未过滤的全量数据,原实现在前 20 条里过滤会漏掉靠后的匹配项)
|
| 707 |
+
const scopedReal = _applySearchScope(realData);
|
| 708 |
+
renderBatch(scopedReal.slice(0, pageSize), false);
|
| 709 |
|
| 710 |
// 启动分页加载器
|
| 711 |
+
if (scopedReal.length > pageSize) {
|
| 712 |
_setupPaginationLoader(contentArea, state, pageSize, loadMoreData, keyword, tab);
|
| 713 |
}
|
| 714 |
|
| 715 |
} catch (error) {
|
| 716 |
+
// 🔍 防竞态:本次加载已被更新的加载取代(切Tab、换排序、继续输入关键词)时直接退出。
|
| 717 |
+
// renderBatch 内部本来就有这道守卫,但它之外的降级分支会直接写 contentArea.innerHTML
|
| 718 |
+
// 并弹提示,缺少守卫会让过期结果覆盖掉新内容
|
| 719 |
+
if (renderToken !== getRenderToken()) {
|
| 720 |
+
console.warn(`⏹️ ${tab}_${sort} 请求已过期,忽略本次失败`);
|
| 721 |
+
return;
|
| 722 |
+
}
|
| 723 |
+
// 🔍 主动撤销(上方 cancelAll)不是网络故障:用户只是又敲了一个字,
|
| 724 |
+
// 新一次加载已经在跑了。这里必须静默退出,否则会误弹「网络不可用」提示
|
| 725 |
+
if (error && error.name === 'RequestCancelledError') {
|
| 726 |
+
console.log(`⏹️ ${tab}_${sort} 搜索请求已被新关键词取代`);
|
| 727 |
+
return;
|
| 728 |
+
}
|
| 729 |
console.error("数据加载失败:", error);
|
| 730 |
|
| 731 |
+
// 🚀 缓存先行场景:首屏已经用本地数据上屏,网络失败时保持当前画面即可,不再走下面的
|
| 732 |
+
// 降级分支(否则会把同一批数据重渲染一遍、重播卡片动画)。与上方后台静默刷新失败一致:
|
| 733 |
+
// 只记录日志,不额外打扰用户
|
| 734 |
+
if (renderedInstant) {
|
| 735 |
+
console.warn(`📴 ${tab}_${sort} 网络刷新失败,保持已渲染的本地数据`);
|
| 736 |
+
return;
|
| 737 |
+
}
|
| 738 |
+
|
| 739 |
// 🚀 回退到任何可用缓存(包括过期的)
|
| 740 |
// 🔍 创作者Tab搜索场景跳过此分支:renderBatch 对创作者不做本地过滤,
|
| 741 |
// 直接渲染全量缓存会导致搜索结果未过滤,改由下方本地搜索降级分支处理
|
| 742 |
if (hasCacheData && cachedData && !(tab === "creators" && keyword)) {
|
| 743 |
console.warn(`📴 网络失败,降级显示${isCacheExpired ? '过期' : ''}缓存`);
|
| 744 |
state.allData = cachedData;
|
| 745 |
+
// 🔧 先过滤再切片:本分支已排除 creators+keyword,但 tools/apps/recommends
|
| 746 |
+
// 带 keyword 时会进来;renderBatch 不再自行过滤,此处必须显式过滤
|
| 747 |
+
const scopedCached = _applySearchScope(cachedData);
|
| 748 |
+
renderBatch(scopedCached.slice(0, pageSize), false);
|
| 749 |
|
| 750 |
// 如果是过期缓存,显示提示
|
| 751 |
+
// 🔧 改用统一提示组件 + 词典(原为手写 div、无动画不排队,且文案硬编码中文)
|
| 752 |
if (isCacheExpired) {
|
| 753 |
+
showToast(t('feedback.cache_fallback'), 'warning');
|
|
|
|
|
|
|
|
|
|
|
|
|
| 754 |
}
|
| 755 |
return;
|
| 756 |
}
|
|
|
|
| 789 |
</div>
|
| 790 |
`;
|
| 791 |
|
| 792 |
+
// 🔧 改用统一提示组件 + 词典(原为手写 div 且文案硬编码中文)
|
| 793 |
+
showToast(t('feedback.search_local_result'), 'warning');
|
|
|
|
|
|
|
|
|
|
| 794 |
return;
|
| 795 |
}
|
| 796 |
}
|
|
|
|
| 798 |
// 按当前排序排序
|
| 799 |
displayData = sortDataLocally(displayData, tab, sort);
|
| 800 |
state.allData = displayData;
|
| 801 |
+
// 统一走 _applySearchScope(创作者Tab恒等返回,数据已由上方
|
| 802 |
+
// searchCreatorsLocally 过滤),保证各渲染入口口径完全一致
|
| 803 |
+
const scopedFallback = _applySearchScope(displayData);
|
| 804 |
+
renderBatch(scopedFallback.slice(0, pageSize), false);
|
| 805 |
|
| 806 |
// 启动分页加载器
|
| 807 |
+
if (scopedFallback.length > pageSize) {
|
| 808 |
_setupPaginationLoader(contentArea, state, pageSize, loadMoreData, keyword, tab);
|
| 809 |
}
|
| 810 |
|
| 811 |
+
// 🔧 改用统一提示组件 + 词典(原为手写 div 且两条文案均硬编码中文)
|
| 812 |
+
showToast(keyword ? t('feedback.search_local_result') : t('feedback.cache_fallback'), 'warning');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 813 |
return;
|
| 814 |
} else if (keyword) {
|
| 815 |
contentArea.innerHTML = '';
|
| 816 |
const emptyDiv = document.createElement('div');
|
| 817 |
emptyDiv.style.cssText = 'text-align:center; padding: 40px 20px; color:#888;';
|
| 818 |
+
emptyDiv.textContent = `🔌 ${t('common.network_error_retry')}`;
|
| 819 |
contentArea.appendChild(emptyDiv);
|
| 820 |
return;
|
| 821 |
}
|
|
|
|
| 860 |
// 添加手动加载按钮作为 fallback
|
| 861 |
let nextPage = 2;
|
| 862 |
const loadMoreBtn = document.createElement('button');
|
| 863 |
+
loadMoreBtn.textContent = t('task.load_more');
|
| 864 |
loadMoreBtn.style.cssText = 'width:100%;padding:10px;margin-top:10px;cursor:pointer;border:1px solid #555;border-radius:6px;background:transparent;color:inherit;';
|
| 865 |
loadMoreBtn.onclick = async () => {
|
| 866 |
loadMoreBtn.disabled = true;
|
| 867 |
+
loadMoreBtn.textContent = t('common.loading');
|
| 868 |
try {
|
| 869 |
const result = await loadMoreData(nextPage, pageSize);
|
| 870 |
if (result && result.length > 0) {
|
| 871 |
nextPage++;
|
| 872 |
+
loadMoreBtn.textContent = t('task.load_more');
|
| 873 |
} else {
|
| 874 |
+
loadMoreBtn.textContent = t('common.no_more');
|
| 875 |
loadMoreBtn.style.cursor = 'default';
|
| 876 |
loadMoreBtn.onclick = null;
|
| 877 |
}
|
| 878 |
} catch (err) {
|
| 879 |
console.error("手动加载更多失败:", err);
|
| 880 |
+
loadMoreBtn.textContent = t('task.load_failed');
|
| 881 |
}
|
| 882 |
loadMoreBtn.disabled = false;
|
| 883 |
};
|
|
|
|
| 886 |
}
|
| 887 |
|
| 888 |
// 计算数据总量(考虑搜索过滤,创作者Tab有keyword时不进行本地过滤,因为后端已过滤)
|
| 889 |
+
// 📌 口径必须与 loadSidebarContent 内的 _applySearchScope 一致(先过滤再计数);
|
| 890 |
+
// 本函数是模块级、拿不到那个闭包,故保留等价实现,改动其一务必同步另一处
|
| 891 |
const getTotalDataCount = () => {
|
| 892 |
if (!keyword) return state.allData.length;
|
| 893 |
// 创作者Tab后端搜索已过滤,直接返回全部数据
|
|
|
|
| 948 |
if (oldData.length !== newData.length) return true;
|
| 949 |
|
| 950 |
// 检查ID顺序是否一致(检测排序变化、新增/删除)
|
| 951 |
+
// 比对条数覆盖首屏渲染量(PAGE_SIZE),避免首屏可见卡片(第 11~20 条)的版本/数字更新被漏检
|
| 952 |
+
const checkCount = Math.min(PAGE_SIZE, oldData.length);
|
| 953 |
for (let i = 0; i < checkCount; i++) {
|
| 954 |
const oldId = oldData[i].id || oldData[i].account;
|
| 955 |
const newId = newData[i].id || newData[i].account;
|
|
|
|
| 960 |
for (const field of keyFields) {
|
| 961 |
if ((oldData[i][field] || 0) !== (newData[i][field] || 0)) return true;
|
| 962 |
}
|
| 963 |
+
|
| 964 |
+
// 检查版本号(字符串):云端扫描到插件新版本时往往只有 latest_version 变化,
|
| 965 |
+
// 点赞/下载/浏览等数字字段全不动;若不比对它,卡片上的"可更新"徽章将永远刷不出来
|
| 966 |
+
if ((oldData[i].latest_version || '') !== (newData[i].latest_version || '')) return true;
|
| 967 |
}
|
| 968 |
return false;
|
| 969 |
}
|
前端页面/core/全局配置.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
| 18 |
// 🌐 API 配置
|
| 19 |
// ==========================================
|
| 20 |
|
| 21 |
-
/** @type {{ BASE_URL: string, TIMEOUT: number, MAX_RETRIES: number, RETRY_DELAY: number }} */
|
| 22 |
export const API = {
|
| 23 |
// 云端 API 基础地址
|
| 24 |
BASE_URL: "https://zhiwei666-comfyui-ranking-api.hf.space",
|
|
@@ -26,11 +26,29 @@ export const API = {
|
|
| 26 |
// 请求超时时间(毫秒)
|
| 27 |
TIMEOUT: 30000,
|
| 28 |
|
| 29 |
-
// 重试次数
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
};
|
| 35 |
|
| 36 |
|
|
@@ -425,6 +443,28 @@ const PROFILE_CACHE_PREFIX = "ComfyCommunity_ProfileCache_";
|
|
| 425 |
const _pendingProfileRequests = new Map(); // 防止 N+1 并发请求
|
| 426 |
const PENDING_TIMEOUT = 30000; // 挂起请求超时清理时间(毫秒)
|
| 427 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 428 |
/** 统一字段映射:将后端 avatarDataUrl 映射为前端 avatar */
|
| 429 |
function _mapAvatarField(profile) {
|
| 430 |
if (profile && profile.avatarDataUrl && !profile.avatar) {
|
|
@@ -540,12 +580,14 @@ export function renderAvatarWithSWR(options) {
|
|
| 540 |
}
|
| 541 |
|
| 542 |
// 生成初始 HTML(无头像时显示首字母背景)
|
|
|
|
|
|
|
| 543 |
const initial = (name || account || 'U')[0].toUpperCase();
|
| 544 |
const avatarHtml = avatar
|
| 545 |
-
? `<img class="swr-avatar" src="${avatar}" style="width: ${avatarSize}px; height: ${avatarSize}px; border-radius: 50%; object-fit: cover; flex-shrink: 0; background: #333;">`
|
| 546 |
-
: `<div class="swr-avatar" style="width: ${avatarSize}px; height: ${avatarSize}px; border-radius: 50%; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); display: flex; align-items: center; justify-content: center; color: white; font-size: ${Math.floor(avatarSize * 0.5)}px; font-weight: bold; flex-shrink: 0;">${initial}</div>`;
|
| 547 |
|
| 548 |
-
return `<span id="${containerId}" style="display: inline-flex; align-items: center; gap: 6px;">${avatarHtml}<span class="swr-name">${name}</span></span>`;
|
| 549 |
}
|
| 550 |
|
| 551 |
|
|
|
|
| 18 |
// 🌐 API 配置
|
| 19 |
// ==========================================
|
| 20 |
|
| 21 |
+
/** @type {{ BASE_URL: string, TIMEOUT: number, MAX_RETRIES: number, RETRY_DELAY: number, LIST_RETRIES: number, SEARCH_TIMEOUT: number, SEARCH_RETRIES: number, SEARCH_COMPONENT_ID: string }} */
|
| 22 |
export const API = {
|
| 23 |
// 云端 API 基础地址
|
| 24 |
BASE_URL: "https://zhiwei666-comfyui-ranking-api.hf.space",
|
|
|
|
| 26 |
// 请求超时时间(毫秒)
|
| 27 |
TIMEOUT: 30000,
|
| 28 |
|
| 29 |
+
// GET 请求重试次数(实际尝试 MAX_RETRIES + 1 次)
|
| 30 |
+
// 🔧 修复:此前本值为 3 且全库无人读取,真正的重试次数硬编码在 网络请求_基础设施.js 中为 2,
|
| 31 |
+
// 现改为由基础设施读取本配置;取值 2 以保持既有的「共 3 次尝试」行为不变
|
| 32 |
+
MAX_RETRIES: 2,
|
| 33 |
+
|
| 34 |
+
// 重试间隔(毫秒,指数退避基数:1s、2s、4s…)
|
| 35 |
+
RETRY_DELAY: 1000,
|
| 36 |
+
|
| 37 |
+
// 🐢 弱网预算分级(仅作用于 GET,由 网络请求_基础设施.js 按 endpoint 路径精确匹配)
|
| 38 |
+
// 默认预算下弱网一次列表请求最长要烧 3×30s + 1s + 2s = 93s,期间用户只能盯骨架屏。
|
| 39 |
+
// 列表类:首屏已可由本地缓存先行上屏(见 侧边栏数据引擎.js),且列表往往是会话的第一个请求、
|
| 40 |
+
// 可能撞上 HF Space 冷启动(本项目无预热机制),故只砍掉一次冗余重试、保留 30s 单次超时,
|
| 41 |
+
// 93s → 61s,冷启动仍有机会在前两次尝试内完成
|
| 42 |
+
LIST_RETRIES: 1,
|
| 43 |
+
// 搜索类:必然发生在列表已加载之后(空间已被唤醒),可放心收紧,93s → 10s×2 + 1s = 21s
|
| 44 |
+
SEARCH_TIMEOUT: 10000,
|
| 45 |
+
SEARCH_RETRIES: 1,
|
| 46 |
+
|
| 47 |
+
// 🔍 创作者搜索请求的取消分组 ID
|
| 48 |
+
// 用户在搜索框继续输入时,侧边栏数据引擎.js 据此撤销上一次仍在途的搜索请求。
|
| 49 |
+
// 不撤销的话,作废请求会持续占用并发额度(最多 6 个)与超时预算,
|
| 50 |
+
// 把最新那次搜索挤到后面排队。仅搜索请求归入本分组,不影响其它任何请求
|
| 51 |
+
SEARCH_COMPONENT_ID: "sidebar-search"
|
| 52 |
};
|
| 53 |
|
| 54 |
|
|
|
|
| 443 |
const _pendingProfileRequests = new Map(); // 防止 N+1 并发请求
|
| 444 |
const PENDING_TIMEOUT = 30000; // 挂起请求超时清理时间(毫秒)
|
| 445 |
|
| 446 |
+
// ==========================================
|
| 447 |
+
// 🔒 HTML 转义(底层公共工具)
|
| 448 |
+
// ==========================================
|
| 449 |
+
// 放在本文件(零依赖的底层模块)而非 components/互动工具函数.js,是因为
|
| 450 |
+
// UI交互提示组件.js 与 打赏等级工具.js 都需要它,而互动工具函数.js 反向依赖这两者
|
| 451 |
+
// (导入 showToast、re-export renderTipBoardHTML),从那里导入会形成循环依赖。
|
| 452 |
+
// 互动工具函数.js 仍以 re-export 方式对外暴露同名函数,既有调用方无需改动。
|
| 453 |
+
|
| 454 |
+
/**
|
| 455 |
+
* HTML转义(统一版:各组件局部副本已归一到此)
|
| 456 |
+
* @param {*} str - 原始字符串(非字符串会先强制转换,兼容数字等入参)
|
| 457 |
+
* @returns {string} 转义后的字符串
|
| 458 |
+
*/
|
| 459 |
+
export function escapeHtml(str) {
|
| 460 |
+
if (str === null || str === undefined || str === "") return "";
|
| 461 |
+
return String(str).replace(/&/g, "&")
|
| 462 |
+
.replace(/</g, "<")
|
| 463 |
+
.replace(/>/g, ">")
|
| 464 |
+
.replace(/"/g, """)
|
| 465 |
+
.replace(/'/g, "'");
|
| 466 |
+
}
|
| 467 |
+
|
| 468 |
/** 统一字段映射:将后端 avatarDataUrl 映射为前端 avatar */
|
| 469 |
function _mapAvatarField(profile) {
|
| 470 |
if (profile && profile.avatarDataUrl && !profile.avatar) {
|
|
|
|
| 580 |
}
|
| 581 |
|
| 582 |
// 生成初始 HTML(无头像时显示首字母背景)
|
| 583 |
+
// 🔒 XSS防护:avatar/name/initial 均来自用户可修改的资料(昵称首字母可能是 < ),
|
| 584 |
+
// 写入 HTML 前必须转义;上方 SWR 刷新路径用的是 .src / .textContent,本身就是安全的。
|
| 585 |
const initial = (name || account || 'U')[0].toUpperCase();
|
| 586 |
const avatarHtml = avatar
|
| 587 |
+
? `<img class="swr-avatar" src="${escapeHtml(avatar)}" style="width: ${avatarSize}px; height: ${avatarSize}px; border-radius: 50%; object-fit: cover; flex-shrink: 0; background: #333;">`
|
| 588 |
+
: `<div class="swr-avatar" style="width: ${avatarSize}px; height: ${avatarSize}px; border-radius: 50%; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); display: flex; align-items: center; justify-content: center; color: white; font-size: ${Math.floor(avatarSize * 0.5)}px; font-weight: bold; flex-shrink: 0;">${escapeHtml(initial)}</div>`;
|
| 589 |
|
| 590 |
+
return `<span id="${escapeHtml(containerId)}" style="display: inline-flex; align-items: center; gap: 6px;">${avatarHtml}<span class="swr-name">${escapeHtml(name)}</span></span>`;
|
| 591 |
}
|
| 592 |
|
| 593 |
|
前端页面/core/状态管理.js
CHANGED
|
@@ -2,12 +2,13 @@
|
|
| 2 |
// ==========================================
|
| 3 |
// 🏪 全局状态管理中心
|
| 4 |
// ==========================================
|
| 5 |
-
// 作用:集中管理应用状态,提供响应式更新和事件通知
|
| 6 |
// 关联文件:
|
| 7 |
// - 类型定义.js (类型定义)
|
| 8 |
// - 所有需要共享状态的组件
|
| 9 |
// - 顶部导航组件.js (用户登录状态)
|
| 10 |
// - 侧边栏主程序.js (当前视图状态)
|
|
|
|
| 11 |
// ==========================================
|
| 12 |
// 🏗️ P2架构优化:轻量级状态管理
|
| 13 |
// 🏗️ P2质量优化:JSDoc 类型注释
|
|
@@ -53,10 +54,9 @@ const state = {
|
|
| 53 |
users: new Map()
|
| 54 |
},
|
| 55 |
|
| 56 |
-
// 网络状态
|
| 57 |
network: {
|
| 58 |
-
isOnline: navigator.onLine
|
| 59 |
-
lastSyncTime: null
|
| 60 |
}
|
| 61 |
};
|
| 62 |
|
|
@@ -432,9 +432,15 @@ export function isLoading() {
|
|
| 432 |
// ==========================================
|
| 433 |
// 🌐 网络状态管理
|
| 434 |
// ==========================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 435 |
|
| 436 |
/**
|
| 437 |
-
* 更新网络状态
|
| 438 |
* @param {boolean} online - 是否在线
|
| 439 |
*/
|
| 440 |
export function setNetworkStatus(online) {
|
|
@@ -442,19 +448,23 @@ export function setNetworkStatus(online) {
|
|
| 442 |
state.network.isOnline = online;
|
| 443 |
|
| 444 |
if (wasOnline !== online) {
|
|
|
|
| 445 |
eventBus.emit(EVENTS.NETWORK_CHANGE, { online });
|
| 446 |
}
|
| 447 |
}
|
| 448 |
|
| 449 |
/**
|
| 450 |
* 检查是否在线
|
|
|
|
|
|
|
|
|
|
| 451 |
* @returns {boolean}
|
| 452 |
*/
|
| 453 |
export function isOnline() {
|
| 454 |
return state.network.isOnline;
|
| 455 |
}
|
| 456 |
|
| 457 |
-
// 自动监听网络状态
|
| 458 |
if (typeof window !== "undefined") {
|
| 459 |
window.addEventListener("online", () => setNetworkStatus(true));
|
| 460 |
window.addEventListener("offline", () => setNetworkStatus(false));
|
|
|
|
| 2 |
// ==========================================
|
| 3 |
// 🏪 全局状态管理中心
|
| 4 |
// ==========================================
|
| 5 |
+
// 作用:集中管理应用状态(含全项目网络状态),提供响应式更新和事件通知
|
| 6 |
// 关联文件:
|
| 7 |
// - 类型定义.js (类型定义)
|
| 8 |
// - 所有需要共享状态的组件
|
| 9 |
// - 顶部导航组件.js (用户登录状态)
|
| 10 |
// - 侧边栏主程序.js (当前视图状态)
|
| 11 |
+
// - 网络请求_基础设施.js / 网络请求_图片代理.js (调用 isOnline() 决定是否走缓存回退)
|
| 12 |
// ==========================================
|
| 13 |
// 🏗️ P2架构优化:轻量级状态管理
|
| 14 |
// 🏗️ P2质量优化:JSDoc 类型注释
|
|
|
|
| 54 |
users: new Map()
|
| 55 |
},
|
| 56 |
|
| 57 |
+
// 网络状态(全项目唯一数据源,见下方「🌐 网络状态管理」段落)
|
| 58 |
network: {
|
| 59 |
+
isOnline: navigator.onLine
|
|
|
|
| 60 |
}
|
| 61 |
};
|
| 62 |
|
|
|
|
| 432 |
// ==========================================
|
| 433 |
// 🌐 网络状态管理
|
| 434 |
// ==========================================
|
| 435 |
+
// 🏗️ 全项目网络状态的唯一数据源。
|
| 436 |
+
// online/offline 事件只在本模块注册一次,下列消费方一律调用 isOnline(),
|
| 437 |
+
// 不再各自维护状态副本与重复监听,避免出现「横幅显示离线但列表仍在发请求」
|
| 438 |
+
// 这类多份状态不同步的分裂表现:
|
| 439 |
+
// - 网络请求_基础设施.js —— 离线 GET 直返缓存、重试耗尽回退过期缓存
|
| 440 |
+
// - 网络请求_图片代理.js —— 离线时不构造远程直链,保持相对路径原样
|
| 441 |
|
| 442 |
/**
|
| 443 |
+
* 更新网络状态(仅供内部 online/offline 监听调用)
|
| 444 |
* @param {boolean} online - 是否在线
|
| 445 |
*/
|
| 446 |
export function setNetworkStatus(online) {
|
|
|
|
| 448 |
state.network.isOnline = online;
|
| 449 |
|
| 450 |
if (wasOnline !== online) {
|
| 451 |
+
console.log(online ? '🌐 网络已恢复' : '📴 网络已断开');
|
| 452 |
eventBus.emit(EVENTS.NETWORK_CHANGE, { online });
|
| 453 |
}
|
| 454 |
}
|
| 455 |
|
| 456 |
/**
|
| 457 |
* 检查是否在线
|
| 458 |
+
*
|
| 459 |
+
* 注意:navigator.onLine 仅反映本机网络接口状态,不代表云端服务可达;
|
| 460 |
+
* 云端不可达(502 / DNS 污染 / 证书失败)由 request() 的重试耗尽兜底负责。
|
| 461 |
* @returns {boolean}
|
| 462 |
*/
|
| 463 |
export function isOnline() {
|
| 464 |
return state.network.isOnline;
|
| 465 |
}
|
| 466 |
|
| 467 |
+
// 自动监听网络状态(全项目唯一一次注册)
|
| 468 |
if (typeof window !== "undefined") {
|
| 469 |
window.addEventListener("online", () => setNetworkStatus(true));
|
| 470 |
window.addEventListener("offline", () => setNetworkStatus(false));
|
前端页面/core/网络请求API.js
CHANGED
|
@@ -8,6 +8,10 @@
|
|
| 8 |
// - 网络请求_缓存管理.js (缓存配置、TTL、精确失效映射)
|
| 9 |
// - 网络请求_业务API.js (api对象:40+个业务方法)
|
| 10 |
// ==========================================
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
export { request, requestCancelManager, invalidateRelatedCache, requestSSE } from './网络请求_基础设施.js';
|
| 13 |
export { proxyImages, unproxyImages } from './网络请求_图片代理.js';
|
|
|
|
| 8 |
// - 网络请求_缓存管理.js (缓存配置、TTL、精确失效映射)
|
| 9 |
// - 网络请求_业务API.js (api对象:40+个业务方法)
|
| 10 |
// ==========================================
|
| 11 |
+
// 📌 本文件只 re-export 下面三行的符号;缓存读写工具(getCache / setCache /
|
| 12 |
+
// getCacheWithMeta / removeCache / removeCacheByPrefix,以及列表缓存
|
| 13 |
+
// readListCache / writeListCache)不在这里,统一从 components/性能优化工具.js 导入。
|
| 14 |
+
// ==========================================
|
| 15 |
|
| 16 |
export { request, requestCancelManager, invalidateRelatedCache, requestSSE } from './网络请求_基础设施.js';
|
| 17 |
export { proxyImages, unproxyImages } from './网络请求_图片代理.js';
|
前端页面/core/网络请求_业务API.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
| 8 |
// ==========================================
|
| 9 |
|
| 10 |
import { request } from "./网络请求_基础设施.js";
|
|
|
|
| 11 |
|
| 12 |
// ============== 业务 API 导出 ==============
|
| 13 |
const api = {
|
|
@@ -54,7 +55,9 @@ const api = {
|
|
| 54 |
async updatePrivacy(account, privacy) { return request(`/api/users/${account}/privacy`, { method: "PUT", body: privacy }); },
|
| 55 |
async toggleFollow(userId, targetAccount, isActive) { return request("/api/users/follow", { method: "POST", body: { user_id: userId, target_account: targetAccount, is_active: isActive } }); },
|
| 56 |
async getCreators(sort, limit) { return request(`/api/creators?sort=${sort}&limit=${limit}`); },
|
| 57 |
-
|
|
|
|
|
|
|
| 58 |
async getCreatorDetails(account) { return request(`/api/creators/${account}/details`); },
|
| 59 |
async uploadFile(file, fileType, timeout = 120000) {
|
| 60 |
const formData = new FormData();
|
|
|
|
| 8 |
// ==========================================
|
| 9 |
|
| 10 |
import { request } from "./网络请求_基础设施.js";
|
| 11 |
+
import { API } from "./全局配置.js";
|
| 12 |
|
| 13 |
// ============== 业务 API 导出 ==============
|
| 14 |
const api = {
|
|
|
|
| 55 |
async updatePrivacy(account, privacy) { return request(`/api/users/${account}/privacy`, { method: "PUT", body: privacy }); },
|
| 56 |
async toggleFollow(userId, targetAccount, isActive) { return request("/api/users/follow", { method: "POST", body: { user_id: userId, target_account: targetAccount, is_active: isActive } }); },
|
| 57 |
async getCreators(sort, limit) { return request(`/api/creators?sort=${sort}&limit=${limit}`); },
|
| 58 |
+
// 🔍 带上取消分组 ID:用户继续输入时,上一次仍在途的搜索请求可被 requestCancelManager 撤销,
|
| 59 |
+
// 不再占用并发额度与超时预算(调用时机见 侧边栏数据引擎.js 的创作者搜索分支)
|
| 60 |
+
async searchCreators(keyword, sort, limit = 50) { return request(`/api/creators/search?keyword=${encodeURIComponent(keyword)}&sort=${sort}&limit=${limit}`, { componentId: API.SEARCH_COMPONENT_ID }); },
|
| 61 |
async getCreatorDetails(account) { return request(`/api/creators/${account}/details`); },
|
| 62 |
async uploadFile(file, fileType, timeout = 120000) {
|
| 63 |
const formData = new FormData();
|
前端页面/core/网络请求_图片代理.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
| 6 |
// ==========================================
|
| 7 |
|
| 8 |
import { API, IS_WEB_MODE } from './全局配置.js';
|
|
|
|
| 9 |
|
| 10 |
// 🟢 入口清洗:接收云端数据时,转换为本地代理,并带【自愈机制】清理被污染的历史数据
|
| 11 |
// 🚀 统一缓存:所有头像字段都走同一个缓存代理,无需重复下载
|
|
@@ -68,7 +69,7 @@ export function proxyImages(obj) {
|
|
| 68 |
} else if (originalUrl && !originalUrl.startsWith('/') && !originalUrl.startsWith('data:')) {
|
| 69 |
// 🎬 相对路径(如 uploads/post_video/...)
|
| 70 |
// 离线时不构造远程 URL,保持原样让浏览器从当前域尝试加载
|
| 71 |
-
if (
|
| 72 |
obj[key] = originalUrl;
|
| 73 |
} else {
|
| 74 |
const fullUrl = `${API.BASE_URL}/api/image_proxy?path=${encodeURIComponent(originalUrl)}`;
|
|
|
|
| 6 |
// ==========================================
|
| 7 |
|
| 8 |
import { API, IS_WEB_MODE } from './全局配置.js';
|
| 9 |
+
import { isOnline } from './状态管理.js'; // 🌐 网络状态统一取自 状态管理.js,不再直读 navigator.onLine
|
| 10 |
|
| 11 |
// 🟢 入口清洗:接收云端数据时,转换为本地代理,并带【自愈机制】清理被污染的历史数据
|
| 12 |
// 🚀 统一缓存:所有头像字段都走同一个缓存代理,无需重复下载
|
|
|
|
| 69 |
} else if (originalUrl && !originalUrl.startsWith('/') && !originalUrl.startsWith('data:')) {
|
| 70 |
// 🎬 相对路径(如 uploads/post_video/...)
|
| 71 |
// 离线时不构造远程 URL,保持原样让浏览器从当前域尝试加载
|
| 72 |
+
if (!isOnline()) {
|
| 73 |
obj[key] = originalUrl;
|
| 74 |
} else {
|
| 75 |
const fullUrl = `${API.BASE_URL}/api/image_proxy?path=${encodeURIComponent(originalUrl)}`;
|
前端页面/core/网络请求_基础设施.js
CHANGED
|
@@ -11,9 +11,12 @@
|
|
| 11 |
|
| 12 |
import { removeCache, getCacheWithMeta } from "../components/性能优化工具.js";
|
| 13 |
import { API, CACHE } from "./全局配置.js";
|
| 14 |
-
import { logoutAndClearUserData } from "./状态管理.js";
|
| 15 |
import { CACHE_CONFIG, CACHE_INVALIDATION_MAP, invalidateRelatedCache, _getCacheTTL, getCache, setCache } from "./网络请求_缓存管理.js";
|
| 16 |
import { unproxyImages } from "./网络请求_图片代理.js";
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
// 🚀 动态时序导入:proxyImages 通过动态 import() 获取,避免模块初始化时序问题
|
| 19 |
let _proxyImages = null;
|
|
@@ -31,10 +34,8 @@ const BASE_URL = API.BASE_URL;
|
|
| 31 |
// ⚡ P1性能优化:请求去重(相同GET请求只发一次)
|
| 32 |
const pendingRequests = new Map();
|
| 33 |
|
| 34 |
-
// 🚀 P3优化:网络状态
|
| 35 |
-
|
| 36 |
-
window.addEventListener('online', () => { isOnline = true; console.log('🌐 网络已恢复'); });
|
| 37 |
-
window.addEventListener('offline', () => { isOnline = false; console.log('📴 网络已断开'); });
|
| 38 |
|
| 39 |
// 🔧 修复:401(Token 过期/签名无效)统一处理——清除本地凭证并提示重新登录。
|
| 40 |
// 背景:服务端更换 JWT_SECRET 或 Token 过期后,旧 Token 被云端拒签,若不清除
|
|
@@ -106,11 +107,16 @@ const requestCancelManager = {
|
|
| 106 |
cancelAll(componentId) {
|
| 107 |
const controllers = this._controllers.get(componentId);
|
| 108 |
if (controllers) {
|
|
|
|
| 109 |
for (const controller of controllers) {
|
| 110 |
controller.abort();
|
| 111 |
}
|
| 112 |
controllers.clear();
|
| 113 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
}
|
| 115 |
},
|
| 116 |
|
|
@@ -226,15 +232,17 @@ async function request(endpoint, options = {}) {
|
|
| 226 |
}
|
| 227 |
}
|
| 228 |
|
| 229 |
-
// 🚀 P3优化:离线模式支持
|
| 230 |
-
if (!isOnline && method === "GET") {
|
| 231 |
const { value, expired, found } = getCacheWithMeta(cacheKey, true); // 忽略过期
|
| 232 |
if (found) {
|
| 233 |
console.log(`📴 离线模式:返回${expired ? '过期' : ''}缓存 (${endpoint})`);
|
| 234 |
const proxyFn2 = await getProxyImages();
|
| 235 |
return proxyFn2(value);
|
| 236 |
}
|
| 237 |
-
|
|
|
|
|
|
|
| 238 |
}
|
| 239 |
|
| 240 |
// ⚡ P1性能优化:请求去重(相同GET请求只发一次)
|
|
@@ -253,9 +261,26 @@ async function request(endpoint, options = {}) {
|
|
| 253 |
fetchOptions.body = options.body;
|
| 254 |
}
|
| 255 |
|
| 256 |
-
// 🚀 P1优化:重试
|
| 257 |
-
|
| 258 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 259 |
|
| 260 |
// ⚡ P1性能优化:封装请求 Promise(支持去重 + 重试)
|
| 261 |
const requestPromise = (async () => {
|
|
@@ -271,12 +296,20 @@ async function request(endpoint, options = {}) {
|
|
| 271 |
|
| 272 |
// 🚀 P4优化:使用请求取消管理器(支持超时 + 组件级取消)
|
| 273 |
const controller = requestCancelManager.create(componentId);
|
| 274 |
-
const timeoutId = setTimeout(() => controller.abort(), options.timeout || API.TIMEOUT);
|
| 275 |
const currentFetchOptions = { ...fetchOptions, signal: controller.signal };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 276 |
|
| 277 |
try {
|
| 278 |
// 🚀 P3优化:使用请求队列限制并发
|
| 279 |
-
const response = await requestQueue.add(() =>
|
|
|
|
|
|
|
|
|
|
| 280 |
clearTimeout(timeoutId); // 清除超时计时器
|
| 281 |
requestCancelManager.remove(componentId, controller); // 🚀 P4: 移除已完成的 controller
|
| 282 |
let responseData = await response.json().catch(() => ({}));
|
|
@@ -336,6 +369,20 @@ async function request(endpoint, options = {}) {
|
|
| 336 |
return responseData;
|
| 337 |
} catch (error) {
|
| 338 |
clearTimeout(timeoutId); // 清除超时计时器
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 339 |
|
| 340 |
// 🔧 P1优化:可重试的错误类型
|
| 341 |
const isRetryable = (
|
|
@@ -381,9 +428,12 @@ async function request(endpoint, options = {}) {
|
|
| 381 |
}
|
| 382 |
|
| 383 |
// 确保请求完成后清除去重记录
|
|
|
|
|
|
|
|
|
|
| 384 |
requestPromise.finally(() => {
|
| 385 |
pendingRequests.delete(cacheKey);
|
| 386 |
-
});
|
| 387 |
|
| 388 |
return requestPromise;
|
| 389 |
}
|
|
|
|
| 11 |
|
| 12 |
import { removeCache, getCacheWithMeta } from "../components/性能优化工具.js";
|
| 13 |
import { API, CACHE } from "./全局配置.js";
|
| 14 |
+
import { logoutAndClearUserData, isOnline } from "./状态管理.js";
|
| 15 |
import { CACHE_CONFIG, CACHE_INVALIDATION_MAP, invalidateRelatedCache, _getCacheTTL, getCache, setCache } from "./网络请求_缓存管理.js";
|
| 16 |
import { unproxyImages } from "./网络请求_图片代理.js";
|
| 17 |
+
// 🔧 直接引入 i18n 叶子模块(而非 用户体验增强.js):本文件处于依赖链最上游,
|
| 18 |
+
// 引入带 DOM 初始化副作用的增强层可能造成启动时序问题;国际化模块无副作用、零循环依赖
|
| 19 |
+
import { t } from "../components/用户体验_国际化.js";
|
| 20 |
|
| 21 |
// 🚀 动态时序导入:proxyImages 通过动态 import() 获取,避免模块初始化时序问题
|
| 22 |
let _proxyImages = null;
|
|
|
|
| 34 |
// ⚡ P1性能优化:请求去重(相同GET请求只发一次)
|
| 35 |
const pendingRequests = new Map();
|
| 36 |
|
| 37 |
+
// 🚀 P3优化:网络状态由 状态管理.js 的 isOnline() 统一提供(见下方离线模式支持),
|
| 38 |
+
// 本模块不再自行注册 online/offline 监听。
|
|
|
|
|
|
|
| 39 |
|
| 40 |
// 🔧 修复:401(Token 过期/签名无效)统一处理——清除本地凭证并提示重新登录。
|
| 41 |
// 背景:服务端更换 JWT_SECRET 或 Token 过期后,旧 Token 被云端拒签,若不清除
|
|
|
|
| 107 |
cancelAll(componentId) {
|
| 108 |
const controllers = this._controllers.get(componentId);
|
| 109 |
if (controllers) {
|
| 110 |
+
const count = controllers.size;
|
| 111 |
for (const controller of controllers) {
|
| 112 |
controller.abort();
|
| 113 |
}
|
| 114 |
controllers.clear();
|
| 115 |
+
// 🔧 仅在实际撤销了请求时打日志:本方法会被搜索框的每一次输入触发,
|
| 116 |
+
// 绝大多数时候并没有在途请求,无条件打印会刷出大量无意义日志
|
| 117 |
+
if (count > 0) {
|
| 118 |
+
console.log(`🚫 已取消组件 [${componentId}] 的 ${count} 个在途请求`);
|
| 119 |
+
}
|
| 120 |
}
|
| 121 |
},
|
| 122 |
|
|
|
|
| 232 |
}
|
| 233 |
}
|
| 234 |
|
| 235 |
+
// 🚀 P3优化:离线模式支持(网络状态取自 状态管理.js 的 isOnline())
|
| 236 |
+
if (!isOnline() && method === "GET") {
|
| 237 |
const { value, expired, found } = getCacheWithMeta(cacheKey, true); // 忽略过期
|
| 238 |
if (found) {
|
| 239 |
console.log(`📴 离线模式:返回${expired ? '过期' : ''}缓存 (${endpoint})`);
|
| 240 |
const proxyFn2 = await getProxyImages();
|
| 241 |
return proxyFn2(value);
|
| 242 |
}
|
| 243 |
+
// 🔧 修复:原为硬编码中文,而本错误会被 12+ 处调用方直接 showToast(err.message) 弹给用户,
|
| 244 |
+
// 导致英文界面下仍弹中文提示;现改走词典
|
| 245 |
+
throw new Error(t('feedback.offline_no_cache'));
|
| 246 |
}
|
| 247 |
|
| 248 |
// ⚡ P1性能优化:请求去重(相同GET请求只发一次)
|
|
|
|
| 261 |
fetchOptions.body = options.body;
|
| 262 |
}
|
| 263 |
|
| 264 |
+
// 🚀 P1优化:重试与超时预算
|
| 265 |
+
// 🔧 修复:重试次数/间隔此前硬编码在本文件(2 / 1000),使 全局配置.js 的
|
| 266 |
+
// API.MAX_RETRIES、API.RETRY_DELAY 成为死配置,现统一读取配置中心
|
| 267 |
+
// 🐢 弱网预算分级:列表/搜索类 GET 有本地缓存兜底,失败上限适当收紧,
|
| 268 |
+
// 避免弱网下用户长时间盯骨架屏;按去掉查询串的路径全匹配,
|
| 269 |
+
// 不会误伤 /api/items/{id}、/api/creators/{account}/details 等详情请求
|
| 270 |
+
const endpointPath = endpoint.split("?")[0];
|
| 271 |
+
let defaultTimeout = API.TIMEOUT;
|
| 272 |
+
let defaultRetries = method === "GET" ? API.MAX_RETRIES : 0; // 非 GET 不重试
|
| 273 |
+
if (method === "GET") {
|
| 274 |
+
if (endpointPath === "/api/creators/search") {
|
| 275 |
+
defaultTimeout = API.SEARCH_TIMEOUT;
|
| 276 |
+
defaultRetries = API.SEARCH_RETRIES;
|
| 277 |
+
} else if (endpointPath === "/api/items" || endpointPath === "/api/creators") {
|
| 278 |
+
defaultRetries = API.LIST_RETRIES;
|
| 279 |
+
}
|
| 280 |
+
}
|
| 281 |
+
const requestTimeout = options.timeout || defaultTimeout;
|
| 282 |
+
const maxRetries = options.retries ?? defaultRetries;
|
| 283 |
+
const retryDelay = options.retryDelay ?? API.RETRY_DELAY; // 指数退避基数
|
| 284 |
|
| 285 |
// ⚡ P1性能优化:封装请求 Promise(支持去重 + 重试)
|
| 286 |
const requestPromise = (async () => {
|
|
|
|
| 296 |
|
| 297 |
// 🚀 P4优化:使用请求取消管理器(支持超时 + 组件级取消)
|
| 298 |
const controller = requestCancelManager.create(componentId);
|
|
|
|
| 299 |
const currentFetchOptions = { ...fetchOptions, signal: controller.signal };
|
| 300 |
+
// 🔧 修复:超时计时器改到真正发出 fetch 时才启动。原实现在入队前就 setTimeout,
|
| 301 |
+
// 并发满 6 个时排队等待会白烧超时预算,排队时长超过超时值会导致 fetch 尚未发出就被 abort
|
| 302 |
+
let timeoutId = null;
|
| 303 |
+
// 🔍 区分「超时中止」与「调用方主动撤销」:两者抛出的都是 AbortError,
|
| 304 |
+
// 但前者应该重试、后者绝不能重试(详见下方 catch 的主动取消分支)
|
| 305 |
+
let timedOut = false;
|
| 306 |
|
| 307 |
try {
|
| 308 |
// 🚀 P3优化:使用请求队列限制并发
|
| 309 |
+
const response = await requestQueue.add(() => {
|
| 310 |
+
timeoutId = setTimeout(() => { timedOut = true; controller.abort(); }, requestTimeout);
|
| 311 |
+
return fetch(url, currentFetchOptions);
|
| 312 |
+
});
|
| 313 |
clearTimeout(timeoutId); // 清除超时计时器
|
| 314 |
requestCancelManager.remove(componentId, controller); // 🚀 P4: 移除已完成的 controller
|
| 315 |
let responseData = await response.json().catch(() => ({}));
|
|
|
|
| 369 |
return responseData;
|
| 370 |
} catch (error) {
|
| 371 |
clearTimeout(timeoutId); // 清除超时计时器
|
| 372 |
+
// 🔍 失败/超时同样摘除 controller:成功路径在上方已 remove,这里不补的话,
|
| 373 |
+
// 取消分组里会堆积已失效的 controller,使 cancelAll 的在途计数虚高
|
| 374 |
+
requestCancelManager.remove(componentId, controller);
|
| 375 |
+
|
| 376 |
+
// 🔍 主动撤销(组件级取消)与超时都表现为 AbortError,但语义完全相反:
|
| 377 |
+
// 超时该重试;主动撤销是调用方明确表示「这次结果我不要了」,绝不能重试,
|
| 378 |
+
// 否则会白烧一轮超时预算(搜索场景下用户每敲一次键都可能触发一次撤销)。
|
| 379 |
+
// 直接抛出而不走循环后的「过期缓存兜底」,避免把作废请求的结果上屏
|
| 380 |
+
if (error.name === 'AbortError' && !timedOut) {
|
| 381 |
+
const cancelErr = new Error(t('feedback.request_cancelled'));
|
| 382 |
+
cancelErr.name = 'RequestCancelledError';
|
| 383 |
+
cancelErr.isCancelled = true;
|
| 384 |
+
throw cancelErr;
|
| 385 |
+
}
|
| 386 |
|
| 387 |
// 🔧 P1优化:可重试的错误类型
|
| 388 |
const isRetryable = (
|
|
|
|
| 428 |
}
|
| 429 |
|
| 430 |
// 确保请求完成后清除去重记录
|
| 431 |
+
// 🔧 补 .catch 空处理:finally 会派生出一个新的 Promise,而它从未被返回也无人接收,
|
| 432 |
+
// 一旦请求失败(含主动撤销)就会在控制台抛 Uncaught (in promise)。
|
| 433 |
+
// 原始 requestPromise 仍在下方正常返回给调用方处理,此处只是吞掉这份重复的拒绝
|
| 434 |
requestPromise.finally(() => {
|
| 435 |
pendingRequests.delete(cacheKey);
|
| 436 |
+
}).catch(() => {});
|
| 437 |
|
| 438 |
return requestPromise;
|
| 439 |
}
|
前端页面/core/网络请求_缓存管理.js
CHANGED
|
@@ -120,8 +120,8 @@ export function getCache(key) {
|
|
| 120 |
_updateLRU(fk);
|
| 121 |
return cached.value;
|
| 122 |
}
|
| 123 |
-
// 过期,清除
|
| 124 |
-
|
| 125 |
}
|
| 126 |
|
| 127 |
// 降级到 localStorage
|
|
@@ -164,8 +164,8 @@ export function getCacheWithMeta(key, ignoreExpiry = false) {
|
|
| 164 |
_updateLRU(fk);
|
| 165 |
return { value: cached.value, expired, found: true };
|
| 166 |
}
|
| 167 |
-
// 过期且不忽略,清除
|
| 168 |
-
|
| 169 |
}
|
| 170 |
|
| 171 |
// 降级到 localStorage
|
|
@@ -192,17 +192,70 @@ export function getCacheWithMeta(key, ignoreExpiry = false) {
|
|
| 192 |
}
|
| 193 |
|
| 194 |
/**
|
| 195 |
-
* 删除缓存
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
* @param {string} key - 缓存键
|
| 197 |
*/
|
| 198 |
export function removeCache(key) {
|
| 199 |
const fk = _fullKey(key);
|
| 200 |
-
|
| 201 |
try {
|
| 202 |
localStorage.removeItem(fk);
|
| 203 |
} catch {}
|
| 204 |
}
|
| 205 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
/**
|
| 207 |
* 清除所有缓存
|
| 208 |
*/
|
|
@@ -460,19 +513,17 @@ export function invalidateRelatedCache(endpoint, method = null) {
|
|
| 460 |
patterns = ["ComfyRanking_ListCache_"];
|
| 461 |
}
|
| 462 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 463 |
let cleared = 0;
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
// localStorage 中的 key 形如 "ComfyRanking_api_/api/tasks?..."
|
| 468 |
-
const prefixedPattern = pattern.startsWith(CACHE.PREFIX) ? pattern : CACHE.PREFIX + pattern;
|
| 469 |
-
if (key.startsWith(prefixedPattern)) {
|
| 470 |
-
localStorage.removeItem(key);
|
| 471 |
-
cleared++;
|
| 472 |
-
break;
|
| 473 |
-
}
|
| 474 |
-
}
|
| 475 |
-
});
|
| 476 |
|
| 477 |
if (cleared > 0) {
|
| 478 |
console.log(`🗑️ 精确清除缓存: ${cleared} 个项 (${endpoint}${method ? ` ${method}` : ''})`);
|
|
|
|
| 120 |
_updateLRU(fk);
|
| 121 |
return cached.value;
|
| 122 |
}
|
| 123 |
+
// 过期,清除(走 _deleteMemoryEntry 同步摘除 LRU 顺序数组,避免幽灵键挤占名额)
|
| 124 |
+
_deleteMemoryEntry(fk);
|
| 125 |
}
|
| 126 |
|
| 127 |
// 降级到 localStorage
|
|
|
|
| 164 |
_updateLRU(fk);
|
| 165 |
return { value: cached.value, expired, found: true };
|
| 166 |
}
|
| 167 |
+
// 过期且不忽略,清除(同上:必须同步维护 memoryCacheOrder)
|
| 168 |
+
_deleteMemoryEntry(fk);
|
| 169 |
}
|
| 170 |
|
| 171 |
// 降级到 localStorage
|
|
|
|
| 192 |
}
|
| 193 |
|
| 194 |
/**
|
| 195 |
+
* 删除内存缓存条目(同步维护 LRU 顺序数组)
|
| 196 |
+
* 🔧 修复:原先各处只调 memoryCache.delete(fk),残留在 memoryCacheOrder 里的幽灵键会
|
| 197 |
+
* 占用 MAX_MEMORY_ITEMS 名额,使 _enforceCacheLimit 淘汰到已不存在的键,内存缓存实际容量缩水
|
| 198 |
+
* @param {string} fk - 完整缓存键(已含前缀)
|
| 199 |
+
*/
|
| 200 |
+
function _deleteMemoryEntry(fk) {
|
| 201 |
+
memoryCache.delete(fk);
|
| 202 |
+
const idx = memoryCacheOrder.indexOf(fk);
|
| 203 |
+
if (idx > -1) memoryCacheOrder.splice(idx, 1);
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
/**
|
| 207 |
+
* 删除缓存(精确键,内存 + localStorage 两级)
|
| 208 |
* @param {string} key - 缓存键
|
| 209 |
*/
|
| 210 |
export function removeCache(key) {
|
| 211 |
const fk = _fullKey(key);
|
| 212 |
+
_deleteMemoryEntry(fk);
|
| 213 |
try {
|
| 214 |
localStorage.removeItem(fk);
|
| 215 |
} catch {}
|
| 216 |
}
|
| 217 |
|
| 218 |
+
/**
|
| 219 |
+
* 🧹 按前缀删除缓存(内存 + localStorage 两级)
|
| 220 |
+
* removeCache 是精确键删除,而 API 层缓存键总是带查询串(形如
|
| 221 |
+
* api_/api/items?type=tool&sort=time&limit=200),调用方无法预知完整键,
|
| 222 |
+
* 用 removeCache('api_/api/items') 删除会全部落空(no-op),故提供前缀删除。
|
| 223 |
+
* 键空间兼容:本模块 setCache 写入的键带 CACHE.PREFIX;少数历史缓存
|
| 224 |
+
* (如 SWR 头像 ComfyCommunity_ProfileCache_*)是直接写 localStorage 的裸键,
|
| 225 |
+
* 两种都要匹配,避免无条件补前缀导致裸键类前缀永远命中不到。
|
| 226 |
+
* @param {string} prefix - 缓存键前缀
|
| 227 |
+
* @returns {number} 实际删除的缓存项数(两级按完整键去重)
|
| 228 |
+
*/
|
| 229 |
+
export function removeCacheByPrefix(prefix) {
|
| 230 |
+
if (!prefix) return 0;
|
| 231 |
+
const candidates = prefix.startsWith(CACHE_PREFIX) ? [prefix] : [_fullKey(prefix), prefix];
|
| 232 |
+
const isMatch = (key) => candidates.some(p => key.startsWith(p));
|
| 233 |
+
const removed = new Set();
|
| 234 |
+
|
| 235 |
+
// 🔧 内存层必须先清:getCache / getCacheWithMeta 都优先读 memoryCache,
|
| 236 |
+
// 只清 localStorage 的话旧数据仍会命中内存并被直接返回(请求根本不会发出)
|
| 237 |
+
for (const fk of Array.from(memoryCache.keys())) {
|
| 238 |
+
if (isMatch(fk)) {
|
| 239 |
+
_deleteMemoryEntry(fk);
|
| 240 |
+
removed.add(fk);
|
| 241 |
+
}
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
try {
|
| 245 |
+
const keysToRemove = [];
|
| 246 |
+
for (let i = 0; i < localStorage.length; i++) {
|
| 247 |
+
const k = localStorage.key(i);
|
| 248 |
+
if (k && isMatch(k)) keysToRemove.push(k);
|
| 249 |
+
}
|
| 250 |
+
keysToRemove.forEach(k => {
|
| 251 |
+
localStorage.removeItem(k);
|
| 252 |
+
removed.add(k);
|
| 253 |
+
});
|
| 254 |
+
} catch {}
|
| 255 |
+
|
| 256 |
+
return removed.size;
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
/**
|
| 260 |
* 清除所有缓存
|
| 261 |
*/
|
|
|
|
| 513 |
patterns = ["ComfyRanking_ListCache_"];
|
| 514 |
}
|
| 515 |
|
| 516 |
+
// 🔧 修复:原实现只遍历 localStorage,漏清 memoryCache —— 而 getCache/getCacheWithMeta
|
| 517 |
+
// 都优先读内存 Map,POST 成功后内存里的旧列表仍会命中并被直接返回(连请求都不发),
|
| 518 |
+
// 表现为「发布/点赞已成功但界面还是旧数据」,要等内存条目 TTL 到期才自愈。
|
| 519 |
+
// 🔧 同时修复:原实现无条件给 pattern 补 CACHE.PREFIX,使上方映射表中的裸键前缀
|
| 520 |
+
// (ComfyCommunity_ProfileCache_,SWR 头像缓存直接写 localStorage、不带前缀)被拼成
|
| 521 |
+
// ComfyRanking_ComfyCommunity_ProfileCache_,永远匹配不到,改头像/昵称后该缓存清不掉。
|
| 522 |
+
// 现统一走 removeCacheByPrefix:两级缓存一起清,且兼容带前缀与裸键两种键空间。
|
| 523 |
let cleared = 0;
|
| 524 |
+
for (const pattern of patterns) {
|
| 525 |
+
cleared += removeCacheByPrefix(pattern);
|
| 526 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 527 |
|
| 528 |
if (cleared > 0) {
|
| 529 |
console.log(`🗑️ 精确清除缓存: ${cleared} 个项 (${endpoint}${method ? ` ${method}` : ''})`);
|
前端页面/market/列表卡片组件.js
CHANGED
|
@@ -80,9 +80,11 @@ export function createItemCard(itemData, currentUser = null, contextType = null)
|
|
| 80 |
const initialCommentCount = itemData.commentsData ? calcActiveComments(itemData.commentsData) : (itemData.comments || 0);
|
| 81 |
|
| 82 |
// 🚀 检查插件安装状态(用于显示更新徽章)
|
|
|
|
|
|
|
| 83 |
const installStatus = checkItemStatus(itemData.id, itemData.latest_version);
|
| 84 |
const updateBadgeHtml = installStatus.hasUpdate
|
| 85 |
-
? `<span style="
|
| 86 |
: '';
|
| 87 |
|
| 88 |
const summaryView = document.createElement("div");
|
|
@@ -92,10 +94,11 @@ export function createItemCard(itemData, currentUser = null, contextType = null)
|
|
| 92 |
// 🚀 核心修改:为评论计数的 span 加上专属的 class,并填入过滤后的真实数量
|
| 93 |
// 🚀 布局重构:第1行标题+使用次数,第2行描述,第3行互动数据+发布者信息
|
| 94 |
summaryView.innerHTML = `
|
| 95 |
-
<!-- 第1行: 标题 + 使用次数 -->
|
| 96 |
-
<div style="display: flex; align-items: center; margin-bottom: 4px;">
|
| 97 |
-
<div style="font-weight: bold; font-size: 14px; color: #4CAF50; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${escapeHtml(itemData.title)}
|
| 98 |
-
|
|
|
|
| 99 |
<span data-stat="uses" style="display: flex; align-items: center; gap: 2px;">📥 ${itemData.uses || 0}</span>
|
| 100 |
<span data-stat="views" style="display: flex; align-items: center; gap: 2px;">🔥 ${itemData.views || 0}</span>
|
| 101 |
</span>
|
|
|
|
| 80 |
const initialCommentCount = itemData.commentsData ? calcActiveComments(itemData.commentsData) : (itemData.comments || 0);
|
| 81 |
|
| 82 |
// 🚀 检查插件安装状态(用于显示更新徽章)
|
| 83 |
+
// 徽章作为标题行的独立 flex 项(flex: none),不再追加在标题文本之后,
|
| 84 |
+
// 避免标题容器的 overflow: hidden + ellipsis 在长标题时把徽章一并裁掉
|
| 85 |
const installStatus = checkItemStatus(itemData.id, itemData.latest_version);
|
| 86 |
const updateBadgeHtml = installStatus.hasUpdate
|
| 87 |
+
? `<span style="flex: none; white-space: nowrap; background: linear-gradient(135deg, #FF9800, #F44336); color: #fff; font-size: 10px; padding: 2px 6px; border-radius: 10px; font-weight: bold; animation: pulse 2s infinite;">🔄 ${t('market.update_available')}</span>`
|
| 88 |
: '';
|
| 89 |
|
| 90 |
const summaryView = document.createElement("div");
|
|
|
|
| 94 |
// 🚀 核心修改:为评论计数的 span 加上专属的 class,并填入过滤后的真实数量
|
| 95 |
// 🚀 布局重构:第1行标题+使用次数,第2行描述,第3行互动数据+发布者信息
|
| 96 |
summaryView.innerHTML = `
|
| 97 |
+
<!-- 第1行: 标题 + 更新徽章 + 使用次数 -->
|
| 98 |
+
<div style="display: flex; align-items: center; margin-bottom: 4px; gap: 6px;">
|
| 99 |
+
<div style="font-weight: bold; font-size: 14px; color: #4CAF50; flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${escapeHtml(itemData.title)}</div>
|
| 100 |
+
${updateBadgeHtml}
|
| 101 |
+
<span style="margin-left: auto; flex: none; font-size: 11px; color: #888; display: flex; align-items: center; gap: 6px;">
|
| 102 |
<span data-stat="uses" style="display: flex; align-items: center; gap: 2px;">📥 ${itemData.uses || 0}</span>
|
| 103 |
<span data-stat="views" style="display: flex; align-items: center; gap: 2px;">🔥 ${itemData.views || 0}</span>
|
| 104 |
</span>
|
前端页面/market/创作者卡片组件.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
| 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 } from "../core/全局配置.js";
|
| 15 |
import { api } from "../core/网络请求_业务API.js";
|
| 16 |
import { getSettings } from "../components/全局设置组件.js";
|
| 17 |
import { t } from "../components/用户体验增强.js";
|
|
@@ -51,7 +51,7 @@ let currentExpandedCard = null;
|
|
| 51 |
async function handleTipCreator(creatorData, currentUser, onTipSuccess) {
|
| 52 |
// 检查用户是否登录
|
| 53 |
if (!currentUser) {
|
| 54 |
-
showToast(t('creator.tip_login_required')
|
| 55 |
return;
|
| 56 |
}
|
| 57 |
|
|
@@ -92,7 +92,7 @@ function loadECharts() {
|
|
| 92 |
* 渲染打赏榜单条目HTML(匿名/非匿名共用外层结构)
|
| 93 |
*/
|
| 94 |
function _renderTipBoardEntry(entry, idx, rankIcon, levelHtml, userHtml, containerId = '') {
|
| 95 |
-
const idAttr = containerId ? ` id="${containerId}"` : '';
|
| 96 |
return `<div${idAttr} style="display:flex;align-items:center;justify-content:space-between;padding:6px 8px;background:${idx % 2 === 0 ? '#222' : 'var(--comfy-menu-bg)'};border-radius:4px;margin-bottom:4px;">` +
|
| 97 |
`<div style="display:flex;align-items:center;gap:8px;"><span style="font-size:14px;min-width:24px;">${rankIcon}</span>${userHtml}</div>` +
|
| 98 |
`<div style="display:flex;align-items:center;gap:6px;">${levelHtml}<span style="color:#4CAF50;font-weight:bold;font-size:11px;">${entry.amount}</span></div></div>`;
|
|
@@ -125,7 +125,7 @@ export function createTipBoardSection(tipBoard = [], title = "🎁 赞赏贡献
|
|
| 125 |
container.innerHTML = `
|
| 126 |
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px;">
|
| 127 |
<div style="font-size: 12px; font-weight: bold; color: #FF9800; display: flex; align-items: center; gap: 6px;">
|
| 128 |
-
${title}
|
| 129 |
<span style="font-size: 10px; color: #888; font-weight: normal;">(${t('creator.tip_board.count', {count: 0})})</span>
|
| 130 |
</div>
|
| 131 |
${creatorData ? `<button id="btn-tip-creator-empty" class="creator-tip-btn" style="${_TIP_BUTTON_STYLE}">${t('creator.tip_this_creator')}</button>` : ''}
|
|
@@ -188,14 +188,16 @@ export function createTipBoardSection(tipBoard = [], title = "🎁 赞赏贡献
|
|
| 188 |
});
|
| 189 |
}, 0);
|
| 190 |
|
| 191 |
-
|
|
|
|
|
|
|
| 192 |
return _renderTipBoardEntry(entry, idx, rankIcon, levelHtml, userHtml, containerId);
|
| 193 |
}).join("");
|
| 194 |
|
| 195 |
container.innerHTML = `
|
| 196 |
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px;">
|
| 197 |
<div style="font-size: 12px; font-weight: bold; color: #FF9800; display: flex; align-items: center; gap: 6px;">
|
| 198 |
-
${title}
|
| 199 |
<span style="font-size: 10px; color: #888; font-weight: normal;">(${t('creator.tip_board.count', {count: tipBoard.length})})</span>
|
| 200 |
</div>
|
| 201 |
${creatorData ? `<button id="btn-tip-creator" class="creator-tip-btn" style="${_TIP_BUTTON_STYLE}">${t('creator.tip_this_creator')}</button>` : ''}
|
|
@@ -264,11 +266,11 @@ export function createCreatorCard(creatorData, currentUser = null) {
|
|
| 264 |
${hasBanner ? '<div style="position: absolute; inset: 0; background: rgba(0,0,0,0.2); border-radius: 8px;"></div>' : ''}
|
| 265 |
<div style="position: relative; z-index: 1;">
|
| 266 |
<div style="display: flex; align-items: center; gap: 15px; margin-bottom: 10px; padding: 5px 0;">
|
| 267 |
-
<img class="creator-avatar-link" src="${avatarSrc}" title="${t('creator.visit_profile')}" style="width: 50px; height: 50px; border-radius: 50%; border: 2px solid ${hasBanner ? 'rgba(255,255,255,0.8)' : '#555'}; object-fit: cover; cursor: pointer; transition: 0.2s; ${hasBanner ? 'box-shadow: 0 2px 8px rgba(0,0,0,0.3);' : ''}" onmouseover="this.style.borderColor='#4CAF50'" onmouseout="this.style.borderColor='${hasBanner ? 'rgba(255,255,255,0.8)' : '#555'}'">
|
| 268 |
-
<div class="creator-name-link" title="${t('creator.visit_profile')}" style="font-weight: bold; font-size: 16px; cursor: pointer; transition: 0.2s; color: #FFD700; text-shadow: 0 1px 4px rgba(0,0,0,0.8), 0 0 8px rgba(255,215,0,0.3);" onmouseover="this.style.color='#FFA500'" onmouseout="this.style.color='#FFD700'">${creatorData.name}</div>
|
| 269 |
<div data-stat="downloads" style="font-size: 12px; color: #FF6B6B; margin-left: auto; text-shadow: 0 1px 3px rgba(0,0,0,0.6);">${t('creator.usage_count', {count: creatorData.downloads || 0})}</div>
|
| 270 |
</div>
|
| 271 |
-
<div style="font-size: 12px; color: ${hasBanner ? '#ccc' : '#aaa'}; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; margin-bottom: 10px;">${creatorData.shortDesc && creatorData.shortDesc !== "null" ? creatorData.shortDesc : t('profile.no_intro')
|
| 272 |
<div style="background: rgba(34,34,34,${hasBanner ? '0.8' : '1'}); border-radius: 6px; padding: 8px 10px; border: 1px dashed #555;">
|
| 273 |
<div style="display: flex; gap: 15px; font-size: 12px; color: #eee; justify-content: space-between; margin-bottom: 8px;">
|
| 274 |
<span data-stat="likes" style="color: #FF5722;">👍 ${t('creator.stats.likes')}: <strong>${creatorData.likes}</strong></span>
|
|
|
|
| 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";
|
|
|
|
| 51 |
async function handleTipCreator(creatorData, currentUser, onTipSuccess) {
|
| 52 |
// 检查用户是否登录
|
| 53 |
if (!currentUser) {
|
| 54 |
+
showToast(t('creator.tip_login_required'), "warning");
|
| 55 |
return;
|
| 56 |
}
|
| 57 |
|
|
|
|
| 92 |
* 渲染打赏榜单条目HTML(匿名/非匿名共用外层结构)
|
| 93 |
*/
|
| 94 |
function _renderTipBoardEntry(entry, idx, rankIcon, levelHtml, userHtml, containerId = '') {
|
| 95 |
+
const idAttr = containerId ? ` id="${escapeHtml(containerId)}"` : '';
|
| 96 |
return `<div${idAttr} style="display:flex;align-items:center;justify-content:space-between;padding:6px 8px;background:${idx % 2 === 0 ? '#222' : 'var(--comfy-menu-bg)'};border-radius:4px;margin-bottom:4px;">` +
|
| 97 |
`<div style="display:flex;align-items:center;gap:8px;"><span style="font-size:14px;min-width:24px;">${rankIcon}</span>${userHtml}</div>` +
|
| 98 |
`<div style="display:flex;align-items:center;gap:6px;">${levelHtml}<span style="color:#4CAF50;font-weight:bold;font-size:11px;">${entry.amount}</span></div></div>`;
|
|
|
|
| 125 |
container.innerHTML = `
|
| 126 |
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px;">
|
| 127 |
<div style="font-size: 12px; font-weight: bold; color: #FF9800; display: flex; align-items: center; gap: 6px;">
|
| 128 |
+
${escapeHtml(title)}
|
| 129 |
<span style="font-size: 10px; color: #888; font-weight: normal;">(${t('creator.tip_board.count', {count: 0})})</span>
|
| 130 |
</div>
|
| 131 |
${creatorData ? `<button id="btn-tip-creator-empty" class="creator-tip-btn" style="${_TIP_BUTTON_STYLE}">${t('creator.tip_this_creator')}</button>` : ''}
|
|
|
|
| 188 |
});
|
| 189 |
}, 0);
|
| 190 |
|
| 191 |
+
// 🔒 XSS防护:昵称/头像均来自用户可修改的资料,写入 HTML 前必须转义
|
| 192 |
+
// (下方 SWR 刷新路径用的是 .src / .textContent,本身就是安全的)
|
| 193 |
+
const userHtml = `<span class="tip-board-user" data-account="${escapeHtml(entry.account)}" data-anon="false" style="display: flex; align-items: center; gap: 6px; color: #4CAF50; cursor: pointer; font-size: 13px;"><img class="tip-board-avatar" src="${escapeHtml(avatarUrl)}" style="width: 20px; height: 20px; border-radius: 50%; object-fit: cover; flex-shrink: 0; background: var(--comfy-input-bg);"><span class="tip-board-name">${escapeHtml(userName)}</span></span>`;
|
| 194 |
return _renderTipBoardEntry(entry, idx, rankIcon, levelHtml, userHtml, containerId);
|
| 195 |
}).join("");
|
| 196 |
|
| 197 |
container.innerHTML = `
|
| 198 |
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px;">
|
| 199 |
<div style="font-size: 12px; font-weight: bold; color: #FF9800; display: flex; align-items: center; gap: 6px;">
|
| 200 |
+
${escapeHtml(title)}
|
| 201 |
<span style="font-size: 10px; color: #888; font-weight: normal;">(${t('creator.tip_board.count', {count: tipBoard.length})})</span>
|
| 202 |
</div>
|
| 203 |
${creatorData ? `<button id="btn-tip-creator" class="creator-tip-btn" style="${_TIP_BUTTON_STYLE}">${t('creator.tip_this_creator')}</button>` : ''}
|
|
|
|
| 266 |
${hasBanner ? '<div style="position: absolute; inset: 0; background: rgba(0,0,0,0.2); border-radius: 8px;"></div>' : ''}
|
| 267 |
<div style="position: relative; z-index: 1;">
|
| 268 |
<div style="display: flex; align-items: center; gap: 15px; margin-bottom: 10px; padding: 5px 0;">
|
| 269 |
+
<img class="creator-avatar-link" src="${escapeHtml(avatarSrc)}" title="${t('creator.visit_profile')}" style="width: 50px; height: 50px; border-radius: 50%; border: 2px solid ${hasBanner ? 'rgba(255,255,255,0.8)' : '#555'}; object-fit: cover; cursor: pointer; transition: 0.2s; ${hasBanner ? 'box-shadow: 0 2px 8px rgba(0,0,0,0.3);' : ''}" onmouseover="this.style.borderColor='#4CAF50'" onmouseout="this.style.borderColor='${hasBanner ? 'rgba(255,255,255,0.8)' : '#555'}'">
|
| 270 |
+
<div class="creator-name-link" title="${t('creator.visit_profile')}" style="font-weight: bold; font-size: 16px; cursor: pointer; transition: 0.2s; color: #FFD700; text-shadow: 0 1px 4px rgba(0,0,0,0.8), 0 0 8px rgba(255,215,0,0.3);" onmouseover="this.style.color='#FFA500'" onmouseout="this.style.color='#FFD700'">${escapeHtml(creatorData.name)}</div>
|
| 271 |
<div data-stat="downloads" style="font-size: 12px; color: #FF6B6B; margin-left: auto; text-shadow: 0 1px 3px rgba(0,0,0,0.6);">${t('creator.usage_count', {count: creatorData.downloads || 0})}</div>
|
| 272 |
</div>
|
| 273 |
+
<div style="font-size: 12px; color: ${hasBanner ? '#ccc' : '#aaa'}; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; margin-bottom: 10px;">${escapeHtml(creatorData.shortDesc && creatorData.shortDesc !== "null" ? creatorData.shortDesc : t('profile.no_intro'))}</div>
|
| 274 |
<div style="background: rgba(34,34,34,${hasBanner ? '0.8' : '1'}); border-radius: 6px; padding: 8px 10px; border: 1px dashed #555;">
|
| 275 |
<div style="display: flex; gap: 15px; font-size: 12px; color: #eee; justify-content: space-between; margin-bottom: 8px;">
|
| 276 |
<span data-stat="likes" style="color: #FF5722;">👍 ${t('creator.stats.likes')}: <strong>${creatorData.likes}</strong></span>
|
前端页面/market/发布内容_UI模板.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
// 前端页面/market/发布内容_UI模板.js
|
|
|
|
| 2 |
|
| 3 |
export function generatePublishHTML(isEditMode, viewTitle, submitBtnText, hasExistingToken, editItemData, t) {
|
| 4 |
const safeData = editItemData || {};
|
|
@@ -10,7 +11,7 @@ export function generatePublishHTML(isEditMode, viewTitle, submitBtnText, hasExi
|
|
| 10 |
<button id="btn-back" style="margin-left: 15px; margin-top: 15px; background: rgba(51,51,51,0.8); border: 1px solid rgba(85,85,85,0.8); color: #fff; padding: 6px 14px; border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: bold; display: flex; align-items: center; gap: 6px; box-shadow: 0 2px 4px rgba(0,0,0,0.3); transition: 0.2s;" onmouseover="this.style.background='#4CAF50'; this.style.borderColor='#4CAF50'" onmouseout="this.style.background='rgba(51,51,51,0.8)'; this.style.borderColor='rgba(85,85,85,0.8)'">
|
| 11 |
⬅ ${t('common.back')}
|
| 12 |
</button>
|
| 13 |
-
<span style="font-size: 16px; font-weight: bold; color: #fff;">${viewTitle}</span>
|
| 14 |
</div>
|
| 15 |
|
| 16 |
<div style="display: flex; gap: 10px; margin-bottom: 5px;">
|
|
@@ -135,6 +136,6 @@ export function generatePublishHTML(isEditMode, viewTitle, submitBtnText, hasExi
|
|
| 135 |
</div>
|
| 136 |
</div>
|
| 137 |
|
| 138 |
-
<button id="btn-submit-publish" style="width: 100%; padding: 12px; background: #2196F3; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold; font-size: 15px; transition: 0.3s; margin-bottom: 20px;">${submitBtnText}</button>
|
| 139 |
`;
|
| 140 |
}
|
|
|
|
| 1 |
// 前端页面/market/发布内容_UI模板.js
|
| 2 |
+
import { escapeHtml } from "../core/全局配置.js";
|
| 3 |
|
| 4 |
export function generatePublishHTML(isEditMode, viewTitle, submitBtnText, hasExistingToken, editItemData, t) {
|
| 5 |
const safeData = editItemData || {};
|
|
|
|
| 11 |
<button id="btn-back" style="margin-left: 15px; margin-top: 15px; background: rgba(51,51,51,0.8); border: 1px solid rgba(85,85,85,0.8); color: #fff; padding: 6px 14px; border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: bold; display: flex; align-items: center; gap: 6px; box-shadow: 0 2px 4px rgba(0,0,0,0.3); transition: 0.2s;" onmouseover="this.style.background='#4CAF50'; this.style.borderColor='#4CAF50'" onmouseout="this.style.background='rgba(51,51,51,0.8)'; this.style.borderColor='rgba(85,85,85,0.8)'">
|
| 12 |
⬅ ${t('common.back')}
|
| 13 |
</button>
|
| 14 |
+
<span style="font-size: 16px; font-weight: bold; color: #fff;">${escapeHtml(viewTitle)}</span>
|
| 15 |
</div>
|
| 16 |
|
| 17 |
<div style="display: flex; gap: 10px; margin-bottom: 5px;">
|
|
|
|
| 136 |
</div>
|
| 137 |
</div>
|
| 138 |
|
| 139 |
+
<button id="btn-submit-publish" style="width: 100%; padding: 12px; background: #2196F3; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold; font-size: 15px; transition: 0.3s; margin-bottom: 20px;">${escapeHtml(submitBtnText)}</button>
|
| 140 |
`;
|
| 141 |
}
|
前端页面/market/发布内容_提交引擎.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
| 2 |
import { api } from "../core/网络请求API.js";
|
| 3 |
import { showToast } from "../components/UI交互提示组件.js";
|
| 4 |
import { t } from "../components/用户体验增强.js";
|
| 5 |
-
import { removeCache } from "../components/性能优化工具.js";
|
| 6 |
import { TYPE_RULES } from "./发布内容组件.js"; // 📋 三榜发布表单声明式规则(与 UI 联动同源)
|
| 7 |
|
| 8 |
/**
|
|
@@ -27,9 +27,12 @@ function clearItemCacheByType(type) {
|
|
| 27 |
}
|
| 28 |
|
| 29 |
// 清除 API 缓存
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
// 清除侧边栏数据引擎缓存(只清除对应类型)
|
| 35 |
const sorts = ['time', 'downloads', 'likes', 'favorites', 'tips', 'views', 'daily_views', 'rating'];
|
|
|
|
| 2 |
import { api } from "../core/网络请求API.js";
|
| 3 |
import { showToast } from "../components/UI交互提示组件.js";
|
| 4 |
import { t } from "../components/用户体验增强.js";
|
| 5 |
+
import { removeCache, removeCacheByPrefix } from "../components/性能优化工具.js";
|
| 6 |
import { TYPE_RULES } from "./发布内容组件.js"; // 📋 三榜发布表单声明式规则(与 UI 联动同源)
|
| 7 |
|
| 8 |
/**
|
|
|
|
| 27 |
}
|
| 28 |
|
| 29 |
// 清除 API 缓存
|
| 30 |
+
// 🔧 修复:API 层缓存键总是带完整查询串(api_/api/items?type=tool&sort=time&limit=200),
|
| 31 |
+
// 而 removeCache 是精确键删除,原先这几个调用一个也命中不到(no-op),发布成功后
|
| 32 |
+
// request() 仍会从缓存直接返回旧列表、看不到刚发布的内容。改用前缀删除,
|
| 33 |
+
// 一次覆盖全部 type/sort/limit 组合(原先按 type 单独删的那行已被本前缀覆盖,属冗余)
|
| 34 |
+
removeCacheByPrefix('api_/api/items');
|
| 35 |
+
removeCacheByPrefix('api_/api/creators'); // 创作者列表也需要刷新
|
| 36 |
|
| 37 |
// 清除侧边栏数据引擎缓存(只清除对应类型)
|
| 38 |
const sorts = ['time', 'downloads', 'likes', 'favorites', 'tips', 'views', 'daily_views', 'rating'];
|
前端页面/market/发布内容组件.js
CHANGED
|
@@ -4,6 +4,7 @@ import { handlePublishSubmit } from "./发布内容_提交引擎.js";
|
|
| 4 |
import { t } from "../components/用户体验增强.js";
|
| 5 |
import { showConfirm, showToast } from "../components/UI交互提示组件.js";
|
| 6 |
import { globalModal } from "../components/全局弹窗管理器.js";
|
|
|
|
| 7 |
|
| 8 |
// ==========================================
|
| 9 |
// 📌 模块级常量
|
|
@@ -40,7 +41,7 @@ function _createImagePreviewWrapper(imgUrl, idx) {
|
|
| 40 |
const wrapper = document.createElement('div');
|
| 41 |
wrapper.style.cssText = 'position: relative; width: 80px; height: 80px;';
|
| 42 |
wrapper.innerHTML = `
|
| 43 |
-
<img src="${imgUrl}" style="width: 80px; height: 80px; object-fit: cover; border-radius: 4px; border: 2px solid ${idx === 0 ? '#4CAF50' : '#444'};">
|
| 44 |
${idx === 0 ? `<span class="cover-label" style="${_COVER_LABEL_STYLE}">${t('post.cover')}</span>` : ''}
|
| 45 |
<button data-action="remove" style="position: absolute; top: -6px; right: -6px; width: 18px; height: 18px; border-radius: 50%; background: #F44336; color: #fff; border: none; cursor: pointer; font-size: 12px; line-height: 1;">×</button>
|
| 46 |
`;
|
|
|
|
| 4 |
import { t } from "../components/用户体验增强.js";
|
| 5 |
import { showConfirm, showToast } from "../components/UI交互提示组件.js";
|
| 6 |
import { globalModal } from "../components/全局弹窗管理器.js";
|
| 7 |
+
import { escapeHtml } from "../core/全局配置.js";
|
| 8 |
|
| 9 |
// ==========================================
|
| 10 |
// 📌 模块级常量
|
|
|
|
| 41 |
const wrapper = document.createElement('div');
|
| 42 |
wrapper.style.cssText = 'position: relative; width: 80px; height: 80px;';
|
| 43 |
wrapper.innerHTML = `
|
| 44 |
+
<img src="${escapeHtml(imgUrl)}" style="width: 80px; height: 80px; object-fit: cover; border-radius: 4px; border: 2px solid ${idx === 0 ? '#4CAF50' : '#444'};">
|
| 45 |
${idx === 0 ? `<span class="cover-label" style="${_COVER_LABEL_STYLE}">${t('post.cover')}</span>` : ''}
|
| 46 |
<button data-action="remove" style="position: absolute; top: -6px; right: -6px; width: 18px; height: 18px; border-radius: 50%; background: #F44336; color: #fff; border: none; cursor: pointer; font-size: 12px; line-height: 1;">×</button>
|
| 47 |
`;
|
前端页面/market/资源安装引擎.js
CHANGED
|
@@ -3,7 +3,8 @@ import { showToast, showConfirm, createInstallProgress } from "../components/UI
|
|
| 3 |
import { api } from "../core/网络请求API.js";
|
| 4 |
import { requestSSE, request } from "../core/网络请求_基础设施.js";
|
| 5 |
import { openUserProfileModal } from "../profile/个人中心视图.js";
|
| 6 |
-
import { API, CACHE, IS_WEB_MODE } from "../core/全局配置.js";
|
|
|
|
| 7 |
|
| 8 |
// 📱 Web 模式兼容:ComfyUI 的 app 对象仅存在于本地环境,改为受保护动态导入
|
| 9 |
// (Web 模式不暴露安装/下载入口,不会走到此路径;万一走到也不抛错)
|
|
@@ -17,7 +18,7 @@ async function _loadGraphToCanvas(graphData) {
|
|
| 17 |
return false;
|
| 18 |
}
|
| 19 |
}
|
| 20 |
-
import { removeCache } from "../components/性能优化工具.js";
|
| 21 |
|
| 22 |
// ==========================================
|
| 23 |
// 📦 已获取资源记录管理
|
|
@@ -27,8 +28,10 @@ import { removeCache } from "../components/性能优化工具.js";
|
|
| 27 |
* 🗑️ 清除使用量相关缓存
|
| 28 |
*/
|
| 29 |
function clearUsesCache() {
|
| 30 |
-
|
| 31 |
-
|
|
|
|
|
|
|
| 32 |
const tabs = ['tools', 'apps', 'recommends', 'creators'];
|
| 33 |
const sorts = ['time', 'downloads', 'likes', 'favorites', 'tips', 'views', 'daily_views', 'rating'];
|
| 34 |
for (const tab of tabs) {
|
|
@@ -199,7 +202,7 @@ export function setupResourceInstall(btnUse, itemData, currentUser, inlineStatus
|
|
| 199 |
|
| 200 |
// 📱 Web 模式:不支持下载/安装。免费(含作者本人)或已购买 → 仅提醒电脑端安装;未购买 → 放行到下方纯购买流程
|
| 201 |
if (IS_WEB_MODE) {
|
| 202 |
-
if (isFree) return showToast(
|
| 203 |
let webOwned = false;
|
| 204 |
try {
|
| 205 |
const st = await api.getPurchaseStatus(currentUser.account, itemData.id);
|
|
@@ -207,7 +210,7 @@ export function setupResourceInstall(btnUse, itemData, currentUser, inlineStatus
|
|
| 207 |
} catch (e) {
|
| 208 |
console.warn('购买状态查询失败,继续购买流程', e);
|
| 209 |
}
|
| 210 |
-
if (webOwned) return showToast(
|
| 211 |
}
|
| 212 |
|
| 213 |
// 记录是否已经拥有,决定是否涨销量
|
|
@@ -267,7 +270,7 @@ export function setupResourceInstall(btnUse, itemData, currentUser, inlineStatus
|
|
| 267 |
clearTimeout(valTimeout);
|
| 268 |
const valData = await valRes.json();
|
| 269 |
if (!valRes.ok || valData.error) {
|
| 270 |
-
inlineStatusBox.innerHTML = `<span style="color: #F44336;">❌ 拦截提示: ${valData.error || '无法访问源地址'}</span>`;
|
| 271 |
return; // 发现死链,强行阻断用户付款
|
| 272 |
}
|
| 273 |
} catch(e) {
|
|
@@ -280,7 +283,7 @@ export function setupResourceInstall(btnUse, itemData, currentUser, inlineStatus
|
|
| 280 |
|
| 281 |
// 已购买用户跳过购买确认弹窗,直接进入后续流程
|
| 282 |
if (!alreadyOwned) {
|
| 283 |
-
const confirmPay = await showConfirm(`该资源标价为 <strong style="color:#FF9800;">${itemData.price} 积分</strong>。<br><br>确认获取吗?<br><span style="font-size:12px;color:#aaa;">(注:一次购买永久免费,如果已购买过系统将直接放行,不会重复扣款)</span>`);
|
| 284 |
if (!confirmPay) {
|
| 285 |
inlineStatusBox.style.display = "none";
|
| 286 |
return;
|
|
@@ -329,7 +332,7 @@ export function setupResourceInstall(btnUse, itemData, currentUser, inlineStatus
|
|
| 329 |
}
|
| 330 |
return; // 直接退出函数,不会走到下方的安装流程
|
| 331 |
} else {
|
| 332 |
-
inlineStatusBox.innerHTML = `<span style="color: #F44336;">❌ 获取失败: ${err.message}</span>`;
|
| 333 |
showToast("获取失败:" + err.message, "error");
|
| 334 |
return;
|
| 335 |
}
|
|
@@ -339,9 +342,9 @@ export function setupResourceInstall(btnUse, itemData, currentUser, inlineStatus
|
|
| 339 |
if (IS_WEB_MODE) {
|
| 340 |
inlineStatusBox.style.display = "block";
|
| 341 |
inlineStatusBox.innerHTML = alreadyOwned
|
| 342 |
-
? `<span style="color: #4CAF50;">✅
|
| 343 |
-
: `<span style="color: #4CAF50;">✅
|
| 344 |
-
showToast(
|
| 345 |
return;
|
| 346 |
}
|
| 347 |
|
|
@@ -449,8 +452,16 @@ export function setupResourceInstall(btnUse, itemData, currentUser, inlineStatus
|
|
| 449 |
if (data.error) {
|
| 450 |
const isLinkError = data.error.includes('不是有效的 Git');
|
| 451 |
inlineStatusBox.innerHTML = isLinkError
|
| 452 |
-
? `<span style="color: #F44336;">❌ ${data.error}</span><br><button
|
| 453 |
-
: `<span style="color: #F44336;">❌ 安装失败: ${data.error}</span>`;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 454 |
showToast(`插件 [${itemData.title}] 安装失败: ${data.error}`, "error");
|
| 455 |
} else {
|
| 456 |
// 根据后端返回的 message 判断是否为自更新场景
|
|
@@ -517,7 +528,7 @@ export function setupResourceInstall(btnUse, itemData, currentUser, inlineStatus
|
|
| 517 |
btnUse.style.background = "#4CAF50";
|
| 518 |
} else {
|
| 519 |
progress.error(result.message);
|
| 520 |
-
inlineStatusBox.innerHTML = `<span style="color: #F44336;">❌ 加载失败:${result.message}</span>`;
|
| 521 |
showToast(`工作流加载失败:${result.message}`, "error");
|
| 522 |
}
|
| 523 |
} catch(err) {
|
|
@@ -533,7 +544,7 @@ export function setupResourceInstall(btnUse, itemData, currentUser, inlineStatus
|
|
| 533 |
});
|
| 534 |
if (!res.ok) {
|
| 535 |
const errText = await res.text().catch(() => '未知服务端错误');
|
| 536 |
-
inlineStatusBox.innerHTML = `<span style="color: #F44336;">❌ 加载失败 (${res.status}):${errText}</span>`;
|
| 537 |
return;
|
| 538 |
}
|
| 539 |
let data;
|
|
@@ -541,11 +552,11 @@ export function setupResourceInstall(btnUse, itemData, currentUser, inlineStatus
|
|
| 541 |
data = await res.json();
|
| 542 |
} catch (parseErr) {
|
| 543 |
inlineStatusBox.innerHTML = `<span style="color: #F44336;">❌ 响应解析失败,请重试</span>`;
|
| 544 |
-
showToast(t('common.parse_error')
|
| 545 |
return;
|
| 546 |
}
|
| 547 |
if (data.error) {
|
| 548 |
-
inlineStatusBox.innerHTML = `<span style="color: #F44336;">❌ 加载失败:${data.error}</span>`;
|
| 549 |
showToast(`工作流加载失败:${data.error}`, "error");
|
| 550 |
} else {
|
| 551 |
await _loadGraphToCanvas(data.data);
|
|
@@ -567,7 +578,7 @@ export function setupResourceInstall(btnUse, itemData, currentUser, inlineStatus
|
|
| 567 |
}
|
| 568 |
} catch(err2) {
|
| 569 |
console.error('❌ 应用下载失败:', err2);
|
| 570 |
-
inlineStatusBox.innerHTML = `<span style="color: #F44336;">❌ 无法连接到本地服务:${err2.message || '网络错误'}</span>`;
|
| 571 |
}
|
| 572 |
}
|
| 573 |
} else {
|
|
|
|
| 3 |
import { api } from "../core/网络请求API.js";
|
| 4 |
import { requestSSE, request } from "../core/网络请求_基础设施.js";
|
| 5 |
import { openUserProfileModal } from "../profile/个人中心视图.js";
|
| 6 |
+
import { API, CACHE, IS_WEB_MODE, escapeHtml } from "../core/全局配置.js";
|
| 7 |
+
import { t } from "../components/用户体验增强.js";
|
| 8 |
|
| 9 |
// 📱 Web 模式兼容:ComfyUI 的 app 对象仅存在于本地环境,改为受保护动态导入
|
| 10 |
// (Web 模式不暴露安装/下载入口,不会走到此路径;万一走到也不抛错)
|
|
|
|
| 18 |
return false;
|
| 19 |
}
|
| 20 |
}
|
| 21 |
+
import { removeCache, removeCacheByPrefix } from "../components/性能优化工具.js";
|
| 22 |
|
| 23 |
// ==========================================
|
| 24 |
// 📦 已获取资源记录管理
|
|
|
|
| 28 |
* 🗑️ 清除使用量相关缓存
|
| 29 |
*/
|
| 30 |
function clearUsesCache() {
|
| 31 |
+
// 🔧 修复:API 层缓存键带完整查询串,removeCache 精确删除命中不到(no-op),
|
| 32 |
+
// 安装后使用量/下载数仍会从旧缓存返回,改用前缀删除
|
| 33 |
+
removeCacheByPrefix('api_/api/items');
|
| 34 |
+
removeCacheByPrefix('api_/api/creators');
|
| 35 |
const tabs = ['tools', 'apps', 'recommends', 'creators'];
|
| 36 |
const sorts = ['time', 'downloads', 'likes', 'favorites', 'tips', 'views', 'daily_views', 'rating'];
|
| 37 |
for (const tab of tabs) {
|
|
|
|
| 202 |
|
| 203 |
// 📱 Web 模式:不支持下载/安装。免费(含作者本人)或已购买 → 仅提醒电脑端安装;未购买 → 放行到下方纯购买流程
|
| 204 |
if (IS_WEB_MODE) {
|
| 205 |
+
if (isFree) return showToast(t('web.install_on_desktop'), "warning");
|
| 206 |
let webOwned = false;
|
| 207 |
try {
|
| 208 |
const st = await api.getPurchaseStatus(currentUser.account, itemData.id);
|
|
|
|
| 210 |
} catch (e) {
|
| 211 |
console.warn('购买状态查询失败,继续购买流程', e);
|
| 212 |
}
|
| 213 |
+
if (webOwned) return showToast(t('item.owned_purchased'), "warning");
|
| 214 |
}
|
| 215 |
|
| 216 |
// 记录是否已经拥有,决定是否涨销量
|
|
|
|
| 270 |
clearTimeout(valTimeout);
|
| 271 |
const valData = await valRes.json();
|
| 272 |
if (!valRes.ok || valData.error) {
|
| 273 |
+
inlineStatusBox.innerHTML = `<span style="color: #F44336;">❌ 拦截提示: ${escapeHtml(valData.error || '无法访问源地址')}</span>`;
|
| 274 |
return; // 发现死链,强行阻断用户付款
|
| 275 |
}
|
| 276 |
} catch(e) {
|
|
|
|
| 283 |
|
| 284 |
// 已购买用户跳过购买确认弹窗,直接进入后续流程
|
| 285 |
if (!alreadyOwned) {
|
| 286 |
+
const confirmPay = await showConfirm(`该资源标价为 <strong style="color:#FF9800;">${itemData.price} 积分</strong>。<br><br>确认获取吗?<br><span style="font-size:12px;color:#aaa;">(注:一次购买永久免费,如果已购买过系统将直接放行,不会重复扣款)</span>`, { html: true });
|
| 287 |
if (!confirmPay) {
|
| 288 |
inlineStatusBox.style.display = "none";
|
| 289 |
return;
|
|
|
|
| 332 |
}
|
| 333 |
return; // 直接退出函数,不会走到下方的安装流程
|
| 334 |
} else {
|
| 335 |
+
inlineStatusBox.innerHTML = `<span style="color: #F44336;">❌ 获取失败: ${escapeHtml(err.message)}</span>`;
|
| 336 |
showToast("获取失败:" + err.message, "error");
|
| 337 |
return;
|
| 338 |
}
|
|
|
|
| 342 |
if (IS_WEB_MODE) {
|
| 343 |
inlineStatusBox.style.display = "block";
|
| 344 |
inlineStatusBox.innerHTML = alreadyOwned
|
| 345 |
+
? `<span style="color: #4CAF50;">✅ ${escapeHtml(t('item.owned_purchased'))}</span>`
|
| 346 |
+
: `<span style="color: #4CAF50;">✅ ${escapeHtml(t('web.purchase_success_desktop'))}</span>`;
|
| 347 |
+
showToast(t('web.install_on_desktop'), "info");
|
| 348 |
return;
|
| 349 |
}
|
| 350 |
|
|
|
|
| 452 |
if (data.error) {
|
| 453 |
const isLinkError = data.error.includes('不是有效的 Git');
|
| 454 |
inlineStatusBox.innerHTML = isLinkError
|
| 455 |
+
? `<span style="color: #F44336;">❌ ${escapeHtml(data.error)}</span><br><button id="btn-open-source-link" style="margin-top:8px;padding:6px 14px;background:#2563eb;color:white;border:none;border-radius:4px;cursor:pointer;font-weight:bold;">🔗 前往资源原始页面</button>`
|
| 456 |
+
: `<span style="color: #F44336;">❌ 安装失败: ${escapeHtml(data.error)}</span>`;
|
| 457 |
+
// 🔒 XSS防护:链接改为 DOM 事件绑定(内联 onclick 里的引号即使转义,
|
| 458 |
+
// 也会被浏览器先还原再交给 JS 解析,依然能被注入;本文件其他分支均用此写法)
|
| 459 |
+
if (isLinkError) {
|
| 460 |
+
const btnOpenSource = inlineStatusBox.querySelector('#btn-open-source-link');
|
| 461 |
+
if (btnOpenSource) {
|
| 462 |
+
btnOpenSource.onclick = () => window.open(itemData.link, '_blank');
|
| 463 |
+
}
|
| 464 |
+
}
|
| 465 |
showToast(`插件 [${itemData.title}] 安装失败: ${data.error}`, "error");
|
| 466 |
} else {
|
| 467 |
// 根据后端返回的 message 判断是否为自更新场景
|
|
|
|
| 528 |
btnUse.style.background = "#4CAF50";
|
| 529 |
} else {
|
| 530 |
progress.error(result.message);
|
| 531 |
+
inlineStatusBox.innerHTML = `<span style="color: #F44336;">❌ 加载失败:${escapeHtml(result.message)}</span>`;
|
| 532 |
showToast(`工作流加载失败:${result.message}`, "error");
|
| 533 |
}
|
| 534 |
} catch(err) {
|
|
|
|
| 544 |
});
|
| 545 |
if (!res.ok) {
|
| 546 |
const errText = await res.text().catch(() => '未知服务端错误');
|
| 547 |
+
inlineStatusBox.innerHTML = `<span style="color: #F44336;">❌ 加载失败 (${res.status}):${escapeHtml(errText)}</span>`;
|
| 548 |
return;
|
| 549 |
}
|
| 550 |
let data;
|
|
|
|
| 552 |
data = await res.json();
|
| 553 |
} catch (parseErr) {
|
| 554 |
inlineStatusBox.innerHTML = `<span style="color: #F44336;">❌ 响应解析失败,请重试</span>`;
|
| 555 |
+
showToast(t('common.parse_error'), "error");
|
| 556 |
return;
|
| 557 |
}
|
| 558 |
if (data.error) {
|
| 559 |
+
inlineStatusBox.innerHTML = `<span style="color: #F44336;">❌ 加载失败:${escapeHtml(data.error)}</span>`;
|
| 560 |
showToast(`工作流加载失败:${data.error}`, "error");
|
| 561 |
} else {
|
| 562 |
await _loadGraphToCanvas(data.data);
|
|
|
|
| 578 |
}
|
| 579 |
} catch(err2) {
|
| 580 |
console.error('❌ 应用下载失败:', err2);
|
| 581 |
+
inlineStatusBox.innerHTML = `<span style="color: #F44336;">❌ 无法连接到本地服务:${escapeHtml(err2.message || '网络错误')}</span>`;
|
| 582 |
}
|
| 583 |
}
|
| 584 |
} else {
|
前端页面/market/资源详情页面组件.js
CHANGED
|
@@ -15,7 +15,7 @@ import { openTipModal } from "../profile/个人中心_赞赏组件.js";
|
|
| 15 |
import { setupResourceInstall } from "./资源安装引擎.js";
|
| 16 |
import { renderTipBoardHTML } from "../components/打赏等级工具.js";
|
| 17 |
import { t } from "../components/用户体验增强.js";
|
| 18 |
-
import { removeCache } from "../components/性能优化工具.js";
|
| 19 |
import { invalidateRelatedCache } from "../core/网络请求API.js";
|
| 20 |
import { showToast } from "../components/UI交互提示组件.js";
|
| 21 |
// 🧹 P2归一:escapeHtml 局部副本已移除,改用统一版(转义更严格,防XSS语义不变)
|
|
@@ -101,7 +101,7 @@ function _showConfirmOverlay({ title, titleColor, infoHtml, warningHtml, warning
|
|
| 101 |
overlay.innerHTML = `
|
| 102 |
<div style="background: #1e2233; border-radius: 12px; padding: 25px; max-width: 420px; width: 90%; color: #fff; box-shadow: 0 8px 32px rgba(0,0,0,0.5);">
|
| 103 |
<div style="font-size: 18px; font-weight: bold; margin-bottom: 20px; color: ${titleColor}; display: flex; align-items: center; gap: 10px;">
|
| 104 |
-
⚠️ ${title}
|
| 105 |
</div>
|
| 106 |
|
| 107 |
<div style="background: #2a2d3e; border-radius: 8px; padding: 15px; margin-bottom: 15px;">
|
|
@@ -119,7 +119,7 @@ function _showConfirmOverlay({ title, titleColor, infoHtml, warningHtml, warning
|
|
| 119 |
${t('common.cancel')}
|
| 120 |
</button>
|
| 121 |
<button id="btn-confirm-action" style="background: ${confirmColor}; font-weight: bold; ${_btnBaseStyle}">
|
| 122 |
-
${confirmText}
|
| 123 |
</button>
|
| 124 |
</div>
|
| 125 |
</div>
|
|
@@ -212,8 +212,10 @@ function burnLocalFiles(itemId) {
|
|
| 212 |
removeCache(`ItemOwnership_${itemId}`);
|
| 213 |
|
| 214 |
// 清除列表缓存(强制下次刷新)
|
| 215 |
-
|
| 216 |
-
|
|
|
|
|
|
|
| 217 |
|
| 218 |
// 清除工具/应用/推荐列表缓存(使用标准 ListCache 格式)
|
| 219 |
const tabs = ['tools', 'apps', 'recommends'];
|
|
@@ -328,7 +330,7 @@ export function createItemDetailView(itemData, currentUser) {
|
|
| 328 |
let authorInfoHtml = `
|
| 329 |
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px;">
|
| 330 |
<div>
|
| 331 |
-
<strong>${t('item.author')}:</strong> <span id="detail-author-name">${authorName}</span>
|
| 332 |
<!-- 原创标识 -->
|
| 333 |
${itemData.is_original ? `
|
| 334 |
<div style="display: inline-flex; align-items: center; gap: 6px; padding: 4px 12px;
|
|
|
|
| 15 |
import { setupResourceInstall } from "./资源安装引擎.js";
|
| 16 |
import { renderTipBoardHTML } from "../components/打赏等级工具.js";
|
| 17 |
import { t } from "../components/用户体验增强.js";
|
| 18 |
+
import { removeCache, removeCacheByPrefix } from "../components/性能优化工具.js";
|
| 19 |
import { invalidateRelatedCache } from "../core/网络请求API.js";
|
| 20 |
import { showToast } from "../components/UI交互提示组件.js";
|
| 21 |
// 🧹 P2归一:escapeHtml 局部副本已移除,改用统一版(转义更严格,防XSS语义不变)
|
|
|
|
| 101 |
overlay.innerHTML = `
|
| 102 |
<div style="background: #1e2233; border-radius: 12px; padding: 25px; max-width: 420px; width: 90%; color: #fff; box-shadow: 0 8px 32px rgba(0,0,0,0.5);">
|
| 103 |
<div style="font-size: 18px; font-weight: bold; margin-bottom: 20px; color: ${titleColor}; display: flex; align-items: center; gap: 10px;">
|
| 104 |
+
⚠️ ${escapeHtml(title)}
|
| 105 |
</div>
|
| 106 |
|
| 107 |
<div style="background: #2a2d3e; border-radius: 8px; padding: 15px; margin-bottom: 15px;">
|
|
|
|
| 119 |
${t('common.cancel')}
|
| 120 |
</button>
|
| 121 |
<button id="btn-confirm-action" style="background: ${confirmColor}; font-weight: bold; ${_btnBaseStyle}">
|
| 122 |
+
${escapeHtml(confirmText)}
|
| 123 |
</button>
|
| 124 |
</div>
|
| 125 |
</div>
|
|
|
|
| 212 |
removeCache(`ItemOwnership_${itemId}`);
|
| 213 |
|
| 214 |
// 清除列表缓存(强制下次刷新)
|
| 215 |
+
// 🔧 修复:API 层缓存键带完整查询串,removeCache 精确删除命中不到(no-op),
|
| 216 |
+
// 购买/下载后返回列表仍会看到旧数据,改用前缀删除
|
| 217 |
+
removeCacheByPrefix('api_/api/items');
|
| 218 |
+
removeCacheByPrefix('api_/api/creators');
|
| 219 |
|
| 220 |
// 清除工具/应用/推荐列表缓存(使用标准 ListCache 格式)
|
| 221 |
const tabs = ['tools', 'apps', 'recommends'];
|
|
|
|
| 330 |
let authorInfoHtml = `
|
| 331 |
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px;">
|
| 332 |
<div>
|
| 333 |
+
<strong>${t('item.author')}:</strong> <span id="detail-author-name">${escapeHtml(authorName)}</span>
|
| 334 |
<!-- 原创标识 -->
|
| 335 |
${itemData.is_original ? `
|
| 336 |
<div style="display: inline-flex; align-items: center; gap: 6px; padding: 4px 12px;
|
前端页面/market/资金与钱包_充值组件.js
CHANGED
|
@@ -149,7 +149,7 @@ export async function openRechargeModal(currentUser, onBalanceChange) {
|
|
| 149 |
return showToast(t('wallet.recharge.invalid_amount'), "warning");
|
| 150 |
}
|
| 151 |
if (customVal > 10000) {
|
| 152 |
-
return showToast(t('wallet.recharge.max_amount_exceeded')
|
| 153 |
}
|
| 154 |
finalPoints = customVal;
|
| 155 |
finalPrice = customVal;
|
|
@@ -167,7 +167,7 @@ export async function openRechargeModal(currentUser, onBalanceChange) {
|
|
| 167 |
});
|
| 168 |
if (!confirmed) return;
|
| 169 |
|
| 170 |
-
_setButtonLoading(btnCreateOrder, true, t('wallet.recharge.processing')
|
| 171 |
qrContainer.style.display = "block";
|
| 172 |
qrLoading.style.display = "block";
|
| 173 |
qrImage.style.display = "none";
|
|
@@ -201,7 +201,7 @@ qrImage.src = `https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${e
|
|
| 201 |
if (pollCount >= MAX_POLL_COUNT) {
|
| 202 |
clearInterval(pollingInterval);
|
| 203 |
pollingInterval = null;
|
| 204 |
-
showToast(t('wallet.recharge.timeout')
|
| 205 |
return;
|
| 206 |
}
|
| 207 |
try {
|
|
@@ -244,7 +244,7 @@ qrImage.src = `https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${e
|
|
| 244 |
qrLoading.innerText = t('wallet.recharge.order_failed') + error.message;
|
| 245 |
qrLoading.style.color = "#F44336";
|
| 246 |
} finally {
|
| 247 |
-
_setButtonLoading(btnCreateOrder, false, t('wallet.recharge.processing')
|
| 248 |
}
|
| 249 |
};
|
| 250 |
|
|
|
|
| 149 |
return showToast(t('wallet.recharge.invalid_amount'), "warning");
|
| 150 |
}
|
| 151 |
if (customVal > 10000) {
|
| 152 |
+
return showToast(t('wallet.recharge.max_amount_exceeded'), "warning");
|
| 153 |
}
|
| 154 |
finalPoints = customVal;
|
| 155 |
finalPrice = customVal;
|
|
|
|
| 167 |
});
|
| 168 |
if (!confirmed) return;
|
| 169 |
|
| 170 |
+
_setButtonLoading(btnCreateOrder, true, t('wallet.recharge.processing'), t('wallet.recharge.get_qr'));
|
| 171 |
qrContainer.style.display = "block";
|
| 172 |
qrLoading.style.display = "block";
|
| 173 |
qrImage.style.display = "none";
|
|
|
|
| 201 |
if (pollCount >= MAX_POLL_COUNT) {
|
| 202 |
clearInterval(pollingInterval);
|
| 203 |
pollingInterval = null;
|
| 204 |
+
showToast(t('wallet.recharge.timeout'), "warning");
|
| 205 |
return;
|
| 206 |
}
|
| 207 |
try {
|
|
|
|
| 244 |
qrLoading.innerText = t('wallet.recharge.order_failed') + error.message;
|
| 245 |
qrLoading.style.color = "#F44336";
|
| 246 |
} finally {
|
| 247 |
+
_setButtonLoading(btnCreateOrder, false, t('wallet.recharge.processing'), t('wallet.recharge.get_qr'));
|
| 248 |
}
|
| 249 |
};
|
| 250 |
|
前端页面/market/资金与钱包_提现组件.js
CHANGED
|
@@ -178,7 +178,7 @@ export async function openWithdrawModal(currentUser, onSuccess) {
|
|
| 178 |
const timeoutId = setTimeout(() => {
|
| 179 |
isSubmitting = false;
|
| 180 |
_setButtonState(btnSubmit, false, "#4CAF50", t('wallet.withdraw.confirm'));
|
| 181 |
-
showToast(t('wallet.withdraw.timeout')
|
| 182 |
}, 30000);
|
| 183 |
|
| 184 |
try {
|
|
|
|
| 178 |
const timeoutId = setTimeout(() => {
|
| 179 |
isSubmitting = false;
|
| 180 |
_setButtonState(btnSubmit, false, "#4CAF50", t('wallet.withdraw.confirm'));
|
| 181 |
+
showToast(t('wallet.withdraw.timeout'), "warning");
|
| 182 |
}, 30000);
|
| 183 |
|
| 184 |
try {
|
前端页面/post/发布帖子组件.js
CHANGED
|
@@ -11,12 +11,14 @@
|
|
| 11 |
import { api } from "../core/网络请求API.js";
|
| 12 |
import { showToast, showConfirm } from "../components/UI交互提示组件.js";
|
| 13 |
import { t } from "../components/用户体验增强.js";
|
| 14 |
-
import { removeCache } from "../components/性能优化工具.js";
|
| 15 |
-
import { API } from "../core/全局配置.js";
|
| 16 |
|
| 17 |
// 📦 清除帖子列表缓存
|
| 18 |
function clearPostListCache() {
|
| 19 |
-
|
|
|
|
|
|
|
| 20 |
// 🔧 P1修复:讨论区组件实际使用 PostsCache_${sort} 键(非 ListCache_*),按真实键格式精准清除
|
| 21 |
// 讨论区排序值:latest, likes, favorites, tips, views, daily_views, rating
|
| 22 |
const sorts = ['latest', 'likes', 'favorites', 'tips', 'views', 'daily_views', 'rating'];
|
|
@@ -372,14 +374,14 @@ export function createPublishPostView(currentUser, editPostData = null) {
|
|
| 372 |
<!-- 模式切换 -->
|
| 373 |
<div style="margin-bottom: 15px;">
|
| 374 |
<label style="display: block; font-size: 13px; font-weight: bold; color: #fff; margin-bottom: 8px;">
|
| 375 |
-
${t('post.mode_label')
|
| 376 |
</label>
|
| 377 |
<div style="display: flex; gap: 0; background: #1a1a1a; border: 1px solid #444; border-radius: 6px; overflow: hidden;">
|
| 378 |
<button id="mode-image" style="flex: 1; padding: 10px; background: #4CAF50; color: #fff; border: none; cursor: pointer; font-size: 13px; font-weight: bold; transition: 0.2s;">
|
| 379 |
-
📷 ${t('post.mode_image')
|
| 380 |
</button>
|
| 381 |
<button id="mode-video" style="flex: 1; padding: 10px; background: transparent; color: #888; border: none; cursor: pointer; font-size: 13px; font-weight: bold; transition: 0.2s; border-left: 1px solid #444;">
|
| 382 |
-
🎬 ${t('post.mode_video')
|
| 383 |
</button>
|
| 384 |
</div>
|
| 385 |
</div>
|
|
@@ -402,14 +404,14 @@ export function createPublishPostView(currentUser, editPostData = null) {
|
|
| 402 |
<!-- 视频上传区 -->
|
| 403 |
<div id="video-upload-section" style="margin-bottom: 20px; display: none;">
|
| 404 |
<label style="display: block; font-size: 13px; font-weight: bold; color: #fff; margin-bottom: 8px;">
|
| 405 |
-
🎬 ${t('post.upload_video')
|
| 406 |
<span style="font-weight: normal; color: #888; font-size: 12px;">(mp4/webm/mov,≤50MB,≤3分钟)</span>
|
| 407 |
</label>
|
| 408 |
<input type="file" id="video-input" accept="video/mp4,video/webm,video/quicktime" style="display: none;">
|
| 409 |
<div id="video-preview-area" style="padding: 15px; background: #1a1a1a; border: 2px dashed #444; border-radius: 8px; cursor: pointer; transition: 0.2s;">
|
| 410 |
<div id="video-empty-state" style="display: flex; flex-direction: column; align-items: center; justify-content: center; width: 100%; color: #666; font-size: 13px;">
|
| 411 |
<div style="font-size: 32px; margin-bottom: 8px;">🎬</div>
|
| 412 |
-
${t('post.click_upload_video')
|
| 413 |
</div>
|
| 414 |
<div id="video-player-wrap" style="display: none;">
|
| 415 |
<video id="video-player" controls style="width: 100%; max-height: 300px; border-radius: 8px; background: #000; display: block;"></video>
|
|
@@ -422,7 +424,7 @@ export function createPublishPostView(currentUser, editPostData = null) {
|
|
| 422 |
</div>
|
| 423 |
<!-- 封面区域 -->
|
| 424 |
<div id="video-cover-wrap" style="display: none; margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border-color, #333);">
|
| 425 |
-
<div style="font-size: 12px; font-weight: bold; color: #fff; margin-bottom: 8px;">${t('post.video_cover')
|
| 426 |
<div style="display: flex; align-items: center; gap: 12px;">
|
| 427 |
<div style="position: relative; width: 80px; height: 80px; flex-shrink: 0;">
|
| 428 |
<img id="cover-thumb" src="" style="width: 80px; height: 80px; object-fit: cover; border-radius: 6px; border: 2px solid #4CAF50; display: none;">
|
|
@@ -430,10 +432,10 @@ export function createPublishPostView(currentUser, editPostData = null) {
|
|
| 430 |
</div>
|
| 431 |
<div style="display: flex; flex-direction: column; gap: 6px;">
|
| 432 |
<button id="btn-capture-frame" style="padding: 6px 12px; background: #333; border: 1px solid #555; color: #ccc; border-radius: 4px; cursor: pointer; font-size: 12px; transition: 0.2s;" onmouseover="this.style.background='#444'" onmouseout="this.style.background='#333'">
|
| 433 |
-
📸 ${t('post.capture_frame')
|
| 434 |
</button>
|
| 435 |
<button id="btn-upload-cover" style="padding: 6px 12px; background: #333; border: 1px solid #555; color: #ccc; border-radius: 4px; cursor: pointer; font-size: 12px; transition: 0.2s;" onmouseover="this.style.background='#444'" onmouseout="this.style.background='#333'">
|
| 436 |
-
🖼️ ${t('post.upload_cover')
|
| 437 |
</button>
|
| 438 |
<input type="file" id="cover-input" accept="image/*" style="display: none;">
|
| 439 |
</div>
|
|
@@ -448,7 +450,7 @@ export function createPublishPostView(currentUser, editPostData = null) {
|
|
| 448 |
<label style="display: block; font-size: 13px; font-weight: bold; color: #fff; margin-bottom: 8px;">
|
| 449 |
📝 ${t('post.title_label')} <span style="color: #F44336;">*</span>
|
| 450 |
</label>
|
| 451 |
-
<input type="text" id="title-input" value="${editTitle}" placeholder="${t('post.title_placeholder')}" maxlength="50" style="width: 100%; padding: 12px; background: #1a1a1a; border: 1px solid #444; border-radius: 6px; color: #fff; font-size: 14px; box-sizing: border-box; outline: none;" onfocus="this.style.borderColor='#4CAF50'" onblur="this.style.borderColor='#444'">
|
| 452 |
<div style="text-align: right; font-size: 11px; color: #666; margin-top: 4px;">
|
| 453 |
<span id="title-count">${editTitle.length}</span>/50
|
| 454 |
</div>
|
|
@@ -459,7 +461,7 @@ export function createPublishPostView(currentUser, editPostData = null) {
|
|
| 459 |
<label style="display: block; font-size: 13px; font-weight: bold; color: #fff; margin-bottom: 8px;">
|
| 460 |
✍️ ${t('post.content_label')}
|
| 461 |
</label>
|
| 462 |
-
<textarea id="content-input" placeholder="${t('post.content_placeholder')}" maxlength="2000" style="width: 100%; height: 200px; padding: 12px; background: #1a1a1a; border: 1px solid #444; border-radius: 6px; color: #fff; font-size: 14px; box-sizing: border-box; outline: none; resize: none; line-height: 1.6;" onfocus="this.style.borderColor='#4CAF50'" onblur="this.style.borderColor='#444'">${editContent}</textarea>
|
| 463 |
<div style="text-align: right; font-size: 11px; color: #666; margin-top: 4px;">
|
| 464 |
<span id="content-count">${editContent.length}</span>/2000
|
| 465 |
</div>
|
|
@@ -684,7 +686,7 @@ export function createPublishPostView(currentUser, editPostData = null) {
|
|
| 684 |
Object.assign(wrapper.style, PREVIEW_WRAPPER_STYLE);
|
| 685 |
|
| 686 |
wrapper.innerHTML = `
|
| 687 |
-
<img src="${url}" style="width: 80px; height: 80px; object-fit: cover; border-radius: 6px; border: 2px solid ${idx === 0 ? '#4CAF50' : '#444'};">
|
| 688 |
${idx === 0 ? `<span class="cover-label" style="position: absolute; top: 4px; left: 4px; background: #4CAF50; color: #fff; font-size: 10px; padding: 2px 6px; border-radius: 3px;">${t('post.cover')}</span>` : ''}
|
| 689 |
<button data-action="remove" data-existing-idx="${idx}" style="position: absolute; top: -6px; right: -6px; width: 20px; height: 20px; border-radius: 50%; background: #F44336; color: #fff; border: none; cursor: pointer; font-size: 12px; line-height: 1;">×</button>
|
| 690 |
`;
|
|
@@ -792,14 +794,14 @@ export function createPublishPostView(currentUser, editPostData = null) {
|
|
| 792 |
const validExts = ['.mp4', '.webm', '.mov'];
|
| 793 |
const isValidType = validTypes.includes(file.type) || validExts.some(ext => file.name.toLowerCase().endsWith(ext));
|
| 794 |
if (!isValidType) {
|
| 795 |
-
_showVideoErrorAndReset(t('post.error_video_format')
|
| 796 |
return;
|
| 797 |
}
|
| 798 |
|
| 799 |
// 大小检查
|
| 800 |
const MAX_SIZE = 100 * 1024 * 1024;
|
| 801 |
if (file.size > MAX_SIZE) {
|
| 802 |
-
_showVideoErrorAndReset(t('post.error_video_size')
|
| 803 |
return;
|
| 804 |
}
|
| 805 |
|
|
@@ -812,7 +814,7 @@ export function createPublishPostView(currentUser, editPostData = null) {
|
|
| 812 |
tempVideo.onloadedmetadata = () => {
|
| 813 |
URL.revokeObjectURL(tempUrl);
|
| 814 |
if (tempVideo.duration > 180) {
|
| 815 |
-
_showVideoErrorAndReset(t('post.error_video_duration')
|
| 816 |
return;
|
| 817 |
}
|
| 818 |
|
|
@@ -826,7 +828,7 @@ export function createPublishPostView(currentUser, editPostData = null) {
|
|
| 826 |
|
| 827 |
tempVideo.onerror = () => {
|
| 828 |
URL.revokeObjectURL(tempUrl);
|
| 829 |
-
_showVideoErrorAndReset(t('post.error_video_load')
|
| 830 |
};
|
| 831 |
};
|
| 832 |
|
|
@@ -854,7 +856,7 @@ export function createPublishPostView(currentUser, editPostData = null) {
|
|
| 854 |
videoNameEl.textContent = videoFile.name;
|
| 855 |
videoMetaEl.textContent = `${formatFileSize(videoFile.size)} · ${Math.round(videoDuration)}秒`;
|
| 856 |
} else if (currentVideoUrl) {
|
| 857 |
-
videoNameEl.textContent = t('post.existing_video')
|
| 858 |
videoMetaEl.textContent = '';
|
| 859 |
}
|
| 860 |
|
|
@@ -930,7 +932,7 @@ export function createPublishPostView(currentUser, editPostData = null) {
|
|
| 930 |
btnCaptureFrame.onclick = async (e) => {
|
| 931 |
e.stopPropagation();
|
| 932 |
if (!videoPlayer.videoWidth) {
|
| 933 |
-
showToast(t('post.error_video_not_ready')
|
| 934 |
return;
|
| 935 |
}
|
| 936 |
const cover = await generateCoverFromVideo(videoPlayer);
|
|
@@ -938,9 +940,9 @@ export function createPublishPostView(currentUser, editPostData = null) {
|
|
| 938 |
videoCoverFile = cover;
|
| 939 |
currentCoverUrl = '';
|
| 940 |
renderCoverThumb();
|
| 941 |
-
showToast(t('post.cover_captured')
|
| 942 |
} else {
|
| 943 |
-
showToast(t('post.cover_capture_failed')
|
| 944 |
}
|
| 945 |
};
|
| 946 |
|
|
@@ -954,7 +956,7 @@ export function createPublishPostView(currentUser, editPostData = null) {
|
|
| 954 |
const file = e.target.files?.[0];
|
| 955 |
if (!file) return;
|
| 956 |
if (!file.type.startsWith('image/')) {
|
| 957 |
-
_showVideoErrorAndReset(t('post.error_cover_format')
|
| 958 |
return;
|
| 959 |
}
|
| 960 |
videoCoverFile = file;
|
|
@@ -984,15 +986,15 @@ export function createPublishPostView(currentUser, editPostData = null) {
|
|
| 984 |
return;
|
| 985 |
}
|
| 986 |
if (!content) {
|
| 987 |
-
showToast(t('post.error_no_content')
|
| 988 |
return;
|
| 989 |
}
|
| 990 |
if (!videoFile && !currentVideoUrl) {
|
| 991 |
-
showToast(t('post.error_no_video')
|
| 992 |
return;
|
| 993 |
}
|
| 994 |
if (!videoCoverFile && !currentCoverUrl) {
|
| 995 |
-
showToast(t('post.error_no_cover')
|
| 996 |
return;
|
| 997 |
}
|
| 998 |
|
|
@@ -1002,7 +1004,7 @@ export function createPublishPostView(currentUser, editPostData = null) {
|
|
| 1002 |
// 上传封面
|
| 1003 |
let coverUrl = currentCoverUrl;
|
| 1004 |
if (videoCoverFile) {
|
| 1005 |
-
submitBtn.textContent = `⏳ ${t('post.uploading_cover')
|
| 1006 |
const res = await api.uploadFile(videoCoverFile, "post");
|
| 1007 |
coverUrl = res.url;
|
| 1008 |
}
|
|
@@ -1010,9 +1012,9 @@ export function createPublishPostView(currentUser, editPostData = null) {
|
|
| 1010 |
// 上传视频(带进度的 XMLHttpRequest)
|
| 1011 |
let videoUrl = currentVideoUrl;
|
| 1012 |
if (videoFile) {
|
| 1013 |
-
submitBtn.textContent = `⏳ ${t('post.uploading_video')
|
| 1014 |
const res = await uploadVideoWithProgress(videoFile, (percent) => {
|
| 1015 |
-
submitBtn.textContent = `⏳ ${t('post.uploading_video')
|
| 1016 |
});
|
| 1017 |
videoUrl = res.url;
|
| 1018 |
}
|
|
|
|
| 11 |
import { api } from "../core/网络请求API.js";
|
| 12 |
import { showToast, showConfirm } from "../components/UI交互提示组件.js";
|
| 13 |
import { t } from "../components/用户体验增强.js";
|
| 14 |
+
import { removeCache, removeCacheByPrefix } from "../components/性能优化工具.js";
|
| 15 |
+
import { API, escapeHtml } from "../core/全局配置.js";
|
| 16 |
|
| 17 |
// 📦 清除帖子列表缓存
|
| 18 |
function clearPostListCache() {
|
| 19 |
+
// 🔧 修复:API 层缓存键带完整查询串(api_/api/posts?sort=..&limit=..),
|
| 20 |
+
// removeCache 精确删除命中不到(no-op),发帖后讨论区仍会从旧缓存返回,改用前缀删除
|
| 21 |
+
removeCacheByPrefix('api_/api/posts');
|
| 22 |
// 🔧 P1修复:讨论区组件实际使用 PostsCache_${sort} 键(非 ListCache_*),按真实键格式精准清除
|
| 23 |
// 讨论区排序值:latest, likes, favorites, tips, views, daily_views, rating
|
| 24 |
const sorts = ['latest', 'likes', 'favorites', 'tips', 'views', 'daily_views', 'rating'];
|
|
|
|
| 374 |
<!-- 模式切换 -->
|
| 375 |
<div style="margin-bottom: 15px;">
|
| 376 |
<label style="display: block; font-size: 13px; font-weight: bold; color: #fff; margin-bottom: 8px;">
|
| 377 |
+
${t('post.mode_label')}
|
| 378 |
</label>
|
| 379 |
<div style="display: flex; gap: 0; background: #1a1a1a; border: 1px solid #444; border-radius: 6px; overflow: hidden;">
|
| 380 |
<button id="mode-image" style="flex: 1; padding: 10px; background: #4CAF50; color: #fff; border: none; cursor: pointer; font-size: 13px; font-weight: bold; transition: 0.2s;">
|
| 381 |
+
📷 ${t('post.mode_image')}
|
| 382 |
</button>
|
| 383 |
<button id="mode-video" style="flex: 1; padding: 10px; background: transparent; color: #888; border: none; cursor: pointer; font-size: 13px; font-weight: bold; transition: 0.2s; border-left: 1px solid #444;">
|
| 384 |
+
🎬 ${t('post.mode_video')}
|
| 385 |
</button>
|
| 386 |
</div>
|
| 387 |
</div>
|
|
|
|
| 404 |
<!-- 视频上传区 -->
|
| 405 |
<div id="video-upload-section" style="margin-bottom: 20px; display: none;">
|
| 406 |
<label style="display: block; font-size: 13px; font-weight: bold; color: #fff; margin-bottom: 8px;">
|
| 407 |
+
🎬 ${t('post.upload_video')} <span style="color: #F44336;">*</span>
|
| 408 |
<span style="font-weight: normal; color: #888; font-size: 12px;">(mp4/webm/mov,≤50MB,≤3分钟)</span>
|
| 409 |
</label>
|
| 410 |
<input type="file" id="video-input" accept="video/mp4,video/webm,video/quicktime" style="display: none;">
|
| 411 |
<div id="video-preview-area" style="padding: 15px; background: #1a1a1a; border: 2px dashed #444; border-radius: 8px; cursor: pointer; transition: 0.2s;">
|
| 412 |
<div id="video-empty-state" style="display: flex; flex-direction: column; align-items: center; justify-content: center; width: 100%; color: #666; font-size: 13px;">
|
| 413 |
<div style="font-size: 32px; margin-bottom: 8px;">🎬</div>
|
| 414 |
+
${t('post.click_upload_video')}
|
| 415 |
</div>
|
| 416 |
<div id="video-player-wrap" style="display: none;">
|
| 417 |
<video id="video-player" controls style="width: 100%; max-height: 300px; border-radius: 8px; background: #000; display: block;"></video>
|
|
|
|
| 424 |
</div>
|
| 425 |
<!-- 封面区域 -->
|
| 426 |
<div id="video-cover-wrap" style="display: none; margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border-color, #333);">
|
| 427 |
+
<div style="font-size: 12px; font-weight: bold; color: #fff; margin-bottom: 8px;">${t('post.video_cover')}</div>
|
| 428 |
<div style="display: flex; align-items: center; gap: 12px;">
|
| 429 |
<div style="position: relative; width: 80px; height: 80px; flex-shrink: 0;">
|
| 430 |
<img id="cover-thumb" src="" style="width: 80px; height: 80px; object-fit: cover; border-radius: 6px; border: 2px solid #4CAF50; display: none;">
|
|
|
|
| 432 |
</div>
|
| 433 |
<div style="display: flex; flex-direction: column; gap: 6px;">
|
| 434 |
<button id="btn-capture-frame" style="padding: 6px 12px; background: #333; border: 1px solid #555; color: #ccc; border-radius: 4px; cursor: pointer; font-size: 12px; transition: 0.2s;" onmouseover="this.style.background='#444'" onmouseout="this.style.background='#333'">
|
| 435 |
+
📸 ${t('post.capture_frame')}
|
| 436 |
</button>
|
| 437 |
<button id="btn-upload-cover" style="padding: 6px 12px; background: #333; border: 1px solid #555; color: #ccc; border-radius: 4px; cursor: pointer; font-size: 12px; transition: 0.2s;" onmouseover="this.style.background='#444'" onmouseout="this.style.background='#333'">
|
| 438 |
+
🖼️ ${t('post.upload_cover')}
|
| 439 |
</button>
|
| 440 |
<input type="file" id="cover-input" accept="image/*" style="display: none;">
|
| 441 |
</div>
|
|
|
|
| 450 |
<label style="display: block; font-size: 13px; font-weight: bold; color: #fff; margin-bottom: 8px;">
|
| 451 |
📝 ${t('post.title_label')} <span style="color: #F44336;">*</span>
|
| 452 |
</label>
|
| 453 |
+
<input type="text" id="title-input" value="${escapeHtml(editTitle)}" placeholder="${t('post.title_placeholder')}" maxlength="50" style="width: 100%; padding: 12px; background: #1a1a1a; border: 1px solid #444; border-radius: 6px; color: #fff; font-size: 14px; box-sizing: border-box; outline: none;" onfocus="this.style.borderColor='#4CAF50'" onblur="this.style.borderColor='#444'">
|
| 454 |
<div style="text-align: right; font-size: 11px; color: #666; margin-top: 4px;">
|
| 455 |
<span id="title-count">${editTitle.length}</span>/50
|
| 456 |
</div>
|
|
|
|
| 461 |
<label style="display: block; font-size: 13px; font-weight: bold; color: #fff; margin-bottom: 8px;">
|
| 462 |
✍️ ${t('post.content_label')}
|
| 463 |
</label>
|
| 464 |
+
<textarea id="content-input" placeholder="${t('post.content_placeholder')}" maxlength="2000" style="width: 100%; height: 200px; padding: 12px; background: #1a1a1a; border: 1px solid #444; border-radius: 6px; color: #fff; font-size: 14px; box-sizing: border-box; outline: none; resize: none; line-height: 1.6;" onfocus="this.style.borderColor='#4CAF50'" onblur="this.style.borderColor='#444'">${escapeHtml(editContent)}</textarea>
|
| 465 |
<div style="text-align: right; font-size: 11px; color: #666; margin-top: 4px;">
|
| 466 |
<span id="content-count">${editContent.length}</span>/2000
|
| 467 |
</div>
|
|
|
|
| 686 |
Object.assign(wrapper.style, PREVIEW_WRAPPER_STYLE);
|
| 687 |
|
| 688 |
wrapper.innerHTML = `
|
| 689 |
+
<img src="${escapeHtml(url)}" style="width: 80px; height: 80px; object-fit: cover; border-radius: 6px; border: 2px solid ${idx === 0 ? '#4CAF50' : '#444'};">
|
| 690 |
${idx === 0 ? `<span class="cover-label" style="position: absolute; top: 4px; left: 4px; background: #4CAF50; color: #fff; font-size: 10px; padding: 2px 6px; border-radius: 3px;">${t('post.cover')}</span>` : ''}
|
| 691 |
<button data-action="remove" data-existing-idx="${idx}" style="position: absolute; top: -6px; right: -6px; width: 20px; height: 20px; border-radius: 50%; background: #F44336; color: #fff; border: none; cursor: pointer; font-size: 12px; line-height: 1;">×</button>
|
| 692 |
`;
|
|
|
|
| 794 |
const validExts = ['.mp4', '.webm', '.mov'];
|
| 795 |
const isValidType = validTypes.includes(file.type) || validExts.some(ext => file.name.toLowerCase().endsWith(ext));
|
| 796 |
if (!isValidType) {
|
| 797 |
+
_showVideoErrorAndReset(t('post.error_video_format'), videoInput);
|
| 798 |
return;
|
| 799 |
}
|
| 800 |
|
| 801 |
// 大小检查
|
| 802 |
const MAX_SIZE = 100 * 1024 * 1024;
|
| 803 |
if (file.size > MAX_SIZE) {
|
| 804 |
+
_showVideoErrorAndReset(t('post.error_video_size'), videoInput);
|
| 805 |
return;
|
| 806 |
}
|
| 807 |
|
|
|
|
| 814 |
tempVideo.onloadedmetadata = () => {
|
| 815 |
URL.revokeObjectURL(tempUrl);
|
| 816 |
if (tempVideo.duration > 180) {
|
| 817 |
+
_showVideoErrorAndReset(t('post.error_video_duration'), videoInput);
|
| 818 |
return;
|
| 819 |
}
|
| 820 |
|
|
|
|
| 828 |
|
| 829 |
tempVideo.onerror = () => {
|
| 830 |
URL.revokeObjectURL(tempUrl);
|
| 831 |
+
_showVideoErrorAndReset(t('post.error_video_load'), videoInput);
|
| 832 |
};
|
| 833 |
};
|
| 834 |
|
|
|
|
| 856 |
videoNameEl.textContent = videoFile.name;
|
| 857 |
videoMetaEl.textContent = `${formatFileSize(videoFile.size)} · ${Math.round(videoDuration)}秒`;
|
| 858 |
} else if (currentVideoUrl) {
|
| 859 |
+
videoNameEl.textContent = t('post.existing_video');
|
| 860 |
videoMetaEl.textContent = '';
|
| 861 |
}
|
| 862 |
|
|
|
|
| 932 |
btnCaptureFrame.onclick = async (e) => {
|
| 933 |
e.stopPropagation();
|
| 934 |
if (!videoPlayer.videoWidth) {
|
| 935 |
+
showToast(t('post.error_video_not_ready'), "warning");
|
| 936 |
return;
|
| 937 |
}
|
| 938 |
const cover = await generateCoverFromVideo(videoPlayer);
|
|
|
|
| 940 |
videoCoverFile = cover;
|
| 941 |
currentCoverUrl = '';
|
| 942 |
renderCoverThumb();
|
| 943 |
+
showToast(t('post.cover_captured'), "success");
|
| 944 |
} else {
|
| 945 |
+
showToast(t('post.cover_capture_failed'), "error");
|
| 946 |
}
|
| 947 |
};
|
| 948 |
|
|
|
|
| 956 |
const file = e.target.files?.[0];
|
| 957 |
if (!file) return;
|
| 958 |
if (!file.type.startsWith('image/')) {
|
| 959 |
+
_showVideoErrorAndReset(t('post.error_cover_format'), coverInput);
|
| 960 |
return;
|
| 961 |
}
|
| 962 |
videoCoverFile = file;
|
|
|
|
| 986 |
return;
|
| 987 |
}
|
| 988 |
if (!content) {
|
| 989 |
+
showToast(t('post.error_no_content'), "warning");
|
| 990 |
return;
|
| 991 |
}
|
| 992 |
if (!videoFile && !currentVideoUrl) {
|
| 993 |
+
showToast(t('post.error_no_video'), "warning");
|
| 994 |
return;
|
| 995 |
}
|
| 996 |
if (!videoCoverFile && !currentCoverUrl) {
|
| 997 |
+
showToast(t('post.error_no_cover'), "warning");
|
| 998 |
return;
|
| 999 |
}
|
| 1000 |
|
|
|
|
| 1004 |
// 上传封面
|
| 1005 |
let coverUrl = currentCoverUrl;
|
| 1006 |
if (videoCoverFile) {
|
| 1007 |
+
submitBtn.textContent = `⏳ ${t('post.uploading_cover')}`;
|
| 1008 |
const res = await api.uploadFile(videoCoverFile, "post");
|
| 1009 |
coverUrl = res.url;
|
| 1010 |
}
|
|
|
|
| 1012 |
// 上传视频(带进度的 XMLHttpRequest)
|
| 1013 |
let videoUrl = currentVideoUrl;
|
| 1014 |
if (videoFile) {
|
| 1015 |
+
submitBtn.textContent = `⏳ ${t('post.uploading_video')} 0%`;
|
| 1016 |
const res = await uploadVideoWithProgress(videoFile, (percent) => {
|
| 1017 |
+
submitBtn.textContent = `⏳ ${t('post.uploading_video')} ${percent}%`;
|
| 1018 |
});
|
| 1019 |
videoUrl = res.url;
|
| 1020 |
}
|
前端页面/post/帖子详情组件.js
CHANGED
|
@@ -31,11 +31,12 @@ import { createRatingStars } from "../social/评论与互动组件.js";
|
|
| 31 |
*/
|
| 32 |
function _generateAvatarHtml(avatar, name, size, extraStyle = '') {
|
| 33 |
const initial = (name || 'U')[0].toUpperCase();
|
|
|
|
| 34 |
if (avatar) {
|
| 35 |
-
return `<img class="swr-avatar" src="${avatar}" style="width: ${size}px; height: ${size}px; border-radius: 50%; object-fit: cover; ${extraStyle}">`;
|
| 36 |
}
|
| 37 |
const fontSize = Math.max(9, Math.round(size * 0.4));
|
| 38 |
-
return `<div class="swr-avatar" style="width: ${size}px; height: ${size}px; border-radius: 50%; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); display: flex; align-items: center; justify-content: center; color: white; font-size: ${fontSize}px; font-weight: bold;">${initial}</div>`;
|
| 39 |
}
|
| 40 |
|
| 41 |
/**
|
|
@@ -50,7 +51,7 @@ function _updateAvatarElement(avatarEl, profile, size, extraStyle = '') {
|
|
| 50 |
if (avatarEl.tagName === 'IMG') {
|
| 51 |
avatarEl.src = profile.avatar;
|
| 52 |
} else {
|
| 53 |
-
avatarEl.outerHTML = `<img class="swr-avatar" src="${profile.avatar}" style="width: ${size}px; height: ${size}px; border-radius: 50%; object-fit: cover; ${extraStyle}">`;
|
| 54 |
}
|
| 55 |
}
|
| 56 |
|
|
@@ -360,11 +361,9 @@ async function loadPostDetail(container, postId, currentUser) {
|
|
| 360 |
loadComments(contentArea, postId, currentUser);
|
| 361 |
|
| 362 |
if (fromCache) {
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
document.body.appendChild(toast);
|
| 367 |
-
setTimeout(() => toast.remove(), 3000);
|
| 368 |
} else {
|
| 369 |
// 👀 记录浏览量(fire-and-forget,不阻塞渲染)
|
| 370 |
recordPostView(contentArea, postId);
|
|
@@ -470,7 +469,7 @@ async function showTipDialog(post, currentUser, container) {
|
|
| 470 |
|
| 471 |
// 本地前置余额校验
|
| 472 |
if ((currentUser.balance || 0) < amount) {
|
| 473 |
-
showToast(t('wallet.insufficient_balance')
|
| 474 |
return;
|
| 475 |
}
|
| 476 |
|
|
|
|
| 31 |
*/
|
| 32 |
function _generateAvatarHtml(avatar, name, size, extraStyle = '') {
|
| 33 |
const initial = (name || 'U')[0].toUpperCase();
|
| 34 |
+
// 🔒 XSS防护:avatar / name 均来自他人可修改的资料(昵称首字母可能是 < ),写入 HTML 前必须转义
|
| 35 |
if (avatar) {
|
| 36 |
+
return `<img class="swr-avatar" src="${escapeHtml(avatar)}" style="width: ${size}px; height: ${size}px; border-radius: 50%; object-fit: cover; ${extraStyle}">`;
|
| 37 |
}
|
| 38 |
const fontSize = Math.max(9, Math.round(size * 0.4));
|
| 39 |
+
return `<div class="swr-avatar" style="width: ${size}px; height: ${size}px; border-radius: 50%; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); display: flex; align-items: center; justify-content: center; color: white; font-size: ${fontSize}px; font-weight: bold;">${escapeHtml(initial)}</div>`;
|
| 40 |
}
|
| 41 |
|
| 42 |
/**
|
|
|
|
| 51 |
if (avatarEl.tagName === 'IMG') {
|
| 52 |
avatarEl.src = profile.avatar;
|
| 53 |
} else {
|
| 54 |
+
avatarEl.outerHTML = `<img class="swr-avatar" src="${escapeHtml(profile.avatar)}" style="width: ${size}px; height: ${size}px; border-radius: 50%; object-fit: cover; ${extraStyle}">`;
|
| 55 |
}
|
| 56 |
}
|
| 57 |
|
|
|
|
| 361 |
loadComments(contentArea, postId, currentUser);
|
| 362 |
|
| 363 |
if (fromCache) {
|
| 364 |
+
// 🔧 改用统一提示组件 + 词典:原为手写 div(无动画、不排队、不避重叠)且文案硬编码中文;
|
| 365 |
+
// 原文「展示的是缓存的数据」与已有词条 feedback.cache_fallback 语义一致,直接复用避免重复建键
|
| 366 |
+
showToast(t('feedback.cache_fallback'), 'warning');
|
|
|
|
|
|
|
| 367 |
} else {
|
| 368 |
// 👀 记录浏览量(fire-and-forget,不阻塞渲染)
|
| 369 |
recordPostView(contentArea, postId);
|
|
|
|
| 469 |
|
| 470 |
// 本地前置余额校验
|
| 471 |
if ((currentUser.balance || 0) < amount) {
|
| 472 |
+
showToast(t('wallet.insufficient_balance'), "warning");
|
| 473 |
return;
|
| 474 |
}
|
| 475 |
|
前端页面/post/讨论区组件.js
CHANGED
|
@@ -17,7 +17,7 @@
|
|
| 17 |
import { api } from "../core/网络请求API.js";
|
| 18 |
import { proxyImages } from "../core/网络请求API.js";
|
| 19 |
import { showToast } from "../components/UI交互提示组件.js";
|
| 20 |
-
import {
|
| 21 |
import { applyViewportAnimations } from "../components/动画音效引擎.js";
|
| 22 |
import { t } from "../components/用户体验增强.js";
|
| 23 |
import { getCachedProfile, getProfileWithSWR } from "../core/全局配置.js";
|
|
@@ -76,11 +76,12 @@ function _renderEmptyState(t) {
|
|
| 76 |
*/
|
| 77 |
function _generateAvatarHtml(avatar, name, size) {
|
| 78 |
const initial = (name || 'U')[0].toUpperCase();
|
|
|
|
| 79 |
if (avatar) {
|
| 80 |
-
return `<img class="swr-avatar" src="${avatar}" style="width: ${size}px; height: ${size}px; border-radius: 50%; object-fit: cover; border: 1px solid #444; background: #333;">`;
|
| 81 |
}
|
| 82 |
const fontSize = Math.max(9, Math.round(size * 0.5));
|
| 83 |
-
return `<div class="swr-avatar" style="width: ${size}px; height: ${size}px; border-radius: 50%; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); display: flex; align-items: center; justify-content: center; color: white; font-size: ${fontSize}px; font-weight: bold;">${initial}</div>`;
|
| 84 |
}
|
| 85 |
|
| 86 |
/**
|
|
@@ -312,8 +313,8 @@ export function createPostsView(currentUser, keyword = "") {
|
|
| 312 |
|
| 313 |
// ✅ 优先从本地缓存读取(含过期缓存:离线容灾,与插件榜/工作流/推荐榜策略一致)
|
| 314 |
if (!append && page === 1) {
|
| 315 |
-
const
|
| 316 |
-
if (
|
| 317 |
// 🚀 缓存数据也需要过一遍图片代理,确保新字段也被处理
|
| 318 |
allPostsData = proxyImages(cachedData);
|
| 319 |
renderPostsFromCache(allPostsData);
|
|
@@ -337,7 +338,7 @@ export function createPostsView(currentUser, keyword = "") {
|
|
| 337 |
// 缓存第一页数据
|
| 338 |
if (page === 1) {
|
| 339 |
allPostsData = proxyImages(posts);
|
| 340 |
-
|
| 341 |
} else {
|
| 342 |
allPostsData = [...allPostsData, ...proxyImages(posts)];
|
| 343 |
}
|
|
@@ -388,8 +389,8 @@ export function createPostsView(currentUser, keyword = "") {
|
|
| 388 |
isLoadingFromNetwork = false;
|
| 389 |
// 网络失败时尝试从缓存读取(包括过期缓存,离线容灾)
|
| 390 |
if (!append) {
|
| 391 |
-
const
|
| 392 |
-
if (
|
| 393 |
// 🚀 缓存数据也需要过一遍图片代理
|
| 394 |
allPostsData = proxyImages(cachedData);
|
| 395 |
renderPostsFromCache(allPostsData);
|
|
@@ -457,7 +458,7 @@ export function createPostsView(currentUser, keyword = "") {
|
|
| 457 |
|
| 458 |
// 更新缓存
|
| 459 |
const cacheKey = getCacheKey();
|
| 460 |
-
|
| 461 |
|
| 462 |
// 对比新旧数据,有变化时重新渲染
|
| 463 |
if (_postsDataChanged(allPostsData, posts)) {
|
|
|
|
| 17 |
import { api } from "../core/网络请求API.js";
|
| 18 |
import { proxyImages } from "../core/网络请求API.js";
|
| 19 |
import { showToast } from "../components/UI交互提示组件.js";
|
| 20 |
+
import { readListCache, writeListCache, createSkeleton, createPaginationLoader, lazyLoadImages } from "../components/性能优化工具.js";
|
| 21 |
import { applyViewportAnimations } from "../components/动画音效引擎.js";
|
| 22 |
import { t } from "../components/用户体验增强.js";
|
| 23 |
import { getCachedProfile, getProfileWithSWR } from "../core/全局配置.js";
|
|
|
|
| 76 |
*/
|
| 77 |
function _generateAvatarHtml(avatar, name, size) {
|
| 78 |
const initial = (name || 'U')[0].toUpperCase();
|
| 79 |
+
// 🔒 XSS防护:avatar / name 均来自他人可修改的资料(昵称首字母可能是 < ),写入 HTML 前必须转义
|
| 80 |
if (avatar) {
|
| 81 |
+
return `<img class="swr-avatar" src="${escapeHtml(avatar)}" style="width: ${size}px; height: ${size}px; border-radius: 50%; object-fit: cover; border: 1px solid #444; background: #333;">`;
|
| 82 |
}
|
| 83 |
const fontSize = Math.max(9, Math.round(size * 0.5));
|
| 84 |
+
return `<div class="swr-avatar" style="width: ${size}px; height: ${size}px; border-radius: 50%; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); display: flex; align-items: center; justify-content: center; color: white; font-size: ${fontSize}px; font-weight: bold;">${escapeHtml(initial)}</div>`;
|
| 85 |
}
|
| 86 |
|
| 87 |
/**
|
|
|
|
| 313 |
|
| 314 |
// ✅ 优先从本地缓存读取(含过期缓存:离线容灾,与插件榜/工作流/推荐榜策略一致)
|
| 315 |
if (!append && page === 1) {
|
| 316 |
+
const cachedData = readListCache(cacheKey);
|
| 317 |
+
if (cachedData) {
|
| 318 |
// 🚀 缓存数据也需要过一遍图片代理,确保新字段也被处理
|
| 319 |
allPostsData = proxyImages(cachedData);
|
| 320 |
renderPostsFromCache(allPostsData);
|
|
|
|
| 338 |
// 缓存第一页数据
|
| 339 |
if (page === 1) {
|
| 340 |
allPostsData = proxyImages(posts);
|
| 341 |
+
writeListCache(cacheKey, posts, getCacheTTL()); // 存原始数据,离线时经图片代理后可展示
|
| 342 |
} else {
|
| 343 |
allPostsData = [...allPostsData, ...proxyImages(posts)];
|
| 344 |
}
|
|
|
|
| 389 |
isLoadingFromNetwork = false;
|
| 390 |
// 网络失败时尝试从缓存读取(包括过期缓存,离线容灾)
|
| 391 |
if (!append) {
|
| 392 |
+
const cachedData = readListCache(cacheKey);
|
| 393 |
+
if (cachedData) {
|
| 394 |
// 🚀 缓存数据也需要过一遍图片代理
|
| 395 |
allPostsData = proxyImages(cachedData);
|
| 396 |
renderPostsFromCache(allPostsData);
|
|
|
|
| 458 |
|
| 459 |
// 更新缓存
|
| 460 |
const cacheKey = getCacheKey();
|
| 461 |
+
writeListCache(cacheKey, posts, getCacheTTL());
|
| 462 |
|
| 463 |
// 对比新旧数据,有变化时重新渲染
|
| 464 |
if (_postsDataChanged(allPostsData, posts)) {
|
前端页面/profile/个人中心_UI模板.js
CHANGED
|
@@ -54,8 +54,10 @@ export function buildProfileHTML(userData, isMe, isSettingsView, isFollowing, fo
|
|
| 54 |
}
|
| 55 |
}
|
| 56 |
|
|
|
|
|
|
|
| 57 |
const bannerStyle = bannerImageUrl
|
| 58 |
-
? `background-image: url(${bannerImageUrl}); background-size: cover; background-position: center;`
|
| 59 |
: `background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);`;
|
| 60 |
|
| 61 |
// 设置视图时返回简化的返回按钮(与个人资料页按钮样式一致,但不显示背景图)
|
|
@@ -114,7 +116,7 @@ export function buildProfileHTML(userData, isMe, isSettingsView, isFollowing, fo
|
|
| 114 |
|
| 115 |
<!-- 用户信息区域 -->
|
| 116 |
<div style="display: flex; align-items: flex-start; gap: 15px; position: relative;">
|
| 117 |
-
<img src="${userData.avatarDataUrl || userData.avatar || PLACEHOLDERS.AVATAR}" style="width: 80px; height: 80px; border-radius: 50%; border: 3px solid #4CAF50; object-fit: cover; box-shadow: 0 4px 12px rgba(0,0,0,0.4);">
|
| 118 |
<div style="flex: 1; padding-right: 150px;">
|
| 119 |
<div style="font-size: 20px; font-weight: bold; margin-bottom: 4px; display: flex; align-items: center; gap: 8px; text-shadow: 0 2px 4px rgba(0,0,0,0.6);">
|
| 120 |
${escapeHtml(userData.name)}
|
|
|
|
| 54 |
}
|
| 55 |
}
|
| 56 |
|
| 57 |
+
// 🔒 XSS防护:他人查看时 bannerUrl 直接来自云端(用户可自行填写),
|
| 58 |
+
// 写入 CSS url() 前剔除引号与括号,避免突破属性(与创作者卡片、个人设置表单同一写法)
|
| 59 |
const bannerStyle = bannerImageUrl
|
| 60 |
+
? `background-image: url(${String(bannerImageUrl).replace(/['"()\\]/g, '')}); background-size: cover; background-position: center;`
|
| 61 |
: `background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);`;
|
| 62 |
|
| 63 |
// 设置视图时返回简化的返回按钮(与个人资料页按钮样式一致,但不显示背景图)
|
|
|
|
| 116 |
|
| 117 |
<!-- 用户信息区域 -->
|
| 118 |
<div style="display: flex; align-items: flex-start; gap: 15px; position: relative;">
|
| 119 |
+
<img src="${escapeHtml(userData.avatarDataUrl || userData.avatar || PLACEHOLDERS.AVATAR)}" style="width: 80px; height: 80px; border-radius: 50%; border: 3px solid #4CAF50; object-fit: cover; box-shadow: 0 4px 12px rgba(0,0,0,0.4);">
|
| 120 |
<div style="flex: 1; padding-right: 150px;">
|
| 121 |
<div style="font-size: 20px; font-weight: bold; margin-bottom: 4px; display: flex; align-items: center; gap: 8px; text-shadow: 0 2px 4px rgba(0,0,0,0.6);">
|
| 122 |
${escapeHtml(userData.name)}
|
前端页面/profile/个人中心视图.js
CHANGED
|
@@ -159,7 +159,7 @@ export function showUserProfile(initialUserData, currentUser = null, isMe = true
|
|
| 159 |
if (isMe) tabs.push({ id: "collected", label: t('profile.my_collected') });
|
| 160 |
if (isMe || !privacy.likes) tabs.push({ id: "liked", label: t('profile.recent_likes') });
|
| 161 |
if (isMe || !privacy.follows) tabs.push({ id: "following", label: t('profile.following') });
|
| 162 |
-
if (isMe || !privacy.followers) tabs.push({ id: "followers", label: t('profile.followers')
|
| 163 |
|
| 164 |
if (!tabs.find(t => t.id === activeTab)) activeTab = "published";
|
| 165 |
|
|
|
|
| 159 |
if (isMe) tabs.push({ id: "collected", label: t('profile.my_collected') });
|
| 160 |
if (isMe || !privacy.likes) tabs.push({ id: "liked", label: t('profile.recent_likes') });
|
| 161 |
if (isMe || !privacy.follows) tabs.push({ id: "following", label: t('profile.following') });
|
| 162 |
+
if (isMe || !privacy.followers) tabs.push({ id: "followers", label: t('profile.followers') });
|
| 163 |
|
| 164 |
if (!tabs.find(t => t.id === activeTab)) activeTab = "published";
|
| 165 |
|
前端页面/profile/个人列表组件.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
// 前端页面/profile/个人列表组件.js
|
| 2 |
import { api } from "../core/网络请求API.js";
|
| 3 |
import { createItemCard } from "../market/列表卡片组件.js";
|
| 4 |
-
import { getAcquiredItems
|
| 5 |
import { showToast } from "../components/UI交互提示组件.js";
|
| 6 |
import { t, tIfExists, getLanguage } from "../components/用户体验增强.js";
|
| 7 |
import { PLACEHOLDERS, getCachedProfile, getProfileWithSWR } from "../core/全局配置.js";
|
|
@@ -28,11 +28,13 @@ function renderUserCard(account, currentUser, openOtherUserModalCb) {
|
|
| 28 |
const name = cached?.name || account;
|
| 29 |
const intro = cached?.intro || '';
|
| 30 |
|
|
|
|
|
|
|
| 31 |
card.innerHTML = `
|
| 32 |
-
<img id="user-avatar-${account}" src="${avatar}" style="width: 48px; height: 48px; border-radius: 50%; object-fit: cover; border: 2px solid #4CAF50; flex-shrink: 0;">
|
| 33 |
<div style="flex: 1; min-width: 0;">
|
| 34 |
-
<div id="user-name-${account}" style="font-weight: bold; color: #4CAF50; font-size: 14px; margin-bottom: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">@${name}</div>
|
| 35 |
-
<div id="user-intro-${account}" style="font-size: 12px; color: #888; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${intro || t('profile.no_intro')
|
| 36 |
</div>
|
| 37 |
<button style="padding: 6px 12px; background: #2196F3; border: none; border-radius: 4px; color: white; cursor: pointer; font-size: 12px; flex-shrink: 0;">${t('profile.homepage')}</button>
|
| 38 |
`;
|
|
@@ -50,7 +52,7 @@ function renderUserCard(account, currentUser, openOtherUserModalCb) {
|
|
| 50 |
nameDiv.textContent = `@${profile.name}`;
|
| 51 |
}
|
| 52 |
if (introDiv && profile.intro !== undefined) {
|
| 53 |
-
introDiv.textContent = profile.intro || t('profile.no_intro')
|
| 54 |
}
|
| 55 |
});
|
| 56 |
|
|
@@ -87,7 +89,7 @@ function renderPaginatedUserList(userList, containerDiv, currentUser, openOtherU
|
|
| 87 |
containerDiv.innerHTML = "";
|
| 88 |
|
| 89 |
if (userList.length === 0) {
|
| 90 |
-
containerDiv.innerHTML = `<div style='text-align:center; padding: 20px; color:#666;'>${emptyMessage}</div>`;
|
| 91 |
return;
|
| 92 |
}
|
| 93 |
|
|
@@ -110,7 +112,7 @@ function renderPaginatedUserList(userList, containerDiv, currentUser, openOtherU
|
|
| 110 |
// 如果有更多,显示"加载更多"按钮
|
| 111 |
if (displayedCount < userList.length) {
|
| 112 |
const loadMoreBtn = document.createElement("button");
|
| 113 |
-
loadMoreBtn.textContent = t('profile.load_more'
|
| 114 |
loadMoreBtn.style.cssText = "width: 100%; padding: 12px; background: var(--comfy-input-bg); border: 1px solid #555; border-radius: 6px; color: #4CAF50; cursor: pointer; font-size: 13px; margin-top: 8px; transition: all 0.2s;";
|
| 115 |
loadMoreBtn.onmouseover = () => { loadMoreBtn.style.background = "#3a3a3a"; };
|
| 116 |
loadMoreBtn.onmouseout = () => { loadMoreBtn.style.background = "var(--comfy-input-bg)"; };
|
|
@@ -142,13 +144,13 @@ export async function renderProfileListContent(tabId, domElement, userData, curr
|
|
| 142 |
|
| 143 |
// 👥 关注列表(使用 SWR 缓存技术)
|
| 144 |
if (tabId === "following") {
|
| 145 |
-
_renderUserListTab(userData.following || [], domElement, currentUser, openOtherUserModalCb, t('profile.no_following')
|
| 146 |
return;
|
| 147 |
}
|
| 148 |
|
| 149 |
// 👥 粉丝列表(使用 SWR 缓存技术)
|
| 150 |
if (tabId === "followers") {
|
| 151 |
-
_renderUserListTab(userData.followers || [], domElement, currentUser, openOtherUserModalCb, t('profile.no_followers')
|
| 152 |
return;
|
| 153 |
}
|
| 154 |
|
|
@@ -331,12 +333,14 @@ export async function renderProfileListContent(tabId, domElement, userData, curr
|
|
| 331 |
containerDiv.style.cssText = "display: flex; flex-direction: column; gap: 15px;";
|
| 332 |
|
| 333 |
// 💰 销售统计卡片(放在最上方,与打赏统计样式一致)
|
|
|
|
|
|
|
| 334 |
containerDiv.appendChild(_createStatsCard(
|
| 335 |
-
`💰 ${t('profile.sales_stats')
|
| 336 |
[
|
| 337 |
-
{ value: `+${salesStats.total_sales || 0}`, color: '#4CAF50', bgRgb: '76,175,80', label: t('profile.sales_income')
|
| 338 |
-
{ value: `-${salesStats.total_purchase || 0}`, color: '#F44336', bgRgb: '244,67,54', label: `${t('profile.purchase_expense')
|
| 339 |
-
{ value: `${(salesStats.net_sales || 0) > 0 ? '+' : ''}${salesStats.net_sales || 0}`, color: '#2196F3', bgRgb: '33,150,243', label: t('profile.net_sales')
|
| 340 |
],
|
| 341 |
' margin-bottom: 15px;'
|
| 342 |
));
|
|
@@ -353,11 +357,11 @@ export async function renderProfileListContent(tabId, domElement, userData, curr
|
|
| 353 |
|
| 354 |
// 🎁 打赏统计卡片
|
| 355 |
containerDiv.appendChild(_createStatsCard(
|
| 356 |
-
`🎁 ${t('profile.tip_stats')
|
| 357 |
[
|
| 358 |
-
{ value: `+${tipStats.total_tip_in || 0}`, color: '#4CAF50', bgRgb: '76,175,80', label: `${t('profile.tip_received')
|
| 359 |
-
{ value: `-${tipStats.total_tip_out || 0}`, color: '#F44336', bgRgb: '244,67,54', label: `${t('profile.tip_sent')
|
| 360 |
-
{ value: `${(tipStats.net_tips || 0) > 0 ? '+' : ''}${tipStats.net_tips || 0}`, color: '#2196F3', bgRgb: '33,150,243', label: t('profile.net_tips')
|
| 361 |
],
|
| 362 |
' margin-bottom: 15px;'
|
| 363 |
));
|
|
@@ -464,7 +468,7 @@ export async function renderProfileListContent(tabId, domElement, userData, curr
|
|
| 464 |
const applyDOM = (items, posts, prompts) => {
|
| 465 |
domElement.innerHTML = "";
|
| 466 |
if ((!items || items.length === 0) && (!posts || posts.length === 0) && (!prompts || prompts.length === 0)) {
|
| 467 |
-
domElement.innerHTML = _renderEmptyState('🔖', t('profile.no_collected')
|
| 468 |
return;
|
| 469 |
}
|
| 470 |
const listDiv = document.createElement("div");
|
|
@@ -473,7 +477,7 @@ export async function renderProfileListContent(tabId, domElement, userData, curr
|
|
| 473 |
if (items && items.length > 0) {
|
| 474 |
const sectionTitle = document.createElement("div");
|
| 475 |
sectionTitle.style.cssText = "font-size: 13px; font-weight: bold; color: #FF9800; margin-bottom: 5px; padding: 5px 0;";
|
| 476 |
-
sectionTitle.textContent = `📦 ${t('profile.collected_items')
|
| 477 |
listDiv.appendChild(sectionTitle);
|
| 478 |
items.forEach(item => { listDiv.appendChild(createItemCard(item, currentUser)); });
|
| 479 |
}
|
|
@@ -481,7 +485,7 @@ export async function renderProfileListContent(tabId, domElement, userData, curr
|
|
| 481 |
if (posts && posts.length > 0) {
|
| 482 |
const sectionTitle = document.createElement("div");
|
| 483 |
sectionTitle.style.cssText = "font-size: 13px; font-weight: bold; color: #9C27B0; margin-bottom: 5px; padding: 5px 0;" + (items && items.length > 0 ? " margin-top: 15px;" : "");
|
| 484 |
-
sectionTitle.textContent = `💬 ${t('profile.collected_posts')
|
| 485 |
listDiv.appendChild(sectionTitle);
|
| 486 |
posts.forEach(post => { listDiv.appendChild(createPostItem(post, currentUser)); });
|
| 487 |
}
|
|
@@ -489,7 +493,7 @@ export async function renderProfileListContent(tabId, domElement, userData, curr
|
|
| 489 |
if (prompts && prompts.length > 0) {
|
| 490 |
const sectionTitle = document.createElement("div");
|
| 491 |
sectionTitle.style.cssText = "font-size: 13px; font-weight: bold; color: #00BCD4; margin-bottom: 5px; padding: 5px 0;" + ((items && items.length > 0) || (posts && posts.length > 0) ? " margin-top: 15px;" : "");
|
| 492 |
-
sectionTitle.textContent = `🧩 ${t('profile.collected_prompts')
|
| 493 |
listDiv.appendChild(sectionTitle);
|
| 494 |
prompts.forEach(prompt => { listDiv.appendChild(createPromptItem(prompt, currentUser)); });
|
| 495 |
}
|
|
@@ -528,7 +532,7 @@ export async function renderProfileListContent(tabId, domElement, userData, curr
|
|
| 528 |
}
|
| 529 |
} catch (error) {
|
| 530 |
console.error("加载收藏失败:", error);
|
| 531 |
-
if (!cachedStr) domElement.innerHTML = `<div style='text-align:center; padding: 20px; color:#F44336;'>${t('profile.load_collected_failed')
|
| 532 |
}
|
| 533 |
return;
|
| 534 |
}
|
|
@@ -702,7 +706,7 @@ function createPostItem(post, currentUser) {
|
|
| 702 |
<div style="display: flex; align-items: flex-start; gap: 10px;">
|
| 703 |
${coverImageUrl ? `
|
| 704 |
<div style="position: relative; flex-shrink: 0;">
|
| 705 |
-
<img src="${coverImageUrl}" style="width: 60px; height: 60px; object-fit: cover; border-radius: 6px; display: block;">
|
| 706 |
${isVideo ? `
|
| 707 |
<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
|
| 708 |
width: 28px; height: 28px; background: rgba(0,0,0,0.6); border-radius: 50%;
|
|
@@ -721,7 +725,7 @@ function createPostItem(post, currentUser) {
|
|
| 721 |
<div style="display: flex; align-items: center; gap: 12px; font-size: 11px; color: #888;">
|
| 722 |
<span>❤️ ${post.like_count || 0}</span>
|
| 723 |
<span>💬 ${post.comment_count || 0}</span>
|
| 724 |
-
${isVideo ? `<span>🎬 ${t('post.video_tag')
|
| 725 |
<span style="margin-left: auto;">${timeStr}</span>
|
| 726 |
</div>
|
| 727 |
</div>
|
|
@@ -770,13 +774,13 @@ function createPromptItem(prompt, currentUser) {
|
|
| 770 |
// 价格徽标
|
| 771 |
const priceBadge = (prompt.price > 0)
|
| 772 |
? `<span style="background: rgba(255,193,7,0.15); color: #FFC107; padding: 1px 6px; border-radius: 4px; font-size: 10px;">💎 ${prompt.price}</span>`
|
| 773 |
-
: `<span style="background: rgba(76,175,80,0.15); color: #4CAF50; padding: 1px 6px; border-radius: 4px; font-size: 10px;">${t('prompt.free')
|
| 774 |
|
| 775 |
promptDiv.innerHTML = `
|
| 776 |
<div style="display: flex; align-items: flex-start; gap: 10px;">
|
| 777 |
${coverImageUrl ? `
|
| 778 |
<div style="position: relative; flex-shrink: 0;">
|
| 779 |
-
<img src="${coverImageUrl}" style="width: 60px; height: 60px; object-fit: cover; border-radius: 6px; display: block;">
|
| 780 |
${isVideo ? `
|
| 781 |
<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
|
| 782 |
width: 28px; height: 28px; background: rgba(0,0,0,0.6); border-radius: 50%;
|
|
@@ -865,9 +869,9 @@ function createTransactionCard(tx) {
|
|
| 865 |
}
|
| 866 |
// 状态标签
|
| 867 |
if (tx.withdraw_status === 'pending') {
|
| 868 |
-
detailLine = `⏳ ${t('tx.withdraw_pending')
|
| 869 |
} else if (tx.withdraw_status === 'completed') {
|
| 870 |
-
detailLine = `✅ ${t('tx.withdraw_completed')
|
| 871 |
}
|
| 872 |
break;
|
| 873 |
|
|
@@ -927,7 +931,7 @@ function createTransactionCard(tx) {
|
|
| 927 |
|
| 928 |
case 'RECHARGE':
|
| 929 |
// 充值
|
| 930 |
-
description = t('tx.recharge_desc')
|
| 931 |
break;
|
| 932 |
|
| 933 |
case 'REFUND':
|
|
@@ -937,7 +941,7 @@ function createTransactionCard(tx) {
|
|
| 937 |
|
| 938 |
case 'WITHDRAW_FEE':
|
| 939 |
// 提现手续费
|
| 940 |
-
description = t('tx.withdraw_fee_desc')
|
| 941 |
break;
|
| 942 |
|
| 943 |
default:
|
|
@@ -959,7 +963,7 @@ function createTransactionCard(tx) {
|
|
| 959 |
|
| 960 |
// 实到金额(仅提现类型)
|
| 961 |
const netAmountHtml = (tx.tx_type === 'WITHDRAW' && tx.net_amount !== null && tx.net_amount !== undefined)
|
| 962 |
-
? `<div style="font-size: 11px; color: #888; text-align: right;">${t('tx.net_amount')
|
| 963 |
: '';
|
| 964 |
|
| 965 |
if (hasDetailedInfo) {
|
|
@@ -973,7 +977,7 @@ function createTransactionCard(tx) {
|
|
| 973 |
${itemTitle}
|
| 974 |
</div>
|
| 975 |
${description ? `<div style="font-size: 11px; color: #aaa; margin-top: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${escapeHtml(description)}</div>` : ''}
|
| 976 |
-
${detailText ? `<div style="font-size: 11px; color: #888; margin-top: 3px;">${detailText}</div>` : ''}
|
| 977 |
</div>
|
| 978 |
</div>
|
| 979 |
<div style="text-align: right; flex-shrink: 0; margin-left: 10px;">
|
|
@@ -990,7 +994,7 @@ function createTransactionCard(tx) {
|
|
| 990 |
<span style="font-size: 20px;">${typeConfig.icon}</span>
|
| 991 |
<div>
|
| 992 |
<div style="font-weight: bold; color: #eee; font-size: 13px;">${typeConfig.title}</div>
|
| 993 |
-
<div style="font-size: 11px; color: #888;">${timeStr}${tx.related_account ? ' · ' + tx.related_account.substring(0, 8) + '...' : ''}</div>
|
| 994 |
</div>
|
| 995 |
</div>
|
| 996 |
<div style="font-weight: bold; font-size: 15px; color: ${amountColor};">
|
|
@@ -1007,20 +1011,20 @@ function createTransactionCard(tx) {
|
|
| 1007 |
*/
|
| 1008 |
function getTransactionTypeConfig(txType) {
|
| 1009 |
const configs = {
|
| 1010 |
-
'WITHDRAW': { icon: '💸', title: t('tx.withdraw')
|
| 1011 |
-
'TIP_IN': { icon: '🎁', title: t('tx.tip_in')
|
| 1012 |
-
'TIP_OUT': { icon: '🎁', title: t('tx.tip_out')
|
| 1013 |
-
'PURCHASE': { icon: '🛒', title: t('tx.purchase')
|
| 1014 |
-
'TASK_DEPOSIT': { icon: '📋', title: t('tx.task_deposit')
|
| 1015 |
-
'TASK_PAYMENT': { icon: '📋', title: t('tx.task_payment')
|
| 1016 |
-
'TASK_INCOME': { icon: '📋', title: t('tx.task_income')
|
| 1017 |
-
'TASK_FREEZE': { icon: '🔒', title: t('tx.task_freeze')
|
| 1018 |
-
'TASK_REFUND': { icon: '↩️', title: t('tx.task_refund')
|
| 1019 |
-
'TASK_CANCEL_REFUND': { icon: '🔄', title: t('tx.task_cancel_refund')
|
| 1020 |
-
'SALE': { icon: '💎', title: t('tx.sale')
|
| 1021 |
-
'RECHARGE': { icon: '💰', title: t('tx.recharge')
|
| 1022 |
-
'REFUND': { icon: '↩️', title: t('tx.refund')
|
| 1023 |
-
'WITHDRAW_FEE': { icon: '💸', title: t('tx.withdraw_fee')
|
| 1024 |
};
|
| 1025 |
|
| 1026 |
return configs[txType] || { icon: '💰', title: txType };
|
|
|
|
| 1 |
// 前端页面/profile/个人列表组件.js
|
| 2 |
import { api } from "../core/网络请求API.js";
|
| 3 |
import { createItemCard } from "../market/列表卡片组件.js";
|
| 4 |
+
import { getAcquiredItems } from "../market/资源安装引擎.js";
|
| 5 |
import { showToast } from "../components/UI交互提示组件.js";
|
| 6 |
import { t, tIfExists, getLanguage } from "../components/用户体验增强.js";
|
| 7 |
import { PLACEHOLDERS, getCachedProfile, getProfileWithSWR } from "../core/全局配置.js";
|
|
|
|
| 28 |
const name = cached?.name || account;
|
| 29 |
const intro = cached?.intro || '';
|
| 30 |
|
| 31 |
+
// 🔒 XSS防护:头像/昵称/简介均来自用户可修改的资料(下方 SWR 刷新路径用的是
|
| 32 |
+
// .src / .textContent,本身就是安全的,仅初次渲染的模板串需要转义)
|
| 33 |
card.innerHTML = `
|
| 34 |
+
<img id="user-avatar-${account}" src="${escapeHtml(avatar)}" style="width: 48px; height: 48px; border-radius: 50%; object-fit: cover; border: 2px solid #4CAF50; flex-shrink: 0;">
|
| 35 |
<div style="flex: 1; min-width: 0;">
|
| 36 |
+
<div id="user-name-${account}" style="font-weight: bold; color: #4CAF50; font-size: 14px; margin-bottom: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">@${escapeHtml(name)}</div>
|
| 37 |
+
<div id="user-intro-${account}" style="font-size: 12px; color: #888; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${escapeHtml(intro || t('profile.no_intro'))}</div>
|
| 38 |
</div>
|
| 39 |
<button style="padding: 6px 12px; background: #2196F3; border: none; border-radius: 4px; color: white; cursor: pointer; font-size: 12px; flex-shrink: 0;">${t('profile.homepage')}</button>
|
| 40 |
`;
|
|
|
|
| 52 |
nameDiv.textContent = `@${profile.name}`;
|
| 53 |
}
|
| 54 |
if (introDiv && profile.intro !== undefined) {
|
| 55 |
+
introDiv.textContent = profile.intro || t('profile.no_intro');
|
| 56 |
}
|
| 57 |
});
|
| 58 |
|
|
|
|
| 89 |
containerDiv.innerHTML = "";
|
| 90 |
|
| 91 |
if (userList.length === 0) {
|
| 92 |
+
containerDiv.innerHTML = `<div style='text-align:center; padding: 20px; color:#666;'>${escapeHtml(emptyMessage)}</div>`;
|
| 93 |
return;
|
| 94 |
}
|
| 95 |
|
|
|
|
| 112 |
// 如果有更多,显示"加载更多"按钮
|
| 113 |
if (displayedCount < userList.length) {
|
| 114 |
const loadMoreBtn = document.createElement("button");
|
| 115 |
+
loadMoreBtn.textContent = t('profile.load_more', { count: userList.length - displayedCount });
|
| 116 |
loadMoreBtn.style.cssText = "width: 100%; padding: 12px; background: var(--comfy-input-bg); border: 1px solid #555; border-radius: 6px; color: #4CAF50; cursor: pointer; font-size: 13px; margin-top: 8px; transition: all 0.2s;";
|
| 117 |
loadMoreBtn.onmouseover = () => { loadMoreBtn.style.background = "#3a3a3a"; };
|
| 118 |
loadMoreBtn.onmouseout = () => { loadMoreBtn.style.background = "var(--comfy-input-bg)"; };
|
|
|
|
| 144 |
|
| 145 |
// 👥 关注列表(使用 SWR 缓存技术)
|
| 146 |
if (tabId === "following") {
|
| 147 |
+
_renderUserListTab(userData.following || [], domElement, currentUser, openOtherUserModalCb, t('profile.no_following'));
|
| 148 |
return;
|
| 149 |
}
|
| 150 |
|
| 151 |
// 👥 粉丝列表(使用 SWR 缓存技术)
|
| 152 |
if (tabId === "followers") {
|
| 153 |
+
_renderUserListTab(userData.followers || [], domElement, currentUser, openOtherUserModalCb, t('profile.no_followers'));
|
| 154 |
return;
|
| 155 |
}
|
| 156 |
|
|
|
|
| 333 |
containerDiv.style.cssText = "display: flex; flex-direction: column; gap: 15px;";
|
| 334 |
|
| 335 |
// 💰 销售统计卡片(放在最上方,与打赏统计样式一致)
|
| 336 |
+
// 🔧 下方销售/打赏两处的 profile.transactions_unit 不再加 || '笔' 兜底:
|
| 337 |
+
// 该词条英文值刻意留空(中文 5笔 → 英文 5),兜底会把中文量词引回英文界面
|
| 338 |
containerDiv.appendChild(_createStatsCard(
|
| 339 |
+
`💰 ${t('profile.sales_stats')}`,
|
| 340 |
[
|
| 341 |
+
{ value: `+${salesStats.total_sales || 0}`, color: '#4CAF50', bgRgb: '76,175,80', label: t('profile.sales_income') },
|
| 342 |
+
{ value: `-${salesStats.total_purchase || 0}`, color: '#F44336', bgRgb: '244,67,54', label: `${t('profile.purchase_expense')} (${salesStats.purchase_count || 0}${t('profile.transactions_unit')})` },
|
| 343 |
+
{ value: `${(salesStats.net_sales || 0) > 0 ? '+' : ''}${salesStats.net_sales || 0}`, color: '#2196F3', bgRgb: '33,150,243', label: t('profile.net_sales') }
|
| 344 |
],
|
| 345 |
' margin-bottom: 15px;'
|
| 346 |
));
|
|
|
|
| 357 |
|
| 358 |
// 🎁 打赏统计卡片
|
| 359 |
containerDiv.appendChild(_createStatsCard(
|
| 360 |
+
`🎁 ${t('profile.tip_stats')}`,
|
| 361 |
[
|
| 362 |
+
{ value: `+${tipStats.total_tip_in || 0}`, color: '#4CAF50', bgRgb: '76,175,80', label: `${t('profile.tip_received')} (${tipStats.tip_in_count || 0}${t('profile.transactions_unit')})` },
|
| 363 |
+
{ value: `-${tipStats.total_tip_out || 0}`, color: '#F44336', bgRgb: '244,67,54', label: `${t('profile.tip_sent')} (${tipStats.tip_out_count || 0}${t('profile.transactions_unit')})` },
|
| 364 |
+
{ value: `${(tipStats.net_tips || 0) > 0 ? '+' : ''}${tipStats.net_tips || 0}`, color: '#2196F3', bgRgb: '33,150,243', label: t('profile.net_tips') }
|
| 365 |
],
|
| 366 |
' margin-bottom: 15px;'
|
| 367 |
));
|
|
|
|
| 468 |
const applyDOM = (items, posts, prompts) => {
|
| 469 |
domElement.innerHTML = "";
|
| 470 |
if ((!items || items.length === 0) && (!posts || posts.length === 0) && (!prompts || prompts.length === 0)) {
|
| 471 |
+
domElement.innerHTML = _renderEmptyState('🔖', t('profile.no_collected'), t('profile.go_discover'));
|
| 472 |
return;
|
| 473 |
}
|
| 474 |
const listDiv = document.createElement("div");
|
|
|
|
| 477 |
if (items && items.length > 0) {
|
| 478 |
const sectionTitle = document.createElement("div");
|
| 479 |
sectionTitle.style.cssText = "font-size: 13px; font-weight: bold; color: #FF9800; margin-bottom: 5px; padding: 5px 0;";
|
| 480 |
+
sectionTitle.textContent = `📦 ${t('profile.collected_items')} (${items.length})`;
|
| 481 |
listDiv.appendChild(sectionTitle);
|
| 482 |
items.forEach(item => { listDiv.appendChild(createItemCard(item, currentUser)); });
|
| 483 |
}
|
|
|
|
| 485 |
if (posts && posts.length > 0) {
|
| 486 |
const sectionTitle = document.createElement("div");
|
| 487 |
sectionTitle.style.cssText = "font-size: 13px; font-weight: bold; color: #9C27B0; margin-bottom: 5px; padding: 5px 0;" + (items && items.length > 0 ? " margin-top: 15px;" : "");
|
| 488 |
+
sectionTitle.textContent = `💬 ${t('profile.collected_posts')} (${posts.length})`;
|
| 489 |
listDiv.appendChild(sectionTitle);
|
| 490 |
posts.forEach(post => { listDiv.appendChild(createPostItem(post, currentUser)); });
|
| 491 |
}
|
|
|
|
| 493 |
if (prompts && prompts.length > 0) {
|
| 494 |
const sectionTitle = document.createElement("div");
|
| 495 |
sectionTitle.style.cssText = "font-size: 13px; font-weight: bold; color: #00BCD4; margin-bottom: 5px; padding: 5px 0;" + ((items && items.length > 0) || (posts && posts.length > 0) ? " margin-top: 15px;" : "");
|
| 496 |
+
sectionTitle.textContent = `🧩 ${t('profile.collected_prompts')} (${prompts.length})`;
|
| 497 |
listDiv.appendChild(sectionTitle);
|
| 498 |
prompts.forEach(prompt => { listDiv.appendChild(createPromptItem(prompt, currentUser)); });
|
| 499 |
}
|
|
|
|
| 532 |
}
|
| 533 |
} catch (error) {
|
| 534 |
console.error("加载收藏失败:", error);
|
| 535 |
+
if (!cachedStr) domElement.innerHTML = `<div style='text-align:center; padding: 20px; color:#F44336;'>${t('profile.load_collected_failed')}</div>`;
|
| 536 |
}
|
| 537 |
return;
|
| 538 |
}
|
|
|
|
| 706 |
<div style="display: flex; align-items: flex-start; gap: 10px;">
|
| 707 |
${coverImageUrl ? `
|
| 708 |
<div style="position: relative; flex-shrink: 0;">
|
| 709 |
+
<img src="${escapeHtml(coverImageUrl)}" style="width: 60px; height: 60px; object-fit: cover; border-radius: 6px; display: block;">
|
| 710 |
${isVideo ? `
|
| 711 |
<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
|
| 712 |
width: 28px; height: 28px; background: rgba(0,0,0,0.6); border-radius: 50%;
|
|
|
|
| 725 |
<div style="display: flex; align-items: center; gap: 12px; font-size: 11px; color: #888;">
|
| 726 |
<span>❤️ ${post.like_count || 0}</span>
|
| 727 |
<span>💬 ${post.comment_count || 0}</span>
|
| 728 |
+
${isVideo ? `<span>🎬 ${t('post.video_tag')}</span>` : ((post.images || []).length > 1 ? `<span>🖼️ ${(post.images || []).length}${t('post.images_unit')}</span>` : "")}
|
| 729 |
<span style="margin-left: auto;">${timeStr}</span>
|
| 730 |
</div>
|
| 731 |
</div>
|
|
|
|
| 774 |
// 价格徽标
|
| 775 |
const priceBadge = (prompt.price > 0)
|
| 776 |
? `<span style="background: rgba(255,193,7,0.15); color: #FFC107; padding: 1px 6px; border-radius: 4px; font-size: 10px;">💎 ${prompt.price}</span>`
|
| 777 |
+
: `<span style="background: rgba(76,175,80,0.15); color: #4CAF50; padding: 1px 6px; border-radius: 4px; font-size: 10px;">${t('prompt.free')}</span>`;
|
| 778 |
|
| 779 |
promptDiv.innerHTML = `
|
| 780 |
<div style="display: flex; align-items: flex-start; gap: 10px;">
|
| 781 |
${coverImageUrl ? `
|
| 782 |
<div style="position: relative; flex-shrink: 0;">
|
| 783 |
+
<img src="${escapeHtml(coverImageUrl)}" style="width: 60px; height: 60px; object-fit: cover; border-radius: 6px; display: block;">
|
| 784 |
${isVideo ? `
|
| 785 |
<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
|
| 786 |
width: 28px; height: 28px; background: rgba(0,0,0,0.6); border-radius: 50%;
|
|
|
|
| 869 |
}
|
| 870 |
// 状态标签
|
| 871 |
if (tx.withdraw_status === 'pending') {
|
| 872 |
+
detailLine = `⏳ ${t('tx.withdraw_pending')}`;
|
| 873 |
} else if (tx.withdraw_status === 'completed') {
|
| 874 |
+
detailLine = `✅ ${t('tx.withdraw_completed')}`;
|
| 875 |
}
|
| 876 |
break;
|
| 877 |
|
|
|
|
| 931 |
|
| 932 |
case 'RECHARGE':
|
| 933 |
// 充值
|
| 934 |
+
description = t('tx.recharge_desc');
|
| 935 |
break;
|
| 936 |
|
| 937 |
case 'REFUND':
|
|
|
|
| 941 |
|
| 942 |
case 'WITHDRAW_FEE':
|
| 943 |
// 提现手续费
|
| 944 |
+
description = t('tx.withdraw_fee_desc');
|
| 945 |
break;
|
| 946 |
|
| 947 |
default:
|
|
|
|
| 963 |
|
| 964 |
// 实到金额(仅提现类型)
|
| 965 |
const netAmountHtml = (tx.tx_type === 'WITHDRAW' && tx.net_amount !== null && tx.net_amount !== undefined)
|
| 966 |
+
? `<div style="font-size: 11px; color: #888; text-align: right;">${t('tx.net_amount')}: ${tx.net_amount}</div>`
|
| 967 |
: '';
|
| 968 |
|
| 969 |
if (hasDetailedInfo) {
|
|
|
|
| 977 |
${itemTitle}
|
| 978 |
</div>
|
| 979 |
${description ? `<div style="font-size: 11px; color: #aaa; margin-top: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${escapeHtml(description)}</div>` : ''}
|
| 980 |
+
${detailText ? `<div style="font-size: 11px; color: #888; margin-top: 3px;">${escapeHtml(detailText)}</div>` : ''}
|
| 981 |
</div>
|
| 982 |
</div>
|
| 983 |
<div style="text-align: right; flex-shrink: 0; margin-left: 10px;">
|
|
|
|
| 994 |
<span style="font-size: 20px;">${typeConfig.icon}</span>
|
| 995 |
<div>
|
| 996 |
<div style="font-weight: bold; color: #eee; font-size: 13px;">${typeConfig.title}</div>
|
| 997 |
+
<div style="font-size: 11px; color: #888;">${timeStr}${tx.related_account ? ' · ' + escapeHtml(tx.related_account.substring(0, 8)) + '...' : ''}</div>
|
| 998 |
</div>
|
| 999 |
</div>
|
| 1000 |
<div style="font-weight: bold; font-size: 15px; color: ${amountColor};">
|
|
|
|
| 1011 |
*/
|
| 1012 |
function getTransactionTypeConfig(txType) {
|
| 1013 |
const configs = {
|
| 1014 |
+
'WITHDRAW': { icon: '💸', title: t('tx.withdraw') },
|
| 1015 |
+
'TIP_IN': { icon: '🎁', title: t('tx.tip_in') },
|
| 1016 |
+
'TIP_OUT': { icon: '🎁', title: t('tx.tip_out') },
|
| 1017 |
+
'PURCHASE': { icon: '🛒', title: t('tx.purchase') },
|
| 1018 |
+
'TASK_DEPOSIT': { icon: '📋', title: t('tx.task_deposit') },
|
| 1019 |
+
'TASK_PAYMENT': { icon: '📋', title: t('tx.task_payment') },
|
| 1020 |
+
'TASK_INCOME': { icon: '📋', title: t('tx.task_income') },
|
| 1021 |
+
'TASK_FREEZE': { icon: '🔒', title: t('tx.task_freeze') },
|
| 1022 |
+
'TASK_REFUND': { icon: '↩️', title: t('tx.task_refund') },
|
| 1023 |
+
'TASK_CANCEL_REFUND': { icon: '🔄', title: t('tx.task_cancel_refund') },
|
| 1024 |
+
'SALE': { icon: '💎', title: t('tx.sale'), color: '#28a745' },
|
| 1025 |
+
'RECHARGE': { icon: '💰', title: t('tx.recharge') },
|
| 1026 |
+
'REFUND': { icon: '↩️', title: t('tx.refund') },
|
| 1027 |
+
'WITHDRAW_FEE': { icon: '💸', title: t('tx.withdraw_fee') }
|
| 1028 |
};
|
| 1029 |
|
| 1030 |
return configs[txType] || { icon: '💰', title: txType };
|
前端页面/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 } from "../core/全局配置.js";
|
| 8 |
import { t } from "../components/用户体验增强.js";
|
| 9 |
|
| 10 |
// 计算年龄工具函数
|
|
@@ -90,7 +90,7 @@ export function createSettingsForm(initialUserData, onCancelCallback, onSaveSucc
|
|
| 90 |
<div style="margin-bottom: 15px; padding-bottom: 10px; border-bottom: 1px solid #444; font-weight: bold; font-size: 16px;">⚙️ ${t('settings_form.title')}</div>
|
| 91 |
<div style="display: flex; flex-direction: column; gap: 10px; margin-bottom: 20px;">
|
| 92 |
<div style="display: flex; align-items: center; gap: 15px; margin-bottom: 5px; background: var(--comfy-input-bg); padding: 10px; border-radius: 6px; border: 1px dashed #555;">
|
| 93 |
-
<img id="setting-avatar-preview" src="${userData.avatarDataUrl || PLACEHOLDERS.AVATAR}" style="width: 60px; height: 60px; border-radius: 50%; object-fit: cover; border: 2px solid #666;">
|
| 94 |
<div>
|
| 95 |
<button id="btn-trigger-avatar" style="padding: 6px 12px; background: var(--comfy-input-bg); border: 1px solid #555; color: #fff; border-radius: 4px; cursor: pointer; font-size: 12px; margin-bottom: 5px;">${t('settings_form.change_avatar')}</button>
|
| 96 |
<div style="font-size: 11px; color: #888;">${t('settings_form.avatar_hint')}</div>
|
|
@@ -100,7 +100,7 @@ export function createSettingsForm(initialUserData, onCancelCallback, onSaveSucc
|
|
| 100 |
|
| 101 |
<div style="background: var(--comfy-input-bg); padding: 10px; border-radius: 6px; border: 1px dashed #555;">
|
| 102 |
<div style="font-size: 12px; color: #aaa; margin-bottom: 8px;">🇺️ ${t('settings_form.banner_title')}</div>
|
| 103 |
-
<div id="setting-banner-preview" style="width: 100%; height: 80px; border-radius: 4px; background: ${userData.bannerUrl ? `url(${userData.bannerUrl})` : '#1a1a1a'}; background-size: cover; background-position: center; margin-bottom: 8px; border: 1px solid #444;"></div>
|
| 104 |
<div style="display: flex; gap: 8px;">
|
| 105 |
<button id="btn-trigger-banner" style="flex: 1; padding: 6px 12px; background: var(--comfy-input-bg); border: 1px solid #555; color: #fff; border-radius: 4px; cursor: pointer; font-size: 12px;">📷 ${t('settings_form.banner_upload')}</button>
|
| 106 |
<button id="btn-clear-banner" style="padding: 6px 12px; background: transparent; border: 1px solid #555; color: #888; border-radius: 4px; cursor: pointer; font-size: 12px;">🗑️ ${t('settings_form.banner_clear')}</button>
|
|
@@ -120,7 +120,7 @@ export function createSettingsForm(initialUserData, onCancelCallback, onSaveSucc
|
|
| 120 |
<input type="file" id="setting-ui-bg" accept="image/*" style="display: none;">
|
| 121 |
</div>
|
| 122 |
|
| 123 |
-
<div><label style="display: block; margin-bottom: 5px; font-size: 12px; color: #aaa;">${t('settings_form.display_name')}</label><input type="text" id="setting-name" value="${userData.name || ''}" style="width: 100%; padding: 8px; background: var(--comfy-input-bg); border: 1px solid #555; color: #fff; border-radius: 4px; box-sizing: border-box;"></div>
|
| 124 |
|
| 125 |
<div style="display: flex; gap: 10px;">
|
| 126 |
<div style="flex: 1;"><label style="display: block; margin-bottom: 5px; font-size: 12px; color: #aaa;">${t('settings_form.gender')}</label>
|
|
@@ -133,7 +133,7 @@ export function createSettingsForm(initialUserData, onCancelCallback, onSaveSucc
|
|
| 133 |
<div style="flex: 1;">
|
| 134 |
<label style="display: block; margin-bottom: 5px; font-size: 12px; color: #aaa;">${t('settings_form.birthday')}</label>
|
| 135 |
<div style="display: flex; align-items: center; gap: 8px;">
|
| 136 |
-
<input type="date" id="setting-birthday" value="${userData.birthday || ''}" style="flex: 1; padding: 8px; background: var(--comfy-input-bg); border: 1px solid #555; color: #fff; border-radius: 4px; box-sizing: border-box;">
|
| 137 |
<span id="setting-age-display" style="color: #888; font-size: 12px; white-space: nowrap;">${userData.age ? userData.age + ' ' + t('profile.age_years') : ''}</span>
|
| 138 |
</div>
|
| 139 |
</div>
|
|
@@ -145,11 +145,11 @@ export function createSettingsForm(initialUserData, onCancelCallback, onSaveSucc
|
|
| 145 |
${countryOptions}
|
| 146 |
</select>
|
| 147 |
<select id="setting-region" style="width: 100%; padding: 8px; background: var(--comfy-input-bg); border: 1px solid #555; color: #fff; border-radius: 4px;">
|
| 148 |
-
<option value="${userData.region || ''}">${userData.region || t('settings_form.select_country_first')}</option>
|
| 149 |
</select>
|
| 150 |
</div>
|
| 151 |
|
| 152 |
-
<div><label style="display: block; margin-bottom: 5px; font-size: 12px; color: #aaa;">${t('settings_form.intro')}</label><textarea id="setting-intro" rows="3" placeholder="${t('settings_form.intro_placeholder')}" style="width: 100%; padding: 8px; background: var(--comfy-input-bg); border: 1px solid #555; color: #fff; border-radius: 4px; box-sizing: border-box; resize: vertical;">${userData.intro || ''}</textarea></div>
|
| 153 |
|
| 154 |
<div style="margin-top: 15px; border-top: 1px solid #444; padding-top: 15px;">
|
| 155 |
<div style="font-size: 14px; font-weight: bold; margin-bottom: 10px; color: #4CAF50;">🛡️ ${t('settings_form.privacy_title')}</div>
|
|
|
|
| 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 |
|
| 10 |
// 计算年龄工具函数
|
|
|
|
| 90 |
<div style="margin-bottom: 15px; padding-bottom: 10px; border-bottom: 1px solid #444; font-weight: bold; font-size: 16px;">⚙️ ${t('settings_form.title')}</div>
|
| 91 |
<div style="display: flex; flex-direction: column; gap: 10px; margin-bottom: 20px;">
|
| 92 |
<div style="display: flex; align-items: center; gap: 15px; margin-bottom: 5px; background: var(--comfy-input-bg); padding: 10px; border-radius: 6px; border: 1px dashed #555;">
|
| 93 |
+
<img id="setting-avatar-preview" src="${escapeHtml(userData.avatarDataUrl || PLACEHOLDERS.AVATAR)}" style="width: 60px; height: 60px; border-radius: 50%; object-fit: cover; border: 2px solid #666;">
|
| 94 |
<div>
|
| 95 |
<button id="btn-trigger-avatar" style="padding: 6px 12px; background: var(--comfy-input-bg); border: 1px solid #555; color: #fff; border-radius: 4px; cursor: pointer; font-size: 12px; margin-bottom: 5px;">${t('settings_form.change_avatar')}</button>
|
| 96 |
<div style="font-size: 11px; color: #888;">${t('settings_form.avatar_hint')}</div>
|
|
|
|
| 100 |
|
| 101 |
<div style="background: var(--comfy-input-bg); padding: 10px; border-radius: 6px; border: 1px dashed #555;">
|
| 102 |
<div style="font-size: 12px; color: #aaa; margin-bottom: 8px;">🇺️ ${t('settings_form.banner_title')}</div>
|
| 103 |
+
<div id="setting-banner-preview" style="width: 100%; height: 80px; border-radius: 4px; background: ${userData.bannerUrl ? `url(${String(userData.bannerUrl).replace(/['"()\\]/g, '')})` : '#1a1a1a'}; background-size: cover; background-position: center; margin-bottom: 8px; border: 1px solid #444;"></div>
|
| 104 |
<div style="display: flex; gap: 8px;">
|
| 105 |
<button id="btn-trigger-banner" style="flex: 1; padding: 6px 12px; background: var(--comfy-input-bg); border: 1px solid #555; color: #fff; border-radius: 4px; cursor: pointer; font-size: 12px;">📷 ${t('settings_form.banner_upload')}</button>
|
| 106 |
<button id="btn-clear-banner" style="padding: 6px 12px; background: transparent; border: 1px solid #555; color: #888; border-radius: 4px; cursor: pointer; font-size: 12px;">🗑️ ${t('settings_form.banner_clear')}</button>
|
|
|
|
| 120 |
<input type="file" id="setting-ui-bg" accept="image/*" style="display: none;">
|
| 121 |
</div>
|
| 122 |
|
| 123 |
+
<div><label style="display: block; margin-bottom: 5px; font-size: 12px; color: #aaa;">${t('settings_form.display_name')}</label><input type="text" id="setting-name" value="${escapeHtml(userData.name || '')}" style="width: 100%; padding: 8px; background: var(--comfy-input-bg); border: 1px solid #555; color: #fff; border-radius: 4px; box-sizing: border-box;"></div>
|
| 124 |
|
| 125 |
<div style="display: flex; gap: 10px;">
|
| 126 |
<div style="flex: 1;"><label style="display: block; margin-bottom: 5px; font-size: 12px; color: #aaa;">${t('settings_form.gender')}</label>
|
|
|
|
| 133 |
<div style="flex: 1;">
|
| 134 |
<label style="display: block; margin-bottom: 5px; font-size: 12px; color: #aaa;">${t('settings_form.birthday')}</label>
|
| 135 |
<div style="display: flex; align-items: center; gap: 8px;">
|
| 136 |
+
<input type="date" id="setting-birthday" value="${escapeHtml(userData.birthday || '')}" style="flex: 1; padding: 8px; background: var(--comfy-input-bg); border: 1px solid #555; color: #fff; border-radius: 4px; box-sizing: border-box;">
|
| 137 |
<span id="setting-age-display" style="color: #888; font-size: 12px; white-space: nowrap;">${userData.age ? userData.age + ' ' + t('profile.age_years') : ''}</span>
|
| 138 |
</div>
|
| 139 |
</div>
|
|
|
|
| 145 |
${countryOptions}
|
| 146 |
</select>
|
| 147 |
<select id="setting-region" style="width: 100%; padding: 8px; background: var(--comfy-input-bg); border: 1px solid #555; color: #fff; border-radius: 4px;">
|
| 148 |
+
<option value="${escapeHtml(userData.region || '')}">${escapeHtml(userData.region || t('settings_form.select_country_first'))}</option>
|
| 149 |
</select>
|
| 150 |
</div>
|
| 151 |
|
| 152 |
+
<div><label style="display: block; margin-bottom: 5px; font-size: 12px; color: #aaa;">${t('settings_form.intro')}</label><textarea id="setting-intro" rows="3" placeholder="${t('settings_form.intro_placeholder')}" style="width: 100%; padding: 8px; background: var(--comfy-input-bg); border: 1px solid #555; color: #fff; border-radius: 4px; box-sizing: border-box; resize: vertical;">${escapeHtml(userData.intro || '')}</textarea></div>
|
| 153 |
|
| 154 |
<div style="margin-top: 15px; border-top: 1px solid #444; padding-top: 15px;">
|
| 155 |
<div style="font-size: 14px; font-weight: bold; margin-bottom: 10px; color: #4CAF50;">🛡️ ${t('settings_form.privacy_title')}</div>
|
前端页面/prompt/发布提示词组件.js
CHANGED
|
@@ -13,21 +13,19 @@
|
|
| 13 |
import { api } from "../core/网络请求API.js";
|
| 14 |
import { showToast, showConfirm } from "../components/UI交互提示组件.js";
|
| 15 |
import { t, tIfExists } from "../components/用户体验增强.js";
|
| 16 |
-
import {
|
| 17 |
import { API } from "../core/全局配置.js";
|
| 18 |
import { escapeHtml } from "../components/互动工具函数.js"; // 🧹 P2归一:局部副本已移除
|
| 19 |
|
| 20 |
// 📦 清除提示词列表缓存(前缀扫描,覆盖全部 类型/分类/排序 组合)
|
| 21 |
function clearPromptsListCache() {
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
} catch (e) {}
|
| 30 |
-
removeCache('api_/api/prompts');
|
| 31 |
console.log('🗑️ 已清除提示词列表缓存');
|
| 32 |
}
|
| 33 |
|
|
|
|
| 13 |
import { api } from "../core/网络请求API.js";
|
| 14 |
import { showToast, showConfirm } from "../components/UI交互提示组件.js";
|
| 15 |
import { t, tIfExists } from "../components/用户体验增强.js";
|
| 16 |
+
import { removeCacheByPrefix } from "../components/性能优化工具.js";
|
| 17 |
import { API } from "../core/全局配置.js";
|
| 18 |
import { escapeHtml } from "../components/互动工具函数.js"; // 🧹 P2归一:局部副本已移除
|
| 19 |
|
| 20 |
// 📦 清除提示词列表缓存(前缀扫描,覆盖全部 类型/分类/排序 组合)
|
| 21 |
function clearPromptsListCache() {
|
| 22 |
+
// 🔧 改用缓存模块的前缀删除原语:原先手写的 localStorage 扫描 + removeCache 组合
|
| 23 |
+
// 与 removeCacheByPrefix 等价(同样内存 + localStorage 两级清除,且同样能匹配不带
|
| 24 |
+
// CACHE.PREFIX 的裸键),统一收口避免各发布组件各写一遍易漏清内存的遍历
|
| 25 |
+
removeCacheByPrefix('PromptsCache');
|
| 26 |
+
// 🔧 修复:API 层缓存键带完整查询串(api_/api/prompts?type=..&category=..&sort=..),
|
| 27 |
+
// 原先的 removeCache 精确删除命中不到(no-op),改用前缀删除
|
| 28 |
+
removeCacheByPrefix('api_/api/prompts');
|
|
|
|
|
|
|
| 29 |
console.log('🗑️ 已清除提示词列表缓存');
|
| 30 |
}
|
| 31 |
|
前端页面/prompt/提示词组件.js
CHANGED
|
@@ -16,7 +16,9 @@
|
|
| 16 |
|
| 17 |
import { api, proxyImages } from "../core/网络请求API.js";
|
| 18 |
import { showToast } from "../components/UI交互提示组件.js";
|
| 19 |
-
|
|
|
|
|
|
|
| 20 |
import { applyViewportAnimations } from "../components/动画音效引擎.js";
|
| 21 |
import { t, tIfExists } from "../components/用户体验增强.js";
|
| 22 |
import { getCachedProfile, getProfileWithSWR } from "../core/全局配置.js";
|
|
@@ -92,11 +94,12 @@ function _renderEmptyState() {
|
|
| 92 |
*/
|
| 93 |
function _generateAvatarHtml(avatar, name, size) {
|
| 94 |
const initial = (name || 'U')[0].toUpperCase();
|
|
|
|
| 95 |
if (avatar) {
|
| 96 |
-
return `<img class="swr-avatar" src="${avatar}" style="width: ${size}px; height: ${size}px; border-radius: 50%; object-fit: cover; border: 1px solid #444; background: #333;">`;
|
| 97 |
}
|
| 98 |
const fontSize = Math.max(9, Math.round(size * 0.5));
|
| 99 |
-
return `<div class="swr-avatar" style="width: ${size}px; height: ${size}px; border-radius: 50%; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); display: flex; align-items: center; justify-content: center; color: white; font-size: ${fontSize}px; font-weight: bold;">${initial}</div>`;
|
| 100 |
}
|
| 101 |
|
| 102 |
/**
|
|
@@ -437,8 +440,8 @@ export function createPromptsView(currentUser, keyword = "") {
|
|
| 437 |
|
| 438 |
// ✅ 优先从本地缓存读取(含过期缓存:离线容灾,与插件榜/工作流/推荐榜策略一致)
|
| 439 |
if (!append && page === 1) {
|
| 440 |
-
const
|
| 441 |
-
if (
|
| 442 |
allPromptsData = proxyImages(cachedData);
|
| 443 |
renderPromptsFromCache(allPromptsData);
|
| 444 |
// 后台静默更新
|
|
@@ -461,7 +464,7 @@ export function createPromptsView(currentUser, keyword = "") {
|
|
| 461 |
// 缓存第一页数据
|
| 462 |
if (page === 1) {
|
| 463 |
allPromptsData = proxyImages(prompts);
|
| 464 |
-
|
| 465 |
} else {
|
| 466 |
allPromptsData = [...allPromptsData, ...proxyImages(prompts)];
|
| 467 |
}
|
|
@@ -512,8 +515,8 @@ export function createPromptsView(currentUser, keyword = "") {
|
|
| 512 |
isLoadingFromNetwork = false;
|
| 513 |
// 网络失败时尝试从缓存读取(包括过期缓存,离线容灾)
|
| 514 |
if (!append) {
|
| 515 |
-
const
|
| 516 |
-
if (
|
| 517 |
allPromptsData = proxyImages(cachedData);
|
| 518 |
renderPromptsFromCache(allPromptsData);
|
| 519 |
showToast(t('prompt.network_cache'), "warning");
|
|
@@ -580,7 +583,7 @@ export function createPromptsView(currentUser, keyword = "") {
|
|
| 580 |
|
| 581 |
// 更新缓存
|
| 582 |
const cacheKey = getCacheKey();
|
| 583 |
-
|
| 584 |
|
| 585 |
// 对比新旧数据,有变化时重新渲染
|
| 586 |
if (_promptsDataChanged(allPromptsData, prompts)) {
|
|
|
|
| 16 |
|
| 17 |
import { api, proxyImages } from "../core/网络请求API.js";
|
| 18 |
import { showToast } from "../components/UI交互提示组件.js";
|
| 19 |
+
// 📦 列表缓存走 readListCache/writeListCache 统一口径;分类缓存形态不同(空数组也是
|
| 20 |
+
// 有效缓存态),仍用底层 setCache/getCacheWithMeta
|
| 21 |
+
import { setCache, getCacheWithMeta, readListCache, writeListCache, createPaginationLoader, lazyLoadImages } from "../components/性能优化工具.js";
|
| 22 |
import { applyViewportAnimations } from "../components/动画音效引擎.js";
|
| 23 |
import { t, tIfExists } from "../components/用户体验增强.js";
|
| 24 |
import { getCachedProfile, getProfileWithSWR } from "../core/全局配置.js";
|
|
|
|
| 94 |
*/
|
| 95 |
function _generateAvatarHtml(avatar, name, size) {
|
| 96 |
const initial = (name || 'U')[0].toUpperCase();
|
| 97 |
+
// 🔒 XSS防护:avatar / name 均来自他人可修改的资料(昵称首字母可能是 < ),写入 HTML 前必须转义
|
| 98 |
if (avatar) {
|
| 99 |
+
return `<img class="swr-avatar" src="${escapeHtml(avatar)}" style="width: ${size}px; height: ${size}px; border-radius: 50%; object-fit: cover; border: 1px solid #444; background: #333;">`;
|
| 100 |
}
|
| 101 |
const fontSize = Math.max(9, Math.round(size * 0.5));
|
| 102 |
+
return `<div class="swr-avatar" style="width: ${size}px; height: ${size}px; border-radius: 50%; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); display: flex; align-items: center; justify-content: center; color: white; font-size: ${fontSize}px; font-weight: bold;">${escapeHtml(initial)}</div>`;
|
| 103 |
}
|
| 104 |
|
| 105 |
/**
|
|
|
|
| 440 |
|
| 441 |
// ✅ 优先从本地缓存读取(含过期缓存:离线容灾,与插件榜/工作流/推荐榜策略一致)
|
| 442 |
if (!append && page === 1) {
|
| 443 |
+
const cachedData = readListCache(cacheKey);
|
| 444 |
+
if (cachedData) {
|
| 445 |
allPromptsData = proxyImages(cachedData);
|
| 446 |
renderPromptsFromCache(allPromptsData);
|
| 447 |
// 后台静默更新
|
|
|
|
| 464 |
// 缓存第一页数据
|
| 465 |
if (page === 1) {
|
| 466 |
allPromptsData = proxyImages(prompts);
|
| 467 |
+
writeListCache(cacheKey, prompts, getCacheTTL()); // 存原始数据,离线时经图片代理后可展示
|
| 468 |
} else {
|
| 469 |
allPromptsData = [...allPromptsData, ...proxyImages(prompts)];
|
| 470 |
}
|
|
|
|
| 515 |
isLoadingFromNetwork = false;
|
| 516 |
// 网络失败时尝试从缓存读取(包括过期缓存,离线容灾)
|
| 517 |
if (!append) {
|
| 518 |
+
const cachedData = readListCache(cacheKey);
|
| 519 |
+
if (cachedData) {
|
| 520 |
allPromptsData = proxyImages(cachedData);
|
| 521 |
renderPromptsFromCache(allPromptsData);
|
| 522 |
showToast(t('prompt.network_cache'), "warning");
|
|
|
|
| 583 |
|
| 584 |
// 更新缓存
|
| 585 |
const cacheKey = getCacheKey();
|
| 586 |
+
writeListCache(cacheKey, prompts, getCacheTTL());
|
| 587 |
|
| 588 |
// 对比新旧数据,有变化时重新渲染
|
| 589 |
if (_promptsDataChanged(allPromptsData, prompts)) {
|
前端页面/prompt/提示词详情组件.js
CHANGED
|
@@ -27,11 +27,12 @@ import { recordView, handleToggleLike, handleToggleFavorite, renderTipBoardHTML
|
|
| 27 |
*/
|
| 28 |
function _generateAvatarHtml(avatar, name, size, extraStyle = '') {
|
| 29 |
const initial = (name || 'U')[0].toUpperCase();
|
|
|
|
| 30 |
if (avatar) {
|
| 31 |
-
return `<img class="swr-avatar" src="${avatar}" style="width: ${size}px; height: ${size}px; border-radius: 50%; object-fit: cover; ${extraStyle}">`;
|
| 32 |
}
|
| 33 |
const fontSize = Math.max(9, Math.round(size * 0.4));
|
| 34 |
-
return `<div class="swr-avatar" style="width: ${size}px; height: ${size}px; border-radius: 50%; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); display: flex; align-items: center; justify-content: center; color: white; font-size: ${fontSize}px; font-weight: bold;">${initial}</div>`;
|
| 35 |
}
|
| 36 |
|
| 37 |
/**
|
|
@@ -42,7 +43,7 @@ function _updateAvatarElement(avatarEl, profile, size, extraStyle = '') {
|
|
| 42 |
if (avatarEl.tagName === 'IMG') {
|
| 43 |
avatarEl.src = profile.avatar;
|
| 44 |
} else {
|
| 45 |
-
avatarEl.outerHTML = `<img class="swr-avatar" src="${profile.avatar}" style="width: ${size}px; height: ${size}px; border-radius: 50%; object-fit: cover; ${extraStyle}">`;
|
| 46 |
}
|
| 47 |
}
|
| 48 |
|
|
@@ -464,7 +465,7 @@ async function showPurchaseConfirmDialog(contentArea, prompt, currentUser) {
|
|
| 464 |
<div style="text-align: center;">
|
| 465 |
<div style="font-size: 36px; margin-bottom: 12px;">💎</div>
|
| 466 |
<div style="font-size: 15px; font-weight: bold; color: #fff; margin-bottom: 10px;">${t('prompt.purchase_confirm_title')}</div>
|
| 467 |
-
<div style="font-size: 13px; color: #aaa; margin-bottom: 15px; line-height: 1.6;">${t('prompt.purchase_confirm_desc', { price: prompt.price, title: prompt.title })}</div>
|
| 468 |
<div style="margin-bottom: 15px; background: rgba(255,152,0,0.1); padding: 10px; border-radius: 4px; border: 1px solid #FF9800;">
|
| 469 |
${t('prompt.purchase_balance')}: <strong style="color:#FF9800;">${balance}</strong>
|
| 470 |
</div>
|
|
@@ -593,7 +594,7 @@ async function showTipDialog(prompt, currentUser, container) {
|
|
| 593 |
|
| 594 |
// 本地前置余额校验
|
| 595 |
if ((currentUser.balance || 0) < amount) {
|
| 596 |
-
showToast(t('wallet.insufficient_balance')
|
| 597 |
return;
|
| 598 |
}
|
| 599 |
|
|
@@ -686,7 +687,7 @@ async function loadComments(container, promptId, currentUser) {
|
|
| 686 |
}
|
| 687 |
try {
|
| 688 |
sendBtn.disabled = true;
|
| 689 |
-
sendBtn.textContent = t('common.sending')
|
| 690 |
await api.addPromptComment(promptId, content);
|
| 691 |
commentInput.value = "";
|
| 692 |
showToast(t('prompt.comment_success'), "success");
|
|
|
|
| 27 |
*/
|
| 28 |
function _generateAvatarHtml(avatar, name, size, extraStyle = '') {
|
| 29 |
const initial = (name || 'U')[0].toUpperCase();
|
| 30 |
+
// 🔒 XSS防护:avatar / name 均来自他人可修改的资料(昵称首字母可能是 < ),写入 HTML 前必须转义
|
| 31 |
if (avatar) {
|
| 32 |
+
return `<img class="swr-avatar" src="${escapeHtml(avatar)}" style="width: ${size}px; height: ${size}px; border-radius: 50%; object-fit: cover; ${extraStyle}">`;
|
| 33 |
}
|
| 34 |
const fontSize = Math.max(9, Math.round(size * 0.4));
|
| 35 |
+
return `<div class="swr-avatar" style="width: ${size}px; height: ${size}px; border-radius: 50%; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); display: flex; align-items: center; justify-content: center; color: white; font-size: ${fontSize}px; font-weight: bold;">${escapeHtml(initial)}</div>`;
|
| 36 |
}
|
| 37 |
|
| 38 |
/**
|
|
|
|
| 43 |
if (avatarEl.tagName === 'IMG') {
|
| 44 |
avatarEl.src = profile.avatar;
|
| 45 |
} else {
|
| 46 |
+
avatarEl.outerHTML = `<img class="swr-avatar" src="${escapeHtml(profile.avatar)}" style="width: ${size}px; height: ${size}px; border-radius: 50%; object-fit: cover; ${extraStyle}">`;
|
| 47 |
}
|
| 48 |
}
|
| 49 |
|
|
|
|
| 465 |
<div style="text-align: center;">
|
| 466 |
<div style="font-size: 36px; margin-bottom: 12px;">💎</div>
|
| 467 |
<div style="font-size: 15px; font-weight: bold; color: #fff; margin-bottom: 10px;">${t('prompt.purchase_confirm_title')}</div>
|
| 468 |
+
<div style="font-size: 13px; color: #aaa; margin-bottom: 15px; line-height: 1.6;">${t('prompt.purchase_confirm_desc', { price: prompt.price, title: escapeHtml(prompt.title) })}</div>
|
| 469 |
<div style="margin-bottom: 15px; background: rgba(255,152,0,0.1); padding: 10px; border-radius: 4px; border: 1px solid #FF9800;">
|
| 470 |
${t('prompt.purchase_balance')}: <strong style="color:#FF9800;">${balance}</strong>
|
| 471 |
</div>
|
|
|
|
| 594 |
|
| 595 |
// 本地前置余额校验
|
| 596 |
if ((currentUser.balance || 0) < amount) {
|
| 597 |
+
showToast(t('wallet.insufficient_balance'), "warning");
|
| 598 |
return;
|
| 599 |
}
|
| 600 |
|
|
|
|
| 687 |
}
|
| 688 |
try {
|
| 689 |
sendBtn.disabled = true;
|
| 690 |
+
sendBtn.textContent = t('common.sending');
|
| 691 |
await api.addPromptComment(promptId, content);
|
| 692 |
commentInput.value = "";
|
| 693 |
showToast(t('prompt.comment_success'), "success");
|
前端页面/social/私信聊天组件.js
CHANGED
|
@@ -26,12 +26,14 @@ const formatRelativeTime = (timestamp) => {
|
|
| 26 |
const minutes = Math.floor(diff / 60000);
|
| 27 |
const hours = Math.floor(diff / 3600000);
|
| 28 |
const days = Math.floor(diff / 86400000);
|
| 29 |
-
if (minutes < 1) return t('chat.time_just_now')
|
| 30 |
-
if (minutes < 60) return `${minutes}${t('chat.time_min_ago')
|
| 31 |
-
if (hours < 24) return `${hours}${t('chat.time_hour_ago')
|
| 32 |
-
if (days < 7) return `${days}${t('chat.time_day_ago')
|
| 33 |
const date = new Date(msgTime);
|
| 34 |
-
|
|
|
|
|
|
|
| 35 |
};
|
| 36 |
// 🚀 P1-5: 超过5分钟则显示时间分隔符
|
| 37 |
const TIME_GAP_THRESHOLD = 5 * 60; // 秒
|
|
@@ -109,7 +111,7 @@ export function openChatModal(currentUser, targetAccount = null) {
|
|
| 109 |
resize: "none", maxHeight: "100px", minHeight: "40px", overflow: "auto",
|
| 110 |
lineHeight: "1.4", boxSizing: "border-box"
|
| 111 |
});
|
| 112 |
-
inputField.placeholder = t('chat.input_placeholder')
|
| 113 |
// 🚀 P1-2: 自适应高度
|
| 114 |
const adjustHeight = () => {
|
| 115 |
inputField.style.height = "auto";
|
|
@@ -194,7 +196,7 @@ export function openChatModal(currentUser, targetAccount = null) {
|
|
| 194 |
}
|
| 195 |
|
| 196 |
chatItem.innerHTML = `
|
| 197 |
-
<img src="${avatar}" style="width: 40px; height: 40px; border-radius: 50%; object-fit: cover; border: 1px solid #555; flex-shrink: 0;">
|
| 198 |
<div style="flex: 1; min-width: 0;">
|
| 199 |
<div style="display: flex; justify-content: space-between; align-items: center;">
|
| 200 |
<div style="font-weight: bold; font-size: 13px; color: #fff;">${escapeHtml(name)}</div>
|
|
@@ -233,7 +235,7 @@ export function openChatModal(currentUser, targetAccount = null) {
|
|
| 233 |
} catch(e) {
|
| 234 |
// 🚀 P1-7: loadChatList 错误处理
|
| 235 |
console.error('[私信] 加载对话列表失败:', e);
|
| 236 |
-
showToast(t('chat.load_list_failed')
|
| 237 |
}
|
| 238 |
};
|
| 239 |
|
|
@@ -254,7 +256,7 @@ export function openChatModal(currentUser, targetAccount = null) {
|
|
| 254 |
textAlign: "center", padding: "10px", cursor: "pointer",
|
| 255 |
color: "#888", fontSize: "12px", transition: "0.2s"
|
| 256 |
});
|
| 257 |
-
loadMoreBtn.textContent = t('chat.load_more')
|
| 258 |
loadMoreBtn.onmouseover = () => { loadMoreBtn.style.color = "#2196F3"; };
|
| 259 |
loadMoreBtn.onmouseout = () => { loadMoreBtn.style.color = "#888"; };
|
| 260 |
loadMoreBtn.onclick = () => {
|
|
@@ -378,9 +380,9 @@ export function openChatModal(currentUser, targetAccount = null) {
|
|
| 378 |
localStorage.setItem(getLastChatKey(currentUser.account), JSON.stringify(currentTargetInfo));
|
| 379 |
|
| 380 |
chatHeader.innerHTML = `
|
| 381 |
-
<img id="chat-target-avatar" src="${avatar}" style="width: 36px; height: 36px; border-radius: 50%; object-fit: cover; border: 1px solid #555; cursor: pointer; margin-right: 10px; transition: 0.2s;" title="${t('chat.view_profile')
|
| 382 |
<div style="flex: 1;">
|
| 383 |
-
<div style="font-weight: bold; font-size: 14px; color: #fff;">${t('chat.chatting_with')
|
| 384 |
<div style="font-size: 11px; color: #888;">@${escapeHtml(targetAccount)}</div>
|
| 385 |
</div>
|
| 386 |
`;
|
|
@@ -442,11 +444,11 @@ export function openChatModal(currentUser, targetAccount = null) {
|
|
| 442 |
} catch(e) {
|
| 443 |
// 🚀 P1-7: loadChatHistory 错误处理
|
| 444 |
console.error('[私信] 加载消息失败:', e);
|
| 445 |
-
showToast(t('chat.load_history_failed')
|
| 446 |
messagesArea.innerHTML = `<div style="text-align:center; padding:40px; color:#888;">
|
| 447 |
-
<div style="margin-bottom:10px;">${t('chat.load_failed_hint')
|
| 448 |
<button id="btn-retry-chat" style="padding:6px 16px; border-radius:4px; border:1px solid #555; background:var(--comfy-input-bg); color:#fff; cursor:pointer;">
|
| 449 |
-
${t('chat.retry')
|
| 450 |
</button>
|
| 451 |
</div>`;
|
| 452 |
const retryBtn = messagesArea.querySelector('#btn-retry-chat');
|
|
@@ -455,11 +457,11 @@ export function openChatModal(currentUser, targetAccount = null) {
|
|
| 455 |
|
| 456 |
// 🚀 绑定顶部清空消息按钮事件
|
| 457 |
clearBtnTop.onclick = async () => {
|
| 458 |
-
if (await showConfirm(t('chat.confirm_clear')
|
| 459 |
localStorage.setItem(clearKey, Date.now().toString());
|
| 460 |
localStorage.setItem(cacheKey, "[]");
|
| 461 |
renderMsgs([], avatar);
|
| 462 |
-
showToast(t('chat.cleared')
|
| 463 |
}
|
| 464 |
};
|
| 465 |
}
|
|
@@ -469,7 +471,7 @@ export function openChatModal(currentUser, targetAccount = null) {
|
|
| 469 |
const txt = inputField.value.trim();
|
| 470 |
if (!txt) return;
|
| 471 |
if (txt.length > MAX_MSG_LENGTH) {
|
| 472 |
-
showToast(t('chat.msg_too_long')
|
| 473 |
return;
|
| 474 |
}
|
| 475 |
if (!currentChatTarget) return;
|
|
@@ -481,8 +483,8 @@ export function openChatModal(currentUser, targetAccount = null) {
|
|
| 481 |
clearDraft(); // 🚀 P1-4: 发送成功后清除草稿
|
| 482 |
loadChatHistory(currentChatTarget, currentTargetInfo?.name, currentTargetInfo?.avatar);
|
| 483 |
loadChatList();
|
| 484 |
-
} catch(e) { showToast(t('chat.send_failed'
|
| 485 |
-
sendBtn.disabled = false; sendBtn.innerText = t('common.send')
|
| 486 |
};
|
| 487 |
|
| 488 |
// 🚀 P1-2: Enter 发送,Shift+Enter 换行
|
|
|
|
| 26 |
const minutes = Math.floor(diff / 60000);
|
| 27 |
const hours = Math.floor(diff / 3600000);
|
| 28 |
const days = Math.floor(diff / 86400000);
|
| 29 |
+
if (minutes < 1) return t('chat.time_just_now');
|
| 30 |
+
if (minutes < 60) return `${minutes}${t('chat.time_min_ago')}`;
|
| 31 |
+
if (hours < 24) return `${hours}${t('chat.time_hour_ago')}`;
|
| 32 |
+
if (days < 7) return `${days}${t('chat.time_day_ago')}`;
|
| 33 |
const date = new Date(msgTime);
|
| 34 |
+
// 🔧 去掉 || '月' / || '日' 兜底:这两个词条的英文值是刻意留空的(中文 9月8日 → 英文 9/8),
|
| 35 |
+
// 加了兜底反而会把中文量词引回英文界面(同文件下方的 chat.chatting_suffix 同理)
|
| 36 |
+
return `${date.getMonth() + 1}${t('chat.date_month')}${date.getDate()}${t('chat.date_day')} ${String(date.getHours()).padStart(2,'0')}:${String(date.getMinutes()).padStart(2,'0')}`;
|
| 37 |
};
|
| 38 |
// 🚀 P1-5: 超过5分钟则显示时间分隔符
|
| 39 |
const TIME_GAP_THRESHOLD = 5 * 60; // 秒
|
|
|
|
| 111 |
resize: "none", maxHeight: "100px", minHeight: "40px", overflow: "auto",
|
| 112 |
lineHeight: "1.4", boxSizing: "border-box"
|
| 113 |
});
|
| 114 |
+
inputField.placeholder = t('chat.input_placeholder');
|
| 115 |
// 🚀 P1-2: 自适应高度
|
| 116 |
const adjustHeight = () => {
|
| 117 |
inputField.style.height = "auto";
|
|
|
|
| 196 |
}
|
| 197 |
|
| 198 |
chatItem.innerHTML = `
|
| 199 |
+
<img src="${escapeHtml(avatar)}" style="width: 40px; height: 40px; border-radius: 50%; object-fit: cover; border: 1px solid #555; flex-shrink: 0;">
|
| 200 |
<div style="flex: 1; min-width: 0;">
|
| 201 |
<div style="display: flex; justify-content: space-between; align-items: center;">
|
| 202 |
<div style="font-weight: bold; font-size: 13px; color: #fff;">${escapeHtml(name)}</div>
|
|
|
|
| 235 |
} catch(e) {
|
| 236 |
// 🚀 P1-7: loadChatList 错误处理
|
| 237 |
console.error('[私信] 加载对话列表失败:', e);
|
| 238 |
+
showToast(t('chat.load_list_failed'), 'error');
|
| 239 |
}
|
| 240 |
};
|
| 241 |
|
|
|
|
| 256 |
textAlign: "center", padding: "10px", cursor: "pointer",
|
| 257 |
color: "#888", fontSize: "12px", transition: "0.2s"
|
| 258 |
});
|
| 259 |
+
loadMoreBtn.textContent = t('chat.load_more');
|
| 260 |
loadMoreBtn.onmouseover = () => { loadMoreBtn.style.color = "#2196F3"; };
|
| 261 |
loadMoreBtn.onmouseout = () => { loadMoreBtn.style.color = "#888"; };
|
| 262 |
loadMoreBtn.onclick = () => {
|
|
|
|
| 380 |
localStorage.setItem(getLastChatKey(currentUser.account), JSON.stringify(currentTargetInfo));
|
| 381 |
|
| 382 |
chatHeader.innerHTML = `
|
| 383 |
+
<img id="chat-target-avatar" src="${escapeHtml(avatar)}" style="width: 36px; height: 36px; border-radius: 50%; object-fit: cover; border: 1px solid #555; cursor: pointer; margin-right: 10px; transition: 0.2s;" title="${t('chat.view_profile')}">
|
| 384 |
<div style="flex: 1;">
|
| 385 |
+
<div style="font-weight: bold; font-size: 14px; color: #fff;">${t('chat.chatting_with')} ${escapeHtml(displayName)} ${t('chat.chatting_suffix')}</div>
|
| 386 |
<div style="font-size: 11px; color: #888;">@${escapeHtml(targetAccount)}</div>
|
| 387 |
</div>
|
| 388 |
`;
|
|
|
|
| 444 |
} catch(e) {
|
| 445 |
// 🚀 P1-7: loadChatHistory 错误处理
|
| 446 |
console.error('[私信] 加载消息失败:', e);
|
| 447 |
+
showToast(t('chat.load_history_failed'), 'error');
|
| 448 |
messagesArea.innerHTML = `<div style="text-align:center; padding:40px; color:#888;">
|
| 449 |
+
<div style="margin-bottom:10px;">${t('chat.load_failed_hint')}</div>
|
| 450 |
<button id="btn-retry-chat" style="padding:6px 16px; border-radius:4px; border:1px solid #555; background:var(--comfy-input-bg); color:#fff; cursor:pointer;">
|
| 451 |
+
${t('chat.retry')}
|
| 452 |
</button>
|
| 453 |
</div>`;
|
| 454 |
const retryBtn = messagesArea.querySelector('#btn-retry-chat');
|
|
|
|
| 457 |
|
| 458 |
// 🚀 绑定顶部清空消息按钮事件
|
| 459 |
clearBtnTop.onclick = async () => {
|
| 460 |
+
if (await showConfirm(t('chat.confirm_clear'))) {
|
| 461 |
localStorage.setItem(clearKey, Date.now().toString());
|
| 462 |
localStorage.setItem(cacheKey, "[]");
|
| 463 |
renderMsgs([], avatar);
|
| 464 |
+
showToast(t('chat.cleared'), "success");
|
| 465 |
}
|
| 466 |
};
|
| 467 |
}
|
|
|
|
| 471 |
const txt = inputField.value.trim();
|
| 472 |
if (!txt) return;
|
| 473 |
if (txt.length > MAX_MSG_LENGTH) {
|
| 474 |
+
showToast(t('chat.msg_too_long'), "warning");
|
| 475 |
return;
|
| 476 |
}
|
| 477 |
if (!currentChatTarget) return;
|
|
|
|
| 483 |
clearDraft(); // 🚀 P1-4: 发送成功后清除草稿
|
| 484 |
loadChatHistory(currentChatTarget, currentTargetInfo?.name, currentTargetInfo?.avatar);
|
| 485 |
loadChatList();
|
| 486 |
+
} catch(e) { showToast(t('chat.send_failed', { msg: e.message }), "error"); }
|
| 487 |
+
sendBtn.disabled = false; sendBtn.innerText = t('common.send');
|
| 488 |
};
|
| 489 |
|
| 490 |
// 🚀 P1-2: Enter 发送,Shift+Enter 换行
|
前端页面/social/评论与互动组件.js
CHANGED
|
@@ -108,7 +108,7 @@ export function createCommentSection(itemId, commentsData, currentUser, onCountC
|
|
| 108 |
const avatarSrc = avatar || DEFAULT_AVATAR_SVG;
|
| 109 |
|
| 110 |
itemDiv.innerHTML = `
|
| 111 |
-
<img class="swr-avatar" src="${avatarSrc}" style="width: ${avatarSize}; height: ${avatarSize}; border-radius: 50%; object-fit: cover; background: var(--comfy-input-bg);">
|
| 112 |
<div style="flex: 1;">
|
| 113 |
<div style="display: flex; justify-content: space-between; margin-bottom: 2px;">
|
| 114 |
<span class="swr-name" style="color: #aaa; font-weight: bold; font-size: 11px;">${escapeHtml(comment.authorName || comment.author)}</span>
|
|
@@ -144,7 +144,7 @@ export function createCommentSection(itemId, commentsData, currentUser, onCountC
|
|
| 144 |
if (canDelete) {
|
| 145 |
itemDiv.querySelector(".delete-btn").onclick = async () => {
|
| 146 |
const confirmed = await showConfirm(t('social.delete_comment_confirm'), {
|
| 147 |
-
title: t('social.delete_comment_title')
|
| 148 |
confirmText: t('common.confirm'),
|
| 149 |
cancelText: t('common.cancel'),
|
| 150 |
type: 'danger'
|
|
@@ -156,7 +156,7 @@ export function createCommentSection(itemId, commentsData, currentUser, onCountC
|
|
| 156 |
comment.isDeleted = true;
|
| 157 |
renderList();
|
| 158 |
triggerCountUpdate();
|
| 159 |
-
} catch(e) { showToast(t('feedback.delete_failed') + t('feedback.retry_suffix'
|
| 160 |
};
|
| 161 |
}
|
| 162 |
}
|
|
|
|
| 108 |
const avatarSrc = avatar || DEFAULT_AVATAR_SVG;
|
| 109 |
|
| 110 |
itemDiv.innerHTML = `
|
| 111 |
+
<img class="swr-avatar" src="${escapeHtml(avatarSrc)}" style="width: ${avatarSize}; height: ${avatarSize}; border-radius: 50%; object-fit: cover; background: var(--comfy-input-bg);">
|
| 112 |
<div style="flex: 1;">
|
| 113 |
<div style="display: flex; justify-content: space-between; margin-bottom: 2px;">
|
| 114 |
<span class="swr-name" style="color: #aaa; font-weight: bold; font-size: 11px;">${escapeHtml(comment.authorName || comment.author)}</span>
|
|
|
|
| 144 |
if (canDelete) {
|
| 145 |
itemDiv.querySelector(".delete-btn").onclick = async () => {
|
| 146 |
const confirmed = await showConfirm(t('social.delete_comment_confirm'), {
|
| 147 |
+
title: t('social.delete_comment_title'),
|
| 148 |
confirmText: t('common.confirm'),
|
| 149 |
cancelText: t('common.cancel'),
|
| 150 |
type: 'danger'
|
|
|
|
| 156 |
comment.isDeleted = true;
|
| 157 |
renderList();
|
| 158 |
triggerCountUpdate();
|
| 159 |
+
} catch(e) { showToast(t('feedback.delete_failed') + t('feedback.retry_suffix'), "error"); }
|
| 160 |
};
|
| 161 |
}
|
| 162 |
}
|
前端页面/social/通知中心组件.js
CHANGED
|
@@ -155,15 +155,17 @@ export async function openNotificationCenter(currentUser, bellBtn) {
|
|
| 155 |
} else if (isAnonymous) {
|
| 156 |
nameLabel = `<strong style="color: #fff;">${escapeHtml(msg.from_name || msg.from_user)}</strong>`;
|
| 157 |
} else {
|
| 158 |
-
|
|
|
|
|
|
|
| 159 |
style="color:#4FC3F7; cursor:pointer; text-decoration:underline dotted; font-weight:bold;">
|
| 160 |
${escapeHtml(msg.from_name || msg.from_user)}
|
| 161 |
</span>`;
|
| 162 |
}
|
| 163 |
|
| 164 |
html += `
|
| 165 |
-
<div class="notif-item" data-account="${msg.from_user}" data-type="${msg.type}" data-item-id="${msg.target_item_id || ''}" style="padding: 12px; border-radius: 8px; background: ${bg}; border: ${border}; display: flex; gap: 12px; align-items: flex-start; cursor: pointer; transition: 0.2s;" onmouseover="this.style.transform='scale(1.01)'" onmouseout="this.style.transform='scale(1)'">
|
| 166 |
-
<img src="${msg.from_avatar || PLACEHOLDERS.AVATAR}" style="width: 40px; height: 40px; border-radius: 50%; object-fit: cover; flex-shrink: 0; border: 1px solid #555;">
|
| 167 |
<div style="flex: 1; min-width: 0;">
|
| 168 |
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;">
|
| 169 |
${nameLabel}
|
|
|
|
| 155 |
} else if (isAnonymous) {
|
| 156 |
nameLabel = `<strong style="color: #fff;">${escapeHtml(msg.from_name || msg.from_user)}</strong>`;
|
| 157 |
} else {
|
| 158 |
+
// 🔒 XSS防护:from_user 是他人账号,写入 HTML 属性前必须转义
|
| 159 |
+
// (dataset.account 经浏览器解码后仍是原始值,下方跳转行为不变)
|
| 160 |
+
nameLabel = `<span class="notif-user-link" data-account="${escapeHtml(msg.from_user)}"
|
| 161 |
style="color:#4FC3F7; cursor:pointer; text-decoration:underline dotted; font-weight:bold;">
|
| 162 |
${escapeHtml(msg.from_name || msg.from_user)}
|
| 163 |
</span>`;
|
| 164 |
}
|
| 165 |
|
| 166 |
html += `
|
| 167 |
+
<div class="notif-item" data-account="${escapeHtml(msg.from_user)}" data-type="${escapeHtml(msg.type)}" data-item-id="${escapeHtml(msg.target_item_id || '')}" style="padding: 12px; border-radius: 8px; background: ${bg}; border: ${border}; display: flex; gap: 12px; align-items: flex-start; cursor: pointer; transition: 0.2s;" onmouseover="this.style.transform='scale(1.01)'" onmouseout="this.style.transform='scale(1)'">
|
| 168 |
+
<img src="${escapeHtml(msg.from_avatar || PLACEHOLDERS.AVATAR)}" style="width: 40px; height: 40px; border-radius: 50%; object-fit: cover; flex-shrink: 0; border: 1px solid #555;">
|
| 169 |
<div style="flex: 1; min-width: 0;">
|
| 170 |
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;">
|
| 171 |
${nameLabel}
|
前端页面/task/任务榜组件.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
| 18 |
import { api } from "../core/网络请求API.js";
|
| 19 |
import { proxyImages } from "../core/网络请求API.js";
|
| 20 |
import { showToast } from "../components/UI交互提示组件.js";
|
| 21 |
-
import {
|
| 22 |
import { applyViewportAnimations } from "../components/动画音效引擎.js";
|
| 23 |
import { t } from "../components/用户体验增强.js";
|
| 24 |
import { getCachedProfile, getProfileWithSWR } from "../core/全局配置.js";
|
|
@@ -221,8 +221,8 @@ export function createTasksView(currentUser, keyword = "") {
|
|
| 221 |
|
| 222 |
// ✅ 优先从本地缓存读取(含过期缓存:离线容灾,与插件榜/工作流/推荐榜策略一致)
|
| 223 |
if (!append && page === 1) {
|
| 224 |
-
const
|
| 225 |
-
if (
|
| 226 |
// 🚀 缓存数据也需要过一遍图片代理,确保新字段也被处理
|
| 227 |
allTasksData = proxyImages(cachedData);
|
| 228 |
renderTasksFromCache(allTasksData);
|
|
@@ -247,7 +247,7 @@ export function createTasksView(currentUser, keyword = "") {
|
|
| 247 |
// 缓存第一页数据
|
| 248 |
if (page === 1) {
|
| 249 |
allTasksData = tasks;
|
| 250 |
-
|
| 251 |
} else {
|
| 252 |
allTasksData = [...allTasksData, ...tasks];
|
| 253 |
}
|
|
@@ -293,8 +293,8 @@ export function createTasksView(currentUser, keyword = "") {
|
|
| 293 |
isLoadingFromNetwork = false;
|
| 294 |
// 网络失败时尝试从缓存读取(包括过期缓存,离线容灾)
|
| 295 |
if (!append) {
|
| 296 |
-
const
|
| 297 |
-
if (
|
| 298 |
// 🚀 缓存数据也需要过一遍图片代理
|
| 299 |
allTasksData = proxyImages(cachedData);
|
| 300 |
renderTasksFromCache(allTasksData);
|
|
@@ -359,7 +359,7 @@ export function createTasksView(currentUser, keyword = "") {
|
|
| 359 |
|
| 360 |
// 更新缓存
|
| 361 |
const cacheKey = getCacheKey();
|
| 362 |
-
|
| 363 |
|
| 364 |
// 对比新旧数据,有变化时重新渲染
|
| 365 |
if (_tasksDataChanged(allTasksData, tasks)) {
|
|
@@ -559,7 +559,8 @@ function createTaskCard(task) {
|
|
| 559 |
|
| 560 |
// 渲染头像(始终使用 img 标签)
|
| 561 |
const avatarSrc = avatar || DEFAULT_AVATAR_SVG;
|
| 562 |
-
|
|
|
|
| 563 |
|
| 564 |
authorContainer.innerHTML = `${avatarHtml}<span class="swr-name">${escapeHtml(name)}</span>`;
|
| 565 |
|
|
|
|
| 18 |
import { api } from "../core/网络请求API.js";
|
| 19 |
import { proxyImages } from "../core/网络请求API.js";
|
| 20 |
import { showToast } from "../components/UI交互提示组件.js";
|
| 21 |
+
import { readListCache, writeListCache, createSkeleton, createPaginationLoader, lazyLoadImages } from "../components/性能优化工具.js";
|
| 22 |
import { applyViewportAnimations } from "../components/动画音效引擎.js";
|
| 23 |
import { t } from "../components/用户体验增强.js";
|
| 24 |
import { getCachedProfile, getProfileWithSWR } from "../core/全局配置.js";
|
|
|
|
| 221 |
|
| 222 |
// ✅ 优先从本地缓存读取(含过期缓存:离线容灾,与插件榜/工作流/推荐榜策略一致)
|
| 223 |
if (!append && page === 1) {
|
| 224 |
+
const cachedData = readListCache(cacheKey);
|
| 225 |
+
if (cachedData) {
|
| 226 |
// 🚀 缓存数据也需要过一遍图片代理,确保新字段也被处理
|
| 227 |
allTasksData = proxyImages(cachedData);
|
| 228 |
renderTasksFromCache(allTasksData);
|
|
|
|
| 247 |
// 缓存第一页数据
|
| 248 |
if (page === 1) {
|
| 249 |
allTasksData = tasks;
|
| 250 |
+
writeListCache(cacheKey, tasks, getCacheTTL()); // 持久化到 localStorage,供离线容灾
|
| 251 |
} else {
|
| 252 |
allTasksData = [...allTasksData, ...tasks];
|
| 253 |
}
|
|
|
|
| 293 |
isLoadingFromNetwork = false;
|
| 294 |
// 网络失败时尝试从缓存读取(包括过期缓存,离线容灾)
|
| 295 |
if (!append) {
|
| 296 |
+
const cachedData = readListCache(cacheKey);
|
| 297 |
+
if (cachedData) {
|
| 298 |
// 🚀 缓存数据也需要过一遍图片代理
|
| 299 |
allTasksData = proxyImages(cachedData);
|
| 300 |
renderTasksFromCache(allTasksData);
|
|
|
|
| 359 |
|
| 360 |
// 更新缓存
|
| 361 |
const cacheKey = getCacheKey();
|
| 362 |
+
writeListCache(cacheKey, tasks, getCacheTTL());
|
| 363 |
|
| 364 |
// 对比新旧数据,有变化时重新渲染
|
| 365 |
if (_tasksDataChanged(allTasksData, tasks)) {
|
|
|
|
| 559 |
|
| 560 |
// 渲染头像(始终使用 img 标签)
|
| 561 |
const avatarSrc = avatar || DEFAULT_AVATAR_SVG;
|
| 562 |
+
// 🔒 XSS防护:头像 URL 来自他人可修改的资料,写入 HTML 属性前必须转义
|
| 563 |
+
const avatarHtml = `<img class="swr-avatar" src="${escapeHtml(avatarSrc)}" style="width: 18px; height: 18px; border-radius: 50%; object-fit: cover; border: 1px solid #444; background: var(--comfy-input-bg);">`;
|
| 564 |
|
| 565 |
authorContainer.innerHTML = `${avatarHtml}<span class="swr-name">${escapeHtml(name)}</span>`;
|
| 566 |
|
前端页面/task/任务详情组件.js
CHANGED
|
@@ -103,11 +103,9 @@ async function loadTaskDetail(container, taskId, currentUser) {
|
|
| 103 |
renderTaskDetail(contentEl, task, currentUser);
|
| 104 |
|
| 105 |
if (fromCache) {
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
document.body.appendChild(toast);
|
| 110 |
-
setTimeout(() => toast.remove(), 3000);
|
| 111 |
} else {
|
| 112 |
// 只有在线加载成功时才记录浏览量
|
| 113 |
recordTaskView(contentEl, taskId);
|
|
@@ -188,7 +186,7 @@ function renderTaskDetail(contentEl, task, currentUser) {
|
|
| 188 |
|
| 189 |
<!-- 发布者信息 -->
|
| 190 |
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 15px; padding: 10px; background: var(--comfy-menu-bg); border-radius: 8px;">
|
| 191 |
-
<img src="${task.publisher_avatar || PLACEHOLDERS.AVATAR_SMALL}"
|
| 192 |
style="width: 40px; height: 40px; border-radius: 50%; object-fit: cover; border: 2px solid var(--border-color, #333); cursor: pointer;" id="publisher-avatar">
|
| 193 |
<div style="flex: 1;">
|
| 194 |
<div style="color: #fff; font-size: 14px; font-weight: 500;">${escapeHtml(task.publisher_name || task.publisher)}</div>
|
|
@@ -296,7 +294,7 @@ ${escapeHtml(task.description)}</div>
|
|
| 296 |
<div style="margin-bottom: 20px; padding: 12px; background: rgba(33,150,243,0.1); border: 1px solid rgba(33,150,243,0.3); border-radius: 8px;">
|
| 297 |
<div style="font-size: 13px; font-weight: bold; color: #2196F3; margin-bottom: 8px;">👷 ${t('task.assignee')}</div>
|
| 298 |
<div style="display: flex; align-items: center; gap: 10px;">
|
| 299 |
-
<img src="${task.assignee_avatar || PLACEHOLDERS.AVATAR_SMALL}"
|
| 300 |
style="width: 32px; height: 32px; border-radius: 50%; object-fit: cover;">
|
| 301 |
<span style="color: #fff;">${escapeHtml(task.assignee_name || task.assignee)}</span>
|
| 302 |
</div>
|
|
@@ -321,7 +319,7 @@ ${escapeHtml(task.description)}</div>
|
|
| 321 |
<div id="applicants-list" style="display: flex; flex-direction: column; gap: 8px;">
|
| 322 |
${task.applicants.map(app => `
|
| 323 |
<div style="display: flex; align-items: center; gap: 10px; padding: 10px; background: var(--comfy-menu-bg); border-radius: 8px;">
|
| 324 |
-
<img src="${app.avatar || PLACEHOLDERS.AVATAR_SMALL}"
|
| 325 |
style="width: 32px; height: 32px; border-radius: 50%; object-fit: cover;">
|
| 326 |
<div style="flex: 1;">
|
| 327 |
<div style="color: #fff; font-size: 13px;">${escapeHtml(app.name || app.account)}</div>
|
|
@@ -383,14 +381,17 @@ ${escapeHtml(task.description)}</div>
|
|
| 383 |
function _showConfirmDialog({ title, icon, message, description, confirmText, confirmStyle, loadingText, errorText, width, onConfirm }) {
|
| 384 |
const content = document.createElement("div");
|
| 385 |
content.style.color = "#ccc";
|
|
|
|
|
|
|
|
|
|
| 386 |
content.innerHTML = `
|
| 387 |
<div style="text-align: center;">
|
| 388 |
<div style="font-size: 40px; margin-bottom: 15px;">${icon}</div>
|
| 389 |
-
<div style="color: #fff; font-size: 14px; margin-bottom: 8px;">${message}</div>
|
| 390 |
-
<div style="color: #888; font-size: 12px; margin-bottom: 20px;">${description}</div>
|
| 391 |
<div style="display: flex; gap: 10px;">
|
| 392 |
<button id="dialog-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>
|
| 393 |
-
<button id="dialog-confirm" style="flex: 1; ${confirmStyle}; border: none; color: #fff; padding: 10px; border-radius: 6px; cursor: pointer; font-weight: bold;">${confirmText}</button>
|
| 394 |
</div>
|
| 395 |
</div>
|
| 396 |
`;
|
|
@@ -444,7 +445,7 @@ function showApplyDialog(task, currentUser) {
|
|
| 444 |
content.innerHTML = `
|
| 445 |
<div style="margin-bottom: 15px;">
|
| 446 |
<div style="color: #888; font-size: 12px; margin-bottom: 8px;">${t('task.apply_message_prompt')}</div>
|
| 447 |
-
<textarea id="apply-message" rows="4" placeholder="${t('task.apply_message_placeholder')
|
| 448 |
</div>
|
| 449 |
<div style="display: flex; gap: 10px;">
|
| 450 |
<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>
|
|
@@ -487,7 +488,7 @@ function showCancelApplyConfirmDialog(task, currentUser) {
|
|
| 487 |
title: t('task.cancel_apply'),
|
| 488 |
icon: '↩️',
|
| 489 |
message: t('task.confirm_cancel_apply'),
|
| 490 |
-
description: t('task.cancel_apply_warning')
|
| 491 |
confirmText: t('common.confirm'),
|
| 492 |
confirmStyle: 'background: #FF9800',
|
| 493 |
errorText: t('task.cancel_apply_failed'),
|
|
@@ -511,7 +512,7 @@ function showCancelTaskConfirmDialog(task) {
|
|
| 511 |
title: t('task.cancel_task'),
|
| 512 |
icon: '⚠️',
|
| 513 |
message: t('task.confirm_cancel_task'),
|
| 514 |
-
description: t('task.cancel_task_warning')
|
| 515 |
confirmText: t('task.cancel_task'),
|
| 516 |
confirmStyle: 'background: #F44336',
|
| 517 |
loadingText: `⏳ ${t('common.cancelling')}...`,
|
|
@@ -558,7 +559,7 @@ function showRejectDialog(task, currentUser) {
|
|
| 558 |
content.innerHTML = `
|
| 559 |
<div style="margin-bottom: 15px;">
|
| 560 |
<div style="color: #888; font-size: 12px; margin-bottom: 8px;">${t('task.reject_reason_prompt')}</div>
|
| 561 |
-
<textarea id="reject-feedback" rows="4" placeholder="${t('task.reject_reason_placeholder')
|
| 562 |
</div>
|
| 563 |
<div style="display: flex; gap: 10px;">
|
| 564 |
<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>
|
|
@@ -571,7 +572,7 @@ function showRejectDialog(task, currentUser) {
|
|
| 571 |
content.querySelector("#reject-confirm").onclick = async () => {
|
| 572 |
const feedback = content.querySelector("#reject-feedback").value.trim();
|
| 573 |
if (!feedback) {
|
| 574 |
-
showToast(t('task.reject_reason_required')
|
| 575 |
return;
|
| 576 |
}
|
| 577 |
|
|
@@ -607,8 +608,8 @@ function showDeleteCommentConfirmDialog(taskId, comment, container, currentUser)
|
|
| 607 |
content.innerHTML = `
|
| 608 |
<div style="text-align: center;">
|
| 609 |
<div style="font-size: 40px; margin-bottom: 15px;">⚠️</div>
|
| 610 |
-
<div style="color: #fff; font-size: 14px; margin-bottom: 8px;">${t('post.delete_comment_confirm')
|
| 611 |
-
<div style="color: #888; font-size: 12px; margin-bottom: 20px;">${t('post.delete_comment_warning')
|
| 612 |
<div style="display: flex; gap: 10px;">
|
| 613 |
<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>
|
| 614 |
<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>
|
|
@@ -625,11 +626,11 @@ function showDeleteCommentConfirmDialog(taskId, comment, container, currentUser)
|
|
| 625 |
|
| 626 |
try {
|
| 627 |
await api.deleteComment(taskId, comment.id);
|
| 628 |
-
showToast(t('post.delete_comment_success')
|
| 629 |
globalModal.closeTopModal();
|
| 630 |
loadTaskComments(container, taskId, currentUser);
|
| 631 |
} catch (err) {
|
| 632 |
-
showToast(
|
| 633 |
confirmBtn.disabled = false;
|
| 634 |
confirmBtn.textContent = t('common.delete');
|
| 635 |
}
|
|
@@ -935,9 +936,10 @@ function renderTipBoard(tipBoard) {
|
|
| 935 |
*/
|
| 936 |
function _renderCommentAvatar(name, avatarUrl) {
|
| 937 |
const initial = (name || 'U')[0].toUpperCase();
|
|
|
|
| 938 |
return avatarUrl
|
| 939 |
-
? `<img class="swr-avatar" src="${avatarUrl}" style="width: 24px; height: 24px; border-radius: 50%; object-fit: cover; background: var(--comfy-input-bg);">`
|
| 940 |
-
: `<div class="swr-avatar" style="width: 24px; height: 24px; border-radius: 50%; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); display: flex; align-items: center; justify-content: center; color: white; font-size: 10px; font-weight: bold;">${initial}</div>`;
|
| 941 |
}
|
| 942 |
|
| 943 |
/**
|
|
|
|
| 103 |
renderTaskDetail(contentEl, task, currentUser);
|
| 104 |
|
| 105 |
if (fromCache) {
|
| 106 |
+
// 🔧 改用统一提示组件:原为手写 div(无动画、不排队、不避重叠,样式与其他提示不一致);
|
| 107 |
+
// warning 类型的背景色与原手写值同为 #FF9800,视觉保持一致
|
| 108 |
+
showToast(t('feedback.cache_fallback'), 'warning');
|
|
|
|
|
|
|
| 109 |
} else {
|
| 110 |
// 只有在线加载成功时才记录浏览量
|
| 111 |
recordTaskView(contentEl, taskId);
|
|
|
|
| 186 |
|
| 187 |
<!-- 发布者信息 -->
|
| 188 |
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 15px; padding: 10px; background: var(--comfy-menu-bg); border-radius: 8px;">
|
| 189 |
+
<img src="${escapeHtml(task.publisher_avatar || PLACEHOLDERS.AVATAR_SMALL)}"
|
| 190 |
style="width: 40px; height: 40px; border-radius: 50%; object-fit: cover; border: 2px solid var(--border-color, #333); cursor: pointer;" id="publisher-avatar">
|
| 191 |
<div style="flex: 1;">
|
| 192 |
<div style="color: #fff; font-size: 14px; font-weight: 500;">${escapeHtml(task.publisher_name || task.publisher)}</div>
|
|
|
|
| 294 |
<div style="margin-bottom: 20px; padding: 12px; background: rgba(33,150,243,0.1); border: 1px solid rgba(33,150,243,0.3); border-radius: 8px;">
|
| 295 |
<div style="font-size: 13px; font-weight: bold; color: #2196F3; margin-bottom: 8px;">👷 ${t('task.assignee')}</div>
|
| 296 |
<div style="display: flex; align-items: center; gap: 10px;">
|
| 297 |
+
<img src="${escapeHtml(task.assignee_avatar || PLACEHOLDERS.AVATAR_SMALL)}"
|
| 298 |
style="width: 32px; height: 32px; border-radius: 50%; object-fit: cover;">
|
| 299 |
<span style="color: #fff;">${escapeHtml(task.assignee_name || task.assignee)}</span>
|
| 300 |
</div>
|
|
|
|
| 319 |
<div id="applicants-list" style="display: flex; flex-direction: column; gap: 8px;">
|
| 320 |
${task.applicants.map(app => `
|
| 321 |
<div style="display: flex; align-items: center; gap: 10px; padding: 10px; background: var(--comfy-menu-bg); border-radius: 8px;">
|
| 322 |
+
<img src="${escapeHtml(app.avatar || PLACEHOLDERS.AVATAR_SMALL)}"
|
| 323 |
style="width: 32px; height: 32px; border-radius: 50%; object-fit: cover;">
|
| 324 |
<div style="flex: 1;">
|
| 325 |
<div style="color: #fff; font-size: 13px;">${escapeHtml(app.name || app.account)}</div>
|
|
|
|
| 381 |
function _showConfirmDialog({ title, icon, message, description, confirmText, confirmStyle, loadingText, errorText, width, onConfirm }) {
|
| 382 |
const content = document.createElement("div");
|
| 383 |
content.style.color = "#ccc";
|
| 384 |
+
// 🔒 XSS防护:message/description 可能嵌入其他用户的账号或昵称
|
| 385 |
+
// (如 t('task.confirm_assign', { assignee })),写入 innerHTML 前必须转义。
|
| 386 |
+
// 本函数的 4 个调用方传的都是词典文本/emoji/数字,转义不改变现有显示效果。
|
| 387 |
content.innerHTML = `
|
| 388 |
<div style="text-align: center;">
|
| 389 |
<div style="font-size: 40px; margin-bottom: 15px;">${icon}</div>
|
| 390 |
+
<div style="color: #fff; font-size: 14px; margin-bottom: 8px;">${escapeHtml(message)}</div>
|
| 391 |
+
<div style="color: #888; font-size: 12px; margin-bottom: 20px;">${escapeHtml(description)}</div>
|
| 392 |
<div style="display: flex; gap: 10px;">
|
| 393 |
<button id="dialog-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>
|
| 394 |
+
<button id="dialog-confirm" style="flex: 1; ${confirmStyle}; border: none; color: #fff; padding: 10px; border-radius: 6px; cursor: pointer; font-weight: bold;">${escapeHtml(confirmText)}</button>
|
| 395 |
</div>
|
| 396 |
</div>
|
| 397 |
`;
|
|
|
|
| 445 |
content.innerHTML = `
|
| 446 |
<div style="margin-bottom: 15px;">
|
| 447 |
<div style="color: #888; font-size: 12px; margin-bottom: 8px;">${t('task.apply_message_prompt')}</div>
|
| 448 |
+
<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>
|
| 449 |
</div>
|
| 450 |
<div style="display: flex; gap: 10px;">
|
| 451 |
<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>
|
|
|
|
| 488 |
title: t('task.cancel_apply'),
|
| 489 |
icon: '↩️',
|
| 490 |
message: t('task.confirm_cancel_apply'),
|
| 491 |
+
description: t('task.cancel_apply_warning'),
|
| 492 |
confirmText: t('common.confirm'),
|
| 493 |
confirmStyle: 'background: #FF9800',
|
| 494 |
errorText: t('task.cancel_apply_failed'),
|
|
|
|
| 512 |
title: t('task.cancel_task'),
|
| 513 |
icon: '⚠️',
|
| 514 |
message: t('task.confirm_cancel_task'),
|
| 515 |
+
description: t('task.cancel_task_warning'),
|
| 516 |
confirmText: t('task.cancel_task'),
|
| 517 |
confirmStyle: 'background: #F44336',
|
| 518 |
loadingText: `⏳ ${t('common.cancelling')}...`,
|
|
|
|
| 559 |
content.innerHTML = `
|
| 560 |
<div style="margin-bottom: 15px;">
|
| 561 |
<div style="color: #888; font-size: 12px; margin-bottom: 8px;">${t('task.reject_reason_prompt')}</div>
|
| 562 |
+
<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>
|
| 563 |
</div>
|
| 564 |
<div style="display: flex; gap: 10px;">
|
| 565 |
<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>
|
|
|
|
| 572 |
content.querySelector("#reject-confirm").onclick = async () => {
|
| 573 |
const feedback = content.querySelector("#reject-feedback").value.trim();
|
| 574 |
if (!feedback) {
|
| 575 |
+
showToast(t('task.reject_reason_required'), "warning");
|
| 576 |
return;
|
| 577 |
}
|
| 578 |
|
|
|
|
| 608 |
content.innerHTML = `
|
| 609 |
<div style="text-align: center;">
|
| 610 |
<div style="font-size: 40px; margin-bottom: 15px;">⚠️</div>
|
| 611 |
+
<div style="color: #fff; font-size: 14px; margin-bottom: 8px;">${t('post.delete_comment_confirm')}</div>
|
| 612 |
+
<div style="color: #888; font-size: 12px; margin-bottom: 20px;">${t('post.delete_comment_warning')}</div>
|
| 613 |
<div style="display: flex; gap: 10px;">
|
| 614 |
<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>
|
| 615 |
<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>
|
|
|
|
| 626 |
|
| 627 |
try {
|
| 628 |
await api.deleteComment(taskId, comment.id);
|
| 629 |
+
showToast(t('post.delete_comment_success'), "success");
|
| 630 |
globalModal.closeTopModal();
|
| 631 |
loadTaskComments(container, taskId, currentUser);
|
| 632 |
} catch (err) {
|
| 633 |
+
showToast(t('post.delete_comment_failed') + ": " + err.message, "error");
|
| 634 |
confirmBtn.disabled = false;
|
| 635 |
confirmBtn.textContent = t('common.delete');
|
| 636 |
}
|
|
|
|
| 936 |
*/
|
| 937 |
function _renderCommentAvatar(name, avatarUrl) {
|
| 938 |
const initial = (name || 'U')[0].toUpperCase();
|
| 939 |
+
// 🔒 XSS防护:评论者昵称与头像均来自他人可修改的资料,写入 HTML 前必须转义
|
| 940 |
return avatarUrl
|
| 941 |
+
? `<img class="swr-avatar" src="${escapeHtml(avatarUrl)}" style="width: 24px; height: 24px; border-radius: 50%; object-fit: cover; background: var(--comfy-input-bg);">`
|
| 942 |
+
: `<div class="swr-avatar" style="width: 24px; height: 24px; border-radius: 50%; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); display: flex; align-items: center; justify-content: center; color: white; font-size: 10px; font-weight: bold;">${escapeHtml(initial)}</div>`;
|
| 943 |
}
|
| 944 |
|
| 945 |
/**
|
前端页面/task/发布任务组件.js
CHANGED
|
@@ -8,14 +8,17 @@
|
|
| 8 |
import { api } from "../core/网络请求API.js";
|
| 9 |
import { showToast, showConfirm } from "../components/UI交互提示组件.js";
|
| 10 |
import { t } from "../components/用户体验增强.js";
|
| 11 |
-
import { removeCache } from "../components/性能优化工具.js";
|
| 12 |
import { compressImageForUpload } from "../market/发布内容_提交引擎.js";
|
|
|
|
| 13 |
|
| 14 |
/**
|
| 15 |
* 🚀 清除任务列表缓存
|
| 16 |
*/
|
| 17 |
function clearTaskListCache() {
|
| 18 |
-
|
|
|
|
|
|
|
| 19 |
// 🔧 P1修复:任务榜组件实际使用 TasksCache_${status}_${sort} 键(非 ListCache_*),按真实键格式精准清除
|
| 20 |
const statuses = ['', 'open', 'in_progress', 'submitted', 'completed', 'disputed'];
|
| 21 |
// 任务榜排序值:latest, price, deadline, views, daily_views, likes, favorites
|
|
@@ -102,7 +105,7 @@ export function createPublishTaskView(currentUser, editTaskData = null) {
|
|
| 102 |
<button id="btn-back" style="background: rgba(51,51,51,0.8); border: 1px solid rgba(85,85,85,0.8); color: #fff; padding: 6px 14px; border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: bold; display: flex; align-items: center; gap: 6px;" onmouseover="this.style.background='#4CAF50'" onmouseout="this.style.background='rgba(51,51,51,0.8)'">
|
| 103 |
⬅ ${t('common.back')}
|
| 104 |
</button>
|
| 105 |
-
<span style="flex: 1; text-align: center; font-size: 15px; font-weight: bold; color: #fff;">📝 ${isEditMode ?
|
| 106 |
<div style="width: 60px;"></div>
|
| 107 |
</div>
|
| 108 |
|
|
@@ -114,7 +117,7 @@ export function createPublishTaskView(currentUser, editTaskData = null) {
|
|
| 114 |
<label style="display: block; color: #fff; font-size: 13px; font-weight: bold; margin-bottom: 6px;">
|
| 115 |
📌 ${t('task.task_title')} <span style="color: #F44336;">*</span>
|
| 116 |
</label>
|
| 117 |
-
<input type="text" id="task-title" maxlength="50" placeholder="${t('task.title_placeholder')}" value="${editTitle}"
|
| 118 |
style="width: 100%; padding: 10px 12px; background: var(--comfy-menu-bg); border: 1px solid var(--border-color, #333); border-radius: 8px; color: #fff; font-size: 14px; box-sizing: border-box;">
|
| 119 |
</div>
|
| 120 |
|
|
@@ -124,7 +127,7 @@ export function createPublishTaskView(currentUser, editTaskData = null) {
|
|
| 124 |
📄 ${t('task.description')} <span style="color: #F44336;">*</span>
|
| 125 |
</label>
|
| 126 |
<textarea id="task-description" rows="6" maxlength="2000" placeholder="${t('task.description_placeholder')}"
|
| 127 |
-
style="width: 100%; padding: 10px 12px; background: var(--comfy-menu-bg); border: 1px solid var(--border-color, #333); border-radius: 8px; color: #fff; font-size: 14px; resize: none; box-sizing: border-box; line-height: 1.5;">${editDescription}</textarea>
|
| 128 |
<div style="text-align: right; font-size: 11px; color: #666; margin-top: 4px;">
|
| 129 |
<span id="desc-count">${editDescription.length}</span>/2000
|
| 130 |
</div>
|
|
@@ -148,7 +151,7 @@ export function createPublishTaskView(currentUser, editTaskData = null) {
|
|
| 148 |
<label style="display: block; color: #fff; font-size: 13px; font-weight: bold; margin-bottom: 6px;">
|
| 149 |
🔗 ${t('task.reference_link_optional')}
|
| 150 |
</label>
|
| 151 |
-
<input type="url" id="ref-link" placeholder="https://..." value="${editRefLink}"
|
| 152 |
style="width: 100%; padding: 10px 12px; background: var(--comfy-menu-bg); border: 1px solid var(--border-color, #333); border-radius: 8px; color: #fff; font-size: 14px; box-sizing: border-box;">
|
| 153 |
</div>
|
| 154 |
|
|
@@ -308,7 +311,7 @@ export function createPublishTaskView(currentUser, editTaskData = null) {
|
|
| 308 |
const renderImagePreview = () => {
|
| 309 |
imagePreview.innerHTML = selectedImages.map((img, i) => `
|
| 310 |
<div style="position: relative; padding-top: 100%; background: var(--comfy-menu-bg); border-radius: 8px; overflow: hidden;">
|
| 311 |
-
<img src="${img.dataUrl}" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: cover;">
|
| 312 |
<button class="btn-remove-img" data-index="${i}" style="position: absolute; top: 4px; right: 4px; width: 20px; height: 20px; background: rgba(244,67,54,0.9); border: none; border-radius: 50%; color: #fff; font-size: 12px; cursor: pointer; display: flex; align-items: center; justify-content: center;">×</button>
|
| 313 |
</div>
|
| 314 |
`).join("");
|
|
@@ -383,7 +386,7 @@ export function createPublishTaskView(currentUser, editTaskData = null) {
|
|
| 383 |
|
| 384 |
await api.updateTask(editTaskData.id, updateData);
|
| 385 |
|
| 386 |
-
showToast(t('task.edit_success')
|
| 387 |
} else {
|
| 388 |
// 新建模式:创建任务
|
| 389 |
publishBtn.textContent = `⏳ ${t('task.publishing')}...`;
|
|
@@ -411,7 +414,7 @@ export function createPublishTaskView(currentUser, editTaskData = null) {
|
|
| 411 |
|
| 412 |
} catch (err) {
|
| 413 |
console.error(isEditMode ? "修改任务失败:" : "发布任务失败:", err);
|
| 414 |
-
showToast(err.message || (isEditMode ? t('task.edit_failed')
|
| 415 |
} finally {
|
| 416 |
const publishBtn = container.querySelector("#btn-publish");
|
| 417 |
publishBtn.disabled = false;
|
|
|
|
| 8 |
import { api } from "../core/网络请求API.js";
|
| 9 |
import { showToast, showConfirm } from "../components/UI交互提示组件.js";
|
| 10 |
import { t } from "../components/用户体验增强.js";
|
| 11 |
+
import { removeCache, removeCacheByPrefix } from "../components/性能优化工具.js";
|
| 12 |
import { compressImageForUpload } from "../market/发布内容_提交引擎.js";
|
| 13 |
+
import { escapeHtml } from "../core/全局配置.js";
|
| 14 |
|
| 15 |
/**
|
| 16 |
* 🚀 清除任务列表缓存
|
| 17 |
*/
|
| 18 |
function clearTaskListCache() {
|
| 19 |
+
// 🔧 修复:API 层缓存键带完整查询串(api_/api/tasks?status=..&sort=..),
|
| 20 |
+
// removeCache 精确删除命中不到(no-op),发任务后任务榜仍会从旧缓存返回,改用前缀删除
|
| 21 |
+
removeCacheByPrefix('api_/api/tasks');
|
| 22 |
// 🔧 P1修复:任务榜组件实际使用 TasksCache_${status}_${sort} 键(非 ListCache_*),按真实键格式精准清除
|
| 23 |
const statuses = ['', 'open', 'in_progress', 'submitted', 'completed', 'disputed'];
|
| 24 |
// 任务榜排序值:latest, price, deadline, views, daily_views, likes, favorites
|
|
|
|
| 105 |
<button id="btn-back" style="background: rgba(51,51,51,0.8); border: 1px solid rgba(85,85,85,0.8); color: #fff; padding: 6px 14px; border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: bold; display: flex; align-items: center; gap: 6px;" onmouseover="this.style.background='#4CAF50'" onmouseout="this.style.background='rgba(51,51,51,0.8)'">
|
| 106 |
⬅ ${t('common.back')}
|
| 107 |
</button>
|
| 108 |
+
<span style="flex: 1; text-align: center; font-size: 15px; font-weight: bold; color: #fff;">📝 ${isEditMode ? t('task.edit_task') : t('task.publish')}</span>
|
| 109 |
<div style="width: 60px;"></div>
|
| 110 |
</div>
|
| 111 |
|
|
|
|
| 117 |
<label style="display: block; color: #fff; font-size: 13px; font-weight: bold; margin-bottom: 6px;">
|
| 118 |
📌 ${t('task.task_title')} <span style="color: #F44336;">*</span>
|
| 119 |
</label>
|
| 120 |
+
<input type="text" id="task-title" maxlength="50" placeholder="${t('task.title_placeholder')}" value="${escapeHtml(editTitle)}"
|
| 121 |
style="width: 100%; padding: 10px 12px; background: var(--comfy-menu-bg); border: 1px solid var(--border-color, #333); border-radius: 8px; color: #fff; font-size: 14px; box-sizing: border-box;">
|
| 122 |
</div>
|
| 123 |
|
|
|
|
| 127 |
📄 ${t('task.description')} <span style="color: #F44336;">*</span>
|
| 128 |
</label>
|
| 129 |
<textarea id="task-description" rows="6" maxlength="2000" placeholder="${t('task.description_placeholder')}"
|
| 130 |
+
style="width: 100%; padding: 10px 12px; background: var(--comfy-menu-bg); border: 1px solid var(--border-color, #333); border-radius: 8px; color: #fff; font-size: 14px; resize: none; box-sizing: border-box; line-height: 1.5;">${escapeHtml(editDescription)}</textarea>
|
| 131 |
<div style="text-align: right; font-size: 11px; color: #666; margin-top: 4px;">
|
| 132 |
<span id="desc-count">${editDescription.length}</span>/2000
|
| 133 |
</div>
|
|
|
|
| 151 |
<label style="display: block; color: #fff; font-size: 13px; font-weight: bold; margin-bottom: 6px;">
|
| 152 |
🔗 ${t('task.reference_link_optional')}
|
| 153 |
</label>
|
| 154 |
+
<input type="url" id="ref-link" placeholder="https://..." value="${escapeHtml(editRefLink)}"
|
| 155 |
style="width: 100%; padding: 10px 12px; background: var(--comfy-menu-bg); border: 1px solid var(--border-color, #333); border-radius: 8px; color: #fff; font-size: 14px; box-sizing: border-box;">
|
| 156 |
</div>
|
| 157 |
|
|
|
|
| 311 |
const renderImagePreview = () => {
|
| 312 |
imagePreview.innerHTML = selectedImages.map((img, i) => `
|
| 313 |
<div style="position: relative; padding-top: 100%; background: var(--comfy-menu-bg); border-radius: 8px; overflow: hidden;">
|
| 314 |
+
<img src="${escapeHtml(img.dataUrl)}" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: cover;">
|
| 315 |
<button class="btn-remove-img" data-index="${i}" style="position: absolute; top: 4px; right: 4px; width: 20px; height: 20px; background: rgba(244,67,54,0.9); border: none; border-radius: 50%; color: #fff; font-size: 12px; cursor: pointer; display: flex; align-items: center; justify-content: center;">×</button>
|
| 316 |
</div>
|
| 317 |
`).join("");
|
|
|
|
| 386 |
|
| 387 |
await api.updateTask(editTaskData.id, updateData);
|
| 388 |
|
| 389 |
+
showToast(t('task.edit_success'), "success");
|
| 390 |
} else {
|
| 391 |
// 新建模式:创建任务
|
| 392 |
publishBtn.textContent = `⏳ ${t('task.publishing')}...`;
|
|
|
|
| 414 |
|
| 415 |
} catch (err) {
|
| 416 |
console.error(isEditMode ? "修改任务失败:" : "发布任务失败:", err);
|
| 417 |
+
showToast(err.message || (isEditMode ? t('task.edit_failed') : t('task.publish_failed')), "error");
|
| 418 |
} finally {
|
| 419 |
const publishBtn = container.querySelector("#btn-publish");
|
| 420 |
publishBtn.disabled = false;
|
前端页面/task/申诉详情组件.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
| 8 |
import { api } from "../core/网络请求API.js";
|
| 9 |
import { showToast } from "../components/UI交互提示组件.js";
|
| 10 |
import { t, getLanguage } from "../components/用户体验增强.js";
|
| 11 |
-
import { isAdmin, IS_WEB_MODE } from "../core/全局配置.js";
|
| 12 |
|
| 13 |
/**
|
| 14 |
* 创建申诉详情视图
|
|
@@ -37,7 +37,7 @@ async function loadDisputeDetail(container, disputeId, currentUser, onBack) {
|
|
| 37 |
const dispute = res.data;
|
| 38 |
renderDisputeDetail(container, dispute, currentUser, onBack);
|
| 39 |
} catch (err) {
|
| 40 |
-
container.innerHTML = `<div style="text-align: center; padding: 40px; color: #f44;">❌ ${err.message}</div>`;
|
| 41 |
}
|
| 42 |
}
|
| 43 |
|
|
@@ -110,7 +110,7 @@ function renderDisputeDetail(container, dispute, currentUser, onBack) {
|
|
| 110 |
<!-- 任务信息 -->
|
| 111 |
<div class="dispute-section">
|
| 112 |
<div class="dispute-section-title"><span class="icon">📋</span> ${t('dispute.related_task')}</div>
|
| 113 |
-
<div style="color: var(--input-text); font-size: 15px;">${dispute.task_title || t('common.unknown_task')}</div>
|
| 114 |
<div style="font-size: 12px; color: #888; margin-top: 4px;">${t('dispute.task_id')}: ${dispute.task_id}</div>
|
| 115 |
</div>
|
| 116 |
|
|
@@ -118,16 +118,16 @@ function renderDisputeDetail(container, dispute, currentUser, onBack) {
|
|
| 118 |
<div class="dispute-section">
|
| 119 |
<div class="dispute-section-title"><span class="icon">👥</span> ${t('dispute.parties')}</div>
|
| 120 |
<div class="dispute-party">
|
| 121 |
-
<img class="dispute-party-avatar" src="${dispute.publisher_avatar || (IS_WEB_MODE ? 'https://api.dicebear.com/7.x/avataaars/svg' : '/community_hub/image?url=https://api.dicebear.com/7.x/avataaars/svg')}" alt="">
|
| 122 |
<div class="dispute-party-info">
|
| 123 |
-
<div class="dispute-party-name">${dispute.publisher_name || dispute.publisher}</div>
|
| 124 |
<div class="dispute-party-role">${t('dispute.publisher')} ${dispute.initiator_role === "publisher" ? `(${t('dispute.initiator')})` : `(${t('dispute.respondent')})`}</div>
|
| 125 |
</div>
|
| 126 |
</div>
|
| 127 |
<div class="dispute-party">
|
| 128 |
-
<img class="dispute-party-avatar" src="${dispute.assignee_avatar || (IS_WEB_MODE ? 'https://api.dicebear.com/7.x/avataaars/svg' : '/community_hub/image?url=https://api.dicebear.com/7.x/avataaars/svg')}" alt="">
|
| 129 |
<div class="dispute-party-info">
|
| 130 |
-
<div class="dispute-party-name">${dispute.assignee_name || dispute.assignee}</div>
|
| 131 |
<div class="dispute-party-role">${t('task.assignee')} ${dispute.initiator_role === "assignee" ? `(${t('dispute.initiator')})` : `(${t('dispute.respondent')})`}</div>
|
| 132 |
</div>
|
| 133 |
</div>
|
|
@@ -137,7 +137,7 @@ function renderDisputeDetail(container, dispute, currentUser, onBack) {
|
|
| 137 |
<div class="dispute-section">
|
| 138 |
<div class="dispute-section-title"><span class="icon">📝</span> ${t('dispute.initiator_statement')}</div>
|
| 139 |
<div class="dispute-content initiator">
|
| 140 |
-
<div style="color: var(--input-text);">${dispute.reason || t('common.none')}</div>
|
| 141 |
${_renderEvidenceGallery(dispute.evidence)}
|
| 142 |
<div class="dispute-time">${t('dispute.submitted_at')} ${formatTime(dispute.created_at)}</div>
|
| 143 |
</div>
|
|
@@ -148,7 +148,7 @@ function renderDisputeDetail(container, dispute, currentUser, onBack) {
|
|
| 148 |
<div class="dispute-section-title"><span class="icon">💬</span> ${t('dispute.respondent_response')}</div>
|
| 149 |
${dispute.response ? `
|
| 150 |
<div class="dispute-content respondent">
|
| 151 |
-
<div style="color: var(--input-text);">${dispute.response}</div>
|
| 152 |
${_renderEvidenceGallery(dispute.response_evidence)}
|
| 153 |
<div class="dispute-time">${t('dispute.responded_at')} ${formatTime(dispute.responded_at)}</div>
|
| 154 |
</div>
|
|
@@ -156,7 +156,7 @@ function renderDisputeDetail(container, dispute, currentUser, onBack) {
|
|
| 156 |
<div style="color: #888; padding: 12px; text-align: center;">${t('dispute.no_response')}</div>
|
| 157 |
${canRespond ? `
|
| 158 |
<div class="dispute-respond-form">
|
| 159 |
-
<textarea id="respondText" placeholder="${t('dispute.
|
| 160 |
<div class="dispute-upload-area" id="uploadArea">📷 ${t('dispute.click_upload_evidence_optional')}</div>
|
| 161 |
<input type="file" id="fileInput" accept="image/*" multiple style="display: none;">
|
| 162 |
<div class="dispute-uploaded-images" id="uploadedImages"></div>
|
|
@@ -174,7 +174,7 @@ function renderDisputeDetail(container, dispute, currentUser, onBack) {
|
|
| 174 |
<div class="dispute-resolution-title">⚖️ ${t('dispute.resolution_result')}</div>
|
| 175 |
<div class="dispute-resolution-result">${getResolutionText(dispute.resolution, dispute.resolution_ratio)}</div>
|
| 176 |
${dispute.resolution_note ? `
|
| 177 |
-
<div class="dispute-resolution-note">${dispute.resolution_note}</div>
|
| 178 |
` : ""}
|
| 179 |
<div class="dispute-time">${t('dispute.resolved_at')} ${formatTime(dispute.resolved_at)}</div>
|
| 180 |
</div>
|
|
@@ -184,6 +184,13 @@ function renderDisputeDetail(container, dispute, currentUser, onBack) {
|
|
| 184 |
// 返回按钮
|
| 185 |
container.querySelector("#backBtn").onclick = () => onBack && onBack();
|
| 186 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
// 回应表单逻辑
|
| 188 |
if (canRespond) {
|
| 189 |
const uploadedEvidence = [];
|
|
@@ -212,7 +219,7 @@ function renderDisputeDetail(container, dispute, currentUser, onBack) {
|
|
| 212 |
function renderUploadedImages() {
|
| 213 |
uploadedImages.innerHTML = uploadedEvidence.map((url, idx) => `
|
| 214 |
<div class="img-wrapper">
|
| 215 |
-
<img src="${url}">
|
| 216 |
<button class="remove-btn" data-idx="${idx}">×</button>
|
| 217 |
</div>
|
| 218 |
`).join("");
|
|
@@ -266,6 +273,6 @@ function formatTime(timestamp) {
|
|
| 266 |
function _renderEvidenceGallery(images) {
|
| 267 |
if (!images || images.length === 0) return "";
|
| 268 |
return `<div class="dispute-evidence">
|
| 269 |
-
${images.map(img => `<img src="${img}"
|
| 270 |
</div>`;
|
| 271 |
}
|
|
|
|
| 8 |
import { api } from "../core/网络请求API.js";
|
| 9 |
import { showToast } from "../components/UI交互提示组件.js";
|
| 10 |
import { t, getLanguage } from "../components/用户体验增强.js";
|
| 11 |
+
import { isAdmin, IS_WEB_MODE, escapeHtml } from "../core/全局配置.js";
|
| 12 |
|
| 13 |
/**
|
| 14 |
* 创建申诉详情视图
|
|
|
|
| 37 |
const dispute = res.data;
|
| 38 |
renderDisputeDetail(container, dispute, currentUser, onBack);
|
| 39 |
} catch (err) {
|
| 40 |
+
container.innerHTML = `<div style="text-align: center; padding: 40px; color: #f44;">❌ ${escapeHtml(err.message)}</div>`;
|
| 41 |
}
|
| 42 |
}
|
| 43 |
|
|
|
|
| 110 |
<!-- 任务信息 -->
|
| 111 |
<div class="dispute-section">
|
| 112 |
<div class="dispute-section-title"><span class="icon">📋</span> ${t('dispute.related_task')}</div>
|
| 113 |
+
<div style="color: var(--input-text); font-size: 15px;">${escapeHtml(dispute.task_title || t('common.unknown_task'))}</div>
|
| 114 |
<div style="font-size: 12px; color: #888; margin-top: 4px;">${t('dispute.task_id')}: ${dispute.task_id}</div>
|
| 115 |
</div>
|
| 116 |
|
|
|
|
| 118 |
<div class="dispute-section">
|
| 119 |
<div class="dispute-section-title"><span class="icon">👥</span> ${t('dispute.parties')}</div>
|
| 120 |
<div class="dispute-party">
|
| 121 |
+
<img class="dispute-party-avatar" src="${escapeHtml(dispute.publisher_avatar || (IS_WEB_MODE ? 'https://api.dicebear.com/7.x/avataaars/svg' : '/community_hub/image?url=https://api.dicebear.com/7.x/avataaars/svg'))}" alt="">
|
| 122 |
<div class="dispute-party-info">
|
| 123 |
+
<div class="dispute-party-name">${escapeHtml(dispute.publisher_name || dispute.publisher)}</div>
|
| 124 |
<div class="dispute-party-role">${t('dispute.publisher')} ${dispute.initiator_role === "publisher" ? `(${t('dispute.initiator')})` : `(${t('dispute.respondent')})`}</div>
|
| 125 |
</div>
|
| 126 |
</div>
|
| 127 |
<div class="dispute-party">
|
| 128 |
+
<img class="dispute-party-avatar" src="${escapeHtml(dispute.assignee_avatar || (IS_WEB_MODE ? 'https://api.dicebear.com/7.x/avataaars/svg' : '/community_hub/image?url=https://api.dicebear.com/7.x/avataaars/svg'))}" alt="">
|
| 129 |
<div class="dispute-party-info">
|
| 130 |
+
<div class="dispute-party-name">${escapeHtml(dispute.assignee_name || dispute.assignee)}</div>
|
| 131 |
<div class="dispute-party-role">${t('task.assignee')} ${dispute.initiator_role === "assignee" ? `(${t('dispute.initiator')})` : `(${t('dispute.respondent')})`}</div>
|
| 132 |
</div>
|
| 133 |
</div>
|
|
|
|
| 137 |
<div class="dispute-section">
|
| 138 |
<div class="dispute-section-title"><span class="icon">📝</span> ${t('dispute.initiator_statement')}</div>
|
| 139 |
<div class="dispute-content initiator">
|
| 140 |
+
<div style="color: var(--input-text);">${escapeHtml(dispute.reason || t('common.none'))}</div>
|
| 141 |
${_renderEvidenceGallery(dispute.evidence)}
|
| 142 |
<div class="dispute-time">${t('dispute.submitted_at')} ${formatTime(dispute.created_at)}</div>
|
| 143 |
</div>
|
|
|
|
| 148 |
<div class="dispute-section-title"><span class="icon">💬</span> ${t('dispute.respondent_response')}</div>
|
| 149 |
${dispute.response ? `
|
| 150 |
<div class="dispute-content respondent">
|
| 151 |
+
<div style="color: var(--input-text);">${escapeHtml(dispute.response)}</div>
|
| 152 |
${_renderEvidenceGallery(dispute.response_evidence)}
|
| 153 |
<div class="dispute-time">${t('dispute.responded_at')} ${formatTime(dispute.responded_at)}</div>
|
| 154 |
</div>
|
|
|
|
| 156 |
<div style="color: #888; padding: 12px; text-align: center;">${t('dispute.no_response')}</div>
|
| 157 |
${canRespond ? `
|
| 158 |
<div class="dispute-respond-form">
|
| 159 |
+
<textarea id="respondText" placeholder="${t('dispute.respond_placeholder')}"></textarea>
|
| 160 |
<div class="dispute-upload-area" id="uploadArea">📷 ${t('dispute.click_upload_evidence_optional')}</div>
|
| 161 |
<input type="file" id="fileInput" accept="image/*" multiple style="display: none;">
|
| 162 |
<div class="dispute-uploaded-images" id="uploadedImages"></div>
|
|
|
|
| 174 |
<div class="dispute-resolution-title">⚖️ ${t('dispute.resolution_result')}</div>
|
| 175 |
<div class="dispute-resolution-result">${getResolutionText(dispute.resolution, dispute.resolution_ratio)}</div>
|
| 176 |
${dispute.resolution_note ? `
|
| 177 |
+
<div class="dispute-resolution-note">${escapeHtml(dispute.resolution_note)}</div>
|
| 178 |
` : ""}
|
| 179 |
<div class="dispute-time">${t('dispute.resolved_at')} ${formatTime(dispute.resolved_at)}</div>
|
| 180 |
</div>
|
|
|
|
| 184 |
// 返回按钮
|
| 185 |
container.querySelector("#backBtn").onclick = () => onBack && onBack();
|
| 186 |
|
| 187 |
+
// 🔒 XSS防护:证据图片的点击放大改为 DOM 事件绑定
|
| 188 |
+
// (原内联 onclick="window.open('${img}')" 中的引号即使转义,也会被浏览器先还原
|
| 189 |
+
// 再交给 JS 解析,依然能被注入;data-full 经 HTML 解码后仍是原始 URL,行为不变)
|
| 190 |
+
container.querySelectorAll(".dispute-evidence-img").forEach(imgEl => {
|
| 191 |
+
imgEl.onclick = () => window.open(imgEl.dataset.full, '_blank');
|
| 192 |
+
});
|
| 193 |
+
|
| 194 |
// 回应表单逻辑
|
| 195 |
if (canRespond) {
|
| 196 |
const uploadedEvidence = [];
|
|
|
|
| 219 |
function renderUploadedImages() {
|
| 220 |
uploadedImages.innerHTML = uploadedEvidence.map((url, idx) => `
|
| 221 |
<div class="img-wrapper">
|
| 222 |
+
<img src="${escapeHtml(url)}">
|
| 223 |
<button class="remove-btn" data-idx="${idx}">×</button>
|
| 224 |
</div>
|
| 225 |
`).join("");
|
|
|
|
| 273 |
function _renderEvidenceGallery(images) {
|
| 274 |
if (!images || images.length === 0) return "";
|
| 275 |
return `<div class="dispute-evidence">
|
| 276 |
+
${images.map(img => `<img class="dispute-evidence-img" src="${escapeHtml(img)}" data-full="${escapeHtml(img)}">`).join("")}
|
| 277 |
</div>`;
|
| 278 |
}
|