From 2860aaf01a3fc41494e1fd240a623d271a028473 Mon Sep 17 00:00:00 2001 From: Hurt6465-ai Date: Mon, 23 Feb 2026 20:58:03 +0630 Subject: [PATCH 001/548] =?UTF-8?q?=E6=96=B0=E4=B8=BB=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- themes/simple/index.js | 610 ++++++++++++++++++++++++++++++++++------- 1 file changed, 511 insertions(+), 99 deletions(-) diff --git a/themes/simple/index.js b/themes/simple/index.js index 40bc987baca..50de16827a7 100644 --- a/themes/simple/index.js +++ b/themes/simple/index.js @@ -7,8 +7,29 @@ import { isBrowser } from '@/lib/utils' import { Transition } from '@headlessui/react' import dynamic from 'next/dynamic' import SmartLink from '@/components/SmartLink' +import Link from 'next/link' import { useRouter } from 'next/router' -import { createContext, useContext, useEffect, useRef } from 'react' +import { createContext, useContext, useEffect, useRef, useState } from 'react' +import { + BookOpen, + BookText, + ChevronRight, + Compass, + FileText, + Globe, + Globe2, + Layers3, + Library, + Lightbulb, + Menu, + MessageCircle, + Mic, + Music2, + Star, + Users, + Volume2, + X +} from 'lucide-react' import BlogPostBar from './components/BlogPostBar' import CONFIG from './config' import { Style } from './style' @@ -18,6 +39,10 @@ const AlgoliaSearchModal = dynamic( { ssr: false } ) +const BookLibrary = dynamic(() => import('@/components/BookLibrary'), { + ssr: false +}) + // 主题组件 const BlogListScroll = dynamic(() => import('./components/BlogListScroll'), { ssr: false @@ -59,16 +84,439 @@ const RecommendPosts = dynamic(() => import('./components/RecommendPosts'), { const ThemeGlobalSimple = createContext() export const useSimpleGlobal = () => useContext(ThemeGlobalSimple) +// ===================== 学习首页配置 ===================== +const pinyinNav = [ + { + zh: '声母', + mm: 'ဗျည်း', + icon: Mic, + href: '/pinyin/initials', + bg: 'bg-blue-100', + iconColor: 'text-blue-600' + }, + { + zh: '韵母', + mm: 'သရ', + icon: Music2, + href: '/pinyin/finals', + bg: 'bg-emerald-100', + iconColor: 'text-emerald-600' + }, + { + zh: '整体', + mm: 'အသံတွဲ', + icon: Layers3, + href: '/pinyin/syllables', + bg: 'bg-purple-100', + iconColor: 'text-purple-600' + }, + { + zh: '声调', + mm: 'အသံ', + icon: FileText, + href: '/pinyin/tones', + bg: 'bg-orange-100', + iconColor: 'text-orange-600' + } +] + +const coreTools = [ + { + zh: 'AI 翻译', + mm: 'AI ဘာသာပြန်', + icon: Globe, + href: '/ai-translate', + bg: 'bg-indigo-100', + iconColor: 'text-indigo-600' + }, + { + zh: '免费书籍', + mm: 'စာကြည့်တိုက်', + icon: Library, + action: 'open-library', + bg: 'bg-cyan-100', + iconColor: 'text-cyan-600' + }, + { + zh: '单词收藏', + mm: 'မှတ်ထားသော စာလုံး', + icon: Star, + href: '/words', + bg: 'bg-slate-200', + iconColor: 'text-slate-700' + }, + { + zh: '口语收藏', + mm: 'မှတ်ထားသော စကားပြော', + icon: Volume2, + href: '/oral', + bg: 'bg-slate-200', + iconColor: 'text-slate-700' + } +] + +const drawerLinks = [ + { label: '首页', href: '/' }, + { label: 'HSK 课程', href: '/course/hsk1' }, + { label: '免费书籍', action: 'open-library' }, + { label: '设置', href: '/settings' } +] + +const systemCourses = [ + { + badge: 'Words', + sub: '词汇 (VOCABULARY)', + title: '日常高频词汇', + mmDesc: 'အခြေခံ စကားလုံးများကို လေ့လာပါ。', + zhDesc: '掌握生活与考试中最核心的词汇', + bgImg: + 'https://images.unsplash.com/photo-1456513080510-7bf3a84b82f8?auto=format&fit=crop&q=80&w=1200', + href: '/course/words' + }, + { + badge: 'Spoken', + sub: '口语 (ORAL)', + title: '地道汉语口语', + mmDesc: 'နေ့စဉ်သုံး စကားပြောဆိုမှုများကို လေ့ကျင့်ပါ。', + zhDesc: '跟读与练习最纯正的日常交流口语', + bgImg: + 'https://images.unsplash.com/photo-1528712306091-ed0763094c98?auto=format&fit=crop&q=80&w=1200', + href: '/course/oral' + }, + { + badge: 'HSK 1', + sub: '入门 (INTRO)', + title: 'HSK 1', + mmDesc: 'အသုံးအများဆုံး စကားလုံးများနှင့် သဒ္ဒါ', + zhDesc: '掌握最常用词语和基本语法', + bgImg: + 'https://images.unsplash.com/photo-1548013146-72479768bada?auto=format&fit=crop&q=80&w=1200', + href: '/course/hsk1' + } +] + +const drawerWidth = 288 + +const LayoutLearningHome = () => { + const [isDrawerOpen, setIsDrawerOpen] = useState(false) + const [drawerX, setDrawerX] = useState(-drawerWidth) + const [isDragging, setIsDragging] = useState(false) + const [isLibraryOpen, setIsLibraryOpen] = useState(false) + + const touchStartXRef = useRef(null) + const drawerStartXRef = useRef(-drawerWidth) + + const openDrawer = () => { + setIsDrawerOpen(true) + setDrawerX(0) + } + + const closeDrawer = () => { + setIsDrawerOpen(false) + setDrawerX(-drawerWidth) + } + + const handleTouchStart = e => { + const startX = e.touches?.[0]?.clientX ?? 0 + if (!isDrawerOpen && startX > 24) return + touchStartXRef.current = startX + drawerStartXRef.current = drawerX + setIsDragging(true) + } + + const handleTouchMove = e => { + if (!isDragging || touchStartXRef.current === null) return + const currentX = e.touches?.[0]?.clientX ?? 0 + const deltaX = currentX - touchStartXRef.current + const nextX = Math.max( + -drawerWidth, + Math.min(0, drawerStartXRef.current + deltaX) + ) + setDrawerX(nextX) + } + + const handleTouchEnd = () => { + if (!isDragging) return + setIsDragging(false) + touchStartXRef.current = null + if (drawerX > -drawerWidth * 0.55) openDrawer() + else closeDrawer() + } + + const openProgress = (drawerX + drawerWidth) / drawerWidth + const overlayOpacity = Math.max(0, Math.min(0.5, openProgress * 0.5)) + const showDrawerLayer = isDrawerOpen || isDragging || drawerX > -drawerWidth + + const glassCard = + 'rounded-2xl border border-white/80 bg-white/94 backdrop-blur-2xl shadow-[0_10px_26px_rgba(15,23,42,0.12)]' + const glassCardHover = `${glassCard} transition-all duration-200 hover:bg-white/97` + + return ( +
+
+
+
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+

