...
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
2025-11-17 15:59:35 +08:00
parent 22a0cf46fb
commit 0bf3b718b2
35 changed files with 204 additions and 841 deletions

View File

@@ -5,3 +5,4 @@ npm-debug.log
README.md README.md
.next .next
.git .git
certificates

2
.gitignore vendored
View File

@@ -47,3 +47,5 @@ build.sh
test.ts test.ts
/generated/prisma /generated/prisma
certificates

View File

@@ -4,7 +4,7 @@
"private": true, "private": true,
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev --turbopack --experimental-https",
"build": "next build --turbopack", "build": "next build --turbopack",
"start": "next start", "start": "next start",
"lint": "eslint" "lint": "eslint"

View File

@@ -6,7 +6,7 @@ import Container from "@/components/cards/Container";
import { useState } from "react"; import { useState } from "react";
import LightButton from "@/components/buttons/LightButton"; import LightButton from "@/components/buttons/LightButton";
import { useAudioPlayer } from "@/hooks/useAudioPlayer"; import { useAudioPlayer } from "@/hooks/useAudioPlayer";
import { getTTSAudioUrl } from "@/lib/tts"; import { getTTSAudioUrl } from "@/lib/browser/tts";
import { VOICES } from "@/config/locales"; import { VOICES } from "@/config/locales";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";

View File

