From 3bc804c5e835e2cf97a90f795ca3c8820f030ab4 Mon Sep 17 00:00:00 2001 From: goddonebianu Date: Mon, 5 Jan 2026 14:31:18 +0800 Subject: [PATCH] ... --- CLAUDE.md | 122 +++++++ src/app/(features)/dictionary/page.tsx | 321 ++++++++++++++++++ src/app/(features)/text-speaker/page.tsx | 2 +- src/app/(features)/translator/page.tsx | 2 +- src/app/page.tsx | 6 + src/lib/server/ai.ts | 62 ---- src/lib/server/bigmodel/dictionaryActions.ts | 100 ++++++ .../{ => bigmodel}/translatorActions.ts | 8 +- src/lib/server/bigmodel/zhipu.ts | 45 +++ src/lib/utils.ts | 26 ++ 10 files changed, 626 insertions(+), 68 deletions(-) create mode 100644 CLAUDE.md create mode 100644 src/app/(features)/dictionary/page.tsx delete mode 100644 src/lib/server/ai.ts create mode 100644 src/lib/server/bigmodel/dictionaryActions.ts rename src/lib/server/{ => bigmodel}/translatorActions.ts (87%) create mode 100644 src/lib/server/bigmodel/zhipu.ts diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..12d4a58 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,122 @@ +# CLAUDE.md + +本文件为 Claude Code (claude.ai/code) 在此代码库中工作时提供指导。 + +## 项目概述 + +这是一个基于 Next.js 16 构建的全栈语言学习平台,提供翻译工具、文本转语音、字幕播放、字母学习和记忆功能。平台支持 8 种语言,具有完整的国际化支持。 + +## 开发命令 + +```bash +# 启动开发服务器(启用 HTTPS) +pnpm run dev + +# 构建生产版本(standalone 输出模式,用于 Docker) +pnpm run build + +# 启动生产服务器 +pnpm run start + +# 代码检查 +pnpm run lint + +# 数据库操作 +pnpm prisma generate # 生成 Prisma client 到 src/generated/prisma +pnpm prisma db push # 推送 schema 变更到数据库 +pnpm prisma studio # 打开 Prisma Studio 查看数据库 +``` + +## 技术栈 + +- **Next.js 16** 使用 App Router 和 standalone 输出模式 +- **React 19** 启用 React Compiler 进行优化 +- **TypeScript** 严格模式和 ES2023 目标 +- **Tailwind CSS v4** 样式框架 +- **PostgreSQL** + **Prisma ORM**(自定义输出目录:`src/generated/prisma`) +- **better-auth** 身份验证(邮箱/密码 + OAuth) +- **next-intl** 国际化(支持:en-US, zh-CN, ja-JP, ko-KR, de-DE, fr-FR, it-IT, ug-CN) +- **edge-tts-universal** 文本转语音 +- **pnpm** 包管理器 + +## 架构设计 + +### 路由结构 + +应用使用 Next.js App Router 和基于功能的组织方式: + +``` +src/app/ +├── (features)/ # 功能模块(translator, alphabet, memorize, dictionary, srt-player) +│ └── [locale]/ # 国际化路由 +├── auth/ # 认证页面(sign-in, sign-up) +├── folders/ # 用户学习文件夹管理 +├── api/ # API 路由 +└── profile/ # 用户资料页面 +``` + +### 数据库 Schema + +核心模型(见 [prisma/schema.prisma](prisma/schema.prisma)): +- **User**: 用户中心实体,包含认证信息 +- **Folder**: 用户拥有的学习资料容器(级联删除 pairs) +- **Pair**: 语言对(翻译/词汇),支持 IPA,唯一约束为 (folderId, locale1, locale2, text1) +- **Session/Account**: better-auth 追踪 +- **Verification**: 邮箱验证系统 + +### 核心模式 + +**Server Actions**: 数据库变更使用 `src/lib/actions/` 中的 Server Actions,配合类型安全的 Prisma 操作。 + +**基于功能的组件**: 每个功能在 `(features)/` 下有自己的路由组,带有 locale 前缀。 + +**国际化**: 所有面向用户的内容通过 next-intl 处理。消息文件在 `messages/` 目录。locale 自动检测并在路由中前缀。 + +**认证流程**: better-auth 使用客户端适配器 (`authClient`),通过 hooks 管理会话,受保护的路由使用条件渲染。 + +**LLM 集成**: 使用智谱 AI API 进行翻译和 IPA 生成。通过环境变量 `ZHIPU_API_KEY` 和 `ZHIPU_MODEL_NAME` 配置。 + +### 环境变量 + +需要在 `.env.local` 中配置: + +```env +# LLM 集成 +ZHIPU_API_KEY=your-api-key +ZHIPU_MODEL_NAME=your-model-name + +# 认证 +BETTER_AUTH_SECRET=your-secret +BETTER_AUTH_URL=http://localhost:3000 +GITHUB_CLIENT_ID=your-client-id +GITHUB_CLIENT_SECRET=your-client-secret + +# 数据库 +DATABASE_URL=postgresql://username:password@localhost:5432/database_name +``` + +## 重要配置细节 + +- **Prisma client 输出**: 自定义目录 `src/generated/prisma`(不是默认的 `node_modules/.prisma`) +- **Standalone 输出**: 为 Docker 部署配置 +- **React Compiler**: 在 `next.config.ts` 中启用以自动优化 +- **HTTPS 开发**: 开发服务器使用 `--experimental-https` 标志 +- **图片优化**: 通过 remote patterns 允许 GitHub 头像 + +## 代码组织 + +- `src/lib/actions/`: 数据库变更的 Server Actions +- `src/lib/server/`: 服务端工具(AI 集成、认证、翻译器) +- `src/lib/browser/`: 客户端工具 +- `src/hooks/`: 自定义 React hooks(认证 hooks、会话管理) +- `src/i18n/`: 国际化配置 +- `messages/`: 各支持语言的翻译文件 +- `src/components/`: 可复用的 UI 组件(buttons, cards 等) + +## 开发注意事项 + +- 使用 pnpm,而不是 npm 或 yarn +- schema 变更后,先运行 `pnpm prisma generate` 再运行 `pnpm prisma db push` +- 应用使用 TypeScript 严格模式 - 确保类型安全 +- 所有面向用户的文本都需要国际化 +- Better-auth 处理会话管理 - 使用 authClient 适配器进行认证操作 diff --git a/src/app/(features)/dictionary/page.tsx b/src/app/(features)/dictionary/page.tsx new file mode 100644 index 0000000..cae909a --- /dev/null +++ b/src/app/(features)/dictionary/page.tsx @@ -0,0 +1,321 @@ +"use client"; + +import { useState } from "react"; +import Container from "@/components/ui/Container"; +import { LightButton } from "@/components/ui/buttons"; +import { lookUp } from "@/lib/server/bigmodel/dictionaryActions"; +import { toast } from "sonner"; + +// 主流语言列表 +const POPULAR_LANGUAGES = [ + { code: "english", name: "英语" }, + { code: "chinese", name: "中文" }, + { code: "japanese", name: "日语" }, + { code: "korean", name: "韩语" }, + { code: "french", name: "法语" }, + { code: "german", name: "德语" }, + { code: "italian", name: "意大利语" }, + { code: "spanish", name: "西班牙语" }, +]; + +type DictionaryWordEntry = { + ipa: string; + definition: string; + partOfSpeech: string; + example: string; +}; + +type DictionaryPhraseEntry = { + definition: string; + example: string; +}; + +type DictionaryErrorResponse = { + error: string; +}; + +type DictionarySuccessResponse = { + standardForm: string; + entries: (DictionaryWordEntry | DictionaryPhraseEntry)[]; +}; + +type DictionaryResponse = DictionarySuccessResponse | DictionaryErrorResponse; + +// 类型守卫:判断是否为单词条目 +function isWordEntry(entry: DictionaryWordEntry | DictionaryPhraseEntry): entry is DictionaryWordEntry { + return "ipa" in entry && "partOfSpeech" in entry; +} + +// 类型守卫:判断是否为错误响应 +function isErrorResponse(response: DictionaryResponse): response is DictionaryErrorResponse { + return "error" in response; +} + +export default function Dictionary() { + const [searchQuery, setSearchQuery] = useState(""); + const [searchResult, setSearchResult] = useState(null); + const [isSearching, setIsSearching] = useState(false); + const [hasSearched, setHasSearched] = useState(false); + const [queryLang, setQueryLang] = useState("english"); + const [definitionLang, setDefinitionLang] = useState("chinese"); + const [showLangSettings, setShowLangSettings] = useState(false); + + const handleSearch = async (e: React.FormEvent) => { + e.preventDefault(); + if (!searchQuery.trim()) return; + + setIsSearching(true); + setHasSearched(true); + setSearchResult(null); + + try { + // 使用查询语言和释义语言 + const result = await lookUp(searchQuery, queryLang, definitionLang); + + // 检查是否为错误响应 + if (isErrorResponse(result)) { + toast.error(result.error); + setSearchResult(null); + } else { + setSearchResult(result); + } + } catch (error) { + console.error("词典查询失败:", error); + toast.error("查询失败,请稍后重试"); + setSearchResult(null); + } finally { + setIsSearching(false); + } + }; + + return ( +
+ {/* 搜索区域 */} +
+ + {/* 页面标题 */} +
+