中缅文学习中心

+

Chinese Learning Hub

+
+
+ +
+ {pinyinNav.map(item => ( + +
+
+ +
+
+

{item.zh}

+

{item.mm}

+
+
+ + ))} +
+ +
+ +
+
+ +
+
+

发音技巧 (Tips)

+

အသံထွက်နည်းလမ်းများ

+
+
+ + +
+ +
+ {coreTools.map(tool => { + const content = ( + <> +
+ +
+
+

{tool.zh}

+

{tool.mm}

+
+ + ) + + if (tool.action === 'open-library') { + return ( + + ) + } + + if (tool.external && tool.href) { + return ( + + {content} + + ) + } + + return ( + + {content} + + ) + })} +
+ +
+
+ +

+ SYSTEM COURSES (သင်ရိုး) +

+
+ +
+ {systemCourses.map(course => ( + +
+
+
+
+ + {course.badge} + +
+

{course.sub}

+

{course.title}

+

{course.mmDesc}

+

{course.zhDesc}

+
+
+ + ))} +
+
+
+ + + + setIsLibraryOpen(false)} /> +
+ ) +} + /** * 基础布局 - * - * @param {*} props - * @returns */ const LayoutBase = props => { const { children, slotTop } = props const { onLoading, fullWidth } = useGlobal() const searchModal = useRef(null) + const router = useRouter() + + // 首页使用学习站专属布局(不显示主题默认头部/侧栏/底部) + const isLearningHome = router?.pathname === '/' + + if (isLearningHome) { + return ( + +
+ + + ); +} From 372b6f5031430be77f9d9131fb4122c3a6e98329 Mon Sep 17 00:00:00 2001 From: Hurt6465-ai Date: Mon, 23 Feb 2026 15:08:30 +0000 Subject: [PATCH 005/548] fix: add framer-motion dependency --- package.json | 1 + yarn.lock | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/package.json b/package.json index acf2100e7c6..c4c66c6a2cd 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "critters": "^0.0.23", "feed": "^4.2.2", "firebase": "^12.9.0", + "framer-motion": "^12.34.3", "hanzi-writer": "^3.7.3", "howler": "^2.2.4", "ioredis": "^5.6.1", diff --git a/yarn.lock b/yarn.lock index 68d017d90c1..23c9aa3d904 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4095,6 +4095,15 @@ fraction.js@^4.3.7: resolved "https://registry.npmmirror.com/fraction.js/-/fraction.js-4.3.7.tgz" integrity sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew== +framer-motion@^12.34.3: + version "12.34.3" + resolved "https://registry.yarnpkg.com/framer-motion/-/framer-motion-12.34.3.tgz#946f716bfef710d564bf721f4f364274f6278fd4" + integrity sha512-v81ecyZKYO/DfpTwHivqkxSUBzvceOpoI+wLfgCgoUIKxlFKEXdg0oR9imxwXumT4SFy8vRk9xzJ5l3/Du/55Q== + dependencies: + motion-dom "^12.34.3" + motion-utils "^12.29.2" + tslib "^2.4.0" + fs-constants@^1.0.0: version "1.0.0" resolved "https://registry.npmmirror.com/fs-constants/-/fs-constants-1.0.0.tgz" @@ -6092,6 +6101,18 @@ mkdirp@^1.0.4: resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== +motion-dom@^12.34.3: + version "12.34.3" + resolved "https://registry.yarnpkg.com/motion-dom/-/motion-dom-12.34.3.tgz#56224109a20bf2cb38277bfaedeeda5151ce369d" + integrity sha512-sYgFe+pR9aIM7o4fhs2aXtOI+oqlUd33N9Yoxcgo1Fv7M20sRkHtCmzE/VRNIcq7uNJ+qio+Xubt1FXH3pQ+eQ== + dependencies: + motion-utils "^12.29.2" + +motion-utils@^12.29.2: + version "12.29.2" + resolved "https://registry.yarnpkg.com/motion-utils/-/motion-utils-12.29.2.tgz#8fdd28babe042c2456b078ab33b32daa3bf5938b" + integrity sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A== + mrmime@^1.0.0: version "1.0.1" resolved "https://registry.npmmirror.com/mrmime/-/mrmime-1.0.1.tgz" From 95b40846e6aeda8254559130a74f33bdbc3538b4 Mon Sep 17 00:00:00 2001 From: Hurt6465-ai Date: Mon, 23 Feb 2026 22:10:27 +0630 Subject: [PATCH 006/548] =?UTF-8?q?=E9=80=8F=E6=98=8E=E8=83=8C=E6=99=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- themes/simple/index.js | 237 +++++++++++++++++++++++------------------ 1 file changed, 131 insertions(+), 106 deletions(-) diff --git a/themes/simple/index.js b/themes/simple/index.js index 50de16827a7..06d91b498da 100644 --- a/themes/simple/index.js +++ b/themes/simple/index.js @@ -39,10 +39,6 @@ const AlgoliaSearchModal = dynamic( { ssr: false } ) -const BookLibrary = dynamic(() => import('@/components/BookLibrary'), { - ssr: false -}) - // 主题组件 const BlogListScroll = dynamic(() => import('./components/BlogListScroll'), { ssr: false @@ -91,7 +87,7 @@ const pinyinNav = [ mm: 'ဗျည်း', icon: Mic, href: '/pinyin/initials', - bg: 'bg-blue-100', + bg: 'bg-blue-100/90', iconColor: 'text-blue-600' }, { @@ -99,7 +95,7 @@ const pinyinNav = [ mm: 'သရ', icon: Music2, href: '/pinyin/finals', - bg: 'bg-emerald-100', + bg: 'bg-emerald-100/90', iconColor: 'text-emerald-600' }, { @@ -107,7 +103,7 @@ const pinyinNav = [ mm: 'အသံတွဲ', icon: Layers3, href: '/pinyin/syllables', - bg: 'bg-purple-100', + bg: 'bg-purple-100/90', iconColor: 'text-purple-600' }, { @@ -115,7 +111,7 @@ const pinyinNav = [ mm: 'အသံ', icon: FileText, href: '/pinyin/tones', - bg: 'bg-orange-100', + bg: 'bg-orange-100/90', iconColor: 'text-orange-600' } ] @@ -126,7 +122,7 @@ const coreTools = [ mm: 'AI ဘာသာပြန်', icon: Globe, href: '/ai-translate', - bg: 'bg-indigo-100', + bg: 'bg-indigo-100/90', iconColor: 'text-indigo-600' }, { @@ -134,7 +130,7 @@ const coreTools = [ mm: 'စာကြည့်တိုက်', icon: Library, action: 'open-library', - bg: 'bg-cyan-100', + bg: 'bg-cyan-100/90', iconColor: 'text-cyan-600' }, { @@ -142,7 +138,7 @@ const coreTools = [ mm: 'မှတ်ထားသော စာလုံး', icon: Star, href: '/words', - bg: 'bg-slate-200', + bg: 'bg-slate-200/90', iconColor: 'text-slate-700' }, { @@ -150,7 +146,7 @@ const coreTools = [ mm: 'မှတ်ထားသော စကားပြော', icon: Volume2, href: '/oral', - bg: 'bg-slate-200', + bg: 'bg-slate-200/90', iconColor: 'text-slate-700' } ] @@ -195,14 +191,69 @@ const systemCourses = [ } ] +const freeBooks = [ + { title: 'HSK1 词汇手册', desc: '入门高频词汇', href: '/course/hsk1' }, + { title: '拼音速查总表', desc: '声母 / 韵母 / 声调', href: '/pinyin/initials' }, + { title: '日常口语 100 句', desc: '生活会话训练', href: '/course/oral' }, + { title: '汉字基础练习', desc: '常见字形+发音', href: '/course/words' } +] + const drawerWidth = 288 +const BookLibraryModal = ({ isOpen, onClose }) => { + useEffect(() => { + if (!isOpen || !isBrowser) return + const old = document.body.style.overflow + document.body.style.overflow = 'hidden' + const onKeyDown = e => { + if (e.key === 'Escape') onClose?.() + } + document.addEventListener('keydown', onKeyDown) + return () => { + document.body.style.overflow = old + document.removeEventListener('keydown', onKeyDown) + } + }, [isOpen, onClose]) + + if (!isOpen) return null + + return ( +
+
+
+
+

