feat: align bilingual post phrases
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
# Community Post Bilingual Phrase 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:** Split community post originals and translations at comma, semicolon, period, exclamation, and question punctuation, then render exact one-to-one groups by merging adjacent short fragments when counts differ.
|
||||
|
||||
**Architecture:** Keep the implementation local to `post_detail.vue`. Replace sentence-only splitting with punctuation phrase splitting, protect numeric and English abbreviation punctuation, normalize both sides to the same count with a deterministic shortest-fragment merge, and reuse the existing bilingual rendering computed property.
|
||||
|
||||
**Tech Stack:** uni-app, Vue 3 `<script setup>`, TypeScript
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Split text into protected punctuation phrases
|
||||
|
||||
**Files:**
|
||||
- Modify: `uniapp/src/packages_community/pages/post_detail.vue:350-402`
|
||||
|
||||
- [ ] **Step 1: Add delimiter protection and phrase splitting**
|
||||
|
||||
Replace `splitSentenceLine` with the following helpers. The delimiter set includes Chinese and English comma, semicolon, period, exclamation, and question punctuation. `shouldKeepEnglishPeriod` remains unchanged.
|
||||
|
||||
```ts
|
||||
const isPhraseDelimiter = (character: string) =>
|
||||
/[,,;;。!?!?]/.test(character) || character === '.'
|
||||
|
||||
const shouldKeepPhraseDelimiter = (text: string, index: number, character: string) => {
|
||||
if (character === '.' && shouldKeepEnglishPeriod(text, index)) return true
|
||||
|
||||
const previous = text[index - 1] || ''
|
||||
const next = text[index + 1] || ''
|
||||
return /[,,]/.test(character) && /\d/.test(previous) && /\d/.test(next)
|
||||
}
|
||||
|
||||
const splitPhraseLine = (text: string) => {
|
||||
const phrases: string[] = []
|
||||
let start = 0
|
||||
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
const character = text[index]
|
||||
if (!isPhraseDelimiter(character) || shouldKeepPhraseDelimiter(text, index, character)) continue
|
||||
|
||||
let end = index + 1
|
||||
while (end < text.length && isPhraseDelimiter(text[end])) end += 1
|
||||
while (end < text.length && /[”’"'))】\]]/.test(text[end])) end += 1
|
||||
|
||||
const phrase = text.slice(start, end).trim()
|
||||
if (phrase) phrases.push(phrase)
|
||||
start = end
|
||||
index = end - 1
|
||||
}
|
||||
|
||||
const tail = text.slice(start).trim()
|
||||
if (tail) phrases.push(tail)
|
||||
return phrases
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Rename the content splitter and preserve manual-line order**
|
||||
|
||||
Replace `splitContentLines` with this function and update the original/translation computed properties to use it.
|
||||
|
||||
```ts
|
||||
const splitContentSegments = (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 segments = manualLines.flatMap((line) => splitPhraseLine(line))
|
||||
return segments.length ? segments : [normalized]
|
||||
}
|
||||
|
||||
const originalSegments = computed(() => splitContentSegments(post.value?.content))
|
||||
const translatedSegments = computed(() => splitContentSegments(translatedText.value))
|
||||
```
|
||||
|
||||
### Task 2: Normalize both sides to an exact one-to-one count
|
||||
|
||||
**Files:**
|
||||
- Modify: `uniapp/src/packages_community/pages/post_detail.vue:402-424`
|
||||
|
||||
- [ ] **Step 1: Add deterministic adjacent-fragment merging**
|
||||
|
||||
Add helpers that identify the shortest body fragment, preserve punctuation, restore spacing between English fragments, and prefer forward merging for comma/semicolon clauses.
|
||||
|
||||
```ts
|
||||
const getSegmentBodyLength = (segment: string) =>
|
||||
segment.replace(/[,,;;。!?.!?]/g, '').replace(/\s+/g, '').length
|
||||
|
||||
const joinSegments = (left: string, right: string) => {
|
||||
const normalizedLeft = left.trim()
|
||||
const normalizedRight = right.trim()
|
||||
const leftBodyEnd = normalizedLeft
|
||||
.replace(/[”’"'))】\]]+$/g, '')
|
||||
.replace(/[,,;;。!?.!?]+$/g, '')
|
||||
const needsSpace = /[A-Za-z0-9]$/.test(leftBodyEnd) && /^[A-Za-z0-9]/.test(normalizedRight)
|
||||
return `${normalizedLeft}${needsSpace ? ' ' : ''}${normalizedRight}`
|
||||
}
|
||||
|
||||
const mergeSegmentsToCount = (segments: string[], targetCount: number) => {
|
||||
const merged = segments.filter(Boolean).slice()
|
||||
if (targetCount <= 0) return []
|
||||
|
||||
while (merged.length > targetCount) {
|
||||
let shortestIndex = 0
|
||||
for (let index = 1; index < merged.length; index += 1) {
|
||||
if (getSegmentBodyLength(merged[index]) < getSegmentBodyLength(merged[shortestIndex])) {
|
||||
shortestIndex = index
|
||||
}
|
||||
}
|
||||
|
||||
const ending = merged[shortestIndex].replace(/[”’"'))】\]]+$/g, '').slice(-1)
|
||||
const mergeForward = shortestIndex === 0
|
||||
|| (shortestIndex < merged.length - 1 && /[,,;;]/.test(ending))
|
||||
|
||||
if (mergeForward) {
|
||||
merged.splice(shortestIndex, 2, joinSegments(merged[shortestIndex], merged[shortestIndex + 1]))
|
||||
} else {
|
||||
merged.splice(shortestIndex - 1, 2, joinSegments(merged[shortestIndex - 1], merged[shortestIndex]))
|
||||
}
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace max-length pairing with equal-count pairing**
|
||||
|
||||
Replace `bilingualBlocks` and update `hasBilingualContent`. The shorter side defines the final number of groups; only the longer side is merged down, so every rendered group contains both values.
|
||||
|
||||
```ts
|
||||
const bilingualBlocks = computed(() => {
|
||||
const pairCount = Math.min(originalSegments.value.length, translatedSegments.value.length)
|
||||
if (!pairCount) return []
|
||||
|
||||
const alignedOriginal = mergeSegmentsToCount(originalSegments.value, pairCount)
|
||||
const alignedTranslation = mergeSegmentsToCount(translatedSegments.value, pairCount)
|
||||
return Array.from({ length: pairCount }, (_, index) => ({
|
||||
original: alignedOriginal[index],
|
||||
translated: alignedTranslation[index]
|
||||
}))
|
||||
})
|
||||
const hasBilingualContent = computed(() => bilingualBlocks.value.length > 0)
|
||||
```
|
||||
|
||||
Keep `previewCanExpand` and `visibleBilingualBlocks` unchanged because they already operate on complete bilingual groups.
|
||||
|
||||
### Task 3: Synchronize project records and review the change
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/业务进度管理.md`
|
||||
- Modify: `docs/设计稿映射.md`
|
||||
- Review: `uniapp/src/packages_community/pages/post_detail.vue`
|
||||
|
||||
- [ ] **Step 1: Update the existing community post detail records**
|
||||
|
||||
In `docs/业务进度管理.md`, replace the existing bilingual bullet with:
|
||||
|
||||
```markdown
|
||||
- 特朗普等已有译文的帖子详情按逗号、分号、句号、感叹号和问号切成短语,采用“一段原文紧跟一段译文”显示;两侧数量不一致时自动合并较多一侧的相邻短片段,避免空侧和内容丢失,同时保护英文缩写、小数及数字千分位。
|
||||
```
|
||||
|
||||
In `docs/设计稿映射.md`, replace the community post detail row with:
|
||||
|
||||
```markdown
|
||||
| 社区帖子详情 | `uniapp/src/packages_community/pages/post_detail.vue` | 🚧 部分完成 | 2026-08-02 已按付费帖子详情设计稿重构作者内容卡、免费摘要、积分解锁卡、互动评论区和底部输入栏;已有译文的帖子按标点短语进行原文/译文一对一交错显示;430px H5 页面已复核,App/小程序与正式构建待验收 |
|
||||
```
|
||||
|
||||
Preserve all unrelated pending edits in both shared documents.
|
||||
|
||||
- [ ] **Step 2: Inspect only the intended source diff**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
git diff -- uniapp/src/packages_community/pages/post_detail.vue
|
||||
git diff --check -- uniapp/src/packages_community/pages/post_detail.vue docs/superpowers/plans/2026-08-02-community-post-bilingual-phrase-pairing.md
|
||||
```
|
||||
|
||||
Expected: the source diff only renames the splitter, adds delimiter protection and merge helpers, and updates the computed pairing model. `git diff --check` exits successfully.
|
||||
|
||||
- [ ] **Step 3: Check required references and removed names**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
rg -n "isPhraseDelimiter|shouldKeepPhraseDelimiter|splitPhraseLine|mergeSegmentsToCount|originalSegments|translatedSegments" uniapp/src/packages_community/pages/post_detail.vue
|
||||
rg -n "splitSentenceLine|splitContentLines|originalLines|translatedLines" uniapp/src/packages_community/pages/post_detail.vue
|
||||
if ($LASTEXITCODE -eq 0) { throw 'Obsolete sentence-level names remain' }
|
||||
if ($LASTEXITCODE -gt 1) { throw 'Obsolete-name scan failed' }
|
||||
```
|
||||
|
||||
Expected: all new helpers are referenced and the obsolete sentence-level names return no matches.
|
||||
|
||||
- [ ] **Step 4: Commit only the implementation files**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
git add -- docs/superpowers/plans/2026-08-02-community-post-bilingual-phrase-pairing.md uniapp/src/packages_community/pages/post_detail.vue
|
||||
git commit -m "feat: align bilingual post phrases"
|
||||
```
|
||||
|
||||
Expected: the commit contains the implementation plan and `post_detail.vue`. Existing changes in `AGENTS.md`, server/admin/match files, `docs/业务进度管理.md`, and `docs/设计稿映射.md` remain outside the commit.
|
||||
|
||||
## Verification boundary
|
||||
|
||||
The repository `AGENTS.md` prohibits automatically running builds or tests unless the user explicitly requests them. This plan therefore performs scoped static diff checks only. Suggested optional verification after separate authorization is `npm run build:h5` followed by browser inspection of community post `23361`.
|
||||
@@ -359,31 +359,41 @@ const shouldKeepEnglishPeriod = (text: string, index: number) => {
|
||||
return /^[A-Z]$/.test(token) && /[A-Za-z]/.test(nextNonSpace)
|
||||
}
|
||||
|
||||
const splitSentenceLine = (text: string) => {
|
||||
const sentences: string[] = []
|
||||
const isPhraseDelimiter = (character: string) =>
|
||||
/[,,;;。!?!?]/.test(character) || character === '.'
|
||||
|
||||
const shouldKeepPhraseDelimiter = (text: string, index: number, character: string) => {
|
||||
if (character === '.' && shouldKeepEnglishPeriod(text, index)) return true
|
||||
|
||||
const previous = text[index - 1] || ''
|
||||
const next = text[index + 1] || ''
|
||||
return /[,,]/.test(character) && /\d/.test(previous) && /\d/.test(next)
|
||||
}
|
||||
|
||||
const splitPhraseLine = (text: string) => {
|
||||
const phrases: 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
|
||||
if (!isPhraseDelimiter(character) || shouldKeepPhraseDelimiter(text, index, character)) continue
|
||||
|
||||
let end = index + 1
|
||||
while (end < text.length && /[。!?.!?]/.test(text[end])) end += 1
|
||||
while (end < text.length && isPhraseDelimiter(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)
|
||||
const phrase = text.slice(start, end).trim()
|
||||
if (phrase) phrases.push(phrase)
|
||||
start = end
|
||||
index = end - 1
|
||||
}
|
||||
|
||||
const tail = text.slice(start).trim()
|
||||
if (tail) sentences.push(tail)
|
||||
return sentences
|
||||
if (tail) phrases.push(tail)
|
||||
return phrases
|
||||
}
|
||||
|
||||
const splitContentLines = (value: unknown) => {
|
||||
const splitContentSegments = (value: unknown) => {
|
||||
const normalized = String(value || '')
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/\r/g, '\n')
|
||||
@@ -395,22 +405,64 @@ const splitContentLines = (value: unknown) => {
|
||||
.map((line) => line.replace(/\s+/g, ' ').trim())
|
||||
.filter(Boolean)
|
||||
|
||||
const sentenceLines = manualLines.flatMap((line) => splitSentenceLine(line))
|
||||
const segments = manualLines.flatMap((line) => splitPhraseLine(line))
|
||||
return segments.length ? segments : [normalized]
|
||||
}
|
||||
|
||||
return sentenceLines.length ? sentenceLines : [normalized]
|
||||
const getSegmentBodyLength = (segment: string) =>
|
||||
segment.replace(/[,,;;。!?.!?]/g, '').replace(/\s+/g, '').length
|
||||
|
||||
const joinSegments = (left: string, right: string) => {
|
||||
const normalizedLeft = left.trim()
|
||||
const normalizedRight = right.trim()
|
||||
const leftBodyEnd = normalizedLeft
|
||||
.replace(/[”’"'))】\]]+$/g, '')
|
||||
.replace(/[,,;;。!?.!?]+$/g, '')
|
||||
const needsSpace = /[A-Za-z0-9]$/.test(leftBodyEnd) && /^[A-Za-z0-9]/.test(normalizedRight)
|
||||
return `${normalizedLeft}${needsSpace ? ' ' : ''}${normalizedRight}`
|
||||
}
|
||||
|
||||
const mergeSegmentsToCount = (segments: string[], targetCount: number) => {
|
||||
const merged = segments.filter(Boolean).slice()
|
||||
if (targetCount <= 0) return []
|
||||
|
||||
while (merged.length > targetCount) {
|
||||
let shortestIndex = 0
|
||||
for (let index = 1; index < merged.length; index += 1) {
|
||||
if (getSegmentBodyLength(merged[index]) < getSegmentBodyLength(merged[shortestIndex])) {
|
||||
shortestIndex = index
|
||||
}
|
||||
}
|
||||
|
||||
const ending = merged[shortestIndex].replace(/[”’"'))】\]]+$/g, '').slice(-1)
|
||||
const mergeForward = shortestIndex === 0
|
||||
|| (shortestIndex < merged.length - 1 && /[,,;;]/.test(ending))
|
||||
|
||||
if (mergeForward) {
|
||||
merged.splice(shortestIndex, 2, joinSegments(merged[shortestIndex], merged[shortestIndex + 1]))
|
||||
} else {
|
||||
merged.splice(shortestIndex - 1, 2, joinSegments(merged[shortestIndex - 1], merged[shortestIndex]))
|
||||
}
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
const postPreview = computed(() => splitPostPreview(post.value?.content))
|
||||
const originalLines = computed(() => splitContentLines(post.value?.content))
|
||||
const translatedLines = computed(() => splitContentLines(translatedText.value))
|
||||
const originalSegments = computed(() => splitContentSegments(post.value?.content))
|
||||
const translatedSegments = computed(() => splitContentSegments(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 pairCount = Math.min(originalSegments.value.length, translatedSegments.value.length)
|
||||
if (!pairCount) return []
|
||||
|
||||
const alignedOriginal = mergeSegmentsToCount(originalSegments.value, pairCount)
|
||||
const alignedTranslation = mergeSegmentsToCount(translatedSegments.value, pairCount)
|
||||
return Array.from({ length: pairCount }, (_, index) => ({
|
||||
original: alignedOriginal[index],
|
||||
translated: alignedTranslation[index]
|
||||
}))
|
||||
})
|
||||
const hasBilingualContent = computed(() => originalLines.value.length > 0 && translatedLines.value.length > 0)
|
||||
const hasBilingualContent = computed(() => bilingualBlocks.value.length > 0)
|
||||
const previewCanExpand = computed(() => {
|
||||
if (hasBilingualContent.value) return bilingualBlocks.value.length > 1
|
||||
|
||||
|
||||
Reference in New Issue
Block a user