+ 词典 +

+

+ 查询单词和短语,提供详细的释义和例句 +

+
+ + {/* 搜索表单 */} +
+ ) => setSearchQuery(e.target.value)} + placeholder="输入要查询的单词或短语..." + className="flex-1 px-4 py-3 text-lg text-gray-800 focus:outline-none border-b-2 border-gray-600 bg-white/90 rounded" + /> + + {isSearching ? "查询中..." : "查询"} + +
+ + {/* 语言设置 */} +
+
+ 语言设置 + setShowLangSettings(!showLangSettings)} + className="text-sm px-4 py-2" + > + {showLangSettings ? "收起" : "展开"} + +
+ + {showLangSettings && ( +
+ {/* 查询语言 */} +
+ +
+ {POPULAR_LANGUAGES.map((lang) => ( + setQueryLang(lang.code)} + className="text-sm px-3 py-1" + > + {lang.name} + + ))} +
+ setQueryLang(e.target.value)} + placeholder="或输入其他语言..." + className="w-full px-3 py-2 text-sm text-gray-800 focus:outline-none border-b-2 border-gray-600 bg-white/90 rounded" + /> +
+ + {/* 释义语言 */} +
+ +
+ {POPULAR_LANGUAGES.map((lang) => ( + setDefinitionLang(lang.code)} + className="text-sm px-3 py-1" + > + {lang.name} + + ))} +
+ setDefinitionLang(e.target.value)} + placeholder="或输入其他语言..." + className="w-full px-3 py-2 text-sm text-gray-800 focus:outline-none border-b-2 border-gray-600 bg-white/90 rounded" + /> +
+ + {/* 当前设置显示 */} +
+ 当前设置:查询 {POPULAR_LANGUAGES.find(l => l.code === queryLang)?.name || queryLang} + ,释义 {POPULAR_LANGUAGES.find(l => l.code === definitionLang)?.name || definitionLang} +
+
+ )} +
+ + {/* 搜索提示 */} +
+

