diff --git a/package.json b/package.json index e933c3c..16cb7e7 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "learn-languages", "version": "0.1.0", "private": true, - "license": "GPL-3.0-only", + "license": "AGPL-3.0-only", "type": "module", "scripts": { "dev": "next dev --experimental-https", diff --git a/src/app/(features)/memorize/FolderSelector.tsx b/src/app/(features)/memorize/FolderSelector.tsx index 2a91d62..3ea25cd 100644 --- a/src/app/(features)/memorize/FolderSelector.tsx +++ b/src/app/(features)/memorize/FolderSelector.tsx @@ -3,11 +3,11 @@ import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import Link from "next/link"; -import { Folder } from "../../../../generated/prisma/browser"; import { Folder as Fd } from "lucide-react"; +import { TSharedFolderWithTotalPairs } from "@/shared/folder-type"; interface FolderSelectorProps { - folders: (Folder & { total: number })[]; + folders: TSharedFolderWithTotalPairs[]; } const FolderSelector: React.FC = ({ folders }) => { @@ -50,7 +50,7 @@ const FolderSelector: React.FC = ({ folders }) => { className="flex flex-row items-center p-4 gap-3 hover:cursor-pointer hover:bg-gray-50 transition-colors border-b border-gray-100 last:border-b-0" > {/* 文件夹图标 */} -
+
{/* 文件夹信息 */} diff --git a/src/app/(features)/memorize/Memorize.tsx b/src/app/(features)/memorize/Memorize.tsx index 9c7c222..9de8838 100644 --- a/src/app/(features)/memorize/Memorize.tsx +++ b/src/app/(features)/memorize/Memorize.tsx @@ -6,14 +6,14 @@ import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/bigmodel/tts"; import { useTranslations } from "next-intl"; import localFont from "next/font/local"; import { isNonNegativeInteger, SeededRandom } from "@/utils/random"; -import { Pair } from "../../../../generated/prisma/browser"; +import { TSharedPair } from "@/shared/folder-type"; const myFont = localFont({ src: "../../../../public/fonts/NotoNaskhArabic-VariableFont_wght.ttf", }); interface MemorizeProps { - textPairs: Pair[]; + textPairs: TSharedPair[]; } const Memorize: React.FC = ({ textPairs }) => { @@ -176,31 +176,28 @@ const Memorize: React.FC = ({ textPairs }) => { diff --git a/src/app/(features)/memorize/page.tsx b/src/app/(features)/memorize/page.tsx index 339dc5e..e24a201 100644 --- a/src/app/(features)/memorize/page.tsx +++ b/src/app/(features)/memorize/page.tsx @@ -1,14 +1,11 @@ import { redirect } from "next/navigation"; import { getTranslations } from "next-intl/server"; -import { - getFoldersWithTotalPairsByUserId, -} from "@/lib/server/services/folderService"; import { isNonNegativeInteger } from "@/utils/random"; import FolderSelector from "./FolderSelector"; import Memorize from "./Memorize"; -import { getPairsByFolderId } from "@/lib/server/services/pairService"; import { auth } from "@/auth"; import { headers } from "next/headers"; +import { actionGetFoldersWithTotalPairsByUserId, actionGetPairsByFolderId } from "@/modules/folder"; export default async function MemorizePage({ searchParams, @@ -27,13 +24,14 @@ export default async function MemorizePage({ if (!folder_id) { const session = await auth.api.getSession({ headers: await headers() }); - if(!session) redirect("/auth?redirect=/memorize") + if (!session) redirect("/auth?redirect=/memorize"); + return ( ); } - return ; + return ; } diff --git a/src/app/(features)/translator/AddToFolder.tsx b/src/app/(features)/translator/AddToFolder.tsx deleted file mode 100644 index ab296af..0000000 --- a/src/app/(features)/translator/AddToFolder.tsx +++ /dev/null @@ -1,84 +0,0 @@ -"use client"; - -import { LightButton } from "@/components/ui/buttons"; -import Container from "@/components/ui/Container"; -import { TranslationHistorySchema } from "@/lib/interfaces"; -import { Dispatch, useEffect, useState } from "react"; -import z from "zod"; -import { Folder } from "../../../../generated/prisma/browser"; -import { getFoldersByUserId } from "@/lib/server/services/folderService"; -import { Folder as Fd } from "lucide-react"; -import { createPair } from "@/lib/server/services/pairService"; -import { toast } from "sonner"; -import { useTranslations } from "next-intl"; -import { authClient } from "@/lib/auth-client"; - -interface AddToFolderProps { - item: z.infer; - setShow: Dispatch>; -} - -const AddToFolder: React.FC = ({ item, setShow }) => { - const { data: session } = authClient.useSession(); - const [folders, setFolders] = useState([]); - const t = useTranslations("translator.add_to_folder"); - const [loading, setLoading] = useState(true); - - useEffect(() => { - if (!session) return; - const userId = session.user.id; - getFoldersByUserId(userId) - .then(setFolders) - .then(() => setLoading(false)); - }, [session]); - - - if (!session) { - 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/FolderSelector.tsx b/src/app/(features)/translator/FolderSelector.tsx deleted file mode 100644 index 5bb696c..0000000 --- a/src/app/(features)/translator/FolderSelector.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import Container from "@/components/ui/Container"; -import { useEffect, useState } from "react"; -import { Folder } from "../../../../generated/prisma/browser"; -import { getFoldersByUserId } from "@/lib/server/services/folderService"; -import { LightButton } from "@/components/ui/buttons"; -import { Folder as Fd } from "lucide-react"; - -interface FolderSelectorProps { - setSelectedFolderId: (id: number) => void; - userId: string; - cancel: () => void; -} - -const FolderSelector: React.FC = ({ - setSelectedFolderId, - userId, - cancel, -}) => { - const [loading, setLoading] = useState(false); - const [folders, setFolders] = useState([]); - - useEffect(() => { - getFoldersByUserId(userId) - .then(setFolders) - .then(() => setLoading(false)); - }, [userId]); - - return ( -
- - {(loading &&

Loading...

) || - (folders.length > 0 && ( - <> -

Select a Folder

-
- {folders.map((folder) => ( - - ))} -
- - )) ||

No folders found

} - Cancel -
-
- ); -}; - -export default FolderSelector; diff --git a/src/app/(features)/translator/page.tsx b/src/app/(features)/translator/page.tsx index c4443b6..3dd1047 100644 --- a/src/app/(features)/translator/page.tsx +++ b/src/app/(features)/translator/page.tsx @@ -4,22 +4,12 @@ import { LightButton } from "@/components/ui/buttons"; import { IconClick } from "@/components/ui/buttons"; import IMAGES from "@/config/images"; import { useAudioPlayer } from "@/hooks/useAudioPlayer"; -import { TranslationHistorySchema } from "@/lib/interfaces"; -import { tlsoPush, tlso } from "@/lib/browser/localStorageOperators"; -import { logger } from "@/lib/logger"; -import { Plus, Trash } from "lucide-react"; import { useTranslations } from "next-intl"; import { useRef, useState } from "react"; -import z from "zod"; -import AddToFolder from "./AddToFolder"; -import { translateText } from "@/modules/translator/translator-action"; -import type { TranslateTextOutput } from "@/lib/server/services/types"; +import { translateText } from "@/modules/translator"; import { toast } from "sonner"; -import FolderSelector from "./FolderSelector"; -import { createPair } from "@/lib/server/services/pairService"; -import { shallowEqual } from "@/utils/random"; -import { authClient } from "@/lib/auth-client"; import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/bigmodel/tts"; +import { TranslateTextOutput } from "@/modules/translator"; export default function TranslatorPage() { const t = useTranslations("translator"); @@ -34,18 +24,10 @@ export default function TranslatorPage() { targetLanguage: string; } | null>(null); const { load, play } = useAudioPlayer(); - const [history, setHistory] = useState[]>(() => tlso.get()); - const [showAddToFolder, setShowAddToFolder] = useState(false); - const [addToFolderItem, setAddToFolderItem] = useState | null>(null); const lastTTS = useRef({ text: "", url: "", }); - const [autoSave, setAutoSave] = useState(false); - const [autoSaveFolderId, setAutoSaveFolderId] = useState(null); - const { data: session } = authClient.useSession(); const tts = async (text: string, locale: string) => { if (lastTTS.current.text !== text) { @@ -65,14 +47,13 @@ export default function TranslatorPage() { const url = await getTTSUrl(text, theLanguage as TTS_SUPPORTED_LANGUAGES); await load(url); + await play(); lastTTS.current.text = text; lastTTS.current.url = url; } catch (error) { toast.error("Failed to generate audio"); - logger.error("生成音频失败", error); } } - await play(); }; const translate = async () => { @@ -94,7 +75,6 @@ export default function TranslatorPage() { targetLanguage, forceRetranslate, needIpa, - userId: session?.user?.id, }); setTranslationResult(result); @@ -102,34 +82,6 @@ export default function TranslatorPage() { sourceText, targetLanguage, }); - - // 更新本地历史记录 - const historyItem = { - text1: result.sourceText, - text2: result.translatedText, - language1: result.sourceLanguage, - language2: result.targetLanguage, - }; - setHistory(tlsoPush(historyItem)); - - // 自动保存到文件夹 - if (autoSave && autoSaveFolderId) { - createPair({ - text1: result.sourceText, - text2: result.translatedText, - language1: result.sourceLanguage, - language2: result.targetLanguage, - ipa1: result.sourceIpa || undefined, - ipa2: result.targetIpa || undefined, - folderId: autoSaveFolderId, - }) - .then(() => { - toast.success(`${sourceText} 保存到文件夹 ${autoSaveFolderId} 成功`); - }) - .catch((error) => { - toast.error(`保存失败: ${error.message}`); - }); - } } catch (error) { toast.error("翻译失败,请重试"); console.error("翻译错误:", error); @@ -261,84 +213,6 @@ export default function TranslatorPage() { {t("translate")}
- - {/* AutoSave Component */} -
- -
- - {history.length > 0 && ( -
-

{t("history")}

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

{item.text1}

-

{item.text2}

-
-
- - -
-
- ))} -
- {showAddToFolder && ( - - )} - {autoSave && !autoSaveFolderId && ( - setAutoSave(false)} - setSelectedFolderId={(id) => setAutoSaveFolderId(id)} - /> - )} -
- )} ); } diff --git a/src/modules/translator/index.ts b/src/modules/translator/index.ts new file mode 100644 index 0000000..f3e4f38 --- /dev/null +++ b/src/modules/translator/index.ts @@ -0,0 +1,2 @@ +export * from './translator-action'; +export * from './translator-action-dto'; \ No newline at end of file diff --git a/src/modules/translator/translator-action-dto.ts b/src/modules/translator/translator-action-dto.ts index e69de29..8184407 100644 --- a/src/modules/translator/translator-action-dto.ts +++ b/src/modules/translator/translator-action-dto.ts @@ -0,0 +1,40 @@ + +export interface CreateTranslationHistoryInput { + userId?: string; + sourceText: string; + sourceLanguage: string; + targetLanguage: string; + translatedText: string; + sourceIpa?: string; + targetIpa?: string; +} + +export interface TranslationHistoryQuery { + sourceText: string; + targetLanguage: string; +} + +export interface TranslateTextInput { + sourceText: string; + targetLanguage: string; + forceRetranslate?: boolean; // 默认 false + needIpa?: boolean; // 默认 true + userId?: string; // 可选用户 ID +} + +export interface TranslateTextOutput { + sourceText: string; + translatedText: string; + sourceLanguage: string; + targetLanguage: string; + sourceIpa: string; // 如果 needIpa=false,返回空字符串 + targetIpa: string; // 如果 needIpa=false,返回空字符串 +} + +export interface TranslationLLMResponse { + translatedText: string; + sourceLanguage: string; + targetLanguage: string; + sourceIpa?: string; // 可选,根据 needIpa 决定 + targetIpa?: string; // 可选,根据 needIpa 决定 +} diff --git a/src/modules/translator/translator-action.ts b/src/modules/translator/translator-action.ts index fbf686b..908fe79 100644 --- a/src/modules/translator/translator-action.ts +++ b/src/modules/translator/translator-action.ts @@ -1,253 +1 @@ "use server"; - -import { getAnswer } from "@/lib/bigmodel/zhipu"; -import { selectLatestTranslation, createTranslationHistory } from "./translator-service"; -import { TranslateTextInput, TranslateTextOutput, TranslationLLMResponse } from "./translator-dto"; - -/** - * @deprecated 请使用 translateText 函数代替 - * 保留此函数以支持旧代码(text-speaker 功能) - */ -export const genIPA = async (text: string) => { - return ( - "[" + - ( - await getAnswer( - ` -${text} - -请生成以上文本的严式国际音标 -然后直接发给我 -不要附带任何说明 -不要擅自增减符号 -不许用"/"或者"[]"包裹 -`.trim(), - ) - ) - .replaceAll("[", "") - .replaceAll("]", "") + - "]" - ); -}; - -/** - * @deprecated 请使用 translateText 函数代替 - * 保留此函数以支持旧代码(text-speaker 功能) - */ -export const genLocale = async (text: string) => { - return await getAnswer( - ` -${text} - -推断以上文本的地区(locale) -然后直接发给我 -形如如zh-CN -不要附带任何说明 -不要擅自增减符号 -`.trim(), - ); -}; - -/** - * @deprecated 请使用 translateText 函数代替 - * 保留此函数以支持旧代码(text-speaker 功能) - */ -export const genLanguage = async (text: string) => { - const language = await getAnswer([ - { - role: "system", - content: ` -你是一个语言检测工具。请识别文本的语言并返回语言名称。 - -返回语言的标准英文名称,例如: -- 中文: Chinese -- 英语: English -- 日语: Japanese -- 韩语: Korean -- 法语: French -- 德语: German -- 意大利语: Italian -- 葡萄牙语: Portuguese -- 西班牙语: Spanish -- 俄语: Russian -- 阿拉伯语: Arabic -- 印地语: Hindi -- 泰语: Thai -- 越南语: Vietnamese -- 等等... - -如果无法识别语言,返回 "Unknown" - -规则: -1. 只返回语言的标准英文名称 -2. 首字母大写,其余小写 -3. 不要附带任何说明 -4. 不要擅自增减符号 - `.trim() - }, - { - role: "user", - content: `${text}` - } - ]); - return language.trim(); -}; - -/** - * @deprecated 请使用 translateText 函数代替 - * 保留此函数以支持旧代码(text-speaker 功能) - */ -export const genTranslation = async (text: string, targetLanguage: string) => { - - return await getAnswer( - ` -${text} - -请将以上文本翻译到 ${targetLanguage} -然后直接发给我 -不要附带任何说明 -不要擅自增减符号 -`.trim(), - ); -}; - -/** - * 统一的翻译函数 - * 一次调用生成所有信息,支持缓存查询 - */ -export async function translateText(options: TranslateTextInput): Promise { - const { - sourceText, - targetLanguage, - forceRetranslate = false, - needIpa = true, - userId, - } = options; - - // 1. 检查缓存(如果未强制重新翻译)并获取翻译数据 - let translatedData: TranslationLLMResponse | null = null; - let fromCache = false; - - if (!forceRetranslate) { - const cached = await selectLatestTranslation({ - sourceText, - targetLanguage, - }); - - if (cached && cached.translatedText && cached.sourceLanguage) { - // 如果不需要 IPA,或缓存已有 IPA,使用缓存 - if (!needIpa || (cached.sourceIpa && cached.targetIpa)) { - console.log("✅ 翻译缓存命中"); - translatedData = { - translatedText: cached.translatedText, - sourceLanguage: cached.sourceLanguage, - targetLanguage: cached.targetLanguage, - sourceIpa: cached.sourceIpa || undefined, - targetIpa: cached.targetIpa || undefined, - }; - fromCache = true; - } - } - } - - // 2. 如果缓存未命中,调用 LLM 生成翻译 - if (!fromCache) { - translatedData = await callTranslationLLM({ - sourceText, - targetLanguage, - needIpa, - }); - } - - // 3. 保存到数据库(不管缓存是否命中都保存) - if (translatedData) { - try { - await createTranslationHistory({ - userId, - sourceText, - sourceLanguage: translatedData.sourceLanguage, - targetLanguage: translatedData.targetLanguage, - translatedText: translatedData.translatedText, - sourceIpa: needIpa ? translatedData.sourceIpa : undefined, - targetIpa: needIpa ? translatedData.targetIpa : undefined, - }); - } catch (error) { - console.error("保存翻译历史失败:", error); - } - } - return { - sourceText, - translatedText: translatedData!.translatedText, - sourceLanguage: translatedData!.sourceLanguage, - targetLanguage: translatedData!.targetLanguage, - sourceIpa: needIpa ? (translatedData!.sourceIpa || "") : "", - targetIpa: needIpa ? (translatedData!.targetIpa || "") : "", - }; -} - -/** - * 调用 LLM 生成翻译和相关数据 - */ -async function callTranslationLLM(params: { - sourceText: string; - targetLanguage: string; - needIpa: boolean; -}): Promise { - const { sourceText, targetLanguage, needIpa } = params; - - console.log("🤖 调用 LLM 翻译"); - - let systemPrompt = "你是一个专业的翻译助手。请根据用户的要求翻译文本,并返回 JSON 格式的结果。\n\n返回的 JSON 必须严格符合以下格式:\n{\n \"translatedText\": \"翻译后的文本\",\n \"sourceLanguage\": \"源语言的标准英文名称(如 Chinese, English, Japanese)\",\n \"targetLanguage\": \"目标语言的标准英文名称\""; - - if (needIpa) { - systemPrompt += ",\n \"sourceIpa\": \"源文本的严式国际音标(用方括号包裹,如 [tɕɪn˥˩]\",\n \"targetIpa\": \"译文的严式国际音标(用方括号包裹)\""; - } - - systemPrompt += "}\n\n规则:\n1. 只返回 JSON,不要包含任何其他文字说明\n2. 语言名称必须是标准英文名称,首字母大写\n"; - - if (needIpa) { - systemPrompt += "3. 国际音标必须用方括号 [] 包裹,使用严式音标\n"; - } else { - systemPrompt += "3. 本次请求不需要生成国际音标\n"; - } - - systemPrompt += needIpa ? "4. 确保翻译准确、自然" : "4. 确保翻译准确、自然"; - - const userPrompt = `请将以下文本翻译成 ${targetLanguage}:\n\n${sourceText}\n\n返回 JSON 格式的翻译结果。`; - - const response = await getAnswer([ - { - role: "system", - content: systemPrompt, - }, - { - role: "user", - content: userPrompt, - }, - ]); - - // 解析 LLM 返回的 JSON - try { - // 清理响应:移除 markdown 代码块标记和多余空白 - let cleanedResponse = response - .replace(/```json\s*\n/g, "") // 移除 ```json 开头 - .replace(/```\s*\n/g, "") // 移除 ``` 结尾 - .replace(/```\s*$/g, "") // 移除末尾的 ``` - .replace(/```json\s*$/g, "") // 移除末尾的 ```json - .trim(); - - const parsed = JSON.parse(cleanedResponse) as TranslationLLMResponse; - - // 验证必需字段 - if (!parsed.translatedText || !parsed.sourceLanguage || !parsed.targetLanguage) { - throw new Error("LLM 返回的数据缺少必需字段"); - } - - console.log("LLM 翻译成功"); - return parsed; - } catch (error) { - console.error("LLM 翻译失败:", error); - console.error("原始响应:", response); - throw new Error("翻译失败:无法解析 LLM 响应"); - } -} diff --git a/src/modules/translator/translator-dto.ts b/src/modules/translator/translator-dto.ts deleted file mode 100644 index 0df3689..0000000 --- a/src/modules/translator/translator-dto.ts +++ /dev/null @@ -1,39 +0,0 @@ -export interface CreateTranslationHistoryInput { - userId?: string; - sourceText: string; - sourceLanguage: string; - targetLanguage: string; - translatedText: string; - sourceIpa?: string; - targetIpa?: string; -} - -export interface TranslationHistoryQuery { - sourceText: string; - targetLanguage: string; -} - -export interface TranslateTextInput { - sourceText: string; - targetLanguage: string; - forceRetranslate?: boolean; // 默认 false - needIpa?: boolean; // 默认 true - userId?: string; // 可选用户 ID -} - -export interface TranslateTextOutput { - sourceText: string; - translatedText: string; - sourceLanguage: string; - targetLanguage: string; - sourceIpa: string; // 如果 needIpa=false,返回空字符串 - targetIpa: string; // 如果 needIpa=false,返回空字符串 -} - -export interface TranslationLLMResponse { - translatedText: string; - sourceLanguage: string; - targetLanguage: string; - sourceIpa?: string; // 可选,根据 needIpa 决定 - targetIpa?: string; // 可选,根据 needIpa 决定 -} diff --git a/src/modules/translator/translator-repository-dto.ts b/src/modules/translator/translator-repository-dto.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/translator/translator-repository.ts b/src/modules/translator/translator-repository.ts index e69de29..5d12bd8 100644 --- a/src/modules/translator/translator-repository.ts +++ b/src/modules/translator/translator-repository.ts @@ -0,0 +1,31 @@ +"use server"; + +import { CreateTranslationHistoryInput, TranslationHistoryQuery } from "./translator-action-dto"; +import prisma from "@/lib/db"; + +/** + * 创建翻译历史记录 + */ +export async function repoCreateTranslationHistory(data: CreateTranslationHistoryInput) { + return prisma.translationHistory.create({ + data: data, + }); +} + +/** + * 查询最新的翻译记录 + * @param sourceText 源文本 + * @param targetLanguage 目标语言 + * @returns 最新的翻译记录,如果不存在则返回 null + */ +export async function repoSelectLatestTranslation(query: TranslationHistoryQuery) { + return prisma.translationHistory.findFirst({ + where: { + sourceText: query.sourceText, + targetLanguage: query.targetLanguage, + }, + orderBy: { + createdAt: 'desc', + }, + }); +} diff --git a/src/modules/translator/translator-service.ts b/src/modules/translator/translator-service.ts index 679d757..e69de29 100644 --- a/src/modules/translator/translator-service.ts +++ b/src/modules/translator/translator-service.ts @@ -1,31 +0,0 @@ -"use server"; - -import { CreateTranslationHistoryInput, TranslationHistoryQuery } from "./translator-dto"; -import prisma from "@/lib/db"; - -/** - * 创建翻译历史记录 - */ -export async function createTranslationHistory(data: CreateTranslationHistoryInput) { - return prisma.translationHistory.create({ - data: data, - }); -} - -/** - * 查询最新的翻译记录 - * @param sourceText 源文本 - * @param targetLanguage 目标语言 - * @returns 最新的翻译记录,如果不存在则返回 null - */ -export async function selectLatestTranslation(query: TranslationHistoryQuery) { - return prisma.translationHistory.findFirst({ - where: { - sourceText: query.sourceText, - targetLanguage: query.targetLanguage, - }, - orderBy: { - createdAt: 'desc', - }, - }); -}