From 0bf3b718b255c505e386870169fdb1d7c344daf6 Mon Sep 17 00:00:00 2001 From: goddonebianu Date: Mon, 17 Nov 2025 15:59:35 +0800 Subject: [PATCH] ... --- .dockerignore | 3 +- .gitignore | 2 + package.json | 2 +- src/app/(features)/memorize/Memorize.tsx | 2 +- src/app/(features)/memorize/page.tsx | 4 +- .../VideoPlayer/SubtitleDisplay.tsx | 11 +- src/app/(features)/text-speaker/SaveList.tsx | 2 +- src/app/(features)/text-speaker/page.tsx | 33 +- src/app/(features)/translator/AddToFolder.tsx | 4 +- .../(features)/translator/FolderSelector.tsx | 2 +- src/app/(features)/translator/page.tsx | 9 +- .../(features)/translator_old/AddToFolder.tsx | 84 ----- src/app/(features)/translator_old/page.tsx | 307 ------------------ src/app/api/ipa/route.ts | 62 ---- src/app/api/locale/route.ts | 64 ---- src/app/api/route.ts | 12 - src/app/api/translate/route.ts | 66 ---- src/app/api/v1/ipa/route.ts | 10 - src/app/api/v1/locale/route.ts | 10 - src/app/api/v1/translate/route.ts | 10 - src/app/folders/FoldersClient.tsx | 2 +- src/app/folders/[folder_id]/InFolder.tsx | 2 +- src/app/folders/[folder_id]/TextPairCard.tsx | 2 +- src/app/folders/[folder_id]/page.tsx | 2 +- src/app/layout.tsx | 2 +- src/{lib => components}/SessionWrapper.tsx | 0 src/hooks/useAudioPlayer.ts | 114 ++++++- src/lib/{ => actions}/ai.ts | 2 + .../{ => actions}/services/folderService.ts | 4 +- .../{ => actions}/services/textPairService.ts | 4 +- src/lib/actions/translatorActions.ts | 2 +- src/lib/browser/localStorageOperators.ts | 58 ++++ src/lib/{ => browser}/tts.ts | 3 +- src/lib/localStorageOperators.ts | 22 -- src/lib/utils.ts | 127 -------- 35 files changed, 204 insertions(+), 841 deletions(-) delete mode 100644 src/app/(features)/translator_old/AddToFolder.tsx delete mode 100644 src/app/(features)/translator_old/page.tsx delete mode 100644 src/app/api/ipa/route.ts delete mode 100644 src/app/api/locale/route.ts delete mode 100644 src/app/api/route.ts delete mode 100644 src/app/api/translate/route.ts delete mode 100644 src/app/api/v1/ipa/route.ts delete mode 100644 src/app/api/v1/locale/route.ts delete mode 100644 src/app/api/v1/translate/route.ts rename src/{lib => components}/SessionWrapper.tsx (100%) rename src/lib/{ => actions}/ai.ts (98%) rename src/lib/{ => actions}/services/folderService.ts (93%) rename src/lib/{ => actions}/services/textPairService.ts (92%) create mode 100644 src/lib/browser/localStorageOperators.ts rename src/lib/{ => browser}/tts.ts (74%) delete mode 100644 src/lib/localStorageOperators.ts diff --git a/.dockerignore b/.dockerignore index 72e9aa4..8f25043 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,4 +4,5 @@ node_modules npm-debug.log README.md .next -.git \ No newline at end of file +.git +certificates diff --git a/.gitignore b/.gitignore index 2c62c23..7761e57 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,5 @@ build.sh test.ts /generated/prisma + +certificates \ No newline at end of file diff --git a/package.json b/package.json index 5d37634..d64ea29 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "private": true, "license": "GPL-3.0-only", "scripts": { - "dev": "next dev --turbopack", + "dev": "next dev --turbopack --experimental-https", "build": "next build --turbopack", "start": "next start", "lint": "eslint" diff --git a/src/app/(features)/memorize/Memorize.tsx b/src/app/(features)/memorize/Memorize.tsx index 8957cbd..52a805e 100644 --- a/src/app/(features)/memorize/Memorize.tsx +++ b/src/app/(features)/memorize/Memorize.tsx @@ -6,7 +6,7 @@ import Container from "@/components/cards/Container"; import { useState } from "react"; import LightButton from "@/components/buttons/LightButton"; import { useAudioPlayer } from "@/hooks/useAudioPlayer"; -import { getTTSAudioUrl } from "@/lib/tts"; +import { getTTSAudioUrl } from "@/lib/browser/tts"; import { VOICES } from "@/config/locales"; import { useTranslations } from "next-intl"; diff --git a/src/app/(features)/memorize/page.tsx b/src/app/(features)/memorize/page.tsx index dd92bab..c0aa297 100644 --- a/src/app/(features)/memorize/page.tsx +++ b/src/app/(features)/memorize/page.tsx @@ -6,11 +6,11 @@ import { getTranslations } from "next-intl/server"; import { getFoldersWithTotalPairsByOwner, getOwnerByFolderId, -} from "@/lib/services/folderService"; +} from "@/lib/actions/services/folderService"; import { isNonNegativeInteger } from "@/lib/utils"; import FolderSelector from "./FolderSelector"; import Memorize from "./Memorize"; -import { getTextPairsByFolderId } from "@/lib/services/textPairService"; +import { getTextPairsByFolderId } from "@/lib/actions/services/textPairService"; export default async function MemorizePage({ searchParams, diff --git a/src/app/(features)/srt-player/VideoPlayer/SubtitleDisplay.tsx b/src/app/(features)/srt-player/VideoPlayer/SubtitleDisplay.tsx index 1aa7b7f..566f1d2 100644 --- a/src/app/(features)/srt-player/VideoPlayer/SubtitleDisplay.tsx +++ b/src/app/(features)/srt-player/VideoPlayer/SubtitleDisplay.tsx @@ -1,13 +1,16 @@ -import { inspect } from "@/lib/utils"; - export default function SubtitleDisplay({ subtitle }: { subtitle: string }) { const words = subtitle.match(/\b[\w']+(?:-[\w']+)*\b/g) || []; let i = 0; return ( -
+
{words.map((v) => ( { + window.open( + `https://www.youdao.com/result?word=${v}&lang=en`, + "_blank", + ); + }} key={i++} className="hover:bg-gray-700 hover:underline hover:cursor-pointer" > diff --git a/src/app/(features)/text-speaker/SaveList.tsx b/src/app/(features)/text-speaker/SaveList.tsx index 86f283b..582f356 100644 --- a/src/app/(features)/text-speaker/SaveList.tsx +++ b/src/app/(features)/text-speaker/SaveList.tsx @@ -1,6 +1,5 @@ "use client"; -import { getLocalStorageOperator } from "@/lib/utils"; import { useState } from "react"; import z from "zod"; import { @@ -10,6 +9,7 @@ import { import IconClick from "@/components/IconClick"; import IMAGES from "@/config/images"; import { useTranslations } from "next-intl"; +import { getLocalStorageOperator } from "@/lib/browser/localStorageOperators"; interface TextCardProps { item: z.infer; diff --git a/src/app/(features)/text-speaker/page.tsx b/src/app/(features)/text-speaker/page.tsx index e7d6671..c606733 100644 --- a/src/app/(features)/text-speaker/page.tsx +++ b/src/app/(features)/text-speaker/page.tsx @@ -8,13 +8,15 @@ import { TextSpeakerArraySchema, TextSpeakerItemSchema, } from "@/lib/interfaces"; -import { getLocalStorageOperator, getTTSAudioUrl } from "@/lib/utils"; import { ChangeEvent, useEffect, useRef, useState } from "react"; import z from "zod"; import SaveList from "./SaveList"; import { VOICES } from "@/config/locales"; import { useTranslations } from "next-intl"; +import { getLocalStorageOperator } from "@/lib/browser/localStorageOperators"; +import { getTTSAudioUrl } from "@/lib/browser/tts"; +import { genIPA, genLocale } from "@/lib/actions/translatorActions"; export default function TextSpeakerPage() { const t = useTranslations("text_speaker"); @@ -94,14 +96,9 @@ export default function TextSpeakerPage() { let theLocale = locale; if (!theLocale) { console.log("downloading text info"); - const params = new URLSearchParams({ - text: textRef.current.slice(0, 30), - }); - const textinfo = await ( - await fetch(`/api/locale?${params}`) - ).json(); - setLocale(textinfo.locale); - theLocale = textinfo.locale as string; + const tmp_locale = await genLocale(textRef.current.slice(0, 30)); + setLocale(tmp_locale); + theLocale = tmp_locale; } const voice = VOICES.find((v) => v.locale.startsWith(theLocale)); @@ -184,22 +181,16 @@ export default function TextSpeakerPage() { let theLocale = locale; if (!theLocale) { console.log("downloading text info"); - const params = new URLSearchParams({ - text: textRef.current.slice(0, 30), - }); - const textinfo = await (await fetch(`/api/locale?${params}`)).json(); - setLocale(textinfo.locale); - theLocale = textinfo.locale as string; + const tmp_locale = await genLocale(textRef.current.slice(0, 30)); + setLocale(tmp_locale); + theLocale = tmp_locale; } let theIPA = ipa; if (ipa.length === 0 && ipaEnabled) { - const params = new URLSearchParams({ - text: textRef.current, - }); - const tmp = await (await fetch(`/api/ipa?${params}`)).json(); - setIPA(tmp.ipa); - theIPA = tmp.ipa; + const tmp_ipa = await genIPA(textRef.current); + setIPA(tmp_ipa); + theIPA = tmp_ipa; } const save = getFromLocalStorage(); diff --git a/src/app/(features)/translator/AddToFolder.tsx b/src/app/(features)/translator/AddToFolder.tsx index 3ce4454..5a270dd 100644 --- a/src/app/(features)/translator/AddToFolder.tsx +++ b/src/app/(features)/translator/AddToFolder.tsx @@ -5,9 +5,9 @@ import { useSession } from "next-auth/react"; import { Dispatch, useEffect, useState } from "react"; import z from "zod"; import { folder } from "../../../../generated/prisma/browser"; -import { getFoldersByOwner } from "@/lib/services/folderService"; +import { getFoldersByOwner } from "@/lib/actions/services/folderService"; import { Folder } from "lucide-react"; -import { createTextPair } from "@/lib/services/textPairService"; +import { createTextPair } from "@/lib/actions/services/textPairService"; import { toast } from "sonner"; import { useTranslations } from "next-intl"; diff --git a/src/app/(features)/translator/FolderSelector.tsx b/src/app/(features)/translator/FolderSelector.tsx index 3d7f125..94bf1c2 100644 --- a/src/app/(features)/translator/FolderSelector.tsx +++ b/src/app/(features)/translator/FolderSelector.tsx @@ -1,7 +1,7 @@ import Container from "@/components/cards/Container"; import { useEffect, useState } from "react"; import { folder } from "../../../../generated/prisma/browser"; -import { getFoldersByOwner } from "@/lib/services/folderService"; +import { getFoldersByOwner } from "@/lib/actions/services/folderService"; import LightButton from "@/components/buttons/LightButton"; import { Folder } from "lucide-react"; diff --git a/src/app/(features)/translator/page.tsx b/src/app/(features)/translator/page.tsx index 9d337ed..1c59f89 100644 --- a/src/app/(features)/translator/page.tsx +++ b/src/app/(features)/translator/page.tsx @@ -6,9 +6,8 @@ import IMAGES from "@/config/images"; import { VOICES } from "@/config/locales"; import { useAudioPlayer } from "@/hooks/useAudioPlayer"; import { TranslationHistorySchema } from "@/lib/interfaces"; -import { tlsoPush, tlso } from "@/lib/localStorageOperators"; -import { getTTSAudioUrl } from "@/lib/tts"; -import { shallowEqual } from "@/lib/utils"; +import { tlsoPush, tlso } from "@/lib/browser/localStorageOperators"; +import { getTTSAudioUrl } from "@/lib/browser/tts"; import { Plus, Trash } from "lucide-react"; import { useTranslations } from "next-intl"; import { useRef, useState } from "react"; @@ -22,7 +21,8 @@ import { import { toast } from "sonner"; import FolderSelector from "./FolderSelector"; import { useSession } from "next-auth/react"; -import { createTextPair } from "@/lib/services/textPairService"; +import { createTextPair } from "@/lib/actions/services/textPairService"; +import { shallowEqual } from "@/lib/utils"; export default function TranslatorPage() { const t = useTranslations("translator"); @@ -64,6 +64,7 @@ export default function TranslatorPage() { lastTTS.current.url = url; } catch (error) { toast.error("Failed to generate audio"); + console.error(error); } } await play(); diff --git a/src/app/(features)/translator_old/AddToFolder.tsx b/src/app/(features)/translator_old/AddToFolder.tsx deleted file mode 100644 index 3ce4454..0000000 --- a/src/app/(features)/translator_old/AddToFolder.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import LightButton from "@/components/buttons/LightButton"; -import Container from "@/components/cards/Container"; -import { TranslationHistorySchema } from "@/lib/interfaces"; -import { useSession } from "next-auth/react"; -import { Dispatch, useEffect, useState } from "react"; -import z from "zod"; -import { folder } from "../../../../generated/prisma/browser"; -import { getFoldersByOwner } from "@/lib/services/folderService"; -import { Folder } from "lucide-react"; -import { createTextPair } from "@/lib/services/textPairService"; -import { toast } from "sonner"; -import { useTranslations } from "next-intl"; - -interface AddToFolderProps { - item: z.infer; - setShow: Dispatch>; -} - -const AddToFolder: React.FC = ({ item, setShow }) => { - const session = useSession(); - const [folders, setFolders] = useState([]); - const t = useTranslations("translator.add_to_folder"); - const [loading, setLoading] = useState(true); - - useEffect(() => { - const username = session.data!.user!.name as string; - getFoldersByOwner(username) - .then(setFolders) - .then(() => setLoading(false)); - }, [session.data]); - - if (session.status !== "authenticated") { - return ( -
- -
{t("notAuthenticated")}
-
-
- ); - } - return ( -
- -

{t("chooseFolder")}

-
- {(loading && ...) || - (folders.length > 0 && - folders.map((folder) => ( - - ))) ||
{t("noFolders")}
} -
- setShow(false)}>{t("close")} -
-
- ); -}; - -export default AddToFolder; diff --git a/src/app/(features)/translator_old/page.tsx b/src/app/(features)/translator_old/page.tsx deleted file mode 100644 index e3c1199..0000000 --- a/src/app/(features)/translator_old/page.tsx +++ /dev/null @@ -1,307 +0,0 @@ -"use client"; - -import LightButton from "@/components/buttons/LightButton"; -import IconClick from "@/components/IconClick"; -import IMAGES from "@/config/images"; -import { VOICES } from "@/config/locales"; -import { useAudioPlayer } from "@/hooks/useAudioPlayer"; -import { TranslationHistorySchema } from "@/lib/interfaces"; -import { tlsoPush, tlso } from "@/lib/localStorageOperators"; -import { getTTSAudioUrl } from "@/lib/tts"; -import { letsFetch, shallowEqual } from "@/lib/utils"; -import { Plus, Trash } from "lucide-react"; -import { useTranslations } from "next-intl"; -import { useRef, useState } from "react"; -import z from "zod"; -import AddToFolder from "./AddToFolder"; - -export default function TranslatorPage() { - const t = useTranslations("translator"); - - const taref = useRef(null); - const [lang, setLang] = useState("chinese"); - const [tresult, setTresult] = useState(""); - const [genIpa, setGenIpa] = useState(true); - const [ipaTexts, setIpaTexts] = useState(["", ""]); - const [processing, setProcessing] = useState(false); - const { load, play } = useAudioPlayer(); - const [history, setHistory] = useState< - z.infer[] - >(tlso.get()); - const [showAddToFolder, setShowAddToFolder] = useState(false); - const [addToFolderItem, setAddToFolderItem] = useState | null>(null); - - const lastTTS = useRef({ - text: "", - url: "", - }); - - const tts = async (text: string, locale: string) => { - if (lastTTS.current.text !== text) { - const url = await getTTSAudioUrl( - text, - VOICES.find((v) => v.locale === locale)!.short_name, - ); - await load(url); - lastTTS.current.text = text; - lastTTS.current.url = url; - } - play(); - }; - - const translate = async () => { - if (processing) return; - setProcessing(true); - - if (!taref.current) return; - const text = taref.current.value; - - const newItem: { - text1: string | null; - text2: string | null; - locale1: string | null; - locale2: string | null; - } = { - text1: text, - text2: null, - locale1: null, - locale2: null, - }; - - const checkUpdateLocalStorage = (item: typeof newItem) => { - if (item.text1 && item.text2 && item.locale1 && item.locale2) { - setHistory(tlsoPush(item as z.infer)); - } - }; - const innerStates = { - text2: false, - ipa1: !genIpa, - ipa2: !genIpa, - }; - const checkUpdateProcessStates = () => { - if (innerStates.ipa1 && innerStates.ipa2 && innerStates.text2) - setProcessing(false); - }; - const updateState = (stateName: keyof typeof innerStates) => () => { - innerStates[stateName] = true; - checkUpdateLocalStorage(newItem); - checkUpdateProcessStates(); - }; - - // Fetch locale for text1 - letsFetch( - `/api/v1/locale?text=${encodeURIComponent(text)}`, - (locale: string) => { - newItem.locale1 = locale; - }, - console.log, - () => {}, - ); - - if (genIpa) - // Fetch IPA for text1 - letsFetch( - `/api/v1/ipa?text=${encodeURIComponent(text)}`, - (ipa: string) => setIpaTexts((prev) => [ipa, prev[1]]), - console.log, - updateState("ipa1"), - ); - // Fetch translation for text2 - letsFetch( - `/api/v1/translate?text=${encodeURIComponent(text)}&lang=${encodeURIComponent(lang)}`, - (text2) => { - setTresult(text2); - newItem.text2 = text2; - if (genIpa) - // Fetch IPA for text2 - letsFetch( - `/api/v1/ipa?text=${encodeURIComponent(text2)}`, - (ipa: string) => setIpaTexts((prev) => [prev[0], ipa]), - console.log, - updateState("ipa2"), - ); - // Fetch locale for text2 - letsFetch( - `/api/v1/locale?text=${encodeURIComponent(text2)}`, - (locale: string) => { - newItem.locale2 = locale; - }, - console.log, - () => {}, - ); - }, - console.log, - updateState("text2"), - ); - }; - - return ( - <> - {/* TCard Component */} -
- {/* Card Component - Left Side */} -
- {/* ICard1 Component */} -
- -
- {ipaTexts[0]} -
-
- { - await navigator.clipboard.writeText( - taref.current?.value || "", - ); - }} - > - { - const t = taref.current?.value; - if (!t) return; - tts(t, tlso.get().find((v) => v.text1 === t)?.locale1 || ""); - }} - > -
-
-
- {t("detectLanguage")} - setGenIpa((prev) => !prev)} - > - {t("generateIPA")} - -
-
- - {/* Card Component - Right Side */} -
- {/* ICard2 Component */} -
-
{tresult}
-
- {ipaTexts[1]} -
-
- { - await navigator.clipboard.writeText(tresult); - }} - > - { - tts( - tresult, - tlso.get().find((v) => v.text2 === tresult)?.locale2 || "", - ); - }} - > -
-
-
- {t("translateInto")} - setLang("chinese")} - > - {t("chinese")} - - setLang("english")} - > - {t("english")} - - setLang("italian")} - > - {t("italian")} - - { - const newLang = prompt(t("enterLanguage")); - if (newLang) { - setLang(newLang); - } - }} - > - {t("other")} - -
-
-
- - {/* TranslateButton Component */} -
- -
- {history.length > 0 && ( -
-

{t("history")}

-
- {history.toReversed().map((item, index) => ( -
-
-
-

{item.text1}

-

{item.text2}

-
-
- - -
-
-
- ))} -
- {showAddToFolder && ( - - )} -
- )} - - ); -} diff --git a/src/app/api/ipa/route.ts b/src/app/api/ipa/route.ts deleted file mode 100644 index 4506748..0000000 --- a/src/app/api/ipa/route.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { callZhipuAPI, handleAPIError } from "@/lib/utils"; -import { NextRequest, NextResponse } from "next/server"; - -async function getIPA(text: string) { - console.log(`get ipa of ${text}`); - const messages = [ - { - role: "user", - content: ` -请推断以下文本的语言,生成对应的宽式国际音标(IPA)以及locale,以JSON格式返回 -[${text}] -结果如: -{ - "ipa": "[ni˨˩˦ xɑʊ˨˩˦]", - "locale": "zh-CN" -} -注意: -直接返回json文本, -ipa一定要加[], -locale如果可能有多个,选取最可能的一个,其中使用符号"-", -locale如果推断失败,就返回{"locale": "en-US"} -`, - }, - ]; - try { - const response = await callZhipuAPI(messages); - let to_parse = response.choices[0].message.content.trim() as string; - if (to_parse.startsWith("`")) - to_parse = to_parse.slice(7, to_parse.length - 3); - if (to_parse.length === 0) throw Error("ai啥也每说"); - return JSON.parse(to_parse); - } catch (error) { - console.error(error); - return null; - } -} - -export async function GET(request: NextRequest) { - try { - const searchParams = request.nextUrl.searchParams; - const text = searchParams.get("text"); - - if (!text) { - return NextResponse.json( - { error: "查询参数错误", message: "text参数是必需的" }, - { status: 400 }, - ); - } - - const textInfo = await getIPA(text); - if (!textInfo) { - return NextResponse.json( - { error: "服务暂时不可用", message: "LLM API 请求失败" }, - { status: 503 }, - ); - } - - return NextResponse.json(textInfo, { status: 200 }); - } catch (error) { - handleAPIError(error, "请稍后再试"); - } -} diff --git a/src/app/api/locale/route.ts b/src/app/api/locale/route.ts deleted file mode 100644 index 8aba5b2..0000000 --- a/src/app/api/locale/route.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { callZhipuAPI } from "@/lib/utils"; -import { NextRequest, NextResponse } from "next/server"; - -async function getLocale(text: string) { - console.log(`get locale of ${text}`); - const messages = [ - { - role: "user", - content: ` -请推断以下文本的的locale,以JSON格式返回 -[${text}] -结果如: -{ - "locale": "zh-CN" -} -注意: -直接返回json文本, -locale如果可能有多个,选取最可能的一个,其中使用符号"-", -locale如果推断失败,就返回{"locale": "en-US"} -`, - }, - ]; - try { - const response = await callZhipuAPI(messages); - let to_parse = response.choices[0].message.content.trim() as string; - if (to_parse.startsWith("`")) - to_parse = to_parse.slice(7, to_parse.length - 3); - if (to_parse.length === 0) throw Error("ai啥也每说"); - return JSON.parse(to_parse); - } catch (error) { - console.error(error); - return null; - } -} - -export async function GET(request: NextRequest) { - try { - const searchParams = request.nextUrl.searchParams; - const text = searchParams.get("text"); - - if (!text) { - return NextResponse.json( - { error: "查询参数错误", message: "text参数是必需的" }, - { status: 400 }, - ); - } - - const textInfo = await getLocale(text.slice(0, 30)); - if (!textInfo) { - return NextResponse.json( - { error: "服务暂时不可用", message: "LLM API 请求失败" }, - { status: 503 }, - ); - } - - return NextResponse.json(textInfo, { status: 200 }); - } catch (error) { - console.error("API 错误:", error); - return NextResponse.json( - { error: "服务器内部错误", message: "请稍后重试" }, - { status: 500 }, - ); - } -} diff --git a/src/app/api/route.ts b/src/app/api/route.ts deleted file mode 100644 index 8e79113..0000000 --- a/src/app/api/route.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; - -export async function GET(request: NextRequest) { - const url = request.url; - return NextResponse.json( - { - message: "Hello World", - url: url, - }, - { status: 200 }, - ); -} diff --git a/src/app/api/translate/route.ts b/src/app/api/translate/route.ts deleted file mode 100644 index 17175f1..0000000 --- a/src/app/api/translate/route.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { callZhipuAPI } from "@/lib/utils"; -import { NextRequest, NextResponse } from "next/server"; - -async function translate(text: string, target_lang: string) { - console.log(`translate "${text}" into ${target_lang}`); - const messages = [ - { - role: "user", - content: ` -请推断以下文本的locale,并翻译到目标语言${target_lang},同样需要locale信息,以JSON格式返回 -[${text}] -结果如: -{ - "source_locale": "zh-CN", - "target_locale": "de-DE", - "target_text": "Halo" -} -注意: -直接返回json文本, -locale如果可能有多个,选取最可能的一个,其中使用符号"-", -locale如果推断失败,就当作是en-US -`, - }, - ]; - try { - const response = await callZhipuAPI(messages); - let to_parse = response.choices[0].message.content.trim() as string; - if (to_parse.startsWith("`")) - to_parse = to_parse.slice(7, to_parse.length - 3); - if (to_parse.length === 0) throw Error("ai啥也每说"); - return JSON.parse(to_parse); - } catch (error) { - console.error(error); - return null; - } -} - -export async function GET(request: NextRequest) { - try { - const searchParams = request.nextUrl.searchParams; - const text = searchParams.get("text"); - const target_lang = searchParams.get("target"); - - if (!text || !target_lang) { - return NextResponse.json( - { error: "查询参数错误", message: "text参数, target参数是必需的" }, - { status: 400 }, - ); - } - - const textInfo = await translate(text, target_lang); - if (!textInfo) { - return NextResponse.json( - { error: "服务暂时不可用", message: "LLM API 请求失败" }, - { status: 503 }, - ); - } - return NextResponse.json(textInfo, { status: 200 }); - } catch (error) { - console.error("API 错误:", error); - return NextResponse.json( - { error: "服务器内部错误", message: "请稍后重试" }, - { status: 500 }, - ); - } -} diff --git a/src/app/api/v1/ipa/route.ts b/src/app/api/v1/ipa/route.ts deleted file mode 100644 index 07a8373..0000000 --- a/src/app/api/v1/ipa/route.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { simpleGetLLMAnswer } from "@/lib/ai"; -import { NextRequest } from "next/server"; - -export async function GET(req: NextRequest) { - return await simpleGetLLMAnswer( - `请生成[[[%s]]]的严式国际音标(International Phonetic Alphabet),然后直接发给我,不要附带任何说明。`, - req.nextUrl.searchParams, - ["text"], - ); -} diff --git a/src/app/api/v1/locale/route.ts b/src/app/api/v1/locale/route.ts deleted file mode 100644 index ba2e77a..0000000 --- a/src/app/api/v1/locale/route.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { simpleGetLLMAnswer } from "@/lib/ai"; -import { NextRequest } from "next/server"; - -export async function GET(req: NextRequest) { - return await simpleGetLLMAnswer( - `请根据文本[[[%s]]]推断地区(locale),形如zh-CN、en-US,然后直接发给我,不要附带任何说明。`, - req.nextUrl.searchParams, - ["text"], - ); -} diff --git a/src/app/api/v1/translate/route.ts b/src/app/api/v1/translate/route.ts deleted file mode 100644 index ad5c9f9..0000000 --- a/src/app/api/v1/translate/route.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { simpleGetLLMAnswer } from "@/lib/ai"; -import { NextRequest } from "next/server"; - -export async function GET(req: NextRequest) { - return await simpleGetLLMAnswer( - `请翻译文本[[[%s]]]到语言[[[%s]]]然后直接发给我,不要附带任何说明,不要新增任何符号。`, - req.nextUrl.searchParams, - ["text", "lang"], - ); -} diff --git a/src/app/folders/FoldersClient.tsx b/src/app/folders/FoldersClient.tsx index c0a31ff..ddc6d29 100644 --- a/src/app/folders/FoldersClient.tsx +++ b/src/app/folders/FoldersClient.tsx @@ -9,7 +9,7 @@ import { createFolder, deleteFolderById, getFoldersWithTotalPairsByOwner, -} from "@/lib/services/folderService"; +} from "@/lib/actions/services/folderService"; import { useTranslations } from "next-intl"; interface FolderProps { diff --git a/src/app/folders/[folder_id]/InFolder.tsx b/src/app/folders/[folder_id]/InFolder.tsx index f2f7e7c..6e6f202 100644 --- a/src/app/folders/[folder_id]/InFolder.tsx +++ b/src/app/folders/[folder_id]/InFolder.tsx @@ -9,7 +9,7 @@ import { createTextPair, deleteTextPairById, getTextPairsByFolderId, -} from "@/lib/services/textPairService"; +} from "@/lib/actions/services/textPairService"; import AddTextPairModal from "./AddTextPairModal"; import TextPairCard from "./TextPairCard"; import LightButton from "@/components/buttons/LightButton"; diff --git a/src/app/folders/[folder_id]/TextPairCard.tsx b/src/app/folders/[folder_id]/TextPairCard.tsx index d03d73f..0c9fe2c 100644 --- a/src/app/folders/[folder_id]/TextPairCard.tsx +++ b/src/app/folders/[folder_id]/TextPairCard.tsx @@ -1,6 +1,6 @@ import { Edit, Trash2 } from "lucide-react"; import { TextPair } from "./InFolder"; -import { updateTextPairById } from "@/lib/services/textPairService"; +import { updateTextPairById } from "@/lib/actions/services/textPairService"; import { useState } from "react"; import { text_pairUpdateInput } from "../../../../generated/prisma/models"; import UpdateTextPairModal from "./UpdateTextPairModal"; diff --git a/src/app/folders/[folder_id]/page.tsx b/src/app/folders/[folder_id]/page.tsx index b6aefd2..1faaee3 100644 --- a/src/app/folders/[folder_id]/page.tsx +++ b/src/app/folders/[folder_id]/page.tsx @@ -2,7 +2,7 @@ import { redirect } from "next/navigation"; import { getServerSession } from "next-auth"; import { getTranslations } from "next-intl/server"; import InFolder from "./InFolder"; -import { getOwnerByFolderId } from "@/lib/services/folderService"; +import { getOwnerByFolderId } from "@/lib/actions/services/folderService"; export default async function FoldersPage({ params, }: { diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 27dc430..3af3dd1 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,7 +2,7 @@ import type { Metadata } from "next"; import "./globals.css"; import type { Viewport } from "next"; import { NextIntlClientProvider } from "next-intl"; -import SessionWrapper from "@/lib/SessionWrapper"; +import SessionWrapper from "@/components/SessionWrapper"; import { Navbar } from "@/components/Navbar"; import { Toaster } from "sonner"; diff --git a/src/lib/SessionWrapper.tsx b/src/components/SessionWrapper.tsx similarity index 100% rename from src/lib/SessionWrapper.tsx rename to src/components/SessionWrapper.tsx diff --git a/src/hooks/useAudioPlayer.ts b/src/hooks/useAudioPlayer.ts index 87695f9..d949633 100644 --- a/src/hooks/useAudioPlayer.ts +++ b/src/hooks/useAudioPlayer.ts @@ -4,6 +4,7 @@ type AudioPlayerError = Error | null; export function useAudioPlayer() { const audioRef = useRef(null); + const abortControllerRef = useRef(null); const [state, setState] = useState({ isPlaying: false, isLoading: false, @@ -32,7 +33,10 @@ export function useAudioPlayer() { setState((prev) => ({ ...prev, isPlaying: false, currentTime: 0 })); const handleError = (e: Event) => { const target = e.target as HTMLAudioElement; - setError(new Error(target.error?.message || "Audio playback error")); + // 忽略中止错误,这些是预期的 + if (target.error?.code !== MediaError.MEDIA_ERR_ABORTED) { + setError(new Error(target.error?.message || "Audio playback error")); + } setState((prev) => ({ ...prev, isLoading: false, isPlaying: false })); }; @@ -44,6 +48,11 @@ export function useAudioPlayer() { audio.addEventListener("error", handleError); return () => { + // 中止所有进行中的操作 + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + audio.removeEventListener("loadstart", handleLoadStart); audio.removeEventListener("canplay", handleCanPlay); audio.removeEventListener("loadedmetadata", handleLoadedMetadata); @@ -67,6 +76,10 @@ export function useAudioPlayer() { await audioRef.current.play(); setState((prev) => ({ ...prev, isPlaying: true })); } catch (err) { + // 忽略中止错误 + if (err instanceof Error && err.name === "AbortError") { + return; + } const error = err instanceof Error ? err : new Error("Failed to play audio"); setError(error); @@ -102,7 +115,7 @@ export function useAudioPlayer() { if (audioRef.current) { const clampedTime = Math.max( 0, - Math.min(audioRef.current.duration, time), + Math.min(audioRef.current.duration || 0, time), ); audioRef.current.currentTime = clampedTime; setState((prev) => ({ ...prev, currentTime: clampedTime })); @@ -112,44 +125,110 @@ export function useAudioPlayer() { const load = useCallback(async (audioUrl: string) => { if (!audioRef.current) return; + // 中止之前的加载操作 + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + + const abortController = new AbortController(); + abortControllerRef.current = abortController; + try { setError(null); setState((prev) => ({ ...prev, isLoading: true })); - // Only load if URL is different + // 如果信号已经中止,直接返回 + if (abortController.signal.aborted) { + return; + } + + // 重置当前播放状态 + if (audioRef.current) { + audioRef.current.pause(); + audioRef.current.currentTime = 0; + } + + // Only load if URL is different or we need to force reload if (audioRef.current.src !== audioUrl) { audioRef.current.src = audioUrl; - await new Promise((resolve, reject) => { - if (!audioRef.current) - return reject(new Error("Audio element not found")); + + await new Promise((resolve, reject) => { + if (!audioRef.current) { + reject(new Error("Audio element not found")); + return; + } + + // 检查是否已经中止 + if (abortController.signal.aborted) { + reject(new DOMException("Aborted", "AbortError")); + return; + } const handleCanPlay = () => { - audioRef.current?.removeEventListener("canplay", handleCanPlay); - audioRef.current?.removeEventListener("error", handleError); - resolve(void 0); + cleanup(); + resolve(); }; - const handleError = () => { - audioRef.current?.removeEventListener("canplay", handleCanPlay); - audioRef.current?.removeEventListener("error", handleError); - reject(new Error("Failed to load audio")); + const handleError = (e: Event) => { + cleanup(); + const target = e.target as HTMLAudioElement; + // 如果是中止错误,不视为真正的错误 + if (target.error?.code === MediaError.MEDIA_ERR_ABORTED) { + reject(new DOMException("Aborted", "AbortError")); + } else { + reject(new Error("Failed to load audio")); + } }; - audioRef.current.addEventListener("canplay", handleCanPlay); - audioRef.current.addEventListener("error", handleError); + const handleAbort = () => { + cleanup(); + reject(new DOMException("Aborted", "AbortError")); + }; + + const cleanup = () => { + audioRef.current?.removeEventListener("canplay", handleCanPlay); + audioRef.current?.removeEventListener("error", handleError); + abortController.signal.removeEventListener("abort", handleAbort); + }; + + audioRef.current.addEventListener("canplay", handleCanPlay, { once: true }); + audioRef.current.addEventListener("error", handleError, { once: true }); + abortController.signal.addEventListener("abort", handleAbort, { once: true }); + + // 如果音频已经可以播放,立即解析 + if (audioRef.current.readyState >= HTMLMediaElement.HAVE_ENOUGH_DATA) { + handleCanPlay(); + } }); } - setState((prev) => ({ ...prev, isLoading: false })); + if (!abortController.signal.aborted) { + setState((prev) => ({ ...prev, isLoading: false })); + } } catch (err) { + // 忽略中止错误 + if (err instanceof DOMException && err.name === "AbortError") { + return; + } const error = err instanceof Error ? err : new Error("Failed to load audio"); setError(error); setState((prev) => ({ ...prev, isLoading: false })); throw error; + } finally { + // 清理中止控制器,如果仍然是当前的话 + if (abortControllerRef.current === abortController) { + abortControllerRef.current = null; + } } }, []); + // 新增:同时加载和播放的便捷方法 + const playAudio = useCallback(async (audioUrl: string) => { + await load(audioUrl); + await play(); + }, [load, play]); + return { ...state, play, @@ -158,7 +237,8 @@ export function useAudioPlayer() { setVolume, seek, load, + playAudio, // 新增的便捷方法 error, audioRef, }; -} +} \ No newline at end of file diff --git a/src/lib/ai.ts b/src/lib/actions/ai.ts similarity index 98% rename from src/lib/ai.ts rename to src/lib/actions/ai.ts index 10f8a95..1db47ee 100644 --- a/src/lib/ai.ts +++ b/src/lib/actions/ai.ts @@ -1,3 +1,5 @@ +"use server"; + import { format } from "util"; async function callZhipuAPI( diff --git a/src/lib/services/folderService.ts b/src/lib/actions/services/folderService.ts similarity index 93% rename from src/lib/services/folderService.ts rename to src/lib/actions/services/folderService.ts index bb77992..7d51d6d 100644 --- a/src/lib/services/folderService.ts +++ b/src/lib/actions/services/folderService.ts @@ -3,8 +3,8 @@ import { folderCreateInput, folderUpdateInput, -} from "../../../generated/prisma/models"; -import prisma from "../db"; +} from "../../../../generated/prisma/models"; +import prisma from "../../db"; export async function getFoldersByOwner(owner: string) { const folders = await prisma.folder.findMany({ diff --git a/src/lib/services/textPairService.ts b/src/lib/actions/services/textPairService.ts similarity index 92% rename from src/lib/services/textPairService.ts rename to src/lib/actions/services/textPairService.ts index 66d92c5..e728cc7 100644 --- a/src/lib/services/textPairService.ts +++ b/src/lib/actions/services/textPairService.ts @@ -3,8 +3,8 @@ import { text_pairCreateInput, text_pairUpdateInput, -} from "../../../generated/prisma/models"; -import prisma from "../db"; +} from "../../../../generated/prisma/models"; +import prisma from "../../db"; export async function createTextPair(data: text_pairCreateInput) { await prisma.text_pair.create({ diff --git a/src/lib/actions/translatorActions.ts b/src/lib/actions/translatorActions.ts index 21194fb..e3067fc 100644 --- a/src/lib/actions/translatorActions.ts +++ b/src/lib/actions/translatorActions.ts @@ -1,6 +1,6 @@ "use server"; -import { getLLMAnswer } from "../ai"; +import { getLLMAnswer } from "./ai"; export const genIPA = async (text: string) => { return ( diff --git a/src/lib/browser/localStorageOperators.ts b/src/lib/browser/localStorageOperators.ts new file mode 100644 index 0000000..5505792 --- /dev/null +++ b/src/lib/browser/localStorageOperators.ts @@ -0,0 +1,58 @@ +import { + TranslationHistoryArraySchema, + TranslationHistorySchema, +} from "@/lib/interfaces"; +import z from "zod"; +import { shallowEqual } from "../utils"; + +export const getLocalStorageOperator = ( + key: string, + schema: T, +) => { + return { + get: (): z.infer => { + try { + const item = globalThis.localStorage.getItem(key); + + if (!item) return []; + + const rawData = JSON.parse(item) as z.infer; + const result = schema.safeParse(rawData); + + if (result.success) { + return result.data; + } else { + console.error( + "Invalid data structure in localStorage:", + result.error, + ); + return []; + } + } catch (e) { + console.error(`Failed to parse ${key} data:`, e); + return []; + } + }, + set: (data: z.infer) => { + if (!globalThis.localStorage) return; + globalThis.localStorage.setItem(key, JSON.stringify(data)); + return data; + }, + }; +}; + + +const MAX_HISTORY_LENGTH = 50; +export const tlso = getLocalStorageOperator< + typeof TranslationHistoryArraySchema +>("translator", TranslationHistoryArraySchema); + +export const tlsoPush = (item: z.infer) => { + const oldHistory = tlso.get(); + if (oldHistory.some((v) => shallowEqual(v, item))) return oldHistory; + + const newHistory = [...oldHistory, item].slice(-MAX_HISTORY_LENGTH); + tlso.set(newHistory); + + return newHistory; +}; diff --git a/src/lib/tts.ts b/src/lib/browser/tts.ts similarity index 74% rename from src/lib/tts.ts rename to src/lib/browser/tts.ts index 9101093..6245761 100644 --- a/src/lib/tts.ts +++ b/src/lib/browser/tts.ts @@ -1,5 +1,4 @@ -import { ProsodyOptions } from "edge-tts-universal"; -import { EdgeTTS } from "edge-tts-universal/browser"; +import { ProsodyOptions, EdgeTTS } from "edge-tts-universal/browser"; export async function getTTSAudioUrl( text: string, diff --git a/src/lib/localStorageOperators.ts b/src/lib/localStorageOperators.ts deleted file mode 100644 index 6a2bba7..0000000 --- a/src/lib/localStorageOperators.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { - TranslationHistoryArraySchema, - TranslationHistorySchema, -} from "@/lib/interfaces"; -import { getLocalStorageOperator, shallowEqual } from "@/lib/utils"; -import z from "zod"; - -const MAX_HISTORY_LENGTH = 50; - -export const tlso = getLocalStorageOperator< - typeof TranslationHistoryArraySchema ->("translator", TranslationHistoryArraySchema); - -export const tlsoPush = (item: z.infer) => { - const oldHistory = tlso.get(); - if (oldHistory.some((v) => shallowEqual(v, item))) return oldHistory; - - const newHistory = [...oldHistory, item].slice(-MAX_HISTORY_LENGTH); - tlso.set(newHistory); - - return newHistory; -}; diff --git a/src/lib/utils.ts b/src/lib/utils.ts index c93cbd1..c41a87d 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,130 +1,3 @@ -import { EdgeTTS, ProsodyOptions } from "edge-tts-universal/browser"; -import { env } from "process"; -import z from "zod"; -import { NextResponse } from "next/server"; - -export function inspect(word: string) { - const goto = (url: string) => { - window.open(url, "_blank"); - }; - return () => { - word = word.toLowerCase(); - goto(`https://www.youdao.com/result?word=${word}&lang=en`); - }; -} - -export function urlGoto(url: string) { - window.open(url, "_blank"); -} -const API_KEY = env.ZHIPU_API_KEY; -export async function callZhipuAPI( - messages: { role: string; content: string }[], - model = "glm-4.6", -) { - const url = "https://open.bigmodel.cn/api/paas/v4/chat/completions"; - - const response = await fetch(url, { - method: "POST", - headers: { - Authorization: "Bearer " + API_KEY, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - model: model, - messages: messages, - temperature: 0.2, - thinking: { - type: "disabled", - }, - }), - }); - - if (!response.ok) { - throw new Error(`API 调用失败: ${response.status}`); - } - - return await response.json(); -} - -export async function getTTSAudioUrl( - text: string, - short_name: string, - options: ProsodyOptions | undefined = undefined, -) { - const tts = new EdgeTTS(text, short_name, options); - try { - const result = await tts.synthesize(); - return URL.createObjectURL(result.audio); - } catch (e) { - throw e; - } -} - -export const getLocalStorageOperator = ( - key: string, - schema: T, -) => { - return { - get: (): z.infer => { - try { - const item = globalThis.localStorage.getItem(key); - - if (!item) return []; - - const rawData = JSON.parse(item) as z.infer; - const result = schema.safeParse(rawData); - - if (result.success) { - return result.data; - } else { - console.error( - "Invalid data structure in localStorage:", - result.error, - ); - return []; - } - } catch (e) { - console.error(`Failed to parse ${key} data:`, e); - return []; - } - }, - set: (data: z.infer) => { - if (!globalThis.localStorage) return; - globalThis.localStorage.setItem(key, JSON.stringify(data)); - return data; - }, - }; -}; - -export function handleAPIError(error: unknown, message: string) { - console.error(message, error); - return NextResponse.json( - { error: "服务器内部错误", message }, - { status: 500 }, - ); -} - - -export const letsFetch = ( - url: string, - onSuccess: (message: string) => void, - onError: (message: string) => void, - onFinally: () => void, -) => { - return fetch(url) - .then((response) => response.json()) - .then((data) => { - if (data.status === "success") { - onSuccess(data.message); - } else if (data.status === "error") { - onError(data.message); - } else { - onError("Unknown error"); - } - }) - .finally(onFinally); -}; - export function isNonNegativeInteger(str: string): boolean { return /^\d+$/.test(str); }