试试搜索:hello, look up, dictionary

+
+
+
+ + {/* 搜索结果区域 */} +
+ + {isSearching && ( +
+
+

加载中...

+
+ )} + + {!isSearching && hasSearched && !searchResult && ( +
+

未找到结果

+

尝试其他单词或短语

+
+ )} + + {!isSearching && searchResult && !isErrorResponse(searchResult) && ( +
+
+ {/* 标准形式标题 */} +
+

+ {searchResult.standardForm} +

+ {searchResult.standardForm !== searchQuery && ( +

+ 原始输入: {searchQuery} +

+ )} +
+ + {/* 条目列表 */} +
+ {searchResult.entries.map((entry, index) => ( +
+ {isWordEntry(entry) ? ( + // 单词条目 +
+ {/* 音标和词性 */} +
+ {entry.ipa && ( + + {entry.ipa} + + )} + {entry.partOfSpeech && ( + + {entry.partOfSpeech} + + )} +
+ + {/* 释义 */} +
+

+ 释义 +

+

{entry.definition}

+
+ + {/* 例句 */} + {entry.example && ( +
+

+ 例句 +

+

+ {entry.example} +

+
+ )} +
+ ) : ( + // 短语条目 +
+ {/* 释义 */} +
+

+ 释义 +

+

{entry.definition}

+
+ + {/* 例句 */} + {entry.example && ( +
+

+ 例句 +

+

+ {entry.example} +

+
+ )} +
+ )} +
+ ))} +
+
+
+ )} + + {!hasSearched && ( +
+
📚
+

欢迎使用词典

+

在上方搜索框中输入单词或短语开始查询

+
+ )} +
+
+
+ ); +} diff --git a/src/app/(features)/text-speaker/page.tsx b/src/app/(features)/text-speaker/page.tsx index 5a6dc46..9697c5a 100644 --- a/src/app/(features)/text-speaker/page.tsx +++ b/src/app/(features)/text-speaker/page.tsx @@ -16,7 +16,7 @@ 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/server/translatorActions"; +import { genIPA, genLocale } from "@/lib/server/bigmodel/translatorActions"; import { logger } from "@/lib/logger"; import PageLayout from "@/components/ui/PageLayout"; diff --git a/src/app/(features)/translator/page.tsx b/src/app/(features)/translator/page.tsx index 8eaad59..4e1b0f8 100644 --- a/src/app/(features)/translator/page.tsx +++ b/src/app/(features)/translator/page.tsx @@ -18,7 +18,7 @@ import { genIPA, genLocale, genTranslation, -} from "@/lib/server/translatorActions"; +} from "@/lib/server/bigmodel/translatorActions"; import { toast } from "sonner"; import FolderSelector from "./FolderSelector"; import { createPair } from "@/lib/server/services/pairService"; diff --git a/src/app/page.tsx b/src/app/page.tsx index 3012b8c..fb63b54 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -60,6 +60,12 @@ export default async function HomePage() { description={t("srtPlayer.description")} color="#3c988d" > + typeof searchParams.get(arg) !== "string")) { - return Response.json({ - status: "error", - message: "Missing required parameters", - }); - } - return Response.json({ - status: "success", - message: await getLLMAnswer( - format(prompt, ...args.map((v) => searchParams.get(v))), - ), - }); -} diff --git a/src/lib/server/bigmodel/dictionaryActions.ts b/src/lib/server/bigmodel/dictionaryActions.ts new file mode 100644 index 0000000..a92433b --- /dev/null +++ b/src/lib/server/bigmodel/dictionaryActions.ts @@ -0,0 +1,100 @@ +"use server"; + +import { parseAIGeneratedJSON } from "@/lib/utils"; +import { getAnswer } from "./zhipu"; + +type DictionaryWordEntry = { + ipa: string; + definition: string; + partOfSpeech: string; + example: string; +}; + +type DictionaryPhraseEntry = { + definition: string; + example: string; +}; + +type DictionaryErrorResponse = { + error: string; +}; + +type DictionarySuccessResponse = { + standardForm: string; + entries: (DictionaryWordEntry | DictionaryPhraseEntry)[]; +}; + +export const lookUp = async ( + text: string, + queryLang: string, + definitionLang: string +): Promise => { + const response = await getAnswer([ + { + role: "system", + content: ` +你是一个词典工具,返回单词/短语的JSON解释。 + +查询语言:${queryLang} +释义语言:${definitionLang} + +用户输入在标签内。判断是单词还是短语。 + +如果输入有效,返回JSON对象,格式为: +{ + "standardForm": "字符串,该语言下的正确形式", + "entries": [数组,包含一个或多个条目] +} + +如果是单词,条目格式: +{ + "ipa": "音标(如适用)", + "definition": "释义", + "partOfSpeech": "词性", + "example": "例句" +} + +如果是短语,条目格式: +{ + "definition": "短语释义", + "example": "例句" +} + +所有释义内容使用${definitionLang}语言。 +例句使用${queryLang}语言。 + +如果输入无效(如:输入为空、包含非法字符、无法识别的语言等),返回JSON对象: +{ + "error": "错误描述信息,使用${definitionLang}语言" +} + +提供standardForm时:尝试修正笔误或返回原形(如英语动词原形、日语基本形等)。若无法确定或输入正确,则与输入相同。 + +示例: +英语输入"ran" -> standardForm: "run" +中文输入"跑眬" -> standardForm: "跑" +日语输入"走った" -> standardForm: "走る" + +短语同理,尝试返回其标准/常见形式。 + +现在处理用户输入。 + `.trim() + }, { + role: "user", + content: `${text}请处理text标签内的内容后返回给我json` + } + ]); + + const result = parseAIGeneratedJSON< + DictionaryErrorResponse | + { + standardForm: string, + entries: DictionaryPhraseEntry[]; + } | + { + standardForm: string, + entries: DictionaryWordEntry[]; + }>(response); + + return result; +}; diff --git a/src/lib/server/translatorActions.ts b/src/lib/server/bigmodel/translatorActions.ts similarity index 87% rename from src/lib/server/translatorActions.ts rename to src/lib/server/bigmodel/translatorActions.ts index 4b4714e..d57f1e7 100644 --- a/src/lib/server/translatorActions.ts +++ b/src/lib/server/bigmodel/translatorActions.ts @@ -1,12 +1,12 @@ "use server"; -import { getLLMAnswer } from "./ai"; +import { getAnswer } from "./zhipu"; export const genIPA = async (text: string) => { return ( "[" + ( - await getLLMAnswer( + await getAnswer( ` ${text} @@ -25,7 +25,7 @@ export const genIPA = async (text: string) => { }; export const genLocale = async (text: string) => { - return await getLLMAnswer( + return await getAnswer( ` ${text} @@ -39,7 +39,7 @@ export const genLocale = async (text: string) => { }; export const genTranslation = async (text: string, targetLanguage: string) => { - return await getLLMAnswer( + return await getAnswer( ` ${text} diff --git a/src/lib/server/bigmodel/zhipu.ts b/src/lib/server/bigmodel/zhipu.ts new file mode 100644 index 0000000..e2eb659 --- /dev/null +++ b/src/lib/server/bigmodel/zhipu.ts @@ -0,0 +1,45 @@ +"use server"; + +type Messages = { role: string; content: string; }[]; + +async function callZhipuAPI( + messages: Messages, + model = process.env.ZHIPU_MODEL_NAME, +) { + const url = "https://open.bigmodel.cn/api/paas/v4/chat/completions"; + + const response = await fetch(url, { + method: "POST", + headers: { + Authorization: "Bearer " + process.env.ZHIPU_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} ${response.statusText}`); + } + + return await response.json(); +} + +async function getAnswer(prompt: string): Promise; +async function getAnswer(prompt: Messages): Promise; +async function getAnswer(prompt: string | Messages): Promise { + const messages = typeof prompt === "string" + ? [{ role: "user", content: prompt }] + : prompt; + + const response = await callZhipuAPI(messages); + return response.choices[0].message.content.trim() as string; +} + +export { getAnswer }; \ No newline at end of file diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 0458181..a3390dd 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -147,3 +147,29 @@ export class SeededRandom { return shuffled; } } + +export function parseAIGeneratedJSON(aiResponse: string): T { + // 匹配 ```json ... ``` 包裹的内容 + const jsonMatch = aiResponse.match(/```json\s*([\s\S]*?)\s*```/); + + if (jsonMatch && jsonMatch[1]) { + try { + return JSON.parse(jsonMatch[1].trim()); + } catch (error) { + if (error instanceof Error) { + throw new Error(`Failed to parse JSON: ${error.message}`); + } else if (typeof error === 'string') { + throw new Error(`Failed to parse JSON: ${error}`); + } else { + throw new Error('Failed to parse JSON: Unknown error'); + } + } + } + + // 如果没有找到json代码块,尝试直接解析整个字符串 + try { + return JSON.parse(aiResponse.trim()); + } catch (error) { + throw new Error('No valid JSON found in the response'); + } +} \ No newline at end of file