Spaces:
Running
Running
Upload 102 files
Browse files- 前端页面/components/全局设置组件.js +68 -6
- 前端页面/components/用户体验_国际化.js +12 -0
- 前端页面/core/全局配置.js +11 -1
- 前端页面/core/本机性能统计.js +81 -0
- 前端页面/core/网络请求_基础设施.js +14 -2
- 前端页面/profile/个人中心视图.js +370 -349
- 前端页面/prompt/提示词详情组件.js +30 -11
前端页面/components/全局设置组件.js
CHANGED
|
@@ -11,7 +11,8 @@
|
|
| 11 |
import { showToast } from "./UI交互提示组件.js";
|
| 12 |
import { setLanguage, getLanguage, t } from "./用户体验增强.js";
|
| 13 |
import { clearAllCache } from "./性能优化工具.js";
|
| 14 |
-
import { IS_WEB_MODE, getSettings, saveSettings } from "../core/全局配置.js";
|
|
|
|
| 15 |
export { getSettings, saveSettings };
|
| 16 |
import { getDiskCacheStats, clearDiskCache } from './缓存管理请求.js';
|
| 17 |
|
|
@@ -151,6 +152,23 @@ export function createSettingsView() {
|
|
| 151 |
${t('settings.data_cache')}
|
| 152 |
</h2>
|
| 153 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
<!-- 设置项:缓存刷新间隔(滑条) -->
|
| 155 |
<div class="ranking-settings__stack setting-item" >
|
| 156 |
<div class="ranking-settings__copy-wide" >
|
|
@@ -226,11 +244,12 @@ export function createSettingsView() {
|
|
| 226 |
*/
|
| 227 |
function _bindSwitchHandler(selector, settingKey, labelKey, iconOn = '✅') {
|
| 228 |
const switchEl = container.querySelector(selector);
|
|
|
|
| 229 |
switchEl.setAttribute("aria-checked", String(switchEl.classList.contains("active")));
|
| 230 |
switchEl.onclick = () => {
|
| 231 |
-
if (
|
| 232 |
let currentSettings;
|
| 233 |
-
try { currentSettings = getSettings(
|
| 234 |
catch { showToast(t('common.error'), 'error'); return; }
|
| 235 |
const previousValue = switchEl.classList.contains("active");
|
| 236 |
const newValue = !previousValue;
|
|
@@ -239,11 +258,11 @@ export function createSettingsView() {
|
|
| 239 |
currentSettings[settingKey] = newValue;
|
| 240 |
const saved = saveSettings(currentSettings);
|
| 241 |
let confirmed = saved === true;
|
| 242 |
-
if (
|
| 243 |
-
try { confirmed = confirmed && getSettings({ strict: true })
|
| 244 |
catch { confirmed = false; }
|
| 245 |
}
|
| 246 |
-
if (
|
| 247 |
switchEl.classList.toggle('active', previousValue);
|
| 248 |
switchEl.setAttribute('aria-checked', String(previousValue));
|
| 249 |
showToast(t('common.error'), 'error');
|
|
@@ -303,6 +322,7 @@ export function createSettingsView() {
|
|
| 303 |
|
| 304 |
// 🎛️ 开关控制:创作者背景图 / 榜单切换动画 / 动画音效
|
| 305 |
_bindSwitchHandler("#switch-keyboard-shortcuts", "enableKeyboardShortcuts", "settings.keyboard_combinations");
|
|
|
|
| 306 |
_bindSwitchHandler("#switch-creator-banner", "showCreatorBanner", "settings.creator_banner", "✅");
|
| 307 |
_bindSwitchHandler("#switch-animations", "enableAnimations", "settings.animations", "✨");
|
| 308 |
_bindSwitchHandler("#switch-sound-effects", "enableSoundEffects", "settings.sound", "🔊");
|
|
@@ -377,6 +397,11 @@ export function createSettingsView() {
|
|
| 377 |
if (cacheViewDisposed) return;
|
| 378 |
cacheViewDisposed = true;
|
| 379 |
container.querySelector("#switch-keyboard-shortcuts").onclick = null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 380 |
languageGeneration++;
|
| 381 |
clearTimeout(languageTimer);
|
| 382 |
languageTimer = null;
|
|
@@ -385,6 +410,43 @@ export function createSettingsView() {
|
|
| 385 |
statsController = null;
|
| 386 |
};
|
| 387 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 388 |
async function loadCacheStats() {
|
| 389 |
const generation = ++statsGeneration;
|
| 390 |
statsController?.abort();
|
|
|
|
| 11 |
import { showToast } from "./UI交互提示组件.js";
|
| 12 |
import { setLanguage, getLanguage, t } from "./用户体验增强.js";
|
| 13 |
import { clearAllCache } from "./性能优化工具.js";
|
| 14 |
+
import { IS_WEB_MODE, LOCAL_PERFORMANCE, getSettings, saveSettings } from "../core/全局配置.js";
|
| 15 |
+
import { getLocalPerformanceStats, clearLocalPerformanceStats } from "../core/本机性能统计.js";
|
| 16 |
export { getSettings, saveSettings };
|
| 17 |
import { getDiskCacheStats, clearDiskCache } from './缓存管理请求.js';
|
| 18 |
|
|
|
|
| 152 |
${t('settings.data_cache')}
|
| 153 |
</h2>
|
| 154 |
|
| 155 |
+
<div class="setting-item">
|
| 156 |
+
<div class="ranking-settings__copy">
|
| 157 |
+
<div class="ranking-settings__label" id="label-local-performance">${t('settings.performance_stats')}</div>
|
| 158 |
+
<div class="ranking-settings__description" id="desc-local-performance">${t('settings.performance_stats_desc')}</div>
|
| 159 |
+
</div>
|
| 160 |
+
<button type="button" role="switch" id="switch-local-performance" aria-labelledby="label-local-performance" aria-describedby="desc-local-performance" class="setting-switch ${settings.enableLocalPerformanceStats === true ? 'active' : ''}"></button>
|
| 161 |
+
</div>
|
| 162 |
+
<section id="local-performance-panel" aria-labelledby="label-local-performance" hidden>
|
| 163 |
+
<div class="ranking-settings__stack setting-item">
|
| 164 |
+
<div class="ranking-settings__stats" id="local-performance-stats" role="status" aria-live="polite"></div>
|
| 165 |
+
<div class="ranking-settings__actions">
|
| 166 |
+
<button type="button" id="btn-refresh-local-performance" class="cache-clear-btn">${t('settings.performance_refresh')}</button>
|
| 167 |
+
<button type="button" id="btn-clear-local-performance" class="cache-clear-btn">${t('settings.performance_clear')}</button>
|
| 168 |
+
</div>
|
| 169 |
+
</div>
|
| 170 |
+
</section>
|
| 171 |
+
|
| 172 |
<!-- 设置项:缓存刷新间隔(滑条) -->
|
| 173 |
<div class="ranking-settings__stack setting-item" >
|
| 174 |
<div class="ranking-settings__copy-wide" >
|
|
|
|
| 244 |
*/
|
| 245 |
function _bindSwitchHandler(selector, settingKey, labelKey, iconOn = '✅') {
|
| 246 |
const switchEl = container.querySelector(selector);
|
| 247 |
+
const strictPreference = settingKey === 'enableKeyboardShortcuts' || settingKey === 'enableLocalPerformanceStats';
|
| 248 |
switchEl.setAttribute("aria-checked", String(switchEl.classList.contains("active")));
|
| 249 |
switchEl.onclick = () => {
|
| 250 |
+
if (strictPreference && (cacheViewDisposed || !container.isConnected)) return;
|
| 251 |
let currentSettings;
|
| 252 |
+
try { currentSettings = getSettings(strictPreference ? { strict: true } : undefined); }
|
| 253 |
catch { showToast(t('common.error'), 'error'); return; }
|
| 254 |
const previousValue = switchEl.classList.contains("active");
|
| 255 |
const newValue = !previousValue;
|
|
|
|
| 258 |
currentSettings[settingKey] = newValue;
|
| 259 |
const saved = saveSettings(currentSettings);
|
| 260 |
let confirmed = saved === true;
|
| 261 |
+
if (strictPreference) {
|
| 262 |
+
try { confirmed = confirmed && getSettings({ strict: true })[settingKey] === newValue; }
|
| 263 |
catch { confirmed = false; }
|
| 264 |
}
|
| 265 |
+
if (strictPreference && !confirmed) {
|
| 266 |
switchEl.classList.toggle('active', previousValue);
|
| 267 |
switchEl.setAttribute('aria-checked', String(previousValue));
|
| 268 |
showToast(t('common.error'), 'error');
|
|
|
|
| 322 |
|
| 323 |
// 🎛️ 开关控制:创作者背景图 / 榜单切换动画 / 动画音效
|
| 324 |
_bindSwitchHandler("#switch-keyboard-shortcuts", "enableKeyboardShortcuts", "settings.keyboard_combinations");
|
| 325 |
+
_bindSwitchHandler("#switch-local-performance", "enableLocalPerformanceStats", "settings.performance_stats");
|
| 326 |
_bindSwitchHandler("#switch-creator-banner", "showCreatorBanner", "settings.creator_banner", "✅");
|
| 327 |
_bindSwitchHandler("#switch-animations", "enableAnimations", "settings.animations", "✨");
|
| 328 |
_bindSwitchHandler("#switch-sound-effects", "enableSoundEffects", "settings.sound", "🔊");
|
|
|
|
| 397 |
if (cacheViewDisposed) return;
|
| 398 |
cacheViewDisposed = true;
|
| 399 |
container.querySelector("#switch-keyboard-shortcuts").onclick = null;
|
| 400 |
+
container.querySelector("#switch-local-performance").onclick = null;
|
| 401 |
+
refreshPerformance.onclick = null;
|
| 402 |
+
clearPerformance.onclick = null;
|
| 403 |
+
window.removeEventListener('comfy-settings-changed', onPerformancePreference);
|
| 404 |
+
window.removeEventListener('storage', onPerformancePreference);
|
| 405 |
languageGeneration++;
|
| 406 |
clearTimeout(languageTimer);
|
| 407 |
languageTimer = null;
|
|
|
|
| 410 |
statsController = null;
|
| 411 |
};
|
| 412 |
|
| 413 |
+
const performancePanel = container.querySelector('#local-performance-panel');
|
| 414 |
+
const performanceStats = container.querySelector('#local-performance-stats');
|
| 415 |
+
const performanceSwitch = container.querySelector('#switch-local-performance');
|
| 416 |
+
const refreshPerformance = container.querySelector('#btn-refresh-local-performance');
|
| 417 |
+
const clearPerformance = container.querySelector('#btn-clear-local-performance');
|
| 418 |
+
function renderPerformanceStats() {
|
| 419 |
+
const snapshot = getLocalPerformanceStats();
|
| 420 |
+
performancePanel.hidden = !snapshot.enabled;
|
| 421 |
+
performanceSwitch.classList.toggle('active', snapshot.enabled);
|
| 422 |
+
performanceSwitch.setAttribute('aria-checked', String(snapshot.enabled));
|
| 423 |
+
performanceStats.replaceChildren();
|
| 424 |
+
if (!snapshot.enabled) return;
|
| 425 |
+
if (!snapshot.rows.length) {
|
| 426 |
+
performanceStats.textContent = t('settings.performance_empty');
|
| 427 |
+
return;
|
| 428 |
+
}
|
| 429 |
+
for (const row of snapshot.rows) {
|
| 430 |
+
const line = document.createElement('div');
|
| 431 |
+
line.className = 'ranking-settings__stat';
|
| 432 |
+
line.textContent = `${t(LOCAL_PERFORMANCE.LABELS[row.category])} · ${t('settings.performance_row', { count: row.count, average: (row.totalMs / row.count).toFixed(1), hits: row.cacheHits })}`;
|
| 433 |
+
performanceStats.appendChild(line);
|
| 434 |
+
}
|
| 435 |
+
}
|
| 436 |
+
function onPerformancePreference() {
|
| 437 |
+
if (cacheViewDisposed || !container.isConnected) return;
|
| 438 |
+
renderPerformanceStats();
|
| 439 |
+
}
|
| 440 |
+
refreshPerformance.onclick = onPerformancePreference;
|
| 441 |
+
clearPerformance.onclick = () => {
|
| 442 |
+
if (cacheViewDisposed || !container.isConnected) return;
|
| 443 |
+
clearLocalPerformanceStats();
|
| 444 |
+
renderPerformanceStats();
|
| 445 |
+
};
|
| 446 |
+
window.addEventListener('comfy-settings-changed', onPerformancePreference);
|
| 447 |
+
window.addEventListener('storage', onPerformancePreference);
|
| 448 |
+
renderPerformanceStats();
|
| 449 |
+
|
| 450 |
async function loadCacheStats() {
|
| 451 |
const generation = ++statsGeneration;
|
| 452 |
statsController?.abort();
|
前端页面/components/用户体验_国际化.js
CHANGED
|
@@ -275,6 +275,12 @@ const translations = {
|
|
| 275 |
'settings.cache_expire': '数据刷新间隔(秒)',
|
| 276 |
'settings.cache_expire_desc': '设置本地缓存数据的刷新间隔,过期后切换页面时会自动后台更新数据。最小60秒,最大86400秒(1天)。',
|
| 277 |
'settings.cache_trigger_desc': '当缓存超过设定时间后,切换页面标签时会自动在后台拉取最新数据并静默更新,不影响使用流畅度。手动刷新不受此设置影响。',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 278 |
'settings.cache_stats': '缓存统计',
|
| 279 |
'settings.cache_stats_hint': '非卸载,不推荐清除磁盘缓存',
|
| 280 |
'settings.cache_disk_images': '图片缓存',
|
|
@@ -2070,6 +2076,12 @@ const translations = {
|
|
| 2070 |
'settings.cache_expire': 'Data Refresh Interval (seconds)',
|
| 2071 |
'settings.cache_expire_desc': 'Set the local cache refresh interval. Data will be silently updated in the background when switching tabs after expiry. Minimum 60 seconds, maximum 86400 seconds (1 day).',
|
| 2072 |
'settings.cache_trigger_desc': 'When the cache exceeds the set time, switching tabs will automatically fetch the latest data in the background and update silently without affecting usability. Manual refresh is not affected by this setting.',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2073 |
'settings.cache_stats': 'Cache Statistics',
|
| 2074 |
'settings.cache_stats_hint': 'Not uninstall, clearing disk cache is not recommended',
|
| 2075 |
'settings.cache_disk_images': 'Image Cache',
|
|
|
|
| 275 |
'settings.cache_expire': '数据刷新间隔(秒)',
|
| 276 |
'settings.cache_expire_desc': '设置本地缓存数据的刷新间隔,过期后切换页面时会自动后台更新数据。最小60秒,最大86400秒(1天)。',
|
| 277 |
'settings.cache_trigger_desc': '当缓存超过设定时间后,切换页面标签时会自动在后台拉取最新数据并静默更新,不影响使用流畅度。手动刷新不受此设置影响。',
|
| 278 |
+
'settings.performance_stats': "本机性能统计",
|
| 279 |
+
'settings.performance_stats_desc': "默认关闭。仅汇总当前页面的榜单请求耗时和缓存命中,不记录账号或内容,也不上传。关闭或刷新页面后清空。",
|
| 280 |
+
'settings.performance_empty': "暂无统计,浏览榜单后可在此刷新查看。",
|
| 281 |
+
'settings.performance_row': "已结束 {count} 次请求 · 平均 {average} 毫秒 · 缓存返回 {hits}/{count}",
|
| 282 |
+
'settings.performance_refresh': "刷新统计",
|
| 283 |
+
'settings.performance_clear': "清空统计",
|
| 284 |
'settings.cache_stats': '缓存统计',
|
| 285 |
'settings.cache_stats_hint': '非卸载,不推荐清除磁盘缓存',
|
| 286 |
'settings.cache_disk_images': '图片缓存',
|
|
|
|
| 2076 |
'settings.cache_expire': 'Data Refresh Interval (seconds)',
|
| 2077 |
'settings.cache_expire_desc': 'Set the local cache refresh interval. Data will be silently updated in the background when switching tabs after expiry. Minimum 60 seconds, maximum 86400 seconds (1 day).',
|
| 2078 |
'settings.cache_trigger_desc': 'When the cache exceeds the set time, switching tabs will automatically fetch the latest data in the background and update silently without affecting usability. Manual refresh is not affected by this setting.',
|
| 2079 |
+
'settings.performance_stats': "Local performance statistics",
|
| 2080 |
+
'settings.performance_stats_desc': "Off by default. Summarizes list request times and cache hits on this page only. No accounts or content are recorded or uploaded. Disabling or reloading clears the totals.",
|
| 2081 |
+
'settings.performance_empty': "No statistics yet. Browse the lists, then refresh here.",
|
| 2082 |
+
'settings.performance_row': "{count} finished requests · Average {average} ms · Cached {hits}/{count}",
|
| 2083 |
+
'settings.performance_refresh': "Refresh statistics",
|
| 2084 |
+
'settings.performance_clear': "Clear statistics",
|
| 2085 |
'settings.cache_stats': 'Cache Statistics',
|
| 2086 |
'settings.cache_stats_hint': 'Not uninstall, clearing disk cache is not recommended',
|
| 2087 |
'settings.cache_disk_images': 'Image Cache',
|
前端页面/core/全局配置.js
CHANGED
|
@@ -112,6 +112,15 @@ export const API = {
|
|
| 112 |
};
|
| 113 |
|
| 114 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
export const SKILL_INSTALL = Object.freeze({
|
| 116 |
PATH: '/community_hub/skill_install',
|
| 117 |
PREFLIGHT_TIMEOUT: 30000,
|
|
@@ -916,7 +925,8 @@ const SETTINGS_KEY = "ComfyCommunity_Settings";
|
|
| 916 |
|
| 917 |
// 🔧 默认设置值
|
| 918 |
const DEFAULT_SETTINGS = {
|
| 919 |
-
enableKeyboardShortcuts: false,
|
|
|
|
| 920 |
showCreatorBanner: true, // 创作者卡片显示背景图
|
| 921 |
enableAnimations: true, // ✨ 榜单切换动画
|
| 922 |
enableSoundEffects: true, // 🔊 动画音效
|
|
|
|
| 112 |
};
|
| 113 |
|
| 114 |
|
| 115 |
+
export const LOCAL_PERFORMANCE = Object.freeze({
|
| 116 |
+
MAX_ENDPOINT_CHARS: 4096,
|
| 117 |
+
MAX_COUNT: 1000000,
|
| 118 |
+
MAX_DURATION_MS: 600000,
|
| 119 |
+
ITEM_CATEGORIES: Object.freeze({ tool: 'tools', skill: 'skills', app: 'apps', recommend: 'recommends' }),
|
| 120 |
+
LIST_CATEGORIES: Object.freeze({ '/api/creators': 'creators', '/api/creators/search': 'creators', '/api/tasks': 'tasks', '/api/posts': 'posts', '/api/prompts': 'prompts' }),
|
| 121 |
+
LABELS: Object.freeze({ tools: 'nav.tools', skills: 'nav.skills', apps: 'nav.apps', recommends: 'nav.recommends', creators: 'nav.creators', tasks: 'nav.tasks', posts: 'nav.posts', prompts: 'nav.prompts' })
|
| 122 |
+
});
|
| 123 |
+
|
| 124 |
export const SKILL_INSTALL = Object.freeze({
|
| 125 |
PATH: '/community_hub/skill_install',
|
| 126 |
PREFLIGHT_TIMEOUT: 30000,
|
|
|
|
| 925 |
|
| 926 |
// 🔧 默认设置值
|
| 927 |
const DEFAULT_SETTINGS = {
|
| 928 |
+
enableKeyboardShortcuts: false,
|
| 929 |
+
enableLocalPerformanceStats: false,
|
| 930 |
showCreatorBanner: true, // 创作者卡片显示背景图
|
| 931 |
enableAnimations: true, // ✨ 榜单切换动画
|
| 932 |
enableSoundEffects: true, // 🔊 动画音效
|
前端页面/core/本机性能统计.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { LOCAL_PERFORMANCE, getSettings } from './全局配置.js';
|
| 2 |
+
|
| 3 |
+
let enabled = false;
|
| 4 |
+
let generation = 0;
|
| 5 |
+
const totals = new Map();
|
| 6 |
+
|
| 7 |
+
function syncPreference() {
|
| 8 |
+
let next = false;
|
| 9 |
+
try { next = getSettings({ strict: true }).enableLocalPerformanceStats === true; } catch {}
|
| 10 |
+
if (next !== enabled) {
|
| 11 |
+
enabled = next;
|
| 12 |
+
generation++;
|
| 13 |
+
totals.clear();
|
| 14 |
+
}
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
function now() {
|
| 18 |
+
try {
|
| 19 |
+
const value = performance.now();
|
| 20 |
+
return Number.isFinite(value) && value >= 0 ? value : null;
|
| 21 |
+
} catch { return null; }
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
function createSample(category) {
|
| 25 |
+
const started = now();
|
| 26 |
+
if (started === null) return null;
|
| 27 |
+
const ownGeneration = generation;
|
| 28 |
+
let finished = false;
|
| 29 |
+
let cached = false;
|
| 30 |
+
return Object.freeze({
|
| 31 |
+
cacheHit() { cached = true; },
|
| 32 |
+
discard() { finished = true; },
|
| 33 |
+
finish() {
|
| 34 |
+
if (finished) return;
|
| 35 |
+
finished = true;
|
| 36 |
+
syncPreference();
|
| 37 |
+
if (!enabled || ownGeneration !== generation) return;
|
| 38 |
+
const ended = now();
|
| 39 |
+
if (ended === null || ended < started) return;
|
| 40 |
+
const previous = totals.get(category) || { count: 0, cacheHits: 0, totalMs: 0 };
|
| 41 |
+
if (previous.count >= LOCAL_PERFORMANCE.MAX_COUNT) return;
|
| 42 |
+
totals.set(category, {
|
| 43 |
+
count: previous.count + 1,
|
| 44 |
+
cacheHits: previous.cacheHits + (cached ? 1 : 0),
|
| 45 |
+
totalMs: previous.totalMs + Math.min(ended - started, LOCAL_PERFORMANCE.MAX_DURATION_MS)
|
| 46 |
+
});
|
| 47 |
+
}
|
| 48 |
+
});
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
export function beginLocalPerformanceSample(endpoint, method = 'GET') {
|
| 52 |
+
syncPreference();
|
| 53 |
+
if (!enabled || typeof method !== 'string' || method.toUpperCase() !== 'GET'
|
| 54 |
+
|| typeof endpoint !== 'string' || endpoint.length > LOCAL_PERFORMANCE.MAX_ENDPOINT_CHARS || endpoint.includes('#')) return null;
|
| 55 |
+
const separator = endpoint.indexOf('?');
|
| 56 |
+
const path = separator < 0 ? endpoint : endpoint.slice(0, separator);
|
| 57 |
+
let category;
|
| 58 |
+
if (path === '/api/items') {
|
| 59 |
+
const types = new URLSearchParams(separator < 0 ? '' : endpoint.slice(separator + 1)).getAll('type');
|
| 60 |
+
if (types.length === 1 && Object.hasOwn(LOCAL_PERFORMANCE.ITEM_CATEGORIES, types[0])) category = LOCAL_PERFORMANCE.ITEM_CATEGORIES[types[0]];
|
| 61 |
+
} else if (Object.hasOwn(LOCAL_PERFORMANCE.LIST_CATEGORIES, path)) category = LOCAL_PERFORMANCE.LIST_CATEGORIES[path];
|
| 62 |
+
return category ? createSample(category) : null;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
export function getLocalPerformanceStats() {
|
| 66 |
+
syncPreference();
|
| 67 |
+
return {
|
| 68 |
+
enabled,
|
| 69 |
+
rows: Object.keys(LOCAL_PERFORMANCE.LABELS).filter(category => totals.has(category))
|
| 70 |
+
.map(category => ({ category, ...totals.get(category) }))
|
| 71 |
+
};
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
export function clearLocalPerformanceStats() {
|
| 75 |
+
generation++;
|
| 76 |
+
totals.clear();
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
syncPreference();
|
| 80 |
+
window.addEventListener('comfy-settings-changed', syncPreference);
|
| 81 |
+
window.addEventListener('storage', syncPreference);
|
前端页面/core/网络请求_基础设施.js
CHANGED
|
@@ -10,7 +10,8 @@
|
|
| 10 |
// ==========================================
|
| 11 |
|
| 12 |
import { removeCache, getCacheWithMeta } from "../components/性能优化工具.js";
|
| 13 |
-
import { API, CACHE } from "./全局配置.js";
|
|
|
|
| 14 |
import { eventBus, EVENTS, logoutAndClearUserData, isOnline, isCloudReachable, markCloudDown, markCloudUp } from "./状态管理.js";
|
| 15 |
import { CACHE_CONFIG, CACHE_INVALIDATION_MAP, invalidateRelatedCache, _getCacheTTL, getCache, setCache, isPrivateApiGet } from "./网络请求_缓存管理.js";
|
| 16 |
import { unproxyImages } from "./网络请求_图片代理.js";
|
|
@@ -279,7 +280,13 @@ const requestQueue = {
|
|
| 279 |
}
|
| 280 |
};
|
| 281 |
|
| 282 |
-
async function request(endpoint, options = {}) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 283 |
const privateRead = (options.method || "GET").toUpperCase() === "GET" && isPrivateApiGet(endpoint);
|
| 284 |
if (privateRead) {
|
| 285 |
removeCache(`api_${endpoint}`);
|
|
@@ -330,6 +337,7 @@ async function request(endpoint, options = {}) {
|
|
| 330 |
const cached = getCache(cacheKey);
|
| 331 |
if (cached) {
|
| 332 |
const proxyFn = await getProxyImages();
|
|
|
|
| 333 |
return proxyFn(cached);
|
| 334 |
}
|
| 335 |
}
|
|
@@ -361,6 +369,7 @@ async function request(endpoint, options = {}) {
|
|
| 361 |
if (fallbackMeta.found) {
|
| 362 |
console.log(`📴 ${isOnline() ? '☁️ 云端不可达' : '离线模式'}:返回${fallbackMeta.expired ? '过期' : ''}缓存`);
|
| 363 |
const proxyFn2 = await getProxyImages();
|
|
|
|
| 364 |
return proxyFn2(fallbackMeta.value);
|
| 365 |
}
|
| 366 |
// 🔧 修复:原为硬编码中文,而本错误会被 12+ 处调用方直接 showToast(err.message) 弹给用户,
|
|
@@ -372,6 +381,7 @@ async function request(endpoint, options = {}) {
|
|
| 372 |
// ⚡ P1性能优化:请求去重(相同GET请求只发一次)
|
| 373 |
if (trackPendingRequest) {
|
| 374 |
if (pendingRequests.has(cacheKey)) {
|
|
|
|
| 375 |
return pendingRequests.get(cacheKey);
|
| 376 |
}
|
| 377 |
}
|
|
@@ -472,6 +482,7 @@ async function request(endpoint, options = {}) {
|
|
| 472 |
&& response.headers?.get?.('ETag') === conditionalMeta.etag
|
| 473 |
&& currentMeta.found && currentMeta.etag === conditionalMeta.etag
|
| 474 |
&& currentMeta.value === conditionalMeta.value) {
|
|
|
|
| 475 |
validatedData = currentMeta.value;
|
| 476 |
} else {
|
| 477 |
// 缓存可能在等待期间被清理/写操作失效;同一预算内补取完整响应。
|
|
@@ -656,6 +667,7 @@ async function request(endpoint, options = {}) {
|
|
| 656 |
if (fallback.found) {
|
| 657 |
console.warn('📴 网络请求失败,回退到本地缓存');
|
| 658 |
const proxyFn4 = await getProxyImages();
|
|
|
|
| 659 |
return proxyFn4(fallback.value); // 返回过期缓存数据
|
| 660 |
}
|
| 661 |
}
|
|
|
|
| 10 |
// ==========================================
|
| 11 |
|
| 12 |
import { removeCache, getCacheWithMeta } from "../components/性能优化工具.js";
|
| 13 |
+
import { API, CACHE } from "./全局配置.js";
|
| 14 |
+
import { beginLocalPerformanceSample } from "./本机性能统计.js";
|
| 15 |
import { eventBus, EVENTS, logoutAndClearUserData, isOnline, isCloudReachable, markCloudDown, markCloudUp } from "./状态管理.js";
|
| 16 |
import { CACHE_CONFIG, CACHE_INVALIDATION_MAP, invalidateRelatedCache, _getCacheTTL, getCache, setCache, isPrivateApiGet } from "./网络请求_缓存管理.js";
|
| 17 |
import { unproxyImages } from "./网络请求_图片代理.js";
|
|
|
|
| 280 |
}
|
| 281 |
};
|
| 282 |
|
| 283 |
+
async function request(endpoint, options = {}) {
|
| 284 |
+
const sample = beginLocalPerformanceSample(endpoint, options.method || 'GET');
|
| 285 |
+
try { return await performRequest(endpoint, options, sample); }
|
| 286 |
+
finally { sample?.finish(); }
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
async function performRequest(endpoint, options = {}, sample = null) {
|
| 290 |
const privateRead = (options.method || "GET").toUpperCase() === "GET" && isPrivateApiGet(endpoint);
|
| 291 |
if (privateRead) {
|
| 292 |
removeCache(`api_${endpoint}`);
|
|
|
|
| 337 |
const cached = getCache(cacheKey);
|
| 338 |
if (cached) {
|
| 339 |
const proxyFn = await getProxyImages();
|
| 340 |
+
sample?.cacheHit();
|
| 341 |
return proxyFn(cached);
|
| 342 |
}
|
| 343 |
}
|
|
|
|
| 369 |
if (fallbackMeta.found) {
|
| 370 |
console.log(`📴 ${isOnline() ? '☁️ 云端不可达' : '离线模式'}:返回${fallbackMeta.expired ? '过期' : ''}缓存`);
|
| 371 |
const proxyFn2 = await getProxyImages();
|
| 372 |
+
sample?.cacheHit();
|
| 373 |
return proxyFn2(fallbackMeta.value);
|
| 374 |
}
|
| 375 |
// 🔧 修复:原为硬编码中文,而本错误会被 12+ 处调用方直接 showToast(err.message) 弹给用户,
|
|
|
|
| 381 |
// ⚡ P1性能优化:请求去重(相同GET请求只发一次)
|
| 382 |
if (trackPendingRequest) {
|
| 383 |
if (pendingRequests.has(cacheKey)) {
|
| 384 |
+
sample?.discard();
|
| 385 |
return pendingRequests.get(cacheKey);
|
| 386 |
}
|
| 387 |
}
|
|
|
|
| 482 |
&& response.headers?.get?.('ETag') === conditionalMeta.etag
|
| 483 |
&& currentMeta.found && currentMeta.etag === conditionalMeta.etag
|
| 484 |
&& currentMeta.value === conditionalMeta.value) {
|
| 485 |
+
sample?.cacheHit();
|
| 486 |
validatedData = currentMeta.value;
|
| 487 |
} else {
|
| 488 |
// 缓存可能在等待期间被清理/写操作失效;同一预算内补取完整响应。
|
|
|
|
| 667 |
if (fallback.found) {
|
| 668 |
console.warn('📴 网络请求失败,回退到本地缓存');
|
| 669 |
const proxyFn4 = await getProxyImages();
|
| 670 |
+
sample?.cacheHit();
|
| 671 |
return proxyFn4(fallback.value); // 返回过期缓存数据
|
| 672 |
}
|
| 673 |
}
|
前端页面/profile/个人中心视图.js
CHANGED
|
@@ -1,349 +1,370 @@
|
|
| 1 |
-
// 前端页面/profile/个人中
|
| 2 |
-
import { api, captureRequestSession, requestCancelManager } from "../core/网络请求API.js";
|
| 3 |
-
import { openChatModal } from "../social/私信聊天组件.js";
|
| 4 |
-
import { createSettingsForm } from "./个人设置表单组件.js";
|
| 5 |
-
import { renderProfileListContent } from "./个人列表组件.js";
|
| 6 |
-
import { showToast, showConfirm } from "../components/UI交互提示组件.js";
|
| 7 |
-
import { t } from "../components/用户体验增强.js";
|
| 8 |
-
// ==========================================
|
| 9 |
-
// 资金模块 (充值/提现)
|
| 10 |
-
// ==========================================
|
| 11 |
-
import { openRechargeModal } from "../market/资金与钱包_充值组件.js";
|
| 12 |
-
import { openWithdrawModal } from "../market/资金与钱包_提现组件.js";
|
| 13 |
-
import { buildProfileHTML } from "./个人中心_UI模板.js";
|
| 14 |
-
import { openTipModal } from "./个人中心_赞赏组件.js";
|
| 15 |
-
import { getBannerCacheKey, getCurrentAccount, IS_WEB_MODE, CACHE, UI, API, getCachedProfile, setCachedProfile, normalizeProfileImageUrl } from "../core/全局配置.js";
|
| 16 |
-
import { logoutAndClearUserData, eventBus, EVENTS } from "../core/状态管理.js";
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
import { createTaskViewScope, subscribeTaskSessionChanges } from '../task/任务视图生命周期.js';
|
| 20 |
-
import { endProfileListView } from './个人列表缓存.js';
|
| 21 |
-
import { normalizeProfileData, normalizeWalletData, mergeStoredProfile } from './个人资料工具.js';
|
| 22 |
-
|
| 23 |
-
let profileSequence = 0;
|
| 24 |
-
let profileNavigation = 0;
|
| 25 |
-
const safeGet = key => { try { return localStorage.getItem(key); } catch (_) { return null; } };
|
| 26 |
-
const safeSet = (key, value) => { try { localStorage.setItem(key, value); } catch (_) {} };
|
| 27 |
-
function sessionStorages() {
|
| 28 |
-
return ['localStorage', 'sessionStorage'].map(name => { try { return window[name]; } catch (_) { return null; } });
|
| 29 |
-
}
|
| 30 |
-
|
| 31 |
-
// ==========================================
|
| 32 |
-
// 🖼️ 个人资料卡背景图云端同步
|
| 33 |
-
// ==========================================
|
| 34 |
-
// 作用:当本地无缓存但云端有背景图时,下载并缓存到本地
|
| 35 |
-
async function syncBannerCache(account, bannerUrl, scope, apply) {
|
| 36 |
-
const source = normalizeProfileImageUrl(bannerUrl, true);
|
| 37 |
-
if (!/^https?:\/\//i.test(source) || !scope.isCurrent()
|
| 38 |
-
|| normalizeProfileImageUrl(safeGet(getBannerCacheKey(account)))) return;
|
| 39 |
-
const controller = new AbortController();
|
| 40 |
-
const release = scope.addCleanup(() => controller.abort());
|
| 41 |
-
const timer = setTimeout(() => controller.abort(), API.BACKGROUND_TIMEOUT);
|
| 42 |
-
try {
|
| 43 |
-
const fetchUrl = IS_WEB_MODE ? source : API.LOCAL_IMAGE_PROXY_PREFIX + encodeURIComponent(source);
|
| 44 |
-
const response = await fetch(fetchUrl, { signal: controller.signal });
|
| 45 |
-
if (!response.ok || !scope.isCurrent()) return;
|
| 46 |
-
const
|
| 47 |
-
if (!
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
if (
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
}
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
const
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
scope
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
if (
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
if (
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
container.querySelector("#btn-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
if (
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
}
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
if (!
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
}
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// 前端页面/profile/个人中���视图.js
|
| 2 |
+
import { api, captureRequestSession, requestCancelManager } from "../core/网络请求API.js";
|
| 3 |
+
import { openChatModal } from "../social/私信聊天组件.js";
|
| 4 |
+
import { createSettingsForm } from "./个人设置表单组件.js";
|
| 5 |
+
import { renderProfileListContent } from "./个人列表组件.js";
|
| 6 |
+
import { showToast, showConfirm } from "../components/UI交互提示组件.js";
|
| 7 |
+
import { t } from "../components/用户体验增强.js";
|
| 8 |
+
// ==========================================
|
| 9 |
+
// 资金模块 (充值/提现)
|
| 10 |
+
// ==========================================
|
| 11 |
+
import { openRechargeModal } from "../market/资金与钱包_充值组件.js";
|
| 12 |
+
import { openWithdrawModal } from "../market/资金与钱包_提现组件.js";
|
| 13 |
+
import { buildProfileHTML } from "./个人中心_UI模板.js";
|
| 14 |
+
import { openTipModal } from "./个人中心_赞赏组件.js";
|
| 15 |
+
import { getBannerCacheKey, getCurrentAccount, IS_WEB_MODE, CACHE, UI, API, getCachedProfile, setCachedProfile, normalizeProfileImageUrl } from "../core/全局配置.js";
|
| 16 |
+
import { logoutAndClearUserData, eventBus, EVENTS } from "../core/状态管理.js";
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
import { createTaskViewScope, subscribeTaskSessionChanges } from '../task/任务视图生命周期.js';
|
| 20 |
+
import { endProfileListView } from './个人列表缓存.js';
|
| 21 |
+
import { normalizeProfileData, normalizeWalletData, mergeStoredProfile } from './个人资料工具.js';
|
| 22 |
+
|
| 23 |
+
let profileSequence = 0;
|
| 24 |
+
let profileNavigation = 0;
|
| 25 |
+
const safeGet = key => { try { return localStorage.getItem(key); } catch (_) { return null; } };
|
| 26 |
+
const safeSet = (key, value) => { try { localStorage.setItem(key, value); } catch (_) {} };
|
| 27 |
+
function sessionStorages() {
|
| 28 |
+
return ['localStorage', 'sessionStorage'].map(name => { try { return window[name]; } catch (_) { return null; } });
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
// ==========================================
|
| 32 |
+
// 🖼️ 个人资料卡背景图云端同步
|
| 33 |
+
// ==========================================
|
| 34 |
+
// 作用:当本地无缓存但云端有背景图时,下载并缓存到本地
|
| 35 |
+
async function syncBannerCache(account, bannerUrl, scope, apply) {
|
| 36 |
+
const source = normalizeProfileImageUrl(bannerUrl, true);
|
| 37 |
+
if (!/^https?:\/\//i.test(source) || !scope.isCurrent()
|
| 38 |
+
|| normalizeProfileImageUrl(safeGet(getBannerCacheKey(account)))) return;
|
| 39 |
+
const controller = new AbortController();
|
| 40 |
+
const release = scope.addCleanup(() => controller.abort());
|
| 41 |
+
const timer = setTimeout(() => controller.abort(), API.BACKGROUND_TIMEOUT);
|
| 42 |
+
try {
|
| 43 |
+
const fetchUrl = IS_WEB_MODE ? source : API.LOCAL_IMAGE_PROXY_PREFIX + encodeURIComponent(source);
|
| 44 |
+
const response = await fetch(fetchUrl, { signal: controller.signal });
|
| 45 |
+
if (!response.ok || !scope.isCurrent()) return;
|
| 46 |
+
const type = new Blob([], { type: response.headers.get('Content-Type') || '' }).type;
|
| 47 |
+
if (!/^image\/(png|jpeg|webp|gif)$/.test(type)) return;
|
| 48 |
+
const length = response.headers.get('Content-Length');
|
| 49 |
+
if (length && /^\d+$/.test(length) && Number(length) > UI.PROFILE_BANNER_CACHE_BYTES) return;
|
| 50 |
+
if (!response.body?.getReader) return;
|
| 51 |
+
const reader = response.body.getReader();
|
| 52 |
+
const chunks = [];
|
| 53 |
+
let bytes = 0, complete = false;
|
| 54 |
+
try {
|
| 55 |
+
while (true) {
|
| 56 |
+
const { done, value } = await reader.read();
|
| 57 |
+
if (!scope.isCurrent()) return;
|
| 58 |
+
if (done) { complete = true; break; }
|
| 59 |
+
if (!(value instanceof Uint8Array) || value.byteLength > UI.PROFILE_BANNER_CACHE_BYTES - bytes) return;
|
| 60 |
+
bytes += value.byteLength;
|
| 61 |
+
chunks.push(value);
|
| 62 |
+
}
|
| 63 |
+
} finally {
|
| 64 |
+
if (!complete) { try { await reader.cancel(); } catch (_) {} }
|
| 65 |
+
reader.releaseLock();
|
| 66 |
+
}
|
| 67 |
+
const blob = new Blob(chunks, { type });
|
| 68 |
+
if (!scope.isCurrent() || !/^image\/(png|jpeg|webp|gif)$/.test(blob.type)) return;
|
| 69 |
+
if (blob.size <= UI.PROFILE_BANNER_CACHE_BYTES) {
|
| 70 |
+
const data = await new Promise(resolve => {
|
| 71 |
+
const reader = new FileReader();
|
| 72 |
+
const off = scope.addCleanup(() => { reader.abort(); resolve(null); });
|
| 73 |
+
reader.onload = () => { off(); resolve(reader.result); };
|
| 74 |
+
reader.onerror = reader.onabort = () => { off(); resolve(null); };
|
| 75 |
+
reader.readAsDataURL(blob);
|
| 76 |
+
});
|
| 77 |
+
if (!scope.isCurrent() || typeof data !== 'string') return;
|
| 78 |
+
safeSet(getBannerCacheKey(account), data);
|
| 79 |
+
}
|
| 80 |
+
if (!scope.isCurrent()) return;
|
| 81 |
+
const blobUrl = URL.createObjectURL(blob);
|
| 82 |
+
scope.addCleanup(() => URL.revokeObjectURL(blobUrl));
|
| 83 |
+
apply(blobUrl);
|
| 84 |
+
} catch (_) { /* 背景同步失败保留当前背景,不影响资料。 */ }
|
| 85 |
+
finally { clearTimeout(timer); controller.abort(); release(); }
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
async function _fetchProfileAndWallet(account, { own = false, options = {} } = {}) {
|
| 89 |
+
const profileRequest = api.getUserProfile(account, options).then(response => {
|
| 90 |
+
if (response?.status !== 'success') throw new Error('Invalid profile response');
|
| 91 |
+
return normalizeProfileData(response.data, account, own);
|
| 92 |
+
});
|
| 93 |
+
if (!own) return profileRequest;
|
| 94 |
+
const [profile, wallet] = await Promise.allSettled([profileRequest, api.getWallet(account, options)]);
|
| 95 |
+
return { ...(profile.status === 'fulfilled' ? profile.value : {}),
|
| 96 |
+
...(wallet.status === 'fulfilled' ? normalizeWalletData(wallet.value) : {}) };
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
/** 统一设置 Tab 按钮点击事件 */
|
| 100 |
+
function _setupTabButton(container, selector, tabId, onSwitch) {
|
| 101 |
+
const btn = container.querySelector(selector);
|
| 102 |
+
if (btn) btn.onclick = () => onSwitch(tabId);
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
export function showUserProfile(initialUserData, currentUser = null, isMe = true) {
|
| 106 |
+
const container = document.createElement("div");
|
| 107 |
+
container.className = "profile-view";
|
| 108 |
+
|
| 109 |
+
let isSettingsView = false;
|
| 110 |
+
let activeTab = safeGet(`Profile_ActiveTab_${isMe}`) || "published";
|
| 111 |
+
let userData = normalizeProfileData(initialUserData, initialUserData.account, isMe);
|
| 112 |
+
let constructing = true;
|
| 113 |
+
let renderGeneration = 0;
|
| 114 |
+
let logoutPending = false;
|
| 115 |
+
const componentId = `profile-detail-${++profileSequence}`;
|
| 116 |
+
const releaseChild = () => {
|
| 117 |
+
const list = container.querySelector('#profile-list-container');
|
| 118 |
+
if (list) endProfileListView(list);
|
| 119 |
+
container.querySelector('.ranking-settings-form')?._cleanup?.();
|
| 120 |
+
};
|
| 121 |
+
const scope = createTaskViewScope({
|
| 122 |
+
sessionMatches: captureRequestSession(), connected: () => constructing || container.isConnected,
|
| 123 |
+
subscribe: notify => subscribeTaskSessionChanges(notify, { target: window, eventBus, events: EVENTS,
|
| 124 |
+
userKey: CACHE.LEGACY_KEYS.USER, tokenKey: CACHE.LEGACY_KEYS.TOKEN }),
|
| 125 |
+
cancelReads: () => requestCancelManager.cancelAll(componentId),
|
| 126 |
+
invalidate: () => {
|
| 127 |
+
releaseChild(); userData = { account: initialUserData.account };
|
| 128 |
+
container.replaceChildren();
|
| 129 |
+
const back = document.createElement('button'); back.type = 'button';
|
| 130 |
+
back.className = 'about-back'; back.textContent = t('common.back');
|
| 131 |
+
back.onclick = () => window.dispatchEvent(new CustomEvent('comfy-route-back'));
|
| 132 |
+
const status = document.createElement('div'); status.setAttribute('role', 'status');
|
| 133 |
+
status.textContent = t('auth.session_changed'); container.append(back, status);
|
| 134 |
+
}
|
| 135 |
+
});
|
| 136 |
+
container._profileScope = scope;
|
| 137 |
+
container._profileReadOptions = { componentId, timeout: API.BACKGROUND_TIMEOUT, retries: API.BACKGROUND_RETRIES };
|
| 138 |
+
container._cleanup = () => { releaseChild(); scope.dispose(); container.replaceChildren(); };
|
| 139 |
+
// 独立挂载者也能释放;正常侧栏路由在移除前调用 _cleanup。
|
| 140 |
+
const observer = new MutationObserver(() => { if (!container.isConnected) container._cleanup(); });
|
| 141 |
+
observer.observe(document.body, { childList: true, subtree: true });
|
| 142 |
+
scope.addCleanup(() => { observer.disconnect(); releaseChild(); });
|
| 143 |
+
|
| 144 |
+
function render() {
|
| 145 |
+
if (!scope.isCurrent()) return;
|
| 146 |
+
releaseChild();
|
| 147 |
+
const generation = ++renderGeneration;
|
| 148 |
+
const current = () => scope.isCurrent() && renderGeneration === generation;
|
| 149 |
+
container.innerHTML = "";
|
| 150 |
+
|
| 151 |
+
let isFollowing = false;
|
| 152 |
+
if (currentUser && userData.followers) { isFollowing = userData.followers.includes(currentUser.account); }
|
| 153 |
+
|
| 154 |
+
const privacy = userData.privacy || {};
|
| 155 |
+
const followingCount = (!isMe && privacy.follows) ? "***" : (userData.following ? userData.following.length : 0);
|
| 156 |
+
|
| 157 |
+
const tabs = [{ id: "published", label: isMe ? t('profile.my_published') : t('profile.their_published') }];
|
| 158 |
+
if (isMe) tabs.push({ id: "acquired", label: t('profile.my_purchased') });
|
| 159 |
+
if (isMe) tabs.push({ id: "my_tasks", label: t('profile.my_tasks') });
|
| 160 |
+
if (isMe) tabs.push({ id: "transactions", label: t('profile.transactions') });
|
| 161 |
+
if (isMe) tabs.push({ id: "my_posts", label: t('profile.my_posts') });
|
| 162 |
+
if (isMe) tabs.push({ id: "my_prompts", label: t('prompt.my_prompts') });
|
| 163 |
+
if (isMe) tabs.push({ id: "collected", label: t('profile.my_collected') });
|
| 164 |
+
if (isMe || !privacy.likes) tabs.push({ id: "liked", label: t('profile.recent_likes') });
|
| 165 |
+
if (isMe || !privacy.follows) tabs.push({ id: "following", label: t('profile.following') });
|
| 166 |
+
if (isMe || !privacy.followers) tabs.push({ id: "followers", label: t('profile.followers') });
|
| 167 |
+
|
| 168 |
+
if (!tabs.find(t => t.id === activeTab)) activeTab = "published";
|
| 169 |
+
|
| 170 |
+
// 调用解耦的模板引擎生成 HTML
|
| 171 |
+
container.innerHTML = buildProfileHTML(userData, isMe, isSettingsView, isFollowing, followingCount, activeTab, tabs);
|
| 172 |
+
|
| 173 |
+
if (isSettingsView && isMe) {
|
| 174 |
+
const settingsContainer = createSettingsForm(
|
| 175 |
+
userData,
|
| 176 |
+
() => { if (!current()) return; isSettingsView = false; render(); },
|
| 177 |
+
(newUserData) => {
|
| 178 |
+
if (!current()) return;
|
| 179 |
+
userData = normalizeProfileData(newUserData, userData.account, true);
|
| 180 |
+
if (container.updateData) container.updateData(userData);
|
| 181 |
+
isSettingsView = false;
|
| 182 |
+
render();
|
| 183 |
+
},
|
| 184 |
+
{ isCurrent: current }
|
| 185 |
+
);
|
| 186 |
+
container.appendChild(settingsContainer);
|
| 187 |
+
container.querySelector("#btn-back-profile").onclick = () => { isSettingsView = false; render(); };
|
| 188 |
+
} else {
|
| 189 |
+
container.querySelector("#btn-back-profile").onclick = () => window.dispatchEvent(new CustomEvent("comfy-route-back"));
|
| 190 |
+
|
| 191 |
+
if (isMe) {
|
| 192 |
+
container.querySelector("#btn-wallet").onclick = () => {
|
| 193 |
+
if (!current()) return;
|
| 194 |
+
openRechargeModal(currentUser, (newBalance) => { if (!current()) return; userData.balance = newBalance; render(); });
|
| 195 |
+
};
|
| 196 |
+
container.querySelector("#btn-withdraw").onclick = () => {
|
| 197 |
+
if (!current()) return;
|
| 198 |
+
openWithdrawModal(currentUser, async () => {
|
| 199 |
+
if (!current()) return;
|
| 200 |
+
// 提现成功后重新拉取用户数据刷新余额
|
| 201 |
+
try {
|
| 202 |
+
const walletResponse = await api.getWallet(currentUser.account, { componentId });
|
| 203 |
+
if (!current()) return;
|
| 204 |
+
const walletRes = normalizeWalletData(walletResponse);
|
| 205 |
+
userData.balance = walletRes.balance ?? userData.balance;
|
| 206 |
+
userData.earn_balance = walletRes.earn_balance ?? userData.earn_balance;
|
| 207 |
+
userData.tip_balance = walletRes.tip_balance ?? userData.tip_balance;
|
| 208 |
+
userData.total_withdrawn = walletRes.total_withdrawn ?? userData.total_withdrawn;
|
| 209 |
+
} catch (err) {
|
| 210 |
+
if (!current()) return;
|
| 211 |
+
console.warn("提现后刷新钱包数据失败");
|
| 212 |
+
}
|
| 213 |
+
render();
|
| 214 |
+
});
|
| 215 |
+
};
|
| 216 |
+
|
| 217 |
+
container.querySelector("#btn-open-settings").onclick = () => { isSettingsView = true; render(); };
|
| 218 |
+
container.querySelector("#btn-logout").onclick = async () => {
|
| 219 |
+
if (!current() || logoutPending) return;
|
| 220 |
+
const currentAccount = getCurrentAccount();
|
| 221 |
+
logoutPending = true;
|
| 222 |
+
try {
|
| 223 |
+
if (!await showConfirm(t('confirm.logout')) || !scope.isCurrent()
|
| 224 |
+
|| getCurrentAccount() !== currentAccount) return;
|
| 225 |
+
logoutAndClearUserData(currentAccount);
|
| 226 |
+
window.dispatchEvent(new CustomEvent("comfy-route-back"));
|
| 227 |
+
window.dispatchEvent(new CustomEvent("comfy-user-logout"));
|
| 228 |
+
showToast(t('feedback.logged_out'), "info");
|
| 229 |
+
} finally {
|
| 230 |
+
logoutPending = false;
|
| 231 |
+
}
|
| 232 |
+
};
|
| 233 |
+
} else {
|
| 234 |
+
const btnTip = container.querySelector("#btn-tip-user");
|
| 235 |
+
if (btnTip) {
|
| 236 |
+
btnTip.onclick = async () => {
|
| 237 |
+
if (!current()) return;
|
| 238 |
+
if (!currentUser) return showToast(t('feedback.login_required_tip'), "warning");
|
| 239 |
+
await openTipModal(currentUser, userData, (newBalance) => {
|
| 240 |
+
if (!current()) return;
|
| 241 |
+
currentUser.balance = newBalance;
|
| 242 |
+
openOtherUserProfileModal(userData.account, currentUser);
|
| 243 |
+
}, null, { isCurrent: current });
|
| 244 |
+
};
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
const btnFollow = container.querySelector("#btn-follow-user");
|
| 248 |
+
if (btnFollow) {
|
| 249 |
+
btnFollow.onclick = async () => {
|
| 250 |
+
if (!current()) return;
|
| 251 |
+
if (!currentUser) return showToast(t('feedback.login_required'), "warning");
|
| 252 |
+
btnFollow.innerHTML = `⏳ ${t('common.processing')}...`; btnFollow.disabled = true;
|
| 253 |
+
try {
|
| 254 |
+
const newStatus = !isFollowing;
|
| 255 |
+
const response = await api.toggleFollow(currentUser.account, userData.account, newStatus);
|
| 256 |
+
if (!current()) return;
|
| 257 |
+
if (response?.status !== 'success') throw new Error('Invalid follow response');
|
| 258 |
+
isFollowing = newStatus;
|
| 259 |
+
|
| 260 |
+
if (!userData.followers) userData.followers = [];
|
| 261 |
+
if (isFollowing) { if (!userData.followers.includes(currentUser.account)) userData.followers.push(currentUser.account); }
|
| 262 |
+
else { userData.followers = userData.followers.filter(a => a !== currentUser.account); }
|
| 263 |
+
render();
|
| 264 |
+
} catch (_) { if (!current()) return; showToast(t('feedback.operation_failed'), "error"); btnFollow.innerHTML = isFollowing ? `✔️ ${t('social.followed')}` : `➕ ${t('social.follow')}`; }
|
| 265 |
+
finally { if (current()) btnFollow.disabled = false; }
|
| 266 |
+
};
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
const btnSendMsg = container.querySelector("#btn-send-msg");
|
| 270 |
+
if (btnSendMsg) {
|
| 271 |
+
btnSendMsg.onclick = () => {
|
| 272 |
+
if (!current()) return;
|
| 273 |
+
if (!currentUser) return showToast(t('feedback.login_required'), "warning");
|
| 274 |
+
openChatModal(currentUser, userData.account);
|
| 275 |
+
};
|
| 276 |
+
}
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
container.querySelectorAll(".profile-tab-btn").forEach(btn => {
|
| 280 |
+
btn.onclick = (e) => {
|
| 281 |
+
if (!current()) return;
|
| 282 |
+
activeTab = e.currentTarget.getAttribute("data-id");
|
| 283 |
+
safeSet(`Profile_ActiveTab_${isMe}`, activeTab);
|
| 284 |
+
render();
|
| 285 |
+
};
|
| 286 |
+
});
|
| 287 |
+
|
| 288 |
+
// 粉丝/关注数点击跳转对应列表
|
| 289 |
+
const _switchTab = (tabId) => { if (!current()) return; activeTab = tabId; safeSet(`Profile_ActiveTab_${isMe}`, tabId); render(); };
|
| 290 |
+
_setupTabButton(container, "#btn-followers", "followers", _switchTab);
|
| 291 |
+
_setupTabButton(container, "#btn-following", "following", _switchTab);
|
| 292 |
+
|
| 293 |
+
const listDOM = container.querySelector("#profile-list-container");
|
| 294 |
+
listDOM._profileSessionCurrent = current;
|
| 295 |
+
listDOM._profileAddCleanup = cleanup => scope.addCleanup(cleanup);
|
| 296 |
+
renderProfileListContent(activeTab, listDOM, userData, currentUser, openOtherUserProfileModal);
|
| 297 |
+
}
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
container.updateData = function(newUserData) {
|
| 301 |
+
if (!scope.isCurrent() || !newUserData || typeof newUserData !== 'object' || Array.isArray(newUserData)) return;
|
| 302 |
+
const safeNewData = normalizeProfileData({ ...userData, ...newUserData }, userData.account, isMe);
|
| 303 |
+
Object.keys(safeNewData).forEach(key => safeNewData[key] == null && delete safeNewData[key]);
|
| 304 |
+
const mergedData = { ...userData, ...safeNewData };
|
| 305 |
+
if (JSON.stringify(userData) !== JSON.stringify(mergedData)) {
|
| 306 |
+
userData = mergedData;
|
| 307 |
+
if (!isSettingsView) { render(); }
|
| 308 |
+
}
|
| 309 |
+
};
|
| 310 |
+
|
| 311 |
+
render();
|
| 312 |
+
constructing = false;
|
| 313 |
+
return container;
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
export function openUserProfileModal(userData) {
|
| 317 |
+
++profileNavigation;
|
| 318 |
+
const view = showUserProfile(userData, userData, true);
|
| 319 |
+
window.dispatchEvent(new CustomEvent("comfy-route-view", { detail: { view } }));
|
| 320 |
+
_fetchProfileAndWallet(userData.account, { own: true, options: view._profileReadOptions }).then(freshData => {
|
| 321 |
+
if (!view._profileScope.isCurrent()) return;
|
| 322 |
+
mergeStoredProfile(userData.account, freshData, sessionStorages(), CACHE.LEGACY_KEYS.USER);
|
| 323 |
+
view.updateData(freshData);
|
| 324 |
+
if (freshData.bannerUrl) syncBannerCache(userData.account, freshData.bannerUrl, view._profileScope,
|
| 325 |
+
blobUrl => view.updateData({ bannerUrl: blobUrl }));
|
| 326 |
+
}).catch(() => { if (view._profileScope.isCurrent()) console.warn('后台更新本人资料失败'); });
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
export async function openOtherUserProfileModal(targetAccount, currentUser) {
|
| 330 |
+
if (currentUser && currentUser.account === targetAccount) return openUserProfileModal(currentUser);
|
| 331 |
+
const generation = ++profileNavigation;
|
| 332 |
+
const sessionMatches = captureRequestSession();
|
| 333 |
+
const componentId = `profile-open-${++profileSequence}`;
|
| 334 |
+
let activeContainer = null;
|
| 335 |
+
const cached = getCachedProfile(targetAccount);
|
| 336 |
+
if (cached) {
|
| 337 |
+
try {
|
| 338 |
+
activeContainer = showUserProfile({ ...cached, account: targetAccount }, currentUser, false);
|
| 339 |
+
window.dispatchEvent(new CustomEvent('comfy-route-view', { detail: { view: activeContainer } }));
|
| 340 |
+
} catch (_) {}
|
| 341 |
+
}
|
| 342 |
+
let obsolete = false;
|
| 343 |
+
const cancel = () => { obsolete = true; requestCancelManager.cancelAll(componentId); };
|
| 344 |
+
const onRouteView = event => { if (event.detail?.view !== activeContainer) cancel(); };
|
| 345 |
+
window.addEventListener('comfy-route-back', cancel);
|
| 346 |
+
window.addEventListener('comfy-route-view', onRouteView);
|
| 347 |
+
const unsubscribe = subscribeTaskSessionChanges(force => { if (force || !sessionMatches()) cancel(); }, {
|
| 348 |
+
target: window, eventBus, events: EVENTS, userKey: CACHE.LEGACY_KEYS.USER, tokenKey: CACHE.LEGACY_KEYS.TOKEN
|
| 349 |
+
});
|
| 350 |
+
const current = () => !obsolete && generation === profileNavigation && sessionMatches()
|
| 351 |
+
&& (!activeContainer || activeContainer._profileScope.isCurrent());
|
| 352 |
+
try {
|
| 353 |
+
const freshData = await _fetchProfileAndWallet(targetAccount, {
|
| 354 |
+
options: activeContainer?._profileReadOptions || { componentId }
|
| 355 |
+
});
|
| 356 |
+
if (!current()) return;
|
| 357 |
+
setCachedProfile(targetAccount, freshData);
|
| 358 |
+
if (activeContainer) activeContainer.updateData(freshData);
|
| 359 |
+
else {
|
| 360 |
+
activeContainer = showUserProfile(freshData, currentUser, false);
|
| 361 |
+
window.dispatchEvent(new CustomEvent('comfy-route-view', { detail: { view: activeContainer } }));
|
| 362 |
+
}
|
| 363 |
+
} catch (_) {
|
| 364 |
+
if (current() && !activeContainer) showToast(t('feedback.user_fetch_failed'), 'error');
|
| 365 |
+
} finally {
|
| 366 |
+
unsubscribe();
|
| 367 |
+
window.removeEventListener('comfy-route-back', cancel);
|
| 368 |
+
window.removeEventListener('comfy-route-view', onRouteView);
|
| 369 |
+
}
|
| 370 |
+
}
|
前端页面/prompt/提示词详情组件.js
CHANGED
|
@@ -495,12 +495,32 @@ async function showPurchaseConfirmDialog(contentArea, prompt, currentUser) {
|
|
| 495 |
const context = viewContexts.get(contentArea);
|
| 496 |
if (!context?.scope.isCurrent() || context.purchasePending) return;
|
| 497 |
context.purchasePending = true;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 498 |
let balance;
|
| 499 |
let expectedPrice;
|
| 500 |
let purchaseTitle;
|
| 501 |
try {
|
| 502 |
-
const detailRes = await api.getPromptDetail(prompt.id,
|
| 503 |
-
if (!
|
| 504 |
const latest = detailRes?.data;
|
| 505 |
if (detailRes?.status !== "success" || !latest || Array.isArray(latest) || latest.id !== prompt.id ||
|
| 506 |
typeof latest.owned !== "boolean" || !Number.isSafeInteger(latest.price) || latest.price < 0) throw new Error();
|
|
@@ -511,26 +531,29 @@ async function showPurchaseConfirmDialog(contentArea, prompt, currentUser) {
|
|
| 511 |
prompt.price = latest.price;
|
| 512 |
renderPromptTextArea(contentArea, prompt, currentUser, true);
|
| 513 |
context.purchasePending = false;
|
|
|
|
|
|
|
|
|
|
| 514 |
return;
|
| 515 |
}
|
| 516 |
if (latest.price_confirmation_supported !== true) {
|
| 517 |
context.purchasePending = false;
|
|
|
|
| 518 |
showToast(t('acquire.purchase_verification_unavailable'), "warning");
|
| 519 |
return;
|
| 520 |
}
|
| 521 |
expectedPrice = latest.price;
|
| 522 |
purchaseTitle = typeof latest.title === "string" ? latest.title : prompt.title;
|
| 523 |
-
const walletRes = await api.getWallet(currentUser.account,
|
| 524 |
-
if (!
|
| 525 |
if (walletRes?.status !== "success" || !Number.isSafeInteger(walletRes.balance) || walletRes.balance < 0) throw new Error();
|
| 526 |
balance = walletRes.balance;
|
| 527 |
} catch (_) {
|
| 528 |
-
if (
|
| 529 |
return;
|
| 530 |
}
|
| 531 |
|
| 532 |
-
|
| 533 |
-
content.className = "prompt-detail-dialog";
|
| 534 |
content.innerHTML = `
|
| 535 |
<div class="prompt-detail-dialog__center">
|
| 536 |
<div class="prompt-detail-dialog__icon" aria-hidden="true">💎</div>
|
|
@@ -546,10 +569,6 @@ async function showPurchaseConfirmDialog(contentArea, prompt, currentUser) {
|
|
| 546 |
</div>
|
| 547 |
`;
|
| 548 |
|
| 549 |
-
const modal = openOwnedModal(contentArea, `💎 ${t('prompt.purchase_confirm_title')}`, content, { width: "320px" });
|
| 550 |
-
let submitted = false;
|
| 551 |
-
const release = content._cleanup;
|
| 552 |
-
content._cleanup = () => { release?.(); if (!submitted) context.purchasePending = false; };
|
| 553 |
content.querySelector("#purchase-cancel").onclick = () => globalModal.closeModal(modal);
|
| 554 |
content.querySelector("#purchase-confirm").onclick = async () => {
|
| 555 |
if (submitted || !isCurrent(contentArea) || !modal?.isConnected) return;
|
|
|
|
| 495 |
const context = viewContexts.get(contentArea);
|
| 496 |
if (!context?.scope.isCurrent() || context.purchasePending) return;
|
| 497 |
context.purchasePending = true;
|
| 498 |
+
const content = document.createElement("div");
|
| 499 |
+
content.className = "prompt-detail-dialog";
|
| 500 |
+
content.setAttribute('aria-busy', 'true');
|
| 501 |
+
const loading = document.createElement('div');
|
| 502 |
+
loading.setAttribute('role', 'status'); loading.setAttribute('aria-live', 'polite');
|
| 503 |
+
loading.textContent = t('common.loading'); content.append(loading);
|
| 504 |
+
const modal = openOwnedModal(contentArea, `💎 ${t('prompt.purchase_confirm_title')}`, content, { width: "320px" });
|
| 505 |
+
if (!modal) { context.purchasePending = false; return; }
|
| 506 |
+
let submitted = false, closed = false;
|
| 507 |
+
context.purchaseSequence = (context.purchaseSequence || 0) + 1;
|
| 508 |
+
const componentId = `${context.readOptions.componentId}-purchase-${context.purchaseSequence}`;
|
| 509 |
+
const readOptions = { ...context.readOptions, componentId };
|
| 510 |
+
const release = content._cleanup;
|
| 511 |
+
content._cleanup = () => {
|
| 512 |
+
if (closed) return;
|
| 513 |
+
closed = true;
|
| 514 |
+
try { requestCancelManager.cancelAll(componentId); }
|
| 515 |
+
finally { release?.(); if (!submitted) context.purchasePending = false; }
|
| 516 |
+
};
|
| 517 |
+
const current = () => !closed && isCurrent(contentArea) && modal.isConnected;
|
| 518 |
let balance;
|
| 519 |
let expectedPrice;
|
| 520 |
let purchaseTitle;
|
| 521 |
try {
|
| 522 |
+
const detailRes = await api.getPromptDetail(prompt.id, readOptions);
|
| 523 |
+
if (!current()) return;
|
| 524 |
const latest = detailRes?.data;
|
| 525 |
if (detailRes?.status !== "success" || !latest || Array.isArray(latest) || latest.id !== prompt.id ||
|
| 526 |
typeof latest.owned !== "boolean" || !Number.isSafeInteger(latest.price) || latest.price < 0) throw new Error();
|
|
|
|
| 531 |
prompt.price = latest.price;
|
| 532 |
renderPromptTextArea(contentArea, prompt, currentUser, true);
|
| 533 |
context.purchasePending = false;
|
| 534 |
+
const restore = modal.contains(document.activeElement);
|
| 535 |
+
globalModal.closeModal(modal);
|
| 536 |
+
if (restore && isCurrent(contentArea) && document.activeElement === document.body) contentArea.querySelector('#btn-copy-prompt')?.focus({ preventScroll: true });
|
| 537 |
return;
|
| 538 |
}
|
| 539 |
if (latest.price_confirmation_supported !== true) {
|
| 540 |
context.purchasePending = false;
|
| 541 |
+
globalModal.closeModal(modal);
|
| 542 |
showToast(t('acquire.purchase_verification_unavailable'), "warning");
|
| 543 |
return;
|
| 544 |
}
|
| 545 |
expectedPrice = latest.price;
|
| 546 |
purchaseTitle = typeof latest.title === "string" ? latest.title : prompt.title;
|
| 547 |
+
const walletRes = await api.getWallet(currentUser.account, readOptions);
|
| 548 |
+
if (!current()) return;
|
| 549 |
if (walletRes?.status !== "success" || !Number.isSafeInteger(walletRes.balance) || walletRes.balance < 0) throw new Error();
|
| 550 |
balance = walletRes.balance;
|
| 551 |
} catch (_) {
|
| 552 |
+
if (current()) { globalModal.closeModal(modal); showToast(t('common.error'), "error"); }
|
| 553 |
return;
|
| 554 |
}
|
| 555 |
|
| 556 |
+
content.setAttribute('aria-busy', 'false');
|
|
|
|
| 557 |
content.innerHTML = `
|
| 558 |
<div class="prompt-detail-dialog__center">
|
| 559 |
<div class="prompt-detail-dialog__icon" aria-hidden="true">💎</div>
|
|
|
|
| 569 |
</div>
|
| 570 |
`;
|
| 571 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 572 |
content.querySelector("#purchase-cancel").onclick = () => globalModal.closeModal(modal);
|
| 573 |
content.querySelector("#purchase-confirm").onclick = async () => {
|
| 574 |
if (submitted || !isCurrent(contentArea) || !modal?.isConnected) return;
|