feat: pair community post translations
This commit is contained in:
@@ -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
|
||||
<view v-if="hasBilingualContent" class="detail-post-card__bilingual"
|
||||
:class="{ 'detail-post-card__bilingual--collapsed': previewCanExpand && !previewExpanded }">
|
||||
<view v-for="(block, index) in bilingualBlocks" :key="`bilingual-${index}`"
|
||||
<view v-if="hasBilingualContent" class="detail-post-card__bilingual">
|
||||
<view v-for="(block, index) in visibleBilingualBlocks" :key="`bilingual-${index}`"
|
||||
class="detail-post-card__bilingual-pair">
|
||||
<text v-if="block.original" class="detail-post-card__bilingual-original">{{ block.original }}</text>
|
||||
<text v-if="block.translated" class="detail-post-card__bilingual-translated">{{ block.translated }}</text>
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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、后端翻译接口、数据库和页面路由。
|
||||
|
||||
|
||||
@@ -53,24 +53,36 @@
|
||||
<text>付费内容</text>
|
||||
</view>
|
||||
|
||||
<text v-if="postPreview.title" class="detail-post-card__title">{{ postPreview.title }}</text>
|
||||
|
||||
<view v-if="postPreview.summary" class="detail-post-card__summary-wrap">
|
||||
<text class="detail-post-card__summary"
|
||||
:class="{ 'detail-post-card__summary--collapsed': previewCanExpand && !previewExpanded }">
|
||||
{{ postPreview.summary }}
|
||||
</text>
|
||||
<text v-if="previewCanExpand" class="detail-post-card__expand"
|
||||
@tap="previewExpanded = !previewExpanded">
|
||||
{{ previewExpanded ? '收起' : '展开' }}
|
||||
</text>
|
||||
<view v-if="hasBilingualContent" class="detail-post-card__bilingual">
|
||||
<view v-for="(block, index) in visibleBilingualBlocks" :key="`bilingual-${index}`"
|
||||
class="detail-post-card__bilingual-pair">
|
||||
<text v-if="block.original"
|
||||
class="detail-post-card__bilingual-original">{{ block.original }}</text>
|
||||
<text v-if="block.translated"
|
||||
class="detail-post-card__bilingual-translated">{{ block.translated }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="translatedLines.length" class="detail-post-card__translation">
|
||||
<text class="detail-post-card__translation-label">译文</text>
|
||||
<text v-for="(line, index) in translatedLines" :key="`translated-${index}`"
|
||||
class="detail-post-card__translation-text">{{ line }}</text>
|
||||
</view>
|
||||
<template v-else>
|
||||
<text v-if="postPreview.title" class="detail-post-card__title">{{ postPreview.title }}</text>
|
||||
|
||||
<view v-if="postPreview.summary" class="detail-post-card__summary-wrap">
|
||||
<text class="detail-post-card__summary"
|
||||
:class="{ 'detail-post-card__summary--collapsed': previewCanExpand && !previewExpanded }">
|
||||
{{ postPreview.summary }}
|
||||
</text>
|
||||
<text v-if="previewCanExpand" class="detail-post-card__expand"
|
||||
@tap="previewExpanded = !previewExpanded">
|
||||
{{ previewExpanded ? '收起' : '展开' }}
|
||||
</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<text v-if="hasBilingualContent && previewCanExpand"
|
||||
class="detail-post-card__expand detail-post-card__expand--bilingual"
|
||||
@tap="previewExpanded = !previewExpanded">
|
||||
{{ previewExpanded ? '收起' : '展开' }}
|
||||
</text>
|
||||
|
||||
<view v-if="post.images && post.images.length > 0" class="detail-images">
|
||||
<view v-if="showExternalCover" class="detail-images__external" @tap="openSourceUrl">
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user