Skip to content

Commit 16bab1d

Browse files
committed
feat: 在PostCard组件中添加翻译功能,支持内容翻译显示
1 parent 45367e2 commit 16bab1d

File tree

2 files changed

+220
-0
lines changed

2 files changed

+220
-0
lines changed

src/components/posts/PostCard.vue

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,28 @@
105105
v-html="formattedContent"
106106
/>
107107

108+
<div
109+
v-if="canTranslateFeatured"
110+
class="post-featured-translate"
111+
>
112+
<v-btn
113+
variant="text"
114+
density="comfortable"
115+
prepend-icon="mdi-translate"
116+
:loading="translationLoading"
117+
@click.stop="toggleFeaturedTranslation"
118+
>
119+
{{ featuredTranslateButtonText }}
120+
</v-btn>
121+
</div>
122+
123+
<div
124+
v-if="showTranslatedContent"
125+
class="post-featured-text post-featured-translation-text"
126+
>
127+
{{ translatedContent }}
128+
</div>
129+
108130
<!-- 媒体 -->
109131
<div
110132
v-if="mediaItems.length"
@@ -765,6 +787,7 @@ const props = defineProps({
765787
contextEmbedData: { type: Object, default: () => ({}) },
766788
hideCurrentContextBase: { type: Boolean, default: false },
767789
featured: { type: Boolean, default: false },
790+
enableTranslation: { type: Boolean, default: false },
768791
});
769792
770793
const emit = defineEmits(["deleted", "created", "updated", "focus-reply"]);
@@ -781,6 +804,8 @@ const actionLoading = ref(false);
781804
const mediaViewerOpen = ref(false);
782805
const mediaViewerIndex = ref(0);
783806
807+
const TRANSLATE_API_BASE = "https://translate.houlang.cloud";
808+
784809
// Post text ref for scratchblocks rendering
785810
const postTextRef = ref(null);
786811
@@ -869,6 +894,42 @@ const displayContent = computed(() => {
869894
return props.post?.content ?? "";
870895
});
871896
897+
const translatedContent = ref("");
898+
const translationLoading = ref(false);
899+
const translationExpanded = ref(false);
900+
const detectedSourceLang = ref("");
901+
const shouldShowTranslateButton = ref(false);
902+
const languageDetecting = ref(false);
903+
904+
const isChineseLanguage = (lang) => /^zh(?:$|[-_])/i.test(String(lang || ""));
905+
906+
const canDetectFeaturedLanguage = computed(() => {
907+
return (
908+
props.featured &&
909+
props.enableTranslation &&
910+
!isDeleted.value &&
911+
Boolean(translationSourceText.value)
912+
);
913+
});
914+
915+
const canTranslateFeatured = computed(() => {
916+
return (
917+
canDetectFeaturedLanguage.value &&
918+
shouldShowTranslateButton.value
919+
);
920+
});
921+
922+
const showTranslatedContent = computed(() => {
923+
return translationExpanded.value && Boolean(translatedContent.value);
924+
});
925+
926+
const featuredTranslateButtonText = computed(() => {
927+
if (translationLoading.value) return "翻译中...";
928+
if (showTranslatedContent.value) return "收起";
929+
if (translatedContent.value) return "翻译";
930+
return "翻译";
931+
});
932+
872933
const formattedContent = computed(() => {
873934
if (!displayContent.value) return "";
874935
let text = displayContent.value;
@@ -972,6 +1033,34 @@ const formattedContent = computed(() => {
9721033
return text;
9731034
});
9741035
1036+
const translationSourceText = computed(() => {
1037+
if (!formattedContent.value) return "";
1038+
let text = formattedContent.value;
1039+
1040+
text = text
1041+
.replace(/<br\s*\/?>/gi, "\n")
1042+
.replace(/<\/p>\s*<p[^>]*>/gi, "\n\n")
1043+
.replace(/<[^>]*>/g, "");
1044+
1045+
let prev;
1046+
do {
1047+
prev = text;
1048+
text = text
1049+
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)))
1050+
.replace(/&#x([0-9a-fA-F]+);/g, (_, n) => String.fromCharCode(parseInt(n, 16)))
1051+
.replace(/&quot;/g, '"')
1052+
.replace(/&apos;/g, "'")
1053+
.replace(/&#39;/g, "'")
1054+
.replace(/&#34;/g, '"')
1055+
.replace(/&amp;/g, "&")
1056+
.replace(/&lt;/g, "<")
1057+
.replace(/&gt;/g, ">")
1058+
.replace(/&nbsp;/g, " ");
1059+
} while (text !== prev);
1060+
1061+
return text.trim();
1062+
});
1063+
9751064
// Scratchblocks: 检测内容是否包含积木代码块
9761065
const hasScratchBlocks = computed(() => {
9771066
if (!displayContent.value) return false;
@@ -1022,6 +1111,68 @@ watch(
10221111
{ immediate: true },
10231112
);
10241113
1114+
watch(
1115+
() => postId.value,
1116+
() => {
1117+
translatedContent.value = "";
1118+
translationExpanded.value = false;
1119+
translationLoading.value = false;
1120+
detectedSourceLang.value = "";
1121+
shouldShowTranslateButton.value = false;
1122+
languageDetecting.value = false;
1123+
},
1124+
);
1125+
1126+
const detectFeaturedLanguage = async ({ silent = true } = {}) => {
1127+
const text = translationSourceText.value;
1128+
if (!text || !canDetectFeaturedLanguage.value) {
1129+
detectedSourceLang.value = "";
1130+
shouldShowTranslateButton.value = false;
1131+
return;
1132+
}
1133+
1134+
languageDetecting.value = true;
1135+
try {
1136+
const detectRes = await fetch(`${TRANSLATE_API_BASE}/detect`, {
1137+
method: "POST",
1138+
headers: { "Content-Type": "application/json" },
1139+
body: JSON.stringify({
1140+
text,
1141+
minConfidence: 0,
1142+
}),
1143+
});
1144+
if (!detectRes.ok) {
1145+
throw new Error(`语言检测失败 (${detectRes.status})`);
1146+
}
1147+
const detectJson = await detectRes.json();
1148+
const fromLang = String(detectJson?.language || "").trim();
1149+
1150+
detectedSourceLang.value = fromLang;
1151+
shouldShowTranslateButton.value = Boolean(fromLang) && !isChineseLanguage(fromLang);
1152+
} catch (e) {
1153+
detectedSourceLang.value = "";
1154+
shouldShowTranslateButton.value = true;
1155+
if (!silent) {
1156+
showSnackbar(e?.message || "语言检测失败", "error");
1157+
}
1158+
} finally {
1159+
languageDetecting.value = false;
1160+
}
1161+
};
1162+
1163+
watch(
1164+
() => [postId.value, translationSourceText.value, canDetectFeaturedLanguage.value],
1165+
async () => {
1166+
translatedContent.value = "";
1167+
translationExpanded.value = false;
1168+
detectedSourceLang.value = "";
1169+
shouldShowTranslateButton.value = false;
1170+
if (!canDetectFeaturedLanguage.value) return;
1171+
await detectFeaturedLanguage({ silent: true });
1172+
},
1173+
{ immediate: true },
1174+
);
1175+
10251176
// Stats
10261177
const stats = computed(() => {
10271178
const s = props.post?.stats || {};
@@ -1576,6 +1727,66 @@ const sharePost = async () => {
15761727
}
15771728
};
15781729
1730+
const fetchFeaturedTranslation = async () => {
1731+
const text = translationSourceText.value;
1732+
if (!text) return;
1733+
1734+
translationLoading.value = true;
1735+
try {
1736+
if (!detectedSourceLang.value) {
1737+
await detectFeaturedLanguage({ silent: false });
1738+
}
1739+
1740+
const fromLang = detectedSourceLang.value;
1741+
1742+
if (!fromLang || isChineseLanguage(fromLang)) {
1743+
translatedContent.value = "";
1744+
translationExpanded.value = false;
1745+
return;
1746+
}
1747+
1748+
const translateRes = await fetch(`${TRANSLATE_API_BASE}/translate`, {
1749+
method: "POST",
1750+
headers: { "Content-Type": "application/json" },
1751+
body: JSON.stringify({
1752+
from: fromLang,
1753+
to: "zh-Hans",
1754+
text,
1755+
}),
1756+
});
1757+
if (!translateRes.ok) {
1758+
throw new Error(`翻译请求失败 (${translateRes.status})`);
1759+
}
1760+
const translateJson = await translateRes.json();
1761+
if (!translateJson?.result) {
1762+
throw new Error("未获取到翻译结果");
1763+
}
1764+
1765+
translatedContent.value = String(translateJson.result);
1766+
translationExpanded.value = true;
1767+
} catch (e) {
1768+
showSnackbar(e?.message || "翻译失败,请稍后重试", "error");
1769+
} finally {
1770+
translationLoading.value = false;
1771+
}
1772+
};
1773+
1774+
const toggleFeaturedTranslation = async () => {
1775+
if (!canTranslateFeatured.value || translationLoading.value) return;
1776+
1777+
if (showTranslatedContent.value) {
1778+
translationExpanded.value = false;
1779+
return;
1780+
}
1781+
1782+
if (translatedContent.value) {
1783+
translationExpanded.value = true;
1784+
return;
1785+
}
1786+
1787+
await fetchFeaturedTranslation();
1788+
};
1789+
15791790
const openMediaViewer = (index) => {
15801791
mediaViewerIndex.value = index;
15811792
mediaViewerOpen.value = true;
@@ -2140,6 +2351,14 @@ const openMediaViewer = (index) => {
21402351
margin: 8px 0;
21412352
}
21422353
2354+
.post-featured-translate {
2355+
margin-top: 8px;
2356+
}
2357+
2358+
.post-featured-translation-text {
2359+
margin-top: 8px;
2360+
}
2361+
21432362
.post-featured-media {
21442363
margin-top: 16px;
21452364
}

src/pages/app/posts/[id].vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
<PostCard
5959
:post="post"
6060
:includes="mergedIncludes"
61+
:enable-translation="true"
6162
featured
6263
@focus-reply="focusReply"
6364
@deleted="handlePostDeleted"

0 commit comments

Comments
 (0)