@@ -6,11 +6,11 @@ import { getTranslations } from "next-intl/server";
import { import {
getFoldersWithTotalPairsByOwner, getFoldersWithTotalPairsByOwner,
getOwnerByFolderId, getOwnerByFolderId,
} from "@/lib/services/folderService"; } from "@/lib/actions/services/folderService";
import { isNonNegativeInteger } from "@/lib/utils"; import { isNonNegativeInteger } from "@/lib/utils";
import FolderSelector from "./FolderSelector"; import FolderSelector from "./FolderSelector";
import Memorize from "./Memorize"; import Memorize from "./Memorize";
import { getTextPairsByFolderId } from "@/lib/services/textPairService"; import { getTextPairsByFolderId } from "@/lib/actions/services/textPairService";
export default async function MemorizePage({ export default async function MemorizePage({
searchParams, searchParams,

View File

@@ -1,13 +1,16 @@
import { inspect } from "@/lib/utils";
export default function SubtitleDisplay({ subtitle }: { subtitle: string }) { export default function SubtitleDisplay({ subtitle }: { subtitle: string }) {
const words = subtitle.match(/\b[\w']+(?:-[\w']+)*\b/g) || []; const words = subtitle.match(/\b[\w']+(?:-[\w']+)*\b/g) || [];
let i = 0; let i = 0;
return ( return (
<div className="w-full subtitle overflow-auto h-16 mt-2 break-words bg-black/50 font-sans text-white text-center text-2xl"> <div className="w-full subtitle overflow-auto h-16 mt-2 wrap-break-word bg-black/50 font-sans text-white text-center text-2xl">
{words.map((v) => ( {words.map((v) => (
<span <span
onClick={inspect(v)} onClick={() => {
window.open(
`https://www.youdao.com/result?word=${v}&lang=en`,
"_blank",
);
}}
key={i++} key={i++}
className="hover:bg-gray-700 hover:underline hover:cursor-pointer" className="hover:bg-gray-700 hover:underline hover:cursor-pointer"
> >

View File

@@ -1,6 +1,5 @@
"use client"; "use client";
import { getLocalStorageOperator } from "@/lib/utils";
import { useState } from "react"; import { useState } from "react";
import z from "zod"; import z from "zod";
import { import {
@@ -10,6 +9,7 @@ import {
import IconClick from "@/components/IconClick"; import IconClick from "@/components/IconClick";
import IMAGES from "@/config/images"; import IMAGES from "@/config/images";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { getLocalStorageOperator } from "@/lib/browser/localStorageOperators";
interface TextCardProps { interface TextCardProps {
item: z.infer<typeof TextSpeakerItemSchema>; item: z.infer<typeof TextSpeakerItemSchema>;

View File

@@ -8,13 +8,15 @@ import {
TextSpeakerArraySchema, TextSpeakerArraySchema,
TextSpeakerItemSchema, TextSpeakerItemSchema,
} from "@/lib/interfaces"; } from "@/lib/interfaces";
import { getLocalStorageOperator, getTTSAudioUrl } from "@/lib/utils";
import { ChangeEvent, useEffect, useRef, useState } from "react"; import { ChangeEvent, useEffect, useRef, useState } from "react";
import z from "zod"; import z from "zod";
import SaveList from "./SaveList"; import SaveList from "./SaveList";
import { VOICES } from "@/config/locales"; import { VOICES } from "@/config/locales";
import { useTranslations } from "next-intl"; 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() { export default function TextSpeakerPage() {
const t = useTranslations("text_speaker"); const t = useTranslations("text_speaker");
@@ -94,14 +96,9 @@ export default function TextSpeakerPage() {
let theLocale = locale; let theLocale = locale;
if (!theLocale) { if (!theLocale) {
console.log("downloading text info"); console.log("downloading text info");
const params = new URLSearchParams({ const tmp_locale = await genLocale(textRef.current.slice(0, 30));
text: textRef.current.slice(0, 30), setLocale(tmp_locale);
}); theLocale = tmp_locale;
const textinfo = await (
await fetch(`/api/locale?${params}`)
).json();
setLocale(textinfo.locale);
theLocale = textinfo.locale as string;
} }
const voice = VOICES.find((v) => v.locale.startsWith(theLocale)); const voice = VOICES.find((v) => v.locale.startsWith(theLocale));
@@ -184,22 +181,16 @@ export default function TextSpeakerPage() {
let theLocale = locale; let theLocale = locale;
if (!theLocale) { if (!theLocale) {
console.log("downloading text info"); console.log("downloading text info");
const params = new URLSearchParams({ const tmp_locale = await genLocale(textRef.current.slice(0, 30));
text: textRef.current.slice(0, 30), setLocale(tmp_locale);
}); theLocale = tmp_locale;
const textinfo = await (await fetch(`/api/locale?${params}`)).json();
setLocale(textinfo.locale);
theLocale = textinfo.locale as string;
} }
let theIPA = ipa; let theIPA = ipa;
if (ipa.length === 0 && ipaEnabled) { if (ipa.length === 0 && ipaEnabled) {
const params = new URLSearchParams({ const tmp_ipa = await genIPA(textRef.current);
text: textRef.current, setIPA(tmp_ipa);
}); theIPA = tmp_ipa;
const tmp = await (await fetch(`/api/ipa?${params}`)).json();
setIPA(tmp.ipa);
theIPA = tmp.ipa;
} }
const save = getFromLocalStorage(); const save = getFromLocalStorage();

View File

@@ -5,9 +5,9 @@ import { useSession } from "next-auth/react";
import { Dispatch, useEffect, useState } from "react"; import { Dispatch, useEffect, useState } from "react";
import z from "zod"; import z from "zod";
import { folder } from "../../../../generated/prisma/browser"; 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 { Folder } from "lucide-react";
import { createTextPair } from "@/lib/services/textPairService"; import { createTextPair } from "@/lib/actions/services/textPairService";
import { toast } from "sonner"; import { toast } from "sonner";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";

View File

@@ -1,7 +1,7 @@
import Container from "@/components/cards/Container"; import Container from "@/components/cards/Container";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { folder } from "../../../../generated/prisma/browser"; 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 LightButton from "@/components/buttons/LightButton";
import { Folder } from "lucide-react"; import { Folder } from "lucide-react";

View File

@@ -6,9 +6,8 @@ import IMAGES from "@/config/images";
import { VOICES } from "@/config/locales"; import { VOICES } from "@/config/locales";
import { useAudioPlayer } from "@/hooks/useAudioPlayer"; import { useAudioPlayer } from "@/hooks/useAudioPlayer";
import { TranslationHistorySchema } from "@/lib/interfaces"; import { TranslationHistorySchema } from "@/lib/interfaces";
import { tlsoPush, tlso } from "@/lib/localStorageOperators"; import { tlsoPush, tlso } from "@/lib/browser/localStorageOperators";
import { getTTSAudioUrl } from "@/lib/tts"; import { getTTSAudioUrl } from "@/lib/browser/tts";
import { shallowEqual } from "@/lib/utils";
import { Plus, Trash } from "lucide-react"; import { Plus, Trash } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
@@ -22,7 +21,8 @@ import {
import { toast } from "sonner"; import { toast } from "sonner";
import FolderSelector from "./FolderSelector"; import FolderSelector from "./FolderSelector";
import { useSession } from "next-auth/react"; 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() { export default function TranslatorPage() {
const t = useTranslations("translator"); const t = useTranslations("translator");
@@ -64,6 +64,7 @@ export default function TranslatorPage() {
lastTTS.current.url = url; lastTTS.current.url = url;
} catch (error) { } catch (error) {
toast.error("Failed to generate audio"); toast.error("Failed to generate audio");
console.error(error);
} }
} }
await play(); await play();

View File

@@ -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<typeof TranslationHistorySchema>;
setShow: Dispatch<React.SetStateAction<boolean>>;
}
const AddToFolder: React.FC<AddToFolderProps> = ({ item, setShow }) => {
const session = useSession();
const [folders, setFolders] = useState<folder[]>([]);
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 (
<div className="fixed left-0 top-0 z-50 w-screen h-screen bg-black/50 flex justify-center items-center">
<Container className="p-6">
<div>{t("notAuthenticated")}</div>
</Container>
</div>
);
}
return (
<div className="fixed left-0 top-0 z-50 w-screen h-screen bg-black/50 flex justify-center items-center">
<Container className="p-6">
<h1>{t("chooseFolder")}</h1>
<div className="border border-gray-200 rounded-2xl">
{(loading && <span>...</span>) ||
(folders.length > 0 &&
folders.map((folder) => (
<button
key={folder.id}
className="p-2 flex items-center justify-start hover:bg-gray-50 gap-2 hover:cursor-pointer w-full border-b border-gray-200"
onClick={() => {
createTextPair({
text1: item.text1,
text2: item.text2,
locale1: item.locale1,
locale2: item.locale2,
folders: {
connect: {
id: folder.id,
},
},
})
.then(() => {
toast.success(t("success"));
setShow(false);
})
.catch(() => {
toast.error(t("error"));
});
}}
>
<Folder />
{t("folderInfo", { id: folder.id, name: folder.name })}
</button>
))) || <div>{t("noFolders")}</div>}
</div>
<LightButton onClick={() => setShow(false)}>{t("close")}</LightButton>
</Container>
</div>
);
};
export default AddToFolder;

View File

@@ -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<HTMLTextAreaElement>(null);
const [lang, setLang] = useState<string>("chinese");
const [tresult, setTresult] = useState<string>("");
const [genIpa, setGenIpa] = useState(true);
const [ipaTexts, setIpaTexts] = useState(["", ""]);
const [processing, setProcessing] = useState(false);
const { load, play } = useAudioPlayer();
const [history, setHistory] = useState<
z.infer<typeof TranslationHistorySchema>[]
>(tlso.get());
const [showAddToFolder, setShowAddToFolder] = useState(false);
const [addToFolderItem, setAddToFolderItem] = useState<z.infer<
typeof TranslationHistorySchema
> | 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<typeof TranslationHistorySchema>));
}
};
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 */}
<div className="w-screen flex flex-col md:flex-row md:justify-between gap-2 p-2">
{/* Card Component - Left Side */}
<div className="w-full md:w-1/2 flex flex-col-reverse gap-2">
{/* ICard1 Component */}
<div className="border border-gray-200 rounded-2xl w-full h-64 p-2">
<textarea
className="resize-none h-8/12 w-full focus:outline-0"
ref={taref}
onKeyDown={(e) => {
if (e.ctrlKey && e.key === "Enter") translate();
}}
></textarea>
<div className="ipa w-full h-2/12 overflow-auto text-gray-600">
{ipaTexts[0]}
</div>
<div className="h-2/12 w-full flex justify-end items-center">
<IconClick
src={IMAGES.copy_all}
alt="copy"
onClick={async () => {
await navigator.clipboard.writeText(
taref.current?.value || "",
);
}}
></IconClick>
<IconClick
src={IMAGES.play_arrow}
alt="play"
onClick={() => {
const t = taref.current?.value;
if (!t) return;
tts(t, tlso.get().find((v) => v.text1 === t)?.locale1 || "");
}}
></IconClick>
</div>
</div>
<div className="option1 w-full flex flex-row justify-between items-center">
<span>{t("detectLanguage")}</span>
<LightButton
selected={genIpa}
onClick={() => setGenIpa((prev) => !prev)}
>
{t("generateIPA")}
</LightButton>
</div>
</div>
{/* Card Component - Right Side */}
<div className="w-full md:w-1/2 flex flex-col-reverse gap-2">
{/* ICard2 Component */}
<div className="bg-gray-100 rounded-2xl w-full h-64 p-2">
<div className="h-2/3 w-full overflow-y-auto">{tresult}</div>
<div className="ipa w-full h-1/6 overflow-y-auto text-gray-600">
{ipaTexts[1]}
</div>
<div className="h-1/6 w-full flex justify-end items-center">
<IconClick
src={IMAGES.copy_all}
alt="copy"
onClick={async () => {
await navigator.clipboard.writeText(tresult);
}}
></IconClick>
<IconClick
src={IMAGES.play_arrow}
alt="play"
onClick={() => {
tts(
tresult,
tlso.get().find((v) => v.text2 === tresult)?.locale2 || "",
);
}}
></IconClick>
</div>
</div>
<div className="option2 w-full flex gap-1 items-center flex-wrap">
<span>{t("translateInto")}</span>
<LightButton
selected={lang === "chinese"}
onClick={() => setLang("chinese")}
>
{t("chinese")}
</LightButton>
<LightButton
selected={lang === "english"}
onClick={() => setLang("english")}
>
{t("english")}
</LightButton>
<LightButton
selected={lang === "italian"}
onClick={() => setLang("italian")}
>
{t("italian")}
</LightButton>
<LightButton
selected={!["chinese", "english", "italian"].includes(lang)}
onClick={() => {
const newLang = prompt(t("enterLanguage"));
if (newLang) {
setLang(newLang);
}
}}
>
{t("other")}
</LightButton>
</div>
</div>
</div>
{/* TranslateButton Component */}
<div className="w-screen flex justify-center items-center">
<button
className={`duration-150 ease-in text-xl font-extrabold border rounded-4xl p-3 border-gray-200 h-16 ${processing ? "bg-gray-200" : "bg-white hover:bg-gray-200 hover:cursor-pointer"}`}
onClick={translate}
>
{t("translate")}
</button>
</div>
{history.length > 0 && (
<div className="m-6 flex flex-col items-center">
<h1 className="text-2xl font-light">{t("history")}</h1>
<div className="border border-gray-200 rounded-2xl m-4">
{history.toReversed().map((item, index) => (
<div key={index}>
<div className="border-b border-gray-200 p-2 group hover:bg-gray-50 flex gap-2 flex-row justify-between items-start">
<div className="flex-1 flex flex-col">
<p className="text-sm font-light">{item.text1}</p>
<p className="text-sm font-light">{item.text2}</p>
</div>
<div className="flex gap-2">
<button
onClick={() => {
setShowAddToFolder(true);
setAddToFolderItem(item);
}}
className="hover:bg-gray-200 hover:cursor-pointer rounded-4xl border border-gray-200 w-8 h-8 flex justify-center items-center"
>
<Plus />
</button>
<button
onClick={() => {
setHistory(
tlso.set(
tlso.get().filter((v) => !shallowEqual(v, item)),
) || [],
);
}}
className="hover:bg-gray-200 hover:cursor-pointer rounded-4xl border border-gray-200 w-8 h-8 flex justify-center items-center"
>
<Trash />
</button>
</div>
</div>
</div>
))}
</div>
{showAddToFolder && (
<AddToFolder setShow={setShowAddToFolder} item={addToFolderItem!} />
)}
</div>
)}
</>
);
}

View File

@@ -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, "请稍后再试");
}
}

View File

@@ -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 },
);
}
}

View File

@@ -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 },
);
}

View File

@@ -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 },
);
}
}

