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

Upload 101 files

Browse files
前端页面/post/帖子详情组件.js CHANGED
@@ -1,739 +1,751 @@
1
- // 前端页面/post/帖子详情组件.js
2
- // ==========================================
3
- // 📄 帖子详情组件
4
- // ==========================================
5
- // 功能:图片轮播、正文、互动(点赞/收藏/评论/打赏)
6
- // 关联文件:
7
- // - 图片沙盒组件.js (图片轮播)
8
- // - 评论与互动组件.js (评论功能)
9
- // - 打赏等级工具.js (打赏榜单)
10
- // ==========================================
11
-
12
- import { api, proxyImages, captureRequestSession, requestCancelManager } from "../core/网络请求API.js";
13
- import { showToast } from "../components/UI交互提示组件.js";
14
- import { getCoverSandboxHTML, setupImageSandboxEvents, cleanupImageSandbox } from "../components/图片沙盒组件.js";
15
- import { getVideoPlayerHTML, setupVideoPlayerEvents, cleanupVideoPlayer } from "../components/视频播放器组件.js";
16
- import { renderTipLevelHTML } from "../components/打赏等级工具.js";
17
- import { openOtherUserProfileModal } from "../profile/个人中心视图.js";
18
- import { t } from "../components/用户体验增强.js";
19
- import { PLACEHOLDERS, getCachedProfile, getProfileWithSWR, normalizeProfileImageUrl, IS_WEB_MODE, API, CACHE } from "../core/全局配置.js";
20
- import { removeCache, findInListCache } from "../components/性能优化工具.js";
21
- import { globalModal } from "../components/全局弹窗管理器.js";
22
- import { recordView, handleToggleLike, handleToggleFavorite, renderTipBoardHTML as renderCommonTipBoardHTML, escapeHtml, formatTime } from "../components/互动工具函数.js"; // 🧹 P2归一:局部 formatTime 已移除
23
- import { createRatingStars, isValidRatingResponse } from "../social/评论与互动组件.js";
24
-
25
- import { getUser, eventBus, EVENTS } from "../core/状态管理.js";
26
- import { createTaskViewScope, subscribeTaskSessionChanges } from "../task/任务视图生命周期.js";
27
-
28
- import { isConfirmedDedicatedCommentResponse, isDefiniteCommentRejection } from "../social/评论数据工具.js";
29
-
30
- const viewContexts = new WeakMap();
31
- let postViewSequence = 0;
32
- const isCurrent = node => !!viewContexts.get(node)?.scope.isCurrent();
33
- function openOwnedModal(node, title, content, options) {
34
- const context = viewContexts.get(node);
35
- if (!context?.scope.isCurrent()) return null;
36
- const modal = globalModal.openModal(title, content, options);
37
- const removeCleanup = context.scope.addCleanup(() => globalModal.closeModal(modal));
38
- content._cleanup = removeCleanup;
39
- return modal;
40
- }
41
-
42
- /**
43
- * 🖼️ 生成头像 HTML 字符串
44
- * @param {string} avatar - 头像 URL
45
- * @param {string} name - 用户名(用于首字母回退)
46
- * @param {number} size - 头像尺寸(px)
47
- * @param {string} [extraStyle] - 额外内联样式(img 时追加)
48
- */
49
- function _generateAvatarHtml(avatar, name, size, extraStyle = '') {
50
- avatar = normalizeProfileImageUrl(avatar);
51
- const initial = (name || 'U')[0].toUpperCase();
52
- // 🔒 XSS防护:avatar / name 均来自他人可修改的资料(昵称首字母可能是 < ),写入 HTML 前必须转义
53
- if (avatar) {
54
- return `<img alt="" class="swr-avatar post-detail-avatar ${extraStyle === 'border: 2px solid #444;' ? 'post-detail-avatar--border' : extraStyle === 'background: var(--comfy-input-bg);' ? 'post-detail-avatar--panel' : ''}" src="${escapeHtml(avatar)}" style="width: ${size}px; height: ${size}px; ${['border: 2px solid #444;', 'background: var(--comfy-input-bg);'].includes(extraStyle) ? '' : extraStyle}">`;
55
- }
56
- const fontSize = Math.max(9, Math.round(size * 0.4));
57
- return `<div class="swr-avatar post-detail-avatar ${extraStyle === 'border: 2px solid #444;' ? 'post-detail-avatar--border' : extraStyle === 'background: var(--comfy-input-bg);' ? 'post-detail-avatar--panel' : ''}" style="width: ${size}px; height: ${size}px; font-size: ${fontSize}px; font-weight: bold;">${escapeHtml(initial)}</div>`;
58
- }
59
-
60
- /**
61
- * 🔄 用 SWR 回调更新已渲染的头像元素
62
- * @param {Element} avatarEl - .swr-avatar 元素
63
- * @param {object} profile - 新的用户 profile
64
- * @param {number} size - 头像尺寸(px)
65
- * @param {string} [extraStyle] - img 额外内联样式
66
- */
67
- function _updateAvatarElement(avatarEl, profile, size, extraStyle = '') {
68
- const avatar = normalizeProfileImageUrl(profile?.avatar);
69
- if (!avatarEl || !avatar) return;
70
- if (avatarEl.tagName === 'IMG') {
71
- avatarEl.src = avatar;
72
- } else {
73
- avatarEl.outerHTML = `<img alt="" class="swr-avatar post-detail-avatar ${extraStyle === 'border: 2px solid #444;' ? 'post-detail-avatar--border' : extraStyle === 'background: var(--comfy-input-bg);' ? 'post-detail-avatar--panel' : ''}" src="${escapeHtml(avatar)}" style="width: ${size}px; height: ${size}px; ${['border: 2px solid #444;', 'background: var(--comfy-input-bg);'].includes(extraStyle) ? '' : extraStyle}">`;
74
- }
75
- }
76
-
77
- /**
78
- * 📄 创建帖子详情视图
79
- */
80
- export function createPostDetailView(postId, currentUser) {
81
- const container = document.createElement("div");
82
- container.className = "discussion-detail";
83
-
84
- container.innerHTML = `
85
- <!-- 顶部标题栏 -->
86
- <div class="post-detail__header">
87
- <button type="button" id="btn-back-detail" class="post-detail__back">
88
- ⬅ ${t('common.back')}
89
- </button>
90
- <span class="post-detail__heading">${t('post.detail_title')}</span>
91
- </div>
92
-
93
- <!-- 内容区域 -->
94
- <div id="post-content" class="post-detail__body">
95
- <div class="post-detail__loading" role="status">
96
- <div class="post-detail__icon">⏳</div>
97
- ${t('post.loading')}
98
- </div>
99
- </div>
100
- `;
101
-
102
- const contentArea = container.querySelector("#post-content");
103
- const owner = currentUser?.account || null;
104
- const sessionMatches = captureRequestSession();
105
- const componentId = `post-detail-${++postViewSequence}`;
106
- let constructing = true, observer;
107
- const scope = createTaskViewScope({
108
- sessionMatches: () => sessionMatches() && (getUser()?.account || null) === owner,
109
- connected: () => constructing || container.isConnected,
110
- subscribe: notify => subscribeTaskSessionChanges(notify, { target: window, eventBus, events: EVENTS,
111
- userKey: CACHE.LEGACY_KEYS.USER, tokenKey: CACHE.LEGACY_KEYS.TOKEN }),
112
- cancelReads: () => requestCancelManager.cancelAll(componentId),
113
- invalidate: () => contentArea.replaceChildren()
114
- });
115
- const context = { scope, tipPending: false,
116
- readOptions: { componentId, noCache: true, timeout: API.BACKGROUND_TIMEOUT, retries: API.BACKGROUND_RETRIES } };
117
- viewContexts.set(container, context); viewContexts.set(contentArea, context);
118
- const handleRouteBack = () => scope.dispose();
119
- scope.addCleanup(() => {
120
- cleanupImageSandbox(contentArea);
121
- cleanupVideoPlayer(contentArea);
122
- contentArea.querySelectorAll('audio').forEach(audio => { audio.pause(); audio.removeAttribute('src'); audio.load(); });
123
- contentArea.replaceChildren(); observer?.disconnect();
124
- window.removeEventListener("comfy-route-back", handleRouteBack);
125
- });
126
- container._cleanup = () => scope.dispose();
127
- container.querySelector("#btn-back-detail").onclick = () => {
128
- scope.dispose(); window.dispatchEvent(new CustomEvent("comfy-route-back"));
129
- };
130
- window.addEventListener("comfy-route-back", handleRouteBack);
131
- queueMicrotask(() => {
132
- constructing = false;
133
- if (!scope.isCurrent()) return;
134
- observer = new MutationObserver(() => { if (!container.isConnected) scope.dispose(); });
135
- observer.observe(document.body, { childList: true, subtree: true });
136
- });
137
-
138
- // 加载帖子详情
139
- loadPostDetail(container, postId, currentUser);
140
-
141
- return container;
142
- }
143
-
144
- /**
145
- * 📥 加载帖子详情
146
- */
147
- async function loadPostDetail(container, postId, currentUser) {
148
- const contentArea = container.querySelector("#post-content");
149
- if (!isCurrent(container)) return;
150
- const context = viewContexts.get(container);
151
- const generation = context.loadGeneration = (context.loadGeneration || 0) + 1;
152
- const current = () => isCurrent(container) && context.loadGeneration === generation;
153
-
154
- // 先清理旧的视频播放器全局事件(如果存在)
155
- if (contentArea) {
156
- cleanupImageSandbox(contentArea);
157
- cleanupVideoPlayer(contentArea);
158
- }
159
-
160
- let post = null;
161
- let fromCache = false;
162
-
163
- try {
164
- const res = await api.getPostDetail(postId, { ...context.readOptions, noCache: false });
165
- if (!current()) return;
166
- post = res.data;
167
- if (!post || typeof post !== "object" || Array.isArray(post) || post.id !== postId) post = null;
168
- } catch (err) {
169
- if (!current()) return;
170
- // 📴 从列表缓存回退
171
- const cached = findInListCache("PostsCache_", postId);
172
- if (cached) {
173
-
174
- post = cached;
175
- fromCache = true;
176
- }
177
- }
178
-
179
- if (!post) {
180
- contentArea.innerHTML = `
181
- <div class="post-detail__error" role="alert">
182
- <div class="post-detail__icon">⚠️</div>
183
- ${t('post.not_exist')}
184
- </div>
185
- `;
186
- return;
187
- }
188
-
189
- post = proxyImages(post); // 对帖子数据应用图片代理
190
-
191
- // 判断是否为视频帖子
192
- const isVideo = post.post_type === "video" && post.video_url;
193
-
194
- // 准备图片列表(视频帖也需要 cover_image 作为 poster)
195
- const images = post.images && post.images.length > 0 ? post.images : (post.cover_image ? [post.cover_image] : []);
196
-
197
- // 准备媒体展示HTML
198
- const mediaHTML = isVideo
199
- ? getVideoPlayerHTML(post.video_url, post.cover_image || '')
200
- : getCoverSandboxHTML(images);
201
-
202
- // 检查当前用户是否已点赞/收藏
203
- const isLiked = post.liked_by?.includes(currentUser?.account) || false;
204
- const isFavorited = post.favorited_by?.includes(currentUser?.account) || false;
205
-
206
- contentArea.innerHTML = `
207
- <!-- 图片展示区 -->
208
- <div id="images-area" class="post-detail__media">
209
- ${mediaHTML}
210
- </div>
211
-
212
- <!-- 标题 -->
213
- <div class="post-detail__title">
214
- ${escapeHtml(post.title)}
215
- </div>
216
-
217
- <!-- 原创标识 -->
218
- ${post.is_original ? `
219
- <div class="post-detail__original">
220
- 🎨 ${t('publish.original_no_commercial')}
221
- </div>
222
- ` : ''}
223
-
224
- <!-- 作者信息(SWR 缓存头像) -->
225
- <div class="post-detail__author post-detail-author-row">
226
- <button type="button" id="author-row" class="post-detail-author-button"><div class="post-detail__avatar-slot swr-avatar-container"></div>
227
- <div class="post-detail__identity">
228
- <div class="post-detail__name swr-name">${escapeHtml(post.author_name || post.author)}</div>
229
- <div class="post-detail__timestamp">${formatTime(post.created_at)}</div>
230
- </div></button>
231
- ${currentUser && currentUser.account === post.author ? `
232
- <div onclick="event.stopPropagation()" class="post-detail__owner-actions">
233
- ${IS_WEB_MODE ? '' : `<button type="button" id="btn-edit-post" aria-label="${t('common.edit')}" title="${t('common.edit')}" class="post-detail__edit">✏️</button>`}
234
- <button type="button" id="btn-delete-post" aria-label="${t('common.delete')}" title="${t('common.delete')}" class="post-detail__delete">🗑️</button>
235
- </div>
236
- ` : ''}
237
- </div>
238
-
239
- <!-- 正文内容 -->
240
- <div class="post-detail__content">${escapeHtml(post.content)}</div>
241
-
242
- <!-- 互动按钮栏 -->
243
- <div id="interaction-bar" class="post-detail__interactions">
244
- <button type="button" id="btn-like" aria-label="${t('market.like')}" aria-pressed="${isLiked}" class="post-detail-toggle">
245
- ❤️ <span id="like-count">${post.likes || 0}</span>
246
- </button>
247
- <button type="button" id="btn-favorite" aria-label="${t('market.favorite')}" aria-pressed="${isFavorited}" class="post-detail-toggle">
248
- 🔖 <span id="favorite-count">${post.favorites || 0}</span>
249
- </button>
250
- <button type="button" id="btn-tip" class="post-detail__tip">
251
- ${t('post.tip_author')}
252
- </button>
253
- <!-- 👀 浏览量统计(纯展示) -->
254
- <div class="post-detail__stats">
255
- <span class="post-detail__stat">🔥 <span id="post-view-total">${post.views || 0}</span></span>
256
- <span class="post-detail__stat">📅 <span id="post-view-daily">${post.daily_views || 0}</span></span>
257
- </div>
258
- </div>
259
-
260
- <!-- 评分区域 -->
261
- <div id="rating-section" class="post-detail__rating"></div>
262
-
263
- <!-- 打赏榜单 -->
264
- <div id="tip-board-area" class="post-detail__media">
265
- ${renderTipBoard(post.tip_board || [])}
266
- </div>
267
-
268
- <!-- 评论区 -->
269
- <div class="post-detail__comments-heading">
270
- ${t('post.comments_title')} <span class="post-detail__comments-count">(${post.comments || 0})</span>
271
- </div>
272
-
273
- <!-- 评论输入框 -->
274
- <div class="post-detail__comment-composer">
275
- <input type="text" id="comment-input" required aria-label="${escapeHtml(t('post.comment_placeholder'))}" placeholder="${t('post.comment_placeholder')}" class="post-detail__comment-input">
276
- <button type="button" id="btn-send-comment" class="post-detail__comment-send">
277
- ${t('common.send')}
278
- </button>
279
- </div>
280
-
281
- <!-- 评论列表 -->
282
- <div id="comments-list" class="post-detail__comments-list">
283
- <div class="post-detail__comments-loading" role="status">
284
- ${t('post.comment_loading')}
285
- </div>
286
- </div>
287
- `;
288
-
289
- // 设置图片沙盒事件(视频帖无沙盒元素,函数会安全返回)
290
- contentArea.querySelectorAll("#images-area button:not([type])").forEach(button => { button.type = "button"; });
291
- setupImageSandboxEvents(contentArea);
292
-
293
- // 视频播放器事件绑定
294
- if (isVideo) {
295
- setupVideoPlayerEvents(contentArea);
296
- const videoEl = contentArea.querySelector('video');
297
- if (videoEl) {
298
- videoEl.addEventListener('error', () => {
299
- console.warn('[VideoPlayer] 视频加载失败');
300
- });
301
- }
302
- }
303
-
304
- // 🚀 SWR 头像渲染:作者头像
305
- const authorRow = contentArea.querySelector("#author-row");
306
- if (authorRow) {
307
- const avatarContainer = authorRow.querySelector(".swr-avatar-container");
308
- const nameEl = authorRow.querySelector(".swr-name");
309
-
310
- const account = post.author;
311
- const cached = getCachedProfile(account);
312
- const avatar = [cached?.avatar, post.author_avatar].map(value => normalizeProfileImageUrl(value)).find(Boolean) || '';
313
- const name = cached?.name || post.author_name || account || '';
314
- const initial = (name || 'U')[0].toUpperCase();
315
-
316
- // 渲染初始头像
317
- if (avatarContainer) {
318
- avatarContainer.innerHTML = _generateAvatarHtml(avatar, name, 40, 'border: 2px solid #444;');
319
- }
320
- if (nameEl) nameEl.textContent = name;
321
-
322
- // 后台静默校对
323
- getProfileWithSWR(account, api.getUserProfile, (profile) => {
324
- if (!current()) return;
325
- const avatarEl = avatarContainer?.querySelector('.swr-avatar');
326
- _updateAvatarElement(avatarEl, profile, 40, 'border: 2px solid #444;');
327
- if (nameEl && profile.name) nameEl.textContent = profile.name;
328
- });
329
- }
330
-
331
- // 绑定作者点击
332
- contentArea.querySelector("#author-row").onclick = (e) => {
333
- if (!current()) return;
334
- // 如果点击的是编辑/删除按钮,不触发作者资料弹窗
335
- if (e.target.id === 'btn-edit-post' || e.target.id === 'btn-delete-post') return;
336
- openOtherUserProfileModal(post.author, currentUser);
337
- };
338
-
339
- // 绑定编辑按钮
340
- const btnEdit = contentArea.querySelector("#btn-edit-post");
341
- if (btnEdit) {
342
- btnEdit.onclick = (e) => {
343
- if (!current()) return;
344
- e.stopPropagation();
345
- window.dispatchEvent(new CustomEvent("comfy-route-edit-post", {
346
- detail: { postData: post, currentUser }
347
- }));
348
- };
349
- }
350
-
351
- // 绑定删除按钮
352
- const btnDelete = contentArea.querySelector("#btn-delete-post");
353
- if (btnDelete) {
354
- btnDelete.onclick = (e) => {
355
- if (!current()) return;
356
- e.stopPropagation();
357
- showDeleteConfirmDialog(post, container);
358
- };
359
- }
360
-
361
- // 绑定互动事件
362
- bindInteractionEvents(contentArea, post, currentUser);
363
-
364
- // 渲染评分组件
365
- const ratingSectionEl = contentArea.querySelector("#rating-section");
366
- if (ratingSectionEl) {
367
- const ratingSection = createRatingStars({
368
- ratingData: {
369
- rating_avg: post.rating_avg || 0,
370
- rating_count: post.rating_count || 0,
371
- rating_dist: post.rating_dist || {"1":0,"2":0,"3":0,"4":0,"5":0},
372
- rated_by: post.rated_by || {}
373
- },
374
- currentUser: currentUser,
375
- authorAccount: post.author,
376
- isCurrent: current,
377
- onRate: async (score) => {
378
- if (!current()) return null;
379
- const res = await api.ratePost(post.id, score);
380
- if (!current()) return null;
381
- if (isValidRatingResponse(res, score)) {
382
- post.rating_avg = res.rating_avg;
383
- post.rating_count = res.rating_count;
384
- post.rating_dist = res.rating_dist;
385
- if (!post.rated_by) post.rated_by = {};
386
- post.rated_by[currentUser.account] = { score: res.user_score, time: Date.now() / 1000 };
387
- // 通知讨论区列表刷新
388
- window.dispatchEvent(new CustomEvent("comfy-posts-refresh"));
389
- }
390
- return res;
391
- },
392
- compact: false
393
- });
394
- ratingSectionEl.replaceWith(ratingSection);
395
- }
396
-
397
- // 加载评论
398
- loadComments(contentArea, postId, currentUser);
399
-
400
- if (fromCache) {
401
- // 🔧 改用统一提示组件 + 词典:原为手写 div(无动画、不排队、不避重叠)且文案硬编码中文;
402
- // 原文「展示的是缓存的数据」与已有词条 feedback.cache_fallback 语义一致,直接复用避免重复建键
403
- showToast(t('feedback.cache_fallback'), 'warning');
404
- } else {
405
- // 👀 记录浏览量(fire-and-forget,不阻塞渲染)
406
- recordPostView(contentArea, postId);
407
- }
408
- }
409
-
410
- /**
411
- * 👀 记录帖子浏览量(带60秒防抖)- 使用公共工具函数
412
- */
413
- async function recordPostView(contentArea, postId) {
414
- if (!isCurrent(contentArea)) return;
415
- await recordView(api.recordPostView, postId, 'post', (res) => {
416
- if (!isCurrent(contentArea)) return;
417
- updatePostViewStats(contentArea, res.views, res.daily_views);
418
- });
419
- }
420
-
421
- /**
422
- * 👀 更新帖子浏览量显示
423
- */
424
- function updatePostViewStats(contentArea, views, dailyViews) {
425
- const totalEl = contentArea.querySelector("#post-view-total");
426
- const dailyEl = contentArea.querySelector("#post-view-daily");
427
- if (totalEl) totalEl.textContent = views || 0;
428
- if (dailyEl) dailyEl.textContent = dailyViews || 0;
429
- }
430
-
431
- /**
432
- * 🔗 绑定互动事件 - 使用公共工具函数
433
- */
434
- function bindInteractionEvents(container, post, currentUser) {
435
- const btnLike = container.querySelector("#btn-like");
436
- const btnFavorite = container.querySelector("#btn-favorite");
437
- const btnTip = container.querySelector("#btn-tip");
438
- const likeCount = container.querySelector("#like-count");
439
- const favoriteCount = container.querySelector("#favorite-count");
440
-
441
- // 点赞 - 使用公共工具函数
442
- btnLike.onclick = () => {
443
- handleToggleLike(api.togglePostLike, post.id, btnLike, likeCount, currentUser, { isCurrent: () => isCurrent(container) });
444
- };
445
-
446
- // 收藏 - 使用公共工具函数
447
- btnFavorite.onclick = () => {
448
- handleToggleFavorite(api.togglePostFavorite, post.id, btnFavorite, favoriteCount, currentUser, { isCurrent: () => isCurrent(container) });
449
- };
450
-
451
- // 打赏
452
- btnTip.onclick = async () => {
453
- if (!isCurrent(container)) return;
454
- if (!currentUser) {
455
- showToast(t('auth.login_required'), "warning");
456
- return;
457
- }
458
- if (currentUser.account === post.author) {
459
- showToast(t('post.tip_self'), "warning");
460
- return;
461
- }
462
- await showTipDialog(post, currentUser, container);
463
- };
464
- }
465
-
466
- /**
467
- * 🎁 显示打赏对话框
468
- */
469
- async function showTipDialog(post, currentUser, container) {
470
- const context = viewContexts.get(container);
471
- if (!isCurrent(container) || context.tipPending) return;
472
- context.tipPending = true;
473
- let balance;
474
- try {
475
- const res = await api.getWallet(currentUser.account, viewContexts.get(container).readOptions);
476
- if (!isCurrent(container)) return;
477
- if (res?.status !== "success" || !Number.isSafeInteger(res.balance) || res.balance < 0) throw new Error();
478
- balance = res.balance;
479
- } catch (_) {
480
- if (isCurrent(container)) { context.tipPending = false; showToast(t('common.error'), "error"); }
481
- return;
482
- }
483
-
484
- const content = document.createElement("div");
485
- content.className = "discussion-modal";
486
- content.innerHTML = `
487
- <div class="post-detail__balance">
488
- ${t('wallet.current_balance')}: <strong class="post-detail__balance-value">${balance}</strong> ${t('wallet.credits')}
489
- </div>
490
- <div class="post-detail__tip-options">
491
- <button type="button" class="post-detail__tip-amount tip-amount" data-amount="10">10 ${t('task.points')}</button>
492
- <button type="button" class="post-detail__tip-amount tip-amount" data-amount="50">50 ${t('task.points')}</button>
493
- <button type="button" class="post-detail__tip-amount tip-amount" data-amount="100">100 ${t('task.points')}</button>
494
- <button type="button" class="post-detail__tip-amount tip-amount" data-amount="500">500 ${t('task.points')}</button>
495
- </div>
496
- <div class="post-detail__modal-actions">
497
- <button type="button" id="tip-cancel" class="post-detail__modal-cancel">${t('common.cancel')}</button>
498
- </div>
499
- `;
500
-
501
- const modal = openOwnedModal(container, `🎁 ${t('post.tip_dialog_title')}`, content, { width: "300px" });
502
-
503
- content.querySelector("#tip-cancel").onclick = () => globalModal.closeModal(modal);
504
- let submitted = false;
505
- const release = content._cleanup;
506
- content._cleanup = () => { release?.(); if (!submitted) context.tipPending = false; };
507
-
508
- content.querySelectorAll(".tip-amount").forEach(btn => {
509
- btn.onclick = async () => {
510
- if (submitted || !isCurrent(container) || !modal?.isConnected) return;
511
- const amount = parseInt(btn.dataset.amount);
512
-
513
- // 本地前置余额校验
514
- if (balance < amount) {
515
- showToast(t('wallet.insufficient_balance'), "warning");
516
- return;
517
- }
518
-
519
- try {
520
- submitted = true;
521
- content.querySelectorAll(".tip-amount").forEach(button => { button.disabled = true; });
522
- const res = await api.tipPost(post.id, amount, false);
523
- if (!isCurrent(container)) return;
524
- if (res?.status !== "success") throw new Error();
525
- context.tipPending = false;
526
- showToast(t('post.tip_success', { amount }), "success");
527
- globalModal.closeModal(modal);
528
- // 刷新页面
529
- loadPostDetail(container.parentElement, post.id, currentUser);
530
- } catch (err) {
531
- if (isCurrent(container)) showToast(t('post.tip_failed'), "error");
532
- }
533
- };
534
- });
535
- }
536
-
537
- /**
538
- * 💬 加载评论
539
- */
540
- async function loadComments(container, postId, currentUser) {
541
- const commentsList = container.querySelector("#comments-list");
542
- const commentInput = container.querySelector("#comment-input");
543
- const sendBtn = container.querySelector("#btn-send-comment");
544
- if (!isCurrent(container)) return;
545
- const context = viewContexts.get(container);
546
- const account = typeof currentUser === "string" ? currentUser : currentUser?.account;
547
- let submission = context.commentSubmission;
548
- if (!submission || submission.input !== commentInput || submission.button !== sendBtn || submission.account !== account) {
549
- submission = context.commentSubmission = { input: commentInput, button: sendBtn, account, phase: "idle", bound: false };
550
- }
551
- const submissionCurrent = () => isCurrent(container) && context.commentSubmission === submission
552
- && commentInput.isConnected && sendBtn.isConnected;
553
- const generation = context.commentGeneration = (context.commentGeneration || 0) + 1;
554
- const current = () => isCurrent(container) && context.commentGeneration === generation && commentsList.isConnected;
555
-
556
- try {
557
- const res = await api.getPostComments(postId, viewContexts.get(container).readOptions);
558
- if (!current()) return;
559
- if (res?.status !== "success" || !Array.isArray(res.data)) throw new Error();
560
- let comments = res.data.filter(value => value && typeof value === "object" && !Array.isArray(value)
561
- && typeof value.author === "string" && typeof value.content === "string");
562
- comments = proxyImages(comments);
563
-
564
- if (comments.length === 0) {
565
- commentsList.innerHTML = `
566
- <div class="post-detail__comments-empty">
567
- ${t('post.no_comments')}
568
- </div>
569
- `;
570
- } else {
571
- // 🚀 SWR 头像渲染:评论作者头像
572
- commentsList.innerHTML = comments.map((c, idx) => {
573
- const cached = getCachedProfile(c.author);
574
- const avatar = [cached?.avatar, c.author_avatar].map(value => normalizeProfileImageUrl(value)).find(Boolean) || '';
575
- const name = cached?.name || c.author_name || c.author || '';
576
-
577
- const avatarHtml = _generateAvatarHtml(avatar, name, 24, 'background: var(--comfy-input-bg);');
578
-
579
- return `
580
- <div id="comment-${idx}" data-account="${escapeHtml(c.author)}" class="post-detail__comment">
581
- <div class="post-detail__comment-author">
582
- <div class="swr-avatar-container">${avatarHtml}</div>
583
- <span class="post-detail__comment-name swr-name">${escapeHtml(name)}</span>
584
- <span class="post-detail__comment-time">${formatTime(c.created_at)}</span>
585
- </div>
586
- <div class="post-detail__comment-content">
587
- ${escapeHtml(c.content)}
588
- </div>
589
- </div>
590
- `;
591
- }).join("");
592
-
593
- // 后台静默校对每个评论作者的头像
594
- comments.forEach((c, idx) => {
595
- getProfileWithSWR(c.author, api.getUserProfile, (profile) => {
596
- if (!current()) return;
597
- const commentEl = commentsList.querySelector(`#comment-${idx}`);
598
- if (!commentEl) return;
599
- const avatarContainer = commentEl.querySelector('.swr-avatar-container');
600
- const nameEl = commentEl.querySelector('.swr-name');
601
- const avatarEl = avatarContainer?.querySelector('.swr-avatar');
602
- _updateAvatarElement(avatarEl, profile, 24, 'background: var(--comfy-input-bg);');
603
- if (nameEl && profile.name) nameEl.textContent = profile.name;
604
- });
605
- });
606
- }
607
- } catch (err) {
608
- if (!current()) return;
609
- commentsList.innerHTML = `
610
- <div class="post-detail__comments-error" role="alert">
611
- ${t('post.comment_load_failed')}
612
- </div>
613
- `;
614
- }
615
-
616
- if (submission.bound || !submissionCurrent()) return;
617
- submission.bound = true;
618
- sendBtn.disabled = false;
619
- commentInput.readOnly = false;
620
- sendBtn.removeAttribute("aria-busy");
621
- commentInput.oninput = () => {
622
- if (submissionCurrent()) commentInput.removeAttribute("aria-invalid");
623
- };
624
- sendBtn.onclick = async () => {
625
- if (!submissionCurrent() || submission.phase !== "idle") return;
626
- if (typeof account !== "string" || !account.trim()) {
627
- showToast(t('auth.login_required'), "warning");
628
- return;
629
- }
630
- const originalInput = commentInput.value, content = originalInput.trim();
631
- if (!content) {
632
- commentInput.setAttribute("aria-invalid", "true");
633
- commentInput.focus();
634
- showToast(t('post.comment_required'), "warning");
635
- return;
636
- }
637
- commentInput.removeAttribute("aria-invalid");
638
- submission.phase = "submitting";
639
- sendBtn.disabled = true;
640
- sendBtn.setAttribute("aria-busy", "true");
641
- sendBtn.textContent = t('common.sending');
642
- try {
643
- const result = await api.addPostComment(postId, content);
644
- if (!submissionCurrent()) return;
645
- if (!isConfirmedDedicatedCommentResponse(result, account, content)) throw new Error("Unconfirmed comment");
646
- submission.phase = "committed";
647
- if (commentInput.value === originalInput) commentInput.value = "";
648
- showToast(t('post.comment_success'), "success");
649
- await loadComments(container, postId, currentUser);
650
- if (submissionCurrent()) submission.phase = "idle";
651
- } catch (err) {
652
- if (!submissionCurrent()) return;
653
- if (submission.phase === "committed") {
654
- try { showToast(t('social.comment_refresh_required'), "warning"); } catch (_) {}
655
- return;
656
- }
657
- const rejected = isDefiniteCommentRejection(err);
658
- submission.phase = rejected ? "idle" : "unconfirmed";
659
- showToast(t(rejected ? 'post.comment_failed' : 'social.comment_unconfirmed'), rejected ? "error" : "warning");
660
- } finally {
661
- if (!submissionCurrent()) return;
662
- sendBtn.disabled = submission.phase !== "idle";
663
- commentInput.readOnly = submission.phase !== "idle";
664
- sendBtn.removeAttribute("aria-busy");
665
- sendBtn.textContent = t(submission.phase === "unconfirmed" ? 'social.comment_pending' : 'common.send');
666
- }
667
- };
668
-
669
- // 回车发送
670
- commentInput.onkeydown = (e) => {
671
- if (isCurrent(container) && !e.defaultPrevented && e.key === "Enter"
672
- && !e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey
673
- && !e.isComposing && e.keyCode !== 229 && !e.repeat
674
- && !commentInput.disabled && !commentInput.readOnly) {
675
- e.preventDefault();
676
- sendBtn.click();
677
- }
678
- };
679
- }
680
-
681
- /**
682
- * 🎁 渲染打赏榜单 - 使用公共工具函数
683
- */
684
- function renderTipBoard(tipBoard) {
685
- return renderCommonTipBoardHTML(tipBoard, 5, t('post.no_tips'));
686
- }
687
-
688
- /**
689
- * 🗑️ 显示删除确认对话框
690
- */
691
- function showDeleteConfirmDialog(post, container) {
692
- if (!isCurrent(container)) return;
693
- const content = document.createElement("div");
694
- content.className = "discussion-modal";
695
- content.innerHTML = `
696
- <div class="post-detail__modal-center">
697
- <div class="post-detail__warning-icon">⚠️</div>
698
- <div class="post-detail__modal-title">${t('post.delete_confirm_title')}</div>
699
- <div class="post-detail__modal-description">${t('post.delete_confirm_desc')}</div>
700
- <div class="post-detail__modal-actions">
701
- <button type="button" id="delete-cancel" class="post-detail__modal-cancel">${t('common.cancel')}</button>
702
- <button type="button" id="delete-confirm" class="post-detail__modal-danger">${t('common.delete')}</button>
703
- </div>
704
- </div>
705
- `;
706
-
707
- const modal = openOwnedModal(container, t('post.delete_confirm_title'), content, { width: "300px" });
708
-
709
- // 取消按钮
710
- content.querySelector("#delete-cancel").onclick = () => globalModal.closeModal(modal);
711
- let submitted = false;
712
-
713
- // 确认删除按钮
714
- content.querySelector("#delete-confirm").onclick = async () => {
715
- if (submitted || !isCurrent(container) || !modal?.isConnected) return;
716
- submitted = true;
717
- try {
718
- const confirmBtn = content.querySelector("#delete-confirm");
719
- confirmBtn.disabled = true;
720
- confirmBtn.textContent = t('common.deleting');
721
-
722
- const res = await api.deletePost(post.id);
723
- if (!isCurrent(container)) return;
724
- if (res?.status !== "success") throw new Error();
725
- showToast(t('post.delete_success'), "success");
726
- globalModal.closeModal(modal);
727
-
728
- // 返回列表页
729
- window.dispatchEvent(new CustomEvent("comfy-route-back"));
730
- } catch (err) {
731
- if (!isCurrent(container)) return;
732
- showToast(t('post.delete_failed'), "error");
733
- submitted = false;
734
- const confirmBtn = content.querySelector("#delete-confirm");
735
- confirmBtn.disabled = false;
736
- confirmBtn.textContent = t('common.delete');
737
- }
738
- };
739
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // 前端页面/post/帖子详情组件.js
2
+ // ==========================================
3
+ // 📄 帖子详情组件
4
+ // ==========================================
5
+ // 功能:图片轮播、正文、互动(点赞/收藏/评论/打赏)
6
+ // 关联文件:
7
+ // - 图片沙盒组件.js (图片轮播)
8
+ // - 评论与互动组件.js (评论功能)
9
+ // - 打赏等级工具.js (打赏榜单)
10
+ // ==========================================
11
+
12
+ import { api, proxyImages, captureRequestSession, requestCancelManager } from "../core/网络请求API.js";
13
+ import { showToast } from "../components/UI交互提示组件.js";
14
+ import { getCoverSandboxHTML, setupImageSandboxEvents, cleanupImageSandbox } from "../components/图片沙盒组件.js";
15
+ import { getVideoPlayerHTML, setupVideoPlayerEvents, cleanupVideoPlayer } from "../components/视频播放器组件.js";
16
+ import { renderTipLevelHTML } from "../components/打赏等级工具.js";
17
+ import { openOtherUserProfileModal } from "../profile/个人中心视图.js";
18
+ import { t } from "../components/用户体验增强.js";
19
+ import { PLACEHOLDERS, getCachedProfile, getProfileWithSWR, normalizeProfileImageUrl, IS_WEB_MODE, API, CACHE } from "../core/全局配置.js";
20
+ import { removeCache, findInListCache } from "../components/性能优化工具.js";
21
+ import { globalModal } from "../components/全局弹窗管理器.js";
22
+ import { recordView, handleToggleLike, handleToggleFavorite, renderTipBoardHTML as renderCommonTipBoardHTML, escapeHtml, formatTime } from "../components/互动工具函数.js"; // 🧹 P2归一:局部 formatTime 已移除
23
+ import { createRatingStars, isValidRatingResponse } from "../social/评论与互动组件.js";
24
+
25
+ import { getUser, eventBus, EVENTS } from "../core/状态管理.js";
26
+ import { createTaskViewScope, subscribeTaskSessionChanges } from "../task/任务视图生命周期.js";
27
+
28
+ import { isConfirmedDedicatedCommentResponse, isDefiniteCommentRejection } from "../social/评论数据工具.js";
29
+
30
+ const viewContexts = new WeakMap();
31
+ let postViewSequence = 0;
32
+ const isCurrent = node => !!viewContexts.get(node)?.scope.isCurrent();
33
+ function openOwnedModal(node, title, content, options) {
34
+ const context = viewContexts.get(node);
35
+ if (!context?.scope.isCurrent()) return null;
36
+ const modal = globalModal.openModal(title, content, options);
37
+ const removeCleanup = context.scope.addCleanup(() => globalModal.closeModal(modal));
38
+ content._cleanup = removeCleanup;
39
+ return modal;
40
+ }
41
+
42
+ /**
43
+ * 🖼️ 生成头像 HTML 字符串
44
+ * @param {string} avatar - 头像 URL
45
+ * @param {string} name - 用户名(用于首字母回退)
46
+ * @param {number} size - 头像尺寸(px)
47
+ * @param {string} [extraStyle] - 额外内联样式(img 时追加)
48
+ */
49
+ function _generateAvatarHtml(avatar, name, size, extraStyle = '') {
50
+ avatar = normalizeProfileImageUrl(avatar);
51
+ const initial = (name || 'U')[0].toUpperCase();
52
+ // 🔒 XSS防护:avatar / name 均来自他人可修改的资料(昵称首字母可能是 < ),写入 HTML 前必须转义
53
+ if (avatar) {
54
+ return `<img alt="" class="swr-avatar post-detail-avatar ${extraStyle === 'border: 2px solid #444;' ? 'post-detail-avatar--border' : extraStyle === 'background: var(--comfy-input-bg);' ? 'post-detail-avatar--panel' : ''}" src="${escapeHtml(avatar)}" style="width: ${size}px; height: ${size}px; ${['border: 2px solid #444;', 'background: var(--comfy-input-bg);'].includes(extraStyle) ? '' : extraStyle}">`;
55
+ }
56
+ const fontSize = Math.max(9, Math.round(size * 0.4));
57
+ return `<div class="swr-avatar post-detail-avatar ${extraStyle === 'border: 2px solid #444;' ? 'post-detail-avatar--border' : extraStyle === 'background: var(--comfy-input-bg);' ? 'post-detail-avatar--panel' : ''}" style="width: ${size}px; height: ${size}px; font-size: ${fontSize}px; font-weight: bold;">${escapeHtml(initial)}</div>`;
58
+ }
59
+
60
+ /**
61
+ * 🔄 用 SWR 回调更新已渲染的头像元素
62
+ * @param {Element} avatarEl - .swr-avatar 元素
63
+ * @param {object} profile - 新的用户 profile
64
+ * @param {number} size - 头像尺寸(px)
65
+ * @param {string} [extraStyle] - img 额外内联样式
66
+ */
67
+ function _updateAvatarElement(avatarEl, profile, size, extraStyle = '') {
68
+ const avatar = normalizeProfileImageUrl(profile?.avatar);
69
+ if (!avatarEl || !avatar) return;
70
+ if (avatarEl.tagName === 'IMG') {
71
+ avatarEl.src = avatar;
72
+ } else {
73
+ avatarEl.outerHTML = `<img alt="" class="swr-avatar post-detail-avatar ${extraStyle === 'border: 2px solid #444;' ? 'post-detail-avatar--border' : extraStyle === 'background: var(--comfy-input-bg);' ? 'post-detail-avatar--panel' : ''}" src="${escapeHtml(avatar)}" style="width: ${size}px; height: ${size}px; ${['border: 2px solid #444;', 'background: var(--comfy-input-bg);'].includes(extraStyle) ? '' : extraStyle}">`;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * 📄 创建帖子详情视图
79
+ */
80
+ export function createPostDetailView(postId, currentUser) {
81
+ const container = document.createElement("div");
82
+ container.className = "discussion-detail";
83
+
84
+ container.innerHTML = `
85
+ <!-- 顶部标题栏 -->
86
+ <div class="post-detail__header">
87
+ <button type="button" id="btn-back-detail" class="post-detail__back">
88
+ ⬅ ${t('common.back')}
89
+ </button>
90
+ <span class="post-detail__heading">${t('post.detail_title')}</span>
91
+ </div>
92
+
93
+ <!-- 内容区域 -->
94
+ <div id="post-content" class="post-detail__body">
95
+ <div class="post-detail__loading" role="status">
96
+ <div class="post-detail__icon">⏳</div>
97
+ ${t('post.loading')}
98
+ </div>
99
+ </div>
100
+ `;
101
+
102
+ const contentArea = container.querySelector("#post-content");
103
+ const owner = currentUser?.account || null;
104
+ const sessionMatches = captureRequestSession();
105
+ const componentId = `post-detail-${++postViewSequence}`;
106
+ let constructing = true, observer;
107
+ const scope = createTaskViewScope({
108
+ sessionMatches: () => sessionMatches() && (getUser()?.account || null) === owner,
109
+ connected: () => constructing || container.isConnected,
110
+ subscribe: notify => subscribeTaskSessionChanges(notify, { target: window, eventBus, events: EVENTS,
111
+ userKey: CACHE.LEGACY_KEYS.USER, tokenKey: CACHE.LEGACY_KEYS.TOKEN }),
112
+ cancelReads: () => requestCancelManager.cancelAll(componentId),
113
+ invalidate: () => contentArea.replaceChildren()
114
+ });
115
+ const context = { scope, tipPending: false,
116
+ readOptions: { componentId, noCache: true, timeout: API.BACKGROUND_TIMEOUT, retries: API.BACKGROUND_RETRIES } };
117
+ viewContexts.set(container, context); viewContexts.set(contentArea, context);
118
+ const handleRouteBack = () => scope.dispose();
119
+ scope.addCleanup(() => {
120
+ cleanupImageSandbox(contentArea);
121
+ cleanupVideoPlayer(contentArea);
122
+ contentArea.querySelectorAll('audio').forEach(audio => { audio.pause(); audio.removeAttribute('src'); audio.load(); });
123
+ contentArea.replaceChildren(); observer?.disconnect();
124
+ window.removeEventListener("comfy-route-back", handleRouteBack);
125
+ });
126
+ container._cleanup = () => scope.dispose();
127
+ container.querySelector("#btn-back-detail").onclick = () => {
128
+ scope.dispose(); window.dispatchEvent(new CustomEvent("comfy-route-back"));
129
+ };
130
+ window.addEventListener("comfy-route-back", handleRouteBack);
131
+ queueMicrotask(() => {
132
+ constructing = false;
133
+ if (!scope.isCurrent()) return;
134
+ observer = new MutationObserver(() => { if (!container.isConnected) scope.dispose(); });
135
+ observer.observe(document.body, { childList: true, subtree: true });
136
+ });
137
+
138
+ // 加载帖子详情
139
+ loadPostDetail(container, postId, currentUser);
140
+
141
+ return container;
142
+ }
143
+
144
+ /**
145
+ * 📥 加载帖子详情
146
+ */
147
+ async function loadPostDetail(container, postId, currentUser) {
148
+ const contentArea = container.querySelector("#post-content");
149
+ if (!isCurrent(container)) return;
150
+ const context = viewContexts.get(container);
151
+ const generation = context.loadGeneration = (context.loadGeneration || 0) + 1;
152
+ const current = () => isCurrent(container) && context.loadGeneration === generation;
153
+
154
+ // 先清理旧的视频播放器全局事件(如果存在)
155
+ if (contentArea) {
156
+ cleanupImageSandbox(contentArea);
157
+ cleanupVideoPlayer(contentArea);
158
+ }
159
+
160
+ let post = null;
161
+ let fromCache = false;
162
+
163
+ try {
164
+ const res = await api.getPostDetail(postId, { ...context.readOptions, noCache: false });
165
+ if (!current()) return;
166
+ post = res.data;
167
+ if (!post || typeof post !== "object" || Array.isArray(post) || post.id !== postId) post = null;
168
+ } catch (err) {
169
+ if (!current()) return;
170
+ // 📴 从列表缓存回退
171
+ const cached = findInListCache("PostsCache_", postId);
172
+ if (cached) {
173
+
174
+ post = cached;
175
+ fromCache = true;
176
+ }
177
+ }
178
+
179
+ if (!post) {
180
+ contentArea.innerHTML = `
181
+ <div class="post-detail__error" role="alert">
182
+ <div class="post-detail__icon">⚠️</div>
183
+ ${t('post.not_exist')}
184
+ </div>
185
+ `;
186
+ return;
187
+ }
188
+
189
+ post = proxyImages(post); // 对帖子数据应用图片代理
190
+
191
+ // 判断是否为视频帖子
192
+ const isVideo = post.post_type === "video" && post.video_url;
193
+
194
+ // 准备图片列表(视频帖也需要 cover_image 作为 poster)
195
+ const images = post.images && post.images.length > 0 ? post.images : (post.cover_image ? [post.cover_image] : []);
196
+
197
+ // 准备媒体展示HTML
198
+ const mediaHTML = isVideo
199
+ ? getVideoPlayerHTML(post.video_url, post.cover_image || '')
200
+ : getCoverSandboxHTML(images);
201
+
202
+ // 检查当前用户是否已点赞/收藏
203
+ const isLiked = post.liked_by?.includes(currentUser?.account) || false;
204
+ const isFavorited = post.favorited_by?.includes(currentUser?.account) || false;
205
+
206
+ contentArea.innerHTML = `
207
+ <!-- 图片展示区 -->
208
+ <div id="images-area" class="post-detail__media">
209
+ ${mediaHTML}
210
+ </div>
211
+
212
+ <!-- 标题 -->
213
+ <div class="post-detail__title">
214
+ ${escapeHtml(post.title)}
215
+ </div>
216
+
217
+ <!-- 原创标识 -->
218
+ ${post.is_original ? `
219
+ <div class="post-detail__original">
220
+ 🎨 ${t('publish.original_no_commercial')}
221
+ </div>
222
+ ` : ''}
223
+
224
+ <!-- 作者信息(SWR 缓存头像) -->
225
+ <div class="post-detail__author post-detail-author-row">
226
+ <button type="button" id="author-row" class="post-detail-author-button"><div class="post-detail__avatar-slot swr-avatar-container"></div>
227
+ <div class="post-detail__identity">
228
+ <div class="post-detail__name swr-name">${escapeHtml(post.author_name || post.author)}</div>
229
+ <div class="post-detail__timestamp">${formatTime(post.created_at)}</div>
230
+ </div></button>
231
+ ${currentUser && currentUser.account === post.author ? `
232
+ <div onclick="event.stopPropagation()" class="post-detail__owner-actions">
233
+ ${IS_WEB_MODE ? '' : `<button type="button" id="btn-edit-post" aria-label="${t('common.edit')}" title="${t('common.edit')}" class="post-detail__edit">✏️</button>`}
234
+ <button type="button" id="btn-delete-post" aria-label="${t('common.delete')}" title="${t('common.delete')}" class="post-detail__delete">🗑️</button>
235
+ </div>
236
+ ` : ''}
237
+ </div>
238
+
239
+ <!-- 正文内容 -->
240
+ <div class="post-detail__content">${escapeHtml(post.content)}</div>
241
+
242
+ <!-- 互动按钮栏 -->
243
+ <div id="interaction-bar" class="post-detail__interactions">
244
+ <button type="button" id="btn-like" aria-label="${t('market.like')}" aria-pressed="${isLiked}" class="post-detail-toggle">
245
+ ❤️ <span id="like-count">${post.likes || 0}</span>
246
+ </button>
247
+ <button type="button" id="btn-favorite" aria-label="${t('market.favorite')}" aria-pressed="${isFavorited}" class="post-detail-toggle">
248
+ 🔖 <span id="favorite-count">${post.favorites || 0}</span>
249
+ </button>
250
+ <button type="button" id="btn-tip" class="post-detail__tip">
251
+ ${t('post.tip_author')}
252
+ </button>
253
+ <!-- 👀 浏览量统计(纯展示) -->
254
+ <div class="post-detail__stats">
255
+ <span class="post-detail__stat">🔥 <span id="post-view-total">${post.views || 0}</span></span>
256
+ <span class="post-detail__stat">📅 <span id="post-view-daily">${post.daily_views || 0}</span></span>
257
+ </div>
258
+ </div>
259
+
260
+ <!-- 评分区域 -->
261
+ <div id="rating-section" class="post-detail__rating"></div>
262
+
263
+ <!-- 打赏榜单 -->
264
+ <div id="tip-board-area" class="post-detail__media">
265
+ ${renderTipBoard(post.tip_board || [])}
266
+ </div>
267
+
268
+ <!-- 评论区 -->
269
+ <div class="post-detail__comments-heading">
270
+ ${t('post.comments_title')} <span class="post-detail__comments-count">(${post.comments || 0})</span>
271
+ </div>
272
+
273
+ <!-- 评论输入框 -->
274
+ <div class="post-detail__comment-composer">
275
+ <input type="text" id="comment-input" required aria-label="${escapeHtml(t('post.comment_placeholder'))}" placeholder="${t('post.comment_placeholder')}" class="post-detail__comment-input">
276
+ <button type="button" id="btn-send-comment" class="post-detail__comment-send">
277
+ ${t('common.send')}
278
+ </button>
279
+ </div>
280
+
281
+ <!-- 评论列表 -->
282
+ <div id="comments-list" class="post-detail__comments-list">
283
+ <div class="post-detail__comments-loading" role="status">
284
+ ${t('post.comment_loading')}
285
+ </div>
286
+ </div>
287
+ `;
288
+
289
+ // 设置图片沙盒事件(视频帖无沙盒元素,函数会安全返回)
290
+ contentArea.querySelectorAll("#images-area button:not([type])").forEach(button => { button.type = "button"; });
291
+ setupImageSandboxEvents(contentArea);
292
+
293
+ // 视频播放器事件绑定
294
+ if (isVideo) {
295
+ setupVideoPlayerEvents(contentArea);
296
+ const videoEl = contentArea.querySelector('video');
297
+ if (videoEl) {
298
+ videoEl.addEventListener('error', () => {
299
+ console.warn('[VideoPlayer] 视频加载失败');
300
+ });
301
+ }
302
+ }
303
+
304
+ // 🚀 SWR 头像渲染:作者头像
305
+ const authorRow = contentArea.querySelector("#author-row");
306
+ if (authorRow) {
307
+ const avatarContainer = authorRow.querySelector(".swr-avatar-container");
308
+ const nameEl = authorRow.querySelector(".swr-name");
309
+
310
+ const account = post.author;
311
+ const cached = getCachedProfile(account);
312
+ const avatar = [cached?.avatar, post.author_avatar].map(value => normalizeProfileImageUrl(value)).find(Boolean) || '';
313
+ const name = cached?.name || post.author_name || account || '';
314
+ const initial = (name || 'U')[0].toUpperCase();
315
+
316
+ // 渲染初始头像
317
+ if (avatarContainer) {
318
+ avatarContainer.innerHTML = _generateAvatarHtml(avatar, name, 40, 'border: 2px solid #444;');
319
+ }
320
+ if (nameEl) nameEl.textContent = name;
321
+
322
+ // 后台静默校对
323
+ getProfileWithSWR(account, api.getUserProfile, (profile) => {
324
+ if (!current()) return;
325
+ const avatarEl = avatarContainer?.querySelector('.swr-avatar');
326
+ _updateAvatarElement(avatarEl, profile, 40, 'border: 2px solid #444;');
327
+ if (nameEl && profile.name) nameEl.textContent = profile.name;
328
+ });
329
+ }
330
+
331
+ // 绑定作者点击
332
+ contentArea.querySelector("#author-row").onclick = (e) => {
333
+ if (!current()) return;
334
+ // 如果点击的是编辑/删除按钮,不触发作者资料弹窗
335
+ if (e.target.id === 'btn-edit-post' || e.target.id === 'btn-delete-post') return;
336
+ openOtherUserProfileModal(post.author, currentUser);
337
+ };
338
+
339
+ // 绑定编辑按钮
340
+ const btnEdit = contentArea.querySelector("#btn-edit-post");
341
+ if (btnEdit) {
342
+ btnEdit.onclick = (e) => {
343
+ if (!current()) return;
344
+ e.stopPropagation();
345
+ window.dispatchEvent(new CustomEvent("comfy-route-edit-post", {
346
+ detail: { postData: post, currentUser }
347
+ }));
348
+ };
349
+ }
350
+
351
+ // 绑定删除按钮
352
+ const btnDelete = contentArea.querySelector("#btn-delete-post");
353
+ if (btnDelete) {
354
+ btnDelete.onclick = (e) => {
355
+ if (!current()) return;
356
+ e.stopPropagation();
357
+ showDeleteConfirmDialog(post, container);
358
+ };
359
+ }
360
+
361
+ // 绑定互动事件
362
+ bindInteractionEvents(contentArea, post, currentUser);
363
+
364
+ // 渲染评分组件
365
+ const ratingSectionEl = contentArea.querySelector("#rating-section");
366
+ if (ratingSectionEl) {
367
+ const ratingSection = createRatingStars({
368
+ ratingData: {
369
+ rating_avg: post.rating_avg || 0,
370
+ rating_count: post.rating_count || 0,
371
+ rating_dist: post.rating_dist || {"1":0,"2":0,"3":0,"4":0,"5":0},
372
+ rated_by: post.rated_by || {}
373
+ },
374
+ currentUser: currentUser,
375
+ authorAccount: post.author,
376
+ isCurrent: current,
377
+ onRate: async (score) => {
378
+ if (!current()) return null;
379
+ const res = await api.ratePost(post.id, score);
380
+ if (!current()) return null;
381
+ if (isValidRatingResponse(res, score)) {
382
+ post.rating_avg = res.rating_avg;
383
+ post.rating_count = res.rating_count;
384
+ post.rating_dist = res.rating_dist;
385
+ if (!post.rated_by) post.rated_by = {};
386
+ post.rated_by[currentUser.account] = { score: res.user_score, time: Date.now() / 1000 };
387
+ // 通知讨论区列表刷新
388
+ window.dispatchEvent(new CustomEvent("comfy-posts-refresh"));
389
+ }
390
+ return res;
391
+ },
392
+ compact: false
393
+ });
394
+ ratingSectionEl.replaceWith(ratingSection);
395
+ }
396
+
397
+ // 加载评论
398
+ loadComments(contentArea, postId, currentUser);
399
+
400
+ if (fromCache) {
401
+ // 🔧 改用统一提示组件 + 词典:原为手写 div(无动画、不排队、不避重叠)且文案硬编码中文;
402
+ // 原文「展示的是缓存的数据」与已有词条 feedback.cache_fallback 语义一致,直接复用避免重复��键
403
+ showToast(t('feedback.cache_fallback'), 'warning');
404
+ } else {
405
+ // 👀 记录浏览量(fire-and-forget,不阻塞渲染)
406
+ recordPostView(contentArea, postId);
407
+ }
408
+ }
409
+
410
+ /**
411
+ * 👀 记录帖子浏览量(带60秒防抖)- 使用公共工具函数
412
+ */
413
+ async function recordPostView(contentArea, postId) {
414
+ if (!isCurrent(contentArea)) return;
415
+ await recordView(api.recordPostView, postId, 'post', (res) => {
416
+ if (!isCurrent(contentArea)) return;
417
+ updatePostViewStats(contentArea, res.views, res.daily_views);
418
+ });
419
+ }
420
+
421
+ /**
422
+ * 👀 更新帖子浏览量显示
423
+ */
424
+ function updatePostViewStats(contentArea, views, dailyViews) {
425
+ const totalEl = contentArea.querySelector("#post-view-total");
426
+ const dailyEl = contentArea.querySelector("#post-view-daily");
427
+ if (totalEl) totalEl.textContent = views || 0;
428
+ if (dailyEl) dailyEl.textContent = dailyViews || 0;
429
+ }
430
+
431
+ /**
432
+ * 🔗 绑定互动事件 - 使用公共工具函数
433
+ */
434
+ function bindInteractionEvents(container, post, currentUser) {
435
+ const btnLike = container.querySelector("#btn-like");
436
+ const btnFavorite = container.querySelector("#btn-favorite");
437
+ const btnTip = container.querySelector("#btn-tip");
438
+ const likeCount = container.querySelector("#like-count");
439
+ const favoriteCount = container.querySelector("#favorite-count");
440
+
441
+ // 点赞 - 使用公共工具函数
442
+ btnLike.onclick = () => {
443
+ handleToggleLike(api.togglePostLike, post.id, btnLike, likeCount, currentUser, { isCurrent: () => isCurrent(container) });
444
+ };
445
+
446
+ // 收藏 - 使用公共工具函数
447
+ btnFavorite.onclick = () => {
448
+ handleToggleFavorite(api.togglePostFavorite, post.id, btnFavorite, favoriteCount, currentUser, { isCurrent: () => isCurrent(container) });
449
+ };
450
+
451
+ // 打赏
452
+ btnTip.onclick = async () => {
453
+ if (!isCurrent(container)) return;
454
+ if (!currentUser) {
455
+ showToast(t('auth.login_required'), "warning");
456
+ return;
457
+ }
458
+ if (currentUser.account === post.author) {
459
+ showToast(t('post.tip_self'), "warning");
460
+ return;
461
+ }
462
+ await showTipDialog(post, currentUser, container);
463
+ };
464
+ }
465
+
466
+ /**
467
+ * 🎁 显示打赏对话框
468
+ */
469
+ async function showTipDialog(post, currentUser, container) {
470
+ const context = viewContexts.get(container);
471
+ if (!isCurrent(container) || context.tipPending) return;
472
+ context.tipPending = true;
473
+ const content = document.createElement("div");
474
+ content.className = "discussion-modal";
475
+ content.setAttribute('aria-busy', 'true');
476
+ const loading = document.createElement('div');
477
+ loading.setAttribute('role', 'status'); loading.setAttribute('aria-live', 'polite');
478
+ loading.textContent = t('common.loading'); content.append(loading);
479
+ const modal = openOwnedModal(container, `🎁 ${t('post.tip_dialog_title')}`, content, { width: "300px" });
480
+ if (!modal) { context.tipPending = false; return; }
481
+ let submitted = false, closed = false;
482
+ context.tipSequence = (context.tipSequence || 0) + 1;
483
+ const walletComponentId = `${context.readOptions.componentId}-tip-${context.tipSequence}`;
484
+ const release = content._cleanup;
485
+ content._cleanup = () => {
486
+ if (closed) return;
487
+ closed = true;
488
+ try { requestCancelManager.cancelAll(walletComponentId); }
489
+ finally { release?.(); if (!submitted) context.tipPending = false; }
490
+ };
491
+ const current = () => !closed && isCurrent(container) && modal.isConnected;
492
+ let balance;
493
+ try {
494
+ const res = await api.getWallet(currentUser.account, { ...context.readOptions, componentId: walletComponentId });
495
+ if (!current()) return;
496
+ if (res?.status !== "success" || !Number.isSafeInteger(res.balance) || res.balance < 0) throw new Error();
497
+ balance = res.balance;
498
+ } catch (_) {
499
+ if (current()) { globalModal.closeModal(modal); showToast(t('common.error'), "error"); }
500
+ return;
501
+ }
502
+ content.setAttribute('aria-busy', 'false');
503
+ content.innerHTML = `
504
+ <div class="post-detail__balance">
505
+ ${t('wallet.current_balance')}: <strong class="post-detail__balance-value">${balance}</strong> ${t('wallet.credits')}
506
+ </div>
507
+ <div class="post-detail__tip-options">
508
+ <button type="button" class="post-detail__tip-amount tip-amount" data-amount="10">10 ${t('task.points')}</button>
509
+ <button type="button" class="post-detail__tip-amount tip-amount" data-amount="50">50 ${t('task.points')}</button>
510
+ <button type="button" class="post-detail__tip-amount tip-amount" data-amount="100">100 ${t('task.points')}</button>
511
+ <button type="button" class="post-detail__tip-amount tip-amount" data-amount="500">500 ${t('task.points')}</button>
512
+ </div>
513
+ <div class="post-detail__modal-actions">
514
+ <button type="button" id="tip-cancel" class="post-detail__modal-cancel">${t('common.cancel')}</button>
515
+ </div>
516
+ `;
517
+
518
+ content.querySelector("#tip-cancel").onclick = () => globalModal.closeModal(modal);
519
+
520
+ content.querySelectorAll(".tip-amount").forEach(btn => {
521
+ btn.onclick = async () => {
522
+ if (submitted || !isCurrent(container) || !modal?.isConnected) return;
523
+ const amount = parseInt(btn.dataset.amount);
524
+
525
+ // 本地前置余额校验
526
+ if (balance < amount) {
527
+ showToast(t('wallet.insufficient_balance'), "warning");
528
+ return;
529
+ }
530
+
531
+ try {
532
+ submitted = true;
533
+ content.querySelectorAll(".tip-amount").forEach(button => { button.disabled = true; });
534
+ const res = await api.tipPost(post.id, amount, false);
535
+ if (!isCurrent(container)) return;
536
+ if (res?.status !== "success") throw new Error();
537
+ context.tipPending = false;
538
+ showToast(t('post.tip_success', { amount }), "success");
539
+ globalModal.closeModal(modal);
540
+ // 刷新页面
541
+ loadPostDetail(container.parentElement, post.id, currentUser);
542
+ } catch (err) {
543
+ if (isCurrent(container)) showToast(t('post.tip_failed'), "error");
544
+ }
545
+ };
546
+ });
547
+ }
548
+
549
+ /**
550
+ * 💬 加载评论
551
+ */
552
+ async function loadComments(container, postId, currentUser) {
553
+ const commentsList = container.querySelector("#comments-list");
554
+ const commentInput = container.querySelector("#comment-input");
555
+ const sendBtn = container.querySelector("#btn-send-comment");
556
+ if (!isCurrent(container)) return;
557
+ const context = viewContexts.get(container);
558
+ const account = typeof currentUser === "string" ? currentUser : currentUser?.account;
559
+ let submission = context.commentSubmission;
560
+ if (!submission || submission.input !== commentInput || submission.button !== sendBtn || submission.account !== account) {
561
+ submission = context.commentSubmission = { input: commentInput, button: sendBtn, account, phase: "idle", bound: false };
562
+ }
563
+ const submissionCurrent = () => isCurrent(container) && context.commentSubmission === submission
564
+ && commentInput.isConnected && sendBtn.isConnected;
565
+ const generation = context.commentGeneration = (context.commentGeneration || 0) + 1;
566
+ const current = () => isCurrent(container) && context.commentGeneration === generation && commentsList.isConnected;
567
+
568
+ try {
569
+ const res = await api.getPostComments(postId, viewContexts.get(container).readOptions);
570
+ if (!current()) return;
571
+ if (res?.status !== "success" || !Array.isArray(res.data)) throw new Error();
572
+ let comments = res.data.filter(value => value && typeof value === "object" && !Array.isArray(value)
573
+ && typeof value.author === "string" && typeof value.content === "string");
574
+ comments = proxyImages(comments);
575
+
576
+ if (comments.length === 0) {
577
+ commentsList.innerHTML = `
578
+ <div class="post-detail__comments-empty">
579
+ ${t('post.no_comments')}
580
+ </div>
581
+ `;
582
+ } else {
583
+ // 🚀 SWR 头像渲染:评论作者头像
584
+ commentsList.innerHTML = comments.map((c, idx) => {
585
+ const cached = getCachedProfile(c.author);
586
+ const avatar = [cached?.avatar, c.author_avatar].map(value => normalizeProfileImageUrl(value)).find(Boolean) || '';
587
+ const name = cached?.name || c.author_name || c.author || '';
588
+
589
+ const avatarHtml = _generateAvatarHtml(avatar, name, 24, 'background: var(--comfy-input-bg);');
590
+
591
+ return `
592
+ <div id="comment-${idx}" data-account="${escapeHtml(c.author)}" class="post-detail__comment">
593
+ <div class="post-detail__comment-author">
594
+ <div class="swr-avatar-container">${avatarHtml}</div>
595
+ <span class="post-detail__comment-name swr-name">${escapeHtml(name)}</span>
596
+ <span class="post-detail__comment-time">${formatTime(c.created_at)}</span>
597
+ </div>
598
+ <div class="post-detail__comment-content">
599
+ ${escapeHtml(c.content)}
600
+ </div>
601
+ </div>
602
+ `;
603
+ }).join("");
604
+
605
+ // 后台静默校对每个评论作者的头像
606
+ comments.forEach((c, idx) => {
607
+ getProfileWithSWR(c.author, api.getUserProfile, (profile) => {
608
+ if (!current()) return;
609
+ const commentEl = commentsList.querySelector(`#comment-${idx}`);
610
+ if (!commentEl) return;
611
+ const avatarContainer = commentEl.querySelector('.swr-avatar-container');
612
+ const nameEl = commentEl.querySelector('.swr-name');
613
+ const avatarEl = avatarContainer?.querySelector('.swr-avatar');
614
+ _updateAvatarElement(avatarEl, profile, 24, 'background: var(--comfy-input-bg);');
615
+ if (nameEl && profile.name) nameEl.textContent = profile.name;
616
+ });
617
+ });
618
+ }
619
+ } catch (err) {
620
+ if (!current()) return;
621
+ commentsList.innerHTML = `
622
+ <div class="post-detail__comments-error" role="alert">
623
+ ${t('post.comment_load_failed')}
624
+ </div>
625
+ `;
626
+ }
627
+
628
+ if (submission.bound || !submissionCurrent()) return;
629
+ submission.bound = true;
630
+ sendBtn.disabled = false;
631
+ commentInput.readOnly = false;
632
+ sendBtn.removeAttribute("aria-busy");
633
+ commentInput.oninput = () => {
634
+ if (submissionCurrent()) commentInput.removeAttribute("aria-invalid");
635
+ };
636
+ sendBtn.onclick = async () => {
637
+ if (!submissionCurrent() || submission.phase !== "idle") return;
638
+ if (typeof account !== "string" || !account.trim()) {
639
+ showToast(t('auth.login_required'), "warning");
640
+ return;
641
+ }
642
+ const originalInput = commentInput.value, content = originalInput.trim();
643
+ if (!content) {
644
+ commentInput.setAttribute("aria-invalid", "true");
645
+ commentInput.focus();
646
+ showToast(t('post.comment_required'), "warning");
647
+ return;
648
+ }
649
+ commentInput.removeAttribute("aria-invalid");
650
+ submission.phase = "submitting";
651
+ sendBtn.disabled = true;
652
+ sendBtn.setAttribute("aria-busy", "true");
653
+ sendBtn.textContent = t('common.sending');
654
+ try {
655
+ const result = await api.addPostComment(postId, content);
656
+ if (!submissionCurrent()) return;
657
+ if (!isConfirmedDedicatedCommentResponse(result, account, content)) throw new Error("Unconfirmed comment");
658
+ submission.phase = "committed";
659
+ if (commentInput.value === originalInput) commentInput.value = "";
660
+ showToast(t('post.comment_success'), "success");
661
+ await loadComments(container, postId, currentUser);
662
+ if (submissionCurrent()) submission.phase = "idle";
663
+ } catch (err) {
664
+ if (!submissionCurrent()) return;
665
+ if (submission.phase === "committed") {
666
+ try { showToast(t('social.comment_refresh_required'), "warning"); } catch (_) {}
667
+ return;
668
+ }
669
+ const rejected = isDefiniteCommentRejection(err);
670
+ submission.phase = rejected ? "idle" : "unconfirmed";
671
+ showToast(t(rejected ? 'post.comment_failed' : 'social.comment_unconfirmed'), rejected ? "error" : "warning");
672
+ } finally {
673
+ if (!submissionCurrent()) return;
674
+ sendBtn.disabled = submission.phase !== "idle";
675
+ commentInput.readOnly = submission.phase !== "idle";
676
+ sendBtn.removeAttribute("aria-busy");
677
+ sendBtn.textContent = t(submission.phase === "unconfirmed" ? 'social.comment_pending' : 'common.send');
678
+ }
679
+ };
680
+
681
+ // 回车发送
682
+ commentInput.onkeydown = (e) => {
683
+ if (isCurrent(container) && !e.defaultPrevented && e.key === "Enter"
684
+ && !e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey
685
+ && !e.isComposing && e.keyCode !== 229 && !e.repeat
686
+ && !commentInput.disabled && !commentInput.readOnly) {
687
+ e.preventDefault();
688
+ sendBtn.click();
689
+ }
690
+ };
691
+ }
692
+
693
+ /**
694
+ * 🎁 渲染打赏榜单 - 使用公共工具函数
695
+ */
696
+ function renderTipBoard(tipBoard) {
697
+ return renderCommonTipBoardHTML(tipBoard, 5, t('post.no_tips'));
698
+ }
699
+
700
+ /**
701
+ * 🗑️ 显示删除确认对话框
702
+ */
703
+ function showDeleteConfirmDialog(post, container) {
704
+ if (!isCurrent(container)) return;
705
+ const content = document.createElement("div");
706
+ content.className = "discussion-modal";
707
+ content.innerHTML = `
708
+ <div class="post-detail__modal-center">
709
+ <div class="post-detail__warning-icon">⚠️</div>
710
+ <div class="post-detail__modal-title">${t('post.delete_confirm_title')}</div>
711
+ <div class="post-detail__modal-description">${t('post.delete_confirm_desc')}</div>
712
+ <div class="post-detail__modal-actions">
713
+ <button type="button" id="delete-cancel" class="post-detail__modal-cancel">${t('common.cancel')}</button>
714
+ <button type="button" id="delete-confirm" class="post-detail__modal-danger">${t('common.delete')}</button>
715
+ </div>
716
+ </div>
717
+ `;
718
+
719
+ const modal = openOwnedModal(container, t('post.delete_confirm_title'), content, { width: "300px" });
720
+
721
+ // 取消按钮
722
+ content.querySelector("#delete-cancel").onclick = () => globalModal.closeModal(modal);
723
+ let submitted = false;
724
+
725
+ // 确认删除按钮
726
+ content.querySelector("#delete-confirm").onclick = async () => {
727
+ if (submitted || !isCurrent(container) || !modal?.isConnected) return;
728
+ submitted = true;
729
+ try {
730
+ const confirmBtn = content.querySelector("#delete-confirm");
731
+ confirmBtn.disabled = true;
732
+ confirmBtn.textContent = t('common.deleting');
733
+
734
+ const res = await api.deletePost(post.id);
735
+ if (!isCurrent(container)) return;
736
+ if (res?.status !== "success") throw new Error();
737
+ showToast(t('post.delete_success'), "success");
738
+ globalModal.closeModal(modal);
739
+
740
+ // 返回列表页
741
+ window.dispatchEvent(new CustomEvent("comfy-route-back"));
742
+ } catch (err) {
743
+ if (!isCurrent(container)) return;
744
+ showToast(t('post.delete_failed'), "error");
745
+ submitted = false;
746
+ const confirmBtn = content.querySelector("#delete-confirm");
747
+ confirmBtn.disabled = false;
748
+ confirmBtn.textContent = t('common.delete');
749
+ }
750
+ };
751
+ }
前端页面/profile/个人中心_赞赏组件.js CHANGED
@@ -49,6 +49,14 @@ export async function openTipModal(currentUser, targetUser, onSuccess, itemId =
49
  observer = new MutationObserver(() => { scope.isCurrent(); });
50
  observer.observe(document.body, { childList: true, subtree: true });
51
  container._cleanup = () => scope.dispose();
 
 
 
 
 
 
 
 
52
  const readOptions = { componentId, noCache: true, timeout: API.BACKGROUND_TIMEOUT, retries: API.BACKGROUND_RETRIES };
53
  let balance;
54
  try {
@@ -81,7 +89,7 @@ export async function openTipModal(currentUser, targetUser, onSuccess, itemId =
81
  anonymousLabel.append(anonymousInput, document.createTextNode(t('tip.anonymous')));
82
  const status = element("div", "tip-dialog__status"); status.hidden = true; status.setAttribute("role", "alert");
83
  const submit = element("button", "tip-dialog__submit", t('tip.confirm')); submit.type = "button";
84
- container.append(balanceRow, amountLabel, amountInput, preview, rules, anonymousLabel, status, submit);
85
  const amountValue = () => amountInput.value.trim() ? Number(amountInput.value) : NaN;
86
  const updatePreview = () => {
87
  if (!scope.isCurrent()) return;
@@ -149,7 +157,6 @@ export async function openTipModal(currentUser, targetUser, onSuccess, itemId =
149
  }
150
  };
151
  updatePreview();
152
- modal = globalModal.openModal(t('tip.dialog_title', { name: targetUser.name || target }), container, { width: "340px" });
153
- loading = false;
154
  return modal;
155
  }
 
49
  observer = new MutationObserver(() => { scope.isCurrent(); });
50
  observer.observe(document.body, { childList: true, subtree: true });
51
  container._cleanup = () => scope.dispose();
52
+ if (!scope.isCurrent()) return;
53
+ container.setAttribute('aria-busy', 'true');
54
+ const loadingStatus = element('div', 'tip-dialog__muted', t('common.loading'));
55
+ loadingStatus.setAttribute('role', 'status');
56
+ loadingStatus.setAttribute('aria-live', 'polite');
57
+ container.append(loadingStatus);
58
+ modal = globalModal.openModal(t('tip.dialog_title', { name: targetUser.name || target }), container, { width: "340px" });
59
+ loading = false;
60
  const readOptions = { componentId, noCache: true, timeout: API.BACKGROUND_TIMEOUT, retries: API.BACKGROUND_RETRIES };
61
  let balance;
62
  try {
 
89
  anonymousLabel.append(anonymousInput, document.createTextNode(t('tip.anonymous')));
90
  const status = element("div", "tip-dialog__status"); status.hidden = true; status.setAttribute("role", "alert");
91
  const submit = element("button", "tip-dialog__submit", t('tip.confirm')); submit.type = "button";
92
+ container.replaceChildren(balanceRow, amountLabel, amountInput, preview, rules, anonymousLabel, status, submit);
93
  const amountValue = () => amountInput.value.trim() ? Number(amountInput.value) : NaN;
94
  const updatePreview = () => {
95
  if (!scope.isCurrent()) return;
 
157
  }
158
  };
159
  updatePreview();
160
+ container.setAttribute('aria-busy', 'false');
 
161
  return modal;
162
  }
前端页面/prompt/提示词详情组件.js CHANGED
@@ -1,896 +1,908 @@
1
- // 前端页面/prompt/提示词详情组件.js
2
- // ==========================================
3
- // 🧩 提示词详情组件
4
- // ==========================================
5
- // 功能:封面媒体展示、提示词正文(付费遮罩+解锁+一键复制)、
6
- // 互动(点赞/收藏/评论/打赏)、作者编辑/删除
7
- // 关联文件:
8
- // - 图片沙盒组件.js (图片展示)
9
- // - 视频播放器组件.js (视频封面)
10
- // - 互动工具函数.js (点赞/收藏/打赏榜/浏览量)
11
- // - 全局弹窗管理器.js (购买确认/打赏/删除弹窗)
12
- // ==========================================
13
-
14
- import { api, proxyImages, captureRequestSession, requestCancelManager } from "../core/网络请求API.js";
15
- import { getPromptPriceState } from "./价格校验.js";
16
- import { showToast } from "../components/UI交互提示组件.js";
17
- import { getCoverSandboxHTML, setupImageSandboxEvents, cleanupImageSandbox } from "../components/图片沙盒组件.js";
18
- import { getVideoPlayerHTML, setupVideoPlayerEvents, cleanupVideoPlayer } from "../components/视频播放器组件.js";
19
- import { openOtherUserProfileModal } from "../profile/个人中心视图.js";
20
- import { t, tIfExists } from "../components/用户体验增强.js";
21
- import { getCachedProfile, getProfileWithSWR, normalizeProfileImageUrl, IS_WEB_MODE, API, CACHE } from "../core/全局配置.js";
22
- import { findInListCache } from "../components/性能优化工具.js";
23
- import { globalModal } from "../components/全局弹窗管理器.js";
24
- import { recordView, handleToggleLike, handleToggleFavorite, renderTipBoardHTML as renderCommonTipBoardHTML, escapeHtml, formatTime } from "../components/互动工具函数.js"; // 🧹 P2归一:局部 formatTime 已移除
25
-
26
- import { getUser, eventBus, EVENTS } from "../core/状态管理.js";
27
- import { createTaskViewScope, subscribeTaskSessionChanges } from "../task/任务视图生命周期.js";
28
-
29
- import { isConfirmedDedicatedCommentResponse, isDefiniteCommentRejection } from "../social/评论数据工具.js";
30
-
31
- const viewContexts = new WeakMap();
32
- let promptViewSequence = 0;
33
- const isCurrent = node => !!viewContexts.get(node)?.scope.isCurrent();
34
- function openOwnedModal(node, title, content, options) {
35
- const context = viewContexts.get(node);
36
- if (!context?.scope.isCurrent()) return null;
37
- const modal = globalModal.openModal(title, content, options);
38
- const removeCleanup = context.scope.addCleanup(() => globalModal.closeModal(modal));
39
- content._cleanup = removeCleanup;
40
- return modal;
41
- }
42
-
43
- /**
44
- * 🖼️ 生成头像 HTML 字符串
45
- */
46
- function _generateAvatarHtml(avatar, name, size, extraStyle = '') {
47
- avatar = normalizeProfileImageUrl(avatar);
48
- const initial = (name || 'U')[0].toUpperCase();
49
- // 🔒 XSS防护:avatar / name 均来自他人可修改的资料(昵称首字母可能是 < ),写入 HTML 前必须转义
50
- if (avatar) {
51
- return `<img class="swr-avatar prompt-detail__avatar ${size === 24 ? 'prompt-detail__avatar--small' : 'prompt-detail__avatar--large'}" alt="" src="${escapeHtml(avatar)}">`;
52
- }
53
- return `<div class="swr-avatar prompt-detail__avatar prompt-detail__avatar--initial ${size === 24 ? 'prompt-detail__avatar--small' : 'prompt-detail__avatar--large'}" aria-hidden="true">${escapeHtml(initial)}</div>`;
54
- }
55
-
56
- /**
57
- * 🔄 用 SWR 回调更新已渲染的头像元素
58
- */
59
- function _updateAvatarElement(avatarEl, profile, size, extraStyle = '') {
60
- const avatar = normalizeProfileImageUrl(profile?.avatar);
61
- if (!avatarEl || !avatar) return;
62
- if (avatarEl.tagName === 'IMG') {
63
- avatarEl.src = avatar;
64
- } else {
65
- avatarEl.outerHTML = `<img class="swr-avatar prompt-detail__avatar ${size === 24 ? 'prompt-detail__avatar--small' : 'prompt-detail__avatar--large'}" alt="" src="${escapeHtml(avatar)}">`;
66
- }
67
- }
68
-
69
- /**
70
- * 📄 创建提示词详情视图
71
- */
72
- export function createPromptDetailView(promptId, currentUser) {
73
- const container = document.createElement("div");
74
- container.className = "prompt-detail";
75
-
76
- container.innerHTML = `
77
- <!-- 顶部标题栏 -->
78
- <div class="prompt-detail__header">
79
- <button type="button" id="btn-back-prompt-detail" class="prompt-detail__button">
80
- ⬅ ${t('common.back')}
81
- </button>
82
- <span class="prompt-detail__heading">${t('prompt.detail_title')}</span>
83
- </div>
84
-
85
- <!-- 内容区域 -->
86
- <div id="prompt-content" class="prompt-detail__content">
87
- <div class="prompt-detail__state" role="status">
88
- <div class="prompt-detail__state-icon" aria-hidden="true">⏳</div>
89
- ${t('prompt.loading')}
90
- </div>
91
- </div>
92
- `;
93
-
94
- const contentArea = container.querySelector("#prompt-content");
95
- const owner = currentUser?.account || null;
96
- const sessionMatches = captureRequestSession();
97
- const componentId = `prompt-detail-${++promptViewSequence}`;
98
- let constructing = true, observer;
99
- const scope = createTaskViewScope({
100
- sessionMatches: () => sessionMatches() && (getUser()?.account || null) === owner,
101
- connected: () => constructing || container.isConnected,
102
- subscribe: notify => subscribeTaskSessionChanges(notify, { target: window, eventBus, events: EVENTS,
103
- userKey: CACHE.LEGACY_KEYS.USER, tokenKey: CACHE.LEGACY_KEYS.TOKEN }),
104
- cancelReads: () => requestCancelManager.cancelAll(componentId),
105
- invalidate: () => contentArea.replaceChildren()
106
- });
107
- const context = { scope, purchasePending: false, tipPending: false,
108
- readOptions: { componentId, noCache: true, timeout: API.BACKGROUND_TIMEOUT, retries: API.BACKGROUND_RETRIES } };
109
- viewContexts.set(container, context); viewContexts.set(contentArea, context);
110
- const handleRouteBack = () => scope.dispose();
111
- scope.addCleanup(() => {
112
- cleanupImageSandbox(contentArea);
113
- cleanupVideoPlayer(contentArea);
114
- contentArea.querySelectorAll('audio').forEach(audio => { audio.pause(); audio.removeAttribute('src'); audio.load(); });
115
- contentArea.replaceChildren(); observer?.disconnect();
116
- window.removeEventListener("comfy-route-back", handleRouteBack);
117
- });
118
- container._cleanup = () => scope.dispose();
119
- container.querySelector("#btn-back-prompt-detail").onclick = () => {
120
- scope.dispose(); window.dispatchEvent(new CustomEvent("comfy-route-back"));
121
- };
122
- window.addEventListener("comfy-route-back", handleRouteBack);
123
- queueMicrotask(() => {
124
- constructing = false;
125
- if (!scope.isCurrent()) return;
126
- observer = new MutationObserver(() => { if (!container.isConnected) scope.dispose(); });
127
- observer.observe(document.body, { childList: true, subtree: true });
128
- });
129
-
130
- // 加载提示词详情
131
- loadPromptDetail(container, promptId, currentUser);
132
-
133
- return container;
134
- }
135
-
136
- /**
137
- * 📥 加载提示词详情
138
- */
139
- async function loadPromptDetail(container, promptId, currentUser) {
140
- const contentArea = container.querySelector("#prompt-content");
141
- if (!isCurrent(container)) return;
142
-
143
- // 先清理旧的视频播放器全局事件(如果存在)
144
- if (contentArea) {
145
- cleanupImageSandbox(contentArea);
146
- cleanupVideoPlayer(contentArea);
147
- }
148
-
149
- let prompt = null;
150
- let fromCache = false;
151
-
152
- try {
153
- const res = await api.getPromptDetail(promptId, viewContexts.get(container).readOptions);
154
- if (!isCurrent(container)) return;
155
- prompt = res.data || res;
156
- if (!prompt || typeof prompt !== "object" || Array.isArray(prompt) || prompt.id !== promptId) prompt = null;
157
- } catch (err) {
158
- if (!isCurrent(container)) return;
159
- // 📴 从列表缓存回退(缓存中无 prompt_text,将显示付费遮罩)
160
- const cached = findInListCache("PromptsCache_", promptId);
161
- if (cached) {
162
- // 列表回退只恢复公开元信息,旧缓存也不得提供正文或拥有状态。
163
- prompt = { ...cached, prompt_text: null, owned: false, locked: true };
164
- fromCache = true;
165
- }
166
- }
167
-
168
- if (!prompt) {
169
- contentArea.innerHTML = `
170
- <div class="prompt-detail__state prompt-detail__state--error" role="alert">
171
- <div class="prompt-detail__state-icon" aria-hidden="true">⚠️</div>
172
- ${t('prompt.not_exist')}
173
- </div>
174
- `;
175
- return;
176
- }
177
-
178
- viewContexts.get(container).scope.addCleanup(() => { prompt.prompt_text = null; });
179
- prompt = proxyImages(prompt); // 对提示词数据应用图片代理
180
-
181
- // 🔓 判断提示词是否可见(免费/作者/已购买 → prompt_text 已由服务端下发)
182
- const isOwner = currentUser && currentUser.account === prompt.author;
183
- const isUnlocked = !fromCache && typeof prompt.prompt_text === "string" && prompt.locked !== true;
184
- const priceState = getPromptPriceState(prompt.price);
185
-
186
- // 判断是否为视频封面
187
- const isVideo = prompt.media_type === "video" && prompt.video_url;
188
-
189
- // 准备媒体展示HTML(图像类型支持多图轮播,旧数据无 images 时回退单封面)
190
- const imageUrls = Array.isArray(prompt.images) && prompt.images.length > 0
191
- ? prompt.images
192
- : (prompt.cover_image ? [prompt.cover_image] : []);
193
- const mediaHTML = isVideo
194
- ? getVideoPlayerHTML(prompt.video_url, prompt.cover_image || '')
195
- : getCoverSandboxHTML(imageUrls);
196
-
197
- // 🎵 音乐提示词音频播放器(图片+音频方案,仅存在 audio_url 时渲染)
198
- const audioHTML = prompt.audio_url ? `
199
- <div class="prompt-detail__audio">
200
- <div class="prompt-detail__muted prompt-detail__audio-label">🎵 ${t('prompt.audio_label')}</div>
201
- <audio controls preload="metadata" src="${escapeHtml(prompt.audio_url)}" class="prompt-detail__audio-player"></audio>
202
- </div>` : '';
203
-
204
- // 检查当前用户是否已点赞/收藏
205
- const isLiked = prompt.liked_by?.includes(currentUser?.account) || false;
206
- const isFavorited = prompt.favorited_by?.includes(currentUser?.account) || false;
207
-
208
- contentArea.innerHTML = `
209
- <!-- 媒体展示区 -->
210
- <div id="media-area" class="prompt-detail__media">
211
- ${mediaHTML}
212
- ${audioHTML}
213
- </div>
214
-
215
- <!-- 标题 -->
216
- <div class="prompt-detail__title">
217
- ${escapeHtml(prompt.title)}
218
- </div>
219
-
220
- <!-- 原创标识 -->
221
- ${prompt.is_original ? `
222
- <div class="prompt-detail__badge prompt-detail__badge--original">
223
- 🎨 ${t('prompt.original_badge')}
224
- </div>
225
- ` : ''}
226
-
227
- <!-- 分类与价格标签 -->
228
- <div class="prompt-detail__badges">
229
- <span class="prompt-detail__badge">${prompt.prompt_type === 'video' ? '🎬' : (prompt.prompt_type === 'music' ? '🎵' : '🎨')} ${escapeHtml(tIfExists('promptcat.' + (prompt.category || ''), prompt.category || ''))}</span>
230
- ${priceState !== 'free'
231
- ? `<span class="prompt-detail__badge prompt-detail__badge--price">💎 ${escapeHtml(priceState === 'paid' ? prompt.price : t('common.unknown'))}</span>`
232
- : `<span class="prompt-detail__badge">${t('prompt.free')}</span>`}
233
- ${prompt.owned ? `<span class="prompt-detail__badge">${t('prompt.owned_mark')}</span>` : ''}
234
- </div>
235
-
236
- <!-- 🏷️ 标签展示 -->
237
- ${(Array.isArray(prompt.tags) && prompt.tags.length) ? `
238
- <div class="prompt-detail__tags">
239
- ${prompt.tags.map(tag => `<span class="prompt-detail__badge">#${escapeHtml(tag)}</span>`).join('')}
240
- </div>` : ''}
241
-
242
- <!-- 作者信息(SWR 缓存头像) -->
243
- <div id="author-row" class="prompt-detail__author">
244
- <button type="button" id="btn-author-prompt" class="prompt-detail__author-link">
245
- <span class="swr-avatar-container prompt-detail__avatar-slot"></span>
246
- <span class="prompt-detail__author-info">
247
- <span class="swr-name prompt-detail__author-name">${escapeHtml(prompt.author_name || prompt.author)}</span>
248
- <span class="prompt-detail__muted prompt-detail__author-time">${escapeHtml(formatTime(prompt.created_at))}</span>
249
- </span>
250
- </button>
251
- ${isOwner ? `
252
- <div class="prompt-detail__author-actions">
253
- ${IS_WEB_MODE ? '' : `<button type="button" id="btn-edit-prompt" class="prompt-detail__button" aria-label="${t('common.edit')}" title="${t('common.edit')}">✏️</button>`}
254
- <button type="button" id="btn-delete-prompt" class="prompt-detail__button prompt-detail__button--danger" aria-label="${t('common.delete')}" title="${t('common.delete')}">🗑️</button>
255
- </div>
256
- ` : ''}
257
- </div>
258
-
259
- <!-- 简介内容 -->
260
- <div class="prompt-detail__description">${escapeHtml((prompt.content || '').trim())}</div>
261
-
262
- <!-- 🔑 提示词正文区(付费遮罩/解锁/复制) -->
263
- <div id="prompt-text-area" class="prompt-detail__text-area"></div>
264
-
265
- <!-- 互动按钮栏 -->
266
- <div id="interaction-bar" class="prompt-detail__interactions">
267
- <button type="button" id="btn-like" aria-label="${t('market.like')}" aria-pressed="${isLiked}" class="prompt-detail__button prompt-detail__interaction">
268
- ❤️ <span id="like-count">${escapeHtml(prompt.likes || 0)}</span>
269
- </button>
270
- <button type="button" id="btn-favorite" aria-label="${t('market.favorite')}" aria-pressed="${isFavorited}" class="prompt-detail__button prompt-detail__interaction">
271
- 🔖 <span id="favorite-count">${escapeHtml(prompt.favorites || 0)}</span>
272
- </button>
273
- <button type="button" id="btn-tip" class="prompt-detail__button prompt-detail__interaction">
274
- ${t('prompt.tip_author')}
275
- </button>
276
- <!-- 👀 浏览量统计(纯展示) -->
277
- <div class="prompt-detail__views">
278
- <span class="prompt-detail__view-count">🔥 <span id="prompt-view-total">${escapeHtml(prompt.views || 0)}</span></span>
279
- <span class="prompt-detail__view-count">📅 <span id="prompt-view-daily">${escapeHtml(prompt.daily_views || 0)}</span></span>
280
- </div>
281
- </div>
282
-
283
- <!-- 打赏榜单 -->
284
- <div id="tip-board-area" class="prompt-detail__tips">
285
- ${renderCommonTipBoardHTML(prompt.tip_board || [], 5, t('prompt.no_tips'))}
286
- </div>
287
-
288
- <!-- 评论区 -->
289
- <div class="prompt-detail__comments-heading">
290
- ${t('prompt.comments_title')} <span class="prompt-detail__muted">(${escapeHtml(prompt.comments || 0)})</span>
291
- </div>
292
-
293
- <!-- 评论输入框 -->
294
- <div class="prompt-detail__comment-form">
295
- <input type="text" id="comment-input" required aria-label="${escapeHtml(t('prompt.comment_placeholder'))}" placeholder="${t('prompt.comment_placeholder')}" class="prompt-detail__comment-input">
296
- <button type="button" id="btn-send-comment" class="prompt-detail__button prompt-detail__button--accent">
297
- ${t('common.send')}
298
- </button>
299
- </div>
300
-
301
- <!-- 评论列表 -->
302
- <div id="comments-list" class="prompt-detail__comments">
303
- <div class="prompt-detail__state prompt-detail__state--compact" role="status" aria-label="${t('prompt.loading')}">⏳</div>
304
- </div>
305
- `;
306
-
307
- // 设置图片沙盒事件(视频无沙盒元素,函数会安全返回)
308
- setupImageSandboxEvents(contentArea);
309
-
310
- // 视频播放器事件绑定
311
- if (isVideo) {
312
- setupVideoPlayerEvents(contentArea);
313
- const videoEl = contentArea.querySelector('video');
314
- if (videoEl) {
315
- videoEl.addEventListener('error', () => {
316
- console.warn('[VideoPlayer] 视频加载失败');
317
- });
318
- }
319
- }
320
-
321
- // 🔑 渲染提示词正文区(解锁 / 付费遮罩)
322
- renderPromptTextArea(contentArea, prompt, currentUser, isUnlocked);
323
-
324
- // 🚀 SWR 头像渲染:作者头像
325
- const authorRow = contentArea.querySelector("#author-row");
326
- if (authorRow) {
327
- const avatarContainer = authorRow.querySelector(".swr-avatar-container");
328
- const nameEl = authorRow.querySelector(".swr-name");
329
-
330
- const account = prompt.author;
331
- const cached = getCachedProfile(account);
332
- const avatar = [cached?.avatar, prompt.author_avatar].map(value => normalizeProfileImageUrl(value)).find(Boolean) || '';
333
- const name = cached?.name || prompt.author_name || account || '';
334
-
335
- if (avatarContainer) {
336
- avatarContainer.innerHTML = _generateAvatarHtml(avatar, name, 40, 'border: 2px solid #444;');
337
- }
338
- if (nameEl) nameEl.textContent = name;
339
-
340
- getProfileWithSWR(account, api.getUserProfile, (profile) => {
341
- if (!isCurrent(contentArea)) return;
342
- const avatarEl = avatarContainer?.querySelector('.swr-avatar');
343
- _updateAvatarElement(avatarEl, profile, 40, 'border: 2px solid #444;');
344
- if (nameEl && profile.name) nameEl.textContent = profile.name;
345
- });
346
- }
347
-
348
- // 绑定作者点击
349
- contentArea.querySelector("#btn-author-prompt").onclick = (e) => {
350
- if (!isCurrent(contentArea)) return;
351
- if (e.target.id === 'btn-edit-prompt' || e.target.id === 'btn-delete-prompt') return;
352
- openOtherUserProfileModal(prompt.author, currentUser);
353
- };
354
-
355
- // 绑定编辑按钮
356
- const btnEdit = contentArea.querySelector("#btn-edit-prompt");
357
- if (btnEdit) {
358
- btnEdit.onclick = (e) => {
359
- if (!isCurrent(contentArea)) return;
360
- e.stopPropagation();
361
- window.dispatchEvent(new CustomEvent("comfy-route-edit-prompt", {
362
- detail: { promptData: prompt, currentUser }
363
- }));
364
- };
365
- }
366
-
367
- // 绑定删除按钮
368
- const btnDelete = contentArea.querySelector("#btn-delete-prompt");
369
- if (btnDelete) {
370
- btnDelete.onclick = (e) => {
371
- if (!isCurrent(container)) return;
372
- e.stopPropagation();
373
- showDeleteConfirmDialog(prompt, container);
374
- };
375
- }
376
-
377
- // 绑定互动事件
378
- bindInteractionEvents(contentArea, prompt, currentUser);
379
-
380
- // 加载评论
381
- loadComments(contentArea, promptId, currentUser);
382
-
383
- if (fromCache) {
384
- showToast(t('prompt.network_cache'), "warning");
385
- } else {
386
- // 👀 记录浏览量(fire-and-forget,不阻塞渲染)
387
- recordPromptView(contentArea, promptId);
388
- }
389
- }
390
-
391
- // ==========================================
392
- // 🔑 提示词正文区(付费遮罩/解锁/复制)
393
- // ==========================================
394
-
395
- /**
396
- * 渲染提示词正文区
397
- */
398
- function renderPromptTextArea(contentArea, prompt, currentUser, isUnlocked) {
399
- if (!isCurrent(contentArea)) return;
400
- const textArea = contentArea.querySelector("#prompt-text-area");
401
- if (!textArea) return;
402
- const priceState = getPromptPriceState(prompt.price);
403
-
404
- if (isUnlocked && prompt.prompt_text) {
405
- // ✅ 已解锁:展示正文 + 一键复制
406
- textArea.innerHTML = `
407
- <div class="prompt-detail__text-box">
408
- <div class="prompt-detail__text-header">
409
- <span class="prompt-detail__text-label">🔑 ${t('prompt.text_label')}</span>
410
- <button type="button" id="btn-copy-prompt" class="prompt-detail__button prompt-detail__button--accent">
411
- 📋 ${t('prompt.copy')}
412
- </button>
413
- </div>
414
- <div class="prompt-detail__text-body">${escapeHtml(prompt.prompt_text)}</div>
415
- </div>
416
- `;
417
- textArea.querySelector("#btn-copy-prompt").onclick = () => {
418
- if (isCurrent(contentArea)) copyToClipboard(prompt.prompt_text, textArea.querySelector("#btn-copy-prompt"), () => isCurrent(contentArea));
419
- };
420
- } else if (priceState === 'paid') {
421
- // 🔒 付费遮罩:模糊预览 + 解锁按钮
422
- textArea.innerHTML = `
423
- <div class="prompt-detail__locked">
424
- <div class="prompt-detail__locked-preview" aria-hidden="true">
425
- ${escapeHtml((prompt.content || '') + '\n' + (prompt.content || ''))}
426
- </div>
427
- <div class="prompt-detail__locked-overlay">
428
- <div class="prompt-detail__lock-icon" aria-hidden="true">🔒</div>
429
- <div class="prompt-detail__lock-title">${t('prompt.locked_title')}</div>
430
- <div class="prompt-detail__muted prompt-detail__locked-description">${t('prompt.locked_desc')}</div>
431
- <button type="button" id="btn-unlock-prompt" class="prompt-detail__button prompt-detail__button--gold">
432
- 💎 ${t('prompt.unlock', { price: prompt.price })}
433
- </button>
434
- </div>
435
- </div>
436
- `;
437
- textArea.querySelector("#btn-unlock-prompt").onclick = () => {
438
- showPurchaseConfirmDialog(contentArea, prompt, currentUser);
439
- };
440
- } else if (priceState === 'unknown') {
441
- const message = document.createElement('p');
442
- message.className = 'prompt-detail__muted';
443
- message.setAttribute('role', 'status');
444
- message.textContent = t('prompt.price_unknown');
445
- textArea.replaceChildren(message);
446
- }
447
- }
448
-
449
- /**
450
- * 📋 一键复制(navigator.clipboard 优先,execCommand 降级)
451
- */
452
- function copyToClipboard(text, btnEl, current) {
453
- const onSuccess = () => {
454
- if (!current()) return;
455
- showToast(t('prompt.copied'), "success");
456
- if (btnEl) {
457
- const original = btnEl.innerHTML;
458
- btnEl.innerHTML = `✅ ${t('prompt.copied')}`;
459
- setTimeout(() => { if (current() && btnEl.isConnected) btnEl.innerHTML = original; }, 1500);
460
- }
461
- };
462
- const onFail = () => current() && showToast(t('prompt.copy_failed'), "error");
463
-
464
- if (navigator.clipboard && navigator.clipboard.writeText) {
465
- navigator.clipboard.writeText(text).then(onSuccess).catch(() => { if (current()) fallbackCopy(text, onSuccess, onFail); });
466
- } else {
467
- fallbackCopy(text, onSuccess, onFail);
468
- }
469
- }
470
-
471
- function fallbackCopy(text, onSuccess, onFail) {
472
- try {
473
- const ta = document.createElement("textarea");
474
- ta.value = text;
475
- ta.style.cssText = "position: fixed; top: -9999px; opacity: 0;";
476
- document.body.appendChild(ta);
477
- ta.select();
478
- const ok = document.execCommand("copy");
479
- ta.remove();
480
- ok ? onSuccess() : onFail();
481
- } catch (e) {
482
- onFail();
483
- }
484
- }
485
-
486
- /**
487
- * 💎 购买确认弹窗
488
- */
489
- async function showPurchaseConfirmDialog(contentArea, prompt, currentUser) {
490
- if (!currentUser) {
491
- showToast(t('prompt.login_first'), "warning");
492
- return;
493
- }
494
-
495
- const context = viewContexts.get(contentArea);
496
- if (!context?.scope.isCurrent() || context.purchasePending) return;
497
- context.purchasePending = true;
498
- let balance;
499
- let expectedPrice;
500
- let purchaseTitle;
501
- try {
502
- const detailRes = await api.getPromptDetail(prompt.id, context.readOptions);
503
- if (!isCurrent(contentArea)) return;
504
- const latest = detailRes?.data;
505
- if (detailRes?.status !== "success" || !latest || Array.isArray(latest) || latest.id !== prompt.id ||
506
- typeof latest.owned !== "boolean" || !Number.isSafeInteger(latest.price) || latest.price < 0) throw new Error();
507
- if (latest.owned) {
508
- if (typeof latest.prompt_text !== "string") throw new Error();
509
- prompt.prompt_text = latest.prompt_text;
510
- prompt.owned = true;
511
- prompt.price = latest.price;
512
- renderPromptTextArea(contentArea, prompt, currentUser, true);
513
- context.purchasePending = false;
514
- return;
515
- }
516
- if (latest.price_confirmation_supported !== true) {
517
- context.purchasePending = false;
518
- showToast(t('acquire.purchase_verification_unavailable'), "warning");
519
- return;
520
- }
521
- expectedPrice = latest.price;
522
- purchaseTitle = typeof latest.title === "string" ? latest.title : prompt.title;
523
- const walletRes = await api.getWallet(currentUser.account, context.readOptions);
524
- if (!isCurrent(contentArea)) return;
525
- if (walletRes?.status !== "success" || !Number.isSafeInteger(walletRes.balance) || walletRes.balance < 0) throw new Error();
526
- balance = walletRes.balance;
527
- } catch (_) {
528
- if (isCurrent(contentArea)) { context.purchasePending = false; showToast(t('common.error'), "error"); }
529
- return;
530
- }
531
-
532
- const content = document.createElement("div");
533
- content.className = "prompt-detail-dialog";
534
- content.innerHTML = `
535
- <div class="prompt-detail-dialog__center">
536
- <div class="prompt-detail-dialog__icon" aria-hidden="true">💎</div>
537
- <div class="prompt-detail-dialog__title">${t('prompt.purchase_confirm_title')}</div>
538
- <div class="prompt-detail-dialog__description">${t('prompt.purchase_confirm_desc', { price: expectedPrice, title: escapeHtml(purchaseTitle) })}</div>
539
- <div class="prompt-detail-dialog__balance">
540
- ${t('prompt.purchase_balance')}: <strong class="prompt-detail-dialog__value">${balance}</strong>
541
- </div>
542
- <div class="prompt-detail-dialog__actions">
543
- <button type="button" id="purchase-cancel" class="prompt-detail__button">${t('common.cancel')}</button>
544
- <button type="button" id="purchase-confirm" class="prompt-detail__button prompt-detail__button--gold">💎 ${t('prompt.unlock', { price: expectedPrice })}</button>
545
- </div>
546
- </div>
547
- `;
548
-
549
- const modal = openOwnedModal(contentArea, `💎 ${t('prompt.purchase_confirm_title')}`, content, { width: "320px" });
550
- let submitted = false;
551
- const release = content._cleanup;
552
- content._cleanup = () => { release?.(); if (!submitted) context.purchasePending = false; };
553
- content.querySelector("#purchase-cancel").onclick = () => globalModal.closeModal(modal);
554
- content.querySelector("#purchase-confirm").onclick = async () => {
555
- if (submitted || !isCurrent(contentArea) || !modal?.isConnected) return;
556
- submitted = true;
557
- const confirmBtn = content.querySelector("#purchase-confirm");
558
- confirmBtn.disabled = true;
559
- confirmBtn.textContent = `⏳ ${t('prompt.purchasing')}`;
560
- try {
561
- const res = await api.purchasePrompt(prompt.id, expectedPrice);
562
- if (!isCurrent(contentArea)) return;
563
- if (res?.status !== "success" || typeof res.prompt_text !== "string") throw new Error();
564
- prompt.prompt_text = res.prompt_text;
565
- prompt.owned = true;
566
- prompt.price = expectedPrice;
567
- renderPromptTextArea(contentArea, prompt, currentUser, true);
568
- globalModal.closeModal(modal);
569
- showToast(t('prompt.purchase_success'), "success");
570
- } catch (error) {
571
- // 请求可能已扣款;保持本视图购买锁,重新进入详情确认拥有状态。
572
- if (!isCurrent(contentArea)) return;
573
- if (error?.code === "purchase_price_changed" || error?.code === "purchase_price_confirmation_required") {
574
- globalModal.closeModal(modal);
575
- showToast(t('acquire.price_changed'), "warning");
576
- } else showToast(t('prompt.purchase_failed'), "error");
577
- }
578
- };
579
- }
580
-
581
- /**
582
- * 👀 记录提示词浏览量(带60秒防抖)- 使用公共工具函数
583
- */
584
- async function recordPromptView(contentArea, promptId) {
585
- if (!isCurrent(contentArea)) return;
586
- await recordView(api.recordPromptView, promptId, 'prompt', (res) => {
587
- if (!isCurrent(contentArea)) return;
588
- const totalEl = contentArea.querySelector("#prompt-view-total");
589
- const dailyEl = contentArea.querySelector("#prompt-view-daily");
590
- if (totalEl) totalEl.textContent = res.views || 0;
591
- if (dailyEl) dailyEl.textContent = res.daily_views || 0;
592
- });
593
- }
594
-
595
- /**
596
- * 🔗 绑定互动事件 - 使用公共工具函数
597
- */
598
- function bindInteractionEvents(container, prompt, currentUser) {
599
- const btnLike = container.querySelector("#btn-like");
600
- const btnFavorite = container.querySelector("#btn-favorite");
601
- const btnTip = container.querySelector("#btn-tip");
602
- const likeCount = container.querySelector("#like-count");
603
- const favoriteCount = container.querySelector("#favorite-count");
604
-
605
- // 点赞
606
- btnLike.onclick = () => {
607
- if (!isCurrent(container)) return;
608
- handleToggleLike(api.togglePromptLike, prompt.id, btnLike, likeCount, currentUser, { isCurrent: () => isCurrent(container) });
609
- };
610
-
611
- // 收藏
612
- btnFavorite.onclick = () => {
613
- if (!isCurrent(container)) return;
614
- handleToggleFavorite(api.togglePromptFavorite, prompt.id, btnFavorite, favoriteCount, currentUser, { isCurrent: () => isCurrent(container) });
615
- };
616
-
617
- // 打赏
618
- btnTip.onclick = async () => {
619
- if (!isCurrent(container)) return;
620
- if (!currentUser) {
621
- showToast(t('auth.login_required'), "warning");
622
- return;
623
- }
624
- if (currentUser.account === prompt.author) {
625
- showToast(t('prompt.tip_self'), "warning");
626
- return;
627
- }
628
- await showTipDialog(prompt, currentUser, container);
629
- };
630
- }
631
-
632
- /**
633
- * 🎁 显示打赏对话框
634
- */
635
- async function showTipDialog(prompt, currentUser, container) {
636
- const context = viewContexts.get(container);
637
- if (!isCurrent(container) || context.tipPending) return;
638
- context.tipPending = true;
639
- let balance;
640
- try {
641
- const res = await api.getWallet(currentUser.account, viewContexts.get(container).readOptions);
642
- if (!isCurrent(container)) return;
643
- if (res?.status !== "success" || !Number.isSafeInteger(res.balance) || res.balance < 0) throw new Error();
644
- balance = res.balance;
645
- } catch (_) {
646
- if (isCurrent(container)) { context.tipPending = false; showToast(t('common.error'), "error"); }
647
- return;
648
- }
649
-
650
- const content = document.createElement("div");
651
- content.className = "prompt-detail-dialog";
652
- content.innerHTML = `
653
- <div class="prompt-detail-dialog__balance">
654
- ${t('wallet.current_balance')}: <strong class="prompt-detail-dialog__value">${balance}</strong> ${t('wallet.credits')}
655
- </div>
656
- <div class="prompt-detail__tip-amounts">
657
- <button type="button" class="tip-amount prompt-detail__button" data-amount="10">10 ${t('task.points')}</button>
658
- <button type="button" class="tip-amount prompt-detail__button" data-amount="50">50 ${t('task.points')}</button>
659
- <button type="button" class="tip-amount prompt-detail__button" data-amount="100">100 ${t('task.points')}</button>
660
- <button type="button" class="tip-amount prompt-detail__button" data-amount="500">500 ${t('task.points')}</button>
661
- </div>
662
- <div class="prompt-detail-dialog__actions">
663
- <button type="button" id="tip-cancel" class="prompt-detail__button">${t('common.cancel')}</button>
664
- </div>
665
- `;
666
-
667
- const modal = openOwnedModal(container, `🎁 ${t('prompt.tip_dialog_title')}`, content, { width: "300px" });
668
-
669
- content.querySelector("#tip-cancel").onclick = () => globalModal.closeModal(modal);
670
- let submitted = false;
671
- const release = content._cleanup;
672
- content._cleanup = () => { release?.(); if (!submitted) context.tipPending = false; };
673
-
674
- content.querySelectorAll(".tip-amount").forEach(btn => {
675
- btn.onclick = async () => {
676
- if (submitted || !isCurrent(container) || !modal?.isConnected) return;
677
- const amount = parseInt(btn.dataset.amount);
678
-
679
- // 本地前置余额校验
680
- if (balance < amount) {
681
- showToast(t('wallet.insufficient_balance'), "warning");
682
- return;
683
- }
684
-
685
- try {
686
- submitted = true;
687
- content.querySelectorAll(".tip-amount").forEach(button => { button.disabled = true; });
688
- const res = await api.tipPrompt(prompt.id, amount, false);
689
- if (!isCurrent(container)) return;
690
- if (res?.status !== "success") throw new Error();
691
- context.tipPending = false;
692
- showToast(t('prompt.tip_success', { amount }), "success");
693
- globalModal.closeModal(modal);
694
- // 刷新页面
695
- loadPromptDetail(container.parentElement, prompt.id, currentUser);
696
- } catch (err) {
697
- if (isCurrent(container)) showToast(t('prompt.tip_failed'), "error");
698
- }
699
- };
700
- });
701
- }
702
-
703
- /**
704
- * 💬 加载评论
705
- */
706
- async function loadComments(container, promptId, currentUser) {
707
- const commentsList = container.querySelector("#comments-list");
708
- const commentInput = container.querySelector("#comment-input");
709
- const sendBtn = container.querySelector("#btn-send-comment");
710
- if (!isCurrent(container)) return;
711
- const context = viewContexts.get(container);
712
- const account = typeof currentUser === "string" ? currentUser : currentUser?.account;
713
- let submission = context.commentSubmission;
714
- if (!submission || submission.input !== commentInput || submission.button !== sendBtn || submission.account !== account) {
715
- submission = context.commentSubmission = { input: commentInput, button: sendBtn, account, phase: "idle", bound: false };
716
- }
717
- const submissionCurrent = () => isCurrent(container) && context.commentSubmission === submission
718
- && commentInput.isConnected && sendBtn.isConnected;
719
- const generation = context.commentGeneration = (context.commentGeneration || 0) + 1;
720
- const current = () => isCurrent(container) && context.commentGeneration === generation && commentsList.isConnected;
721
-
722
- try {
723
- const res = await api.getPromptComments(promptId, viewContexts.get(container).readOptions);
724
- if (!current()) return;
725
- if (res?.status !== "success" || !Array.isArray(res.data)) throw new Error();
726
- let comments = res.data.filter(value => value && typeof value === "object" && !Array.isArray(value)
727
- && typeof value.author === "string" && typeof value.content === "string");
728
- comments = proxyImages(comments);
729
-
730
- if (comments.length === 0) {
731
- commentsList.innerHTML = `
732
- <div class="prompt-detail__state prompt-detail__state--compact" role="status">
733
- ${t('prompt.no_comments')}
734
- </div>
735
- `;
736
- } else {
737
- // 🚀 SWR 头像渲染:评论作者头像
738
- commentsList.innerHTML = comments.map((c, idx) => {
739
- const cached = getCachedProfile(c.author);
740
- const avatar = [cached?.avatar, c.author_avatar].map(value => normalizeProfileImageUrl(value)).find(Boolean) || '';
741
- const name = cached?.name || c.author_name || c.author || '';
742
-
743
- const avatarHtml = _generateAvatarHtml(avatar, name, 24, 'background: var(--comfy-input-bg);');
744
-
745
- return `
746
- <div id="comment-${idx}" data-account="${escapeHtml(c.author)}" class="prompt-detail__comment">
747
- <div class="prompt-detail__comment-header">
748
- <div class="swr-avatar-container">${avatarHtml}</div>
749
- <span class="swr-name prompt-detail__comment-name">${escapeHtml(name)}</span>
750
- <span class="prompt-detail__muted prompt-detail__comment-time">${escapeHtml(formatTime(c.created_at))}</span>
751
- </div>
752
- <div class="prompt-detail__comment-body">
753
- ${escapeHtml(c.content)}
754
- </div>
755
- </div>
756
- `;
757
- }).join("");
758
-
759
- // 后台静默校对每个评论作者的头像
760
- comments.forEach((c, idx) => {
761
- getProfileWithSWR(c.author, api.getUserProfile, (profile) => {
762
- if (!current()) return;
763
- const commentEl = commentsList.querySelector(`#comment-${idx}`);
764
- if (!commentEl) return;
765
- const avatarContainer = commentEl.querySelector('.swr-avatar-container');
766
- const nameEl = commentEl.querySelector('.swr-name');
767
- const avatarEl = avatarContainer?.querySelector('.swr-avatar');
768
- _updateAvatarElement(avatarEl, profile, 24, 'background: var(--comfy-input-bg);');
769
- if (nameEl && profile.name) nameEl.textContent = profile.name;
770
- });
771
- });
772
- }
773
- } catch (err) {
774
- if (!current()) return;
775
- commentsList.innerHTML = `
776
- <div class="prompt-detail__state prompt-detail__state--compact prompt-detail__state--error" role="alert">
777
- ${t('prompt.load_failed')}
778
- </div>
779
- `;
780
- }
781
-
782
- if (submission.bound || !submissionCurrent()) return;
783
- submission.bound = true;
784
- sendBtn.disabled = false;
785
- commentInput.readOnly = false;
786
- sendBtn.removeAttribute("aria-busy");
787
- commentInput.oninput = () => {
788
- if (submissionCurrent()) commentInput.removeAttribute("aria-invalid");
789
- };
790
- sendBtn.onclick = async () => {
791
- if (!submissionCurrent() || submission.phase !== "idle") return;
792
- if (typeof account !== "string" || !account.trim()) {
793
- showToast(t('auth.login_required'), "warning");
794
- return;
795
- }
796
- const originalInput = commentInput.value, content = originalInput.trim();
797
- if (!content) {
798
- commentInput.setAttribute("aria-invalid", "true");
799
- commentInput.focus();
800
- showToast(t('prompt.comment_required'), "warning");
801
- return;
802
- }
803
- commentInput.removeAttribute("aria-invalid");
804
- submission.phase = "submitting";
805
- sendBtn.disabled = true;
806
- sendBtn.setAttribute("aria-busy", "true");
807
- sendBtn.textContent = t('common.sending');
808
- try {
809
- const result = await api.addPromptComment(promptId, content);
810
- if (!submissionCurrent()) return;
811
- if (!isConfirmedDedicatedCommentResponse(result, account, content)) throw new Error("Unconfirmed comment");
812
- submission.phase = "committed";
813
- if (commentInput.value === originalInput) commentInput.value = "";
814
- showToast(t('prompt.comment_success'), "success");
815
- await loadComments(container, promptId, currentUser);
816
- if (submissionCurrent()) submission.phase = "idle";
817
- } catch (err) {
818
- if (!submissionCurrent()) return;
819
- if (submission.phase === "committed") {
820
- try { showToast(t('social.comment_refresh_required'), "warning"); } catch (_) {}
821
- return;
822
- }
823
- const rejected = isDefiniteCommentRejection(err);
824
- submission.phase = rejected ? "idle" : "unconfirmed";
825
- showToast(t(rejected ? 'prompt.comment_failed' : 'social.comment_unconfirmed'), rejected ? "error" : "warning");
826
- } finally {
827
- if (!submissionCurrent()) return;
828
- sendBtn.disabled = submission.phase !== "idle";
829
- commentInput.readOnly = submission.phase !== "idle";
830
- sendBtn.removeAttribute("aria-busy");
831
- sendBtn.textContent = t(submission.phase === "unconfirmed" ? 'social.comment_pending' : 'common.send');
832
- }
833
- };
834
-
835
- // 回车发送
836
- commentInput.onkeydown = (e) => {
837
- if (isCurrent(container) && !e.defaultPrevented && e.key === "Enter"
838
- && !e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey
839
- && !e.isComposing && e.keyCode !== 229 && !e.repeat
840
- && !commentInput.disabled && !commentInput.readOnly) {
841
- e.preventDefault();
842
- sendBtn.click();
843
- }
844
- };
845
- }
846
-
847
- /**
848
- * 🗑️ 显示删除确认对话框
849
- */
850
- function showDeleteConfirmDialog(prompt, container) {
851
- if (!isCurrent(container)) return;
852
- const content = document.createElement("div");
853
- content.className = "prompt-detail-dialog";
854
- content.innerHTML = `
855
- <div class="prompt-detail-dialog__center">
856
- <div class="prompt-detail-dialog__icon" aria-hidden="true">⚠️</div>
857
- <div class="prompt-detail-dialog__title">${t('prompt.delete_confirm')}</div>
858
- <div class="prompt-detail-dialog__description">${escapeHtml(prompt.title)}</div>
859
- <div class="prompt-detail-dialog__actions">
860
- <button type="button" id="delete-cancel" class="prompt-detail__button">${t('common.cancel')}</button>
861
- <button type="button" id="delete-confirm" class="prompt-detail__button prompt-detail__button--danger">${t('common.delete')}</button>
862
- </div>
863
- </div>
864
- `;
865
-
866
- const modal = openOwnedModal(container, t('prompt.delete_confirm'), content, { width: "300px" });
867
-
868
- content.querySelector("#delete-cancel").onclick = () => globalModal.closeModal(modal);
869
- let submitted = false;
870
-
871
- content.querySelector("#delete-confirm").onclick = async () => {
872
- if (submitted || !isCurrent(container) || !modal?.isConnected) return;
873
- submitted = true;
874
- try {
875
- const confirmBtn = content.querySelector("#delete-confirm");
876
- confirmBtn.disabled = true;
877
- confirmBtn.textContent = t('common.deleting');
878
-
879
- await api.deletePrompt(prompt.id);
880
- if (!isCurrent(container)) return;
881
- showToast(t('prompt.deleted'), "success");
882
- globalModal.closeModal(modal);
883
-
884
- // 通知列表刷新并返回
885
- window.dispatchEvent(new CustomEvent("comfy-prompts-refresh"));
886
- window.dispatchEvent(new CustomEvent("comfy-route-back"));
887
- } catch (err) {
888
- if (!isCurrent(container)) return;
889
- showToast(t('common.error'), "error");
890
- submitted = false;
891
- const confirmBtn = content.querySelector("#delete-confirm");
892
- confirmBtn.disabled = false;
893
- confirmBtn.textContent = t('common.delete');
894
- }
895
- };
896
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // 前端页面/prompt/提示词详情组件.js
2
+ // ==========================================
3
+ // 🧩 提示词详情组件
4
+ // ==========================================
5
+ // 功能:封面媒体展示、提示词正文(付费遮罩+解锁+一键复制)、
6
+ // 互动(点赞/收藏/评论/打赏)、作者编辑/删除
7
+ // 关联文件:
8
+ // - 图片沙盒组件.js (图片展示)
9
+ // - 视频播放器组件.js (视频封面)
10
+ // - 互动工具函数.js (点赞/收藏/打赏榜/浏览量)
11
+ // - 全局弹窗管理器.js (购买确认/打赏/删除弹窗)
12
+ // ==========================================
13
+
14
+ import { api, proxyImages, captureRequestSession, requestCancelManager } from "../core/网络请求API.js";
15
+ import { getPromptPriceState } from "./价格校验.js";
16
+ import { showToast } from "../components/UI交互提示组件.js";
17
+ import { getCoverSandboxHTML, setupImageSandboxEvents, cleanupImageSandbox } from "../components/图片沙盒组件.js";
18
+ import { getVideoPlayerHTML, setupVideoPlayerEvents, cleanupVideoPlayer } from "../components/视频播放器组件.js";
19
+ import { openOtherUserProfileModal } from "../profile/个人中心视图.js";
20
+ import { t, tIfExists } from "../components/用户体验增强.js";
21
+ import { getCachedProfile, getProfileWithSWR, normalizeProfileImageUrl, IS_WEB_MODE, API, CACHE } from "../core/全局配置.js";
22
+ import { findInListCache } from "../components/性能优化工具.js";
23
+ import { globalModal } from "../components/全局弹窗管理器.js";
24
+ import { recordView, handleToggleLike, handleToggleFavorite, renderTipBoardHTML as renderCommonTipBoardHTML, escapeHtml, formatTime } from "../components/互动工具函数.js"; // 🧹 P2归一:局部 formatTime 已移除
25
+
26
+ import { getUser, eventBus, EVENTS } from "../core/状态管理.js";
27
+ import { createTaskViewScope, subscribeTaskSessionChanges } from "../task/任务视图生命周期.js";
28
+
29
+ import { isConfirmedDedicatedCommentResponse, isDefiniteCommentRejection } from "../social/评论数据工具.js";
30
+
31
+ const viewContexts = new WeakMap();
32
+ let promptViewSequence = 0;
33
+ const isCurrent = node => !!viewContexts.get(node)?.scope.isCurrent();
34
+ function openOwnedModal(node, title, content, options) {
35
+ const context = viewContexts.get(node);
36
+ if (!context?.scope.isCurrent()) return null;
37
+ const modal = globalModal.openModal(title, content, options);
38
+ const removeCleanup = context.scope.addCleanup(() => globalModal.closeModal(modal));
39
+ content._cleanup = removeCleanup;
40
+ return modal;
41
+ }
42
+
43
+ /**
44
+ * 🖼️ 生成头像 HTML 字符串
45
+ */
46
+ function _generateAvatarHtml(avatar, name, size, extraStyle = '') {
47
+ avatar = normalizeProfileImageUrl(avatar);
48
+ const initial = (name || 'U')[0].toUpperCase();
49
+ // 🔒 XSS防护:avatar / name 均来自他人可修改的资料(昵称首字母可能是 < ),写入 HTML 前必须转义
50
+ if (avatar) {
51
+ return `<img class="swr-avatar prompt-detail__avatar ${size === 24 ? 'prompt-detail__avatar--small' : 'prompt-detail__avatar--large'}" alt="" src="${escapeHtml(avatar)}">`;
52
+ }
53
+ return `<div class="swr-avatar prompt-detail__avatar prompt-detail__avatar--initial ${size === 24 ? 'prompt-detail__avatar--small' : 'prompt-detail__avatar--large'}" aria-hidden="true">${escapeHtml(initial)}</div>`;
54
+ }
55
+
56
+ /**
57
+ * 🔄 用 SWR 回调更新已渲染的头像元素
58
+ */
59
+ function _updateAvatarElement(avatarEl, profile, size, extraStyle = '') {
60
+ const avatar = normalizeProfileImageUrl(profile?.avatar);
61
+ if (!avatarEl || !avatar) return;
62
+ if (avatarEl.tagName === 'IMG') {
63
+ avatarEl.src = avatar;
64
+ } else {
65
+ avatarEl.outerHTML = `<img class="swr-avatar prompt-detail__avatar ${size === 24 ? 'prompt-detail__avatar--small' : 'prompt-detail__avatar--large'}" alt="" src="${escapeHtml(avatar)}">`;
66
+ }
67
+ }
68
+
69
+ /**
70
+ * 📄 创建提示词详情视图
71
+ */
72
+ export function createPromptDetailView(promptId, currentUser) {
73
+ const container = document.createElement("div");
74
+ container.className = "prompt-detail";
75
+
76
+ container.innerHTML = `
77
+ <!-- 顶部标题栏 -->
78
+ <div class="prompt-detail__header">
79
+ <button type="button" id="btn-back-prompt-detail" class="prompt-detail__button">
80
+ ⬅ ${t('common.back')}
81
+ </button>
82
+ <span class="prompt-detail__heading">${t('prompt.detail_title')}</span>
83
+ </div>
84
+
85
+ <!-- 内容区域 -->
86
+ <div id="prompt-content" class="prompt-detail__content">
87
+ <div class="prompt-detail__state" role="status">
88
+ <div class="prompt-detail__state-icon" aria-hidden="true">⏳</div>
89
+ ${t('prompt.loading')}
90
+ </div>
91
+ </div>
92
+ `;
93
+
94
+ const contentArea = container.querySelector("#prompt-content");
95
+ const owner = currentUser?.account || null;
96
+ const sessionMatches = captureRequestSession();
97
+ const componentId = `prompt-detail-${++promptViewSequence}`;
98
+ let constructing = true, observer;
99
+ const scope = createTaskViewScope({
100
+ sessionMatches: () => sessionMatches() && (getUser()?.account || null) === owner,
101
+ connected: () => constructing || container.isConnected,
102
+ subscribe: notify => subscribeTaskSessionChanges(notify, { target: window, eventBus, events: EVENTS,
103
+ userKey: CACHE.LEGACY_KEYS.USER, tokenKey: CACHE.LEGACY_KEYS.TOKEN }),
104
+ cancelReads: () => requestCancelManager.cancelAll(componentId),
105
+ invalidate: () => contentArea.replaceChildren()
106
+ });
107
+ const context = { scope, purchasePending: false, tipPending: false,
108
+ readOptions: { componentId, noCache: true, timeout: API.BACKGROUND_TIMEOUT, retries: API.BACKGROUND_RETRIES } };
109
+ viewContexts.set(container, context); viewContexts.set(contentArea, context);
110
+ const handleRouteBack = () => scope.dispose();
111
+ scope.addCleanup(() => {
112
+ cleanupImageSandbox(contentArea);
113
+ cleanupVideoPlayer(contentArea);
114
+ contentArea.querySelectorAll('audio').forEach(audio => { audio.pause(); audio.removeAttribute('src'); audio.load(); });
115
+ contentArea.replaceChildren(); observer?.disconnect();
116
+ window.removeEventListener("comfy-route-back", handleRouteBack);
117
+ });
118
+ container._cleanup = () => scope.dispose();
119
+ container.querySelector("#btn-back-prompt-detail").onclick = () => {
120
+ scope.dispose(); window.dispatchEvent(new CustomEvent("comfy-route-back"));
121
+ };
122
+ window.addEventListener("comfy-route-back", handleRouteBack);
123
+ queueMicrotask(() => {
124
+ constructing = false;
125
+ if (!scope.isCurrent()) return;
126
+ observer = new MutationObserver(() => { if (!container.isConnected) scope.dispose(); });
127
+ observer.observe(document.body, { childList: true, subtree: true });
128
+ });
129
+
130
+ // 加载提示词详情
131
+ loadPromptDetail(container, promptId, currentUser);
132
+
133
+ return container;
134
+ }
135
+
136
+ /**
137
+ * 📥 加载提示词详情
138
+ */
139
+ async function loadPromptDetail(container, promptId, currentUser) {
140
+ const contentArea = container.querySelector("#prompt-content");
141
+ if (!isCurrent(container)) return;
142
+
143
+ // 先清理旧的视频播放器全局事件(如果存在)
144
+ if (contentArea) {
145
+ cleanupImageSandbox(contentArea);
146
+ cleanupVideoPlayer(contentArea);
147
+ }
148
+
149
+ let prompt = null;
150
+ let fromCache = false;
151
+
152
+ try {
153
+ const res = await api.getPromptDetail(promptId, viewContexts.get(container).readOptions);
154
+ if (!isCurrent(container)) return;
155
+ prompt = res.data || res;
156
+ if (!prompt || typeof prompt !== "object" || Array.isArray(prompt) || prompt.id !== promptId) prompt = null;
157
+ } catch (err) {
158
+ if (!isCurrent(container)) return;
159
+ // 📴 从列表缓存回退(缓存中无 prompt_text,将显示付费遮罩)
160
+ const cached = findInListCache("PromptsCache_", promptId);
161
+ if (cached) {
162
+ // 列表回退只恢复公开元信息,旧缓存也不得提供正文或拥有状态。
163
+ prompt = { ...cached, prompt_text: null, owned: false, locked: true };
164
+ fromCache = true;
165
+ }
166
+ }
167
+
168
+ if (!prompt) {
169
+ contentArea.innerHTML = `
170
+ <div class="prompt-detail__state prompt-detail__state--error" role="alert">
171
+ <div class="prompt-detail__state-icon" aria-hidden="true">⚠️</div>
172
+ ${t('prompt.not_exist')}
173
+ </div>
174
+ `;
175
+ return;
176
+ }
177
+
178
+ viewContexts.get(container).scope.addCleanup(() => { prompt.prompt_text = null; });
179
+ prompt = proxyImages(prompt); // 对提示词数据应用图片代理
180
+
181
+ // 🔓 判断提示词是否可见(免费/作者/已购买 → prompt_text 已由服务端下发)
182
+ const isOwner = currentUser && currentUser.account === prompt.author;
183
+ const isUnlocked = !fromCache && typeof prompt.prompt_text === "string" && prompt.locked !== true;
184
+ const priceState = getPromptPriceState(prompt.price);
185
+
186
+ // 判断是否为视频封面
187
+ const isVideo = prompt.media_type === "video" && prompt.video_url;
188
+
189
+ // 准备媒体展示HTML(图像类型支持多图轮播,旧数据无 images 时回退单封面)
190
+ const imageUrls = Array.isArray(prompt.images) && prompt.images.length > 0
191
+ ? prompt.images
192
+ : (prompt.cover_image ? [prompt.cover_image] : []);
193
+ const mediaHTML = isVideo
194
+ ? getVideoPlayerHTML(prompt.video_url, prompt.cover_image || '')
195
+ : getCoverSandboxHTML(imageUrls);
196
+
197
+ // 🎵 音乐提示词音频播放器(图片+音频方案,仅存在 audio_url 时渲染)
198
+ const audioHTML = prompt.audio_url ? `
199
+ <div class="prompt-detail__audio">
200
+ <div class="prompt-detail__muted prompt-detail__audio-label">🎵 ${t('prompt.audio_label')}</div>
201
+ <audio controls preload="metadata" src="${escapeHtml(prompt.audio_url)}" class="prompt-detail__audio-player"></audio>
202
+ </div>` : '';
203
+
204
+ // 检查当前用户是否已点赞/收藏
205
+ const isLiked = prompt.liked_by?.includes(currentUser?.account) || false;
206
+ const isFavorited = prompt.favorited_by?.includes(currentUser?.account) || false;
207
+
208
+ contentArea.innerHTML = `
209
+ <!-- 媒体展示区 -->
210
+ <div id="media-area" class="prompt-detail__media">
211
+ ${mediaHTML}
212
+ ${audioHTML}
213
+ </div>
214
+
215
+ <!-- 标题 -->
216
+ <div class="prompt-detail__title">
217
+ ${escapeHtml(prompt.title)}
218
+ </div>
219
+
220
+ <!-- 原创标识 -->
221
+ ${prompt.is_original ? `
222
+ <div class="prompt-detail__badge prompt-detail__badge--original">
223
+ 🎨 ${t('prompt.original_badge')}
224
+ </div>
225
+ ` : ''}
226
+
227
+ <!-- 分类与价格标签 -->
228
+ <div class="prompt-detail__badges">
229
+ <span class="prompt-detail__badge">${prompt.prompt_type === 'video' ? '🎬' : (prompt.prompt_type === 'music' ? '🎵' : '🎨')} ${escapeHtml(tIfExists('promptcat.' + (prompt.category || ''), prompt.category || ''))}</span>
230
+ ${priceState !== 'free'
231
+ ? `<span class="prompt-detail__badge prompt-detail__badge--price">💎 ${escapeHtml(priceState === 'paid' ? prompt.price : t('common.unknown'))}</span>`
232
+ : `<span class="prompt-detail__badge">${t('prompt.free')}</span>`}
233
+ ${prompt.owned ? `<span class="prompt-detail__badge">${t('prompt.owned_mark')}</span>` : ''}
234
+ </div>
235
+
236
+ <!-- 🏷️ 标签展示 -->
237
+ ${(Array.isArray(prompt.tags) && prompt.tags.length) ? `
238
+ <div class="prompt-detail__tags">
239
+ ${prompt.tags.map(tag => `<span class="prompt-detail__badge">#${escapeHtml(tag)}</span>`).join('')}
240
+ </div>` : ''}
241
+
242
+ <!-- 作者信息(SWR 缓存头像) -->
243
+ <div id="author-row" class="prompt-detail__author">
244
+ <button type="button" id="btn-author-prompt" class="prompt-detail__author-link">
245
+ <span class="swr-avatar-container prompt-detail__avatar-slot"></span>
246
+ <span class="prompt-detail__author-info">
247
+ <span class="swr-name prompt-detail__author-name">${escapeHtml(prompt.author_name || prompt.author)}</span>
248
+ <span class="prompt-detail__muted prompt-detail__author-time">${escapeHtml(formatTime(prompt.created_at))}</span>
249
+ </span>
250
+ </button>
251
+ ${isOwner ? `
252
+ <div class="prompt-detail__author-actions">
253
+ ${IS_WEB_MODE ? '' : `<button type="button" id="btn-edit-prompt" class="prompt-detail__button" aria-label="${t('common.edit')}" title="${t('common.edit')}">✏️</button>`}
254
+ <button type="button" id="btn-delete-prompt" class="prompt-detail__button prompt-detail__button--danger" aria-label="${t('common.delete')}" title="${t('common.delete')}">🗑️</button>
255
+ </div>
256
+ ` : ''}
257
+ </div>
258
+
259
+ <!-- 简介内容 -->
260
+ <div class="prompt-detail__description">${escapeHtml((prompt.content || '').trim())}</div>
261
+
262
+ <!-- 🔑 提示词正文区(付费遮罩/解锁/复制) -->
263
+ <div id="prompt-text-area" class="prompt-detail__text-area"></div>
264
+
265
+ <!-- 互动按钮栏 -->
266
+ <div id="interaction-bar" class="prompt-detail__interactions">
267
+ <button type="button" id="btn-like" aria-label="${t('market.like')}" aria-pressed="${isLiked}" class="prompt-detail__button prompt-detail__interaction">
268
+ ❤️ <span id="like-count">${escapeHtml(prompt.likes || 0)}</span>
269
+ </button>
270
+ <button type="button" id="btn-favorite" aria-label="${t('market.favorite')}" aria-pressed="${isFavorited}" class="prompt-detail__button prompt-detail__interaction">
271
+ 🔖 <span id="favorite-count">${escapeHtml(prompt.favorites || 0)}</span>
272
+ </button>
273
+ <button type="button" id="btn-tip" class="prompt-detail__button prompt-detail__interaction">
274
+ ${t('prompt.tip_author')}
275
+ </button>
276
+ <!-- 👀 浏览量统计(纯展示) -->
277
+ <div class="prompt-detail__views">
278
+ <span class="prompt-detail__view-count">🔥 <span id="prompt-view-total">${escapeHtml(prompt.views || 0)}</span></span>
279
+ <span class="prompt-detail__view-count">📅 <span id="prompt-view-daily">${escapeHtml(prompt.daily_views || 0)}</span></span>
280
+ </div>
281
+ </div>
282
+
283
+ <!-- 打赏榜单 -->
284
+ <div id="tip-board-area" class="prompt-detail__tips">
285
+ ${renderCommonTipBoardHTML(prompt.tip_board || [], 5, t('prompt.no_tips'))}
286
+ </div>
287
+
288
+ <!-- 评论区 -->
289
+ <div class="prompt-detail__comments-heading">
290
+ ${t('prompt.comments_title')} <span class="prompt-detail__muted">(${escapeHtml(prompt.comments || 0)})</span>
291
+ </div>
292
+
293
+ <!-- 评论输入框 -->
294
+ <div class="prompt-detail__comment-form">
295
+ <input type="text" id="comment-input" required aria-label="${escapeHtml(t('prompt.comment_placeholder'))}" placeholder="${t('prompt.comment_placeholder')}" class="prompt-detail__comment-input">
296
+ <button type="button" id="btn-send-comment" class="prompt-detail__button prompt-detail__button--accent">
297
+ ${t('common.send')}
298
+ </button>
299
+ </div>
300
+
301
+ <!-- 评论列表 -->
302
+ <div id="comments-list" class="prompt-detail__comments">
303
+ <div class="prompt-detail__state prompt-detail__state--compact" role="status" aria-label="${t('prompt.loading')}">⏳</div>
304
+ </div>
305
+ `;
306
+
307
+ // 设置图片沙盒事件(视频无沙盒元素,函数会安全返回)
308
+ setupImageSandboxEvents(contentArea);
309
+
310
+ // 视频播放器事件绑定
311
+ if (isVideo) {
312
+ setupVideoPlayerEvents(contentArea);
313
+ const videoEl = contentArea.querySelector('video');
314
+ if (videoEl) {
315
+ videoEl.addEventListener('error', () => {
316
+ console.warn('[VideoPlayer] 视频加载失败');
317
+ });
318
+ }
319
+ }
320
+
321
+ // 🔑 渲染提示词正文区(解锁 / 付费遮罩)
322
+ renderPromptTextArea(contentArea, prompt, currentUser, isUnlocked);
323
+
324
+ // 🚀 SWR 头像渲染:作者头像
325
+ const authorRow = contentArea.querySelector("#author-row");
326
+ if (authorRow) {
327
+ const avatarContainer = authorRow.querySelector(".swr-avatar-container");
328
+ const nameEl = authorRow.querySelector(".swr-name");
329
+
330
+ const account = prompt.author;
331
+ const cached = getCachedProfile(account);
332
+ const avatar = [cached?.avatar, prompt.author_avatar].map(value => normalizeProfileImageUrl(value)).find(Boolean) || '';
333
+ const name = cached?.name || prompt.author_name || account || '';
334
+
335
+ if (avatarContainer) {
336
+ avatarContainer.innerHTML = _generateAvatarHtml(avatar, name, 40, 'border: 2px solid #444;');
337
+ }
338
+ if (nameEl) nameEl.textContent = name;
339
+
340
+ getProfileWithSWR(account, api.getUserProfile, (profile) => {
341
+ if (!isCurrent(contentArea)) return;
342
+ const avatarEl = avatarContainer?.querySelector('.swr-avatar');
343
+ _updateAvatarElement(avatarEl, profile, 40, 'border: 2px solid #444;');
344
+ if (nameEl && profile.name) nameEl.textContent = profile.name;
345
+ });
346
+ }
347
+
348
+ // 绑定作者点击
349
+ contentArea.querySelector("#btn-author-prompt").onclick = (e) => {
350
+ if (!isCurrent(contentArea)) return;
351
+ if (e.target.id === 'btn-edit-prompt' || e.target.id === 'btn-delete-prompt') return;
352
+ openOtherUserProfileModal(prompt.author, currentUser);
353
+ };
354
+
355
+ // 绑定编辑按钮
356
+ const btnEdit = contentArea.querySelector("#btn-edit-prompt");
357
+ if (btnEdit) {
358
+ btnEdit.onclick = (e) => {
359
+ if (!isCurrent(contentArea)) return;
360
+ e.stopPropagation();
361
+ window.dispatchEvent(new CustomEvent("comfy-route-edit-prompt", {
362
+ detail: { promptData: prompt, currentUser }
363
+ }));
364
+ };
365
+ }
366
+
367
+ // 绑定删除按钮
368
+ const btnDelete = contentArea.querySelector("#btn-delete-prompt");
369
+ if (btnDelete) {
370
+ btnDelete.onclick = (e) => {
371
+ if (!isCurrent(container)) return;
372
+ e.stopPropagation();
373
+ showDeleteConfirmDialog(prompt, container);
374
+ };
375
+ }
376
+
377
+ // 绑定互动事件
378
+ bindInteractionEvents(contentArea, prompt, currentUser);
379
+
380
+ // 加载评论
381
+ loadComments(contentArea, promptId, currentUser);
382
+
383
+ if (fromCache) {
384
+ showToast(t('prompt.network_cache'), "warning");
385
+ } else {
386
+ // 👀 记录浏览量(fire-and-forget,不阻塞渲染)
387
+ recordPromptView(contentArea, promptId);
388
+ }
389
+ }
390
+
391
+ // ==========================================
392
+ // 🔑 提示词正文区(付费遮罩/解锁/复制)
393
+ // ==========================================
394
+
395
+ /**
396
+ * 渲染提示词正文区
397
+ */
398
+ function renderPromptTextArea(contentArea, prompt, currentUser, isUnlocked) {
399
+ if (!isCurrent(contentArea)) return;
400
+ const textArea = contentArea.querySelector("#prompt-text-area");
401
+ if (!textArea) return;
402
+ const priceState = getPromptPriceState(prompt.price);
403
+
404
+ if (isUnlocked && prompt.prompt_text) {
405
+ // ✅ 已解锁:展示正文 + 一键复制
406
+ textArea.innerHTML = `
407
+ <div class="prompt-detail__text-box">
408
+ <div class="prompt-detail__text-header">
409
+ <span class="prompt-detail__text-label">🔑 ${t('prompt.text_label')}</span>
410
+ <button type="button" id="btn-copy-prompt" class="prompt-detail__button prompt-detail__button--accent">
411
+ 📋 ${t('prompt.copy')}
412
+ </button>
413
+ </div>
414
+ <div class="prompt-detail__text-body">${escapeHtml(prompt.prompt_text)}</div>
415
+ </div>
416
+ `;
417
+ textArea.querySelector("#btn-copy-prompt").onclick = () => {
418
+ if (isCurrent(contentArea)) copyToClipboard(prompt.prompt_text, textArea.querySelector("#btn-copy-prompt"), () => isCurrent(contentArea));
419
+ };
420
+ } else if (priceState === 'paid') {
421
+ // 🔒 付费遮罩:模糊预览 + 解锁按钮
422
+ textArea.innerHTML = `
423
+ <div class="prompt-detail__locked">
424
+ <div class="prompt-detail__locked-preview" aria-hidden="true">
425
+ ${escapeHtml((prompt.content || '') + '\n' + (prompt.content || ''))}
426
+ </div>
427
+ <div class="prompt-detail__locked-overlay">
428
+ <div class="prompt-detail__lock-icon" aria-hidden="true">🔒</div>
429
+ <div class="prompt-detail__lock-title">${t('prompt.locked_title')}</div>
430
+ <div class="prompt-detail__muted prompt-detail__locked-description">${t('prompt.locked_desc')}</div>
431
+ <button type="button" id="btn-unlock-prompt" class="prompt-detail__button prompt-detail__button--gold">
432
+ 💎 ${t('prompt.unlock', { price: prompt.price })}
433
+ </button>
434
+ </div>
435
+ </div>
436
+ `;
437
+ textArea.querySelector("#btn-unlock-prompt").onclick = () => {
438
+ showPurchaseConfirmDialog(contentArea, prompt, currentUser);
439
+ };
440
+ } else if (priceState === 'unknown') {
441
+ const message = document.createElement('p');
442
+ message.className = 'prompt-detail__muted';
443
+ message.setAttribute('role', 'status');
444
+ message.textContent = t('prompt.price_unknown');
445
+ textArea.replaceChildren(message);
446
+ }
447
+ }
448
+
449
+ /**
450
+ * 📋 一键复制(navigator.clipboard 优先,execCommand 降级)
451
+ */
452
+ function copyToClipboard(text, btnEl, current) {
453
+ const onSuccess = () => {
454
+ if (!current()) return;
455
+ showToast(t('prompt.copied'), "success");
456
+ if (btnEl) {
457
+ const original = btnEl.innerHTML;
458
+ btnEl.innerHTML = `✅ ${t('prompt.copied')}`;
459
+ setTimeout(() => { if (current() && btnEl.isConnected) btnEl.innerHTML = original; }, 1500);
460
+ }
461
+ };
462
+ const onFail = () => current() && showToast(t('prompt.copy_failed'), "error");
463
+
464
+ if (navigator.clipboard && navigator.clipboard.writeText) {
465
+ navigator.clipboard.writeText(text).then(onSuccess).catch(() => { if (current()) fallbackCopy(text, onSuccess, onFail); });
466
+ } else {
467
+ fallbackCopy(text, onSuccess, onFail);
468
+ }
469
+ }
470
+
471
+ function fallbackCopy(text, onSuccess, onFail) {
472
+ try {
473
+ const ta = document.createElement("textarea");
474
+ ta.value = text;
475
+ ta.style.cssText = "position: fixed; top: -9999px; opacity: 0;";
476
+ document.body.appendChild(ta);
477
+ ta.select();
478
+ const ok = document.execCommand("copy");
479
+ ta.remove();
480
+ ok ? onSuccess() : onFail();
481
+ } catch (e) {
482
+ onFail();
483
+ }
484
+ }
485
+
486
+ /**
487
+ * 💎 购买确认弹窗
488
+ */
489
+ async function showPurchaseConfirmDialog(contentArea, prompt, currentUser) {
490
+ if (!currentUser) {
491
+ showToast(t('prompt.login_first'), "warning");
492
+ return;
493
+ }
494
+
495
+ const context = viewContexts.get(contentArea);
496
+ if (!context?.scope.isCurrent() || context.purchasePending) return;
497
+ context.purchasePending = true;
498
+ let balance;
499
+ let expectedPrice;
500
+ let purchaseTitle;
501
+ try {
502
+ const detailRes = await api.getPromptDetail(prompt.id, context.readOptions);
503
+ if (!isCurrent(contentArea)) return;
504
+ const latest = detailRes?.data;
505
+ if (detailRes?.status !== "success" || !latest || Array.isArray(latest) || latest.id !== prompt.id ||
506
+ typeof latest.owned !== "boolean" || !Number.isSafeInteger(latest.price) || latest.price < 0) throw new Error();
507
+ if (latest.owned) {
508
+ if (typeof latest.prompt_text !== "string") throw new Error();
509
+ prompt.prompt_text = latest.prompt_text;
510
+ prompt.owned = true;
511
+ prompt.price = latest.price;
512
+ renderPromptTextArea(contentArea, prompt, currentUser, true);
513
+ context.purchasePending = false;
514
+ return;
515
+ }
516
+ if (latest.price_confirmation_supported !== true) {
517
+ context.purchasePending = false;
518
+ showToast(t('acquire.purchase_verification_unavailable'), "warning");
519
+ return;
520
+ }
521
+ expectedPrice = latest.price;
522
+ purchaseTitle = typeof latest.title === "string" ? latest.title : prompt.title;
523
+ const walletRes = await api.getWallet(currentUser.account, context.readOptions);
524
+ if (!isCurrent(contentArea)) return;
525
+ if (walletRes?.status !== "success" || !Number.isSafeInteger(walletRes.balance) || walletRes.balance < 0) throw new Error();
526
+ balance = walletRes.balance;
527
+ } catch (_) {
528
+ if (isCurrent(contentArea)) { context.purchasePending = false; showToast(t('common.error'), "error"); }
529
+ return;
530
+ }
531
+
532
+ const content = document.createElement("div");
533
+ content.className = "prompt-detail-dialog";
534
+ content.innerHTML = `
535
+ <div class="prompt-detail-dialog__center">
536
+ <div class="prompt-detail-dialog__icon" aria-hidden="true">💎</div>
537
+ <div class="prompt-detail-dialog__title">${t('prompt.purchase_confirm_title')}</div>
538
+ <div class="prompt-detail-dialog__description">${t('prompt.purchase_confirm_desc', { price: expectedPrice, title: escapeHtml(purchaseTitle) })}</div>
539
+ <div class="prompt-detail-dialog__balance">
540
+ ${t('prompt.purchase_balance')}: <strong class="prompt-detail-dialog__value">${balance}</strong>
541
+ </div>
542
+ <div class="prompt-detail-dialog__actions">
543
+ <button type="button" id="purchase-cancel" class="prompt-detail__button">${t('common.cancel')}</button>
544
+ <button type="button" id="purchase-confirm" class="prompt-detail__button prompt-detail__button--gold">💎 ${t('prompt.unlock', { price: expectedPrice })}</button>
545
+ </div>
546
+ </div>
547
+ `;
548
+
549
+ const modal = openOwnedModal(contentArea, `💎 ${t('prompt.purchase_confirm_title')}`, content, { width: "320px" });
550
+ let submitted = false;
551
+ const release = content._cleanup;
552
+ content._cleanup = () => { release?.(); if (!submitted) context.purchasePending = false; };
553
+ content.querySelector("#purchase-cancel").onclick = () => globalModal.closeModal(modal);
554
+ content.querySelector("#purchase-confirm").onclick = async () => {
555
+ if (submitted || !isCurrent(contentArea) || !modal?.isConnected) return;
556
+ submitted = true;
557
+ const confirmBtn = content.querySelector("#purchase-confirm");
558
+ confirmBtn.disabled = true;
559
+ confirmBtn.textContent = `⏳ ${t('prompt.purchasing')}`;
560
+ try {
561
+ const res = await api.purchasePrompt(prompt.id, expectedPrice);
562
+ if (!isCurrent(contentArea)) return;
563
+ if (res?.status !== "success" || typeof res.prompt_text !== "string") throw new Error();
564
+ prompt.prompt_text = res.prompt_text;
565
+ prompt.owned = true;
566
+ prompt.price = expectedPrice;
567
+ renderPromptTextArea(contentArea, prompt, currentUser, true);
568
+ globalModal.closeModal(modal);
569
+ showToast(t('prompt.purchase_success'), "success");
570
+ } catch (error) {
571
+ // 请求可能已扣款;保持本视图购买锁,重新进入详情确认拥有状态。
572
+ if (!isCurrent(contentArea)) return;
573
+ if (error?.code === "purchase_price_changed" || error?.code === "purchase_price_confirmation_required") {
574
+ globalModal.closeModal(modal);
575
+ showToast(t('acquire.price_changed'), "warning");
576
+ } else showToast(t('prompt.purchase_failed'), "error");
577
+ }
578
+ };
579
+ }
580
+
581
+ /**
582
+ * 👀 记录提示词浏览量(带60秒防抖)- 使用公共工具函数
583
+ */
584
+ async function recordPromptView(contentArea, promptId) {
585
+ if (!isCurrent(contentArea)) return;
586
+ await recordView(api.recordPromptView, promptId, 'prompt', (res) => {
587
+ if (!isCurrent(contentArea)) return;
588
+ const totalEl = contentArea.querySelector("#prompt-view-total");
589
+ const dailyEl = contentArea.querySelector("#prompt-view-daily");
590
+ if (totalEl) totalEl.textContent = res.views || 0;
591
+ if (dailyEl) dailyEl.textContent = res.daily_views || 0;
592
+ });
593
+ }
594
+
595
+ /**
596
+ * 🔗 绑定互动事件 - 使用公共工具函数
597
+ */
598
+ function bindInteractionEvents(container, prompt, currentUser) {
599
+ const btnLike = container.querySelector("#btn-like");
600
+ const btnFavorite = container.querySelector("#btn-favorite");
601
+ const btnTip = container.querySelector("#btn-tip");
602
+ const likeCount = container.querySelector("#like-count");
603
+ const favoriteCount = container.querySelector("#favorite-count");
604
+
605
+ // 点赞
606
+ btnLike.onclick = () => {
607
+ if (!isCurrent(container)) return;
608
+ handleToggleLike(api.togglePromptLike, prompt.id, btnLike, likeCount, currentUser, { isCurrent: () => isCurrent(container) });
609
+ };
610
+
611
+ // 收藏
612
+ btnFavorite.onclick = () => {
613
+ if (!isCurrent(container)) return;
614
+ handleToggleFavorite(api.togglePromptFavorite, prompt.id, btnFavorite, favoriteCount, currentUser, { isCurrent: () => isCurrent(container) });
615
+ };
616
+
617
+ // 打赏
618
+ btnTip.onclick = async () => {
619
+ if (!isCurrent(container)) return;
620
+ if (!currentUser) {
621
+ showToast(t('auth.login_required'), "warning");
622
+ return;
623
+ }
624
+ if (currentUser.account === prompt.author) {
625
+ showToast(t('prompt.tip_self'), "warning");
626
+ return;
627
+ }
628
+ await showTipDialog(prompt, currentUser, container);
629
+ };
630
+ }
631
+
632
+ /**
633
+ * 🎁 显示打赏对话框
634
+ */
635
+ async function showTipDialog(prompt, currentUser, container) {
636
+ const context = viewContexts.get(container);
637
+ if (!isCurrent(container) || context.tipPending) return;
638
+ context.tipPending = true;
639
+ const content = document.createElement("div");
640
+ content.className = "prompt-detail-dialog";
641
+ content.setAttribute('aria-busy', 'true');
642
+ const loading = document.createElement('div');
643
+ loading.setAttribute('role', 'status'); loading.setAttribute('aria-live', 'polite');
644
+ loading.textContent = t('common.loading'); content.append(loading);
645
+ const modal = openOwnedModal(container, `🎁 ${t('prompt.tip_dialog_title')}`, content, { width: "300px" });
646
+ if (!modal) { context.tipPending = false; return; }
647
+ let submitted = false, closed = false;
648
+ context.tipSequence = (context.tipSequence || 0) + 1;
649
+ const walletComponentId = `${context.readOptions.componentId}-tip-${context.tipSequence}`;
650
+ const release = content._cleanup;
651
+ content._cleanup = () => {
652
+ if (closed) return;
653
+ closed = true;
654
+ try { requestCancelManager.cancelAll(walletComponentId); }
655
+ finally { release?.(); if (!submitted) context.tipPending = false; }
656
+ };
657
+ const current = () => !closed && isCurrent(container) && modal.isConnected;
658
+ let balance;
659
+ try {
660
+ const res = await api.getWallet(currentUser.account, { ...context.readOptions, componentId: walletComponentId });
661
+ if (!current()) return;
662
+ if (res?.status !== "success" || !Number.isSafeInteger(res.balance) || res.balance < 0) throw new Error();
663
+ balance = res.balance;
664
+ } catch (_) {
665
+ if (current()) { globalModal.closeModal(modal); showToast(t('common.error'), "error"); }
666
+ return;
667
+ }
668
+ content.setAttribute('aria-busy', 'false');
669
+ content.innerHTML = `
670
+ <div class="prompt-detail-dialog__balance">
671
+ ${t('wallet.current_balance')}: <strong class="prompt-detail-dialog__value">${balance}</strong> ${t('wallet.credits')}
672
+ </div>
673
+ <div class="prompt-detail__tip-amounts">
674
+ <button type="button" class="tip-amount prompt-detail__button" data-amount="10">10 ${t('task.points')}</button>
675
+ <button type="button" class="tip-amount prompt-detail__button" data-amount="50">50 ${t('task.points')}</button>
676
+ <button type="button" class="tip-amount prompt-detail__button" data-amount="100">100 ${t('task.points')}</button>
677
+ <button type="button" class="tip-amount prompt-detail__button" data-amount="500">500 ${t('task.points')}</button>
678
+ </div>
679
+ <div class="prompt-detail-dialog__actions">
680
+ <button type="button" id="tip-cancel" class="prompt-detail__button">${t('common.cancel')}</button>
681
+ </div>
682
+ `;
683
+
684
+ content.querySelector("#tip-cancel").onclick = () => globalModal.closeModal(modal);
685
+
686
+ content.querySelectorAll(".tip-amount").forEach(btn => {
687
+ btn.onclick = async () => {
688
+ if (submitted || !isCurrent(container) || !modal?.isConnected) return;
689
+ const amount = parseInt(btn.dataset.amount);
690
+
691
+ // 本地前置余额校验
692
+ if (balance < amount) {
693
+ showToast(t('wallet.insufficient_balance'), "warning");
694
+ return;
695
+ }
696
+
697
+ try {
698
+ submitted = true;
699
+ content.querySelectorAll(".tip-amount").forEach(button => { button.disabled = true; });
700
+ const res = await api.tipPrompt(prompt.id, amount, false);
701
+ if (!isCurrent(container)) return;
702
+ if (res?.status !== "success") throw new Error();
703
+ context.tipPending = false;
704
+ showToast(t('prompt.tip_success', { amount }), "success");
705
+ globalModal.closeModal(modal);
706
+ // 刷新页面
707
+ loadPromptDetail(container.parentElement, prompt.id, currentUser);
708
+ } catch (err) {
709
+ if (isCurrent(container)) showToast(t('prompt.tip_failed'), "error");
710
+ }
711
+ };
712
+ });
713
+ }
714
+
715
+ /**
716
+ * 💬 加载评论
717
+ */
718
+ async function loadComments(container, promptId, currentUser) {
719
+ const commentsList = container.querySelector("#comments-list");
720
+ const commentInput = container.querySelector("#comment-input");
721
+ const sendBtn = container.querySelector("#btn-send-comment");
722
+ if (!isCurrent(container)) return;
723
+ const context = viewContexts.get(container);
724
+ const account = typeof currentUser === "string" ? currentUser : currentUser?.account;
725
+ let submission = context.commentSubmission;
726
+ if (!submission || submission.input !== commentInput || submission.button !== sendBtn || submission.account !== account) {
727
+ submission = context.commentSubmission = { input: commentInput, button: sendBtn, account, phase: "idle", bound: false };
728
+ }
729
+ const submissionCurrent = () => isCurrent(container) && context.commentSubmission === submission
730
+ && commentInput.isConnected && sendBtn.isConnected;
731
+ const generation = context.commentGeneration = (context.commentGeneration || 0) + 1;
732
+ const current = () => isCurrent(container) && context.commentGeneration === generation && commentsList.isConnected;
733
+
734
+ try {
735
+ const res = await api.getPromptComments(promptId, viewContexts.get(container).readOptions);
736
+ if (!current()) return;
737
+ if (res?.status !== "success" || !Array.isArray(res.data)) throw new Error();
738
+ let comments = res.data.filter(value => value && typeof value === "object" && !Array.isArray(value)
739
+ && typeof value.author === "string" && typeof value.content === "string");
740
+ comments = proxyImages(comments);
741
+
742
+ if (comments.length === 0) {
743
+ commentsList.innerHTML = `
744
+ <div class="prompt-detail__state prompt-detail__state--compact" role="status">
745
+ ${t('prompt.no_comments')}
746
+ </div>
747
+ `;
748
+ } else {
749
+ // 🚀 SWR 头像渲染:评论作者头像
750
+ commentsList.innerHTML = comments.map((c, idx) => {
751
+ const cached = getCachedProfile(c.author);
752
+ const avatar = [cached?.avatar, c.author_avatar].map(value => normalizeProfileImageUrl(value)).find(Boolean) || '';
753
+ const name = cached?.name || c.author_name || c.author || '';
754
+
755
+ const avatarHtml = _generateAvatarHtml(avatar, name, 24, 'background: var(--comfy-input-bg);');
756
+
757
+ return `
758
+ <div id="comment-${idx}" data-account="${escapeHtml(c.author)}" class="prompt-detail__comment">
759
+ <div class="prompt-detail__comment-header">
760
+ <div class="swr-avatar-container">${avatarHtml}</div>
761
+ <span class="swr-name prompt-detail__comment-name">${escapeHtml(name)}</span>
762
+ <span class="prompt-detail__muted prompt-detail__comment-time">${escapeHtml(formatTime(c.created_at))}</span>
763
+ </div>
764
+ <div class="prompt-detail__comment-body">
765
+ ${escapeHtml(c.content)}
766
+ </div>
767
+ </div>
768
+ `;
769
+ }).join("");
770
+
771
+ // 后台静默校对每个评论作者的头像
772
+ comments.forEach((c, idx) => {
773
+ getProfileWithSWR(c.author, api.getUserProfile, (profile) => {
774
+ if (!current()) return;
775
+ const commentEl = commentsList.querySelector(`#comment-${idx}`);
776
+ if (!commentEl) return;
777
+ const avatarContainer = commentEl.querySelector('.swr-avatar-container');
778
+ const nameEl = commentEl.querySelector('.swr-name');
779
+ const avatarEl = avatarContainer?.querySelector('.swr-avatar');
780
+ _updateAvatarElement(avatarEl, profile, 24, 'background: var(--comfy-input-bg);');
781
+ if (nameEl && profile.name) nameEl.textContent = profile.name;
782
+ });
783
+ });
784
+ }
785
+ } catch (err) {
786
+ if (!current()) return;
787
+ commentsList.innerHTML = `
788
+ <div class="prompt-detail__state prompt-detail__state--compact prompt-detail__state--error" role="alert">
789
+ ${t('prompt.load_failed')}
790
+ </div>
791
+ `;
792
+ }
793
+
794
+ if (submission.bound || !submissionCurrent()) return;
795
+ submission.bound = true;
796
+ sendBtn.disabled = false;
797
+ commentInput.readOnly = false;
798
+ sendBtn.removeAttribute("aria-busy");
799
+ commentInput.oninput = () => {
800
+ if (submissionCurrent()) commentInput.removeAttribute("aria-invalid");
801
+ };
802
+ sendBtn.onclick = async () => {
803
+ if (!submissionCurrent() || submission.phase !== "idle") return;
804
+ if (typeof account !== "string" || !account.trim()) {
805
+ showToast(t('auth.login_required'), "warning");
806
+ return;
807
+ }
808
+ const originalInput = commentInput.value, content = originalInput.trim();
809
+ if (!content) {
810
+ commentInput.setAttribute("aria-invalid", "true");
811
+ commentInput.focus();
812
+ showToast(t('prompt.comment_required'), "warning");
813
+ return;
814
+ }
815
+ commentInput.removeAttribute("aria-invalid");
816
+ submission.phase = "submitting";
817
+ sendBtn.disabled = true;
818
+ sendBtn.setAttribute("aria-busy", "true");
819
+ sendBtn.textContent = t('common.sending');
820
+ try {
821
+ const result = await api.addPromptComment(promptId, content);
822
+ if (!submissionCurrent()) return;
823
+ if (!isConfirmedDedicatedCommentResponse(result, account, content)) throw new Error("Unconfirmed comment");
824
+ submission.phase = "committed";
825
+ if (commentInput.value === originalInput) commentInput.value = "";
826
+ showToast(t('prompt.comment_success'), "success");
827
+ await loadComments(container, promptId, currentUser);
828
+ if (submissionCurrent()) submission.phase = "idle";
829
+ } catch (err) {
830
+ if (!submissionCurrent()) return;
831
+ if (submission.phase === "committed") {
832
+ try { showToast(t('social.comment_refresh_required'), "warning"); } catch (_) {}
833
+ return;
834
+ }
835
+ const rejected = isDefiniteCommentRejection(err);
836
+ submission.phase = rejected ? "idle" : "unconfirmed";
837
+ showToast(t(rejected ? 'prompt.comment_failed' : 'social.comment_unconfirmed'), rejected ? "error" : "warning");
838
+ } finally {
839
+ if (!submissionCurrent()) return;
840
+ sendBtn.disabled = submission.phase !== "idle";
841
+ commentInput.readOnly = submission.phase !== "idle";
842
+ sendBtn.removeAttribute("aria-busy");
843
+ sendBtn.textContent = t(submission.phase === "unconfirmed" ? 'social.comment_pending' : 'common.send');
844
+ }
845
+ };
846
+
847
+ // 回车发送
848
+ commentInput.onkeydown = (e) => {
849
+ if (isCurrent(container) && !e.defaultPrevented && e.key === "Enter"
850
+ && !e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey
851
+ && !e.isComposing && e.keyCode !== 229 && !e.repeat
852
+ && !commentInput.disabled && !commentInput.readOnly) {
853
+ e.preventDefault();
854
+ sendBtn.click();
855
+ }
856
+ };
857
+ }
858
+
859
+ /**
860
+ * 🗑️ 显示删除确认对话框
861
+ */
862
+ function showDeleteConfirmDialog(prompt, container) {
863
+ if (!isCurrent(container)) return;
864
+ const content = document.createElement("div");
865
+ content.className = "prompt-detail-dialog";
866
+ content.innerHTML = `
867
+ <div class="prompt-detail-dialog__center">
868
+ <div class="prompt-detail-dialog__icon" aria-hidden="true">⚠️</div>
869
+ <div class="prompt-detail-dialog__title">${t('prompt.delete_confirm')}</div>
870
+ <div class="prompt-detail-dialog__description">${escapeHtml(prompt.title)}</div>
871
+ <div class="prompt-detail-dialog__actions">
872
+ <button type="button" id="delete-cancel" class="prompt-detail__button">${t('common.cancel')}</button>
873
+ <button type="button" id="delete-confirm" class="prompt-detail__button prompt-detail__button--danger">${t('common.delete')}</button>
874
+ </div>
875
+ </div>
876
+ `;
877
+
878
+ const modal = openOwnedModal(container, t('prompt.delete_confirm'), content, { width: "300px" });
879
+
880
+ content.querySelector("#delete-cancel").onclick = () => globalModal.closeModal(modal);
881
+ let submitted = false;
882
+
883
+ content.querySelector("#delete-confirm").onclick = async () => {
884
+ if (submitted || !isCurrent(container) || !modal?.isConnected) return;
885
+ submitted = true;
886
+ try {
887
+ const confirmBtn = content.querySelector("#delete-confirm");
888
+ confirmBtn.disabled = true;
889
+ confirmBtn.textContent = t('common.deleting');
890
+
891
+ await api.deletePrompt(prompt.id);
892
+ if (!isCurrent(container)) return;
893
+ showToast(t('prompt.deleted'), "success");
894
+ globalModal.closeModal(modal);
895
+
896
+ // 通知列表刷新并返回
897
+ window.dispatchEvent(new CustomEvent("comfy-prompts-refresh"));
898
+ window.dispatchEvent(new CustomEvent("comfy-route-back"));
899
+ } catch (err) {
900
+ if (!isCurrent(container)) return;
901
+ showToast(t('common.error'), "error");
902
+ submitted = false;
903
+ const confirmBtn = content.querySelector("#delete-confirm");
904
+ confirmBtn.disabled = false;
905
+ confirmBtn.textContent = t('common.delete');
906
+ }
907
+ };
908
+ }