+ alt="">
${isVideo ? `
`;
@@ -120,7 +121,7 @@ export function buildProfileHTML(userData, isMe, isSettingsView, isFollowing, fo
${escapeHtml(userData.name)}
- ${userData.gender === 'male' ? '♂️' : (userData.gender === 'female' ? '♀️' : '🔒')} ${userData.age !== undefined && userData.age !== null ? userData.age : t('profile.unknown_age')}${t('profile.age_years')}
+ ${userData.gender === 'male' ? '♂️' : (userData.gender === 'female' ? '♀️' : '🔒')} ${userData.age !== undefined && userData.age !== null ? escapeHtml(userData.age) : t('profile.unknown_age')}${t('profile.age_years')}
📍 ${escapeHtml(userData.country) || t('profile.location_secret')} - ${escapeHtml(userData.region || '')}
${escapeHtml(userData.intro) || t('profile.no_intro')}
diff --git "a/\345\211\215\347\253\257\351\241\265\351\235\242/profile/\344\270\252\344\272\272\344\270\255\345\277\203_\350\265\236\350\265\217\347\273\204\344\273\266.js" "b/\345\211\215\347\253\257\351\241\265\351\235\242/profile/\344\270\252\344\272\272\344\270\255\345\277\203_\350\265\236\350\265\217\347\273\204\344\273\266.js"
index 800aece1dff5ffc200cb9712223b72dc03ffccac..3ae1874762dc5835ffe6ca096f36b4540ae58dbc 100644
--- "a/\345\211\215\347\253\257\351\241\265\351\235\242/profile/\344\270\252\344\272\272\344\270\255\345\277\203_\350\265\236\350\265\217\347\273\204\344\273\266.js"
+++ "b/\345\211\215\347\253\257\351\241\265\351\235\242/profile/\344\270\252\344\272\272\344\270\255\345\277\203_\350\265\236\350\265\217\347\273\204\344\273\266.js"
@@ -1,198 +1,155 @@
-// 前端页面/profile/个人中心_赞赏组件.js
-// ==========================================
-// 🎁 打赏弹窗组件
-// ==========================================
-// 作用:提供打赏弹窗界面和交互逻辑
-// 关联文件:
-// - 打赏等级工具.js (等级计算与显示)
-// - 网络请求API.js (发送打赏请求)
-// - 全局弹窗管理器.js (弹窗管理)
-// ==========================================
-
-import { api } from "../core/网络请求API.js";
+// 赞赏弹窗:原账号/父页面范围,新余额与提交锁;已提交POST不自动重放。
+import { api, captureRequestSession, requestCancelManager } from "../core/网络请求API.js";
+import { API, CACHE, TIP_LEVELS } from "../core/全局配置.js";
+import { getUser, eventBus, EVENTS } from "../core/状态管理.js";
+import { createTaskViewScope, subscribeTaskSessionChanges } from "../task/任务视图生命周期.js";
import { showToast, showConfirm } from "../components/UI交互提示组件.js";
import { globalModal } from "../components/全局弹窗管理器.js";
import { openUserProfileModal } from "./个人中心视图.js";
-import { isMaxTipLevel, renderTipLevelHTML, getTipLevelDescription, calculateTipLevel } from "../components/打赏等级工具.js";
-
-// 最高等级积分上限(9个太阳 = 22500积分)
-const MAX_TIP_POINTS = 22500;
-
-/** 校验打赏金额,返回错误消息或null */
-function _validateTipAmount(amount) {
- if (!amount || amount <= 0) return "打赏金额必须大于 0";
- if (amount > MAX_TIP_POINTS) return `单次打赏上限为 ${MAX_TIP_POINTS} 积分`;
- return null;
-}
+import { renderTipLevelHTML, getTipLevelDescription, calculateTipLevel } from "../components/打赏等级工具.js";
+import { t } from "../components/用户体验增强.js";
-/** 余额不足时引导充值 */
-async function _promptRechargeIfNeeded(currentUser) {
- if (await showConfirm("您的积分余额不足,是否立刻前往个人中心充值?")) {
- globalModal.closeTopModal();
- openUserProfileModal(currentUser);
- }
-}
+const active = new Map();
+let sequence = 0;
+const validBalance = value => Number.isSafeInteger(value) && value >= 0;
+const validAmount = value => Number.isSafeInteger(value) && value > 0 && value <= TIP_LEVELS.MAX_POINTS;
+const element = (tag, className, text) => {
+ const node = document.createElement(tag); node.className = className;
+ if (text !== undefined) node.textContent = text;
+ return node;
+};
-/**
- * 打开打赏弹窗
- * @param {object} currentUser - 当前登录用户
- * @param {object} targetUser - 目标用户(被打赏者)
- * @param {function} onSuccess - 成功回调
- * @param {string} itemId - 可选,关联的内容ID
- */
-export async function openTipModal(currentUser, targetUser, onSuccess, itemId = null) {
- // ==========================================
- // 刷新钱包余额,避免使用缓存的旧值
- // ==========================================
+/** 原四参数调用保持;options.isCurrent可提供原父页面检查。 */
+export async function openTipModal(currentUser, targetUser, onSuccess, itemId = null, options = {}) {
+ const account = currentUser?.account, target = targetUser?.account;
+ const matches = captureRequestSession(), parentCurrent = options.isCurrent || (() => true);
+ const sessionMatches = () => matches() && getUser()?.account === account;
+ if (!account) { showToast(t('feedback.login_required'), "warning"); return; }
+ if (!sessionMatches() || !parentCurrent() || typeof target !== "string" || !target.trim()) return;
+ if (account === target) { showToast(t('creator.cannot_tip_self'), "warning"); return; }
+ const key = JSON.stringify([account, target, itemId]);
+ if (active.has(key)) return;
+ const id = `tip-${++sequence}`, componentId = `tip-wallet-${sequence}`;
+ let loading = true, modal, observer, submitted = false, confirming = false;
+ const confirmationController = new AbortController();
+ const container = element("div", "tip-dialog");
+ const scope = createTaskViewScope({
+ sessionMatches,
+ connected: () => parentCurrent() && (loading || !!modal?.isConnected),
+ subscribe: notify => subscribeTaskSessionChanges(notify, { target: window, eventBus, events: EVENTS,
+ userKey: CACHE.LEGACY_KEYS.USER, tokenKey: CACHE.LEGACY_KEYS.TOKEN }),
+ cancelReads: () => requestCancelManager.cancelAll(componentId), invalidate: () => {}
+ });
+ active.set(key, scope);
+ scope.addCleanup(() => {
+ observer?.disconnect(); confirmationController.abort();
+ globalModal.closeModal(modal); container.replaceChildren();
+ if (active.get(key) === scope) active.delete(key);
+ });
+ observer = new MutationObserver(() => { scope.isCurrent(); });
+ observer.observe(document.body, { childList: true, subtree: true });
+ container._cleanup = () => scope.dispose();
+ const readOptions = { componentId, noCache: true, timeout: API.BACKGROUND_TIMEOUT, retries: API.BACKGROUND_RETRIES };
+ let balance;
try {
- const walletRes = await api.getWallet(currentUser.account, { noCache: true });
- if (walletRes && walletRes.status === "success") {
- currentUser.balance = walletRes.balance || 0;
- }
- } catch (err) {
- console.warn("刷新钱包余额失败,使用缓存值:", err);
- // 失败时继续使用缓存值,不阻断打赏流程
+ const response = await api.getWallet(account, readOptions);
+ if (!scope.isCurrent()) return;
+ if (response?.status !== "success" || !validBalance(response.balance)) throw new Error();
+ balance = response.balance;
+ } catch (_) {
+ if (scope.isCurrent()) showToast(t('common.error'), "error");
+ scope.dispose(); return;
}
-
- // ==========================================
- // 检查目标用户是否已满级
- // ==========================================
- // 如果是打赏具体内容,从内容的 tip_board 检查
- // 如果是打赏用户,从用户的 tip_board 检查
- // 后续版本实现:查询当前用户对目标的累计打赏金额(tip_board 已包含累计数据,待新增专用查询接口)
-
- const container = document.createElement("div");
- container.style.color = "#eee";
-
- // 当前用户余额(使用刷新后的值)
- const userBalance = currentUser.balance || 0;
-
- container.innerHTML = `
-
- 当前余额: ${userBalance} 积分
-
-
-
-
-
-
-
-
-
-
打赏后等级预览
-
- ${renderTipLevelHTML(100, false)}
- 支持者
-
-
-
-
-
-
📊 等级规则:
-
- • 1-100 积分 = 1⭐ 星星,101-200 积分 = 2⭐ 星星,以此类推
- • 每 5 星星 = 1🌙 月亮 (500积分)
- • 每 5 月亮 = 1☀️ 太阳 (2500积分)
- • 最高等级:9☀️ 太阳 (22500积分)
-
-
-
-
-
-
-
-
- `;
-
- // ==========================================
- // 绑定金额输入事件,实时更新等级预览
- // ==========================================
- const amountInput = container.querySelector("#tip-amount");
- const previewLevel = container.querySelector("#preview-level");
-
+ const balanceRow = element("div", "tip-dialog__balance", `${t('wallet.current_balance')}: ${balance} ${t('wallet.credits')}`);
+ const amountLabel = element("label", "tip-dialog__label", t('tip.amount_label'));
+ const amountInput = element("input", "tip-dialog__input");
+ amountInput.id = `${id}-amount`; amountLabel.htmlFor = amountInput.id;
+ amountInput.type = "number"; amountInput.min = "1"; amountInput.max = String(TIP_LEVELS.MAX_POINTS);
+ amountInput.step = "1"; amountInput.value = String(TIP_LEVELS.POINTS_PER_STAR);
+ const preview = element("div", "tip-dialog__preview");
+ preview.append(element("div", "tip-dialog__muted", t('tip.level_preview')));
+ const previewLevel = element("div", "tip-dialog__level"); preview.append(previewLevel);
+ const rules = element("div", "tip-dialog__rules"); rules.append(element("div", "", t('tip.rules_title')));
+ const ruleList = element("ul", "");
+ for (const text of [t('tip.rule_star', { points: TIP_LEVELS.POINTS_PER_STAR }),
+ t('tip.rule_moon', { stars: TIP_LEVELS.STARS_PER_MOON }),
+ t('tip.rule_sun', { moons: TIP_LEVELS.MOONS_PER_SUN }),
+ t('tip.rule_max', { suns: TIP_LEVELS.MAX_SUNS, points: TIP_LEVELS.MAX_POINTS })]) ruleList.append(element("li", "", text));
+ rules.append(ruleList);
+ const anonymousLabel = element("label", "tip-dialog__anonymous");
+ const anonymousInput = element("input", ""); anonymousInput.type = "checkbox";
+ anonymousLabel.append(anonymousInput, document.createTextNode(t('tip.anonymous')));
+ const status = element("div", "tip-dialog__status"); status.hidden = true; status.setAttribute("role", "alert");
+ const submit = element("button", "tip-dialog__submit", t('tip.confirm')); submit.type = "button";
+ container.append(balanceRow, amountLabel, amountInput, preview, rules, anonymousLabel, status, submit);
+ const amountValue = () => amountInput.value.trim() ? Number(amountInput.value) : NaN;
const updatePreview = () => {
- const amount = parseInt(amountInput.value) || 0;
- const validationError = _validateTipAmount(amount);
- if (validationError) {
- previewLevel.innerHTML = amount > MAX_TIP_POINTS
- ? `
⚠️ 超过单次上限 ${MAX_TIP_POINTS} 积分`
- : `
请输入有效金额`;
+ if (!scope.isCurrent()) return;
+ const amount = amountValue(); previewLevel.replaceChildren();
+ if (!validAmount(amount)) {
+ previewLevel.textContent = amount > TIP_LEVELS.MAX_POINTS ? t('tip.amount_limit', { max: TIP_LEVELS.MAX_POINTS }) : t('tip.integer_amount');
return;
}
-
- const levelHtml = renderTipLevelHTML(amount, false);
- const description = getTipLevelDescription(amount);
- const level = calculateTipLevel(amount);
-
- if (level.isMaxLevel) {
- previewLevel.innerHTML = `${levelHtml}
👑 至尊赞助者`;
- } else {
- previewLevel.innerHTML = `${levelHtml}
${description}`;
- }
+ const badge = element("span", ""); badge.innerHTML = renderTipLevelHTML(amount, false);
+ previewLevel.append(badge, element("span", "", getTipLevelDescription(amount)));
};
-
amountInput.addEventListener("input", updatePreview);
-
- // ==========================================
- // 绑定提交按钮事件
- // ==========================================
- const btn = container.querySelector("#btn-submit-tip");
- btn.onclick = async () => {
- const amount = parseInt(amountInput.value);
- const isAnon = container.querySelector("#tip-anonymous").checked;
-
- // 基本校验
- const validationError = _validateTipAmount(amount);
- if (validationError) return showToast(validationError, "warning");
-
- // 本地前置余额校验
- if (userBalance < amount) {
- await _promptRechargeIfNeeded(currentUser);
+ submit.onclick = async () => {
+ if (!scope.isCurrent() || submitted || confirming) return;
+ const amount = amountValue(), isAnonymous = anonymousInput.checked;
+ if (!validAmount(amount)) {
+ showToast(amount > TIP_LEVELS.MAX_POINTS ? t('tip.amount_limit', { max: TIP_LEVELS.MAX_POINTS }) : t('tip.integer_amount'), "warning"); return;
+ }
+ if (balance < amount) {
+ confirming = true;
+ try {
+ const go = await showConfirm(t('tip.balance_short_recharge'), { signal: confirmationController.signal });
+ if (!go || !scope.isCurrent()) return;
+ globalModal.closeModal(modal);
+ if (sessionMatches() && parentCurrent()) await openUserProfileModal(currentUser);
+ } catch (_) { if (sessionMatches() && parentCurrent()) showToast(t('common.error'), "error"); }
+ finally { confirming = false; }
return;
}
-
- btn.disabled = true;
- btn.innerText = "处理中...";
-
+ submitted = true; submit.disabled = amountInput.disabled = anonymousInput.disabled = true;
+ submit.textContent = t('common.processing'); container.setAttribute("aria-busy", "true");
+ let response;
try {
- const res = await api.tipUser(currentUser.account, targetUser.account, amount, isAnon, itemId);
-
- const level = calculateTipLevel(amount);
- showToast(level.isMaxLevel ? "🎉 恭喜您成为至尊赞助者!已达最高等级。" : "🎉 打赏成功!感谢您的慷慨支持。", "success");
-
- // 🐛 Bug修复:打赏成功后主动刷新余额,避免依赖缓存旧数据
- let freshBalance = res.balance;
- try {
- const freshWallet = await api.getWallet(currentUser.account, { noCache: true });
- if (freshWallet && freshWallet.status === "success") {
- freshBalance = freshWallet.balance;
- }
- } catch (walletErr) {
- console.warn("打赏后刷新余额失败,使用tipUser响应中的balance:", walletErr);
- }
- currentUser.balance = freshBalance;
-
- // 派发全局余额更新事件,通知其他组件同步
- window.dispatchEvent(new CustomEvent('comfy-wallet-update', { detail: { balance: freshBalance } }));
-
- globalModal.closeTopModal();
- if (onSuccess) onSuccess(freshBalance);
- } catch (err) {
- // 余额不足引导充值
- if (err.message && err.message.includes("余额不足")) {
- await _promptRechargeIfNeeded(currentUser);
- } else {
- showToast(err.message, "error");
+ response = await api.tipUser(account, target, amount, isAnonymous, itemId);
+ if (!scope.isCurrent()) return;
+ if (response?.status !== "success") throw new Error();
+ } catch (_) {
+ if (scope.isCurrent()) {
+ container.setAttribute("aria-busy", "false");
+ status.hidden = false; status.textContent = t('tip.unknown_result');
+ submit.textContent = t('tip.confirm');
}
- btn.disabled = false;
- btn.innerText = "确 认 打 赏";
+ return;
+ }
+ let freshBalance = validBalance(response.balance) ? response.balance : undefined;
+ if (freshBalance === undefined) {
+ try {
+ const wallet = await api.getWallet(account, readOptions);
+ if (!scope.isCurrent()) return;
+ if (wallet?.status === "success" && validBalance(wallet.balance)) freshBalance = wallet.balance;
+ } catch (_) { /* 资金已经提交,补充读取失败不改写成功。 */ }
+ }
+ if (!scope.isCurrent()) return;
+ if (freshBalance !== undefined) currentUser.balance = freshBalance;
+ const level = calculateTipLevel(amount);
+ showToast(t(level.isMaxLevel ? 'tip.supreme_sponsor_success' : 'tip.success_thanks'), "success");
+ if (freshBalance === undefined) showToast(t('tip.balance_refresh_failed'), "warning");
+ globalModal.closeModal(modal);
+ if (!sessionMatches() || !parentCurrent()) return;
+ if (freshBalance !== undefined) {
+ try { window.dispatchEvent(new CustomEvent('comfy-wallet-update', { detail: { account, balance: freshBalance } })); }
+ catch (_) { /* 派生界面故障不重放打赏。 */ }
+ if (!sessionMatches() || !parentCurrent()) return;
+ try { await onSuccess?.(freshBalance); } catch (_) { console.warn('赞赏成功,视图更新失败'); }
}
};
-
- globalModal.openModal(`🎁 赞赏 ${targetUser.name || targetUser.account}`, container, { width: "340px" });
+ updatePreview();
+ modal = globalModal.openModal(t('tip.dialog_title', { name: targetUser.name || target }), container, { width: "340px" });
+ loading = false;
+ return modal;
}
diff --git "a/\345\211\215\347\253\257\351\241\265\351\235\242/profile/\344\270\252\344\272\272\344\270\255\345\277\203\350\247\206\345\233\276.js" "b/\345\211\215\347\253\257\351\241\265\351\235\242/profile/\344\270\252\344\272\272\344\270\255\345\277\203\350\247\206\345\233\276.js"
index 8da6e1d2976b83035d087be138399aef71a1f5d4..3965f4c25dc5aee0050de992230bac3c135d6c4f 100644
--- "a/\345\211\215\347\253\257\351\241\265\351\235\242/profile/\344\270\252\344\272\272\344\270\255\345\277\203\350\247\206\345\233\276.js"
+++ "b/\345\211\215\347\253\257\351\241\265\351\235\242/profile/\344\270\252\344\272\272\344\270\255\345\277\203\350\247\206\345\233\276.js"
@@ -1,5 +1,5 @@
// 前端页面/profile/个人中心视图.js
-import { api } from "../core/网络请求API.js";
+import { api, captureRequestSession, requestCancelManager } from "../core/网络请求API.js";
import { openChatModal } from "../social/私信聊天组件.js";
import { createSettingsForm } from "./个人设置表单组件.js";
import { renderProfileListContent } from "./个人列表组件.js";
@@ -9,99 +9,72 @@ import { t } from "../components/用户体验增强.js";
// 资金模块 (充值/提现)
// ==========================================
import { openRechargeModal } from "../market/资金与钱包_充值组件.js";
-import { openWithdrawModal } from "../market/资金与钱包_提现组件.js";
-import { buildProfileHTML } from "./个人中心_UI模板.js";
-import { openTipModal } from "./个人中心_赞赏组件.js";
-import { getBannerCacheKey, getCurrentAccount, IS_WEB_MODE } from "../core/全局配置.js";
-import { logoutAndClearUserData } from "../core/状态管理.js";
+import { openWithdrawModal } from "../market/资金与钱包_提现组件.js";
+import { buildProfileHTML } from "./个人中心_UI模板.js";
+import { openTipModal } from "./个人中心_赞赏组件.js";
+import { getBannerCacheKey, getCurrentAccount, IS_WEB_MODE, CACHE, UI, API, getCachedProfile, setCachedProfile } from "../core/全局配置.js";
+import { logoutAndClearUserData, eventBus, EVENTS } from "../core/状态管理.js";
+import { createTaskViewScope, subscribeTaskSessionChanges } from '../task/任务视图生命周期.js';
+import { endProfileListView } from './个人列表缓存.js';
+import { normalizeProfileData, normalizeWalletData, mergeStoredProfile } from './个人资料工具.js';
+
+let profileSequence = 0;
+let profileNavigation = 0;
+const safeGet = key => { try { return localStorage.getItem(key); } catch (_) { return null; } };
+const safeSet = (key, value) => { try { localStorage.setItem(key, value); } catch (_) {} };
+function sessionStorages() {
+ return ['localStorage', 'sessionStorage'].map(name => { try { return window[name]; } catch (_) { return null; } });
+}
+
// ==========================================
// 🖼️ 个人资料卡背景图云端同步
// ==========================================
// 作用:当本地无缓存但云端有背景图时,下载并缓存到本地
-async function syncBannerCache(account, bannerUrl) {
- if (!bannerUrl || !account) return;
-
- const cacheKey = getBannerCacheKey(account);
- const localCache = localStorage.getItem(cacheKey);
-
- // 本地已有缓存,无需同步
- if (localCache) return;
-
+async function syncBannerCache(account, bannerUrl, scope, apply) {
+ if (!bannerUrl || !scope.isCurrent() || safeGet(getBannerCacheKey(account))) return;
+ const controller = new AbortController();
+ const release = scope.addCleanup(() => controller.abort());
+ const timer = setTimeout(() => controller.abort(), API.BACKGROUND_TIMEOUT);
try {
- // 处理代理 URL(云端 URL 可能需要通过本地代理访问)
- let fetchUrl = bannerUrl;
- if (bannerUrl.startsWith('http') && !bannerUrl.includes('/community_hub/image')) {
- // 📱 Web 模式:同源/直链可直接 fetch,无需本地代理中转
- if (!IS_WEB_MODE) fetchUrl = `/community_hub/image?url=${encodeURIComponent(bannerUrl)}`;
- }
-
- const response = await fetch(fetchUrl);
- if (!response.ok) throw new Error(`HTTP ${response.status}`);
-
+ let fetchUrl;
+ const url = new URL(bannerUrl, window.location.href);
+ if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password) return;
+ fetchUrl = !IS_WEB_MODE && url.origin !== window.location.origin
+ ? `/community_hub/image?url=${encodeURIComponent(url.href)}` : url.href;
+ const response = await fetch(fetchUrl, { signal: controller.signal });
+ if (!response.ok || !scope.isCurrent()) return;
const blob = await response.blob();
-
- // ✅ 大图不存localStorage(超过512KB的图片转Base64后会轻松超过配额)
- if (blob.size > 512 * 1024) {
- // 大图改用 sessionStorage 临时缓存(关闭标签页即释放),或直接使用 blob URL
- const blobUrl = URL.createObjectURL(blob);
- window.dispatchEvent(new CustomEvent("comfy-banner-synced", { detail: { account, blobUrl } }));
- return;
+ if (!scope.isCurrent() || !/^image\/(png|jpeg|webp|gif)$/.test(blob.type)) return;
+ if (blob.size <= UI.PROFILE_BANNER_CACHE_BYTES) {
+ const data = await new Promise(resolve => {
+ const reader = new FileReader();
+ const off = scope.addCleanup(() => { reader.abort(); resolve(null); });
+ reader.onload = () => { off(); resolve(reader.result); };
+ reader.onerror = reader.onabort = () => { off(); resolve(null); };
+ reader.readAsDataURL(blob);
+ });
+ if (!scope.isCurrent() || typeof data !== 'string') return;
+ safeSet(getBannerCacheKey(account), data);
}
-
- // 转换为 Base64 并存储(仅小图)
- const reader = new FileReader();
- reader.onload = () => {
- try {
- localStorage.setItem(cacheKey, reader.result);
- window.dispatchEvent(new CustomEvent("comfy-banner-synced", { detail: { account } }));
- } catch (storageErr) {
- // 配额溢出:先清理其他用户的旧 banner 缓存后重试
- if (storageErr.name === "QuotaExceededError") {
- try {
- const keysToRemove = [];
- for (let i = 0; i < localStorage.length; i++) {
- const key = localStorage.key(i);
- if (key && key.startsWith("ComfyRanking_ProfileBannerCache_") && key !== cacheKey) {
- keysToRemove.push(key);
- }
- }
- keysToRemove.forEach(k => localStorage.removeItem(k));
- localStorage.setItem(cacheKey, reader.result);
- window.dispatchEvent(new CustomEvent("comfy-banner-synced", { detail: { account } }));
- return;
- } catch (_) { /* 仍然失败,降级 */ }
- // 最终降级:使用 blob URL(不占 localStorage)
- const blobUrl = URL.createObjectURL(blob);
- window.dispatchEvent(new CustomEvent("comfy-banner-synced", { detail: { account, blobUrl } }));
- }
- }
- };
- reader.onerror = () => console.warn("背景图读取失败");
- reader.readAsDataURL(blob);
- } catch (err) {
- console.warn(`背景图下载失败: ${err.message}`);
- }
+ if (!scope.isCurrent()) return;
+ const blobUrl = URL.createObjectURL(blob);
+ scope.addCleanup(() => URL.revokeObjectURL(blobUrl));
+ apply(blobUrl);
+ } catch (_) { /* 背景同步失败保留当前背景,不影响资料。 */ }
+ finally { clearTimeout(timer); release(); }
}
-// ==========================================
-// 🔗 公共辅助:拉取用户资料+钱包数据
-// ==========================================
-async function _fetchProfileAndWallet(account, { logErrors = false } = {}) {
- const [profileRes, walletRes] = await Promise.all([
- api.getUserProfile(account).catch(err => {
- if (logErrors) console.warn(`⚠️ getUserProfile(${account}) 失败:`, err.message || err);
- return { data: {} };
- }),
- api.getWallet(account).catch(err => {
- if (logErrors) console.warn(`⚠️ getWallet(${account}) 失败:`, err.message || err);
- return {};
- })
- ]);
- const freshData = { ...profileRes.data, ...walletRes };
- Object.keys(freshData).forEach(key => freshData[key] == null && delete freshData[key]);
- return freshData;
+async function _fetchProfileAndWallet(account, { own = false, options = {} } = {}) {
+ const profileRequest = api.getUserProfile(account, options).then(response => {
+ if (response?.status !== 'success') throw new Error('Invalid profile response');
+ return normalizeProfileData(response.data, account, own);
+ });
+ if (!own) return profileRequest;
+ const [profile, wallet] = await Promise.allSettled([profileRequest, api.getWallet(account, options)]);
+ return { ...(profile.status === 'fulfilled' ? profile.value : {}),
+ ...(wallet.status === 'fulfilled' ? normalizeWalletData(wallet.value) : {}) };
}
/** 统一设置 Tab 按钮点击事件 */
@@ -112,37 +85,53 @@ function _setupTabButton(container, selector, tabId, onSwitch) {
export function showUserProfile(initialUserData, currentUser = null, isMe = true) {
const container = document.createElement("div");
- Object.assign(container.style, {
- display: "flex", flexDirection: "column", gap: "15px", color: "#eee",
+ Object.assign(container.style, {
+ display: "flex", flexDirection: "column", gap: "15px", color: "#eee",
// 🔧 自适应高度:flex 填充剩余空间,内容超出时在容器内部滚动(与主界面保持一致)
fontSize: "14px", padding: "15px", flex: "1", minHeight: "0", boxSizing: "border-box", overflowY: "auto",
background: "var(--comfy-menu-bg)"
});
let isSettingsView = false;
- let activeTab = localStorage.getItem(`Profile_ActiveTab_${isMe}`) || "published";
- let userData = initialUserData;
-
- // 🖼️ 监听背景图同步完成事件,自动刷新显示
- const bannerSyncHandler = (e) => {
- if (isMe && e.detail?.account === userData.account && !isSettingsView) {
- console.log(`✨ 背景图同步完成,刷新界面`);
- render();
- }
+ let activeTab = safeGet(`Profile_ActiveTab_${isMe}`) || "published";
+ let userData = normalizeProfileData(initialUserData, initialUserData.account, isMe);
+ let constructing = true;
+ let renderGeneration = 0;
+ const componentId = `profile-detail-${++profileSequence}`;
+ const releaseChild = () => {
+ const list = container.querySelector('#profile-list-container');
+ if (list) endProfileListView(list);
+ container.querySelector('.ranking-settings-form')?._cleanup?.();
};
- window.addEventListener("comfy-banner-synced", bannerSyncHandler);
-
- // 组件销毁时移除事件监听
- const observer = new MutationObserver(() => {
- if (!document.body.contains(container)) {
- window.removeEventListener("comfy-banner-synced", bannerSyncHandler);
- observer.disconnect();
+ const scope = createTaskViewScope({
+ sessionMatches: captureRequestSession(), connected: () => constructing || container.isConnected,
+ subscribe: notify => subscribeTaskSessionChanges(notify, { target: window, eventBus, events: EVENTS,
+ userKey: CACHE.LEGACY_KEYS.USER, tokenKey: CACHE.LEGACY_KEYS.TOKEN }),
+ cancelReads: () => requestCancelManager.cancelAll(componentId),
+ invalidate: () => {
+ releaseChild(); userData = { account: initialUserData.account };
+ container.replaceChildren();
+ const back = document.createElement('button'); back.type = 'button';
+ back.className = 'about-back'; back.textContent = t('common.back');
+ back.onclick = () => window.dispatchEvent(new CustomEvent('comfy-route-back'));
+ const status = document.createElement('div'); status.setAttribute('role', 'status');
+ status.textContent = t('auth.session_changed'); container.append(back, status);
}
});
+ container._profileScope = scope;
+ container._profileReadOptions = { componentId, timeout: API.BACKGROUND_TIMEOUT, retries: API.BACKGROUND_RETRIES };
+ container._cleanup = () => { releaseChild(); scope.dispose(); container.replaceChildren(); };
+ // 独立挂载者也能释放;正常侧栏路由在移除前调用 _cleanup。
+ const observer = new MutationObserver(() => { if (!container.isConnected) container._cleanup(); });
observer.observe(document.body, { childList: true, subtree: true });
+ scope.addCleanup(() => { observer.disconnect(); releaseChild(); });
function render() {
- container.innerHTML = "";
+ if (!scope.isCurrent()) return;
+ releaseChild();
+ const generation = ++renderGeneration;
+ const current = () => scope.isCurrent() && renderGeneration === generation;
+ container.innerHTML = "";
let isFollowing = false;
if (currentUser && userData.followers) { isFollowing = userData.followers.includes(currentUser.account); }
@@ -169,13 +158,15 @@ export function showUserProfile(initialUserData, currentUser = null, isMe = true
if (isSettingsView && isMe) {
const settingsContainer = createSettingsForm(
userData,
- () => { isSettingsView = false; render(); },
- (newUserData) => {
- userData = newUserData;
+ () => { if (!current()) return; isSettingsView = false; render(); },
+ (newUserData) => {
+ if (!current()) return;
+ userData = normalizeProfileData(newUserData, userData.account, true);
if (container.updateData) container.updateData(userData);
isSettingsView = false;
render();
- }
+ },
+ { isCurrent: current }
);
container.appendChild(settingsContainer);
container.querySelector("#btn-back-profile").onclick = () => { isSettingsView = false; render(); };
@@ -184,34 +175,48 @@ export function showUserProfile(initialUserData, currentUser = null, isMe = true
if (isMe) {
container.querySelector("#btn-wallet").onclick = () => {
- openRechargeModal(currentUser, (newBalance) => { userData.balance = newBalance; render(); });
+ if (!current()) return;
+ openRechargeModal(currentUser, (newBalance) => { if (!current()) return; userData.balance = newBalance; render(); });
};
container.querySelector("#btn-withdraw").onclick = async () => {
+ if (!current()) return;
+ const button = container.querySelector('#btn-withdraw');
+ if (button.disabled) return;
+ button.disabled = true;
// 点击提现按钮前先刷新钱包数据
try {
- const freshWallet = await api.getWallet(currentUser.account);
- Object.assign(currentUser, freshWallet);
+ const freshWallet = await api.getWallet(currentUser.account, { componentId });
+ if (!current()) return;
+ Object.assign(currentUser, normalizeWalletData(freshWallet));
} catch (err) {
- console.warn("刷新钱包数据失败:", err);
+ if (!current()) return;
+ console.warn("刷新钱包数据失败");
}
- openWithdrawModal(currentUser, async () => {
+ if (!current()) return;
+ button.disabled = false;
+ openWithdrawModal(currentUser, async () => {
+ if (!current()) return;
// 提现成功后重新拉取用户数据刷新余额
try {
- const walletRes = await api.getWallet(currentUser.account);
+ const walletResponse = await api.getWallet(currentUser.account, { componentId });
+ if (!current()) return;
+ const walletRes = normalizeWalletData(walletResponse);
userData.balance = walletRes.balance ?? userData.balance;
userData.earn_balance = walletRes.earn_balance ?? userData.earn_balance;
userData.tip_balance = walletRes.tip_balance ?? userData.tip_balance;
userData.total_withdrawn = walletRes.total_withdrawn ?? userData.total_withdrawn;
} catch (err) {
- console.warn("提现后刷新钱包数据失败:", err);
+ if (!current()) return;
+ console.warn("提现后刷新钱包数据失败");
}
- render();
+ render();
});
};
container.querySelector("#btn-open-settings").onclick = () => { isSettingsView = true; render(); };
container.querySelector("#btn-logout").onclick = async () => {
- if (await showConfirm(t('confirm.logout'))) {
+ if (!current()) return;
+ if (await showConfirm(t('confirm.logout')) && current()) {
// 获取当前账号(清除前获取)
const currentAccount = getCurrentAccount();
@@ -229,36 +234,42 @@ export function showUserProfile(initialUserData, currentUser = null, isMe = true
const btnTip = container.querySelector("#btn-tip-user");
if (btnTip) {
btnTip.onclick = async () => {
+ if (!current()) return;
if (!currentUser) return showToast(t('feedback.login_required_tip'), "warning");
await openTipModal(currentUser, userData, (newBalance) => {
+ if (!current()) return;
currentUser.balance = newBalance;
- openOtherUserProfileModal(userData.account, currentUser);
- });
+ openOtherUserProfileModal(userData.account, currentUser);
+ }, null, { isCurrent: current });
};
}
const btnFollow = container.querySelector("#btn-follow-user");
if (btnFollow) {
btnFollow.onclick = async () => {
+ if (!current()) return;
if (!currentUser) return showToast(t('feedback.login_required'), "warning");
btnFollow.innerHTML = `⏳ ${t('common.processing')}...`; btnFollow.disabled = true;
try {
const newStatus = !isFollowing;
- await api.toggleFollow(currentUser.account, userData.account, newStatus);
+ const response = await api.toggleFollow(currentUser.account, userData.account, newStatus);
+ if (!current()) return;
+ if (response?.status !== 'success') throw new Error('Invalid follow response');
isFollowing = newStatus;
-
+
if (!userData.followers) userData.followers = [];
- if (isFollowing) { if (!userData.followers.includes(currentUser.account)) userData.followers.push(currentUser.account); }
+ if (isFollowing) { if (!userData.followers.includes(currentUser.account)) userData.followers.push(currentUser.account); }
else { userData.followers = userData.followers.filter(a => a !== currentUser.account); }
- render();
- } catch (err) { showToast(t('feedback.operation_failed') + err.message, "error"); btnFollow.innerHTML = isFollowing ? `✔️ ${t('social.followed')}` : `➕ ${t('social.follow')}`; }
- finally { btnFollow.disabled = false; }
+ render();
+ } catch (_) { if (!current()) return; showToast(t('feedback.operation_failed'), "error"); btnFollow.innerHTML = isFollowing ? `✔️ ${t('social.followed')}` : `➕ ${t('social.follow')}`; }
+ finally { if (current()) btnFollow.disabled = false; }
};
}
const btnSendMsg = container.querySelector("#btn-send-msg");
if (btnSendMsg) {
btnSendMsg.onclick = () => {
+ if (!current()) return;
if (!currentUser) return showToast(t('feedback.login_required'), "warning");
openChatModal(currentUser, userData.account);
};
@@ -267,92 +278,93 @@ export function showUserProfile(initialUserData, currentUser = null, isMe = true
container.querySelectorAll(".profile-tab-btn").forEach(btn => {
btn.onclick = (e) => {
- activeTab = e.target.getAttribute("data-id");
- localStorage.setItem(`Profile_ActiveTab_${isMe}`, activeTab);
+ if (!current()) return;
+ activeTab = e.currentTarget.getAttribute("data-id");
+ safeSet(`Profile_ActiveTab_${isMe}`, activeTab);
render();
};
});
// 粉丝/关注数点击跳转对应列表
- const _switchTab = (tabId) => { activeTab = tabId; localStorage.setItem(`Profile_ActiveTab_${isMe}`, tabId); render(); };
+ const _switchTab = (tabId) => { if (!current()) return; activeTab = tabId; safeSet(`Profile_ActiveTab_${isMe}`, tabId); render(); };
_setupTabButton(container, "#btn-followers", "followers", _switchTab);
_setupTabButton(container, "#btn-following", "following", _switchTab);
const listDOM = container.querySelector("#profile-list-container");
+ listDOM._profileSessionCurrent = current;
+ listDOM._profileAddCleanup = cleanup => scope.addCleanup(cleanup);
renderProfileListContent(activeTab, listDOM, userData, currentUser, openOtherUserProfileModal);
}
}
container.updateData = function(newUserData) {
- const safeNewData = { ...newUserData };
+ if (!scope.isCurrent() || !newUserData || typeof newUserData !== 'object' || Array.isArray(newUserData)) return;
+ const safeNewData = normalizeProfileData({ ...userData, ...newUserData }, userData.account, isMe);
Object.keys(safeNewData).forEach(key => safeNewData[key] == null && delete safeNewData[key]);
const mergedData = { ...userData, ...safeNewData };
- if (JSON.stringify(userData) !== JSON.stringify(mergedData)) {
- userData = mergedData;
- if (!isSettingsView) { render(); }
+ if (JSON.stringify(userData) !== JSON.stringify(mergedData)) {
+ userData = mergedData;
+ if (!isSettingsView) { render(); }
}
};
render();
+ constructing = false;
return container;
}
export function openUserProfileModal(userData) {
+ ++profileNavigation;
const view = showUserProfile(userData, userData, true);
window.dispatchEvent(new CustomEvent("comfy-route-view", { detail: { view } }));
-
- // 同步拉取云端 JSON 资料表与 SQL 金融账本
- _fetchProfileAndWallet(userData.account).then(freshData => {
- const storage = localStorage.getItem("ComfyCommunity_User") ? localStorage : sessionStorage;
- try {
- const savedStr = storage.getItem("ComfyCommunity_User");
- if (savedStr) {
- const savedObj = JSON.parse(savedStr);
- savedObj.user = { ...savedObj.user, ...freshData };
- storage.setItem("ComfyCommunity_User", JSON.stringify(savedObj));
- }
- } catch(e){}
-
- // 🖼️ 同步个人资料卡背景图:云端有但本地无缓存时自动下载
- if (freshData.bannerUrl) {
- syncBannerCache(userData.account, freshData.bannerUrl);
- }
-
- // 立即更新界面,显示云端背景图(通过代理 URL)
- if (view.updateData) view.updateData(freshData);
- }).catch(err => console.warn("后台更新本人资料失败", err));
+ _fetchProfileAndWallet(userData.account, { own: true, options: view._profileReadOptions }).then(freshData => {
+ if (!view._profileScope.isCurrent()) return;
+ mergeStoredProfile(userData.account, freshData, sessionStorages(), CACHE.LEGACY_KEYS.USER);
+ view.updateData(freshData);
+ if (freshData.bannerUrl) syncBannerCache(userData.account, freshData.bannerUrl, view._profileScope,
+ blobUrl => view.updateData({ bannerUrl: blobUrl }));
+ }).catch(() => { if (view._profileScope.isCurrent()) console.warn('后台更新本人资料失败'); });
}
export async function openOtherUserProfileModal(targetAccount, currentUser) {
- if (currentUser && currentUser.account === targetAccount) { return openUserProfileModal(currentUser); }
-
- const cacheKey = `ComfyCommunity_ProfileCache_${targetAccount}`;
- const cachedStr = localStorage.getItem(cacheKey);
+ if (currentUser && currentUser.account === targetAccount) return openUserProfileModal(currentUser);
+ const generation = ++profileNavigation;
+ const sessionMatches = captureRequestSession();
+ const componentId = `profile-open-${++profileSequence}`;
let activeContainer = null;
-
- if (cachedStr) {
+ const cached = getCachedProfile(targetAccount);
+ if (cached) {
try {
- const cachedData = JSON.parse(cachedStr);
- activeContainer = showUserProfile(cachedData, currentUser, false);
- window.dispatchEvent(new CustomEvent("comfy-route-view", { detail: { view: activeContainer } }));
- } catch (e) {}
+ activeContainer = showUserProfile({ ...cached, account: targetAccount }, currentUser, false);
+ window.dispatchEvent(new CustomEvent('comfy-route-view', { detail: { view: activeContainer } }));
+ } catch (_) {}
}
-
+ let obsolete = false;
+ const cancel = () => { obsolete = true; requestCancelManager.cancelAll(componentId); };
+ const onRouteView = event => { if (event.detail?.view !== activeContainer) cancel(); };
+ window.addEventListener('comfy-route-back', cancel);
+ window.addEventListener('comfy-route-view', onRouteView);
+ const unsubscribe = subscribeTaskSessionChanges(force => { if (force || !sessionMatches()) cancel(); }, {
+ target: window, eventBus, events: EVENTS, userKey: CACHE.LEGACY_KEYS.USER, tokenKey: CACHE.LEGACY_KEYS.TOKEN
+ });
+ const current = () => !obsolete && generation === profileNavigation && sessionMatches()
+ && (!activeContainer || activeContainer._profileScope.isCurrent());
try {
- const freshData = await _fetchProfileAndWallet(targetAccount, { logErrors: true });
-
- // 如果 getUserProfile 失败(data 为空),用 targetAccount 兜底确保能渲染
- if (!freshData.account) freshData.account = targetAccount;
-
- try { localStorage.setItem(cacheKey, JSON.stringify(freshData)); } catch(e) { /* localStorage 满时忽略 */ }
-
- if (activeContainer) { if (activeContainer.updateData) activeContainer.updateData(freshData); }
+ const freshData = await _fetchProfileAndWallet(targetAccount, {
+ options: activeContainer?._profileReadOptions || { componentId }
+ });
+ if (!current()) return;
+ setCachedProfile(targetAccount, freshData);
+ if (activeContainer) activeContainer.updateData(freshData);
else {
- const profileElement = showUserProfile(freshData, currentUser, false);
- window.dispatchEvent(new CustomEvent("comfy-route-view", { detail: { view: profileElement } }));
+ activeContainer = showUserProfile(freshData, currentUser, false);
+ window.dispatchEvent(new CustomEvent('comfy-route-view', { detail: { view: activeContainer } }));
}
- } catch (err) {
- console.error(`❌ openOtherUserProfileModal(${targetAccount}) 异常:`, err);
- if (!activeContainer) showToast(t('feedback.user_fetch_failed'), "error");
+ } catch (_) {
+ if (current() && !activeContainer) showToast(t('feedback.user_fetch_failed'), 'error');
+ } finally {
+ unsubscribe();
+ window.removeEventListener('comfy-route-back', cancel);
+ window.removeEventListener('comfy-route-view', onRouteView);
}
-}
\ No newline at end of file
+}
diff --git "a/\345\211\215\347\253\257\351\241\265\351\235\242/profile/\344\270\252\344\272\272\345\210\227\350\241\250\347\273\204\344\273\266.js" "b/\345\211\215\347\253\257\351\241\265\351\235\242/profile/\344\270\252\344\272\272\345\210\227\350\241\250\347\273\204\344\273\266.js"
index a41935180f8db62ff6d6212d202e37c5c885025b..6ed389bacc7f84543df10b7e4e19d6e793784bd5 100644
--- "a/\345\211\215\347\253\257\351\241\265\351\235\242/profile/\344\270\252\344\272\272\345\210\227\350\241\250\347\273\204\344\273\266.js"
+++ "b/\345\211\215\347\253\257\351\241\265\351\235\242/profile/\344\270\252\344\272\272\345\210\227\350\241\250\347\273\204\344\273\266.js"
@@ -4,7 +4,8 @@ import { createItemCard } from "../market/列表卡片组件.js";
import { getAcquiredItems } from "../market/资源安装引擎.js";
import { showToast } from "../components/UI交互提示组件.js";
import { t, tIfExists, getLanguage } from "../components/用户体验增强.js";
-import { PLACEHOLDERS, getCachedProfile, getProfileWithSWR } from "../core/全局配置.js";
+import { PLACEHOLDERS, PAGINATION, CACHE, API, getCachedProfile, getProfileWithSWR } from "../core/全局配置.js";
+import { beginProfileListView, normalizeProfileItems, normalizeCollectedProfile, profileItemsFromResponse, readProfileListCache, writeProfileListCache } from './个人列表缓存.js';
import { escapeHtml } from "../components/互动工具函数.js"; // 🧹 P2归一:局部副本已移除
/**
@@ -14,48 +15,39 @@ import { escapeHtml } from "../components/互动工具函数.js"; // 🧹 P2归
* @param {Function} openOtherUserModalCb - 打开用户详情回调
* @returns {HTMLElement} 用户卡片元素
*/
+let nextUserCardId = 0;
+
function renderUserCard(account, currentUser, openOtherUserModalCb) {
- const card = document.createElement("div");
- card.id = `user-card-${account}`;
- card.style.cssText = "padding: 12px; background: var(--comfy-input-bg); border-radius: 8px; border: 1px solid #444; display: flex; align-items: center; gap: 12px; cursor: pointer; transition: all 0.2s;";
- card.onmouseover = () => { card.style.borderColor = "#666"; card.style.transform = "translateX(3px)"; };
- card.onmouseout = () => { card.style.borderColor = "#444"; card.style.transform = "translateX(0)"; };
+ const card = document.createElement("button");
+ const cardId = `ranking-profile-user-${++nextUserCardId}`;
+ card.type = 'button';
+ card.id = cardId;
+ card.className = 'profile-user-card';
+ card.setAttribute('aria-labelledby', `${cardId}-name ${cardId}-action`);
+ card.setAttribute('aria-describedby', `${cardId}-intro`);
card.onclick = () => openOtherUserModalCb(account, currentUser);
- // 先从缓存获取数据快速渲染
const cached = getCachedProfile(account);
const avatar = cached?.avatar || PLACEHOLDERS.AVATAR;
const name = cached?.name || account;
const intro = cached?.intro || '';
-
- // 🔒 XSS防护:头像/昵称/简介均来自用户可修改的资料(下方 SWR 刷新路径用的是
- // .src / .textContent,本身就是安全的,仅初次渲染的模板串需要转义)
card.innerHTML = `
-
})
-
-
@${escapeHtml(name)}
-
${escapeHtml(intro || t('profile.no_intro'))}
-
-
+
})
+
+ @${escapeHtml(name)}
+ ${escapeHtml(intro || t('profile.no_intro'))}
+
+
${t('profile.homepage')}
`;
-
- // SWR:后台静默更新
- getProfileWithSWR(account, api.getUserProfile, (profile) => {
- const avatarImg = card.querySelector(`#user-avatar-${account}`);
- const nameDiv = card.querySelector(`#user-name-${account}`);
- const introDiv = card.querySelector(`#user-intro-${account}`);
-
- if (avatarImg && profile.avatar) {
- avatarImg.src = profile.avatar;
- }
- if (nameDiv && profile.name) {
- nameDiv.textContent = `@${profile.name}`;
- }
- if (introDiv && profile.intro !== undefined) {
- introDiv.textContent = profile.intro || t('profile.no_intro');
- }
+ const avatarImg = card.querySelector('.profile-user-card__avatar');
+ const nameDiv = card.querySelector('.profile-user-card__name');
+ const introDiv = card.querySelector('.profile-user-card__intro');
+ getProfileWithSWR(account, api.getUserProfile, profile => {
+ if (!card.isConnected || card.querySelector('.profile-user-card__name') !== nameDiv) return;
+ if (profile.avatar) avatarImg.src = profile.avatar;
+ if (profile.name) nameDiv.textContent = `@${profile.name}`;
+ if (profile.intro !== undefined) introDiv.textContent = profile.intro || t('profile.no_intro');
});
-
return card;
}
@@ -70,9 +62,7 @@ function renderUserCard(account, currentUser, openOtherUserModalCb) {
function _renderUserListTab(listData, domElement, currentUser, openOtherUserModalCb, emptyMsg) {
domElement.innerHTML = "";
const containerDiv = document.createElement("div");
- containerDiv.style.display = "flex";
- containerDiv.style.flexDirection = "column";
- containerDiv.style.gap = "8px";
+ containerDiv.className = 'profile-user-list';
domElement.appendChild(containerDiv);
renderPaginatedUserList(listData, containerDiv, currentUser, openOtherUserModalCb, emptyMsg);
}
@@ -86,58 +76,66 @@ function _renderUserListTab(listData, domElement, currentUser, openOtherUserModa
* {string} emptyMessage - 空列表提示消息
*/
function renderPaginatedUserList(userList, containerDiv, currentUser, openOtherUserModalCb, emptyMessage) {
- containerDiv.innerHTML = "";
-
- if (userList.length === 0) {
- containerDiv.innerHTML = `
${escapeHtml(emptyMessage)}
`;
+ containerDiv.replaceChildren();
+ const accounts = Array.isArray(userList) ? userList.filter(account => typeof account === 'string' && account.trim()) : [];
+ if (!accounts.length) {
+ const empty = document.createElement('div');
+ empty.className = 'profile-user-list__empty';
+ empty.setAttribute('role', 'status');
+ empty.textContent = emptyMessage;
+ containerDiv.appendChild(empty);
return;
}
-
- // 分页逻辑:先加载前20个
- const PAGE_SIZE = 20;
- let displayedCount = Math.min(PAGE_SIZE, userList.length);
-
- // 渲染当前页的用户
- const renderPage = () => {
- // 保留容器但清空内容
- containerDiv.innerHTML = "";
-
- const pageAccounts = userList.slice(0, displayedCount);
-
- pageAccounts.forEach((account) => {
+ let displayedCount = 0;
+ const loadMoreBtn = document.createElement('button');
+ loadMoreBtn.type = 'button';
+ loadMoreBtn.className = 'profile-user-list__more';
+ containerDiv.appendChild(loadMoreBtn);
+ const appendPage = (fromUser = false) => {
+ const nextCount = Math.min(displayedCount + PAGINATION.PROFILE_USERS_PAGE_SIZE, accounts.length);
+ let firstAdded;
+ for (const account of accounts.slice(displayedCount, nextCount)) {
const card = renderUserCard(account, currentUser, openOtherUserModalCb);
- containerDiv.appendChild(card);
- });
-
- // 如果有更多,显示"加载更多"按钮
- if (displayedCount < userList.length) {
- const loadMoreBtn = document.createElement("button");
- loadMoreBtn.textContent = t('profile.load_more', { count: userList.length - displayedCount });
- 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;";
- loadMoreBtn.onmouseover = () => { loadMoreBtn.style.background = "#3a3a3a"; };
- loadMoreBtn.onmouseout = () => { loadMoreBtn.style.background = "var(--comfy-input-bg)"; };
- loadMoreBtn.onclick = () => {
- displayedCount = Math.min(displayedCount + PAGE_SIZE, userList.length);
- renderPage();
- };
- containerDiv.appendChild(loadMoreBtn);
+ firstAdded ||= card;
+ containerDiv.insertBefore(card, loadMoreBtn);
+ }
+ displayedCount = nextCount;
+ if (displayedCount < accounts.length) {
+ loadMoreBtn.textContent = t('profile.load_more', { count: accounts.length - displayedCount });
+ } else {
+ loadMoreBtn.remove();
+ if (fromUser) firstAdded?.focus();
}
};
+ loadMoreBtn.onclick = () => appendPage(true);
+ appendPage();
+}
- renderPage();
+function setProfileListState(element, message, role = 'status') {
+ const state = document.createElement('div');
+ state.className = 'profile-user-list__empty';
+ state.setAttribute('role', role);
+ state.textContent = message;
+ element.replaceChildren(state);
}
export async function renderProfileListContent(tabId, domElement, userData, currentUser, openOtherUserModalCb) {
+ const isCurrent = beginProfileListView(domElement, domElement._profileSessionCurrent);
const isMe = currentUser && currentUser.account === userData.account;
const privacy = userData.privacy || {};
+ if (!isMe && ['acquired', 'my_tasks', 'transactions', 'my_posts', 'my_prompts'].includes(tabId)) {
+ domElement.innerHTML = `
${t('profile.privacy_hidden')}
`;
+ return;
+ }
+
// 【修复】:从底层杜绝隐私被穿透抓取
if (!isMe) {
if ((tabId === "following" && privacy.follows) ||
(tabId === "liked" && privacy.likes) ||
(tabId === "followers" && privacy.follows) ||
(tabId === "collected" && privacy.favorites)) {
- domElement.innerHTML = `
🔒 ${t('profile.privacy_hidden')}
`;
+ setProfileListState(domElement, t('profile.privacy_hidden'), 'status');
return;
}
}
@@ -154,98 +152,70 @@ export async function renderProfileListContent(tabId, domElement, userData, curr
return;
}
- // 🚀 已获取的资源列表(使用标准 createItemCard 组件)
- if (tabId === "acquired") {
- let acquiredItemIds = [];
- let useCloudData = false;
-
- // 优先从云端获取购买记录
+ // 已获取资源先显示本地记录,再校对云端所有权与详情。
+ if (tabId === 'acquired') {
+ let localItems = [];
+ try { localItems = normalizeProfileItems(getAcquiredItems()); } catch {}
+ const renderAcquired = (items, hasOwnership = false) => {
+ if (!isCurrent()) return;
+ if (!items.length) {
+ if (hasOwnership) {
+ const error = document.createElement('div');
+ error.className = 'profile-user-list__empty';
+ error.setAttribute('role', 'alert');
+ error.textContent = t('profile.load_failed');
+ domElement.replaceChildren(error);
+ } else domElement.innerHTML = _renderEmptyState('📦', t('profile.no_purchased_resources'), t('profile.discover_resources'));
+ return;
+ }
+ const list = document.createElement('div');
+ list.className = 'profile-content-list';
+ for (const item of items) list.appendChild(createItemCard(item, currentUser, 'acquired'));
+ domElement.replaceChildren(list);
+ };
+ if (localItems.length) renderAcquired(localItems.map(item => ({ ...item, _isOffline: true })));
+ else domElement.innerHTML = `
${t('profile.loading_resources')}
`;
+ let acquiredIds = localItems.map(item => String(item.id));
try {
if (currentUser?.account) {
- const purchasesRes = await api.getUserPurchases(currentUser.account);
- if (purchasesRes?.status === "success" && purchasesRes.data) {
- acquiredItemIds = purchasesRes.data.map(p => p.item_id);
- useCloudData = true;
- }
+ const response = await api.getUserPurchases(currentUser.account);
+ if (!isCurrent()) return;
+ if (response?.status !== 'success' || !Array.isArray(response.data)) throw new Error('Invalid purchases');
+ const cloudIds = response.data.filter(record => record && typeof record === 'object'
+ && typeof record.item_id === 'string' && record.item_id).map(record => record.item_id);
+ if (response.data.length && !cloudIds.length) throw new Error('Invalid purchase entries');
+ acquiredIds = cloudIds;
}
- } catch (e) {
- console.warn("云端购买记录获取失败,降级使用本地缓存:", e);
+ } catch {
+ if (!isCurrent()) return;
+ console.warn('购买记录暂不可用,保留本地资源');
}
-
- // 降级:从 localStorage 获取
- if (!useCloudData) {
- const localItems = getAcquiredItems();
- acquiredItemIds = localItems.map(item => item.id);
- }
-
- if (acquiredItemIds.length === 0) {
- domElement.innerHTML = _renderEmptyState('📦', '还没有购买任何资源', '去榜单页面发现优秀插件吧!');
- return;
- }
-
- domElement.innerHTML = `
⏳ 正在加载...
`;
-
+ if (!acquiredIds.length) { renderAcquired([]); return; }
+ const localMap = new Map(localItems.map(item => [String(item.id), item]));
+ const cloudMap = new Map();
+ const controller = new AbortController();
+ const release = domElement._profileAddCleanup?.(() => controller.abort());
+ let timer;
try {
- // 获取资源详情(用于完整显示卡片,5秒超时)
- let cloudItems = [];
- const timeoutMs = 5000;
- const fetchPromise = Promise.all([
- api.getItems("tool", "time", 200),
- api.getItems("skill", "time", 200),
- api.getItems("app", "time", 200)
- ]);
- const timeoutPromise = new Promise((resolve) => setTimeout(() => resolve(null), timeoutMs));
- const result = await Promise.race([fetchPromise, timeoutPromise]);
-
- if (result) {
- const [toolsRes, skillsRes, appsRes] = result;
- cloudItems = [...(toolsRes?.data || []), ...(skillsRes?.data || []), ...(appsRes?.data || [])];
- }
-
- const cloudMap = {};
- cloudItems.forEach(item => { cloudMap[item.id] = item; });
-
- // 用 acquiredItemIds 过滤出已购买的资源
- const enrichedItems = acquiredItemIds.map(itemId => {
- const cloudItem = cloudMap[itemId];
- if (cloudItem) return cloudItem;
- // 云端资源列表中找不到(可能已下架),尝试从本地缓存恢复
- const localItems = getAcquiredItems();
- const localItem = localItems.find(l => l.id === itemId);
- return localItem ? { ...localItem, _isOffline: true } : null;
- }).filter(Boolean);
-
- // 使用 createItemCard 渲染(与 published 一致)
- const listDiv = document.createElement("div");
- listDiv.style.cssText = "display:flex;flex-direction:column;gap:10px;";
-
- enrichedItems.forEach(item => {
- const card = createItemCard(item, currentUser, "acquired");
- listDiv.appendChild(card);
+ const fetches = ['tool', 'skill', 'app'].map(async type => {
+ const response = await api.getItems(type, 'time', PAGINATION.PROFILE_RESOURCE_FETCH_LIMIT, { signal: controller.signal });
+ for (const item of profileItemsFromResponse(response)) cloudMap.set(String(item.id), item);
});
-
- domElement.innerHTML = "";
- domElement.appendChild(listDiv);
-
- } catch (e) {
- console.warn("获取云端资源超时或失败,使用本地数据:", e);
- // 网络失败时也用 createItemCard 渲染本地数据
- const localItems = getAcquiredItems();
- const listDiv = document.createElement("div");
- listDiv.style.cssText = "display:flex;flex-direction:column;gap:10px;";
- localItems.forEach(item => {
- const card = createItemCard(item, currentUser, "acquired");
- listDiv.appendChild(card);
+ const deadline = new Promise(resolve => {
+ timer = setTimeout(() => { controller.abort(); resolve(); }, API.PROFILE_RESOURCE_TIMEOUT);
});
- domElement.innerHTML = "";
- domElement.appendChild(listDiv);
- }
+ await Promise.race([Promise.allSettled(fetches), deadline]);
+ if (!isCurrent()) return;
+ const enriched = [...new Set(acquiredIds)].map(id => cloudMap.get(id)
+ || (localMap.has(id) ? { ...localMap.get(id), _isOffline: true } : null)).filter(Boolean);
+ renderAcquired(enriched, true);
+ } finally { clearTimeout(timer); release?.(); }
return;
}
// 📝 新增:我的任务列表
if (tabId === "my_tasks") {
- domElement.innerHTML = "
⏳ 加载任务中...
";
+ setProfileListState(domElement, t('profile.loading_tasks'), 'status');
try {
// 并行获取我发布的 + 我接的任务
@@ -254,11 +224,12 @@ export async function renderProfileListContent(tabId, domElement, userData, curr
api.getMyTasks("assignee")
]);
+ if (!isCurrent()) return;
const publishedTasks = publishedRes.data || [];
const assignedTasks = assignedRes.data || [];
if (publishedTasks.length === 0 && assignedTasks.length === 0) {
- domElement.innerHTML = _renderEmptyState('📝', '还没有任何任务记录', '去任务榜发布或接受任务吧!');
+ domElement.innerHTML = _renderEmptyState('📝', t('profile.no_task_records'), t('profile.go_tasks'));
return;
}
@@ -267,8 +238,8 @@ export async function renderProfileListContent(tabId, domElement, userData, curr
completed: "#9E9E9E", disputed: "#F44336", cancelled: "#757575"
};
const statusLabels = {
- open: "开放接单", in_progress: "进行中", submitted: "待验收",
- completed: "已完成", disputed: "争议中", cancelled: "已取消"
+ open: t('task.status_open'), in_progress: t('task.status_in_progress'), submitted: t('task.status_submitted'),
+ completed: t('task.status_completed'), disputed: t('task.status_disputed'), cancelled: t('task.status_cancelled')
};
const listDiv = document.createElement("div");
@@ -280,7 +251,7 @@ export async function renderProfileListContent(tabId, domElement, userData, curr
if (publishedTasks.length > 0) {
const sectionTitle = document.createElement("div");
sectionTitle.style.cssText = "font-size: 13px; font-weight: bold; color: #FF9800; margin-bottom: 5px; padding: 5px 0;";
- sectionTitle.textContent = `📤 我发布的任务 (${publishedTasks.length})`;
+ sectionTitle.textContent = `📤 ${t('profile.published_tasks')} (${publishedTasks.length})`;
listDiv.appendChild(sectionTitle);
publishedTasks.forEach(task => {
@@ -293,7 +264,7 @@ export async function renderProfileListContent(tabId, domElement, userData, curr
if (assignedTasks.length > 0) {
const sectionTitle = document.createElement("div");
sectionTitle.style.cssText = "font-size: 13px; font-weight: bold; color: #2196F3; margin-bottom: 5px; padding: 5px 0; margin-top: 15px;";
- sectionTitle.textContent = `📥 我接的任务 (${assignedTasks.length})`;
+ sectionTitle.textContent = `📥 ${t('profile.assigned_tasks')} (${assignedTasks.length})`;
listDiv.appendChild(sectionTitle);
assignedTasks.forEach(task => {
@@ -306,15 +277,16 @@ export async function renderProfileListContent(tabId, domElement, userData, curr
domElement.appendChild(listDiv);
} catch (e) {
- console.error("加载任务失败:", e);
- domElement.innerHTML = `
任务加载失败
`;
+ if (!isCurrent()) return;
+ console.error("加载任务失败");
+ setProfileListState(domElement, t('profile.load_tasks_failed'), 'alert');
}
return;
}
// 💳 P6支付增强:交易明细列表
if (tabId === "transactions") {
- domElement.innerHTML = "
⏳ 加载交易记录中...
";
+ setProfileListState(domElement, t('profile.loading_transactions'), 'status');
try {
// 并行获取任务统计、交易明细、打赏统计和销售统计
@@ -325,6 +297,7 @@ export async function renderProfileListContent(tabId, domElement, userData, curr
api.getSalesStats(currentUser.account).catch(() => ({ data: {} })) // 新增,带容错
]);
+ if (!isCurrent()) return;
const stats = statsRes.data || {};
const transactions = txRes.data || [];
const tipStats = tipStatsRes.data || {};
@@ -348,11 +321,11 @@ export async function renderProfileListContent(tabId, domElement, userData, curr
// 📊 任务收益统计卡片
containerDiv.appendChild(_createStatsCard(
- '📊 任务收益统计',
+ `📊 ${t('profile.task_earnings_stats')}`,
[
- { value: `+${stats.total_income || 0}`, color: '#4CAF50', bgRgb: '76,175,80', label: `任务收入 (${stats.income_count || 0}笔)` },
- { value: `-${stats.total_payment || 0}`, color: '#F44336', bgRgb: '244,67,54', label: `任务支出 (${stats.payment_count || 0}笔)` },
- { value: `${stats.net_earnings > 0 ? '+' : ''}${stats.net_earnings || 0}`, color: '#2196F3', bgRgb: '33,150,243', label: '净收益' }
+ { value: `+${stats.total_income || 0}`, color: '#4CAF50', bgRgb: '76,175,80', label: `${t('profile.task_income')} (${stats.income_count || 0}${t('profile.transactions_unit')})` },
+ { value: `-${stats.total_payment || 0}`, color: '#F44336', bgRgb: '244,67,54', label: `${t('profile.task_expense')} (${stats.payment_count || 0}${t('profile.transactions_unit')})` },
+ { value: `${stats.net_earnings > 0 ? '+' : ''}${stats.net_earnings || 0}`, color: '#2196F3', bgRgb: '33,150,243', label: t('profile.net_earnings') }
]
));
@@ -372,7 +345,7 @@ export async function renderProfileListContent(tabId, domElement, userData, curr
txListDiv.style.cssText = "display: flex; flex-direction: column; gap: 8px;";
if (transactions.length === 0) {
- txListDiv.innerHTML = _renderEmptyState('💰', '暂无交易记录');
+ txListDiv.innerHTML = _renderEmptyState('💰', t('profile.no_transactions'));
} else {
transactions.forEach(tx => {
try {
@@ -389,22 +362,24 @@ export async function renderProfileListContent(tabId, domElement, userData, curr
domElement.appendChild(containerDiv);
} catch (e) {
- console.error("加载交易记录失败:", e);
- domElement.innerHTML = `
交易记录加载失败
`;
+ if (!isCurrent()) return;
+ console.error("加载交易记录失败");
+ setProfileListState(domElement, t('profile.load_transactions_failed'), 'alert');
}
return;
}
// 💬 新增:我的帖子列表
if (tabId === "my_posts") {
- domElement.innerHTML = "
⏳ 加载帖子中...
";
+ setProfileListState(domElement, t('profile.loading_posts'), 'status');
try {
const res = await api.getMyPosts();
+ if (!isCurrent()) return;
const posts = res.data || [];
if (posts.length === 0) {
- domElement.innerHTML = _renderEmptyState('💬', '还没有发布任何帖子', '去讨论区分享你的想法吧!');
+ domElement.innerHTML = _renderEmptyState('💬', t('profile.no_posts'), t('profile.go_posts'));
return;
}
@@ -422,22 +397,24 @@ export async function renderProfileListContent(tabId, domElement, userData, curr
domElement.appendChild(listDiv);
} catch (e) {
- console.error("加载帖子失败:", e);
- domElement.innerHTML = `
帖子加载失败
`;
+ if (!isCurrent()) return;
+ console.error("加载帖子失败");
+ setProfileListState(domElement, t('profile.load_posts_failed'), 'alert');
}
return;
}
// 🧩 新增:我的提示词列表
if (tabId === "my_prompts") {
- domElement.innerHTML = "
⏳ 加载提示词中...
";
+ setProfileListState(domElement, t('profile.loading_prompts'), 'status');
try {
const res = await api.getMyPrompts();
+ if (!isCurrent()) return;
const prompts = res.data || [];
if (prompts.length === 0) {
- domElement.innerHTML = _renderEmptyState('🧩', '还没有发布任何提示词', '去提示词广场分享你的创作吧!');
+ domElement.innerHTML = _renderEmptyState('🧩', t('profile.no_prompts'), t('profile.go_prompts'));
return;
}
@@ -455,18 +432,21 @@ export async function renderProfileListContent(tabId, domElement, userData, curr
domElement.appendChild(listDiv);
} catch (e) {
- console.error("加载提示词失败:", e);
- domElement.innerHTML = `
提示词加载失败
`;
+ if (!isCurrent()) return;
+ console.error("加载提示词失败");
+ setProfileListState(domElement, t('profile.load_prompts_failed'), 'alert');
}
return;
}
// ⭐ 新增:我的收藏列表(资源 + 帖子 + 提示词)
if (tabId === "collected") {
- const cacheKey = `ProfileList_${userData.account}_collected`;
- const cachedStr = localStorage.getItem(cacheKey);
+ const cacheKey = `${CACHE.LOCAL_KEYS.PROFILE_LIST_PREFIX}${userData.account}_collected`;
+ const cached = readProfileListCache(cacheKey, normalizeCollectedProfile);
+ const cachedStr = cached ? JSON.stringify(cached) : null;
const applyDOM = (items, posts, prompts) => {
+ if (!isCurrent()) return;
domElement.innerHTML = "";
if ((!items || items.length === 0) && (!posts || posts.length === 0) && (!prompts || prompts.length === 0)) {
domElement.innerHTML = _renderEmptyState('🔖', t('profile.no_collected'), t('profile.go_discover'));
@@ -502,13 +482,10 @@ export async function renderProfileListContent(tabId, domElement, userData, curr
domElement.appendChild(listDiv);
};
- if (cachedStr) {
- try {
- const cached = JSON.parse(cachedStr);
- applyDOM(cached.items, cached.posts, cached.prompts);
- } catch(e) { localStorage.removeItem(cacheKey); }
+ if (cached) {
+ applyDOM(cached.items, cached.posts, cached.prompts);
} else {
- domElement.innerHTML = "
⏳ 正在拉取数据...
";
+ setProfileListState(domElement, t('profile.fetching_data'), 'status');
}
try {
@@ -519,56 +496,62 @@ export async function renderProfileListContent(tabId, domElement, userData, curr
api.getPosts(1, 200, "latest"),
api.getPrompts(null, null, 1, 200, "latest")
]);
- const allItems = [...(toolsRes.data || []), ...(skillsRes.data || []), ...(appsRes.data || [])];
- const allPosts = postsRes.data || [];
- const allPrompts = promptsRes.data || [];
- const collectedItems = allItems.filter(item => item.favorited_by && item.favorited_by.includes(userData.account));
- const collectedPosts = allPosts.filter(post => post.favorited_by && post.favorited_by.includes(userData.account));
- const collectedPrompts = allPrompts.filter(prompt => prompt.favorited_by && prompt.favorited_by.includes(userData.account));
+ if (!isCurrent()) return;
+ const allItems = [toolsRes, skillsRes, appsRes].flatMap(profileItemsFromResponse);
+ const allPosts = profileItemsFromResponse(postsRes);
+ const allPrompts = profileItemsFromResponse(promptsRes);
+ const collectedItems = allItems.filter(item => Array.isArray(item.favorited_by) && item.favorited_by.includes(userData.account));
+ const collectedPosts = allPosts.filter(post => Array.isArray(post.favorited_by) && post.favorited_by.includes(userData.account));
+ const collectedPrompts = allPrompts.filter(prompt => Array.isArray(prompt.favorited_by) && prompt.favorited_by.includes(userData.account));
const freshData = { items: collectedItems, posts: collectedPosts, prompts: collectedPrompts };
const freshStr = JSON.stringify(freshData);
if (freshStr !== cachedStr) {
- localStorage.setItem(cacheKey, freshStr);
+ writeProfileListCache(cacheKey, freshData);
applyDOM(collectedItems, collectedPosts, collectedPrompts);
}
} catch (error) {
- console.error("加载收藏失败:", error);
- if (!cachedStr) domElement.innerHTML = `
${t('profile.load_collected_failed')}
`;
+ if (!isCurrent()) return;
+ console.error("加载收藏失败");
+ if (!cachedStr) setProfileListState(domElement, t('profile.load_collected_failed'), 'alert');
}
return;
}
- const cacheKey = `ProfileList_${userData.account}_${tabId}`;
- const cachedStr = localStorage.getItem(cacheKey);
+ const cacheKey = `${CACHE.LOCAL_KEYS.PROFILE_LIST_PREFIX}${userData.account}_${tabId}`;
+ const cached = readProfileListCache(cacheKey);
+ const cachedStr = cached ? JSON.stringify(cached) : null;
const applyDOM = (items) => {
+ if (!isCurrent()) return;
domElement.innerHTML = "";
- if (items.length === 0) { domElement.innerHTML = _renderEmptyState('📋', '暂无记录'); return; }
+ if (items.length === 0) { domElement.innerHTML = _renderEmptyState('📋', t('profile.no_records')); return; }
items.forEach(item => { domElement.appendChild(createItemCard(item, currentUser)); });
};
- if (cachedStr) {
- try { applyDOM(JSON.parse(cachedStr)); } catch(e) { localStorage.removeItem(cacheKey); }
+ if (cached) {
+ applyDOM(cached);
} else {
- domElement.innerHTML = "
⏳ 正在拉取数据...
";
+ setProfileListState(domElement, t('profile.fetching_data'), 'status');
}
try {
const [toolsRes, skillsRes, appsRes] = await Promise.all([ api.getItems("tool", "time", 100), api.getItems("skill", "time", 100), api.getItems("app", "time", 100) ]);
- const allItems = [...(toolsRes.data || []), ...(skillsRes.data || []), ...(appsRes.data || [])];
+ if (!isCurrent()) return;
+ const allItems = [toolsRes, skillsRes, appsRes].flatMap(profileItemsFromResponse);
let filteredItems = [];
if (tabId === "published") filteredItems = allItems.filter(item => item.author === userData.account);
- else if (tabId === "liked") filteredItems = allItems.filter(item => item.liked_by && item.liked_by.includes(userData.account));
+ else if (tabId === "liked") filteredItems = allItems.filter(item => Array.isArray(item.liked_by) && item.liked_by.includes(userData.account));
const freshStr = JSON.stringify(filteredItems);
if (freshStr !== cachedStr) {
- localStorage.setItem(cacheKey, freshStr);
+ writeProfileListCache(cacheKey, filteredItems);
applyDOM(filteredItems);
}
- } catch (error) {
- if (!cachedStr) domElement.innerHTML = "
数据加载失败
";
+ } catch (error) {
+ if (!isCurrent()) return;
+ if (!cachedStr) setProfileListState(domElement, t('profile.load_failed'), 'alert');
}
}
@@ -581,11 +564,11 @@ export async function renderProfileListContent(tabId, domElement, userData, curr
*/
function _renderEmptyState(icon, title, subtitle = '') {
const subtitleHtml = subtitle
- ? `
${subtitle}
`
+ ? `
${escapeHtml(subtitle)}
`
: '';
- return `
-
${icon}
-
${title}
+ return `
+
${escapeHtml(icon)}
+
${escapeHtml(title)}
${subtitleHtml}
`;
}
@@ -627,17 +610,17 @@ function createTaskItem(task, statusColors, statusLabels, currentUser) {
});
const statusColor = statusColors[task.status] || "#666";
- const statusLabel = statusLabels[task.status] || "未知";
+ const statusLabel = statusLabels[task.status] || t('task.status_unknown');
taskDiv.innerHTML = `
${statusLabel}
- 💰 ${task.total_price} 积分
+ 💰 ${task.total_price} ${t('task.points')}
${escapeHtml(task.title)}
✈️ ${formatDeadline(task.deadline)}
- ${task.status === "open" ? `👥 ${(task.applicants || []).length}人申请` : ""}
+ ${task.status === "open" ? `👥 ${(task.applicants || []).length} ${t('task.applicants')}` : ""}
`;
@@ -660,16 +643,16 @@ function createTaskItem(task, statusColors, statusLabels, currentUser) {
* 📅 格式化截止日期
*/
function formatDeadline(deadline) {
- if (!deadline) return "无限期";
+ if (!deadline) return t('time.unlimited');
try {
const date = new Date(deadline);
const now = new Date();
const diff = date - now;
- if (diff < 0) return "已截止";
+ if (diff < 0) return t('time.deadline_ended');
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
- if (days === 0) return "今天截止";
- if (days === 1) return "明天截止";
- if (days < 7) return `${days}天后`;
+ if (days === 0) return t('time.deadline_today');
+ if (days === 1) return t('time.deadline_tomorrow');
+ if (days < 7) return t('time.deadline_days', { n: days });
return `${date.getMonth() + 1}/${date.getDate()}`;
} catch { return deadline; }
}
@@ -745,7 +728,7 @@ function createPostItem(post, currentUser) {
window.dispatchEvent(new CustomEvent("comfy-route-view", { detail: { view } }));
}).catch(err => {
console.error("加载帖子详情组件失败:", err);
- showToast("加载帖子详情失败", "error");
+ showToast(t('profile.post_detail_load_failed'), "error");
});
};
@@ -821,7 +804,7 @@ function createPromptItem(prompt, currentUser) {
window.dispatchEvent(new CustomEvent("comfy-route-view", { detail: { view } }));
}).catch(err => {
console.error("加载提示词详情组件失败:", err);
- showToast("加载提示词详情失败", "error");
+ showToast(t('profile.prompt_detail_load_failed'), "error");
});
};
@@ -837,10 +820,10 @@ function formatPostTime(timestamp) {
const now = new Date();
const diff = now - date;
- if (diff < 60 * 1000) return "刚刚";
- if (diff < 60 * 60 * 1000) return `${Math.floor(diff / 60000)}分钟前`;
- if (diff < 24 * 60 * 60 * 1000) return `${Math.floor(diff / 3600000)}小时前`;
- if (diff < 7 * 24 * 60 * 60 * 1000) return `${Math.floor(diff / 86400000)}天前`;
+ if (diff < 60 * 1000) return t('time.just_now');
+ if (diff < 60 * 60 * 1000) return t('time.minutes_ago', { n: Math.floor(diff / 60000) });
+ if (diff < 24 * 60 * 60 * 1000) return t('time.hours_ago', { n: Math.floor(diff / 3600000) });
+ if (diff < 7 * 24 * 60 * 60 * 1000) return t('time.days_ago', { n: Math.floor(diff / 86400000) });
return `${date.getMonth() + 1}/${date.getDate()}`;
}
@@ -921,6 +904,7 @@ function createTransactionCard(tx) {
}
break;
+ case 'TASK_PRICE_ADJUST':
case 'TASK_FREEZE':
// 任务冻结
description = tx.item_title || '';
@@ -937,6 +921,7 @@ function createTransactionCard(tx) {
break;
case 'REFUND':
+ case 'SALE_REFUND':
// 退款
description = tx.item_title || '';
break;
@@ -1021,13 +1006,15 @@ function getTransactionTypeConfig(txType) {
'TASK_PAYMENT': { icon: '📋', title: t('tx.task_payment') },
'TASK_INCOME': { icon: '📋', title: t('tx.task_income') },
'TASK_FREEZE': { icon: '🔒', title: t('tx.task_freeze') },
+ 'TASK_PRICE_ADJUST': { icon: '🔒', title: t('tx.task_price_adjust') },
'TASK_REFUND': { icon: '↩️', title: t('tx.task_refund') },
'TASK_CANCEL_REFUND': { icon: '🔄', title: t('tx.task_cancel_refund') },
'SALE': { icon: '💎', title: t('tx.sale'), color: '#28a745' },
'RECHARGE': { icon: '💰', title: t('tx.recharge') },
'REFUND': { icon: '↩️', title: t('tx.refund') },
+ 'SALE_REFUND': { icon: '↩️', title: t('tx.sale_refund') },
'WITHDRAW_FEE': { icon: '💸', title: t('tx.withdraw_fee') }
};
return configs[txType] || { icon: '💰', title: txType };
-}
\ No newline at end of file
+}
diff --git "a/\345\211\215\347\253\257\351\241\265\351\235\242/profile/\344\270\252\344\272\272\345\210\227\350\241\250\347\274\223\345\255\230.js" "b/\345\211\215\347\253\257\351\241\265\351\235\242/profile/\344\270\252\344\272\272\345\210\227\350\241\250\347\274\223\345\255\230.js"
new file mode 100644
index 0000000000000000000000000000000000000000..966c9ae5717b348fd5fa2bb1e71ef4687876f43d
--- /dev/null
+++ "b/\345\211\215\347\253\257\351\241\265\351\235\242/profile/\344\270\252\344\272\272\345\210\227\350\241\250\347\274\223\345\255\230.js"
@@ -0,0 +1,54 @@
+import { CACHE } from '../core/全局配置.js';
+import { getCacheWithMeta, setCache, removeCache } from '../core/网络请求_缓存管理.js';
+
+const viewRequests = new WeakMap();
+
+export function beginProfileListView(element, sessionCurrent = () => true) {
+ const token = {};
+ viewRequests.set(element, token);
+ return () => viewRequests.get(element) === token && sessionCurrent();
+}
+
+export function endProfileListView(element) { viewRequests.delete(element); }
+
+export function normalizeProfileItems(data) {
+ if (!Array.isArray(data)) throw new Error('Invalid profile list');
+ const items = data.filter(item => item && typeof item === 'object' && !Array.isArray(item)
+ && ((typeof item.id === 'string' && item.id.length > 0) || (typeof item.id === 'number' && Number.isFinite(item.id))));
+ if (data.length && !items.length) throw new Error('Invalid profile list entries');
+ return items;
+}
+
+export function normalizeCollectedProfile(data) {
+ if (!data || typeof data !== 'object' || Array.isArray(data)
+ || !['items', 'posts', 'prompts'].some(key => Object.hasOwn(data, key))) throw new Error('Invalid collected list');
+ return Object.fromEntries(['items', 'posts', 'prompts'].map(key => [key, normalizeProfileItems(Object.hasOwn(data, key) ? data[key] : [])]));
+}
+
+export function profileItemsFromResponse(response) {
+ if (response?.status !== 'success') throw new Error('Profile list request failed');
+ return normalizeProfileItems(response.data);
+}
+
+export function readProfileListCache(key, normalize = normalizeProfileItems) {
+ const managed = getCacheWithMeta(key, true);
+ if (managed.found) {
+ try { return normalize(managed.value); } catch { removeCache(key); }
+ }
+ // 兼容旧版未带管理器前缀、未封装 schema 的裸 JSON 缓存。
+ try {
+ const raw = localStorage.getItem(key);
+ if (!raw) return null;
+ const data = normalize(JSON.parse(raw));
+ writeProfileListCache(key, data);
+ try { if (localStorage.getItem(CACHE.PREFIX + key)) localStorage.removeItem(key); } catch {}
+ return data;
+ } catch {
+ try { localStorage.removeItem(key); } catch {}
+ return null;
+ }
+}
+
+export function writeProfileListCache(key, data) {
+ try { setCache(key, data, CACHE.TTL.LIST_DATA); } catch {}
+}
diff --git "a/\345\211\215\347\253\257\351\241\265\351\235\242/profile/\344\270\252\344\272\272\350\256\276\347\275\256\350\241\250\345\215\225\347\273\204\344\273\266.js" "b/\345\211\215\347\253\257\351\241\265\351\235\242/profile/\344\270\252\344\272\272\350\256\276\347\275\256\350\241\250\345\215\225\347\273\204\344\273\266.js"
index ac5155dc52caa6d00a57159bc81ff4fb9e3dc99e..1be4b1e1ce52f8b2cba790823ad09a26750cd6e3 100644
--- "a/\345\211\215\347\253\257\351\241\265\351\235\242/profile/\344\270\252\344\272\272\350\256\276\347\275\256\350\241\250\345\215\225\347\273\204\344\273\266.js"
+++ "b/\345\211\215\347\253\257\351\241\265\351\235\242/profile/\344\270\252\344\272\272\350\256\276\347\275\256\350\241\250\345\215\225\347\273\204\344\273\266.js"
@@ -1,11 +1,20 @@
// 前端页面/profile/个人设置表单组件.js
-import { api } from "../core/网络请求API.js";
+import { api, captureRequestSession } from "../core/网络请求API.js";
import { regionData, getSortedCountries } from "../auth/国家地区数据.js";
import { showToast } from "../components/UI交互提示组件.js";
import { uploadFile } from "../market/发布内容_提交引擎.js";
import { openImageCropper } from "../components/图片裁剪组件.js";
import { CACHE, getBackgroundKey, getBannerCacheKey, PLACEHOLDERS, escapeHtml } from "../core/全局配置.js";
import { t } from "../components/用户体验增强.js";
+import { normalizeProfileData, mergeStoredProfile } from "./个人资料工具.js";
+
+function _safeStorageGet(storageName, key) {
+ try { return window[storageName].getItem(key); } catch { return null; }
+}
+
+function _safeLocalStorageRemove(key) {
+ try { window.localStorage.removeItem(key); return true; } catch { return false; }
+}
// 计算年龄工具函数
function calculateAge(birthDate) {
@@ -21,23 +30,33 @@ function calculateAge(birthDate) {
}
// 图像上传处理通用函数:绑定触发按钮 → 文件选择 → 裁剪 → 回调
-function _createImageUploadHandler(inputEl, triggerEl, cropRatio, cropTitle, qualityLevel, onCropped) {
- triggerEl.onclick = () => inputEl.click();
+function _createImageUploadHandler(inputEl, triggerEl, cropRatio, cropTitle, qualityLevel, onCropped, current) {
+ let busy = false;
+ triggerEl.onclick = () => { if (current() && !busy) inputEl.click(); };
inputEl.onchange = async (e) => {
+ if (!current() || busy) return;
const file = e.target.files[0];
if (!file) return;
inputEl.value = "";
- const croppedFile = await openImageCropper(file, cropRatio, cropTitle, qualityLevel);
- if (!croppedFile) return;
- await onCropped(croppedFile);
+ busy = true;
+ try {
+ const croppedFile = await openImageCropper(file, cropRatio, cropTitle, qualityLevel);
+ if (!current() || !croppedFile) return;
+ await onCropped(croppedFile);
+ } catch (_) {
+ if (current()) showToast(t('settings_form.avatar_process_failed'), 'error');
+ } finally { busy = false; }
};
}
// FileReader读取图片并预览到目标元素,可选保存到localStorage
-function _readAndPreviewImage(file, previewElement, cacheKey) {
+function _readAndPreviewImage(file, previewElement, cacheKey, current, readers) {
return new Promise((resolve) => {
const reader = new FileReader();
+ readers.add(reader);
+ const finish = value => { readers.delete(reader); resolve(value); };
reader.onload = (ev) => {
+ if (!current() || !previewElement.isConnected) { finish(null); return; }
const base64 = ev.target.result;
if (cacheKey) {
try {
@@ -63,20 +82,35 @@ function _readAndPreviewImage(file, previewElement, cacheKey) {
previewElement.style.backgroundImage = `url(${base64})`;
previewElement.style.backgroundSize = "cover";
previewElement.style.backgroundPosition = "center";
- resolve(base64);
+ finish(base64);
};
reader.onerror = () => {
- showToast("❌ 文件读取失败,请重试", "error");
- resolve(null);
+ if (current()) showToast(t('settings_form.file_read_failed'), "error");
+ finish(null);
};
+ reader.onabort = () => finish(null);
reader.readAsDataURL(file);
});
}
-export function createSettingsForm(initialUserData, onCancelCallback, onSaveSuccessCallback) {
+export function createSettingsForm(initialUserData, onCancelCallback, onSaveSuccessCallback, options = {}) {
const container = document.createElement("div");
let userData = { ...initialUserData };
- const isAdmin = userData.isAdmin === true; // 🔒 管理员标识
+ container.className = 'ranking-settings-form';
+ const sessionMatches = captureRequestSession();
+ let disposed = false, saving = false;
+ const readers = new Set();
+ const current = () => !disposed && container.isConnected && sessionMatches() && (options.isCurrent?.() ?? true);
+ container._cleanup = () => {
+ if (disposed) return;
+ disposed = true;
+ for (const reader of readers) reader.abort();
+ readers.clear();
+ container.querySelectorAll('input, textarea').forEach(input => { input.value = ''; });
+ pendingBannerFile = pendingBannerDataUrl = null;
+ userData = {};
+ };
+ const isAdmin = userData.is_admin === true || userData.isAdmin === true; // 🔒 管理员标识
// 从用户数据的 privacy 对象中读取真实状态
const privacy = userData.privacy || {};
@@ -262,52 +296,57 @@ export function createSettingsForm(initialUserData, onCancelCallback, onSaveSucc
if (isAdmin) {
setTimeout(async () => {
const moderationToggle = container.querySelector('#admin-image-moderation');
+ if (!current()) return;
const statusDiv = container.querySelector('#moderation-status');
if (!moderationToggle) return;
try {
// 获取当前审核状态
const res = await api.getSystemConfig('image_moderation');
+ if (!current()) return;
const enabled = res?.data?.enabled === true;
moderationToggle.checked = enabled;
if (statusDiv) {
const quota = res?.data?.quota || {};
- statusDiv.innerHTML = enabled
- ? `
✅ 已启用 | 本月已用: 阿里云 ${quota.aliyun || 0}/3000, 腾讯云 ${quota.tencent || 0}/10000`
- : `
⚪ 未启用 | 上传图片将跳过审核`;
+ statusDiv.innerHTML = enabled
+ ? `
✅ ${escapeHtml(t('settings_form.enabled'))} | ${escapeHtml(t('settings_form.moderation_quota', { aliyun: quota.aliyun || 0, tencent: quota.tencent || 0 }))}`
+ : `
⚪ ${escapeHtml(t('settings_form.disabled'))} | ${escapeHtml(t('settings_form.moderation_skipped'))}`;
}
} catch (e) {
- console.warn('获取审核状态失败:', e);
+ console.warn('获取审核状态失败:', e?.name || 'Error');
}
// 开关变化事件
moderationToggle.onchange = async () => {
+ if (!current() || moderationToggle.disabled) return;
const newState = moderationToggle.checked;
moderationToggle.disabled = true;
try {
await api.setSystemConfig('image_moderation', { enabled: newState });
- showToast(newState ? '✅ 图像审核已启用' : '⚪ 图像审核已关闭', 'success');
+ if (!current()) return;
+ showToast(newState ? t('settings_form.moderation_enabled') : t('settings_form.moderation_disabled'), 'success');
if (statusDiv) {
- statusDiv.innerHTML = newState
- ? `
✅ 已启用`
- : `
⚪ 未启用 | 上传图片将跳过审核`;
+ statusDiv.innerHTML = newState
+ ? `
✅ ${escapeHtml(t('settings_form.enabled'))}`
+ : `
⚪ ${escapeHtml(t('settings_form.disabled'))} | ${escapeHtml(t('settings_form.moderation_skipped'))}`;
}
} catch (e) {
- showToast(t('settings_form.save_failed') + ': ' + e.message, 'error');
+ if (!current()) return;
+ showToast(t('settings_form.save_failed'), 'error');
moderationToggle.checked = !newState; // 回滚
} finally {
- moderationToggle.disabled = false;
+ if (current()) moderationToggle.disabled = false;
}
};
}, 100);
}
// 加载本地界面背景预览(使用账号区分键)
- const savedUiBg = localStorage.getItem(getBackgroundKey(userData.account));
- if (savedUiBg) {
+ const savedUiBg = _safeStorageGet('localStorage', getBackgroundKey(userData.account));
+ if (typeof savedUiBg === "string" && /^data:image\/(?:png|jpeg|webp);base64,/i.test(savedUiBg)) {
uiBgPreview.style.backgroundImage = `url(${savedUiBg})`;
uiBgPreview.style.backgroundSize = "cover";
uiBgPreview.style.backgroundPosition = "center";
@@ -317,7 +356,7 @@ export function createSettingsForm(initialUserData, onCancelCallback, onSaveSucc
birthdayInput.onchange = () => {
const age = calculateAge(birthdayInput.value);
if (age !== null) {
- ageDisplay.textContent = `${age} 岁`;
+ ageDisplay.textContent = `${age} ${t('profile.age_years')}`;
ageDisplay.style.color = "#4CAF50";
} else {
ageDisplay.textContent = "";
@@ -333,43 +372,47 @@ export function createSettingsForm(initialUserData, onCancelCallback, onSaveSucc
// 头像上传逻辑(1:1 裁剪)
_createImageUploadHandler(
settingAvatarInput, container.querySelector("#btn-trigger-avatar"),
- 1, "裁剪头像", 1,
+ 1, t('settings_form.crop_avatar'), 1,
async (croppedFile) => {
+ if (!current()) return;
const btnTrigger = container.querySelector("#btn-trigger-avatar");
const originalText = btnTrigger.innerText;
- btnTrigger.innerText = "上传中...";
+ btnTrigger.innerText = t('settings_form.uploading');
btnTrigger.disabled = true;
try {
const cloudImageUrl = await uploadFile(croppedFile, "avatar");
+ if (!current()) return;
if (cloudImageUrl) {
avatarDataUrl = cloudImageUrl;
container.querySelector("#setting-avatar-preview").src = cloudImageUrl;
showToast(`✅ ${t('settings_form.avatar_upload_success')}`, "success");
}
} catch (err) {
- showToast("❌ 头像处理失败: " + err.message, "error");
+ if (current()) showToast(t('settings_form.avatar_process_failed'), "error");
} finally {
- btnTrigger.innerText = originalText;
- btnTrigger.disabled = false;
+ if (current()) { btnTrigger.innerText = originalText; btnTrigger.disabled = false; }
}
- }
+ }, current
);
// 个人资料卡背景图上传逻辑(16:9 裁剪)
// 🚀 优化:裁剪后只预览,点击保存时才上传到云端
_createImageUploadHandler(
settingBannerInput, container.querySelector("#btn-trigger-banner"),
- 16/9, "裁剪个人资料卡背景", 3,
+ 16/9, t('settings_form.crop_banner'), 3,
async (croppedFile) => {
+ if (!current()) return;
pendingBannerFile = croppedFile;
- const dataUrl = await _readAndPreviewImage(croppedFile, bannerPreview, null);
+ const dataUrl = await _readAndPreviewImage(croppedFile, bannerPreview, null, current, readers);
+ if (!current()) return;
if (dataUrl) {
pendingBannerDataUrl = dataUrl;
- showToast("✅ 背景图已选择,点击底部保存设置后才会上传。", "info");
+ showToast(t('settings_form.banner_selected'), "info");
}
- }
+ }, current
);
container.querySelector("#btn-clear-banner").onclick = () => {
+ if (!current()) return;
// 🔧 清除必须传空串而非 null:云端 PUT /api/users/{account} 的更新循环只写非 None 值
// (见 router_users_profile.py「遍历请求中的字段,只更新非空值」),传 null 等同于
// 「本次没改这个字段」而被跳过,云端旧图残留 —— 表现为点清除并保存后提示成功,
@@ -383,45 +426,55 @@ export function createSettingsForm(initialUserData, onCancelCallback, onSaveSucc
bannerPreview.style.backgroundImage = "none";
bannerPreview.style.background = "#1a1a1a";
// 同时清除本地缓存(使用账号区分键)
- localStorage.removeItem(getBannerCacheKey(userData.account));
+ if (!_safeLocalStorageRemove(getBannerCacheKey(userData.account))) {
+ showToast(t('settings_form.local_storage_unavailable'), "warning");
+ }
showToast(`✅ ${t('settings_form.banner_cleared')}`, "success");
};
// 界面背景上传逻辑(9:16 裁剪,仅本地)
_createImageUploadHandler(
settingUiBgInput, container.querySelector("#btn-trigger-ui-bg"),
- 9/16, "裁剪界面背景", 3,
+ 9/16, t('settings_form.crop_ui_bg'), 3,
async (croppedFile) => {
- const dataUrl = await _readAndPreviewImage(croppedFile, uiBgPreview, getBackgroundKey(userData.account));
+ const dataUrl = await _readAndPreviewImage(croppedFile, uiBgPreview, getBackgroundKey(userData.account), current, readers);
+ if (!current()) return;
if (dataUrl) {
window.dispatchEvent(new CustomEvent("comfy-sidebar-bg-update"));
- showToast("✅ 界面背景设置成功,已立即生效!", "success");
+ showToast(t('settings_form.ui_bg_applied'), "success");
}
- }
+ }, current
);
container.querySelector("#btn-clear-ui-bg").onclick = () => {
+ if (!current()) return;
// 使用账号区分键清除
- localStorage.removeItem(getBackgroundKey(userData.account));
+ if (!_safeLocalStorageRemove(getBackgroundKey(userData.account))) {
+ showToast(t('settings_form.local_storage_unavailable'), "error");
+ return;
+ }
uiBgPreview.style.backgroundImage = "none";
uiBgPreview.style.background = "#1a1a1a";
// 立即应用到侧边栏
window.dispatchEvent(new CustomEvent("comfy-sidebar-bg-update"));
- showToast("✅ 已清除界面背景,已立即生效。", "success");
+ showToast(t('settings_form.ui_bg_cleared'), "success");
};
- container.querySelector("#btn-cancel-settings").onclick = () => { if (onCancelCallback) onCancelCallback(); };
+ container.querySelector("#btn-cancel-settings").onclick = () => { if (current() && onCancelCallback) onCancelCallback(); };
container.querySelector("#btn-save-settings").onclick = async (e) => {
- const btn = e.target;
- btn.innerHTML = "⏳ 保存中...";
+ if (!current() || saving) return;
+ saving = true;
+ const btn = e.currentTarget;
+ btn.innerHTML = `⏳ ${escapeHtml(t('settings_form.saving'))}`;
btn.disabled = true;
try {
// 🚀 优化:如果有待上传的背景图,先上传
if (pendingBannerFile) {
- btn.innerHTML = "⏳ 上传背景图...";
+ btn.innerHTML = `⏳ ${escapeHtml(t('settings_form.uploading_banner'))}`;
try {
const cloudImageUrl = await uploadFile(pendingBannerFile, "banner");
+ if (!current()) return;
if (cloudImageUrl) {
bannerUrl = cloudImageUrl;
// 保存到本地缓存
@@ -450,14 +503,16 @@ export function createSettingsForm(initialUserData, onCancelCallback, onSaveSucc
pendingBannerFile = null;
pendingBannerDataUrl = null;
} catch (uploadErr) {
- showToast("❌ 背景图上传失败: " + uploadErr.message, "error");
- btn.innerHTML = "保存所有设置";
+ if (!current()) return;
+ saving = false;
+ showToast(t('settings_form.banner_upload_failed'), "error");
+ btn.innerHTML = escapeHtml(t('settings_form.save_all'));
btn.disabled = false;
return;
}
}
- btn.innerHTML = "⏳ 保存设置...";
+ btn.innerHTML = `⏳ ${escapeHtml(t('settings_form.saving_settings'))}`;
const birthday = birthdayInput.value || null;
const updateData = {
name: container.querySelector("#setting-name").value.trim(),
@@ -472,6 +527,8 @@ export function createSettingsForm(initialUserData, onCancelCallback, onSaveSucc
};
const res = await api.updateUserProfile(userData.account, updateData);
+ if (!current()) return;
+ if (res?.status !== 'success' || !res.data || typeof res.data !== 'object' || Array.isArray(res.data) || res.data.account !== userData.account) throw new Error('Invalid settings response');
// 收集隐私设置并发送到后端
const privacySettings = {
@@ -480,32 +537,33 @@ export function createSettingsForm(initialUserData, onCancelCallback, onSaveSucc
favorites: container.querySelector("#hide-favorites").checked,
downloads: container.querySelector("#hide-downloads").checked
};
- await api.updatePrivacy(userData.account, privacySettings);
+ const privacyResponse = await api.updatePrivacy(userData.account, privacySettings);
+ if (!current()) return;
+ if (privacyResponse?.status !== 'success') throw new Error('Invalid privacy response');
- const newUserData = { ...userData, ...res.data, privacy: privacySettings };
+ const newUserData = normalizeProfileData({ ...userData, ...res.data, privacy: privacySettings }, userData.account, true);
- const storage = localStorage.getItem("ComfyCommunity_User") ? localStorage : sessionStorage;
- try {
- const savedStr = storage.getItem("ComfyCommunity_User");
- if (savedStr) {
- const savedObj = JSON.parse(savedStr);
- savedObj.user = newUserData;
- storage.setItem("ComfyCommunity_User", JSON.stringify(savedObj));
- }
- } catch(e){}
+ const storages = ['localStorage', 'sessionStorage'].map(name => {
+ try { return window[name]; } catch (_) { return null; }
+ });
+ mergeStoredProfile(userData.account, newUserData, storages, CACHE.LEGACY_KEYS.USER);
// 主动清除该用户的 SWR 缓存,确保头像等信息立即刷新
const profileCacheKey = "ComfyCommunity_ProfileCache_" + userData.account;
- localStorage.removeItem(profileCacheKey);
+ if (!_safeLocalStorageRemove(profileCacheKey)) {
+ showToast(t('settings_form.local_storage_unavailable'), "warning");
+ }
- showToast("设置已保存并同步至云端!", "success");
+ showToast(t('settings_form.settings_saved'), "success");
if (onSaveSuccessCallback) onSaveSuccessCallback(newUserData);
} catch (err) {
- showToast("保存失败:" + err.message, "error");
- btn.innerHTML = "保存所有设置";
+ if (!current()) return;
+ saving = false;
+ showToast(t('settings_form.settings_save_failed'), "error");
+ btn.innerHTML = escapeHtml(t('settings_form.save_all'));
btn.disabled = false;
}
};
return container;
-}
\ No newline at end of file
+}
diff --git "a/\345\211\215\347\253\257\351\241\265\351\235\242/profile/\344\270\252\344\272\272\350\265\204\346\226\231\345\267\245\345\205\267.js" "b/\345\211\215\347\253\257\351\241\265\351\235\242/profile/\344\270\252\344\272\272\350\265\204\346\226\231\345\267\245\345\205\267.js"
new file mode 100644
index 0000000000000000000000000000000000000000..c289eb7f8bbd5ffc94dc3ca882804ff3e1fc136c
--- /dev/null
+++ "b/\345\211\215\347\253\257\351\241\265\351\235\242/profile/\344\270\252\344\272\272\350\265\204\346\226\231\345\267\245\345\205\267.js"
@@ -0,0 +1,44 @@
+/** 用户资料与 SQL 钱包响应分别校验,未知内部字段不进入页面。 */
+const PUBLIC_FIELDS = ['name', 'avatar', 'avatarDataUrl', 'bannerUrl', 'intro', 'gender', 'country', 'region'];
+const WALLET_FIELDS = ['balance', 'earn_balance', 'tip_balance', 'task_balance', 'frozen_balance', 'total_withdrawn'];
+
+export function normalizeProfileData(value, account, own = false) {
+ if (!value || typeof value !== 'object' || Array.isArray(value) || value.account !== account
+ || typeof account !== 'string' || !account.trim()) throw new Error('Invalid profile');
+ const result = { account };
+ for (const key of [...PUBLIC_FIELDS, ...(own ? ['email', 'phone', 'birthday'] : [])]) {
+ if (typeof value[key] === 'string') result[key] = value[key];
+ }
+ for (const key of ['age', 'created_at', 'receivedLikes', 'receivedFavorites', 'receivedUses']) {
+ if (typeof value[key] === 'number' && Number.isFinite(value[key]) && value[key] >= 0) result[key] = value[key];
+ }
+ for (const key of ['followers', 'following']) {
+ result[key] = Array.isArray(value[key]) ? value[key].filter(v => typeof v === 'string' && v.trim()) : [];
+ }
+ result.privacy = Object.fromEntries(['follows', 'followers', 'likes', 'favorites', 'downloads']
+ .map(key => [key, value.privacy?.[key] === true]));
+ result.is_admin = value.is_admin === true || (own && value.isAdmin === true);
+ if (own) Object.assign(result, normalizeWalletData({ ...value, status: 'success' }));
+ return result;
+}
+
+export function normalizeWalletData(value) {
+ if (!value || typeof value !== 'object' || Array.isArray(value) || value.status !== 'success') return {};
+ return Object.fromEntries(WALLET_FIELDS.filter(key => Number.isSafeInteger(value[key]) && value[key] >= 0)
+ .map(key => [key, value[key]]));
+}
+
+/** 身份有效性由调用者核查;此函数还核对存储记录的账号,防止覆盖新账号。 */
+export function mergeStoredProfile(account, freshData, storages, userKey) {
+ for (const storage of storages) {
+ try {
+ const raw = storage?.getItem(userKey);
+ if (!raw) continue;
+ const saved = JSON.parse(raw);
+ if (saved?.user?.account !== account) continue;
+ const profile = normalizeProfileData({ ...saved.user, ...freshData }, account, true);
+ for (const key of WALLET_FIELDS) delete profile[key];
+ storage.setItem(userKey, JSON.stringify({ ...saved, user: profile }));
+ } catch (_) {}
+ }
+}
diff --git "a/\345\211\215\347\253\257\351\241\265\351\235\242/prompt/\344\273\267\346\240\274\346\240\241\351\252\214.js" "b/\345\211\215\347\253\257\351\241\265\351\235\242/prompt/\344\273\267\346\240\274\346\240\241\351\252\214.js"
new file mode 100644
index 0000000000000000000000000000000000000000..112a530091c2ef840310dbcccc0719b43d65ac9f
--- /dev/null
+++ "b/\345\211\215\347\253\257\351\241\265\351\235\242/prompt/\344\273\267\346\240\274\346\240\241\351\252\214.js"
@@ -0,0 +1,22 @@
+import { PROMPT_LIMITS } from "../core/全局配置.js";
+
+export function parsePromptPrice(value) {
+ if (typeof value !== "string" || !/^\d+$/.test(value.trim())) return null;
+ const price = Number(value.trim());
+ return Number.isSafeInteger(price) && price >= 0 && price <= PROMPT_LIMITS.MAX_PRICE ? price : null;
+}
+
+export function validatePromptPriceInput(input, error, t) {
+ const price = parsePromptPrice(input.value);
+ if (price === null) {
+ error.textContent = t("prompt.price_invalid", { max: PROMPT_LIMITS.MAX_PRICE });
+ error.hidden = false;
+ input.setAttribute("aria-invalid", "true");
+ input.focus();
+ return null;
+ }
+ error.hidden = true;
+ error.textContent = "";
+ input.removeAttribute("aria-invalid");
+ return price;
+}
diff --git "a/\345\211\215\347\253\257\351\241\265\351\235\242/prompt/\345\217\221\345\270\203\346\217\220\347\244\272\350\257\215\347\273\204\344\273\266.js" "b/\345\211\215\347\253\257\351\241\265\351\235\242/prompt/\345\217\221\345\270\203\346\217\220\347\244\272\350\257\215\347\273\204\344\273\266.js"
index 2c40e34467e48613cedc15b4a96481b034af76d1..5294c369aaed13b8cfe030a6d2c2c0a7aeec4110 100644
--- "a/\345\211\215\347\253\257\351\241\265\351\235\242/prompt/\345\217\221\345\270\203\346\217\220\347\244\272\350\257\215\347\273\204\344\273\266.js"
+++ "b/\345\211\215\347\253\257\351\241\265\351\235\242/prompt/\345\217\221\345\270\203\346\217\220\347\244\272\350\257\215\347\273\204\344\273\266.js"
@@ -10,13 +10,33 @@
// - 提示词组件.js (发布后刷新列表)
// ==========================================
+import { createPublishFieldErrors } from "../market/发布字段错误.js";
+import { bindPublishFilePicker, setPublishFilePickerAction } from "../market/发布文件选择可访问性.js";
+import { compressPublishImage as compressImage, capturePublishVideoFrame } from "../market/有界图片处理.js";
import { api } from "../core/网络请求API.js";
import { showToast, showConfirm } from "../components/UI交互提示组件.js";
import { t, tIfExists } from "../components/用户体验增强.js";
import { removeCacheByPrefix } from "../components/性能优化工具.js";
-import { API } from "../core/全局配置.js";
+import { createPublishEditingLock } from "../market/发布编辑锁.js";
+import { createPublishSubmissionScope } from "../market/发布提交生命周期.js";
+import { parsePromptPrice, validatePromptPriceInput } from "./价格校验.js";
+import { API, CACHE, PUBLISH_MEDIA_LIMITS, IMAGE_PROCESS, PROMPT_LIMITS } from "../core/全局配置.js";
import { escapeHtml } from "../components/互动工具函数.js"; // 🧹 P2归一:局部副本已移除
+function requirePromptUpload(result) {
+ if (result?.status !== 'success' || typeof result.url !== 'string' || !result.url.trim()) {
+ throw new Error('Unconfirmed prompt upload');
+ }
+ return result.url;
+}
+
+function confirmPromptSave(result, isEditMode) {
+ if (result?.status !== 'success' || (!isEditMode &&
+ (typeof result.data?.id !== 'string' || !result.data.id.trim()))) {
+ throw new Error('Unconfirmed prompt save');
+ }
+}
+
// 📦 清除提示词列表缓存(前缀扫描,覆盖全部 类型/分类/排序 组合)
function clearPromptsListCache() {
// 🔧 改用缓存模块的前缀删除原语:原先手写的 localStorage 扫描 + removeCache 组合
@@ -29,99 +49,63 @@ function clearPromptsListCache() {
console.log('🗑️ 已清除提示词列表缓存');
}
-// 🖼️ 图片压缩配置
-const IMAGE_MAX_SIZE = 1920; // 最大宽/高
-const IMAGE_QUALITY = 0.85; // JPG压缩质量
-
-/**
- * 🖼️ 压缩图片:转为JPG格式并限制尺寸
- */
-function compressImage(file) {
- return new Promise((resolve, reject) => {
- const reader = new FileReader();
- reader.readAsDataURL(file);
- reader.onload = (e) => {
- const img = new Image();
- img.src = e.target.result;
- img.onload = () => {
- const canvas = document.createElement("canvas");
- const ctx = canvas.getContext("2d");
-
- let { width, height } = img;
- if (width > IMAGE_MAX_SIZE || height > IMAGE_MAX_SIZE) {
- if (width > height) {
- height = Math.round(height * IMAGE_MAX_SIZE / width);
- width = IMAGE_MAX_SIZE;
- } else {
- width = Math.round(width * IMAGE_MAX_SIZE / height);
- height = IMAGE_MAX_SIZE;
- }
- }
-
- canvas.width = width;
- canvas.height = height;
- ctx.drawImage(img, 0, 0, width, height);
-
- canvas.toBlob((blob) => {
- if (blob) {
- const newFileName = file.name.replace(/\.[^/.]+$/, "") + ".jpg";
- const compressedFile = new File([blob], newFileName, { type: "image/jpeg" });
- console.log(`🖼️ 图片压缩: ${(file.size/1024).toFixed(1)}KB → ${(compressedFile.size/1024).toFixed(1)}KB`);
- resolve(compressedFile);
- } else {
- reject(new Error("图片压缩失败"));
- }
- }, "image/jpeg", IMAGE_QUALITY);
- };
- img.onerror = () => reject(new Error("图片加载失败"));
- };
- reader.onerror = () => reject(new Error("文件读取失败"));
- });
-}
-
/**
* 🎬 使用 XMLHttpRequest 上传视频并支持进度回调
*/
-function uploadVideoWithProgress(file, onProgress) {
+function uploadVideoWithProgress(file, onProgress, { signal } = {}) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
- const formData = new FormData();
- formData.append("file", file);
- formData.append("file_type", "post_video");
-
- const baseUrl = API?.BASE_URL || "";
- xhr.open("POST", `${baseUrl}/api/upload`);
-
- const token = localStorage.getItem("ComfyCommunity_Token") || sessionStorage.getItem("ComfyCommunity_Token");
- if (token) xhr.setRequestHeader("Authorization", `Bearer ${token}`);
-
- xhr.upload.onprogress = (e) => {
- if (e.lengthComputable && onProgress) {
- onProgress(Math.round((e.loaded / e.total) * 100));
- }
+ let settled = false;
+ const abort = () => {
+ finish(Object.assign(new Error('Video upload aborted'), { name: 'AbortError' }));
+ try { xhr.abort(); } catch {}
};
-
- xhr.onload = () => {
- if (xhr.status >= 200 && xhr.status < 300) {
- try {
- resolve(JSON.parse(xhr.responseText));
- } catch {
- resolve({ url: xhr.responseText });
+ const finish = (error, result) => {
+ if (settled) return;
+ settled = true;
+ signal?.removeEventListener('abort', abort);
+ xhr.onload = xhr.onerror = xhr.ontimeout = xhr.onabort = null;
+ xhr.upload.onprogress = null;
+ if (error) reject(error); else resolve(result);
+ };
+ if (signal?.aborted) { abort(); return; }
+ signal?.addEventListener('abort', abort, { once: true });
+ const tokenFrom = readStorage => {
+ try { return readStorage().getItem(CACHE.LEGACY_KEYS.TOKEN); } catch { return null; }
+ };
+ try {
+ const formData = new FormData();
+ formData.append('file', file);
+ formData.append('file_type', 'post_video');
+ xhr.open('POST', `${API.BASE_URL}/api/upload`);
+ const token = tokenFrom(() => localStorage) || tokenFrom(() => sessionStorage);
+ if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);
+ xhr.upload.onprogress = event => {
+ if (settled || !event.lengthComputable || !Number.isFinite(event.loaded) ||
+ !Number.isFinite(event.total) || event.loaded < 0 || event.total <= 0) return;
+ const percent = Math.max(0, Math.min(100, Math.round(event.loaded / event.total * 100)));
+ try { onProgress?.(percent); } catch {}
+ };
+ xhr.onload = () => {
+ if (settled) return;
+ if (xhr.status < 200 || xhr.status >= 300) {
+ finish(Object.assign(new Error('Video upload rejected'), { status: xhr.status })); return;
}
- } else {
- let msg = `上传失败 (${xhr.status})`;
try {
- const data = JSON.parse(xhr.responseText);
- if (data.detail || data.message || data.error) msg = data.detail || data.message || data.error;
- } catch {}
- reject(new Error(msg));
- }
- };
-
- xhr.onerror = () => reject(new Error('网络错误,上传失败'));
- xhr.ontimeout = () => reject(new Error('上传超时'));
- xhr.timeout = 300000;
- xhr.send(formData);
+ if (typeof xhr.responseText !== 'string' || xhr.responseText.length > API.VIDEO_UPLOAD_RESPONSE_MAX_CHARS) {
+ throw new Error('Invalid video upload response');
+ }
+ const result = JSON.parse(xhr.responseText);
+ requirePromptUpload(result);
+ finish(null, result);
+ } catch { finish(new Error('Unconfirmed video upload')); }
+ };
+ xhr.onerror = () => finish(new Error('Video upload network failure'));
+ xhr.ontimeout = () => finish(new Error('Video upload timed out'));
+ xhr.onabort = () => finish(Object.assign(new Error('Video upload aborted'), { name: 'AbortError' }));
+ xhr.timeout = API.VIDEO_UPLOAD_TIMEOUT;
+ xhr.send(formData);
+ } catch { finish(new Error('Video upload could not start')); }
});
}
@@ -146,22 +130,27 @@ function _clearDragIndicators(wrappers) {
}
// 创建图片区域「+」添加按钮
-function _createAddButton(imagesInput) {
- const addBtn = document.createElement("div");
+function _createAddButton(imagesInput, scope) {
+ const addBtn = document.createElement("button");
+ addBtn.type = "button";
+ addBtn.setAttribute("aria-label", t("post.upload_images"));
+ addBtn.setAttribute("data-file-picker-button", "");
Object.assign(addBtn.style, {
width: "80px",
height: "80px",
- border: "2px dashed #444",
+ background: "transparent",
+ border: "2px dashed var(--rk-border, var(--border-color, #444))",
borderRadius: "6px",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
- color: "#666",
+ color: "var(--rk-muted, var(--descrip-text, #a4a4a4))",
fontSize: "24px"
});
addBtn.textContent = "+";
addBtn.onclick = (e) => {
+ if (!scope.isCurrent() || imagesInput.disabled) return;
e.stopPropagation();
imagesInput.click();
};
@@ -169,10 +158,22 @@ function _createAddButton(imagesInput) {
}
// 创建删除按钮点击处理器(停止冒泡 + 执行回调)
-function _createRemoveButtonHandler(removeCallback) {
+function _createRemoveButtonHandler(removeCallback, options = {}) {
+ const { previewContainer, wrapper, canInteract = () => true, focusEmpty } = options;
+ const images = () => Array.from(previewContainer.children).filter(child =>
+ child.tagName === 'DIV' && child.querySelector('img'));
return (e) => {
e.stopPropagation();
+ if (!canInteract() || e.currentTarget?.disabled) return;
+ const index = previewContainer ? images().indexOf(wrapper) : -1;
+ if (previewContainer && index < 0) return;
+ const restoreFocus = wrapper?.contains(document.activeElement);
removeCallback();
+ if (!restoreFocus || !canInteract()) return;
+ const remaining = images();
+ const next = remaining[Math.min(index, remaining.length - 1)];
+ if (next) next.querySelector('button[data-action="remove"]').focus();
+ else focusEmpty?.focus();
};
}
@@ -184,14 +185,26 @@ function updateCoverMark(previewContainer) {
wrappers.forEach((wrapper, idx) => {
const img = wrapper.querySelector('img');
const coverLabel = wrapper.querySelector('.cover-label');
+ wrapper.tabIndex = 0;
+ wrapper.setAttribute('role', 'group');
+ wrapper.setAttribute('aria-label', t('post.image_position', { index: idx + 1, total: wrappers.length }));
+ wrapper.setAttribute('aria-keyshortcuts', 'Alt+ArrowLeft Alt+ArrowRight');
+ if (!wrapper.title) wrapper.title = t('post.image_move_hint');
+ const remove = wrapper.querySelector('button[data-action="remove"]');
+ if (remove) {
+ remove.type = 'button';
+ remove.setAttribute('aria-label', t('post.remove_image', { index: idx + 1 }));
+ }
+ if (img && !img.hasAttribute('alt')) img.alt = '';
+
if (img) {
- img.style.borderColor = idx === 0 ? '#4CAF50' : '#444';
+ img.style.borderColor = idx === 0 ? '#4CAF50' : 'var(--rk-border, var(--border-color, #444))';
}
if (idx === 0) {
if (!coverLabel) {
const label = document.createElement('span');
label.className = 'cover-label';
- label.style.cssText = 'position: absolute; top: 4px; left: 4px; background: #4CAF50; color: #fff; font-size: 10px; padding: 2px 6px; border-radius: 3px; pointer-events: none;';
+ label.style.cssText = 'position: absolute; top: 4px; left: 4px; background: #4CAF50; color: var(--rk-text, var(--fg-color, #ddd)); font-size: 10px; padding: 2px 6px; border-radius: 3px; pointer-events: none;';
label.textContent = t('post.cover');
wrapper.appendChild(label);
}
@@ -203,7 +216,7 @@ function updateCoverMark(previewContainer) {
// ✅ 为图片预览容器设置拖拽排序
function setupImageDragSort(previewContainer, fileArray, options = {}) {
- const { onRemove, onDrop } = options;
+ const { onRemove, onDrop, canInteract = () => true, focusEmpty } = options;
const getWrappers = () => Array.from(previewContainer.children).filter(
child => child.tagName === 'DIV' && child.querySelector('img')
);
@@ -211,6 +224,7 @@ function setupImageDragSort(previewContainer, fileArray, options = {}) {
const wrappers = getWrappers();
if (wrappers.length === 0) return;
+ updateCoverMark(previewContainer);
let dragSrcEl = null;
let dragSrcIndex = -1;
@@ -243,10 +257,32 @@ function setupImageDragSort(previewContainer, fileArray, options = {}) {
updateCoverMark(previewContainer);
setupImageDragSort(previewContainer, fileArray, options);
}
- });
+ }, { previewContainer, wrapper, canInteract, focusEmpty });
}
+ wrapper.onkeydown = (e) => {
+ if (e.target !== wrapper || !e.altKey || e.ctrlKey || e.metaKey || e.shiftKey ||
+ e.repeat || e.isComposing || !canInteract() ||
+ !['ArrowLeft', 'ArrowRight'].includes(e.key)) return;
+ const current = getWrappers();
+ const from = current.indexOf(wrapper);
+ const to = from + (e.key === 'ArrowLeft' ? -1 : 1);
+ e.preventDefault(); e.stopPropagation();
+ // 编辑保存按已有URL、新文件分组;不能移动到另一组而仅改变视觉顺序。
+ if (from < 0 || to < 0 || to >= current.length ||
+ current[to]._imageType !== wrapper._imageType) return;
+ previewContainer.insertBefore(wrapper, to > from ? current[to].nextSibling : current[to]);
+ if (onDrop) onDrop();
+ else if (fileArray) {
+ const [moved] = fileArray.splice(from, 1);
+ fileArray.splice(to, 0, moved);
+ }
+ updateCoverMark(previewContainer);
+ wrapper.focus();
+ };
+
wrapper.ondragstart = (e) => {
+ if (!canInteract()) { e.preventDefault(); return; }
e.stopPropagation();
dragSrcEl = wrapper;
dragSrcIndex = getWrappers().indexOf(wrapper);
@@ -285,7 +321,8 @@ function setupImageDragSort(previewContainer, fileArray, options = {}) {
e.stopPropagation();
const currentWrappers = getWrappers();
const targetIndex = currentWrappers.indexOf(wrapper);
- if (targetIndex === dragSrcIndex || dragSrcIndex === -1) return;
+ if (!canInteract() || targetIndex === dragSrcIndex || dragSrcIndex === -1 ||
+ !currentWrappers.includes(dragSrcEl) || wrapper._imageType !== dragSrcEl._imageType) return;
// 移动 DOM 元素
if (targetIndex > dragSrcIndex) {
@@ -340,45 +377,34 @@ export function createPublishPromptView(currentUser, editPromptData = null) {
const editTags = isEditMode ? (Array.isArray(editPromptData.tags) ? editPromptData.tags : []) : [];
const container = document.createElement("div");
- Object.assign(container.style, {
- display: "flex",
- flexDirection: "column",
- gap: "0",
- color: "#ccc",
- fontSize: "14px",
- padding: "0",
- overflowY: "auto",
- flex: "1",
- boxSizing: "border-box"
- });
-
- const fieldLabelStyle = "display: block; font-size: 13px; font-weight: bold; color: #fff; margin-bottom: 8px;";
- const inputStyle = "width: 100%; padding: 12px; background: #1a1a1a; border: 1px solid #444; border-radius: 6px; color: #fff; font-size: 14px; box-sizing: border-box; outline: none;";
+ const fieldLabelStyle = "display: block; font-size: 13px; font-weight: bold; color: var(--rk-text, var(--fg-color, #ddd)); margin-bottom: 8px;";
+ const inputStyle = "";
+ container.classList.add("ranking-publish-form");
container.innerHTML = `
-
-
-