免费书籍

+ +
+
+ {freeBooks.map(item => ( + +

{item.title}

+

{item.desc}

+ + ))} +
+
+
+ ) +} + const LayoutLearningHome = () => { const [isDrawerOpen, setIsDrawerOpen] = useState(false) const [drawerX, setDrawerX] = useState(-drawerWidth) const [isDragging, setIsDragging] = useState(false) const [isLibraryOpen, setIsLibraryOpen] = useState(false) + // 只在抽屉已打开时支持滑动关闭,避免和系统返回手势冲突 const touchStartXRef = useRef(null) const drawerStartXRef = useRef(-drawerWidth) @@ -216,64 +267,60 @@ const LayoutLearningHome = () => { setDrawerX(-drawerWidth) } - const handleTouchStart = e => { + const handleDrawerTouchStart = e => { + if (!isDrawerOpen) return const startX = e.touches?.[0]?.clientX ?? 0 - if (!isDrawerOpen && startX > 24) return touchStartXRef.current = startX drawerStartXRef.current = drawerX setIsDragging(true) } - const handleTouchMove = e => { + const handleDrawerTouchMove = e => { if (!isDragging || touchStartXRef.current === null) return const currentX = e.touches?.[0]?.clientX ?? 0 const deltaX = currentX - touchStartXRef.current - const nextX = Math.max( - -drawerWidth, - Math.min(0, drawerStartXRef.current + deltaX) - ) + // 打开的抽屉只允许往左拖(0 到 -drawerWidth) + const nextX = Math.max(-drawerWidth, Math.min(0, drawerStartXRef.current + deltaX)) setDrawerX(nextX) } - const handleTouchEnd = () => { + const handleDrawerTouchEnd = () => { if (!isDragging) return setIsDragging(false) touchStartXRef.current = null - if (drawerX > -drawerWidth * 0.55) openDrawer() - else closeDrawer() + if (drawerX < -drawerWidth * 0.45) closeDrawer() + else openDrawer() } const openProgress = (drawerX + drawerWidth) / drawerWidth const overlayOpacity = Math.max(0, Math.min(0.5, openProgress * 0.5)) - const showDrawerLayer = isDrawerOpen || isDragging || drawerX > -drawerWidth + const showDrawerLayer = isDrawerOpen || isDragging const glassCard = - 'rounded-2xl border border-white/80 bg-white/94 backdrop-blur-2xl shadow-[0_10px_26px_rgba(15,23,42,0.12)]' - const glassCardHover = `${glassCard} transition-all duration-200 hover:bg-white/97` + 'rounded-2xl border border-white/45 bg-white/55 backdrop-blur-2xl shadow-[0_10px_28px_rgba(15,23,42,0.12)]' + const glassCardHover = `${glassCard} transition-all duration-200 hover:bg-white/70` return ( -
-
+
+ {/* 背景层(修复白底遮挡) */} +
-
-
+
+
+ {/* 抽屉 */}
{ onClick={closeDrawer} />
) } @@ -510,19 +563,17 @@ const LayoutBase = props => { if (isLearningHome) { return ( -
- +) + +const nowId = () => `${Date.now()}-${Math.random().toString(36).slice(2, 9)}` +const safeLocalStorageGet = key => (typeof window === 'undefined' ? null : localStorage.getItem(key)) +const safeLocalStorageSet = (key, value) => { + if (typeof window !== 'undefined') localStorage.setItem(key, value) +} + +const detectScript = text => { + if (!text) return null + if (/[\u1000-\u109F\uAA60-\uAA7F]+/.test(text)) return 'my-MM' + if (/[\u4e00-\u9fa5]+/.test(text)) return 'zh-CN' + if (/[\uac00-\ud7af]+/.test(text)) return 'ko-KR' + if (/[\u3040-\u30ff\u31f0-\u31ff]+/.test(text)) return 'ja-JP' + if (/[\u0E00-\u0E7F]+/.test(text)) return 'th-TH' + if (/[\u0400-\u04FF]+/.test(text)) return 'ru-RU' + if (/[\u0600-\u06FF]+/.test(text)) return 'ar-SA' + if (/[\u0900-\u097F]+/.test(text)) return 'hi-IN' + if (/^[\w\s,.?!'"()\-:;]+$/.test(text)) return 'en-US' + return null +} + +const getLangName = code => SUPPORTED_LANGUAGES.find(l => l.code === code)?.name || code +const getLangFlag = code => SUPPORTED_LANGUAGES.find(l => l.code === code)?.flag || '🌐' + +const parseJsonSafe = raw => { + try { + return JSON.parse(raw) + } catch { + return null + } +} + +const normalizeTranslations = raw => { + if (!raw) return [{ translation: '无有效译文', back_translation: '' }] + + let clean = typeof raw === 'string' ? raw.trim() : '' + clean = clean.replace(/```json|```/gi, '').trim() + + const parsed = parseJsonSafe(clean) + if (parsed) { + const arr = Array.isArray(parsed?.data) ? parsed.data : Array.isArray(parsed) ? parsed : [] + const valid = arr.filter(x => x && typeof x.translation === 'string' && x.translation.trim()) + if (valid.length) return valid.slice(0, 4) + } + + if (clean) return [{ translation: clean, back_translation: '' }] + return [{ translation: '无有效译文', back_translation: '' }] +} + +const parseSuggestionArray = raw => { + if (!raw) return [] + const clean = raw.replace(/```json|```/gi, '').trim() + const parsed = parseJsonSafe(clean) + if (Array.isArray(parsed)) return parsed.filter(Boolean).slice(0, 8) + return clean + .split('\n') + .map(x => x.replace(/^[\-\d\.\)\s]+/, '').trim()) + .filter(Boolean) + .slice(0, 8) +} + +const compressImage = file => + new Promise(resolve => { + const reader = new FileReader() + reader.onload = e => { + const img = new Image() + img.onload = () => { + const maxWidth = 1280 + let { width, height } = img + if (width > maxWidth) { + height = (height * maxWidth) / width + width = maxWidth + } + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = height + const ctx = canvas.getContext('2d') + ctx.drawImage(img, 0, 0, width, height) + resolve(canvas.toDataURL('image/jpeg', 0.7)) + } + img.src = e.target.result + } + reader.readAsDataURL(file) + }) + +const playTTS = (text, lang, speed = 1) => { + if (typeof window === 'undefined' || !window.speechSynthesis || !text) return + const utter = new SpeechSynthesisUtterance(text) + utter.lang = lang + utter.rate = speed + window.speechSynthesis.cancel() + window.speechSynthesis.speak(utter) +} + +const ensureSettings = settings => { + const providers = settings.providers?.length ? settings.providers : DEFAULT_PROVIDERS + const models = settings.models?.length ? settings.models : DEFAULT_MODELS + const modelIds = new Set(models.map(m => m.id)) + + const mainModelId = modelIds.has(settings.mainModelId) ? settings.mainModelId : models[0].id + const followUpModelId = modelIds.has(settings.followUpModelId) ? settings.followUpModelId : mainModelId + const secondModelId = settings.secondModelId && modelIds.has(settings.secondModelId) ? settings.secondModelId : null + + return { ...DEFAULT_SETTINGS, ...settings, providers, models, mainModelId, followUpModelId, secondModelId } +} + +const TranslationCard = memo(({ data, onPlay }) => { + const [copied, setCopied] = useState(false) + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(data.translation || '') + setCopied(true) + setTimeout(() => setCopied(false), 800) + } catch {} + } + + return ( +
+ {copied && ( +
+ 已复制 +
+ )} +
{data.translation}
+ {!!data.back_translation &&
{data.back_translation}
} + +
+ ) +}) + +const TranslationResultContainer = memo(({ item, targetLang, ttsSpeed }) => { + const hasDual = item.modelResults && item.modelResults.length > 1 + const [idx, setIdx] = useState(0) + const touchX = useRef(null) + + const active = hasDual ? item.modelResults[idx] : null + const data = hasDual ? active.data : item.results + + const onTouchStart = e => { + if (!hasDual) return + touchX.current = e.targetTouches[0].clientX + } + + const onTouchEnd = e => { + if (!hasDual || touchX.current === null) return + const diff = touchX.current - e.changedTouches[0].clientX + if (diff > 50) setIdx(p => (p + 1) % item.modelResults.length) + if (diff < -50) setIdx(p => (p - 1 + item.modelResults.length) % item.modelResults.length) + touchX.current = null + } + + return ( +
+ {hasDual && ( +
+ {item.modelResults.map((m, i) => ( +
+ )} + {hasDual &&
{active.modelName}
} + {data.map((r, i) => ( + playTTS(r.translation, targetLang, ttsSpeed)} /> + ))} +
+ ) +}) + +const ReplyChips = ({ list, onClick }) => { + if (!list.length) return null + return ( +
+
快捷回复建议
+
+ {list.map((x, i) => ( + + ))} +
+
+ ) +} + +const LanguagePicker = ({ open, onClose, current, onSelect, title }) => ( + +
+
+ +
{title}
+
+ {SUPPORTED_LANGUAGES.map(l => ( + + ))} +
+
+
+
+) + +const SettingsModal = ({ settings, onSave, onClose }) => { + const [local, setLocal] = useState(settings) + useEffect(() => setLocal(settings), [settings]) + + const addProvider = () => setLocal(prev => ({ ...prev, providers: [...prev.providers, { id: nowId(), name: '新接口', url: '', key: '' }] })) + const updateProvider = (id, field, value) => + setLocal(prev => ({ ...prev, providers: prev.providers.map(p => (p.id === id ? { ...p, [field]: value } : p)) })) + const deleteProvider = id => { + if (local.providers.length <= 1) return + setLocal(prev => ({ ...prev, providers: prev.providers.filter(p => p.id !== id), models: prev.models.filter(m => m.providerId !== id) })) + } + + const addModel = providerId => setLocal(prev => ({ ...prev, models: [...prev.models, { id: nowId(), providerId, name: '新模型', value: '' }] })) + const updateModel = (id, field, value) => setLocal(prev => ({ ...prev, models: prev.models.map(m => (m.id === id ? { ...m, [field]: value } : m)) })) + const deleteModel = id => setLocal(prev => ({ ...prev, models: prev.models.filter(m => m.id !== id) })) + + const modelOptions = useMemo(() => local.models.map(m => ({ id: m.id, label: `${m.name} (${m.value})` })), [local.models]) + + return ( + +
+
+ +
+
接口与模型设置
+ +
+ +
+
+
模型分配
+
+ + + + + +
+ +
+ + + +
+
+ +
+ {local.providers.map(p => ( +
+
+ updateProvider(p.id, 'name', e.target.value)} /> + +
+ +
+ updateProvider(p.id, 'url', e.target.value)} /> + updateProvider(p.id, 'key', e.target.value)} /> +
+ +
+
+
模型列表
+ +
+ + {local.models.filter(m => m.providerId === p.id).map(m => ( +
+ updateModel(m.id, 'name', e.target.value)} /> + updateModel(m.id, 'value', e.target.value)} /> + +
+ ))} +
+
+ ))} +
+ + +
+ +
+ + +
+
+
+
+ ) +} + +const AiChatContent = ({ onClose }) => { + const [settings, setSettings] = useState(DEFAULT_SETTINGS) + const [sourceLang, setSourceLang] = useState('zh-CN') + const [targetLang, setTargetLang] = useState('my-MM') + + const [inputVal, setInputVal] = useState('') + const [inputImages, setInputImages] = useState([]) + const [history, setHistory] = useState([]) + const [suggestions, setSuggestions] = useState([]) + + const [isLoading, setIsLoading] = useState(false) + const [isSuggesting, setIsSuggesting] = useState(false) + const [isRecording, setIsRecording] = useState(false) + + const [showSettings, setShowSettings] = useState(false) + const [showSrcPicker, setShowSrcPicker] = useState(false) + const [showTgtPicker, setShowTgtPicker] = useState(false) + + const fileInputRef = useRef(null) + const cameraInputRef = useRef(null) + const recognitionRef = useRef(null) + const scrollRef = useRef(null) + + useEffect(() => { + const raw = safeLocalStorageGet(STORAGE_KEY) + if (!raw) return + try { + const parsed = ensureSettings(JSON.parse(raw)) + setSettings(parsed) + setSourceLang(parsed.lastSourceLang || 'zh-CN') + setTargetLang(parsed.lastTargetLang || 'my-MM') + } catch {} + }, []) + + useEffect(() => { + safeLocalStorageSet(STORAGE_KEY, JSON.stringify({ ...settings, lastSourceLang: sourceLang, lastTargetLang: targetLang })) + }, [settings, sourceLang, targetLang]) + + useEffect(() => { + return () => { + if (recognitionRef.current) { + try { + recognitionRef.current.stop() + } catch {} + } + } + }, []) + + const scrollToBottom = () => { + setTimeout(() => { + if (!scrollRef.current) return + scrollRef.current.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' }) + }, 100) + } + + const getProviderAndModel = modelId => { + const model = settings.models.find(m => m.id === modelId) + if (!model) return null + const provider = settings.providers.find(p => p.id === model.providerId) + if (!provider) return null + return { model, provider } + } + + const requestCompletion = async (endpoint, headers, body) => { + const res = await fetch(endpoint, { method: 'POST', headers, body: JSON.stringify(body) }) + const text = await res.text() + const parsed = parseJsonSafe(text) + + if (!res.ok) { + const msg = parsed?.error?.message || text || `API Error ${res.status}` + throw new Error(msg) + } + + const content = parsed?.choices?.[0]?.message?.content + if (typeof content !== 'string') throw new Error('API 返回格式异常') + return content + } + + const fetchAi = async (messages, modelId, jsonMode = true) => { + const pm = getProviderAndModel(modelId) + if (!pm) throw new Error('模型未配置') + if (!pm.provider.url) throw new Error(`${pm.provider.name} 缺少 URL`) + if (!pm.provider.key) throw new Error(`${pm.provider.name} 缺少 API Key`) + + const endpoint = `${pm.provider.url.replace(/\/$/, '')}/chat/completions` + const headers = { 'Content-Type': 'application/json', Authorization: `Bearer ${pm.provider.key}` } + const body = { model: pm.model.value, messages, stream: false, temperature: 0.2 } + if (jsonMode) body.response_format = { type: 'json_object' } + + try { + let content = await requestCompletion(endpoint, headers, body) + if (settings.filterThinking) content = content.replace(/>[\s\S]*?<\/think>/g, '').trim() +> return { content, modelName: pm.model.name } +> } catch (e) { +> const msg = String(e?.message || '') +> if (jsonMode && /response_format|json_object|unsupported|invalid/i.test(msg)) { +> const fallbackBody = { ...body } +> delete fallbackBody.response_format +> let content = await requestCompletion(endpoint, headers, fallbackBody) +> if (settings.filterThinking) content = content.replace(/[\s\S]*?<\/think>/g, '').trim() +> return { content, modelName: pm.model.name } +> } +> throw e +> } +> } +> const fetchSuggestions = async (originalText, src, tgt) => { +> setIsSuggesting(true) +> try { +> const prompt = `原文(${getLangName(src)}): ${originalText}\n目标语言: ${getLangName(tgt)}` +> const { content } = await fetchAi( +> [ +> { role: 'system', content: REPLY_SYSTEM_INSTRUCTION }, +> { role: 'user', content: prompt } +> ], +> settings.followUpModelId, +> false +> ) +> setSuggestions(parseSuggestionArray(content)) +> } catch { +> setSuggestions([]) +> } finally { +> setIsSuggesting(false) +> } +> } +> const handleTranslate = async textOverride => { +> const text = (textOverride ?? inputVal).trim() +> if (!text && inputImages.length === 0) return +> let currentSource = sourceLang +> let currentTarget = targetLang +> if (text) { +> const detected = detectScript(text) +> if (detected && detected !== currentSource && detected === currentTarget) { +> currentSource = targetLang +> currentTarget = sourceLang +> setSourceLang(currentSource) +> setTargetLang(currentTarget) +> } else if (detected && detected !== currentSource && detected !== 'en-US') { +> currentSource = detected +> setSourceLang(detected) +> } +> } +> setIsLoading(true) +> setSuggestions([]) +> const userMsg = { id: nowId(), role: 'user', text, images: inputImages, ts: Date.now() } +> setHistory([userMsg]) +> setInputVal('') +> setInputImages([]) +> scrollToBottom() +> try { +> const sysPrompt = +> `${BASE_SYSTEM_INSTRUCTION}\n` + +> `source=${getLangName(currentSource)} target=${getLangName(currentTarget)}\n` + +> `back_translation 用 ${getLangName(currentSource)}` +> const userPrompt = `Source: ${getLangName(currentSource)}\nTarget: ${getLangName(currentTarget)}\nContent:\n${text || '[Image Content]'}` +> const userContent = +> userMsg.images?.length > 0 +> ? [{ type: 'text', text: userPrompt }, ...userMsg.images.map(img => ({ type: 'image_url', image_url: { url: img } }))] +> : userPrompt +> const messages = [{ role: 'system', content: sysPrompt }, { role: 'user', content: userContent }] +> const jobs = [ +> fetchAi(messages, settings.mainModelId, true) +> .then(r => ({ ...r, ok: true })) +> .catch(e => ({ ok: false, error: e.message })) +> ] +> if (settings.secondModelId && settings.secondModelId !== settings.mainModelId) { +> jobs.push( +> fetchAi(messages, settings.secondModelId, true) +> .then(r => ({ ...r, ok: true })) +> .catch(e => ({ ok: false, error: e.message })) +> ) +> } +> const list = await Promise.all(jobs) +> const modelResults = list.map(x => { +> if (!x.ok) return { modelName: 'Error', data: [{ translation: x.error || '请求失败', back_translation: '' }] } +> return { modelName: x.modelName, data: normalizeTranslations(x.content) } +> }) +> const aiMsg = { id: nowId(), role: 'ai', ts: Date.now(), modelResults, results: modelResults[0]?.data || [{ translation: '无结果', back_translation: '' }] } +> setHistory(prev => [...prev, aiMsg]) +> scrollToBottom() +> if (settings.autoPlayTTS && aiMsg.results[0]?.translation) { +> playTTS(aiMsg.results[0].translation, currentTarget, settings.ttsSpeed) +> } +> if (settings.enableFollowUp && text) { +> fetchSuggestions(text, currentSource, currentTarget) +> } +> } catch (e) { +> setHistory(prev => [...prev, { id: nowId(), role: 'error', text: e.message || '请求失败' }]) +> } finally { +> setIsLoading(false) +> } +> } +> const handleImageSelect = async e => { +> const files = Array.from(e.target.files || []) +> if (!files.length) return +> const list = [] +> for (const file of files) { +> try { +> const b64 = await compressImage(file) +> list.push(b64) +> } catch {} +> } +> setInputImages(prev => [...prev, ...list]) +> e.target.value = '' +> } +> const stopRecognition = () => { +> if (!recognitionRef.current) return +> try { +> recognitionRef.current.stop() +> } catch {} +> } +> const startRecognition = () => { +> const SR = window.SpeechRecognition || window.webkitSpeechRecognition +> if (!SR) { +> alert('当前浏览器不支持语音识别') +> return +> } +> if (isRecording) { +> stopRecognition() +> return +> } +> const rec = new SR() +> recognitionRef.current = rec +> rec.lang = sourceLang +> rec.interimResults = true +> rec.continuous = false +> setInputVal('') +> setIsRecording(true) +> rec.onresult = event => { +> const text = Array.from(event.results) +> .map(r => r[0]?.transcript || '') +> .join('') +> setInputVal(text) +> const isFinal = Array.from(event.results).some(r => r.isFinal) +> if (isFinal && text.trim()) { +> try { +> rec.stop() +> } catch {} +> setIsRecording(false) +> handleTranslate(text.trim()) +> } +> } +> rec.onerror = () => setIsRecording(false) +> rec.onend = () => { +> setIsRecording(false) +> recognitionRef.current = null +> } +> rec.start() +> } +> return ( +>
+> +>
+>
+> +>
AI 翻译
+> +>
+>
+> show={isRecording} +> as={Fragment} +> enter='transition-opacity duration-200' +> enterFrom='opacity-0' +> enterTo='opacity-100' +> leave='transition-opacity duration-150' +> leaveFrom='opacity-100' +> leaveTo='opacity-0'> +>
+>
正在识别({getLangName(sourceLang)})...
+>
+>
+>
+>
+> {!history.length && !isLoading && ( +>
+>
👋
+> 输入文字 / 语音 / 图片开始翻译 +>
+> )} +> {history.map((item, idx) => { +> if (item.role === 'user') { +> return ( +>
+>
+> {!!item.images?.length && ( +>
+> {item.images.map((img, i) => ( +> upload +> ))} +>
+> )} +> {!!item.text &&
{item.text}
} +>
+>
+> ) +> } +> if (item.role === 'error') { +> return
{item.text}
+> } +> return ( +>
+> +> {idx === history.length - 1 && +> (isSuggesting ? ( +>
正在生成回复建议...
+> ) : ( +> list={suggestions} +> onClick={reply => { +> setInputVal(reply) +> handleTranslate(reply) +> }} +> /> +> ))} +>
+> ) +> })} +> {isLoading && ( +>
+>
翻译中...
+>
+> )} +>
+>
+>
+>
+>
+>
+> +> +> +>
+>
+>
+> +> +> as={Fragment} +> enter='transition duration-100 ease-out' +> enterFrom='scale-95 opacity-0' +> enterTo='scale-100 opacity-100' +> leave='transition duration-75 ease-in' +> leaveFrom='scale-100 opacity-100' +> leaveTo='scale-95 opacity-0'> +> +> +> {({ active }) => ( +> +> )} +> +> +> {({ active }) => ( +> +> )} +> +> +> +> +> +> +>
+> {!!inputImages.length && ( +>
+> {inputImages.map((img, idx) => ( +>
+> preview +> +>
+> ))} +>
+> )} +>