before refractor

This commit is contained in:
2026-01-05 16:55:34 +08:00
parent 3bc804c5e8
commit bd7eca1bd0
14 changed files with 1062 additions and 396 deletions

View File

@@ -0,0 +1,138 @@
/*
Warnings:
- You are about to drop the column `ipa1` on the `pairs` table. All the data in the column will be lost.
- You are about to drop the column `ipa2` on the `pairs` table. All the data in the column will be lost.
*/
-- AlterTable
-- 重命名并修改类型为 TEXT
ALTER TABLE "pairs"
RENAME COLUMN "locale1" TO "language1";
ALTER TABLE "pairs"
ALTER COLUMN "language1" SET DATA TYPE VARCHAR(20);
ALTER TABLE "pairs"
RENAME COLUMN "locale2" TO "language2";
ALTER TABLE "pairs"
ALTER COLUMN "language2" SET DATA TYPE VARCHAR(20);
-- CreateTable
CREATE TABLE "dictionary_lookups" (
"id" SERIAL NOT NULL,
"user_id" TEXT,
"text" TEXT NOT NULL,
"query_lang" TEXT NOT NULL,
"definition_lang" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"dictionary_word_id" INTEGER,
"dictionary_phrase_id" INTEGER,
CONSTRAINT "dictionary_lookups_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "dictionary_words" (
"id" SERIAL NOT NULL,
"standard_form" TEXT NOT NULL,
"query_lang" TEXT NOT NULL,
"definition_lang" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "dictionary_words_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "dictionary_phrases" (
"id" SERIAL NOT NULL,
"standard_form" TEXT NOT NULL,
"query_lang" TEXT NOT NULL,
"definition_lang" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "dictionary_phrases_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "dictionary_word_entries" (
"id" SERIAL NOT NULL,
"word_id" INTEGER NOT NULL,
"ipa" TEXT NOT NULL,
"definition" TEXT NOT NULL,
"part_of_speech" TEXT NOT NULL,
"example" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "dictionary_word_entries_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "dictionary_phrase_entries" (
"id" SERIAL NOT NULL,
"phrase_id" INTEGER NOT NULL,
"definition" TEXT NOT NULL,
"example" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "dictionary_phrase_entries_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "dictionary_lookups_user_id_idx" ON "dictionary_lookups"("user_id");
-- CreateIndex
CREATE INDEX "dictionary_lookups_created_at_idx" ON "dictionary_lookups"("created_at");
-- CreateIndex
CREATE INDEX "dictionary_lookups_text_query_lang_definition_lang_idx" ON "dictionary_lookups"("text", "query_lang", "definition_lang");
-- CreateIndex
CREATE INDEX "dictionary_words_standard_form_idx" ON "dictionary_words"("standard_form");
-- CreateIndex
CREATE INDEX "dictionary_words_query_lang_definition_lang_idx" ON "dictionary_words"("query_lang", "definition_lang");
-- CreateIndex
CREATE UNIQUE INDEX "dictionary_words_standard_form_query_lang_definition_lang_key" ON "dictionary_words"("standard_form", "query_lang", "definition_lang");
-- CreateIndex
CREATE INDEX "dictionary_phrases_standard_form_idx" ON "dictionary_phrases"("standard_form");
-- CreateIndex
CREATE INDEX "dictionary_phrases_query_lang_definition_lang_idx" ON "dictionary_phrases"("query_lang", "definition_lang");
-- CreateIndex
CREATE UNIQUE INDEX "dictionary_phrases_standard_form_query_lang_definition_lang_key" ON "dictionary_phrases"("standard_form", "query_lang", "definition_lang");
-- CreateIndex
CREATE INDEX "dictionary_word_entries_word_id_idx" ON "dictionary_word_entries"("word_id");
-- CreateIndex
CREATE INDEX "dictionary_word_entries_created_at_idx" ON "dictionary_word_entries"("created_at");
-- CreateIndex
CREATE INDEX "dictionary_phrase_entries_phrase_id_idx" ON "dictionary_phrase_entries"("phrase_id");
-- CreateIndex
CREATE INDEX "dictionary_phrase_entries_created_at_idx" ON "dictionary_phrase_entries"("created_at");
-- AddForeignKey
ALTER TABLE "dictionary_lookups" ADD CONSTRAINT "dictionary_lookups_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "dictionary_lookups" ADD CONSTRAINT "dictionary_lookups_dictionary_word_id_fkey" FOREIGN KEY ("dictionary_word_id") REFERENCES "dictionary_words"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "dictionary_lookups" ADD CONSTRAINT "dictionary_lookups_dictionary_phrase_id_fkey" FOREIGN KEY ("dictionary_phrase_id") REFERENCES "dictionary_phrases"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "dictionary_word_entries" ADD CONSTRAINT "dictionary_word_entries_word_id_fkey" FOREIGN KEY ("word_id") REFERENCES "dictionary_words"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "dictionary_phrase_entries" ADD CONSTRAINT "dictionary_phrase_entries_phrase_id_fkey" FOREIGN KEY ("phrase_id") REFERENCES "dictionary_phrases"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -1,4 +1,3 @@
generator client {
provider = "prisma-client"
output = "../generated/prisma"
@@ -8,39 +7,6 @@ datasource db {
provider = "postgresql"
}
model Pair {
id Int @id @default(autoincrement())
locale1 String @db.VarChar(10)
locale2 String @db.VarChar(10)
text1 String
text2 String
ipa1 String?
ipa2 String?
folderId Int @map("folder_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
folder Folder @relation(fields: [folderId], references: [id], onDelete: Cascade)
@@unique([folderId, locale1, locale2, text1])
@@index([folderId])
@@map("pairs")
}
model Folder {
id Int @id @default(autoincrement())
name String
userId String @map("user_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
pairs Pair[]
@@index([userId])
@@map("folders")
}
model User {
id String @id
name String
@@ -52,6 +18,7 @@ model User {
sessions Session[]
accounts Account[]
folders Folder[]
dictionaryLookUps DictionaryLookUp[]
@@unique([email])
@@map("user")
@@ -104,3 +71,122 @@ model Verification {
@@index([identifier])
@@map("verification")
}
model Pair {
id Int @id @default(autoincrement())
text1 String
text2 String
language1 String @db.VarChar(20)
language2 String @db.VarChar(20)
ipa1 String?
ipa2 String?
folderId Int @map("folder_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
folder Folder @relation(fields: [folderId], references: [id], onDelete: Cascade)
@@unique([folderId, language1, language2, text1])
@@index([folderId])
@@map("pairs")
}
model Folder {
id Int @id @default(autoincrement())
name String
userId String @map("user_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
pairs Pair[]
@@index([userId])
@@map("folders")
}
model DictionaryLookUp {
id Int @id @default(autoincrement())
userId String? @map("user_id")
text String
queryLang String @map("query_lang")
definitionLang String @map("definition_lang")
createdAt DateTime @default(now()) @map("created_at")
dictionaryWordId Int? @map("dictionary_word_id")
dictionaryPhraseId Int? @map("dictionary_phrase_id")
user User? @relation(fields: [userId], references: [id])
dictionaryWord DictionaryWord? @relation(fields: [dictionaryWordId], references: [id], onDelete: SetNull)
dictionaryPhrase DictionaryPhrase? @relation(fields: [dictionaryPhraseId], references: [id], onDelete: SetNull)
@@index([userId])
@@index([createdAt])
@@index([text, queryLang, definitionLang])
@@map("dictionary_lookups")
}
model DictionaryWord {
id Int @id @default(autoincrement())
standardForm String @map("standard_form")
queryLang String @map("query_lang")
definitionLang String @map("definition_lang")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
lookups DictionaryLookUp[]
entries DictionaryWordEntry[]
@@unique([standardForm, queryLang, definitionLang])
@@index([standardForm])
@@index([queryLang, definitionLang])
@@map("dictionary_words")
}
model DictionaryPhrase {
id Int @id @default(autoincrement())
standardForm String @map("standard_form")
queryLang String @map("query_lang")
definitionLang String @map("definition_lang")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
lookups DictionaryLookUp[]
entries DictionaryPhraseEntry[]
@@unique([standardForm, queryLang, definitionLang])
@@index([standardForm])
@@index([queryLang, definitionLang])
@@map("dictionary_phrases")
}
model DictionaryWordEntry {
id Int @id @default(autoincrement())
wordId Int @map("word_id")
ipa String
definition String
partOfSpeech String @map("part_of_speech")
example String
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
word DictionaryWord @relation(fields: [wordId], references: [id], onDelete: Cascade)
@@index([wordId])
@@index([createdAt])
@@map("dictionary_word_entries")
}
model DictionaryPhraseEntry {
id Int @id @default(autoincrement())
phraseId Int @map("phrase_id")
definition String
example String
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
phrase DictionaryPhrase @relation(fields: [phraseId], references: [id], onDelete: Cascade)
@@index([phraseId])
@@index([createdAt])
@@map("dictionary_phrase_entries")
}

View File

@@ -0,0 +1,96 @@
"use client";
import { LightButton } from "@/components/ui/buttons";
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 { Folder as Fd } from "lucide-react";
import { createPair } from "@/lib/server/services/pairService";
import { toast } from "sonner";
import { authClient } from "@/lib/auth-client";
interface AddToFolderProps {
definitionLang: string;
queryLang: string;
standardForm: string;
definition: string;
ipa?: string;
setShow: (show: boolean) => void;
}
const AddToFolder: React.FC<AddToFolderProps> = ({
definitionLang,
queryLang,
standardForm,
definition,
ipa,
setShow,
}) => {
const { data: session } = authClient.useSession();
const [folders, setFolders] = useState<Folder[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!session) return;
const userId = session.user.id as string;
getFoldersByUserId(userId)
.then(setFolders)
.then(() => setLoading(false));
}, [session]);
if (!session) {
return null;
}
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 className="text-xl font-bold mb-4"></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={() => {
createPair({
text1: standardForm,
text2: definition,
language1: queryLang,
language2: definitionLang,
ipa1: ipa || undefined,
folder: {
connect: {
id: folder.id,
},
},
})
.then(() => {
toast.success(`已保存到文件夹:${folder.name}`);
setShow(false);
})
.catch(() => {
toast.error("保存失败,请稍后重试");
});
}}
>
<Fd />
{folder.name}
</button>
))
) : (
<div className="p-4 text-gray-500"></div>
)}
</div>
<div className="mt-4 flex justify-end gap-2">
<LightButton onClick={() => setShow(false)}></LightButton>
</div>
</Container>
</div>
);
};
export default AddToFolder;

View File

@@ -1,10 +1,15 @@
"use client";
import { useState } from "react";
import { useState, useEffect } 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";
import { Plus } from "lucide-react";
import { authClient } from "@/lib/auth-client";
import { Folder } from "../../../../generated/prisma/browser";
import { getFoldersByUserId } from "@/lib/server/services/folderService";
import { createPair } from "@/lib/server/services/pairService";
// 主流语言列表
const POPULAR_LANGUAGES = [
@@ -59,6 +64,23 @@ export default function Dictionary() {
const [queryLang, setQueryLang] = useState("english");
const [definitionLang, setDefinitionLang] = useState("chinese");
const [showLangSettings, setShowLangSettings] = useState(false);
const [selectedFolderId, setSelectedFolderId] = useState<number | null>(null);
const [folders, setFolders] = useState<Folder[]>([]);
const { data: session } = authClient.useSession();
// 加载用户的文件夹列表
useEffect(() => {
if (session) {
getFoldersByUserId(session.user.id as string)
.then((loadedFolders) => {
setFolders(loadedFolders);
// 如果有文件夹且未选择,默认选择第一个
if (loadedFolders.length > 0 && !selectedFolderId) {
setSelectedFolderId(loadedFolders[0].id);
}
});
}
}, [session, selectedFolderId]);
const handleSearch = async (e: React.FormEvent) => {
e.preventDefault();
@@ -223,8 +245,9 @@ export default function Dictionary() {
{!isSearching && searchResult && !isErrorResponse(searchResult) && (
<div className="space-y-6">
<div className="bg-white rounded-lg p-6 shadow-lg">
{/* 标准形式标题 */}
<div className="mb-6">
{/* 标题和保存按钮 */}
<div className="flex items-start justify-between mb-6">
<div className="flex-1">
<h2 className="text-3xl font-bold text-gray-800 mb-2">
{searchResult.standardForm}
</h2>
@@ -234,6 +257,60 @@ export default function Dictionary() {
</p>
)}
</div>
<div className="flex items-center gap-2 ml-4">
{session && folders.length > 0 && (
<select
value={selectedFolderId || ""}
onChange={(e) => setSelectedFolderId(e.target.value ? Number(e.target.value) : null)}
className="px-3 py-2 text-sm border border-gray-300 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-[#35786f]"
>
{folders.map((folder) => (
<option key={folder.id} value={folder.id}>
{folder.name}
</option>
))}
</select>
)}
<button
onClick={() => {
if (!session) {
toast.error("请先登录");
return;
}
if (!selectedFolderId) {
toast.error("请先创建文件夹");
return;
}
if (!searchResult || isErrorResponse(searchResult)) return;
const entry = searchResult.entries[0];
createPair({
text1: searchResult.standardForm,
text2: entry.definition,
language1: queryLang,
language2: definitionLang,
ipa1: isWordEntry(entry) ? entry.ipa : undefined,
folder: {
connect: {
id: selectedFolderId,
},
},
})
.then(() => {
const folderName = folders.find(f => f.id === selectedFolderId)?.name;
toast.success(`已保存到文件夹:${folderName}`);
})
.catch(() => {
toast.error("保存失败,请稍后重试");
});
}}
className="hover:bg-gray-200 hover:cursor-pointer rounded-4xl border border-gray-200 w-10 h-10 flex justify-center items-center flex-shrink-0"
title="保存到文件夹"
>
<Plus />
</button>
</div>
</div>
{/* 条目列表 */}
<div className="space-y-6">

View File

@@ -15,10 +15,10 @@ 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/server/bigmodel/translatorActions";
import { genIPA, genLanguage } from "@/lib/server/bigmodel/translatorActions";
import { logger } from "@/lib/logger";
import PageLayout from "@/components/ui/PageLayout";
import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/server/bigmodel/tts";
export default function TextSpeakerPage() {
const t = useTranslations("text_speaker");
@@ -31,7 +31,7 @@ export default function TextSpeakerPage() {
const [pause, setPause] = useState(true);
const [autopause, setAutopause] = useState(true);
const textRef = useRef("");
const [locale, setLocale] = useState<string | null>(null);
const [language, setLanguage] = useState<string | null>(null);
const [ipa, setIPA] = useState<string>("");
const objurlRef = useRef<string | null>(null);
const [processing, setProcessing] = useState(false);
@@ -95,38 +95,35 @@ export default function TextSpeakerPage() {
} else {
// 第一次播放
try {
let theLocale = locale;
if (!theLocale) {
const tmp_locale = await genLocale(textRef.current.slice(0, 30));
setLocale(tmp_locale);
theLocale = tmp_locale;
let theLanguage = language;
if (!theLanguage) {
const tmp_language = await genLanguage(textRef.current.slice(0, 30));
setLanguage(tmp_language);
theLanguage = tmp_language;
}
const voice = VOICES.find((v) => v.locale.startsWith(theLocale));
if (!voice) throw "Voice not found.";
theLanguage = theLanguage.toLowerCase().replace(/[^a-z]/g, '').replace(/^./, match => match.toUpperCase());
objurlRef.current = await getTTSAudioUrl(
// 检查语言是否在 TTS 支持列表中
const supportedLanguages: TTS_SUPPORTED_LANGUAGES[] = [
"Auto", "Chinese", "English", "German", "Italian", "Portuguese",
"Spanish", "Japanese", "Korean", "French", "Russian"
];
if (!supportedLanguages.includes(theLanguage as TTS_SUPPORTED_LANGUAGES)) {
theLanguage = "Auto";
}
objurlRef.current = await getTTSUrl(
textRef.current,
voice.short_name,
(() => {
if (speed === 1) return {};
else if (speed < 1)
return {
rate: `-${100 - speed * 100}%`,
};
else
return {
rate: `+${speed * 100 - 100}%`,
};
})(),
theLanguage as TTS_SUPPORTED_LANGUAGES
);
load(objurlRef.current);
play();
} catch (e) {
logger.error("播放音频失败", e);
setPause(true);
setLocale(null);
setLanguage(null);
setProcessing(false);
}
}
@@ -142,7 +139,7 @@ export default function TextSpeakerPage() {
const handleInputChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
textRef.current = e.target.value.trim();
setLocale(null);
setLanguage(null);
setIPA("");
if (objurlRef.current) URL.revokeObjectURL(objurlRef.current);
objurlRef.current = null;
@@ -163,7 +160,7 @@ export default function TextSpeakerPage() {
const handleUseItem = (item: z.infer<typeof TextSpeakerItemSchema>) => {
if (textareaRef.current) textareaRef.current.value = item.text;
textRef.current = item.text;
setLocale(item.locale);
setLanguage(item.locale);
setIPA(item.ipa || "");
if (objurlRef.current) URL.revokeObjectURL(objurlRef.current);
objurlRef.current = null;
@@ -178,11 +175,11 @@ export default function TextSpeakerPage() {
setSaving(true);
try {
let theLocale = locale;
if (!theLocale) {
const tmp_locale = await genLocale(textRef.current.slice(0, 30));
setLocale(tmp_locale);
theLocale = tmp_locale;
let theLanguage = language;
if (!theLanguage) {
const tmp_language = await genLanguage(textRef.current.slice(0, 30));
setLanguage(tmp_language);
theLanguage = tmp_language;
}
let theIPA = ipa;
@@ -205,19 +202,19 @@ export default function TextSpeakerPage() {
} else if (theIPA.length === 0) {
save.push({
text: textRef.current,
locale: theLocale,
locale: theLanguage as string,
});
} else {
save.push({
text: textRef.current,
locale: theLocale,
locale: theLanguage as string,
ipa: theIPA,
});
}
setIntoLocalStorage(save);
} catch (e) {
logger.error("保存到本地存储失败", e);
setLocale(null);
setLanguage(null);
} finally {
setSaving(false);
}

View File

@@ -7,7 +7,6 @@ import { VOICES } from "@/config/locales";
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
import { TranslationHistorySchema } from "@/lib/interfaces";
import { tlsoPush, tlso } from "@/lib/browser/localStorageOperators";
import { getTTSAudioUrl } from "@/lib/browser/tts";
import { logger } from "@/lib/logger";
import { Plus, Trash } from "lucide-react";
import { useTranslations } from "next-intl";
@@ -24,6 +23,7 @@ import FolderSelector from "./FolderSelector";
import { createPair } from "@/lib/server/services/pairService";
import { shallowEqual } from "@/lib/utils";
import { authClient } from "@/lib/auth-client";
import { getTTSUrl } from "@/lib/server/bigmodel/tts";
export default function TranslatorPage() {
const t = useTranslations("translator");
@@ -50,13 +50,8 @@ export default function TranslatorPage() {
const tts = async (text: string, locale: string) => {
if (lastTTS.current.text !== text) {
const shortName = VOICES.find((v) => v.locale === locale)?.short_name;
if (!shortName) {
toast.error("Voice not found");
return;
}
try {
const url = await getTTSAudioUrl(text, shortName);
const url = await getTTSUrl(text, locale);
await load(url);
lastTTS.current.text = text;
lastTTS.current.url = url;

View File

@@ -5,7 +5,7 @@ import AuthForm from "./AuthForm";
export default async function AuthPage(
props: {
searchParams: Promise<{ [key: string]: string | string[] | undefined; }>
searchParams: Promise<{ [key: string]: string | string[] | undefined; }>;
}
) {
const searchParams = await props.searchParams;

View File

@@ -21,8 +21,8 @@ export interface TextPair {
id: number;
text1: string;
text2: string;
locale1: string;
locale2: string;
language1: string;
language2: string;
}
export default function InFolder({ folderId }: { folderId: number }) {
@@ -146,8 +146,8 @@ export default function InFolder({ folderId }: { folderId: number }) {
await createPair({
text1: text1,
text2: text2,
locale1: locale1,
locale2: locale2,
language1: locale1,
language2: locale2,
folder: {
connect: {
id: folderId,

View File

@@ -25,11 +25,11 @@ export default function TextPairCard({
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2 text-xs text-gray-500">
<span className="px-2 py-1 bg-gray-100 rounded-md">
{textPair.locale1.toUpperCase()}
{textPair.language1.toUpperCase()}
</span>
<span></span>
<span className="px-2 py-1 bg-gray-100 rounded-md">
{textPair.locale2.toUpperCase()}
{textPair.language2.toUpperCase()}
</span>
</div>

View File

@@ -23,8 +23,8 @@ export default function UpdateTextPairModal({
const t = useTranslations("folder_id");
const input1Ref = useRef<HTMLInputElement>(null);
const input2Ref = useRef<HTMLInputElement>(null);
const [locale1, setLocale1] = useState(textPair.locale1);
const [locale2, setLocale2] = useState(textPair.locale2);
const [locale1, setLocale1] = useState(textPair.language1);
const [locale2, setLocale2] = useState(textPair.language2);
if (!isOpen) return null;

View File

@@ -111,6 +111,9 @@ export async function signInAction(prevState: SignUpState, formData: FormData) {
redirect(redirectTo || "/");
} catch (error) {
if (error instanceof Error && error.message.includes('NEXT_REDIRECT')) {
throw error;
}
return {
success: false,
message: "登录失败,请检查您的邮箱和密码"

View File

@@ -1,15 +0,0 @@
import { ProsodyOptions, EdgeTTS } from "edge-tts-universal/browser";
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;
}
}

View File

@@ -38,6 +38,47 @@ export const genLocale = async (text: string) => {
);
};
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>${text}</text>`
}
]);
return language.trim();
};
export const genTranslation = async (text: string, targetLanguage: string) => {
return await getAnswer(
`

View File

@@ -0,0 +1,248 @@
// ==================== 类型定义 ====================
/**
* 支持的语音合成模型
*/
type TTSModel = 'qwen3-tts-flash' | string; // 主要模型为 'qwen3-tts-flash'
/**
* API 支持的语言类型(必须严格按文档使用)
*/
type SupportedLanguage =
| 'Auto' // 自动检测(混合语言场景)
| 'Chinese' // 中文
| 'English' // 英文
| 'German' // 德文
| 'Italian' | 'Portuguese' | 'Spanish'
| 'Japanese' | 'Korean' | 'French'
| 'Russian';
/**
* API 请求参数接口
*/
interface TTSRequest {
model: TTSModel;
input: {
text: string; // 要合成的文本qwen3-tts-flash最长600字符
voice: string; // 音色名称,如 'Cherry'
language_type?: SupportedLanguage; // 可选,默认为 'Auto'
};
parameters?: {
stream?: boolean; // 是否流式输出需配合特定Header
};
}
/**
* API 响应接口(通用结构)
*/
interface TTSResponse {
status_code: number; // HTTP状态码200表示成功
request_id: string; // 请求唯一标识,用于排查问题
code: string; // 错误码,成功时为 ''
message: string; // 错误信息,成功时为 ''
output: {
audio: {
data: string; // Base64编码的音频数据流式输出时有效
url: string; // 音频文件下载URL非流式输出时有效
id: string; // 音频ID
expires_at: number; // URL过期时间戳
};
text: null; // 文档注明始终为null
choices: null; // 文档注明始终为null
finish_reason: string; // 生成状态
};
usage: {
characters: number; // 计费字符数qwen3-tts-flash
input_tokens?: number;
output_tokens?: number;
};
}
// ==================== TTS 服务类 ====================
class QwenTTSService {
private baseUrl: string;
private apiKey: string;
private region: 'cn-beijing' | 'intl-singapore'; // 地域
/**
* 构造函数
* @param apiKey - DashScope API Key从环境变量获取更安全
* @param region - 服务地域,默认北京
*/
constructor(
apiKey: string,
region: 'cn-beijing' | 'intl-singapore' = 'cn-beijing'
) {
this.apiKey = apiKey;
this.region = region;
// 根据地域设置API端点文档中特别强调
this.baseUrl = region === 'cn-beijing'
? 'https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation'
: 'https://dashscope-intl.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation';
}
/**
* 验证文本长度qwen3-tts-flash模型限制600字符
*/
private validateTextLength(text: string, model: TTSModel): void {
const maxLength = model.includes('qwen3-tts-flash') ? 600 : 512;
if (text.length > maxLength) {
throw new Error(
`文本长度 ${text.length} 字符超过模型限制(最大 ${maxLength} 字符)`
);
}
}
/**
* 合成语音非流式输出返回音频URL
*/
async synthesize(
text: string,
options: {
voice?: string; // 音色,默认 'Cherry'
language?: SupportedLanguage; // 语种,默认 'Auto'
model?: TTSModel; // 模型,默认 'qwen3-tts-flash'
} = {}
): Promise<TTSResponse> {
const {
voice = 'Cherry',
language = 'Auto',
model = 'qwen3-tts-flash'
} = options;
// 1. 文本长度验证
this.validateTextLength(text, model);
// 2. 构建请求体
const requestBody: TTSRequest = {
model,
input: {
text,
voice,
language_type: language
}
// 非流式输出不需要 stream 参数
};
try {
// 3. 调用API
const response = await fetch(this.baseUrl, {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
});
const data: TTSResponse = await response.json();
// 4. 错误处理
if (data.status_code !== 200) {
throw new Error(`API错误: [${data.code}] ${data.message}`);
}
return data;
} catch (error) {
console.error('语音合成请求失败:', error);
throw error;
}
}
/**
* 流式合成语音边生成边输出Base64音频数据
*/
async synthesizeStream(
text: string,
options: {
voice?: string;
language?: SupportedLanguage;
model?: TTSModel;
onAudioChunk?: (chunk: string) => void; // 接收音频片段的回调
} = {}
): Promise<void> {
const {
voice = 'Cherry',
language = 'Auto',
model = 'qwen3-tts-flash',
onAudioChunk
} = options;
this.validateTextLength(text, model);
const requestBody: TTSRequest = {
model,
input: {
text,
voice,
language_type: language
},
parameters: {
stream: true // 启用流式输出
}
};
try {
const response = await fetch(this.baseUrl, {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json',
'X-DashScope-SSE': 'enable' // 关键:启用服务器发送事件
},
body: JSON.stringify(requestBody),
});
if (!response.ok || !response.body) {
throw new Error(`流式请求失败: ${response.status}`);
}
// 处理流式响应此处为简化示例实际需解析SSE格式
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
if (onAudioChunk && chunk.trim()) {
onAudioChunk(chunk); // 处理音频数据片段
}
}
} catch (error) {
console.error('流式合成失败:', error);
throw error;
}
}
}
export type TTS_SUPPORTED_LANGUAGES = 'Auto' | 'Chinese' | 'English' | 'German' | 'Italian' | 'Portuguese' | 'Spanish' | 'Japanese' | 'Korean' | 'French' | 'Russian';
export async function getTTSUrl(text: string, lang: TTS_SUPPORTED_LANGUAGES) {
try {
if (!process.env.DASHSCORE_API_KEY) {
console.warn(
`⚠️ 环境变量 DASHSCORE_API_KEY 未设置\n` +
` 请在 .env 文件中设置或直接传入API Key\n` +
` 获取API Key: https://help.aliyun.com/zh/model-studio/get-api-key`
);
throw "API Key设置错误";
}
const ttsService = new QwenTTSService(
process.env.DASHSCOPE_API_KEY || 'sk-xxx',
);
const result = await ttsService.synthesize(
text,
{
voice: 'Cherry',
language: lang
}
);
return result.output.audio.url;
} catch (error) {
console.error('TTS合成失败:', error instanceof Error ? error.message : error);
return "error";
}
}