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
770793const emit = defineEmits ([" deleted" , " created" , " updated" , " focus-reply" ]);
@@ -781,6 +804,8 @@ const actionLoading = ref(false);
781804const mediaViewerOpen = ref (false );
782805const mediaViewerIndex = ref (0 );
783806
807+ const TRANSLATE_API_BASE = " https://translate.houlang.cloud" ;
808+
784809// Post text ref for scratchblocks rendering
785810const 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+
872933const 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 (/ "/ g , ' "' )
1052+ .replace (/ '/ g , " '" )
1053+ .replace (/ '/ g , " '" )
1054+ .replace (/ "/ g , ' "' )
1055+ .replace (/ &/ g , " &" )
1056+ .replace (/ </ g , " <" )
1057+ .replace (/ >/ g , " >" )
1058+ .replace (/ / g , " " );
1059+ } while (text !== prev);
1060+
1061+ return text .trim ();
1062+ });
1063+
9751064// Scratchblocks: 检测内容是否包含积木代码块
9761065const 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
10261177const 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+
15791790const 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}
0 commit comments