diff --git a/src/content.css b/src/content.css index e88d96c..203f632 100644 --- a/src/content.css +++ b/src/content.css @@ -570,12 +570,22 @@ body.ld-drawer-page-open #ld-drawer-root { } #ld-drawer-root .ld-post-actions { + display: grid; + justify-items: end; + gap: 8px; + padding: 0 16px 14px; +} + +#ld-drawer-root .ld-post-action-bar { display: flex; + flex-wrap: wrap; justify-content: flex-end; - padding: 0 16px 14px; + gap: 8px; } -#ld-drawer-root .ld-post-reply-button { +#ld-drawer-root .ld-post-action-button, +#ld-drawer-root .ld-post-reply-button, +#ld-drawer-root .ld-post-flag-option { display: inline-flex; align-items: center; gap: 6px; @@ -587,28 +597,97 @@ body.ld-drawer-page-open #ld-drawer-root { font-size: 12px; line-height: 1; cursor: pointer; + transition: border-color 0.18s ease, color 0.18s ease, background-color 0.18s ease, transform 0.18s ease; } -#ld-drawer-root .ld-post-reply-button:hover { +#ld-drawer-root .ld-post-action-button:hover, +#ld-drawer-root .ld-post-reply-button:hover, +#ld-drawer-root .ld-post-flag-option:hover { border-color: var(--tertiary, #3b82f6); color: var(--tertiary, #3b82f6); + transform: translateY(-1px); +} + +#ld-drawer-root .ld-post-action-button:disabled, +#ld-drawer-root .ld-post-reply-button:disabled, +#ld-drawer-root .ld-post-flag-option:disabled { + opacity: 0.56; + cursor: not-allowed; + transform: none; } +#ld-drawer-root .ld-post-action-button.is-active { + border-color: color-mix(in srgb, var(--tertiary, #3b82f6) 30%, rgba(255, 255, 255, 0.2)); + background: color-mix(in srgb, var(--tertiary, #3b82f6) 16%, var(--secondary, #fff)); +} + +#ld-drawer-root .ld-post-action-button[data-action="like"].is-active { + color: #ff6b8a; +} + +#ld-drawer-root .ld-post-action-button[data-action="bookmark"].is-active { + color: #17a0d7; +} + +#ld-drawer-root .ld-post-action-button[data-action="flag"].is-active { + color: #f59e0b; +} + +#ld-drawer-root .ld-post-action-button-icon, #ld-drawer-root .ld-post-reply-button-icon { width: 14px; height: 14px; display: inline-flex; } +#ld-drawer-root .ld-post-action-button-icon svg, #ld-drawer-root .ld-post-reply-button-icon svg { width: 14px; height: 14px; } +#ld-drawer-root .ld-post-action-button-count, +#ld-drawer-root .ld-post-action-button-label, #ld-drawer-root .ld-post-reply-button-label { white-space: nowrap; } +#ld-drawer-root .ld-post-flag-menu { + width: min(280px, 100%); + display: grid; + gap: 8px; + padding: 12px; + border-radius: 16px; + border: 1px solid var(--primary-low, rgba(15, 23, 42, 0.12)); + background: color-mix(in srgb, var(--secondary, #fff) 96%, rgba(15, 23, 42, 0.04)); + box-shadow: 0 18px 36px rgba(15, 23, 42, 0.14); +} + +#ld-drawer-root .ld-post-flag-menu-title { + font-size: 12px; + color: var(--primary-medium, rgba(15, 23, 42, 0.64)); +} + +#ld-drawer-root .ld-post-flag-option { + justify-content: center; + padding-top: 8px; + padding-bottom: 8px; +} + +#ld-drawer-root .ld-post-action-status { + min-height: 18px; + font-size: 12px; + color: var(--primary-medium, rgba(15, 23, 42, 0.64)); +} + +#ld-drawer-root .ld-post-action-status[data-tone="success"] { + color: #0f766e; +} + +#ld-drawer-root .ld-post-action-status[data-tone="error"] { + color: #dc2626; +} + #ld-drawer-root .ld-post-body > :first-child { margin-top: 0; } @@ -836,6 +915,16 @@ body.ld-drawer-page-open #ld-drawer-root { width: auto; transform: none; } + + #ld-drawer-root .ld-post-actions, + #ld-drawer-root .ld-post-action-bar { + justify-items: stretch; + justify-content: flex-start; + } + + #ld-drawer-root .ld-post-flag-menu { + width: 100%; + } } @media (prefers-reduced-motion: reduce) { diff --git a/src/content.js b/src/content.js index e31a259..6afc78a 100644 --- a/src/content.js +++ b/src/content.js @@ -10,6 +10,23 @@ const IMAGE_PREVIEW_SCALE_MIN = 1; const IMAGE_PREVIEW_SCALE_MAX = 4; const IMAGE_PREVIEW_SCALE_STEP = 0.2; + const POST_ACTION_TYPE_LIKE = 2; + const POST_ACTION_TYPE_OFF_TOPIC = 3; + const POST_ACTION_TYPE_INAPPROPRIATE = 4; + const POST_ACTION_TYPE_NOTIFY_USER = 6; + const POST_ACTION_TYPE_NOTIFY_MODERATORS = 7; + const POST_ACTION_TYPE_SPAM = 8; + const POST_ACTION_TYPE_ILLEGAL = 10; + const POST_BOOKMARK_TYPE = "Post"; + const POST_FLAG_OPTIONS = [ + { id: POST_ACTION_TYPE_OFF_TOPIC, label: "偏离话题", requireMessage: false }, + { id: POST_ACTION_TYPE_INAPPROPRIATE, label: "不当言论", requireMessage: false }, + { id: POST_ACTION_TYPE_SPAM, label: "垃圾信息", requireMessage: false }, + { id: 1002, label: "AIGC未截图", requireMessage: false }, + { id: 1001, label: "违规推广", requireMessage: false }, + { id: POST_ACTION_TYPE_ILLEGAL, label: "非法", requireMessage: true }, + { id: POST_ACTION_TYPE_NOTIFY_MODERATORS, label: "其他内容", requireMessage: true } + ]; const DEFAULT_SETTINGS = { previewMode: "smart", postMode: "all", @@ -102,6 +119,7 @@ currentTopic: null, currentLatestRepliesTopic: null, currentTargetSpec: null, + openFlagMenuPostId: null, replyTargetPostNumber: null, replyTargetLabel: "", abortController: null, @@ -112,6 +130,8 @@ isResizing: false, isLoadingMorePosts: false, isReplySubmitting: false, + pendingPostActionKeys: new Set(), + postActionStatusTimers: {}, loadMoreError: "", loadMoreStatus: null, hasShownPreviewNotice: false @@ -306,6 +326,10 @@ setReplyPanelOpen(false); } + if (state.openFlagMenuPostId && !target.closest(".ld-post-flag-menu") && !target.closest(".ld-post-flag-trigger")) { + setOpenFlagMenuPostId(null); + } + const link = target.closest("a[href]"); if (!link || link.closest(`#${ROOT_ID}`)) { return; @@ -344,6 +368,13 @@ return; } + if (event.key === "Escape" && state.openFlagMenuPostId) { + event.preventDefault(); + event.stopPropagation(); + setOpenFlagMenuPostId(null); + return; + } + if (isTypingTarget(event.target)) { return; } @@ -449,8 +480,11 @@ state.currentResolvedTargetPostNumber = null; state.currentTargetSpec = null; state.currentTopic = null; + state.openFlagMenuPostId = null; state.loadMoreError = ""; state.isLoadingMorePosts = false; + state.pendingPostActionKeys.clear(); + clearPostActionStatuses(); resetReplyComposer(); state.title.textContent = fallbackTitle || "加载中…"; state.meta.textContent = "正在载入帖子内容…"; @@ -493,9 +527,12 @@ state.currentTopic = null; state.currentLatestRepliesTopic = null; state.currentTargetSpec = null; + state.openFlagMenuPostId = null; state.meta.textContent = ""; state.loadMoreError = ""; state.isLoadingMorePosts = false; + state.pendingPostActionKeys.clear(); + clearPostActionStatuses(); resetReplyComposer(); closeImagePreview(); clearHighlight(); @@ -891,6 +928,9 @@ function buildPostCard(post) { const article = document.createElement("article"); article.className = "ld-post-card"; + if (Number.isFinite(Number(post?.id))) { + article.dataset.postId = String(post.id); + } if (typeof post.post_number === "number") { article.dataset.postNumber = String(post.post_number); } @@ -937,24 +977,223 @@ const actions = document.createElement("div"); actions.className = "ld-post-actions"; + const actionBar = document.createElement("div"); + actionBar.className = "ld-post-action-bar"; + + const likeSummary = getPostActionSummary(post, POST_ACTION_TYPE_LIKE); + const likeButton = buildPostActionButton({ + action: "like", + icon: "like", + label: "点赞", + count: Number.isFinite(Number(likeSummary?.count)) ? Number(likeSummary.count) : null, + active: Boolean(likeSummary?.acted), + disabled: !(likeSummary?.can_act || likeSummary?.can_undo) || isPostActionPending(post.id, "like") + }); + likeButton.addEventListener("click", () => handleToggleLike(post)); + + const copyButton = buildPostActionButton({ + action: "copy-link", + icon: "link", + label: "复制帖子链接", + disabled: false + }); + copyButton.addEventListener("click", () => handleCopyPostLink(post)); + + const flagOptions = getAvailablePostFlagOptions(post); + let flagButton = null; + if (flagOptions.length > 0) { + flagButton = buildPostActionButton({ + action: "flag", + icon: "flag", + label: "举报", + active: hasActedPostFlag(post), + disabled: isFlagMenuBusy(post, flagOptions) + }); + flagButton.classList.add("ld-post-flag-trigger"); + flagButton.setAttribute("aria-expanded", String(state.openFlagMenuPostId === post.id)); + flagButton.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + toggleFlagMenuForPost(post); + }); + } + + const bookmarkButton = buildPostActionButton({ + action: "bookmark", + icon: "bookmark", + label: post.bookmarked ? "取消书签" : "加入书签", + active: Boolean(post.bookmarked), + disabled: isPostActionPending(post.id, "bookmark") + }); + bookmarkButton.addEventListener("click", () => handleToggleBookmark(post)); + + const replyButton = buildPostActionButton({ + action: "reply", + icon: "reply", + label: "回复这条", + disabled: state.isReplySubmitting + }); + replyButton.classList.add("ld-post-reply-button"); + replyButton.addEventListener("click", () => openReplyPanelForPost(post)); + + actionBar.append(likeButton, copyButton); + if (flagButton) { + actionBar.appendChild(flagButton); + } + actionBar.append(bookmarkButton, replyButton); + actions.appendChild(actionBar); + + if (flagOptions.length > 0 && state.openFlagMenuPostId === post.id) { + const flagMenu = document.createElement("div"); + flagMenu.className = "ld-post-flag-menu"; + + const flagMenuTitle = document.createElement("div"); + flagMenuTitle.className = "ld-post-flag-menu-title"; + flagMenuTitle.textContent = "选择举报原因"; + flagMenu.appendChild(flagMenuTitle); + + for (const option of flagOptions) { + const optionButton = document.createElement("button"); + optionButton.type = "button"; + optionButton.className = "ld-post-flag-option"; + optionButton.textContent = option.label; + optionButton.disabled = isPostActionPending(post.id, `flag-${option.id}`); + optionButton.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + handleFlagPost(post, option); + }); + flagMenu.appendChild(optionButton); + } + + actions.appendChild(flagMenu); + } + + const actionStatus = document.createElement("div"); + actionStatus.className = "ld-post-action-status"; + actionStatus.hidden = true; + actionStatus.setAttribute("aria-live", "polite"); + actions.appendChild(actionStatus); + + article.append(header, body, actions); + return article; + } + + function buildPostActionButton(options = {}) { + const { + action = "", + icon = "link", + label = "", + count = null, + active = false, + disabled = false + } = options; + const button = document.createElement("button"); + button.type = "button"; + button.className = "ld-post-action-button"; + if (action) { + button.dataset.action = action; + } + if (label) { + button.setAttribute("aria-label", label); + button.title = label; + } + if (active) { + button.classList.add("is-active"); + } + button.disabled = Boolean(disabled); + + const iconSpan = document.createElement("span"); + iconSpan.className = "ld-post-action-button-icon"; + iconSpan.setAttribute("aria-hidden", "true"); + iconSpan.innerHTML = buildPostActionIcon(icon); + button.appendChild(iconSpan); + + if (Number.isFinite(count) && count > 0) { + const countSpan = document.createElement("span"); + countSpan.className = "ld-post-action-button-count"; + countSpan.textContent = String(count); + button.appendChild(countSpan); + } else if (action === "reply") { + const labelSpan = document.createElement("span"); + labelSpan.className = "ld-post-action-button-label"; + labelSpan.textContent = "回复这条"; + button.appendChild(labelSpan); + } + + return button; + } + + function buildPostActionIcon(icon) { + if (icon === "like") { + return ` + + + + `; + } - const replyButton = document.createElement("button"); - replyButton.type = "button"; - replyButton.className = "ld-post-reply-button"; - replyButton.setAttribute("aria-label", `回复第 ${post.post_number || "?"} 条`); - replyButton.innerHTML = ` - - 回复这条 + `; + } + + return ` + + + `; - replyButton.addEventListener("click", () => openReplyPanelForPost(post)); + } - actions.appendChild(replyButton); - article.append(header, body, actions); - return article; + function getPostActionSummary(post, actionTypeId) { + return (post?.actions_summary || []).find((summary) => Number(summary?.id) === Number(actionTypeId)) || null; + } + + function getAvailablePostFlagOptions(post) { + const availableFlagIds = new Set( + (post?.actions_summary || []) + .filter((summary) => { + const id = Number(summary?.id); + return id !== POST_ACTION_TYPE_LIKE && id !== POST_ACTION_TYPE_NOTIFY_USER && Boolean(summary?.can_act); + }) + .map((summary) => Number(summary.id)) + ); + + return POST_FLAG_OPTIONS.filter((option) => availableFlagIds.has(option.id)); + } + + function hasActedPostFlag(post) { + return (post?.actions_summary || []).some((summary) => { + const actionId = Number(summary?.id); + return actionId !== POST_ACTION_TYPE_LIKE && actionId !== POST_ACTION_TYPE_NOTIFY_USER && Boolean(summary?.acted); + }); + } + + function isFlagMenuBusy(post, options) { + if (!Array.isArray(options) || !options.length) { + return true; + } + + return options.every((option) => isPostActionPending(post?.id, `flag-${option.id}`)); } function handleDrawerBodyScroll() { @@ -1379,8 +1618,11 @@ state.currentResolvedTargetPostNumber = null; state.isLoadingMorePosts = false; state.isReplySubmitting = false; + state.openFlagMenuPostId = null; + state.pendingPostActionKeys.clear(); state.loadMoreError = ""; state.loadMoreStatus = null; + clearPostActionStatuses(); state.title.textContent = fallbackTitle || "帖子预览"; state.meta.textContent = "智能预览暂时不可用。"; resetReplyComposer(); @@ -1411,8 +1653,11 @@ state.currentResolvedTargetPostNumber = null; state.isLoadingMorePosts = false; state.isReplySubmitting = false; + state.openFlagMenuPostId = null; + state.pendingPostActionKeys.clear(); state.loadMoreError = ""; state.loadMoreStatus = null; + clearPostActionStatuses(); state.title.textContent = fallbackTitle || "帖子预览"; state.meta.textContent = forcedIframe ? "当前为整页模式。" : "接口预览失败,已回退为完整页面。"; resetReplyComposer(); @@ -1720,6 +1965,132 @@ return data; } + async function createPostAction(postId, postActionTypeId, options = {}) { + const csrfToken = getCsrfToken(); + if (!csrfToken) { + throw new Error("未找到登录令牌,请刷新页面后重试"); + } + + const body = new URLSearchParams(); + body.set("id", String(postId)); + body.set("post_action_type_id", String(postActionTypeId)); + if (options.message) { + body.set("message", options.message); + } + + const response = await fetch(`${location.origin}/post_actions`, { + method: "POST", + credentials: "include", + headers: { + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", + "X-Requested-With": "XMLHttpRequest", + "X-CSRF-Token": csrfToken + }, + body + }); + + const data = await readJsonResponse(response); + ensureJsonRequestSucceeded(response, data); + return data; + } + + async function deletePostAction(postId, postActionTypeId) { + const csrfToken = getCsrfToken(); + if (!csrfToken) { + throw new Error("未找到登录令牌,请刷新页面后重试"); + } + + const url = new URL(`${location.origin}/post_actions/${postId}`); + url.searchParams.set("post_action_type_id", String(postActionTypeId)); + + const response = await fetch(url.toString(), { + method: "DELETE", + credentials: "include", + headers: { + Accept: "application/json", + "X-Requested-With": "XMLHttpRequest", + "X-CSRF-Token": csrfToken + } + }); + + const data = await readJsonResponse(response); + ensureJsonRequestSucceeded(response, data); + return data; + } + + async function createBookmark(postId) { + const csrfToken = getCsrfToken(); + if (!csrfToken) { + throw new Error("未找到登录令牌,请刷新页面后重试"); + } + + const body = new URLSearchParams(); + body.set("bookmarkable_id", String(postId)); + body.set("bookmarkable_type", POST_BOOKMARK_TYPE); + + const response = await fetch(`${location.origin}/bookmarks`, { + method: "POST", + credentials: "include", + headers: { + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", + "X-Requested-With": "XMLHttpRequest", + "X-CSRF-Token": csrfToken + }, + body + }); + + const data = await readJsonResponse(response); + ensureJsonRequestSucceeded(response, data); + return data; + } + + async function deleteBookmark(bookmarkId) { + const csrfToken = getCsrfToken(); + if (!csrfToken) { + throw new Error("未找到登录令牌,请刷新页面后重试"); + } + + const response = await fetch(`${location.origin}/bookmarks/${bookmarkId}`, { + method: "DELETE", + credentials: "include", + headers: { + Accept: "application/json", + "X-Requested-With": "XMLHttpRequest", + "X-CSRF-Token": csrfToken + } + }); + + const data = await readJsonResponse(response); + ensureJsonRequestSucceeded(response, data); + return data; + } + + async function readJsonResponse(response) { + const contentType = response.headers.get("content-type") || ""; + if (!contentType.includes("json")) { + return null; + } + + try { + return await response.json(); + } catch { + return null; + } + } + + function ensureJsonRequestSucceeded(response, data) { + if (response.ok && !(data && data.success === false)) { + return; + } + + const message = Array.isArray(data?.errors) && data.errors.length > 0 + ? data.errors.join(";") + : (data?.error || data?.message || `Unexpected response: ${response.status}`); + throw new Error(message); + } + function getCsrfToken() { const token = document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") || ""; return token.trim(); @@ -2230,6 +2601,265 @@ } } + function renderCurrentTopicPreservingScroll(preserveScrollTop = state.drawerBody?.scrollTop || 0) { + if (!state.currentTopic || !state.currentUrl) { + return; + } + + renderTopic(state.currentTopic, state.currentUrl, state.currentFallbackTitle, state.currentResolvedTargetPostNumber, { + latestRepliesTopic: state.currentLatestRepliesTopic, + targetSpec: state.currentTargetSpec, + preserveScrollTop + }); + } + + function setOpenFlagMenuPostId(postId) { + const normalizedPostId = Number.isFinite(Number(postId)) ? Number(postId) : null; + if (state.openFlagMenuPostId === normalizedPostId) { + return; + } + + state.openFlagMenuPostId = normalizedPostId; + renderCurrentTopicPreservingScroll(); + } + + function toggleFlagMenuForPost(post) { + const postId = Number(post?.id); + if (!Number.isFinite(postId)) { + return; + } + + setOpenFlagMenuPostId(state.openFlagMenuPostId === postId ? null : postId); + } + + function makePendingPostActionKey(postId, actionName) { + return `${postId || "unknown"}::${actionName || "action"}`; + } + + function isPostActionPending(postId, actionName) { + return state.pendingPostActionKeys.has(makePendingPostActionKey(postId, actionName)); + } + + function replacePostInTopic(topic, updatedPost) { + if (!topic || !updatedPost || typeof updatedPost !== "object") { + return topic; + } + + return mergeTopicPreviewData(topic, { + post_stream: { + stream: [updatedPost.id], + posts: [updatedPost] + } + }); + } + + function updatePreviewPost(updatedPost) { + state.currentTopic = replacePostInTopic(state.currentTopic, updatedPost); + state.currentLatestRepliesTopic = replacePostInTopic(state.currentLatestRepliesTopic, updatedPost); + } + + function clearPostActionStatuses() { + for (const timerId of Object.values(state.postActionStatusTimers)) { + clearTimeout(timerId); + } + + state.postActionStatusTimers = {}; + } + + function setPostActionStatus(postId, message, tone = "info", timeoutMs = 2400) { + if (!Number.isFinite(Number(postId))) { + return; + } + + const card = state.content?.querySelector(`.ld-post-card[data-post-id="${postId}"]`); + const status = card?.querySelector(".ld-post-action-status"); + if (!(status instanceof HTMLElement)) { + return; + } + + const timerKey = String(postId); + if (state.postActionStatusTimers[timerKey]) { + clearTimeout(state.postActionStatusTimers[timerKey]); + } + + status.hidden = false; + status.dataset.tone = tone; + status.textContent = message; + + state.postActionStatusTimers[timerKey] = window.setTimeout(() => { + status.hidden = true; + status.textContent = ""; + delete state.postActionStatusTimers[timerKey]; + }, timeoutMs); + } + + async function handleCopyPostLink(post) { + const postId = Number(post?.id); + const postUrl = buildPostUrl(post); + + if (!postUrl) { + setPostActionStatus(postId, "未找到帖子链接", "error"); + return; + } + + try { + await copyTextToClipboard(postUrl); + setPostActionStatus(postId, "已复制帖子链接", "success"); + } catch (error) { + setPostActionStatus(postId, error?.message || "复制失败", "error"); + } + } + + async function handleToggleLike(post) { + const likeSummary = getPostActionSummary(post, POST_ACTION_TYPE_LIKE); + if (!likeSummary) { + setPostActionStatus(post?.id, "当前帖子暂不支持点赞", "error"); + return; + } + + await performPostMutation(post, "like", () => { + if (likeSummary.can_undo) { + return deletePostAction(post.id, POST_ACTION_TYPE_LIKE); + } + return createPostAction(post.id, POST_ACTION_TYPE_LIKE); + }, { + successMessage: likeSummary.can_undo ? "已取消点赞" : "已点赞" + }); + } + + async function handleToggleBookmark(post) { + const isBookmarked = Boolean(post?.bookmarked); + const bookmarkId = Number(post?.bookmark_id); + + await performPostMutation(post, "bookmark", async () => { + if (isBookmarked && Number.isFinite(bookmarkId)) { + await deleteBookmark(bookmarkId); + return { + ...post, + bookmarked: false, + bookmark_id: null, + bookmark_name: null, + bookmark_reminder_at: null + }; + } + + const createdBookmark = await createBookmark(post.id); + return { + ...post, + bookmarked: true, + bookmark_id: createdBookmark?.id || post?.bookmark_id || null + }; + }, { + successMessage: isBookmarked ? "已移除书签" : "已加入书签" + }); + } + + async function handleFlagPost(post, option) { + if (!post || !option) { + return; + } + + let message = ""; + if (option.requireMessage) { + const input = window.prompt(`请补充“${option.label}”的举报说明:`, ""); + if (input === null) { + return; + } + message = input.trim(); + if (!message) { + setPostActionStatus(post.id, "该举报类型需要补充说明", "error"); + return; + } + } + + await performPostMutation(post, `flag-${option.id}`, () => createPostAction(post.id, option.id, { message }), { + successMessage: `已提交“${option.label}”举报` + }); + } + + async function performPostMutation(post, actionName, task, options = {}) { + const postId = Number(post?.id); + if (!Number.isFinite(postId) || typeof task !== "function" || !state.currentTopic) { + return; + } + + const pendingKey = makePendingPostActionKey(postId, actionName); + if (state.pendingPostActionKeys.has(pendingKey)) { + return; + } + + const preserveScrollTop = state.drawerBody?.scrollTop || 0; + let updatedPost = null; + let statusMessage = ""; + let statusTone = "success"; + + state.pendingPostActionKeys.add(pendingKey); + state.openFlagMenuPostId = null; + renderCurrentTopicPreservingScroll(preserveScrollTop); + + try { + updatedPost = await task(); + statusMessage = options.successMessage || ""; + } catch (error) { + statusTone = "error"; + statusMessage = error?.message || "操作失败"; + } finally { + state.pendingPostActionKeys.delete(pendingKey); + + if (updatedPost) { + updatePreviewPost(updatedPost); + } + + renderCurrentTopicPreservingScroll(preserveScrollTop); + + if (statusMessage) { + setPostActionStatus(postId, statusMessage, statusTone); + } + } + } + + function buildPostUrl(post) { + if (typeof post?.post_url === "string" && post.post_url.trim()) { + try { + return new URL(post.post_url, location.origin).toString(); + } catch { + return post.post_url.trim(); + } + } + + if (state.currentUrl && Number.isFinite(Number(post?.post_number))) { + const url = new URL(state.currentUrl); + url.hash = ""; + url.search = ""; + return `${url.toString().replace(/\/$/, "")}/${post.post_number}`; + } + + return state.currentUrl || location.href; + } + + async function copyTextToClipboard(text) { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return; + } + + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", "readonly"); + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + textarea.style.pointerEvents = "none"; + document.body.appendChild(textarea); + textarea.select(); + + const copied = document.execCommand("copy"); + textarea.remove(); + + if (!copied) { + throw new Error("浏览器拒绝写入剪贴板"); + } + } + function syncSettingsUI() { if (!state.settingsPanel) { return;