ZHIWEI666 commited on
Commit
f8aacdd
·
verified ·
1 Parent(s): be93389

Upload 99 files

Browse files
前端页面/components/UI交互提示组件.js CHANGED
@@ -632,6 +632,7 @@ export function showRetryDialog(message, retryFn, options = {}) {
632
 
633
  let loadingOverlay = null;
634
  let loadingCount = 0;
 
635
 
636
  /**
637
  * 显示全局加载遮罩
@@ -639,11 +640,17 @@ let loadingCount = 0;
639
  */
640
  export function showLoading(message = t('feedback.processing')) {
641
  loadingCount++;
 
 
 
 
 
642
 
643
  if (loadingOverlay) {
644
  // 更新文字
645
  const textEl = loadingOverlay.querySelector(".loading-text");
646
  if (textEl) textEl.textContent = message;
 
647
  return;
648
  }
649
 
@@ -694,8 +701,9 @@ export function showLoading(message = t('feedback.processing')) {
694
 
695
  document.body.appendChild(loadingOverlay);
696
 
 
697
  requestAnimationFrame(() => {
698
- loadingOverlay.style.opacity = "1";
699
  });
700
  }
701
 
@@ -707,9 +715,16 @@ export function hideLoading() {
707
 
708
  if (loadingCount > 0 || !loadingOverlay) return;
709
 
710
- loadingOverlay.style.opacity = "0";
711
- setTimeout(() => {
712
- loadingOverlay?.remove();
 
 
 
 
 
 
 
713
  loadingOverlay = null;
714
  }, 200);
715
  }
 
632
 
633
  let loadingOverlay = null;
634
  let loadingCount = 0;
635
+ let loadingHideTask = null;
636
 
637
  /**
638
  * 显示全局加载遮罩
 
640
  */
641
  export function showLoading(message = t('feedback.processing')) {
642
  loadingCount++;
643
+ if (loadingHideTask) {
644
+ clearTimeout(loadingHideTask.timer);
645
+ loadingHideTask = null;
646
+ }
647
+ if (loadingOverlay && !loadingOverlay.isConnected) loadingOverlay = null;
648
 
649
  if (loadingOverlay) {
650
  // 更新文字
651
  const textEl = loadingOverlay.querySelector(".loading-text");
652
  if (textEl) textEl.textContent = message;
653
+ loadingOverlay.style.opacity = "1";
654
  return;
655
  }
656
 
 
701
 
702
  document.body.appendChild(loadingOverlay);
703
 
704
+ const overlay = loadingOverlay;
705
  requestAnimationFrame(() => {
706
+ if (loadingOverlay === overlay && loadingCount > 0 && overlay.isConnected) overlay.style.opacity = "1";
707
  });
708
  }
709
 
 
715
 
716
  if (loadingCount > 0 || !loadingOverlay) return;
717
 
718
+ const overlay = loadingOverlay;
719
+ overlay.style.opacity = "0";
720
+ if (loadingHideTask) return;
721
+ const task = { timer: null };
722
+ loadingHideTask = task;
723
+ task.timer = setTimeout(() => {
724
+ if (loadingHideTask !== task) return;
725
+ loadingHideTask = null;
726
+ if (loadingOverlay !== overlay || loadingCount > 0) return;
727
+ overlay.remove();
728
  loadingOverlay = null;
729
  }, 200);
730
  }
前端页面/components/互动工具函数.js CHANGED
@@ -319,13 +319,17 @@ export function getCacheTTL() {
319
  * @returns {string}
320
  */
321
  export function formatTime(timestamp) {
322
- if (!timestamp) return "";
 
 
 
 
 
323
  const now = Date.now() / 1000;
324
- const diff = now - timestamp;
325
  if (diff < 60) return t('time.just_now');
326
  if (diff < 3600) return t('time.minutes_ago', { n: Math.floor(diff / 60) });
327
  if (diff < 86400) return t('time.hours_ago', { n: Math.floor(diff / 3600) });
328
  if (diff < 604800) return t('time.days_ago', { n: Math.floor(diff / 86400) });
329
- const date = new Date(timestamp * 1000);
330
  return `${date.getMonth() + 1}/${date.getDate()}`;
331
  }
 
319
  * @returns {string}
320
  */
321
  export function formatTime(timestamp) {
322
+ if (timestamp === null || timestamp === undefined || timestamp === "" || timestamp === 0) return "";
323
+ if (typeof timestamp !== "number" && typeof timestamp !== "string") return t('common.unknown');
324
+ const seconds = typeof timestamp === "number" ? timestamp : timestamp.trim() === "" ? NaN : Number(timestamp);
325
+ if (!Number.isFinite(seconds)) return t('common.unknown');
326
+ const date = new Date(seconds * 1000);
327
+ if (!Number.isFinite(date.getTime())) return t('common.unknown');
328
  const now = Date.now() / 1000;
329
+ const diff = now - seconds;
330
  if (diff < 60) return t('time.just_now');
331
  if (diff < 3600) return t('time.minutes_ago', { n: Math.floor(diff / 60) });
332
  if (diff < 86400) return t('time.hours_ago', { n: Math.floor(diff / 3600) });
333
  if (diff < 604800) return t('time.days_ago', { n: Math.floor(diff / 86400) });
 
334
  return `${date.getMonth() + 1}/${date.getDate()}`;
335
  }
前端页面/components/图表渲染组件.js CHANGED
@@ -1,16 +1,17 @@
1
- // 前端页面/components/图表渲染组件.js
2
-
3
  import { t } from "./用户体验增强.js";
 
 
4
 
5
- function loadECharts() {
6
- return new Promise((resolve, reject) => {
7
- if (window.echarts) { resolve(window.echarts); return; }
8
- const script = document.createElement("script");
9
- script.src = "https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js";
10
- script.onload = () => resolve(window.echarts);
11
- script.onerror = () => reject(new Error("ECharts 加载失败"));
12
- document.head.appendChild(script);
13
  });
 
14
  }
15
 
16
  function getLast6Months() {
@@ -27,75 +28,170 @@ function getLast6Months() {
27
  return res;
28
  }
29
 
30
- function _initChart(domElement, itemData) {
31
- loadECharts().then(echarts => {
32
- domElement.innerHTML = "";
33
- const chartInstance = echarts.init(domElement, 'dark', { backgroundColor: 'transparent' });
34
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  const months = getLast6Months();
36
  // 提取底层的真实时间序列
37
  const history = itemData.use_history || {};
38
  const data = months.map(m => history[m] || 0);
39
 
40
- chartInstance.setOption({
41
- tooltip: { trigger: 'axis', textStyle: { fontSize: 11 } },
42
- grid: { top: 10, bottom: 20, left: 35, right: 10, containLabel: true },
43
- xAxis: { type: 'category', data: months, axisLabel: { color: '#888', fontSize: 10 }, axisLine: { lineStyle: { color: '#444' } } },
44
- yAxis: { type: 'value', splitLine: { lineStyle: { color: '#333', type: 'dashed' } }, axisLabel: { color: '#888', fontSize: 10 }, minInterval: 1 },
45
- series: [{
46
- name: '按月获取量', type: 'line', data: data, smooth: true,
47
- itemStyle: { color: '#2196F3' }, lineStyle: { width: 2 },
48
- areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [{offset: 0, color: 'rgba(33,150,243,0.5)'}, {offset: 1, color: 'rgba(33,150,243,0)'}]) }
49
- }]
50
- });
51
-
52
- // 🔧 P1修复:resize 监听可自清理——容器被移出 DOM(详情/列表关闭)后
53
- // 下次触发时自动解绑并 dispose 图表实例,避免监听累积与实例泄漏
54
- const onResize = () => {
55
- if (!document.contains(domElement) || chartInstance.isDisposed()) {
56
- window.removeEventListener('resize', onResize);
57
- if (!chartInstance.isDisposed()) chartInstance.dispose();
58
- return;
59
- }
60
- chartInstance.resize();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  };
62
- window.addEventListener('resize', onResize);
63
 
64
- // 初始化完成后延迟调用resize确保正确渲染
65
- setTimeout(() => {
66
- chartInstance.resize();
67
- }, 300);
68
- }).catch(err => {
69
- domElement.innerHTML = `<div style="text-align:center; line-height:160px; color:#F44336;">${t('chart.load_failed')}</div>`;
70
- });
71
- }
72
-
73
- // 【核心修改】:接收入参从单纯的数字改为完整的 itemData 以读取历史记录
74
- export function renderItemTrendChart(domElement, itemData) {
75
- domElement.innerHTML = `<div style="text-align:center; line-height:160px; color:#888;">${t('chart.loading')}</div>`;
76
-
77
- // 使用 ResizeObserver 等待容器获得有效宽度后再渲染
78
- if (domElement.clientWidth === 0) {
79
- const observer = new ResizeObserver((entries) => {
80
- for (const entry of entries) {
81
- if (entry.contentRect.width > 0) {
82
- observer.disconnect();
83
- _initChart(domElement, itemData);
84
- return;
85
- }
86
  }
87
  });
 
88
  observer.observe(domElement);
89
-
90
- // 安全超时:5秒后如果仍未获得宽度,放弃并清理
91
- setTimeout(() => {
92
- observer.disconnect();
93
- if (domElement.clientWidth === 0) {
94
- domElement.innerHTML = '';
95
- }
96
- }, 5000);
97
- return;
98
- }
99
-
100
- _initChart(domElement, itemData);
101
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import { t } from "./用户体验增强.js";
2
+ import { CHART_LIFECYCLE, getSettings } from "../core/全局配置.js";
3
+ import { loadECharts } from "./图表脚本加载.js";
4
 
5
+ const owners = new Map();
6
+ let removalObserver = null;
7
+ function observeRemoval() {
8
+ if (removalObserver) return;
9
+ removalObserver = new MutationObserver(() => {
10
+ for (const [element, controller] of owners) {
11
+ if (!element.isConnected) controller.cleanup();
12
+ }
13
  });
14
+ removalObserver.observe(document.body, { childList: true, subtree: true });
15
  }
16
 
17
  function getLast6Months() {
 
28
  return res;
29
  }
30
 
31
+ export function renderItemTrendChart(domElement, itemData) {
32
+ if (!domElement?.isConnected) return null;
33
+ owners.get(domElement)?.cleanup();
34
+ let active = true, disposed = false, generation = 0;
35
+ let chartInstance = null, widthObserver = null, widthTimer = null, resizeTimer = null;
36
+ domElement.classList.add('resource-trend');
37
+ let removeResize = null, removeAppearance = null;
38
+ const clearDescription = () => {
39
+ domElement.removeAttribute('role');
40
+ domElement.removeAttribute('aria-label');
41
+ };
42
+ const showState = (message, failed = false) => {
43
+ clearDescription();
44
+ const state = document.createElement('div');
45
+ state.className = 'resource-trend__state';
46
+ state.setAttribute('role', failed ? 'alert' : 'status');
47
+ state.textContent = message;
48
+ domElement.replaceChildren(state);
49
+ };
50
+ const clearWork = () => {
51
+ widthObserver?.disconnect(); widthObserver = null;
52
+ clearTimeout(widthTimer); widthTimer = null;
53
+ clearTimeout(resizeTimer); resizeTimer = null;
54
+ removeResize?.(); removeResize = null;
55
+ removeAppearance?.(); removeAppearance = null;
56
+ clearDescription();
57
+ if (chartInstance) {
58
+ const old = chartInstance; chartInstance = null;
59
+ try { if (!old.isDisposed?.()) old.dispose(); } catch (_) {}
60
+ }
61
+ };
62
+ const cleanup = () => {
63
+ if (disposed) return;
64
+ disposed = true; active = false; generation++;
65
+ clearWork();
66
+ if (owners.get(domElement) === controller) owners.delete(domElement);
67
+ if (!owners.size) { removalObserver?.disconnect(); removalObserver = null; }
68
+ };
69
+ const current = token => {
70
+ if (disposed || !active || generation !== token || owners.get(domElement) !== controller) return false;
71
+ if (!domElement.isConnected) { cleanup(); return false; }
72
+ return true;
73
+ };
74
+ const showError = (token, key = 'chart.render_failed') => {
75
+ if (!current(token)) return;
76
+ generation++;
77
+ clearWork();
78
+ const message = key === 'chart.load_failed' ? t('chart.load_failed')
79
+ : key === 'chart.size_unavailable' ? t('chart.size_unavailable') : t('chart.render_failed');
80
+ showState(message, true);
81
+ };
82
+ const resize = token => {
83
+ if (!current(token) || !chartInstance) return;
84
+ if (chartInstance.isDisposed?.()) { cleanup(); return; }
85
+ try { chartInstance.resize(); } catch (_) { showError(token); }
86
+ };
87
+ const initialize = token => {
88
+ let libraryReady = false;
89
+ loadECharts().then(echarts => {
90
+ if (!current(token)) return;
91
+ libraryReady = true;
92
+ if (domElement.clientWidth === 0) { waitWidth(token); return; }
93
+ domElement.innerHTML = "";
94
+ const instance = echarts.init(domElement, null, { backgroundColor: 'transparent' });
95
+ if (!current(token)) { instance.dispose(); return; }
96
+ chartInstance = instance;
97
  const months = getLast6Months();
98
  // 提取底层的真实时间序列
99
  const history = itemData.use_history || {};
100
  const data = months.map(m => history[m] || 0);
101
 
102
+ const describe = () => {
103
+ domElement.setAttribute('role', 'img');
104
+ domElement.setAttribute('aria-label', t('chart.data_description', {
105
+ series: t('chart.monthly_downloads'),
106
+ values: months.map((month, index) => t('chart.month_value', { month, value: data[index] })).join('; ')
107
+ }));
108
+ };
109
+ const appearance = () => {
110
+ const style = getComputedStyle(domElement);
111
+ const fontSize = parseFloat(style.fontSize);
112
+ const size = Number.isFinite(fontSize) ? fontSize : 12;
113
+ const color = name => style.getPropertyValue(name).trim();
114
+ const text = color('--resource-trend-text');
115
+ const border = color('--resource-trend-border');
116
+ const line = color('--resource-trend-line');
117
+ return {
118
+ animation: getSettings().enableAnimations !== false && !window.matchMedia('(prefers-reduced-motion: reduce)').matches,
119
+ textStyle: { color: text, fontFamily: style.fontFamily, fontSize: size },
120
+ tooltip: { trigger: 'axis', backgroundColor: color('--resource-trend-panel'), borderColor: border, textStyle: { color: text, fontSize: size } },
121
+ grid: { top: size, bottom: size * 1.5, left: size, right: size, containLabel: true },
122
+ xAxis: { type: 'category', data: months, axisLabel: { color: text, fontSize: size, hideOverlap: true, formatter: value => value.replace('-', '\n'), showMinLabel: true, showMaxLabel: true, alignMinLabel: 'left', alignMaxLabel: 'right' }, axisLine: { lineStyle: { color: border } } },
123
+ yAxis: { type: 'value', splitLine: { lineStyle: { color: border, type: 'dashed' } }, axisLabel: { color: text, fontSize: size }, minInterval: 1 },
124
+ series: [{
125
+ name: t('chart.monthly_downloads'), type: 'line', data: data, smooth: true,
126
+ itemStyle: { color: line }, lineStyle: { color: line, width: 2 },
127
+ areaStyle: { color: line, opacity: .12 }
128
+ }]
129
+ };
130
+ };
131
+ instance.setOption(appearance());
132
+ describe();
133
+ const onAppearance = () => {
134
+ if (!current(token) || chartInstance !== instance) return;
135
+ try { instance.setOption(appearance()); describe(); instance.resize(); }
136
+ catch (_) { showError(token); }
137
+ };
138
+ const observer = new MutationObserver(onAppearance);
139
+ for (let ancestor = domElement.parentElement; ancestor; ancestor = ancestor.parentElement) {
140
+ observer.observe(ancestor, { attributes: true, attributeFilter: ['class', 'style', 'data-theme', 'data-color-scheme'] });
141
+ }
142
+ const queries = ['(prefers-color-scheme: dark)', '(forced-colors: active)', '(prefers-reduced-motion: reduce)'].map(query => window.matchMedia(query));
143
+ for (const query of queries) query.addEventListener('change', onAppearance);
144
+ window.addEventListener('comfy-settings-changed', onAppearance);
145
+ removeAppearance = () => {
146
+ observer.disconnect();
147
+ for (const query of queries) query.removeEventListener('change', onAppearance);
148
+ window.removeEventListener('comfy-settings-changed', onAppearance);
149
  };
 
150
 
151
+ if (!current(token) || chartInstance !== instance) return;
152
+ const onResize = onAppearance;
153
+ window.addEventListener('resize', onResize);
154
+ removeResize = () => window.removeEventListener('resize', onResize);
155
+ resizeTimer = setTimeout(() => { resizeTimer = null; resize(token); }, CHART_LIFECYCLE.RESIZE_DELAY);
156
+ }).catch(() => showError(token, libraryReady ? 'chart.render_failed' : 'chart.load_failed'));
157
+ };
158
+ const waitWidth = token => {
159
+ if (!current(token)) return;
160
+ if (domElement.clientWidth > 0) { initialize(token); return; }
161
+ const observer = new ResizeObserver(entries => {
162
+ if (widthObserver !== observer || !current(token)) return;
163
+ if (entries.some(entry => entry.contentRect.width > 0)) {
164
+ observer.disconnect(); widthObserver = null;
165
+ clearTimeout(widthTimer); widthTimer = null;
166
+ initialize(token);
 
 
 
 
 
 
167
  }
168
  });
169
+ widthObserver = observer;
170
  observer.observe(domElement);
171
+ const timer = setTimeout(() => {
172
+ if (widthTimer !== timer) return;
173
+ widthTimer = null;
174
+ if (current(token)) showError(token, 'chart.size_unavailable');
175
+ }, CHART_LIFECYCLE.WIDTH_WAIT_TIMEOUT);
176
+ widthTimer = timer;
177
+ };
178
+ const start = () => {
179
+ const token = ++generation;
180
+ clearWork();
181
+ showState(t('chart.loading'));
182
+ waitWidth(token);
183
+ };
184
+ const controller = {
185
+ cleanup,
186
+ setActive(value) {
187
+ if (disposed || active === !!value) return;
188
+ active = !!value; generation++;
189
+ clearWork();
190
+ if (active) start();
191
+ }
192
+ };
193
+ owners.set(domElement, controller);
194
+ observeRemoval();
195
+ start();
196
+ return controller;
197
+ }
前端页面/components/图表脚本加载.js ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { API } from "../core/全局配置.js";
2
+
3
+ let echartsLoading = null, echartsLoadFailures = 0;
4
+ export function loadECharts() {
5
+ if (typeof window.echarts?.init === 'function') return Promise.resolve(window.echarts);
6
+ if (echartsLoading) return echartsLoading;
7
+ echartsLoading = new Promise((resolve, reject) => {
8
+ const script = document.createElement("script");
9
+ let settled = false;
10
+ const finish = (error) => {
11
+ if (settled) return;
12
+ settled = true; clearTimeout(timer);
13
+ script.onload = script.onerror = null; script.remove();
14
+ if (error) { echartsLoadFailures++; reject(new Error('ECharts load failed')); }
15
+ else { echartsLoadFailures = 0; resolve(window.echarts); }
16
+ };
17
+ const timer = setTimeout(() => finish(true), API.PROFILE_RESOURCE_TIMEOUT);
18
+ const scriptURL = new URL(API.CREATOR_CHART_SCRIPT_URL);
19
+ // 移除script不保证终止原下载;失败后的读取不能复用未结束的旧URL。
20
+ if (echartsLoadFailures) scriptURL.searchParams.set('_ranking_retry', `${Date.now()}-${echartsLoadFailures}`);
21
+ script.src = scriptURL.href;
22
+ script.onload = () => finish(typeof window.echarts?.init !== 'function');
23
+ script.onerror = () => finish(true);
24
+ document.head.appendChild(script);
25
+ }).finally(() => { echartsLoading = null; });
26
+ return echartsLoading;
27
+ }
前端页面/components/打赏等级工具.js CHANGED
@@ -69,7 +69,7 @@ export function calculateTipLevel(amount) {
69
  */
70
  export function renderTipLevelHTML(amount, compact = false) {
71
  if (!amount || amount <= 0) {
72
- return `<span style="color:#666; font-size:11px;">${t('tip.new_fan')}</span>`;
73
  }
74
 
75
  const level = calculateTipLevel(amount);
@@ -79,46 +79,46 @@ export function renderTipLevelHTML(amount, compact = false) {
79
  if (compact) {
80
  // 太阳(最高级别优先显示)
81
  if (level.suns > 0) {
82
- html += `<span style="font-size:12px;">${ICON_SUN.repeat(Math.min(level.suns, 3))}${level.suns > 3 ? '×' + level.suns : ''}</span>`;
83
  }
84
  // 月亮
85
  if (level.moons > 0) {
86
- html += `<span style="font-size:11px;">${ICON_MOON.repeat(level.moons)}</span>`;
87
  }
88
  // 星星
89
  if (level.stars > 0) {
90
- html += `<span style="font-size:10px;">${ICON_STAR.repeat(level.stars)}</span>`;
91
  }
92
 
93
  // 满级特效
94
  if (level.isMaxLevel) {
95
- html = `<span style="background: linear-gradient(90deg, #FFD700, #FF6B6B, #4CAF50); -webkit-background-clip: text; -webkit-text-fill-color: transparent; font-weight: bold;">${html} 👑</span>`;
96
  }
97
 
98
- return html || `<span style="color:#888; font-size:10px;">⭐</span>`;
99
  }
100
 
101
  // ========== 完整模式:显示图标+数量 ==========
102
  const parts = [];
103
 
104
  if (level.suns > 0) {
105
- parts.push(`<span style="color:#FF6B00; font-weight:bold;">${ICON_SUN}×${level.suns}</span>`);
106
  }
107
  if (level.moons > 0) {
108
- parts.push(`<span style="color:#FFD700;">${ICON_MOON}×${level.moons}</span>`);
109
  }
110
  if (level.stars > 0) {
111
- parts.push(`<span style="color:#FFC107;">${ICON_STAR}×${level.stars}</span>`);
112
  }
113
 
114
  // 满级特效
115
  if (level.isMaxLevel) {
116
- return `<span style="background: linear-gradient(90deg, #FFD700, #FF6B6B, #4CAF50); padding: 2px 6px; border-radius: 4px; font-size:11px;">👑 ${t('tip.supreme_sponsor')}</span>`;
117
  }
118
 
119
  return parts.length > 0
120
- ? `<span style="display:inline-flex; gap:4px; align-items:center; font-size:11px;">${parts.join(' ')}</span>`
121
- : `<span style="color:#888; font-size:10px;">⭐</span>`;
122
  }
123
 
124
  /**
@@ -163,36 +163,26 @@ export function renderTipBoardItemHTML(tipData, rank, size = 'normal') {
163
  if (!Number.isFinite(amount) || amount < 0) return '';
164
  rank = Number.isSafeInteger(rank) && rank >= 0 ? rank : 0;
165
 
166
- // 排名徽章颜色
167
- const badgeColors = ['#FFD700', '#C0C0C0', '#CD7F32']; // 金银铜
168
- const badgeBg = rank < 3 ? badgeColors[rank] : '#444';
169
- const badgeColor = rank < 3 ? '#000' : '#aaa';
170
-
171
- // 尺寸配置
172
- const sizeConfig = {
173
- small: { badge: 16, font: 11, padding: 3, avatar: 20 },
174
- normal: { badge: 20, font: 12, padding: 5, avatar: 24 },
175
- large: { badge: 24, font: 14, padding: 8, avatar: 28 }
176
- };
177
- const cfg = sizeConfig[size] || sizeConfig.normal;
178
-
179
  // 等级图标
180
  const levelHtml = renderTipLevelHTML(amount, true);
181
 
182
  // 排名弽章 HTML(两个分支共用)
183
- const badgeHtml = `<span style="display:inline-flex; justify-content:center; align-items:center; width:${cfg.badge}px; height:${cfg.badge}px; border-radius:50%; background:${badgeBg}; color:${badgeColor}; font-weight:bold; font-size:${cfg.font - 2}px;">${rank + 1}</span>`;
184
 
185
  // 匿名用户处理
186
  if (is_anon) {
187
  return `
188
- <div style="display:flex; justify-content:space-between; padding:${cfg.padding}px 0; font-size:${cfg.font}px; align-items:center; border-bottom:1px dashed #333;">
189
- <span style="display:flex; align-items:center; gap:6px;">
190
  ${badgeHtml}
191
- <span style="color:#888;">🔒 ${t('creator.anonymous')}</span>
192
  </span>
193
- <span style="display:flex; align-items:center; gap:6px;">
194
  ${levelHtml}
195
- <span style="color:#4CAF50; font-weight:bold; font-size:${cfg.font - 1}px;">${amount}</span>
196
  </span>
197
  </div>
198
  `;
@@ -228,17 +218,17 @@ export function renderTipBoardItemHTML(tipData, rank, size = 'normal') {
228
  // 🔒 XSS防护:昵称/头像来自用户可修改的资料,写入 HTML 前必须转义
229
  // (上方 SWR 刷新路径用的是 .src / .textContent,本身就是安全的)
230
  return `
231
- <div id="${escapeHtml(containerId)}" style="display:flex; justify-content:space-between; padding:${cfg.padding}px 0; font-size:${cfg.font}px; align-items:center; border-bottom:1px dashed #333;">
232
- <span style="display:flex; align-items:center; gap:6px;">
233
  ${badgeHtml}
234
- <span style="display:flex; align-items:center; gap:6px;">
235
- <img class="tip-board-avatar" alt="" src="${escapeHtml(avatarUrl)}" style="width:${cfg.avatar}px; height:${cfg.avatar}px; border-radius:50%; object-fit:cover; flex-shrink:0; background:#333;">
236
- <span class="tip-board-name" style="color:#ddd;">${escapeHtml(userName)}</span>
237
  </span>
238
  </span>
239
- <span style="display:flex; align-items:center; gap:6px;">
240
  ${levelHtml}
241
- <span style="color:#4CAF50; font-weight:bold; font-size:${cfg.font - 1}px;">${amount}</span>
242
  </span>
243
  </div>
244
  `;
@@ -256,7 +246,7 @@ export function renderTipBoardHTML(boardData, maxShow = 5, emptyText = t('tip.no
256
  const entries = Array.isArray(boardData) ? boardData.filter(entry => entry && typeof entry === 'object'
257
  && !Array.isArray(entry) && Number.isFinite(Number(entry.amount)) && Number(entry.amount) >= 0) : [];
258
  if (entries.length === 0) {
259
- return `<div style="color:#666; font-size:12px; text-align:center; padding:12px 0;">${escapeHtml(emptyText)}</div>`;
260
  }
261
 
262
  return entries.slice(0, maxShow).map((entry, i) => renderTipBoardItemHTML(entry, i, size)).join('');
 
69
  */
70
  export function renderTipLevelHTML(amount, compact = false) {
71
  if (!amount || amount <= 0) {
72
+ return `<span class="tip-level tip-level--new">${t('tip.new_fan')}</span>`;
73
  }
74
 
75
  const level = calculateTipLevel(amount);
 
79
  if (compact) {
80
  // 太阳(最高级别优先显示)
81
  if (level.suns > 0) {
82
+ html += `<span class="tip-level__icon tip-level__icon--sun">${ICON_SUN.repeat(Math.min(level.suns, 3))}${level.suns > 3 ? '×' + level.suns : ''}</span>`;
83
  }
84
  // 月亮
85
  if (level.moons > 0) {
86
+ html += `<span class="tip-level__icon tip-level__icon--moon">${ICON_MOON.repeat(level.moons)}</span>`;
87
  }
88
  // 星星
89
  if (level.stars > 0) {
90
+ html += `<span class="tip-level__icon tip-level__icon--star">${ICON_STAR.repeat(level.stars)}</span>`;
91
  }
92
 
93
  // 满级特效
94
  if (level.isMaxLevel) {
95
+ html = `<span class="tip-level tip-level--max-compact">${html} 👑</span>`;
96
  }
97
 
98
+ return html || `<span class="tip-level tip-level--fallback">⭐</span>`;
99
  }
100
 
101
  // ========== 完整模式:显示图标+数量 ==========
102
  const parts = [];
103
 
104
  if (level.suns > 0) {
105
+ parts.push(`<span class="tip-level__part tip-level__part--sun">${ICON_SUN}×${level.suns}</span>`);
106
  }
107
  if (level.moons > 0) {
108
+ parts.push(`<span class="tip-level__part tip-level__part--moon">${ICON_MOON}×${level.moons}</span>`);
109
  }
110
  if (level.stars > 0) {
111
+ parts.push(`<span class="tip-level__part tip-level__part--star">${ICON_STAR}×${level.stars}</span>`);
112
  }
113
 
114
  // 满级特效
115
  if (level.isMaxLevel) {
116
+ return `<span class="tip-level tip-level--max">👑 ${t('tip.supreme_sponsor')}</span>`;
117
  }
118
 
119
  return parts.length > 0
120
+ ? `<span class="tip-level tip-level--full">${parts.join(' ')}</span>`
121
+ : `<span class="tip-level tip-level--fallback">⭐</span>`;
122
  }
123
 
124
  /**
 
163
  if (!Number.isFinite(amount) || amount < 0) return '';
164
  rank = Number.isSafeInteger(rank) && rank >= 0 ? rank : 0;
165
 
166
+ const rankClass = rank < 3 ? ['gold', 'silver', 'bronze'][rank] : 'default';
167
+ const sizeClass = size === 'small' || size === 'large' ? size : 'normal';
168
+
 
 
 
 
 
 
 
 
 
 
169
  // 等级图标
170
  const levelHtml = renderTipLevelHTML(amount, true);
171
 
172
  // 排名弽章 HTML(两个分支共用)
173
+ const badgeHtml = `<span class="tip-board-badge tip-board-badge--${rankClass}">${rank + 1}</span>`;
174
 
175
  // 匿名用户处理
176
  if (is_anon) {
177
  return `
178
+ <div class="tip-board-item tip-board-item--${sizeClass}">
179
+ <span class="tip-board-group">
180
  ${badgeHtml}
181
+ <span class="tip-board-anonymous">🔒 ${t('creator.anonymous')}</span>
182
  </span>
183
+ <span class="tip-board-group">
184
  ${levelHtml}
185
+ <span class="tip-board-amount">${amount}</span>
186
  </span>
187
  </div>
188
  `;
 
218
  // 🔒 XSS防护:昵称/头像来自用户可修改的资料,写入 HTML 前必须转义
219
  // (上方 SWR 刷新路径用的是 .src / .textContent,本身就是安全的)
220
  return `
221
+ <div id="${escapeHtml(containerId)}" class="tip-board-item tip-board-item--${sizeClass}">
222
+ <span class="tip-board-group">
223
  ${badgeHtml}
224
+ <span class="tip-board-group">
225
+ <img class="tip-board-avatar" alt="" src="${escapeHtml(avatarUrl)}">
226
+ <span class="tip-board-name">${escapeHtml(userName)}</span>
227
  </span>
228
  </span>
229
+ <span class="tip-board-group">
230
  ${levelHtml}
231
+ <span class="tip-board-amount">${amount}</span>
232
  </span>
233
  </div>
234
  `;
 
246
  const entries = Array.isArray(boardData) ? boardData.filter(entry => entry && typeof entry === 'object'
247
  && !Array.isArray(entry) && Number.isFinite(Number(entry.amount)) && Number(entry.amount) >= 0) : [];
248
  if (entries.length === 0) {
249
+ return `<div class="tip-board-empty">${escapeHtml(emptyText)}</div>`;
250
  }
251
 
252
  return entries.slice(0, maxShow).map((entry, i) => renderTipBoardItemHTML(entry, i, size)).join('');
前端页面/components/用户体验_国际化.js CHANGED
@@ -1287,6 +1287,11 @@ const translations = {
1287
 
1288
  // 📊 图表
1289
  'chart.loading': '正在加载图表...',
 
 
 
 
 
1290
  'chart.load_failed': '图表库加载失败',
1291
 
1292
  // 🖼️ 图片
@@ -3136,6 +3141,11 @@ const translations = {
3136
  'tip.points': 'pts',
3137
  'common.seconds': 'sec',
3138
  'chart.loading': 'Loading chart...',
 
 
 
 
 
3139
  'chart.load_failed': 'Chart failed to load',
3140
  'image.loading': 'Loading...',
3141
  'image.load_failed': 'Image load failed',
 
1287
 
1288
  // 📊 图表
1289
  'chart.loading': '正在加载图表...',
1290
+ 'chart.monthly_downloads': '按月获取量',
1291
+ 'chart.data_description': '{series},最近六个月:{values}',
1292
+ 'chart.month_value': '{month}:{value}',
1293
+ 'chart.size_unavailable': '图表区域暂时不可用',
1294
+ 'chart.render_failed': '图表暂时无法显示',
1295
  'chart.load_failed': '图表库加载失败',
1296
 
1297
  // 🖼️ 图片
 
3141
  'tip.points': 'pts',
3142
  'common.seconds': 'sec',
3143
  'chart.loading': 'Loading chart...',
3144
+ 'chart.monthly_downloads': 'Monthly acquisitions',
3145
+ 'chart.data_description': '{series}, last six months: {values}',
3146
+ 'chart.month_value': '{month}: {value}',
3147
+ 'chart.size_unavailable': 'Chart area is currently unavailable.',
3148
+ 'chart.render_failed': 'Chart could not be displayed.',
3149
  'chart.load_failed': 'Chart failed to load',
3150
  'image.loading': 'Loading...',
3151
  'image.load_failed': 'Image load failed',
前端页面/core/侧边栏数据引擎.js CHANGED
@@ -209,7 +209,7 @@ export async function loadSidebarContent({
209
  // 空数据处理
210
  if (displayData.length === 0 && state.displayedCount === 0) {
211
  const emptyState = document.createElement('div');
212
- emptyState.style.cssText = 'text-align:center; padding:40px 20px; color:#888;';
213
  emptyState.textContent = t(keyword ? 'feedback.no_search_results' : 'feedback.no_list_data');
214
  emptyState.setAttribute('role', 'status');
215
  contentArea.replaceChildren(emptyState);
@@ -619,7 +619,7 @@ export async function loadSidebarContent({
619
  displayData = searchCreatorsLocally(keyword, fallbackData);
620
  if (displayData.length === 0) {
621
  const emptyState = document.createElement('div');
622
- emptyState.style.cssText = 'text-align:center; padding:40px 20px; color:#888;';
623
  emptyState.textContent = t('feedback.no_search_results');
624
  emptyState.setAttribute('role', 'status');
625
  contentArea.replaceChildren(emptyState);
 
209
  // 空数据处理
210
  if (displayData.length === 0 && state.displayedCount === 0) {
211
  const emptyState = document.createElement('div');
212
+ emptyState.className = 'sidebar-status-message';
213
  emptyState.textContent = t(keyword ? 'feedback.no_search_results' : 'feedback.no_list_data');
214
  emptyState.setAttribute('role', 'status');
215
  contentArea.replaceChildren(emptyState);
 
619
  displayData = searchCreatorsLocally(keyword, fallbackData);
620
  if (displayData.length === 0) {
621
  const emptyState = document.createElement('div');
622
+ emptyState.className = 'sidebar-status-message';
623
  emptyState.textContent = t('feedback.no_search_results');
624
  emptyState.setAttribute('role', 'status');
625
  contentArea.replaceChildren(emptyState);
前端页面/core/全局配置.js CHANGED
@@ -18,7 +18,7 @@
18
  // 🌐 API 配置
19
  // ==========================================
20
 
21
- /** @type {{ BASE_URL: string, TIMEOUT: number, VIDEO_UPLOAD_TIMEOUT: number, VIDEO_UPLOAD_RESPONSE_MAX_CHARS: number, LOCAL_CACHE_STATS_TIMEOUT: number, LOCAL_CACHE_CLEAR_TIMEOUT: number, LOCAL_CACHE_RESPONSE_MAX_BYTES: number, SSE_MAX_RESPONSE_BYTES: number, SSE_MAX_LINE_BYTES: number, SSE_MAX_LINES: number, SSE_YIELD_LINES: number, SSE_YIELD_BYTES: number, SSE_YIELD_CHUNKS: number, MAX_RETRIES: number, RETRY_DELAY: number, LIST_RETRIES: number, SEARCH_TIMEOUT: number, SEARCH_RETRIES: number, PROFILE_RESOURCE_TIMEOUT: number, CREATOR_CHART_SCRIPT_URL: string, BACKGROUND_TIMEOUT: number, BANNER_IMAGE_TIMEOUT: number, BACKGROUND_RETRIES: number, WORKFLOW_DOWNLOAD_TIMEOUT: number, CLOUD_DOWN_COOLDOWN: number, SEARCH_COMPONENT_ID: string, IMAGE_PROXY_PATH: string, LOCAL_IMAGE_PROXY_PREFIX: string, COMPLETE_IMAGE_QUERY_SUFFIX: string, PRIVATE_GET_PATTERNS: RegExp[], PUBLIC_CONDITIONAL_GET_PATHS: string[], CONDITIONAL_ETAG_PATTERN: RegExp, REQUEST_ID_HEADER: string, REQUEST_ID_PATTERN: RegExp, ENDPOINT_ERROR_SCOPE_HEADER: string, ENDPOINT_ERROR_SCOPE_VALUE: string }} */
22
  export const API = {
23
  REQUEST_ID_HEADER: "X-Ranking-Request-Id",
24
  REQUEST_ID_PATTERN: /^(?:[a-f0-9]{32}|ERR_[0-9]{10,13}_[a-f0-9]{1,8})$/,
@@ -91,6 +91,7 @@ export const API = {
91
  SEARCH_COMPONENT_ID: "sidebar-search",
92
  // 上传相对路径的云端图片读取入口。
93
  IMAGE_PROXY_PATH: "/api/image_proxy",
 
94
  // 本地广告预载要求真实图片,熔断占位图不能作为下载成功。
95
  LOCAL_IMAGE_PROXY_PREFIX: "/community_hub/image?url=",
96
  COMPLETE_IMAGE_QUERY_SUFFIX: "&require_complete=1",
@@ -103,6 +104,11 @@ export const API = {
103
  };
104
 
105
 
 
 
 
 
 
106
  // 发布视频的本地读取预算,不改变云端上传额度。
107
  export const LAZY_IMAGES = Object.freeze({
108
  ROOT_MARGIN: "200px 0px",
@@ -590,6 +596,78 @@ export function escapeHtml(str) {
590
  .replace(/'/g, "&#039;");
591
  }
592
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
593
  /** 统一字段映射:将后端 avatarDataUrl 映射为前端 avatar */
594
  function _mapAvatarField(profile) {
595
  if (profile && profile.avatarDataUrl && !profile.avatar) {
@@ -602,7 +680,8 @@ const PUBLIC_PROFILE_FIELDS = ["account", "name", "avatar", "avatarDataUrl", "in
602
  "receivedLikes", "receivedFavorites", "receivedUses"];
603
  function _publicProfile(profile) {
604
  return Object.fromEntries(PUBLIC_PROFILE_FIELDS.filter(key => Object.prototype.hasOwnProperty.call(profile, key))
605
- .map(key => [key, profile[key]]));
 
606
  }
607
 
608
  function _validProfile(profile) {
@@ -708,7 +787,7 @@ export function renderAvatarWithSWR(options) {
708
 
709
  // 从缓存获取
710
  const cached = getCachedProfile(account);
711
- const avatar = cached?.avatar || cached?.avatarDataUrl || fallbackAvatar || '';
712
  const name = cached?.name || fallbackName || account || '';
713
 
714
  // 后台静默校对并更新 DOM
 
18
  // 🌐 API 配置
19
  // ==========================================
20
 
21
+ /** @type {{ BASE_URL: string, TIMEOUT: number, VIDEO_UPLOAD_TIMEOUT: number, VIDEO_UPLOAD_RESPONSE_MAX_CHARS: number, LOCAL_CACHE_STATS_TIMEOUT: number, LOCAL_CACHE_CLEAR_TIMEOUT: number, LOCAL_CACHE_RESPONSE_MAX_BYTES: number, SSE_MAX_RESPONSE_BYTES: number, SSE_MAX_LINE_BYTES: number, SSE_MAX_LINES: number, SSE_YIELD_LINES: number, SSE_YIELD_BYTES: number, SSE_YIELD_CHUNKS: number, MAX_RETRIES: number, RETRY_DELAY: number, LIST_RETRIES: number, SEARCH_TIMEOUT: number, SEARCH_RETRIES: number, PROFILE_RESOURCE_TIMEOUT: number, CREATOR_CHART_SCRIPT_URL: string, BACKGROUND_TIMEOUT: number, BANNER_IMAGE_TIMEOUT: number, BACKGROUND_RETRIES: number, WORKFLOW_DOWNLOAD_TIMEOUT: number, CLOUD_DOWN_COOLDOWN: number, SEARCH_COMPONENT_ID: string, IMAGE_PROXY_PATH: string, PROFILE_MEDIA_URL_MAX_BYTES: number, LOCAL_IMAGE_PROXY_PREFIX: string, COMPLETE_IMAGE_QUERY_SUFFIX: string, PRIVATE_GET_PATTERNS: RegExp[], PUBLIC_CONDITIONAL_GET_PATHS: string[], CONDITIONAL_ETAG_PATTERN: RegExp, REQUEST_ID_HEADER: string, REQUEST_ID_PATTERN: RegExp, ENDPOINT_ERROR_SCOPE_HEADER: string, ENDPOINT_ERROR_SCOPE_VALUE: string }} */
22
  export const API = {
23
  REQUEST_ID_HEADER: "X-Ranking-Request-Id",
24
  REQUEST_ID_PATTERN: /^(?:[a-f0-9]{32}|ERR_[0-9]{10,13}_[a-f0-9]{1,8})$/,
 
91
  SEARCH_COMPONENT_ID: "sidebar-search",
92
  // 上传相对路径的云端图片读取入口。
93
  IMAGE_PROXY_PATH: "/api/image_proxy",
94
+ PROFILE_MEDIA_URL_MAX_BYTES: 16384,
95
  // 本地广告预载要求真实图片,熔断占位图不能作为下载成功。
96
  LOCAL_IMAGE_PROXY_PREFIX: "/community_hub/image?url=",
97
  COMPLETE_IMAGE_QUERY_SUFFIX: "&require_complete=1",
 
104
  };
105
 
106
 
107
+ export const CHART_LIFECYCLE = Object.freeze({
108
+ WIDTH_WAIT_TIMEOUT: 5000,
109
+ RESIZE_DELAY: 300
110
+ });
111
+
112
  // 发布视频的本地读取预算,不改变云端上传额度。
113
  export const LAZY_IMAGES = Object.freeze({
114
  ROOT_MARGIN: "200px 0px",
 
596
  .replace(/'/g, "&#039;");
597
  }
598
 
599
+ /** 资料图片只接受明确的图片表示或网络地址,不相对宿主解析业务路径。 */
600
+ export function normalizeProfileImageUrl(value, unwrapProxy = false) {
601
+ if (typeof value !== 'string' || !value || value.trim() !== value || /[\u0000-\u001f\u007f]/.test(value)) return '';
602
+ let source = value;
603
+ let layers = 0;
604
+ while (source.startsWith(API.LOCAL_IMAGE_PROXY_PREFIX)) {
605
+ if (++layers > 10) return '';
606
+ let encoded = source.slice(API.LOCAL_IMAGE_PROXY_PREFIX.length);
607
+ if (encoded.endsWith(API.COMPLETE_IMAGE_QUERY_SUFFIX)) encoded = encoded.slice(0, -API.COMPLETE_IMAGE_QUERY_SUFFIX.length);
608
+ try { source = decodeURIComponent(encoded); } catch (_) { return ''; }
609
+ if (!source || source.trim() !== source || /[\u0000-\u001f\u007f]/.test(source)) return '';
610
+ }
611
+ if (!layers && /^data:image\/[a-z0-9.+-]+(?:;[^,]*)?,.+$/i.test(source)) return value;
612
+ if (!layers && /^blob:https?:\/\//i.test(source)) {
613
+ try {
614
+ const blob = new URL(source);
615
+ if (typeof location !== 'undefined' && location.origin && blob.origin !== location.origin) return '';
616
+ return blob.origin === 'null' ? '' : value;
617
+ } catch (_) { return ''; }
618
+ }
619
+ if (!/^https?:\/\//i.test(source) || source.includes('\\')) return '';
620
+ try {
621
+ if (value.length > API.PROFILE_MEDIA_URL_MAX_BYTES
622
+ || encodeURIComponent(value).replace(/%[0-9a-f]{2}/gi, 'x').length > API.PROFILE_MEDIA_URL_MAX_BYTES) return '';
623
+ const url = new URL(source);
624
+ const authority = source.match(/^https?:\/\/([^/?#]*)/i)?.[1];
625
+ if (!authority || authority.includes('@') || authority.includes('%') || authority.endsWith(':') || url.port === '0') return '';
626
+ if (url.username || url.password) return '';
627
+ const host = url.hostname.toLowerCase().replace(/\.$/, '');
628
+ if (/^\d+\.\d+\.\d+\.\d+$/.test(host)
629
+ && authority.replace(/:[0-9]+$/, '').replace(/\.$/, '').toLowerCase() !== host) return '';
630
+ const base = new URL(API.BASE_URL);
631
+ const baseHost = base.hostname.toLowerCase().replace(/\.$/, '');
632
+ if (host === baseHost) {
633
+ const expectedPath = base.pathname.replace(/\/$/, '') + API.IMAGE_PROXY_PATH;
634
+ const sourcePath = source.slice(source.indexOf('://') + 3 + authority.length).split(/[?#]/, 1)[0] || '/';
635
+ if (url.protocol !== base.protocol || url.port !== base.port || sourcePath !== expectedPath) return '';
636
+ return unwrapProxy ? source : value;
637
+ }
638
+ if (!host.startsWith('[') && !/^\d+\.\d+\.\d+\.\d+$/.test(host)) {
639
+ const labels = host.split('.');
640
+ if (host.length > 253 || labels.length < 2 || !labels.every(label => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label))
641
+ || /^(?:0x[0-9a-f]+|[0-9]+)$/i.test(labels.at(-1))) return '';
642
+ }
643
+ if (!host || host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local')) return '';
644
+ if (host.startsWith('[')) {
645
+ if (!/^\[[23][0-9a-f]{0,3}:/i.test(host)) return '';
646
+ const pieces = host.slice(1, -1).split('::');
647
+ const left = pieces[0] ? pieces[0].split(':').map(part => parseInt(part, 16)) : [];
648
+ const right = pieces[1] ? pieces[1].split(':').map(part => parseInt(part, 16)) : [];
649
+ const groups = pieces.length === 1 ? left : [...left, ...Array(8 - left.length - right.length).fill(0), ...right];
650
+ const [first, second, third] = groups;
651
+ if (first === 0x2002 || (first === 0x3fff && second < 0x1000) || (first === 0x2001 && second === 0xdb8)) return '';
652
+ if (first === 0x2001 && second < 0x200) {
653
+ const exception = (second === 1 && groups.slice(2, 7).every(part => part === 0) && [1, 2].includes(groups[7]))
654
+ || second === 3 || (second === 4 && third === 0x112) || (second >= 0x20 && second <= 0x3f);
655
+ if (!exception) return '';
656
+ }
657
+ } else if (/^\d+\.\d+\.\d+\.\d+$/.test(host)) {
658
+ const rawHost = authority.replace(/:[0-9]+$/, '').replace(/\.$/, '').toLowerCase();
659
+ if (rawHost !== host) return '';
660
+ const [a,b,c,last] = host.split('.').map(Number);
661
+ if (a === 0 || a === 10 || a === 127 || a >= 224 || (a === 100 && b >= 64 && b <= 127)
662
+ || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31)
663
+ || (a === 192 && b === 168) || (a === 198 && (b === 18 || b === 19))
664
+ || (a === 192 && b === 0 && ((c === 0 && ![9, 10].includes(last)) || c === 2))
665
+ || (a === 198 && b === 51 && c === 100) || (a === 203 && b === 0 && c === 113)) return '';
666
+ }
667
+ return unwrapProxy ? source : value;
668
+ } catch (_) { return ''; }
669
+ }
670
+
671
  /** 统一字段映射:将后端 avatarDataUrl 映射为前端 avatar */
672
  function _mapAvatarField(profile) {
673
  if (profile && profile.avatarDataUrl && !profile.avatar) {
 
680
  "receivedLikes", "receivedFavorites", "receivedUses"];
681
  function _publicProfile(profile) {
682
  return Object.fromEntries(PUBLIC_PROFILE_FIELDS.filter(key => Object.prototype.hasOwnProperty.call(profile, key))
683
+ .map(key => [key, ['avatar', 'avatarDataUrl', 'bannerUrl'].includes(key)
684
+ ? normalizeProfileImageUrl(profile[key]) : profile[key]]));
685
  }
686
 
687
  function _validProfile(profile) {
 
787
 
788
  // 从缓存获取
789
  const cached = getCachedProfile(account);
790
+ const avatar = cached?.avatar || cached?.avatarDataUrl || normalizeProfileImageUrl(fallbackAvatar);
791
  const name = cached?.name || fallbackName || account || '';
792
 
793
  // 后台静默校对并更新 DOM
前端页面/core/榜单分页控制.js CHANGED
@@ -26,7 +26,7 @@ export function setupSidebarPagination(contentArea, state, pageSize, loadMoreDat
26
  const isCurrent = () => !stopped && renderToken === getRenderToken();
27
  const loadMoreBtn = document.createElement('button');
28
  loadMoreBtn.textContent = t('task.load_more');
29
- loadMoreBtn.style.cssText = 'width:100%;padding:10px;margin-top:10px;cursor:pointer;border:1px solid #555;border-radius:6px;background:transparent;color:inherit;';
30
  state.loader = { stop() {
31
  stopped = true;
32
  loadMoreBtn.onclick = null;
@@ -34,6 +34,7 @@ export function setupSidebarPagination(contentArea, state, pageSize, loadMoreDat
34
  } };
35
  loadMoreBtn.onclick = async () => {
36
  if (!isCurrent()) return;
 
37
  loadMoreBtn.disabled = true;
38
  loadMoreBtn.textContent = t('common.loading');
39
  try {
@@ -53,6 +54,9 @@ export function setupSidebarPagination(contentArea, state, pageSize, loadMoreDat
53
  loadMoreBtn.textContent = t('task.load_failed');
54
  }
55
  loadMoreBtn.disabled = false;
 
 
 
56
  };
57
  contentArea.appendChild(loadMoreBtn);
58
  return;
 
26
  const isCurrent = () => !stopped && renderToken === getRenderToken();
27
  const loadMoreBtn = document.createElement('button');
28
  loadMoreBtn.textContent = t('task.load_more');
29
+ loadMoreBtn.className = 'sidebar-status-more';
30
  state.loader = { stop() {
31
  stopped = true;
32
  loadMoreBtn.onclick = null;
 
34
  } };
35
  loadMoreBtn.onclick = async () => {
36
  if (!isCurrent()) return;
37
+ const ownedFocus = document.activeElement === loadMoreBtn;
38
  loadMoreBtn.disabled = true;
39
  loadMoreBtn.textContent = t('common.loading');
40
  try {
 
54
  loadMoreBtn.textContent = t('task.load_failed');
55
  }
56
  loadMoreBtn.disabled = false;
57
+ if (ownedFocus && isCurrent() && loadMoreBtn.isConnected && document.activeElement === document.body) {
58
+ loadMoreBtn.focus({ preventScroll: true });
59
+ }
60
  };
61
  contentArea.appendChild(loadMoreBtn);
62
  return;
前端页面/core/榜单视图切换.js CHANGED
@@ -42,7 +42,7 @@ export async function mountSidebarDomainView({
42
  contentArea.innerHTML = '';
43
  const error = document.createElement('div');
44
  error.setAttribute('role', 'alert');
45
- error.style.cssText = 'text-align:center; padding: 40px 20px; color:#F44336;';
46
  error.textContent = `❌ ${t(errorKey)}`;
47
  contentArea.appendChild(error);
48
  }
 
42
  contentArea.innerHTML = '';
43
  const error = document.createElement('div');
44
  error.setAttribute('role', 'alert');
45
+ error.className = 'sidebar-status-message sidebar-status-message--error';
46
  error.textContent = `❌ ${t(errorKey)}`;
47
  contentArea.appendChild(error);
48
  }
前端页面/core/网络请求_图片代理.js CHANGED
@@ -5,7 +5,7 @@
5
  // 作用:处理图片URL的本地代理转换,支持入口清洗和出口剥离
6
  // ==========================================
7
 
8
- import { API, IS_WEB_MODE } from './全局配置.js';
9
  import { isOnline } from './状态管理.js'; // 🌐 网络状态统一取自 状态管理.js,不再直读 navigator.onLine
10
 
11
  // 🟢 入口清洗:接收云端数据时,转换为本地代理,并带【自愈机制】清理被污染的历史数据
@@ -65,10 +65,12 @@ export function proxyImages(obj) {
65
  for (let key in obj) {
66
  if (IMAGE_PROXY_FIELDS.includes(key) && typeof obj[key] === 'string') {
67
 
68
- let originalUrl = _unwrapProxy(obj[key], '/community_hub/image?url=');
 
 
69
 
70
  // 只有最终剥离出来的确实是外部网络链接(包括云端代理URL),才挂上本地缓存代理
71
- if (originalUrl.startsWith('http')) {
72
  // 📱 Web 模式:同源/直链可直接加载,不挂本地代理前缀
73
  obj[key] = IS_WEB_MODE ? originalUrl : `/community_hub/image?url=${encodeURIComponent(originalUrl)}`;
74
  } else {
 
5
  // 作用:处理图片URL的本地代理转换,支持入口清洗和出口剥离
6
  // ==========================================
7
 
8
+ import { API, IS_WEB_MODE, normalizeProfileImageUrl } from './全局配置.js';
9
  import { isOnline } from './状态管理.js'; // 🌐 网络状态统一取自 状态管理.js,不再直读 navigator.onLine
10
 
11
  // 🟢 入口清洗:接收云端数据时,转换为本地代理,并带【自愈机制】清理被污染的历史数据
 
65
  for (let key in obj) {
66
  if (IMAGE_PROXY_FIELDS.includes(key) && typeof obj[key] === 'string') {
67
 
68
+ const isProfileImage = key !== 'coverUrl' && key !== 'cover_image';
69
+ const safeUrl = isProfileImage ? normalizeProfileImageUrl(obj[key]) : obj[key];
70
+ let originalUrl = _unwrapProxy(safeUrl, '/community_hub/image?url=');
71
 
72
  // 只有最终剥离出来的确实是外部网络链接(包括云端代理URL),才挂上本地缓存代理
73
+ if (isProfileImage ? /^https?:\/\//i.test(originalUrl) : originalUrl.startsWith('http')) {
74
  // 📱 Web 模式:同源/直链可直接加载,不挂本地代理前缀
75
  obj[key] = IS_WEB_MODE ? originalUrl : `/community_hub/image?url=${encodeURIComponent(originalUrl)}`;
76
  } else {
前端页面/market/列表卡片组件.js CHANGED
@@ -366,6 +366,8 @@ export function createItemCard(itemData, currentUser = null, contextType = null)
366
 
367
  let isRendered = false;
368
  let commentUI = null;
 
 
369
  const toggleButton = summaryView.querySelector('.card-summary__toggle');
370
  const setExpanded = (expanded) => {
371
  if (expanded) {
@@ -377,6 +379,8 @@ export function createItemCard(itemData, currentUser = null, contextType = null)
377
  detailView.hidden = !expanded;
378
  detailView.style.display = expanded ? "block" : "none";
379
  toggleButton.setAttribute("aria-expanded", String(expanded));
 
 
380
  };
381
  summaryView.onclick = (event) => {
382
  // 摘要中的作者、评分等独立控件不触发折叠;标题按钮使用浏览器原生键盘行为。
@@ -386,7 +390,6 @@ export function createItemCard(itemData, currentUser = null, contextType = null)
386
  setExpanded(isHidden);
387
 
388
  if (isHidden && !isRendered) {
389
- renderItemTrendChart(detailView.querySelector(`#${chartContainerId}`), itemData);
390
  setupImageSandboxEvents(detailView);
391
  isRendered = true;
392
 
@@ -458,7 +461,7 @@ export function createItemCard(itemData, currentUser = null, contextType = null)
458
  // 🖼️ 启用图片懒加载(跳过 data URL 和已处理的)
459
  lazyLoadImages(card);
460
 
461
- card._cleanup = () => { cleanupImageSandbox(detailView); commentUI?._cleanup?.(); };
462
  card.appendChild(summaryView);
463
  card.appendChild(detailView);
464
  return card;
 
366
 
367
  let isRendered = false;
368
  let commentUI = null;
369
+ let trendChart = null;
370
+ let trendChartStopped = false;
371
  const toggleButton = summaryView.querySelector('.card-summary__toggle');
372
  const setExpanded = (expanded) => {
373
  if (expanded) {
 
379
  detailView.hidden = !expanded;
380
  detailView.style.display = expanded ? "block" : "none";
381
  toggleButton.setAttribute("aria-expanded", String(expanded));
382
+ if (expanded && !trendChart && !trendChartStopped) trendChart = renderItemTrendChart(detailView.querySelector(`#${chartContainerId}`), itemData);
383
+ trendChart?.setActive(expanded);
384
  };
385
  summaryView.onclick = (event) => {
386
  // 摘要中的作者、评分等独立控件不触发折叠;标题按钮使用浏览器原生键盘行为。
 
390
  setExpanded(isHidden);
391
 
392
  if (isHidden && !isRendered) {
 
393
  setupImageSandboxEvents(detailView);
394
  isRendered = true;
395
 
 
461
  // 🖼️ 启用图片懒加载(跳过 data URL 和已处理的)
462
  lazyLoadImages(card);
463
 
464
+ card._cleanup = () => { trendChartStopped = true; trendChart?.cleanup(); cleanupImageSandbox(detailView); commentUI?._cleanup?.(); };
465
  card.appendChild(summaryView);
466
  card.appendChild(detailView);
467
  return card;
前端页面/market/创作者卡片组件.js CHANGED
@@ -17,6 +17,7 @@ import { captureRequestSession, requestCancelManager } from "../core/网络请
17
  import { getUser, eventBus, EVENTS } from "../core/状态管理.js";
18
  import { createTaskViewScope, subscribeTaskSessionChanges } from "../task/任务视图生命周期.js";
19
  import { t } from "../components/用户体验增强.js";
 
20
  import { showToast } from "../components/UI交互提示组件.js";
21
  import { openTipModal } from "../profile/个人中心_赞赏组件.js";
22
 
@@ -115,31 +116,7 @@ async function handleTipCreator(creatorData, currentUser, onTipSuccess, parentCu
115
  }, null, { isCurrent: parentCurrent });
116
  }
117
 
118
- let echartsLoading = null, echartsLoadFailures = 0;
119
- function loadECharts() {
120
- if (typeof window.echarts?.init === 'function') return Promise.resolve(window.echarts);
121
- if (echartsLoading) return echartsLoading;
122
- echartsLoading = new Promise((resolve, reject) => {
123
- const script = document.createElement("script");
124
- let settled = false;
125
- const finish = (error) => {
126
- if (settled) return;
127
- settled = true; clearTimeout(timer);
128
- script.onload = script.onerror = null; script.remove();
129
- if (error) { echartsLoadFailures++; reject(new Error('ECharts load failed')); }
130
- else { echartsLoadFailures = 0; resolve(window.echarts); }
131
- };
132
- const timer = setTimeout(() => finish(true), API.PROFILE_RESOURCE_TIMEOUT);
133
- const scriptURL = new URL(API.CREATOR_CHART_SCRIPT_URL);
134
- // 移除script不保证终止原下载;失败后的读取不能复用未结束的旧URL。
135
- if (echartsLoadFailures) scriptURL.searchParams.set('_ranking_retry', `${Date.now()}-${echartsLoadFailures}`);
136
- script.src = scriptURL.href;
137
- script.onload = () => finish(typeof window.echarts?.init !== 'function');
138
- script.onerror = () => finish(true);
139
- document.head.appendChild(script);
140
- }).finally(() => { echartsLoading = null; });
141
- return echartsLoading;
142
- }
143
 
144
  /**
145
  * 渲染打赏榜单条目HTML(匿名/非匿名共用外层结构)
 
17
  import { getUser, eventBus, EVENTS } from "../core/状态管理.js";
18
  import { createTaskViewScope, subscribeTaskSessionChanges } from "../task/任务视图生命周期.js";
19
  import { t } from "../components/用户体验增强.js";
20
+ import { loadECharts } from "../components/图表脚本加载.js";
21
  import { showToast } from "../components/UI交互提示组件.js";
22
  import { openTipModal } from "../profile/个人中心_赞赏组件.js";
23
 
 
116
  }, null, { isCurrent: parentCurrent });
117
  }
118
 
119
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
 
121
  /**
122
  * 渲染打赏榜单条目HTML(匿名/非匿名共用外层结构)
前端页面/market/发布内容组件.js CHANGED
@@ -6,7 +6,7 @@ import { handlePublishSubmit } from "./发布内容_提交引擎.js";
6
  import { t } from "../components/用户体验增强.js";
7
  import { showConfirm, showToast } from "../components/UI交互提示组件.js";
8
  import { globalModal } from "../components/全局弹窗管理器.js";
9
- import { escapeHtml, TYPE_RULES } from "../core/全局配置.js";
10
 
11
  // ==========================================
12
  // 📌 模块级常量
@@ -16,9 +16,6 @@ import { escapeHtml, TYPE_RULES } from "../core/全局配置.js";
16
  // UI 显隐、联动、校验统一读取此配置,避免类型分支散落多处
17
  export { TYPE_RULES };
18
 
19
- // 封面标签统一样式
20
- const _COVER_LABEL_STYLE = 'position: absolute; top: 2px; left: 2px; background: #4CAF50; color: #fff; font-size: 10px; padding: 1px 4px; border-radius: 2px; pointer-events: none;';
21
-
22
  /**
23
  * 创建单个图片预览 wrapper 元素
24
  * @param {string} imgUrl - 图片URL或Base64
@@ -27,11 +24,12 @@ const _COVER_LABEL_STYLE = 'position: absolute; top: 2px; left: 2px; background:
27
  */
28
  function _createImagePreviewWrapper(imgUrl, idx) {
29
  const wrapper = document.createElement('div');
30
- wrapper.style.cssText = 'position: relative; width: 80px; height: 80px;';
 
31
  wrapper.innerHTML = `
32
- <img src="${escapeHtml(imgUrl)}" style="width: 80px; height: 80px; object-fit: cover; border-radius: 4px; border: 2px solid ${idx === 0 ? '#4CAF50' : '#444'};">
33
- ${idx === 0 ? `<span class="cover-label" style="${_COVER_LABEL_STYLE}">${t('post.cover')}</span>` : ''}
34
- <button data-action="remove" style="position: absolute; top: -6px; right: -6px; width: 18px; height: 18px; border-radius: 50%; background: #F44336; color: #fff; border: none; cursor: pointer; font-size: 12px; line-height: 1;">×</button>
35
  `;
36
  return wrapper;
37
  }
@@ -49,15 +47,23 @@ function updateCoverMark(previewContainer) {
49
  const wrappers = previewContainer.querySelectorAll(':scope > div');
50
  wrappers.forEach((wrapper, idx) => {
51
  const img = wrapper.querySelector('img');
52
- const coverLabel = wrapper.querySelector('.cover-label');
 
 
 
 
 
 
 
 
 
53
  if (img) {
54
- img.style.borderColor = idx === 0 ? '#4CAF50' : '#444';
55
  }
56
  if (idx === 0) {
57
  if (!coverLabel) {
58
  const label = document.createElement('span');
59
- label.className = 'cover-label';
60
- label.style.cssText = _COVER_LABEL_STYLE;
61
  label.textContent = t('post.cover');
62
  wrapper.appendChild(label);
63
  }
@@ -67,16 +73,44 @@ function updateCoverMark(previewContainer) {
67
  });
68
  }
69
 
 
 
70
  // ✅ 为图片预览容器设置拖拽排序
71
- function setupImageDragSort(previewContainer, fileArray) {
 
 
72
  const wrappers = Array.from(previewContainer.querySelectorAll(':scope > div'));
73
- if (wrappers.length === 0) return;
 
 
 
74
 
 
75
  let dragSrcEl = null;
76
- let dragSrcIndex = -1;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
 
78
  // 清除所有旧事件,防止重复绑定
79
  wrappers.forEach(wrapper => {
 
 
 
80
  wrapper.ondragstart = null;
81
  wrapper.ondragover = null;
82
  wrapper.ondragleave = null;
@@ -86,101 +120,103 @@ function setupImageDragSort(previewContainer, fileArray) {
86
 
87
  wrappers.forEach(wrapper => {
88
  wrapper.draggable = true;
89
- wrapper.style.cursor = 'grab';
90
 
91
  // 绑定删除按钮
92
  const removeBtn = wrapper.querySelector('button[data-action="remove"]');
93
  if (removeBtn) {
94
  removeBtn.onclick = null; // 清除旧事件,防止重复绑定
95
  removeBtn.onclick = (e) => {
96
- e.stopPropagation();
97
  const currentIdx = Array.from(previewContainer.children).indexOf(wrapper);
 
 
 
98
  fileArray.splice(currentIdx, 1);
99
  wrapper.remove();
100
  updateCoverMark(previewContainer);
101
- setupImageDragSort(previewContainer, fileArray);
 
 
 
 
 
 
102
  };
103
  }
104
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  wrapper.ondragstart = (e) => {
106
- e.stopPropagation();
 
 
 
 
 
107
  dragSrcEl = wrapper;
108
- dragSrcIndex = Array.from(previewContainer.children).indexOf(wrapper);
109
- wrapper.style.opacity = '0.5';
 
110
  e.dataTransfer.effectAllowed = 'move';
111
- e.dataTransfer.setData('text/plain', String(dragSrcIndex));
112
  };
113
 
114
  wrapper.ondragover = (e) => {
 
 
115
  e.preventDefault();
116
  e.stopPropagation();
117
  e.dataTransfer.dropEffect = 'move';
118
-
119
- const targetIndex = Array.from(previewContainer.children).indexOf(wrapper);
120
- if (targetIndex === dragSrcIndex) {
121
- Array.from(previewContainer.children).forEach(w => {
122
- w.style.borderLeft = '';
123
- w.style.paddingLeft = '';
124
- });
125
- return;
126
- }
127
-
128
- // 清除所有插入指示
129
- Array.from(previewContainer.children).forEach(w => {
130
- w.style.borderLeft = '';
131
- w.style.paddingLeft = '';
132
- });
133
-
134
- // 在当前目标左侧显示绿色竖线指示插入位置
135
- wrapper.style.borderLeft = '3px solid #4CAF50';
136
- wrapper.style.paddingLeft = '5px';
137
  };
138
 
139
  wrapper.ondragleave = () => {
140
- wrapper.style.borderLeft = '';
141
- wrapper.style.paddingLeft = '';
142
  };
143
 
144
  wrapper.ondrop = (e) => {
 
 
145
  e.preventDefault();
146
  e.stopPropagation();
147
- const targetIndex = Array.from(previewContainer.children).indexOf(wrapper);
148
- if (targetIndex === dragSrcIndex || dragSrcIndex === -1) return;
149
-
150
- // 移动数组元素(splice 方式,不是交换)
151
- const [moved] = fileArray.splice(dragSrcIndex, 1);
152
- fileArray.splice(targetIndex, 0, moved);
153
-
154
- // 移动 DOM 元素
155
- if (targetIndex > dragSrcIndex) {
156
- previewContainer.insertBefore(dragSrcEl, wrapper.nextSibling);
157
- } else {
158
- previewContainer.insertBefore(dragSrcEl, wrapper);
159
- }
160
-
161
- // 更新封面标记
162
  updateCoverMark(previewContainer);
163
  };
164
 
165
  wrapper.ondragend = () => {
166
- dragSrcEl = null;
167
- dragSrcIndex = -1;
168
- Array.from(previewContainer.children).forEach(w => {
169
- w.style.opacity = '';
170
- w.style.borderLeft = '';
171
- w.style.paddingLeft = '';
172
- });
173
  };
174
  });
175
  }
176
 
177
  export function createPublishView(currentUser, onBackCallback, onSuccessCallback, editItemData = null, initialType = null) {
178
  const container = document.createElement("div");
179
- Object.assign(container.style, {
180
- display: "flex", flexDirection: "column", gap: "15px", color: "#ccc",
181
- // 🔧 自适应高度:flex 填充剩余空间,内容超出时在容器内部滚动(与主界面保持一致)
182
- fontSize: "14px", padding: "15px", overflowY: "auto", flex: "1", minHeight: "0", boxSizing: "border-box"
183
- });
184
 
185
  const isEditMode = !!editItemData;
186
  const viewTitle = isEditMode ? `✏️ ${t('publish.edit_content')}` : `🚀 ${t('publish.new_content')}`;
@@ -268,7 +304,7 @@ export function createPublishView(currentUser, onBackCallback, onSuccessCallback
268
  if (effectiveTime > now) {
269
  const hoursLeft = Math.ceil((effectiveTime - now) / (1000 * 60 * 60));
270
  pendingHint.style.display = "block";
271
- pendingHint.innerHTML = `⏳ ${t('publish.current_price')} <strong>${editItemData.price}</strong> ${t('common.credits')} → ${t('publish.will_change_in')} <strong>${hoursLeft}</strong> ${t('publish.hours_later')} <strong style="color:#4CAF50;">${editItemData.pending_price}</strong> ${t('common.credits')}`;
272
  }
273
  }
274
 
@@ -282,7 +318,7 @@ export function createPublishView(currentUser, onBackCallback, onSuccessCallback
282
  if (editImageUrls.length > 0) {
283
  container.querySelector("#cover-file-hint").style.display = "inline";
284
  renderImagePreviews(editImageUrls, imagesPreview);
285
- setupImageDragSort(imagesPreview, editImageUrls);
286
  }
287
 
288
  if (!editItemData.type.startsWith("recommend")) {
@@ -465,7 +501,7 @@ export function createPublishView(currentUser, onBackCallback, onSuccessCallback
465
  const url = URL.createObjectURL(file); previewUrls.add(url);
466
  imagesPreview.appendChild(_createImagePreviewWrapper(url, index));
467
  }
468
- updateCoverMark(imagesPreview); setupImageDragSort(imagesPreview, imageFiles);
469
  } catch (_) {
470
  if (generation === previewGeneration && submissionScope.isCurrent()) {
471
  releasePreviews(); imageFiles = []; imagesPreview.replaceChildren();
@@ -503,22 +539,23 @@ export function createPublishView(currentUser, onBackCallback, onSuccessCallback
503
  const showOriginalConfirmModal = (onConfirm, onCancel) => {
504
  if (!submissionScope?.isCurrent()) return;
505
  const contentDiv = document.createElement("div");
 
506
  contentDiv.innerHTML = `
507
- <div style="line-height: 1.6; color: #ccc;">
508
- <p style="margin-bottom: 15px;">${t('publish.original_confirm_desc')}</p>
509
- <p style="margin-bottom: 10px; color: #fff; font-weight: bold;">${t('publish.original_confirm_subtitle')}</p>
510
- <ul style="margin: 0 0 15px 20px; padding: 0; color: #aaa;">
511
- <li style="margin-bottom: 5px;">${t('publish.original_confirm_item1')}</li>
512
- <li style="margin-bottom: 5px;">${t('publish.original_confirm_item2')}</li>
513
- <li style="margin-bottom: 5px;">${t('publish.original_confirm_item3')}</li>
514
  </ul>
515
- <p style="margin-top: 15px; padding: 10px; background: rgba(255,152,0,0.1); border-left: 3px solid #FF9800; border-radius: 3px; font-size: 12px; color: #FF9800;">
516
  💡 ${t('publish.original_confirm_warning')}
517
  </p>
518
  </div>
519
- <div style="display: flex; gap: 10px; margin-top: 20px; justify-content: flex-end;">
520
- <button id="btn-original-cancel" style="padding: 8px 16px; background: #444; color: #fff; border: none; border-radius: 4px; cursor: pointer;">${t('common.cancel')}</button>
521
- <button id="btn-original-confirm" style="padding: 8px 16px; background: #4CAF50; color: #fff; border: none; border-radius: 4px; cursor: pointer;">${t('common.confirm')}</button>
522
  </div>
523
  `;
524
 
 
6
  import { t } from "../components/用户体验增强.js";
7
  import { showConfirm, showToast } from "../components/UI交互提示组件.js";
8
  import { globalModal } from "../components/全局弹窗管理器.js";
9
+ import { escapeHtml, TYPE_RULES, areKeyboardShortcutsEnabled } from "../core/全局配置.js";
10
 
11
  // ==========================================
12
  // 📌 模块级常量
 
16
  // UI 显隐、联动、校验统一读取此配置,避免类型分支散落多处
17
  export { TYPE_RULES };
18
 
 
 
 
19
  /**
20
  * 创建单个图片预览 wrapper 元素
21
  * @param {string} imgUrl - 图片URL或Base64
 
24
  */
25
  function _createImagePreviewWrapper(imgUrl, idx) {
26
  const wrapper = document.createElement('div');
27
+ wrapper.className = 'ranking-publish__image';
28
+ wrapper.classList.toggle('ranking-publish__image--cover', idx === 0);
29
  wrapper.innerHTML = `
30
+ <img src="${escapeHtml(imgUrl)}" class="ranking-publish__thumbnail" alt="">
31
+ ${idx === 0 ? `<span class="cover-label ranking-publish__cover-label">${t('post.cover')}</span>` : ''}
32
+ <button type="button" data-action="remove" class="ranking-publish__image-remove" aria-label="${escapeHtml(t('post.remove_image', { index: idx + 1 }))}">×</button>
33
  `;
34
  return wrapper;
35
  }
 
47
  const wrappers = previewContainer.querySelectorAll(':scope > div');
48
  wrappers.forEach((wrapper, idx) => {
49
  const img = wrapper.querySelector('img');
50
+ const coverLabel = wrapper.querySelector('.cover-label');
51
+ wrapper.tabIndex = 0;
52
+ wrapper.setAttribute('role', 'group');
53
+ wrapper.setAttribute('aria-label', t('post.image_position', { index: idx + 1, total: wrappers.length }));
54
+ const shortcutsEnabled = areKeyboardShortcutsEnabled();
55
+ wrapper.title = shortcutsEnabled ? t('post.image_move_hint') : t('post.image_drag_hint');
56
+ if (shortcutsEnabled) wrapper.setAttribute('aria-keyshortcuts', 'Alt+ArrowLeft Alt+ArrowRight');
57
+ else wrapper.removeAttribute('aria-keyshortcuts');
58
+ const removeBtn = wrapper.querySelector('button[data-action="remove"]');
59
+ if (removeBtn) removeBtn.setAttribute('aria-label', t('post.remove_image', { index: idx + 1 }));
60
  if (img) {
61
+ wrapper.classList.toggle('ranking-publish__image--cover', idx === 0);
62
  }
63
  if (idx === 0) {
64
  if (!coverLabel) {
65
  const label = document.createElement('span');
66
+ label.className = 'cover-label ranking-publish__cover-label';
 
67
  label.textContent = t('post.cover');
68
  wrapper.appendChild(label);
69
  }
 
73
  });
74
  }
75
 
76
+ const imageSortOwners = new WeakMap();
77
+
78
  // ✅ 为图片预览容器设置拖拽排序
79
+ function setupImageDragSort(previewContainer, fileArray, options = {}) {
80
+ const owner = {};
81
+ imageSortOwners.set(previewContainer, owner);
82
  const wrappers = Array.from(previewContainer.querySelectorAll(':scope > div'));
83
+ if (wrappers.length === 0) return;
84
+ updateCoverMark(previewContainer);
85
+ const canInteract = () => previewContainer.isConnected &&
86
+ typeof options.isCurrent === 'function' && options.isCurrent();
87
 
88
+ const isActiveBinding = () => imageSortOwners.get(previewContainer) === owner && canInteract();
89
  let dragSrcEl = null;
90
+ let dragSrcFile = null;
91
+ const resetDrag = () => {
92
+ dragSrcEl = null;
93
+ dragSrcFile = null;
94
+ Array.from(previewContainer.children).forEach(w => {
95
+ w.classList.remove('ranking-publish__image--dragging');
96
+ w.classList.remove('ranking-publish__image--target');
97
+ });
98
+ };
99
+ const getDragState = (target) => {
100
+ if (!isActiveBinding() || !dragSrcEl) return null;
101
+ const current = Array.from(previewContainer.children);
102
+ const from = current.indexOf(dragSrcEl);
103
+ const to = current.indexOf(target);
104
+ if (current.length !== fileArray.length || from < 0 || to < 0 ||
105
+ from >= fileArray.length || to >= fileArray.length || fileArray[from] !== dragSrcFile) return null;
106
+ return { current, from, to };
107
+ };
108
 
109
  // 清除所有旧事件,防止重复绑定
110
  wrappers.forEach(wrapper => {
111
+ wrapper.classList.remove('ranking-publish__image--dragging');
112
+ wrapper.classList.remove('ranking-publish__image--target');
113
+ wrapper.onkeydown = null;
114
  wrapper.ondragstart = null;
115
  wrapper.ondragover = null;
116
  wrapper.ondragleave = null;
 
120
 
121
  wrappers.forEach(wrapper => {
122
  wrapper.draggable = true;
123
+ wrapper.classList.add('ranking-publish__image--sortable');
124
 
125
  // 绑定删除按钮
126
  const removeBtn = wrapper.querySelector('button[data-action="remove"]');
127
  if (removeBtn) {
128
  removeBtn.onclick = null; // 清除旧事件,防止重复绑定
129
  removeBtn.onclick = (e) => {
130
+ if (!isActiveBinding() || removeBtn.disabled) return;
131
  const currentIdx = Array.from(previewContainer.children).indexOf(wrapper);
132
+ if (currentIdx < 0 || currentIdx >= fileArray.length) return;
133
+ const ownedFocus = document.activeElement === removeBtn || document.activeElement === wrapper;
134
+ e.stopPropagation();
135
  fileArray.splice(currentIdx, 1);
136
  wrapper.remove();
137
  updateCoverMark(previewContainer);
138
+ setupImageDragSort(previewContainer, fileArray, options);
139
+ if (ownedFocus && canInteract() && document.activeElement === document.body) {
140
+ const remaining = Array.from(previewContainer.children);
141
+ const next = remaining[Math.min(currentIdx, remaining.length - 1)];
142
+ if (next) next.focus({ preventScroll: true });
143
+ else options.focusEmpty?.();
144
+ }
145
  };
146
  }
147
 
148
+ wrapper.onkeydown = (e) => {
149
+ if (!areKeyboardShortcutsEnabled() || e.defaultPrevented || e.keyCode === 229 ||
150
+ e.target !== wrapper || !e.altKey || e.ctrlKey || e.metaKey || e.shiftKey ||
151
+ e.repeat || e.isComposing || !isActiveBinding() ||
152
+ !['ArrowLeft', 'ArrowRight'].includes(e.key)) return;
153
+ const current = Array.from(previewContainer.children);
154
+ const from = current.indexOf(wrapper);
155
+ const to = from + (e.key === 'ArrowLeft' ? -1 : 1);
156
+ e.preventDefault();
157
+ e.stopPropagation();
158
+ if (from < 0 || to < 0 || to >= current.length) return;
159
+ const [moved] = fileArray.splice(from, 1);
160
+ fileArray.splice(to, 0, moved);
161
+ previewContainer.insertBefore(wrapper, to > from ? current[to].nextSibling : current[to]);
162
+ updateCoverMark(previewContainer);
163
+ wrapper.focus({ preventScroll: true });
164
+ };
165
+
166
  wrapper.ondragstart = (e) => {
167
+ if (!isActiveBinding() || e.defaultPrevented || !e.dataTransfer ||
168
+ typeof e.dataTransfer.setData !== 'function') return;
169
+ const current = Array.from(previewContainer.children);
170
+ const index = current.indexOf(wrapper);
171
+ if (current.length !== fileArray.length || index < 0 || index >= fileArray.length) return;
172
+ resetDrag();
173
  dragSrcEl = wrapper;
174
+ dragSrcFile = fileArray[index];
175
+ e.stopPropagation();
176
+ wrapper.classList.add('ranking-publish__image--dragging');
177
  e.dataTransfer.effectAllowed = 'move';
178
+ e.dataTransfer.setData('text/plain', String(index));
179
  };
180
 
181
  wrapper.ondragover = (e) => {
182
+ const state = getDragState(wrapper);
183
+ if (!state || e.defaultPrevented || !e.dataTransfer) return;
184
  e.preventDefault();
185
  e.stopPropagation();
186
  e.dataTransfer.dropEffect = 'move';
187
+ state.current.forEach(w => w.classList.remove('ranking-publish__image--target'));
188
+ if (state.from !== state.to) wrapper.classList.add('ranking-publish__image--target');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
  };
190
 
191
  wrapper.ondragleave = () => {
192
+ if (!isActiveBinding() || !dragSrcEl) return;
193
+ wrapper.classList.remove('ranking-publish__image--target');
194
  };
195
 
196
  wrapper.ondrop = (e) => {
197
+ const state = getDragState(wrapper);
198
+ if (!state || e.defaultPrevented || !e.dataTransfer) return;
199
  e.preventDefault();
200
  e.stopPropagation();
201
+ const source = dragSrcEl;
202
+ resetDrag();
203
+ if (state.from === state.to) return;
204
+ const [moved] = fileArray.splice(state.from, 1);
205
+ fileArray.splice(state.to, 0, moved);
206
+ previewContainer.insertBefore(source, state.to > state.from ? state.current[state.to].nextSibling : wrapper);
 
 
 
 
 
 
 
 
 
207
  updateCoverMark(previewContainer);
208
  };
209
 
210
  wrapper.ondragend = () => {
211
+ if (!isActiveBinding()) return;
212
+ resetDrag();
 
 
 
 
 
213
  };
214
  });
215
  }
216
 
217
  export function createPublishView(currentUser, onBackCallback, onSuccessCallback, editItemData = null, initialType = null) {
218
  const container = document.createElement("div");
219
+ container.className = 'ranking-publish-view';
 
 
 
 
220
 
221
  const isEditMode = !!editItemData;
222
  const viewTitle = isEditMode ? `✏️ ${t('publish.edit_content')}` : `🚀 ${t('publish.new_content')}`;
 
304
  if (effectiveTime > now) {
305
  const hoursLeft = Math.ceil((effectiveTime - now) / (1000 * 60 * 60));
306
  pendingHint.style.display = "block";
307
+ pendingHint.innerHTML = `⏳ ${t('publish.current_price')} <strong>${escapeHtml(String(editItemData.price))}</strong> ${t('common.credits')} → ${t('publish.will_change_in')} <strong>${hoursLeft}</strong> ${t('publish.hours_later')} <strong class="ranking-publish__pending-value">${escapeHtml(String(editItemData.pending_price))}</strong> ${t('common.credits')}`;
308
  }
309
  }
310
 
 
318
  if (editImageUrls.length > 0) {
319
  container.querySelector("#cover-file-hint").style.display = "inline";
320
  renderImagePreviews(editImageUrls, imagesPreview);
321
+ setupImageDragSort(imagesPreview, editImageUrls, { isCurrent: () => submissionScope?.isCurrent(), focusEmpty: () => { if (submissionScope?.isCurrent() && imagesInput.isConnected) imagesInput.focus({ preventScroll: true }); } });
322
  }
323
 
324
  if (!editItemData.type.startsWith("recommend")) {
 
501
  const url = URL.createObjectURL(file); previewUrls.add(url);
502
  imagesPreview.appendChild(_createImagePreviewWrapper(url, index));
503
  }
504
+ updateCoverMark(imagesPreview); setupImageDragSort(imagesPreview, imageFiles, { isCurrent: () => submissionScope?.isCurrent(), focusEmpty: () => { if (submissionScope?.isCurrent() && imagesInput.isConnected) imagesInput.focus({ preventScroll: true }); } });
505
  } catch (_) {
506
  if (generation === previewGeneration && submissionScope.isCurrent()) {
507
  releasePreviews(); imageFiles = []; imagesPreview.replaceChildren();
 
539
  const showOriginalConfirmModal = (onConfirm, onCancel) => {
540
  if (!submissionScope?.isCurrent()) return;
541
  const contentDiv = document.createElement("div");
542
+ contentDiv.className = "ranking-original-confirm";
543
  contentDiv.innerHTML = `
544
+ <div class="ranking-original-confirm__copy">
545
+ <p class="ranking-original-confirm__description">${t('publish.original_confirm_desc')}</p>
546
+ <p class="ranking-original-confirm__subtitle">${t('publish.original_confirm_subtitle')}</p>
547
+ <ul class="ranking-original-confirm__list">
548
+ <li>${t('publish.original_confirm_item1')}</li>
549
+ <li>${t('publish.original_confirm_item2')}</li>
550
+ <li>${t('publish.original_confirm_item3')}</li>
551
  </ul>
552
+ <p class="ranking-original-confirm__warning">
553
  💡 ${t('publish.original_confirm_warning')}
554
  </p>
555
  </div>
556
+ <div class="ranking-original-confirm__actions">
557
+ <button type="button" id="btn-original-cancel" class="ranking-original-confirm__button">${t('common.cancel')}</button>
558
+ <button type="button" id="btn-original-confirm" class="ranking-original-confirm__button ranking-original-confirm__button--confirm">${t('common.confirm')}</button>
559
  </div>
560
  `;
561
 
前端页面/profile/个人中心_UI模板.js CHANGED
@@ -8,7 +8,7 @@
8
  // - 打赏等级工具.js (打赏榜单等级渲染)
9
  // ==========================================
10
 
11
- import { getBannerCacheKey, PLACEHOLDERS } from "../core/全局配置.js";
12
  import { t } from "../components/用户体验增强.js";
13
  import { escapeHtml } from "../components/互动工具函数.js"; // 🧹 P2归一:局部副本已移除
14
 
@@ -21,17 +21,16 @@ export function buildProfileHTML(userData, isMe, isSettingsView, isFollowing, fo
21
  // 个人资料卡背景图:
22
  // - 自己查看自己:优先使用本地缓存(账号区分键),否则使用云端 URL
23
  // - 他人查看:直接使用云端 URL (userData.bannerUrl)
24
- let bannerImageUrl = userData.bannerUrl;
25
  if (isMe) {
26
  let localBannerCache = null;
27
  try { localBannerCache = localStorage.getItem(getBannerCacheKey(userData.account)); } catch (_) {}
28
- if (localBannerCache) {
29
  bannerImageUrl = localBannerCache;
30
  }
31
  }
32
 
33
- const hasBannerImage = typeof bannerImageUrl === "string" && bannerImageUrl.trim()
34
- && !/[\u0000-\u001f\u007f]/.test(bannerImageUrl);
35
  const bannerStyle = hasBannerImage
36
  ? `background-image: ${escapeHtml(`url(${JSON.stringify(bannerImageUrl)})`)}; background-size: cover; background-position: center;`
37
  : ``;
@@ -92,7 +91,7 @@ export function buildProfileHTML(userData, isMe, isSettingsView, isFollowing, fo
92
 
93
  <!-- 用户信息区域 -->
94
  <div class="profile-identity">
95
- <img alt="" class="profile-avatar" src="${escapeHtml(userData.avatarDataUrl || userData.avatar || PLACEHOLDERS.AVATAR)}">
96
  <div class="profile-bio">
97
  <div class="profile-name">
98
  ${escapeHtml(userData.name)}
 
8
  // - 打赏等级工具.js (打赏榜单等级渲染)
9
  // ==========================================
10
 
11
+ import { getBannerCacheKey, PLACEHOLDERS, normalizeProfileImageUrl } from "../core/全局配置.js";
12
  import { t } from "../components/用户体验增强.js";
13
  import { escapeHtml } from "../components/互动工具函数.js"; // 🧹 P2归一:局部副本已移除
14
 
 
21
  // 个人资料卡背景图:
22
  // - 自己查看自己:优先使用本地缓存(账号区分键),否则使用云端 URL
23
  // - 他人查看:直接使用云端 URL (userData.bannerUrl)
24
+ let bannerImageUrl = normalizeProfileImageUrl(userData.bannerUrl);
25
  if (isMe) {
26
  let localBannerCache = null;
27
  try { localBannerCache = localStorage.getItem(getBannerCacheKey(userData.account)); } catch (_) {}
28
+ if (normalizeProfileImageUrl(localBannerCache)) {
29
  bannerImageUrl = localBannerCache;
30
  }
31
  }
32
 
33
+ const hasBannerImage = !!bannerImageUrl;
 
34
  const bannerStyle = hasBannerImage
35
  ? `background-image: ${escapeHtml(`url(${JSON.stringify(bannerImageUrl)})`)}; background-size: cover; background-position: center;`
36
  : ``;
 
91
 
92
  <!-- 用户信息区域 -->
93
  <div class="profile-identity">
94
+ <img alt="" class="profile-avatar" src="${escapeHtml(normalizeProfileImageUrl(userData.avatarDataUrl) || normalizeProfileImageUrl(userData.avatar) || PLACEHOLDERS.AVATAR)}">
95
  <div class="profile-bio">
96
  <div class="profile-name">
97
  ${escapeHtml(userData.name)}
前端页面/profile/个人中心视图.js CHANGED
@@ -12,7 +12,7 @@ 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 } from "../core/全局配置.js";
16
  import { logoutAndClearUserData, eventBus, EVENTS } from "../core/状态管理.js";
17
 
18
 
@@ -33,16 +33,14 @@ function sessionStorages() {
33
  // ==========================================
34
  // 作用:当本地无缓存但云端有背景图时,下载并缓存到本地
35
  async function syncBannerCache(account, bannerUrl, scope, apply) {
36
- if (!bannerUrl || !scope.isCurrent() || safeGet(getBannerCacheKey(account))) return;
 
 
37
  const controller = new AbortController();
38
  const release = scope.addCleanup(() => controller.abort());
39
  const timer = setTimeout(() => controller.abort(), API.BACKGROUND_TIMEOUT);
40
  try {
41
- let fetchUrl;
42
- const url = new URL(bannerUrl, window.location.href);
43
- if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password) return;
44
- fetchUrl = !IS_WEB_MODE && url.origin !== window.location.origin
45
- ? `/community_hub/image?url=${encodeURIComponent(url.href)}` : url.href;
46
  const response = await fetch(fetchUrl, { signal: controller.signal });
47
  if (!response.ok || !scope.isCurrent()) return;
48
  const blob = await response.blob();
@@ -85,12 +83,7 @@ function _setupTabButton(container, selector, tabId, onSwitch) {
85
 
86
  export function showUserProfile(initialUserData, currentUser = null, isMe = true) {
87
  const container = document.createElement("div");
88
- Object.assign(container.style, {
89
- display: "flex", flexDirection: "column", gap: "15px", color: "#eee",
90
- // 🔧 自适应高度:flex 填充剩余空间,内容超出时在容器内部滚动(与主界面保持一致)
91
- fontSize: "14px", padding: "15px", flex: "1", minHeight: "0", boxSizing: "border-box", overflowY: "auto",
92
- background: "var(--comfy-menu-bg)"
93
- });
94
 
95
  let isSettingsView = false;
96
  let activeTab = safeGet(`Profile_ActiveTab_${isMe}`) || "published";
 
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
 
 
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();
 
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";
前端页面/profile/个人列表组件.js CHANGED
@@ -775,8 +775,12 @@ function createPromptItem(prompt, currentUser) {
775
  * 🕒 格式化帖子时间
776
  */
777
  function formatPostTime(timestamp) {
778
- if (!timestamp) return "";
779
- const date = new Date(timestamp * 1000);
 
 
 
 
780
  const now = new Date();
781
  const diff = now - date;
782
 
@@ -787,6 +791,34 @@ function formatPostTime(timestamp) {
787
  return `${date.getMonth() + 1}/${date.getDate()}`;
788
  }
789
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
790
  /**
791
  * 💳 创建交易记录卡片
792
  * 根据交易类型展示不同的详细信息
@@ -796,7 +828,7 @@ function createTransactionCard(tx) {
796
  txDiv.className = 'profile-transaction-card';
797
 
798
  const isPositive = tx.amount > 0;
799
- const timeStr = tx.created_at ? new Date(tx.created_at).toLocaleString(getLanguage(), { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '';
800
 
801
  // 获取交易类型配置
802
  const typeConfig = getTransactionTypeConfig(tx.tx_type);
 
775
  * 🕒 格式化帖子时间
776
  */
777
  function formatPostTime(timestamp) {
778
+ if (timestamp === null || timestamp === undefined || timestamp === "" || timestamp === 0) return "";
779
+ if (typeof timestamp !== "number" && typeof timestamp !== "string") return t('common.unknown');
780
+ if (typeof timestamp === "string" && timestamp.trim() === "") return t('common.unknown');
781
+ const seconds = Number(timestamp);
782
+ const date = new Date(seconds * 1000);
783
+ if (!Number.isFinite(seconds) || !Number.isFinite(date.getTime())) return t('common.unknown');
784
  const now = new Date();
785
  const diff = now - date;
786
 
 
791
  return `${date.getMonth() + 1}/${date.getDate()}`;
792
  }
793
 
794
+ function formatTransactionTime(timestamp) {
795
+ if (timestamp === null || timestamp === undefined || timestamp === "" || timestamp === 0) return "";
796
+ let date;
797
+ if (typeof timestamp === "number") {
798
+ if (!Number.isFinite(timestamp)) return t('common.unknown');
799
+ date = new Date(timestamp);
800
+ } else if (typeof timestamp === "string") {
801
+ const parts = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,6}))?(Z|[+-]\d{2}:\d{2})?$/.exec(timestamp);
802
+ if (!parts) return t('common.unknown');
803
+ const [year, month, day, hour, minute, second] = parts.slice(1, 7).map(Number);
804
+ if (year < 1 || month < 1 || month > 12 || day < 1 || day > 31 || hour > 23 || minute > 59 || second > 59) return t('common.unknown');
805
+ const milliseconds = Number((parts[7] || '').padEnd(3, '0').slice(0, 3));
806
+ date = new Date(0);
807
+ date.setUTCFullYear(year, month - 1, day);
808
+ date.setUTCHours(hour, minute, second, milliseconds);
809
+ if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) return t('common.unknown');
810
+ const zone = parts[8];
811
+ if (zone && zone !== 'Z') {
812
+ const hours = Number(zone.slice(1, 3)), minutes = Number(zone.slice(4, 6));
813
+ if (hours > 23 || minutes > 59) return t('common.unknown');
814
+ const offset = (hours * 60 + minutes) * (zone[0] === '+' ? 1 : -1);
815
+ date = new Date(date.getTime() - offset * 60000);
816
+ }
817
+ } else return t('common.unknown');
818
+ if (!Number.isFinite(date.getTime())) return t('common.unknown');
819
+ return date.toLocaleString(getLanguage(), { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' });
820
+ }
821
+
822
  /**
823
  * 💳 创建交易记录卡片
824
  * 根据交易类型展示不同的详细信息
 
828
  txDiv.className = 'profile-transaction-card';
829
 
830
  const isPositive = tx.amount > 0;
831
+ const timeStr = formatTransactionTime(tx.created_at);
832
 
833
  // 获取交易类型配置
834
  const typeConfig = getTransactionTypeConfig(tx.tx_type);
前端页面/profile/个人设置表单组件.js CHANGED
@@ -4,7 +4,7 @@ import { regionData, getSortedCountries } from "../auth/国家地区数据.js";
4
  import { showToast } from "../components/UI交互提示组件.js";
5
  import { uploadFile } from "../market/发布内容_提交引擎.js";
6
  import { openImageCropper } from "../components/图片裁剪组件.js";
7
- import { CACHE, IMAGE_PROCESS, getBackgroundKey, getBannerCacheKey, PLACEHOLDERS, escapeHtml } from "../core/全局配置.js";
8
  import { t } from "../components/用户体验增强.js";
9
  import { normalizeProfileData, mergeStoredProfile } from "./个人资料工具.js";
10
 
@@ -17,8 +17,13 @@ function _safeLocalStorageRemove(key) {
17
  }
18
 
19
  function _cssBackgroundImage(value) {
20
- if (typeof value !== "string" || !value.trim() || /[\u0000-\u001f\u007f]/.test(value)) return "none";
21
- return `url(${JSON.stringify(value)})`;
 
 
 
 
 
22
  }
23
 
24
  // 计算年龄工具函数
@@ -84,7 +89,7 @@ function _readAndPreviewImage(file, previewElement, cacheKey, current, readers,
84
  if (settled) return;
85
  if (!current() || signal?.aborted || !previewElement.isConnected) { finish(null); return; }
86
  const base64 = ev.target.result;
87
- if (typeof base64 !== 'string' || !base64.startsWith('data:image/') || !base64.includes(',')) { fail(); return; }
88
  if (cacheKey) {
89
  try {
90
  localStorage.setItem(cacheKey, base64);
@@ -155,7 +160,7 @@ export function createSettingsForm(initialUserData, onCancelCallback, onSaveSucc
155
  <div class="settings-heading">⚙️ ${t('settings_form.title')}</div>
156
  <div class="settings-fields">
157
  <div class="settings-avatar-panel">
158
- <img alt="" class="settings-avatar" id="setting-avatar-preview" src="${escapeHtml(userData.avatarDataUrl || PLACEHOLDERS.AVATAR)}">
159
  <div>
160
  <button type="button" class="settings-avatar-action" id="btn-trigger-avatar">${t('settings_form.change_avatar')}</button>
161
  <div class="settings-hint">${t('settings_form.avatar_hint')}</div>
@@ -285,8 +290,8 @@ export function createSettingsForm(initialUserData, onCancelCallback, onSaveSucc
285
  }
286
  const birthdayInput = container.querySelector("#setting-birthday");
287
  const ageDisplay = container.querySelector("#setting-age-display");
288
- let avatarDataUrl = userData.avatarDataUrl;
289
- let bannerUrl = userData.bannerUrl || null;
290
 
291
  // 🔧 初始化地区下拉列表:根据当前国家加载所有地区选项
292
  const currentCountry = userData.country;
@@ -422,8 +427,10 @@ export function createSettingsForm(initialUserData, onCancelCallback, onSaveSucc
422
  btnTrigger.innerText = t('settings_form.uploading');
423
  btnTrigger.disabled = true;
424
  try {
425
- const cloudImageUrl = await uploadFile(croppedFile, "avatar");
426
  if (!current()) return;
 
 
427
  if (cloudImageUrl) {
428
  avatarDataUrl = cloudImageUrl;
429
  container.querySelector("#setting-avatar-preview").src = cloudImageUrl;
@@ -465,7 +472,7 @@ export function createSettingsForm(initialUserData, onCancelCallback, onSaveSucc
465
  // 「本次没改这个字段」而被跳过,云端旧图残留 —— 表现为点清除并保存后提示成功,
466
  // 但自己和他人在创作者榜单里看到的背景图依然存在。空串则会真实落库,
467
  // 且下游 hasBanner / bannerImageUrl 等判断都按 falsy 正确处理。
468
- // 注意上方 `let bannerUrl = userData.bannerUrl || null` 的初值仍保持 null:那是「本次未涉及该字段」
469
  // 的语义,可防止 userData 恰好缺 bannerUrl 时把云端图误清掉。
470
  cancelBannerSelection();
471
  bannerUrl = "";
@@ -549,9 +556,10 @@ export function createSettingsForm(initialUserData, onCancelCallback, onSaveSucc
549
  if (pendingBannerFile) {
550
  btn.innerHTML = `⏳ ${escapeHtml(t('settings_form.uploading_banner'))}`;
551
  try {
552
- const cloudImageUrl = await uploadFile(pendingBannerFile, "banner", { isCurrent: current, silent: true });
553
  if (!current()) return;
554
- if (typeof cloudImageUrl !== "string" || !cloudImageUrl.trim()) throw new Error("Invalid banner upload");
 
555
  bannerUrl = cloudImageUrl;
556
  updateData.bannerUrl = cloudImageUrl;
557
  pendingBannerFile = null;
 
4
  import { showToast } from "../components/UI交互提示组件.js";
5
  import { uploadFile } from "../market/发布内容_提交引擎.js";
6
  import { openImageCropper } from "../components/图片裁剪组件.js";
7
+ import { CACHE, IMAGE_PROCESS, getBackgroundKey, getBannerCacheKey, PLACEHOLDERS, escapeHtml, normalizeProfileImageUrl } from "../core/全局配置.js";
8
  import { t } from "../components/用户体验增强.js";
9
  import { normalizeProfileData, mergeStoredProfile } from "./个人资料工具.js";
10
 
 
17
  }
18
 
19
  function _cssBackgroundImage(value) {
20
+ const image = normalizeProfileImageUrl(value);
21
+ return image ? `url(${JSON.stringify(image)})` : "none";
22
+ }
23
+
24
+ function _cloudImageValue(value) {
25
+ const image = normalizeProfileImageUrl(value, true);
26
+ return /^https?:\/\//i.test(image) ? image : null;
27
  }
28
 
29
  // 计算年龄工具函数
 
89
  if (settled) return;
90
  if (!current() || signal?.aborted || !previewElement.isConnected) { finish(null); return; }
91
  const base64 = ev.target.result;
92
+ if (!normalizeProfileImageUrl(base64) || !base64.startsWith('data:image/')) { fail(); return; }
93
  if (cacheKey) {
94
  try {
95
  localStorage.setItem(cacheKey, base64);
 
160
  <div class="settings-heading">⚙️ ${t('settings_form.title')}</div>
161
  <div class="settings-fields">
162
  <div class="settings-avatar-panel">
163
+ <img alt="" class="settings-avatar" id="setting-avatar-preview" src="${escapeHtml(normalizeProfileImageUrl(userData.avatarDataUrl) || PLACEHOLDERS.AVATAR)}">
164
  <div>
165
  <button type="button" class="settings-avatar-action" id="btn-trigger-avatar">${t('settings_form.change_avatar')}</button>
166
  <div class="settings-hint">${t('settings_form.avatar_hint')}</div>
 
290
  }
291
  const birthdayInput = container.querySelector("#setting-birthday");
292
  const ageDisplay = container.querySelector("#setting-age-display");
293
+ let avatarDataUrl = _cloudImageValue(userData.avatarDataUrl);
294
+ let bannerUrl = _cloudImageValue(userData.bannerUrl);
295
 
296
  // 🔧 初始化地区下拉列表:根据当前国家加载所有地区选项
297
  const currentCountry = userData.country;
 
427
  btnTrigger.innerText = t('settings_form.uploading');
428
  btnTrigger.disabled = true;
429
  try {
430
+ const uploadedUrl = await uploadFile(croppedFile, "avatar");
431
  if (!current()) return;
432
+ const cloudImageUrl = _cloudImageValue(uploadedUrl);
433
+ if (!cloudImageUrl) throw new Error("Invalid avatar upload");
434
  if (cloudImageUrl) {
435
  avatarDataUrl = cloudImageUrl;
436
  container.querySelector("#setting-avatar-preview").src = cloudImageUrl;
 
472
  // 「本次没改这个字段」而被跳过,云端旧图残留 —— 表现为点清除并保存后提示成功,
473
  // 但自己和他人在创作者榜单里看到的背景图依然存在。空串则会真实落库,
474
  // 且下游 hasBanner / bannerImageUrl 等判断都按 falsy 正确处理。
475
+ // 初值只保留网络图片;临时预览或缺省值为 null,表示「本次未涉及该字段」
476
  // 的语义,可防止 userData 恰好缺 bannerUrl 时把云端图误清掉。
477
  cancelBannerSelection();
478
  bannerUrl = "";
 
556
  if (pendingBannerFile) {
557
  btn.innerHTML = `⏳ ${escapeHtml(t('settings_form.uploading_banner'))}`;
558
  try {
559
+ const uploadedUrl = await uploadFile(pendingBannerFile, "banner", { isCurrent: current, silent: true });
560
  if (!current()) return;
561
+ const cloudImageUrl = _cloudImageValue(uploadedUrl);
562
+ if (!cloudImageUrl) throw new Error("Invalid banner upload");
563
  bannerUrl = cloudImageUrl;
564
  updateData.bannerUrl = cloudImageUrl;
565
  pendingBannerFile = null;
前端页面/profile/个人资料工具.js CHANGED
@@ -1,3 +1,5 @@
 
 
1
  /** 用户资料与 SQL 钱包响应分别校验,未知内部字段不进入页面。 */
2
  const PUBLIC_FIELDS = ['name', 'avatar', 'avatarDataUrl', 'bannerUrl', 'intro', 'gender', 'country', 'region'];
3
  const WALLET_FIELDS = ['balance', 'earn_balance', 'tip_balance', 'task_balance', 'frozen_balance', 'total_withdrawn'];
@@ -7,7 +9,8 @@ export function normalizeProfileData(value, account, own = false) {
7
  || typeof account !== 'string' || !account.trim()) throw new Error('Invalid profile');
8
  const result = { account };
9
  for (const key of [...PUBLIC_FIELDS, ...(own ? ['email', 'phone', 'birthday'] : [])]) {
10
- if (typeof value[key] === 'string') result[key] = value[key];
 
11
  }
12
  for (const key of ['age', 'created_at', 'receivedLikes', 'receivedFavorites', 'receivedUses']) {
13
  if (typeof value[key] === 'number' && Number.isFinite(value[key]) && value[key] >= 0) result[key] = value[key];
 
1
+ import { normalizeProfileImageUrl } from '../core/全局配置.js';
2
+
3
  /** 用户资料与 SQL 钱包响应分别校验,未知内部字段不进入页面。 */
4
  const PUBLIC_FIELDS = ['name', 'avatar', 'avatarDataUrl', 'bannerUrl', 'intro', 'gender', 'country', 'region'];
5
  const WALLET_FIELDS = ['balance', 'earn_balance', 'tip_balance', 'task_balance', 'frozen_balance', 'total_withdrawn'];
 
9
  || typeof account !== 'string' || !account.trim()) throw new Error('Invalid profile');
10
  const result = { account };
11
  for (const key of [...PUBLIC_FIELDS, ...(own ? ['email', 'phone', 'birthday'] : [])]) {
12
+ if (typeof value[key] === 'string') result[key] = ['avatar', 'avatarDataUrl', 'bannerUrl'].includes(key)
13
+ ? normalizeProfileImageUrl(value[key]) : value[key];
14
  }
15
  for (const key of ['age', 'created_at', 'receivedLikes', 'receivedFavorites', 'receivedUses']) {
16
  if (typeof value[key] === 'number' && Number.isFinite(value[key]) && value[key] >= 0) result[key] = value[key];
前端页面/styles/主题基础.css CHANGED
@@ -1205,7 +1205,7 @@
1205
  .ranking-modal-overlay[hidden] { display: none; }
1206
  .ranking-modal { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); width: var(--ranking-modal-width, 500px); max-width: calc(100vw - 24px); max-height: calc(100vh - 24px); max-height: calc(100dvh - 24px); box-sizing: border-box; display: flex; flex-direction: column; overflow: hidden; border: 1px solid var(--rk-border, var(--border-color, #555)); border-radius: var(--rk-radius-md, 12px); background: var(--rk-bg, var(--comfy-menu-bg, #202020)); color: var(--rk-text, var(--fg-color, #ddd)); box-shadow: 0 10px 30px rgba(0,0,0,.4); }
1207
  .ranking-modal__header { display: flex; flex-shrink: 0; justify-content: space-between; align-items: center; gap: 12px; padding: 15px 20px; border-bottom: 1px solid var(--rk-border, var(--border-color, #555)); background: var(--rk-panel, var(--comfy-input-bg, #292929)); }
1208
- .ranking-modal__title { min-width: 0; overflow-wrap: anywhere; font-weight: bold; font-size: 16px; }
1209
  .ranking-modal__close { flex-shrink: 0; min-width: 32px; min-height: 32px; border: 0; border-radius: 4px; background: transparent; color: inherit; cursor: pointer; font-size: 14px; }
1210
  .ranking-modal__body { min-height: 0; padding: 20px; overflow: auto; overflow-wrap: anywhere; }
1211
  .ranking-modal :focus-visible { outline: 2px solid var(--rk-focus, #8ab4ff); outline-offset: 2px; }
@@ -2639,3 +2639,116 @@ button.ranking-rating__star { cursor: pointer; min-width: 28px; min-height: 28px
2639
  .wallet-modal__danger, .wallet-modal__success { text-decoration-color: CanvasText; }
2640
  .wallet-withdraw__summary, .wallet-withdraw__fee, .wallet-recharge__qr { border-color: CanvasText; }
2641
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1205
  .ranking-modal-overlay[hidden] { display: none; }
1206
  .ranking-modal { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); width: var(--ranking-modal-width, 500px); max-width: calc(100vw - 24px); max-height: calc(100vh - 24px); max-height: calc(100dvh - 24px); box-sizing: border-box; display: flex; flex-direction: column; overflow: hidden; border: 1px solid var(--rk-border, var(--border-color, #555)); border-radius: var(--rk-radius-md, 12px); background: var(--rk-bg, var(--comfy-menu-bg, #202020)); color: var(--rk-text, var(--fg-color, #ddd)); box-shadow: 0 10px 30px rgba(0,0,0,.4); }
1207
  .ranking-modal__header { display: flex; flex-shrink: 0; justify-content: space-between; align-items: center; gap: 12px; padding: 15px 20px; border-bottom: 1px solid var(--rk-border, var(--border-color, #555)); background: var(--rk-panel, var(--comfy-input-bg, #292929)); }
1208
+ .ranking-modal__title { min-width: 0; overflow-wrap: anywhere; font-weight: bold; font-size: 1rem; }
1209
  .ranking-modal__close { flex-shrink: 0; min-width: 32px; min-height: 32px; border: 0; border-radius: 4px; background: transparent; color: inherit; cursor: pointer; font-size: 14px; }
1210
  .ranking-modal__body { min-height: 0; padding: 20px; overflow: auto; overflow-wrap: anywhere; }
1211
  .ranking-modal :focus-visible { outline: 2px solid var(--rk-focus, #8ab4ff); outline-offset: 2px; }
 
2639
  .wallet-modal__danger, .wallet-modal__success { text-decoration-color: CanvasText; }
2640
  .wallet-withdraw__summary, .wallet-withdraw__fee, .wallet-recharge__qr { border-color: CanvasText; }
2641
  }
2642
+
2643
+ .tip-level { max-width: 100%; overflow-wrap: anywhere; }
2644
+ .tip-level--new, .tip-level--fallback { color: var(--rk-text, var(--fg-color, #ddd)); font-size: .6875rem; }
2645
+ .tip-level__icon { overflow-wrap: anywhere; }
2646
+ .tip-level__icon--sun { font-size: .75rem; }
2647
+ .tip-level__icon--moon { font-size: .6875rem; }
2648
+ .tip-level__icon--star, .tip-level--fallback { font-size: .625rem; }
2649
+ .tip-level--full { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 4px; font-size: .6875rem; }
2650
+ .tip-level__part { color: var(--rk-text, var(--fg-color, #ddd)); }
2651
+ .tip-level__part--sun { font-weight: bold; }
2652
+ .tip-level--max-compact { color: var(--rk-text, var(--fg-color, #ddd)); font-weight: bold; padding-inline: 2px; border-bottom: 2px solid #c38a00; }
2653
+ .tip-level--max { display: inline-block; box-sizing: border-box; padding: 2px 6px; border: 1px solid #a87500; border-radius: 4px; background: #ffdf80; color: #241c00; font-size: .6875rem; font-weight: bold; }
2654
+ .tip-board-item { --tip-badge-size: 1.25rem; --tip-avatar-size: 1.5rem; --tip-font-size: .75rem; --tip-badge-font: .625rem; --tip-amount-font: .6875rem; --tip-row-padding: 5px; display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 4px 8px; min-width: 0; max-width: 100%; box-sizing: border-box; padding: var(--tip-row-padding) 0; border-bottom: 1px dashed var(--rk-border, var(--border-color, #454545)); color: var(--rk-text, var(--fg-color, #ddd)); font-size: var(--tip-font-size); line-height: 1.5; }
2655
+ .tip-board-item--small { --tip-badge-size: 1rem; --tip-avatar-size: 1.25rem; --tip-font-size: .6875rem; --tip-badge-font: .5625rem; --tip-amount-font: .625rem; --tip-row-padding: 3px; }
2656
+ .tip-board-item--large { --tip-badge-size: 1.5rem; --tip-avatar-size: 1.75rem; --tip-font-size: .875rem; --tip-badge-font: .75rem; --tip-amount-font: .8125rem; --tip-row-padding: 8px; }
2657
+ .tip-board-group { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; min-width: 0; max-width: 100%; overflow-wrap: anywhere; }
2658
+ .tip-board-item > .tip-board-group:first-child { flex: 1 1 8rem; }
2659
+ .tip-board-item > .tip-board-group:last-child { justify-content: flex-end; margin-inline-start: auto; }
2660
+ .tip-board-badge { display: inline-flex; justify-content: center; align-items: center; flex: 0 0 auto; min-width: var(--tip-badge-size); min-height: var(--tip-badge-size); box-sizing: border-box; padding: 1px 3px; border: 1px solid var(--rk-border, var(--border-color, #454545)); border-radius: 50%; background: var(--rk-panel, var(--comfy-input-bg, #292929)); color: var(--rk-text, var(--fg-color, #ddd)); font-size: var(--tip-badge-font); font-weight: bold; }
2661
+ .tip-board-badge--gold { background: #ffd700; color: #211900; border-color: #b28a00; }
2662
+ .tip-board-badge--silver { background: #c0c0c0; color: #191919; border-color: #777; }
2663
+ .tip-board-badge--bronze { background: #cd7f32; color: #1c1000; border-color: #8d511b; }
2664
+ .tip-board-avatar { width: var(--tip-avatar-size); height: var(--tip-avatar-size); max-width: 100%; border-radius: 50%; object-fit: cover; flex-shrink: 0; background: var(--rk-panel, var(--comfy-input-bg, #292929)); }
2665
+ .tip-board-name, .tip-board-anonymous { min-width: 0; max-width: 100%; color: var(--rk-text, var(--fg-color, #ddd)); overflow-wrap: anywhere; }
2666
+ .tip-board-amount { max-width: 100%; color: var(--rk-text, var(--fg-color, #ddd)); color: color-mix(in srgb, var(--rk-text, var(--fg-color, #ddd)) 80%, var(--rk-success, #4caf50)); font-weight: bold; font-size: var(--tip-amount-font); overflow-wrap: anywhere; }
2667
+ .tip-board-empty { padding: 12px 0; color: var(--rk-text, var(--fg-color, #ddd)); font-size: .75rem; text-align: center; overflow-wrap: anywhere; }
2668
+ @media (forced-colors: active) {
2669
+ .tip-board-item { border-color: CanvasText; color: CanvasText; }
2670
+ .tip-board-badge, .tip-level--max { border-color: CanvasText; background: Canvas; color: CanvasText; }
2671
+ .tip-level--max-compact { color: CanvasText; border-color: CanvasText; }
2672
+ .tip-level__part, .tip-level--new, .tip-level--fallback, .tip-board-name, .tip-board-anonymous, .tip-board-amount, .tip-board-empty { color: CanvasText; }
2673
+ }
2674
+
2675
+
2676
+ .ranking-publish-view { display:flex; flex-direction:column; flex:1; gap:15px; min-width:0; min-height:0; padding:15px; box-sizing:border-box; overflow-y:auto; color:var(--rk-text,var(--fg-color,#eee)); font:inherit; font-size:.875rem; line-height:1.5; color-scheme:normal; }
2677
+ .ranking-publish__image { position:relative; flex:0 0 96px; width:96px; height:96px; min-width:0; box-sizing:border-box; }
2678
+ .ranking-publish__image--sortable { cursor:grab; }
2679
+ .ranking-publish__image:focus-visible { outline:3px solid var(--rk-focus,#8ab4ff); outline-offset:2px; }
2680
+ .ranking-publish__thumbnail { display:block; width:100%; height:100%; object-fit:cover; box-sizing:border-box; border:2px solid var(--rk-border,var(--border-color,#777)); border-radius:6px; }
2681
+ .ranking-publish__image--cover .ranking-publish__thumbnail { border-color:var(--rk-success,#3a9360); }
2682
+ .ranking-publish__cover-label { position:absolute; top:2px; left:2px; max-width:calc(100% - 4px); padding:1px 4px; box-sizing:border-box; border:1px solid var(--rk-success,#3a9360); border-radius:3px; background:var(--rk-panel,var(--comfy-input-bg,#292929)); color:var(--rk-text,var(--fg-color,#eee)); font-size:.625rem; line-height:1.3; font-weight:700; overflow-wrap:anywhere; pointer-events:none; }
2683
+ .ranking-publish .ranking-publish__image-remove { position:absolute; right:2px; bottom:2px; width:36px; height:36px; min-height:36px; padding:0; display:grid; place-items:center; border:2px solid var(--rk-danger,#d14b4b); border-radius:6px; background:var(--rk-panel,var(--comfy-input-bg,#292929)); color:var(--rk-text,var(--fg-color,#eee)); font:inherit; font-size:.875rem; line-height:1; cursor:pointer; }
2684
+ .ranking-publish .ranking-publish__image-remove:hover:not(:disabled) { border-color:var(--rk-focus,#8ab4ff); }
2685
+ .ranking-publish .ranking-publish__image-remove:focus-visible { outline:3px solid var(--rk-focus,#8ab4ff); outline-offset:2px; }
2686
+ .ranking-publish__image--dragging { opacity:.5; }
2687
+ .ranking-publish__image--target::before { content:""; position:absolute; left:-5px; top:0; bottom:0; width:3px; border-radius:2px; background:var(--rk-success,#3a9360); pointer-events:none; }
2688
+ .ranking-publish__pending-value { padding-inline:3px; border-bottom:2px solid var(--rk-success,#3a9360); color:inherit; }
2689
+ .ranking-original-confirm { min-width:0; max-width:100%; color:var(--rk-text,var(--fg-color,#eee)); font:inherit; font-size:.875rem; line-height:1.6; overflow-wrap:anywhere; color-scheme:normal; }
2690
+ .ranking-original-confirm *, .ranking-original-confirm *::before, .ranking-original-confirm *::after { box-sizing:border-box; }
2691
+ .ranking-original-confirm__description { margin:0 0 15px; }
2692
+ .ranking-original-confirm__subtitle { margin:0 0 10px; font-weight:700; }
2693
+ .ranking-original-confirm__list { margin:0 0 15px; padding-inline-start:20px; }
2694
+ .ranking-original-confirm__list li { margin-bottom:5px; }
2695
+ .ranking-original-confirm__warning { margin:15px 0 0; padding:10px; border:1px solid var(--rk-border,var(--border-color,#777)); border-inline-start:3px solid var(--rk-warning,#d97706); border-radius:4px; background:var(--rk-bg,var(--comfy-menu-bg,#202020)); color:inherit; font-size:.75rem; }
2696
+ .ranking-original-confirm__actions { display:flex; flex-wrap:wrap; gap:10px; margin-top:20px; justify-content:flex-end; }
2697
+ .ranking-original-confirm__button { flex:0 1 auto; min-width:0; min-height:36px; max-width:100%; padding:8px 16px; border:1px solid var(--rk-border,var(--border-color,#777)); border-radius:6px; background:var(--rk-panel,var(--comfy-input-bg,#292929)); color:inherit; font:inherit; line-height:1.4; white-space:normal; overflow-wrap:anywhere; cursor:pointer; }
2698
+ .ranking-original-confirm__button--confirm { border-inline-start:3px solid var(--rk-success,#3a9360); font-weight:700; }
2699
+ .ranking-original-confirm__button:hover:not(:disabled) { border-color:var(--rk-focus,#8ab4ff); }
2700
+ .ranking-original-confirm__button:focus-visible { outline:3px solid var(--rk-focus,#8ab4ff); outline-offset:2px; }
2701
+ .ranking-original-confirm__button:disabled { opacity:.7; cursor:default; }
2702
+ @media (forced-colors:active) {
2703
+ .ranking-publish__thumbnail { border-color:CanvasText; }
2704
+ .ranking-publish__image--cover .ranking-publish__thumbnail { border-color:Highlight; }
2705
+ .ranking-publish__image--target::before { background:Highlight; }
2706
+ .ranking-publish__cover-label, .ranking-publish .ranking-publish__image-remove, .ranking-original-confirm__button { background:ButtonFace; color:ButtonText; border-color:ButtonText; }
2707
+ .ranking-publish__image:focus-visible, .ranking-publish .ranking-publish__image-remove:focus-visible, .ranking-original-confirm__button:focus-visible { outline-color:Highlight; }
2708
+ .ranking-publish__pending-value, .ranking-original-confirm__warning, .ranking-original-confirm__button--confirm { border-color:Highlight; }
2709
+ }
2710
+
2711
+ .comfy-ranking-root .sidebar-status-message {
2712
+ box-sizing: border-box;
2713
+ min-width: 0;
2714
+ max-width: 100%;
2715
+ padding: 2.5rem 1.25rem;
2716
+ color: var(--rk-text, var(--fg-color, #f1f1f1));
2717
+ font: inherit;
2718
+ line-height: 1.5;
2719
+ text-align: center;
2720
+ overflow-wrap: anywhere;
2721
+ }
2722
+ .comfy-ranking-root .sidebar-status-more {
2723
+ box-sizing: border-box;
2724
+ width: 100%;
2725
+ min-width: 0;
2726
+ min-height: 2.25rem;
2727
+ margin-top: .625rem;
2728
+ padding: .625rem;
2729
+ border: 1px solid var(--rk-border, var(--border-color, #555));
2730
+ border-radius: .375rem;
2731
+ background: transparent;
2732
+ color: var(--rk-text, var(--fg-color, #f1f1f1));
2733
+ font: inherit;
2734
+ line-height: 1.5;
2735
+ text-align: center;
2736
+ white-space: normal;
2737
+ overflow-wrap: anywhere;
2738
+ cursor: pointer;
2739
+ }
2740
+ .comfy-ranking-root .sidebar-status-more:focus-visible { outline: 2px solid var(--rk-text, var(--fg-color, #f1f1f1)); outline-offset: -3px; }
2741
+ .comfy-ranking-root .sidebar-status-more:disabled { cursor: wait; }
2742
+ @media (forced-colors: active) {
2743
+ .comfy-ranking-root .sidebar-status-message { color: CanvasText; }
2744
+ .comfy-ranking-root .sidebar-status-more { color: ButtonText; border-color: ButtonText; background: ButtonFace; }
2745
+ .comfy-ranking-root .sidebar-status-more:focus-visible { outline-color: Highlight; }
2746
+ .comfy-ranking-root .sidebar-status-more:disabled { color: GrayText; border-color: GrayText; }
2747
+ }
2748
+
2749
+ .comfy-ranking-root .profile-view { display:flex; flex-direction:column; gap:.9375rem; padding:.9375rem; flex:1; min-width:0; min-height:0; box-sizing:border-box; overflow-y:auto; background:var(--rk-bg,var(--comfy-menu-bg,#202020)); color:var(--rk-text,var(--fg-color,#eee)); font-size:.875rem; overflow-wrap:anywhere; }
2750
+
2751
+ .comfy-ranking-root .resource-trend { --resource-trend-text: var(--rk-text); --resource-trend-border: var(--rk-border); --resource-trend-line: var(--rk-text); --resource-trend-panel: var(--rk-panel); min-width:0; min-height:10rem; max-width:100%; transition-property:none; font-family:inherit; font-size:.75rem; color:var(--rk-text); }
2752
+ .comfy-ranking-root .resource-trend__state { display:flex; justify-content:center; align-items:center; min-height:160px; box-sizing:border-box; padding:1rem; text-align:center; font:inherit; line-height:1.5; overflow-wrap:anywhere; color:var(--rk-text); }
2753
+ .comfy-ranking-root .resource-trend__state[role=alert] { border-inline-start:2px solid var(--rk-danger); }
2754
+ @media (forced-colors:active) { .comfy-ranking-root .resource-trend { --resource-trend-text:CanvasText; --resource-trend-border:CanvasText; --resource-trend-line:CanvasText; --resource-trend-panel:Canvas; } .comfy-ranking-root .resource-trend__state { color:CanvasText; } .comfy-ranking-root .resource-trend__state[role=alert] { border-color:CanvasText; } }
前端页面/task/任务详情组件.js CHANGED
@@ -1300,7 +1300,9 @@ async function loadTaskComments(container, taskId, currentUser) {
1300
  * 🕐 格式化时间
1301
  */
1302
  function formatTime(timestamp) {
1303
- if (!timestamp) return "";
 
 
1304
  const seconds = Number(timestamp);
1305
  const date = new Date(seconds * 1000);
1306
  if (!Number.isFinite(seconds) || seconds < 0 || !Number.isFinite(date.getTime())) return t('common.unknown');
 
1300
  * 🕐 格式化时间
1301
  */
1302
  function formatTime(timestamp) {
1303
+ if (timestamp === null || timestamp === undefined || timestamp === "" || timestamp === 0) return "";
1304
+ if (typeof timestamp !== "number" && typeof timestamp !== "string") return t('common.unknown');
1305
+ if (typeof timestamp === "string" && timestamp.trim() === "") return t('common.unknown');
1306
  const seconds = Number(timestamp);
1307
  const date = new Date(seconds * 1000);
1308
  if (!Number.isFinite(seconds) || seconds < 0 || !Number.isFinite(date.getTime())) return t('common.unknown');