Files
sbnews/docs/superpowers/plans/2026-08-02-community-post-bilingual-pairing.md
T

282 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Community Post Bilingual Pairing Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Display translated community posts as vertically interleaved original/translation sentence pairs instead of separate long text blocks.
**Architecture:** Keep the change local to the community post detail page. Reuse the news detail page's newline-first, sentence-second splitting rule, derive a bilingual view model with a computed property, and render it through a dedicated template branch without changing API contracts.
**Tech Stack:** uni-app, Vue 3 `<script setup>`, TypeScript, SCSS
---
### Task 1: Add the bilingual sentence view model
**Files:**
- Modify: `uniapp/src/packages_community/pages/post_detail.vue`
- [ ] **Step 1: Upgrade the local text splitter**
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')
.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]
}
```
- [ ] **Step 2: Derive paired original and translated blocks**
Add computed original lines and a computed array whose length is the maximum of both sides. Filter only fully empty rows so an unmatched trailing sentence remains visible.
```ts
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)
```
- [ ] **Step 3: Collapse only at complete pair boundaries**
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) 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
**Files:**
- Modify: `uniapp/src/packages_community/pages/post_detail.vue`
- [ ] **Step 1: Add the bilingual template branch**
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">
<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>
<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>
```
- [ ] **Step 2: Remove the obsolete standalone translation block**
Delete the old `detail-post-card__translation`, `detail-post-card__translation-label`, and `detail-post-card__translation-text` template markup because translation is now rendered inside each bilingual pair.
### Task 3: Style the bilingual reading hierarchy
**Files:**
- Modify: `uniapp/src/packages_community/pages/post_detail.vue`
- [ ] **Step 1: Add bilingual layout styles**
Use a single-column, mobile-first hierarchy. Keep the original dominant and the translation visually subordinate without wrapping every pair in a heavy card.
```scss
&__bilingual {
margin-top: 20rpx;
display: flex;
flex-direction: column;
gap: 24rpx;
}
&__bilingual-pair {
display: flex;
flex-direction: column;
gap: 8rpx;
}
&__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;
}
```
- [ ] **Step 2: Remove unused translation-card styles**
Delete the old `&__translation`, `&__translation-label`, and `&__translation-text` rules so the stylesheet matches the template.
### 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`
- [ ] **Step 1: Inspect the scoped diff**
Run:
```powershell
git diff -- uniapp/src/packages_community/pages/post_detail.vue
```
Expected: only the bilingual template, computed pairing logic, expansion rule, and related styles change.
- [ ] **Step 2: Check whitespace and conflict markers**
Run:
```powershell
git diff --check -- uniapp/src/packages_community/pages/post_detail.vue
rg -n "<<<<<<<|=======|>>>>>>>" uniapp/src/packages_community/pages/post_detail.vue
if ($LASTEXITCODE -eq 0) { throw 'Conflict markers found' }
if ($LASTEXITCODE -gt 1) { throw 'Conflict marker scan failed' }
```
Expected: `git diff --check` exits successfully and `rg` returns no matches.
- [ ] **Step 3: Review logic against edge cases**
Confirm from the diff that:
- bilingual mode requires both original content and translated content;
- unmatched trailing sentences remain rendered;
- 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.
- [ ] **Step 4: Commit only the intended files**
Run:
```powershell
git add -- docs/superpowers/plans/2026-08-02-community-post-bilingual-pairing.md uniapp/src/packages_community/pages/post_detail.vue
git commit -m "feat: pair community post translations"
```
Expected: one commit containing the plan and the uni-app page source. Do not include existing `.playwright-mcp` or modified shared docs.
## Verification boundary
The repository `AGENTS.md` prohibits automatically running builds or tests unless the user explicitly requests them. This plan therefore stops at scoped static diff checks and code review; suggested optional verification is `npm run build:h5` plus browser inspection of post `23361` after separate authorization.