ZHIWEI666 commited on
Commit
a0bd9c4
·
verified ·
1 Parent(s): f3f4444

Upload 102 files

Browse files
前端页面/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 (settingKey === 'enableKeyboardShortcuts' && (cacheViewDisposed || !container.isConnected)) return;
232
  let currentSettings;
233
- try { currentSettings = getSettings(settingKey === 'enableKeyboardShortcuts' ? { strict: true } : undefined); }
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 (settingKey === 'enableKeyboardShortcuts') {
243
- try { confirmed = confirmed && getSettings({ strict: true }).enableKeyboardShortcuts === newValue; }
244
  catch { confirmed = false; }
245
  }
246
- if (settingKey === 'enableKeyboardShortcuts' && !confirmed) {
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/个人中心视图.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 blob = await response.blob();
47
- if (!scope.isCurrent() || !/^image\/(png|jpeg|webp|gif)$/.test(blob.type)) return;
48
- if (blob.size <= UI.PROFILE_BANNER_CACHE_BYTES) {
49
- const data = await new Promise(resolve => {
50
- const reader = new FileReader();
51
- const off = scope.addCleanup(() => { reader.abort(); resolve(null); });
52
- reader.onload = () => { off(); resolve(reader.result); };
53
- reader.onerror = reader.onabort = () => { off(); resolve(null); };
54
- reader.readAsDataURL(blob);
55
- });
56
- if (!scope.isCurrent() || typeof data !== 'string') return;
57
- safeSet(getBannerCacheKey(account), data);
58
- }
59
- if (!scope.isCurrent()) return;
60
- const blobUrl = URL.createObjectURL(blob);
61
- scope.addCleanup(() => URL.revokeObjectURL(blobUrl));
62
- apply(blobUrl);
63
- } catch (_) { /* 背景同步失败保留当前背景,不影响资料。 */ }
64
- finally { clearTimeout(timer); release(); }
65
- }
66
-
67
- async function _fetchProfileAndWallet(account, { own = false, options = {} } = {}) {
68
- const profileRequest = api.getUserProfile(account, options).then(response => {
69
- if (response?.status !== 'success') throw new Error('Invalid profile response');
70
- return normalizeProfileData(response.data, account, own);
71
- });
72
- if (!own) return profileRequest;
73
- const [profile, wallet] = await Promise.allSettled([profileRequest, api.getWallet(account, options)]);
74
- return { ...(profile.status === 'fulfilled' ? profile.value : {}),
75
- ...(wallet.status === 'fulfilled' ? normalizeWalletData(wallet.value) : {}) };
76
- }
77
-
78
- /** 统一设置 Tab 按钮点击事件 */
79
- function _setupTabButton(container, selector, tabId, onSwitch) {
80
- const btn = container.querySelector(selector);
81
- if (btn) btn.onclick = () => onSwitch(tabId);
82
- }
83
-
84
- export function showUserProfile(initialUserData, currentUser = null, isMe = true) {
85
- const container = document.createElement("div");
86
- container.className = "profile-view";
87
-
88
- let isSettingsView = false;
89
- let activeTab = safeGet(`Profile_ActiveTab_${isMe}`) || "published";
90
- let userData = normalizeProfileData(initialUserData, initialUserData.account, isMe);
91
- let constructing = true;
92
- let renderGeneration = 0;
93
- let logoutPending = false;
94
- const componentId = `profile-detail-${++profileSequence}`;
95
- const releaseChild = () => {
96
- const list = container.querySelector('#profile-list-container');
97
- if (list) endProfileListView(list);
98
- container.querySelector('.ranking-settings-form')?._cleanup?.();
99
- };
100
- const scope = createTaskViewScope({
101
- sessionMatches: captureRequestSession(), connected: () => constructing || container.isConnected,
102
- subscribe: notify => subscribeTaskSessionChanges(notify, { target: window, eventBus, events: EVENTS,
103
- userKey: CACHE.LEGACY_KEYS.USER, tokenKey: CACHE.LEGACY_KEYS.TOKEN }),
104
- cancelReads: () => requestCancelManager.cancelAll(componentId),
105
- invalidate: () => {
106
- releaseChild(); userData = { account: initialUserData.account };
107
- container.replaceChildren();
108
- const back = document.createElement('button'); back.type = 'button';
109
- back.className = 'about-back'; back.textContent = t('common.back');
110
- back.onclick = () => window.dispatchEvent(new CustomEvent('comfy-route-back'));
111
- const status = document.createElement('div'); status.setAttribute('role', 'status');
112
- status.textContent = t('auth.session_changed'); container.append(back, status);
113
- }
114
- });
115
- container._profileScope = scope;
116
- container._profileReadOptions = { componentId, timeout: API.BACKGROUND_TIMEOUT, retries: API.BACKGROUND_RETRIES };
117
- container._cleanup = () => { releaseChild(); scope.dispose(); container.replaceChildren(); };
118
- // 独立挂载者也能释放;正常侧栏路由在移除前调用 _cleanup。
119
- const observer = new MutationObserver(() => { if (!container.isConnected) container._cleanup(); });
120
- observer.observe(document.body, { childList: true, subtree: true });
121
- scope.addCleanup(() => { observer.disconnect(); releaseChild(); });
122
-
123
- function render() {
124
- if (!scope.isCurrent()) return;
125
- releaseChild();
126
- const generation = ++renderGeneration;
127
- const current = () => scope.isCurrent() && renderGeneration === generation;
128
- container.innerHTML = "";
129
-
130
- let isFollowing = false;
131
- if (currentUser && userData.followers) { isFollowing = userData.followers.includes(currentUser.account); }
132
-
133
- const privacy = userData.privacy || {};
134
- const followingCount = (!isMe && privacy.follows) ? "***" : (userData.following ? userData.following.length : 0);
135
-
136
- const tabs = [{ id: "published", label: isMe ? t('profile.my_published') : t('profile.their_published') }];
137
- if (isMe) tabs.push({ id: "acquired", label: t('profile.my_purchased') });
138
- if (isMe) tabs.push({ id: "my_tasks", label: t('profile.my_tasks') });
139
- if (isMe) tabs.push({ id: "transactions", label: t('profile.transactions') });
140
- if (isMe) tabs.push({ id: "my_posts", label: t('profile.my_posts') });
141
- if (isMe) tabs.push({ id: "my_prompts", label: t('prompt.my_prompts') });
142
- if (isMe) tabs.push({ id: "collected", label: t('profile.my_collected') });
143
- if (isMe || !privacy.likes) tabs.push({ id: "liked", label: t('profile.recent_likes') });
144
- if (isMe || !privacy.follows) tabs.push({ id: "following", label: t('profile.following') });
145
- if (isMe || !privacy.followers) tabs.push({ id: "followers", label: t('profile.followers') });
146
-
147
- if (!tabs.find(t => t.id === activeTab)) activeTab = "published";
148
-
149
- // 调用解耦的模板引擎生成 HTML
150
- container.innerHTML = buildProfileHTML(userData, isMe, isSettingsView, isFollowing, followingCount, activeTab, tabs);
151
-
152
- if (isSettingsView && isMe) {
153
- const settingsContainer = createSettingsForm(
154
- userData,
155
- () => { if (!current()) return; isSettingsView = false; render(); },
156
- (newUserData) => {
157
- if (!current()) return;
158
- userData = normalizeProfileData(newUserData, userData.account, true);
159
- if (container.updateData) container.updateData(userData);
160
- isSettingsView = false;
161
- render();
162
- },
163
- { isCurrent: current }
164
- );
165
- container.appendChild(settingsContainer);
166
- container.querySelector("#btn-back-profile").onclick = () => { isSettingsView = false; render(); };
167
- } else {
168
- container.querySelector("#btn-back-profile").onclick = () => window.dispatchEvent(new CustomEvent("comfy-route-back"));
169
-
170
- if (isMe) {
171
- container.querySelector("#btn-wallet").onclick = () => {
172
- if (!current()) return;
173
- openRechargeModal(currentUser, (newBalance) => { if (!current()) return; userData.balance = newBalance; render(); });
174
- };
175
- container.querySelector("#btn-withdraw").onclick = () => {
176
- if (!current()) return;
177
- openWithdrawModal(currentUser, async () => {
178
- if (!current()) return;
179
- // 提现成功后重新拉取用户数据刷新余额
180
- try {
181
- const walletResponse = await api.getWallet(currentUser.account, { componentId });
182
- if (!current()) return;
183
- const walletRes = normalizeWalletData(walletResponse);
184
- userData.balance = walletRes.balance ?? userData.balance;
185
- userData.earn_balance = walletRes.earn_balance ?? userData.earn_balance;
186
- userData.tip_balance = walletRes.tip_balance ?? userData.tip_balance;
187
- userData.total_withdrawn = walletRes.total_withdrawn ?? userData.total_withdrawn;
188
- } catch (err) {
189
- if (!current()) return;
190
- console.warn("提现后刷新钱包数据失败");
191
- }
192
- render();
193
- });
194
- };
195
-
196
- container.querySelector("#btn-open-settings").onclick = () => { isSettingsView = true; render(); };
197
- container.querySelector("#btn-logout").onclick = async () => {
198
- if (!current() || logoutPending) return;
199
- const currentAccount = getCurrentAccount();
200
- logoutPending = true;
201
- try {
202
- if (!await showConfirm(t('confirm.logout')) || !scope.isCurrent()
203
- || getCurrentAccount() !== currentAccount) return;
204
- logoutAndClearUserData(currentAccount);
205
- window.dispatchEvent(new CustomEvent("comfy-route-back"));
206
- window.dispatchEvent(new CustomEvent("comfy-user-logout"));
207
- showToast(t('feedback.logged_out'), "info");
208
- } finally {
209
- logoutPending = false;
210
- }
211
- };
212
- } else {
213
- const btnTip = container.querySelector("#btn-tip-user");
214
- if (btnTip) {
215
- btnTip.onclick = async () => {
216
- if (!current()) return;
217
- if (!currentUser) return showToast(t('feedback.login_required_tip'), "warning");
218
- await openTipModal(currentUser, userData, (newBalance) => {
219
- if (!current()) return;
220
- currentUser.balance = newBalance;
221
- openOtherUserProfileModal(userData.account, currentUser);
222
- }, null, { isCurrent: current });
223
- };
224
- }
225
-
226
- const btnFollow = container.querySelector("#btn-follow-user");
227
- if (btnFollow) {
228
- btnFollow.onclick = async () => {
229
- if (!current()) return;
230
- if (!currentUser) return showToast(t('feedback.login_required'), "warning");
231
- btnFollow.innerHTML = `⏳ ${t('common.processing')}...`; btnFollow.disabled = true;
232
- try {
233
- const newStatus = !isFollowing;
234
- const response = await api.toggleFollow(currentUser.account, userData.account, newStatus);
235
- if (!current()) return;
236
- if (response?.status !== 'success') throw new Error('Invalid follow response');
237
- isFollowing = newStatus;
238
-
239
- if (!userData.followers) userData.followers = [];
240
- if (isFollowing) { if (!userData.followers.includes(currentUser.account)) userData.followers.push(currentUser.account); }
241
- else { userData.followers = userData.followers.filter(a => a !== currentUser.account); }
242
- render();
243
- } catch (_) { if (!current()) return; showToast(t('feedback.operation_failed'), "error"); btnFollow.innerHTML = isFollowing ? `✔️ ${t('social.followed')}` : `➕ ${t('social.follow')}`; }
244
- finally { if (current()) btnFollow.disabled = false; }
245
- };
246
- }
247
-
248
- const btnSendMsg = container.querySelector("#btn-send-msg");
249
- if (btnSendMsg) {
250
- btnSendMsg.onclick = () => {
251
- if (!current()) return;
252
- if (!currentUser) return showToast(t('feedback.login_required'), "warning");
253
- openChatModal(currentUser, userData.account);
254
- };
255
- }
256
- }
257
-
258
- container.querySelectorAll(".profile-tab-btn").forEach(btn => {
259
- btn.onclick = (e) => {
260
- if (!current()) return;
261
- activeTab = e.currentTarget.getAttribute("data-id");
262
- safeSet(`Profile_ActiveTab_${isMe}`, activeTab);
263
- render();
264
- };
265
- });
266
-
267
- // 粉丝/关注数点击跳转对应列表
268
- const _switchTab = (tabId) => { if (!current()) return; activeTab = tabId; safeSet(`Profile_ActiveTab_${isMe}`, tabId); render(); };
269
- _setupTabButton(container, "#btn-followers", "followers", _switchTab);
270
- _setupTabButton(container, "#btn-following", "following", _switchTab);
271
-
272
- const listDOM = container.querySelector("#profile-list-container");
273
- listDOM._profileSessionCurrent = current;
274
- listDOM._profileAddCleanup = cleanup => scope.addCleanup(cleanup);
275
- renderProfileListContent(activeTab, listDOM, userData, currentUser, openOtherUserProfileModal);
276
- }
277
- }
278
-
279
- container.updateData = function(newUserData) {
280
- if (!scope.isCurrent() || !newUserData || typeof newUserData !== 'object' || Array.isArray(newUserData)) return;
281
- const safeNewData = normalizeProfileData({ ...userData, ...newUserData }, userData.account, isMe);
282
- Object.keys(safeNewData).forEach(key => safeNewData[key] == null && delete safeNewData[key]);
283
- const mergedData = { ...userData, ...safeNewData };
284
- if (JSON.stringify(userData) !== JSON.stringify(mergedData)) {
285
- userData = mergedData;
286
- if (!isSettingsView) { render(); }
287
- }
288
- };
289
-
290
- render();
291
- constructing = false;
292
- return container;
293
- }
294
-
295
- export function openUserProfileModal(userData) {
296
- ++profileNavigation;
297
- const view = showUserProfile(userData, userData, true);
298
- window.dispatchEvent(new CustomEvent("comfy-route-view", { detail: { view } }));
299
- _fetchProfileAndWallet(userData.account, { own: true, options: view._profileReadOptions }).then(freshData => {
300
- if (!view._profileScope.isCurrent()) return;
301
- mergeStoredProfile(userData.account, freshData, sessionStorages(), CACHE.LEGACY_KEYS.USER);
302
- view.updateData(freshData);
303
- if (freshData.bannerUrl) syncBannerCache(userData.account, freshData.bannerUrl, view._profileScope,
304
- blobUrl => view.updateData({ bannerUrl: blobUrl }));
305
- }).catch(() => { if (view._profileScope.isCurrent()) console.warn('后台更新本人资料失败'); });
306
- }
307
-
308
- export async function openOtherUserProfileModal(targetAccount, currentUser) {
309
- if (currentUser && currentUser.account === targetAccount) return openUserProfileModal(currentUser);
310
- const generation = ++profileNavigation;
311
- const sessionMatches = captureRequestSession();
312
- const componentId = `profile-open-${++profileSequence}`;
313
- let activeContainer = null;
314
- const cached = getCachedProfile(targetAccount);
315
- if (cached) {
316
- try {
317
- activeContainer = showUserProfile({ ...cached, account: targetAccount }, currentUser, false);
318
- window.dispatchEvent(new CustomEvent('comfy-route-view', { detail: { view: activeContainer } }));
319
- } catch (_) {}
320
- }
321
- let obsolete = false;
322
- const cancel = () => { obsolete = true; requestCancelManager.cancelAll(componentId); };
323
- const onRouteView = event => { if (event.detail?.view !== activeContainer) cancel(); };
324
- window.addEventListener('comfy-route-back', cancel);
325
- window.addEventListener('comfy-route-view', onRouteView);
326
- const unsubscribe = subscribeTaskSessionChanges(force => { if (force || !sessionMatches()) cancel(); }, {
327
- target: window, eventBus, events: EVENTS, userKey: CACHE.LEGACY_KEYS.USER, tokenKey: CACHE.LEGACY_KEYS.TOKEN
328
- });
329
- const current = () => !obsolete && generation === profileNavigation && sessionMatches()
330
- && (!activeContainer || activeContainer._profileScope.isCurrent());
331
- try {
332
- const freshData = await _fetchProfileAndWallet(targetAccount, {
333
- options: activeContainer?._profileReadOptions || { componentId }
334
- });
335
- if (!current()) return;
336
- setCachedProfile(targetAccount, freshData);
337
- if (activeContainer) activeContainer.updateData(freshData);
338
- else {
339
- activeContainer = showUserProfile(freshData, currentUser, false);
340
- window.dispatchEvent(new CustomEvent('comfy-route-view', { detail: { view: activeContainer } }));
341
- }
342
- } catch (_) {
343
- if (current() && !activeContainer) showToast(t('feedback.user_fetch_failed'), 'error');
344
- } finally {
345
- unsubscribe();
346
- window.removeEventListener('comfy-route-back', cancel);
347
- window.removeEventListener('comfy-route-view', onRouteView);
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, context.readOptions);
503
- if (!isCurrent(contentArea)) return;
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, context.readOptions);
524
- if (!isCurrent(contentArea)) return;
525
  if (walletRes?.status !== "success" || !Number.isSafeInteger(walletRes.balance) || walletRes.balance < 0) throw new Error();
526
  balance = walletRes.balance;
527
  } catch (_) {
528
- if (isCurrent(contentArea)) { context.purchasePending = false; showToast(t('common.error'), "error"); }
529
  return;
530
  }
531
 
532
- const content = document.createElement("div");
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;