View File

@@ -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"],
);
}

View File

@@ -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"],
);
}

View File

@@ -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"],
);
}

View File

@@ -9,7 +9,7 @@ import {
createFolder, createFolder,
deleteFolderById, deleteFolderById,
getFoldersWithTotalPairsByOwner, getFoldersWithTotalPairsByOwner,
} from "@/lib/services/folderService"; } from "@/lib/actions/services/folderService";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
interface FolderProps { interface FolderProps {

View File

@@ -9,7 +9,7 @@ import {
createTextPair, createTextPair,
deleteTextPairById, deleteTextPairById,
getTextPairsByFolderId, getTextPairsByFolderId,
} from "@/lib/services/textPairService"; } from "@/lib/actions/services/textPairService";
import AddTextPairModal from "./AddTextPairModal"; import AddTextPairModal from "./AddTextPairModal";
import TextPairCard from "./TextPairCard"; import TextPairCard from "./TextPairCard";
import LightButton from "@/components/buttons/LightButton"; import LightButton from "@/components/buttons/LightButton";

View File

@@ -1,6 +1,6 @@
import { Edit, Trash2 } from "lucide-react"; import { Edit, Trash2 } from "lucide-react";
import { TextPair } from "./InFolder"; import { TextPair } from "./InFolder";
import { updateTextPairById } from "@/lib/services/textPairService"; import { updateTextPairById } from "@/lib/actions/services/textPairService";
import { useState } from "react"; import { useState } from "react";
import { text_pairUpdateInput } from "../../../../generated/prisma/models"; import { text_pairUpdateInput } from "../../../../generated/prisma/models";
import UpdateTextPairModal from "./UpdateTextPairModal"; import UpdateTextPairModal from "./UpdateTextPairModal";

View File

@@ -2,7 +2,7 @@ import { redirect } from "next/navigation";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import InFolder from "./InFolder"; import InFolder from "./InFolder";
import { getOwnerByFolderId } from "@/lib/services/folderService"; import { getOwnerByFolderId } from "@/lib/actions/services/folderService";
export default async function FoldersPage({ export default async function FoldersPage({
params, params,
}: { }: {

View File

@@ -2,7 +2,7 @@ import type { Metadata } from "next";
import "./globals.css"; import "./globals.css";
import type { Viewport } from "next"; import type { Viewport } from "next";
import { NextIntlClientProvider } from "next-intl"; import { NextIntlClientProvider } from "next-intl";
import SessionWrapper from "@/lib/SessionWrapper"; import SessionWrapper from "@/components/SessionWrapper";
import { Navbar } from "@/components/Navbar"; import { Navbar } from "@/components/Navbar";
import { Toaster } from "sonner"; import { Toaster } from "sonner";

View File

@@ -4,6 +4,7 @@ type AudioPlayerError = Error | null;
export function useAudioPlayer() { export function useAudioPlayer() {
const audioRef = useRef<HTMLAudioElement | null>(null); const audioRef = useRef<HTMLAudioElement | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const [state, setState] = useState({ const [state, setState] = useState({
isPlaying: false, isPlaying: false,
isLoading: false, isLoading: false,
@@ -32,7 +33,10 @@ export function useAudioPlayer() {
setState((prev) => ({ ...prev, isPlaying: false, currentTime: 0 })); setState((prev) => ({ ...prev, isPlaying: false, currentTime: 0 }));
const handleError = (e: Event) => { const handleError = (e: Event) => {
const target = e.target as HTMLAudioElement; const target = e.target as HTMLAudioElement;
// 忽略中止错误,这些是预期的
if (target.error?.code !== MediaError.MEDIA_ERR_ABORTED) {
setError(new Error(target.error?.message || "Audio playback error")); setError(new Error(target.error?.message || "Audio playback error"));
}
setState((prev) => ({ ...prev, isLoading: false, isPlaying: false })); setState((prev) => ({ ...prev, isLoading: false, isPlaying: false }));
}; };
@@ -44,6 +48,11 @@ export function useAudioPlayer() {
audio.addEventListener("error", handleError); audio.addEventListener("error", handleError);
return () => { return () => {
// 中止所有进行中的操作
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
audio.removeEventListener("loadstart", handleLoadStart); audio.removeEventListener("loadstart", handleLoadStart);
audio.removeEventListener("canplay", handleCanPlay); audio.removeEventListener("canplay", handleCanPlay);
audio.removeEventListener("loadedmetadata", handleLoadedMetadata); audio.removeEventListener("loadedmetadata", handleLoadedMetadata);
@@ -67,6 +76,10 @@ export function useAudioPlayer() {
await audioRef.current.play(); await audioRef.current.play();
setState((prev) => ({ ...prev, isPlaying: true })); setState((prev) => ({ ...prev, isPlaying: true }));
} catch (err) { } catch (err) {
// 忽略中止错误
if (err instanceof Error && err.name === "AbortError") {
return;
}
const error = const error =
err instanceof Error ? err : new Error("Failed to play audio"); err instanceof Error ? err : new Error("Failed to play audio");
setError(error); setError(error);
@@ -102,7 +115,7 @@ export function useAudioPlayer() {
if (audioRef.current) { if (audioRef.current) {
const clampedTime = Math.max( const clampedTime = Math.max(
0, 0,
Math.min(audioRef.current.duration, time), Math.min(audioRef.current.duration || 0, time),
); );
audioRef.current.currentTime = clampedTime; audioRef.current.currentTime = clampedTime;
setState((prev) => ({ ...prev, currentTime: clampedTime })); setState((prev) => ({ ...prev, currentTime: clampedTime }));
@@ -112,44 +125,110 @@ export function useAudioPlayer() {
const load = useCallback(async (audioUrl: string) => { const load = useCallback(async (audioUrl: string) => {
if (!audioRef.current) return; if (!audioRef.current) return;
// 中止之前的加载操作
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
const abortController = new AbortController();
abortControllerRef.current = abortController;
try { try {
setError(null); setError(null);
setState((prev) => ({ ...prev, isLoading: true })); 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) { if (audioRef.current.src !== audioUrl) {
audioRef.current.src = audioUrl; audioRef.current.src = audioUrl;
await new Promise((resolve, reject) => {
if (!audioRef.current) await new Promise<void>((resolve, reject) => {
return reject(new Error("Audio element not found")); if (!audioRef.current) {
reject(new Error("Audio element not found"));
return;
}
// 检查是否已经中止
if (abortController.signal.aborted) {
reject(new DOMException("Aborted", "AbortError"));
return;
}
const handleCanPlay = () => { const handleCanPlay = () => {
audioRef.current?.removeEventListener("canplay", handleCanPlay); cleanup();
audioRef.current?.removeEventListener("error", handleError); resolve();
resolve(void 0);
}; };
const handleError = () => { const handleError = (e: Event) => {
audioRef.current?.removeEventListener("canplay", handleCanPlay); cleanup();
audioRef.current?.removeEventListener("error", handleError); 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")); reject(new Error("Failed to load audio"));
}
}; };
audioRef.current.addEventListener("canplay", handleCanPlay); const handleAbort = () => {
audioRef.current.addEventListener("error", handleError); 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();
}
}); });
} }
if (!abortController.signal.aborted) {
setState((prev) => ({ ...prev, isLoading: false })); setState((prev) => ({ ...prev, isLoading: false }));
}
} catch (err) { } catch (err) {
// 忽略中止错误
if (err instanceof DOMException && err.name === "AbortError") {
return;
}
const error = const error =
err instanceof Error ? err : new Error("Failed to load audio"); err instanceof Error ? err : new Error("Failed to load audio");
setError(error); setError(error);
setState((prev) => ({ ...prev, isLoading: false })); setState((prev) => ({ ...prev, isLoading: false }));
throw error; throw error;
} finally {
// 清理中止控制器,如果仍然是当前的话
if (abortControllerRef.current === abortController) {
abortControllerRef.current = null;
}
} }
}, []); }, []);
// 新增:同时加载和播放的便捷方法
const playAudio = useCallback(async (audioUrl: string) => {
await load(audioUrl);
await play();
}, [load, play]);
return { return {
...state, ...state,
play, play,
@@ -158,6 +237,7 @@ export function useAudioPlayer() {
setVolume, setVolume,
seek, seek,
load, load,
playAudio, // 新增的便捷方法
error, error,
audioRef, audioRef,
}; };

View File

@@ -1,3 +1,5 @@
"use server";
import { format } from "util"; import { format } from "util";
async function callZhipuAPI( async function callZhipuAPI(

View File

@@ -3,8 +3,8 @@
import { import {
folderCreateInput, folderCreateInput,
folderUpdateInput, folderUpdateInput,
} from "../../../generated/prisma/models"; } from "../../../../generated/prisma/models";
import prisma from "../db"; import prisma from "../../db";
export async function getFoldersByOwner(owner: string) { export async function getFoldersByOwner(owner: string) {
const folders = await prisma.folder.findMany({ const folders = await prisma.folder.findMany({

View File

@@ -3,8 +3,8 @@
import { import {
text_pairCreateInput, text_pairCreateInput,
text_pairUpdateInput, text_pairUpdateInput,
} from "../../../generated/prisma/models"; } from "../../../../generated/prisma/models";
import prisma from "../db"; import prisma from "../../db";
export async function createTextPair(data: text_pairCreateInput) { export async function createTextPair(data: text_pairCreateInput) {
await prisma.text_pair.create({ await prisma.text_pair.create({

View File

@@ -1,6 +1,6 @@
"use server"; "use server";
import { getLLMAnswer } from "../ai"; import { getLLMAnswer } from "./ai";
export const genIPA = async (text: string) => { export const genIPA = async (text: string) => {
return ( return (

View File

@@ -0,0 +1,58 @@
import {
TranslationHistoryArraySchema,
TranslationHistorySchema,
} from "@/lib/interfaces";
import z from "zod";
import { shallowEqual } from "../utils";
export const getLocalStorageOperator = <T extends z.ZodTypeAny>(
key: string,
schema: T,
) => {
return {
get: (): z.infer<T> => {
try {
const item = globalThis.localStorage.getItem(key);
if (!item) return [];
const rawData = JSON.parse(item) as z.infer<T>;
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<T>) => {
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<typeof TranslationHistorySchema>) => {
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;
};

View File

@@ -1,5 +1,4 @@
import { ProsodyOptions } from "edge-tts-universal"; import { ProsodyOptions, EdgeTTS } from "edge-tts-universal/browser";
import { EdgeTTS } from "edge-tts-universal/browser";
export async function getTTSAudioUrl( export async function getTTSAudioUrl(
text: string, text: string,

View File

@@ -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<typeof TranslationHistorySchema>) => {
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;
};

View File

@@ -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 = <T extends z.ZodTypeAny>(
key: string,
schema: T,
) => {
return {
get: (): z.infer<T> => {
try {
const item = globalThis.localStorage.getItem(key);
if (!item) return [];
const rawData = JSON.parse(item) as z.infer<T>;
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<T>) => {
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 { export function isNonNegativeInteger(str: string): boolean {
return /^\d+$/.test(str); return /^\d+$/.test(str);
} }