From 18b7f843ae9da10c45f040ea9678b8a8d5462d2f Mon Sep 17 00:00:00 2001 From: hajimi Date: Sun, 2 Aug 2026 02:27:04 +0800 Subject: [PATCH] feat: pair community post translations --- ...-08-02-community-post-bilingual-pairing.md | 105 +++++++++--- ...community-post-bilingual-pairing-design.md | 5 +- .../packages_community/pages/post_detail.vue | 159 ++++++++++++++---- 3 files changed, 204 insertions(+), 65 deletions(-) diff --git a/docs/superpowers/plans/2026-08-02-community-post-bilingual-pairing.md b/docs/superpowers/plans/2026-08-02-community-post-bilingual-pairing.md index cecef8a..4d3ca78 100644 --- a/docs/superpowers/plans/2026-08-02-community-post-bilingual-pairing.md +++ b/docs/superpowers/plans/2026-08-02-community-post-bilingual-pairing.md @@ -17,9 +17,45 @@ - [ ] **Step 1: Upgrade the local text splitter** -Replace the newline-only `splitContentLines` helper with the article-detail behavior: normalize line endings, prefer explicit non-empty lines, and otherwise split the single line by Chinese or English sentence punctuation. +Replace the newline-only `splitContentLines` helper with the article-detail behavior: normalize line endings, preserve explicit line order, and split every line by Chinese or English sentence punctuation. Protect decimals, abbreviations such as `U.S.`, and name initials such as `J.` so sentence indexes do not drift. ```ts +const shouldKeepEnglishPeriod = (text: string, index: number) => { + const previous = text[index - 1] || '' + const next = text[index + 1] || '' + if (/\d/.test(previous) && /\d/.test(next)) return true + if (/[A-Za-z]/.test(previous) && /[A-Za-z]/.test(next)) return true + + const token = text.slice(0, index).match(/([A-Za-z](?:\.[A-Za-z])*)$/)?.[1] || '' + const nextNonSpace = text.slice(index + 1).match(/\S/)?.[0] || '' + if (/^(?:[A-Za-z]\.)+[A-Za-z]$/.test(token)) return true + return /^[A-Z]$/.test(token) && /[A-Za-z]/.test(nextNonSpace) +} + +const splitSentenceLine = (text: string) => { + const sentences: string[] = [] + let start = 0 + + for (let index = 0; index < text.length; index += 1) { + const character = text[index] + const isStop = /[。!?!?]/.test(character) || character === '.' + if (!isStop || (character === '.' && shouldKeepEnglishPeriod(text, index))) continue + + let end = index + 1 + while (end < text.length && /[。!?.!?]/.test(text[end])) end += 1 + while (end < text.length && /[”’"'))】\]]/.test(text[end])) end += 1 + + const sentence = text.slice(start, end).trim() + if (sentence) sentences.push(sentence) + start = end + index = end - 1 + } + + const tail = text.slice(start).trim() + if (tail) sentences.push(tail) + return sentences +} + const splitContentLines = (value: unknown) => { const normalized = String(value || '') .replace(/\r\n/g, '\n') @@ -32,15 +68,9 @@ const splitContentLines = (value: unknown) => { .map((line) => line.replace(/\s+/g, ' ').trim()) .filter(Boolean) - if (manualLines.length > 1) return manualLines + const sentenceLines = manualLines.flatMap((line) => splitSentenceLine(line)) - const sentenceLines = normalized - .replace(/\s+/g, ' ') - .match(/[^。!?.!?]+[。!?.!?]?/g) - ?.map((line) => line.trim()) - .filter(Boolean) - - return sentenceLines?.length ? sentenceLines : [normalized] + return sentenceLines.length ? sentenceLines : [normalized] } ``` @@ -61,23 +91,22 @@ const bilingualBlocks = computed(() => { const hasBilingualContent = computed(() => originalLines.value.length > 0 && translatedLines.value.length > 0) ``` -- [ ] **Step 3: Make expansion evaluate the complete bilingual body** +- [ ] **Step 3: Collapse only at complete pair boundaries** -Use the combined bilingual text length and group count when bilingual content exists; keep the existing summary rule for ordinary posts. +Treat multiple bilingual pairs as expandable. When collapsed, render only the first complete pair instead of clipping the container by height; keep the existing summary rule for ordinary posts. ```ts const previewCanExpand = computed(() => { - if (hasBilingualContent.value) { - const contentLength = bilingualBlocks.value.reduce( - (total, block) => total + block.original.length + block.translated.length, - 0 - ) - return contentLength > 180 || bilingualBlocks.value.length > 3 - } + if (hasBilingualContent.value) return bilingualBlocks.value.length > 1 const summary = postPreview.value.summary return summary.length > 90 || summary.split('\n').length > 3 }) + +const visibleBilingualBlocks = computed(() => { + if (!previewCanExpand.value || previewExpanded.value) return bilingualBlocks.value + return bilingualBlocks.value.slice(0, 1) +}) ``` ### Task 2: Render one original sentence followed by one translation @@ -90,9 +119,8 @@ const previewCanExpand = computed(() => { Place this branch before the ordinary title/summary content. The branch replaces both the artificial title and the former standalone translation card. ```vue - - + {{ block.original }} {{ block.translated }} @@ -139,11 +167,6 @@ Use a single-column, mobile-first hierarchy. Keep the original dominant and the display: flex; flex-direction: column; gap: 24rpx; - - &--collapsed { - max-height: 430rpx; - overflow: hidden; - } } &__bilingual-pair { @@ -178,7 +201,32 @@ Use a single-column, mobile-first hierarchy. Keep the original dominant and the Delete the old `&__translation`, `&__translation-label`, and `&__translation-text` rules so the stylesheet matches the template. -### Task 4: Review and commit the source change +### Task 4: Restore translation after a paid post is unlocked + +**Files:** +- Modify: `uniapp/src/packages_community/pages/post_detail.vue` + +- [ ] **Step 1: Trigger the existing translation flow after unlocking** + +After applying the purchased content, start the existing non-blocking translation request only for a Trump post that has no translation and has not already started translation. Keep `loadUserPoints()` unchanged. + +```ts +const applyUnlockedContent = async (result: any) => { + if (!post.value || !result?.content) return + post.value.content = result.content + post.value.is_locked = false + post.value.is_purchased = true + if (!result.from_cache) post.value.paid_count = Number(post.value.paid_count || 0) + 1 + previewExpanded.value = true + if (isTrumpPost.value && !translatedText.value && !autoTranslateStarted.value) { + autoTranslateStarted.value = true + void requestTranslate() + } + await loadUserPoints() +} +``` + +### Task 5: Review and commit the source change **Files:** - Review: `uniapp/src/packages_community/pages/post_detail.vue` @@ -212,7 +260,8 @@ Confirm from the diff that: - bilingual mode requires both original content and translated content; - unmatched trailing sentences remain rendered; -- no API or paid-content logic changed; +- no API contract, payment confirmation, or points calculation changed; +- the only unlock-path change is the guarded non-blocking translation trigger; - normal posts retain the old title/summary branch; - old translation-card selectors have no remaining references. diff --git a/docs/superpowers/specs/2026-08-02-community-post-bilingual-pairing-design.md b/docs/superpowers/specs/2026-08-02-community-post-bilingual-pairing-design.md index 9794657..b3c4762 100644 --- a/docs/superpowers/specs/2026-08-02-community-post-bilingual-pairing-design.md +++ b/docs/superpowers/specs/2026-08-02-community-post-bilingual-pairing-design.md @@ -10,6 +10,7 @@ - 参考 `uniapp/src/pages/news_detail/news_detail.vue` 的句子拆分与按序配对逻辑。 - 双语帖子不再把正文前 42 个字符自动截成标题,避免产生并不存在的标题。 - 原文或译文句数不一致时不丢失任何内容。 +- 付费特朗普帖子解锁后若尚无译文,异步触发现有翻译接口,无需刷新页面。 - 无译文帖子、付费锁定、图片、标签、互动和评论逻辑保持不变。 ## 方案比较 @@ -29,7 +30,7 @@ ## 数据与配对规则 1. 将原文和译文统一换行符并清理空行。 -2. 有人工换行时优先按人工行拆分;只有一行时,按中英文句末标点拆分。 +2. 先保留人工换行的顺序,再将每一行按中英文句末标点拆成句子。 3. 使用两个拆分结果的最大长度创建配对数组。 4. 同一索引的原文和译文组成一组;缺少一侧时只显示存在的内容。 5. 译文为空时继续使用原来的标题、摘要展示,不进入双语模式。 @@ -48,9 +49,9 @@ - 原文或译文只有一段:仍形成一个配对组。 - 句数不一致:尾部未配对句子继续显示。 - 付费内容锁定:沿用后端返回的可见内容范围,不在前端拼接隐藏内容。 +- 付费内容解锁:先展示完整原文,再异步获取译文并切换为逐句配对,不阻塞解锁成功提示。 ## 改动范围 - 修改 `uniapp/src/packages_community/pages/post_detail.vue`。 - 不修改 API、后端翻译接口、数据库和页面路由。 - diff --git a/uniapp/src/packages_community/pages/post_detail.vue b/uniapp/src/packages_community/pages/post_detail.vue index 4a3fb90..4a593cd 100644 --- a/uniapp/src/packages_community/pages/post_detail.vue +++ b/uniapp/src/packages_community/pages/post_detail.vue @@ -53,24 +53,36 @@ 付费内容 - {{ postPreview.title }} - - - - {{ postPreview.summary }} - - - {{ previewExpanded ? '收起' : '展开' }} - + + + {{ block.original }} + {{ block.translated }} + - - 译文 - {{ line }} - + + + + {{ previewExpanded ? '收起' : '展开' }} + @@ -335,19 +347,80 @@ const splitPostPreview = (value: unknown) => { return { title: '', summary: content } } -const splitContentLines = (value: unknown) => String(value || '') - .replace(/\r\n/g, '\n') - .replace(/\r/g, '\n') - .split('\n') - .map((line) => line.trim()) - .filter(Boolean) +const shouldKeepEnglishPeriod = (text: string, index: number) => { + const previous = text[index - 1] || '' + const next = text[index + 1] || '' + if (/\d/.test(previous) && /\d/.test(next)) return true + if (/[A-Za-z]/.test(previous) && /[A-Za-z]/.test(next)) return true + + const token = text.slice(0, index).match(/([A-Za-z](?:\.[A-Za-z])*)$/)?.[1] || '' + const nextNonSpace = text.slice(index + 1).match(/\S/)?.[0] || '' + if (/^(?:[A-Za-z]\.)+[A-Za-z]$/.test(token)) return true + return /^[A-Z]$/.test(token) && /[A-Za-z]/.test(nextNonSpace) +} + +const splitSentenceLine = (text: string) => { + const sentences: string[] = [] + let start = 0 + + for (let index = 0; index < text.length; index += 1) { + const character = text[index] + const isStop = /[。!?!?]/.test(character) || character === '.' + if (!isStop || (character === '.' && shouldKeepEnglishPeriod(text, index))) continue + + let end = index + 1 + while (end < text.length && /[。!?.!?]/.test(text[end])) end += 1 + while (end < text.length && /[”’"'))】\]]/.test(text[end])) end += 1 + + const sentence = text.slice(start, end).trim() + if (sentence) sentences.push(sentence) + start = end + index = end - 1 + } + + const tail = text.slice(start).trim() + if (tail) sentences.push(tail) + return sentences +} + +const splitContentLines = (value: unknown) => { + const normalized = String(value || '') + .replace(/\r\n/g, '\n') + .replace(/\r/g, '\n') + .trim() + if (!normalized) return [] + + const manualLines = normalized + .split('\n') + .map((line) => line.replace(/\s+/g, ' ').trim()) + .filter(Boolean) + + const sentenceLines = manualLines.flatMap((line) => splitSentenceLine(line)) + + return sentenceLines.length ? sentenceLines : [normalized] +} const postPreview = computed(() => splitPostPreview(post.value?.content)) +const originalLines = computed(() => splitContentLines(post.value?.content)) const translatedLines = computed(() => splitContentLines(translatedText.value)) +const bilingualBlocks = computed(() => { + const size = Math.max(originalLines.value.length, translatedLines.value.length) + return Array.from({ length: size }, (_, index) => ({ + original: originalLines.value[index] || '', + translated: translatedLines.value[index] || '' + })).filter((block) => block.original || block.translated) +}) +const hasBilingualContent = computed(() => originalLines.value.length > 0 && translatedLines.value.length > 0) const previewCanExpand = computed(() => { + if (hasBilingualContent.value) return bilingualBlocks.value.length > 1 + const summary = postPreview.value.summary return summary.length > 90 || summary.split('\n').length > 3 }) +const visibleBilingualBlocks = computed(() => { + if (!previewCanExpand.value || previewExpanded.value) return bilingualBlocks.value + return bilingualBlocks.value.slice(0, 1) +}) const pageTitle = computed(() => postPreview.value.title || postPreview.value.summary.slice(0, 40) || '帖子详情') const userPointsText = computed(() => { if (!getToken()) return '登录后查看' @@ -555,6 +628,10 @@ const applyUnlockedContent = async (result: any) => { post.value.is_purchased = true if (!result.from_cache) post.value.paid_count = Number(post.value.paid_count || 0) + 1 previewExpanded.value = true + if (isTrumpPost.value && !translatedText.value && !autoTranslateStarted.value) { + autoTranslateStarted.value = true + void requestTranslate() + } await loadUserPoints() } @@ -659,7 +736,7 @@ const openSourceUrl = () => { uni.navigateTo({ url: `/pages/webview/webview?url=${encodeURIComponent(trumpSourceUrl.value)}` }) } -const requestTranslate = async () => { +async function requestTranslate() { if (!post.value?.id) return try { const result = await translatePost({ post_id: post.value.id }) @@ -984,26 +1061,38 @@ const formatCommentTime = (value: any) => { white-space: nowrap; } - &__translation { - margin-top: 22rpx; - padding: 20rpx 22rpx; + &__bilingual { + margin-top: 20rpx; display: flex; flex-direction: column; - gap: 12rpx; - border-radius: 14rpx; - background: #f6f8fc; + gap: 24rpx; } - &__translation-label { - color: #1468f5; - font-size: 23rpx; - font-weight: 700; + &__bilingual-pair { + display: flex; + flex-direction: column; + gap: 8rpx; } - &__translation-text { - color: #465164; + &__bilingual-original { + color: #20242b; + font-size: 29rpx; + font-weight: 500; + line-height: 1.72; + word-break: break-word; + } + + &__bilingual-translated { + color: #647187; font-size: 27rpx; line-height: 1.72; + word-break: break-word; + } + + &__expand--bilingual { + display: block; + margin-top: 12rpx; + text-align: right; } }