282 lines
10 KiB
Markdown
282 lines
10 KiB
Markdown
# 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.
|