before refractor
This commit is contained in:
138
prisma/migrations/20260105081337_dictionary_add/migration.sql
Normal file
138
prisma/migrations/20260105081337_dictionary_add/migration.sql
Normal 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;
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client"
|
provider = "prisma-client"
|
||||||
output = "../generated/prisma"
|
output = "../generated/prisma"
|
||||||
@@ -8,39 +7,6 @@ datasource db {
|
|||||||
provider = "postgresql"
|
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 {
|
model User {
|
||||||
id String @id
|
id String @id
|
||||||
name String
|
name String
|
||||||
@@ -52,6 +18,7 @@ model User {
|
|||||||
sessions Session[]
|
sessions Session[]
|
||||||
accounts Account[]
|
accounts Account[]
|
||||||
folders Folder[]
|
folders Folder[]
|
||||||
|
dictionaryLookUps DictionaryLookUp[]
|
||||||
|
|
||||||
@@unique([email])
|
@@unique([email])
|
||||||
@@map("user")
|
@@map("user")
|
||||||
@@ -104,3 +71,122 @@ model Verification {
|
|||||||
@@index([identifier])
|
@@index([identifier])
|
||||||
@@map("verification")
|
@@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")
|
||||||
|
}
|
||||||
|
|||||||
96
src/app/(features)/dictionary/AddToFolder.tsx
Normal file
96
src/app/(features)/dictionary/AddToFolder.tsx
Normal 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;
|
||||||
@@ -1,10 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import Container from "@/components/ui/Container";
|
import Container from "@/components/ui/Container";
|
||||||
import { LightButton } from "@/components/ui/buttons";
|
import { LightButton } from "@/components/ui/buttons";
|
||||||
import { lookUp } from "@/lib/server/bigmodel/dictionaryActions";
|
import { lookUp } from "@/lib/server/bigmodel/dictionaryActions";
|
||||||
import { toast } from "sonner";
|
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 = [
|
const POPULAR_LANGUAGES = [
|
||||||
@@ -59,6 +64,23 @@ export default function Dictionary() {
|
|||||||
const [queryLang, setQueryLang] = useState("english");
|
const [queryLang, setQueryLang] = useState("english");
|
||||||
const [definitionLang, setDefinitionLang] = useState("chinese");
|
const [definitionLang, setDefinitionLang] = useState("chinese");
|
||||||
const [showLangSettings, setShowLangSettings] = useState(false);
|
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) => {
|
const handleSearch = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -223,8 +245,9 @@ export default function Dictionary() {
|
|||||||
{!isSearching && searchResult && !isErrorResponse(searchResult) && (
|
{!isSearching && searchResult && !isErrorResponse(searchResult) && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="bg-white rounded-lg p-6 shadow-lg">
|
<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">
|
<h2 className="text-3xl font-bold text-gray-800 mb-2">
|
||||||
{searchResult.standardForm}
|
{searchResult.standardForm}
|
||||||
</h2>
|
</h2>
|
||||||
@@ -234,6 +257,60 @@ export default function Dictionary() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<div className="space-y-6">
|
||||||
|
|||||||
@@ -15,10 +15,10 @@ 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 { getLocalStorageOperator } from "@/lib/browser/localStorageOperators";
|
||||||
import { getTTSAudioUrl } from "@/lib/browser/tts";
|
import { genIPA, genLanguage } from "@/lib/server/bigmodel/translatorActions";
|
||||||
import { genIPA, genLocale } from "@/lib/server/bigmodel/translatorActions";
|
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import PageLayout from "@/components/ui/PageLayout";
|
import PageLayout from "@/components/ui/PageLayout";
|
||||||
|
import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/server/bigmodel/tts";
|
||||||
|
|
||||||
export default function TextSpeakerPage() {
|
export default function TextSpeakerPage() {
|
||||||
const t = useTranslations("text_speaker");
|
const t = useTranslations("text_speaker");
|
||||||
@@ -31,7 +31,7 @@ export default function TextSpeakerPage() {
|
|||||||
const [pause, setPause] = useState(true);
|
const [pause, setPause] = useState(true);
|
||||||
const [autopause, setAutopause] = useState(true);
|
const [autopause, setAutopause] = useState(true);
|
||||||
const textRef = useRef("");
|
const textRef = useRef("");
|
||||||
const [locale, setLocale] = useState<string | null>(null);
|
const [language, setLanguage] = useState<string | null>(null);
|
||||||
const [ipa, setIPA] = useState<string>("");
|
const [ipa, setIPA] = useState<string>("");
|
||||||
const objurlRef = useRef<string | null>(null);
|
const objurlRef = useRef<string | null>(null);
|
||||||
const [processing, setProcessing] = useState(false);
|
const [processing, setProcessing] = useState(false);
|
||||||
@@ -95,38 +95,35 @@ export default function TextSpeakerPage() {
|
|||||||
} else {
|
} else {
|
||||||
// 第一次播放
|
// 第一次播放
|
||||||
try {
|
try {
|
||||||
let theLocale = locale;
|
let theLanguage = language;
|
||||||
if (!theLocale) {
|
if (!theLanguage) {
|
||||||
const tmp_locale = await genLocale(textRef.current.slice(0, 30));
|
const tmp_language = await genLanguage(textRef.current.slice(0, 30));
|
||||||
setLocale(tmp_locale);
|
setLanguage(tmp_language);
|
||||||
theLocale = tmp_locale;
|
theLanguage = tmp_language;
|
||||||
}
|
}
|
||||||
|
|
||||||
const voice = VOICES.find((v) => v.locale.startsWith(theLocale));
|
theLanguage = theLanguage.toLowerCase().replace(/[^a-z]/g, '').replace(/^./, match => match.toUpperCase());
|
||||||
if (!voice) throw "Voice not found.";
|
|
||||||
|
|
||||||
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,
|
textRef.current,
|
||||||
voice.short_name,
|
theLanguage as TTS_SUPPORTED_LANGUAGES
|
||||||
(() => {
|
|
||||||
if (speed === 1) return {};
|
|
||||||
else if (speed < 1)
|
|
||||||
return {
|
|
||||||
rate: `-${100 - speed * 100}%`,
|
|
||||||
};
|
|
||||||
else
|
|
||||||
return {
|
|
||||||
rate: `+${speed * 100 - 100}%`,
|
|
||||||
};
|
|
||||||
})(),
|
|
||||||
);
|
);
|
||||||
load(objurlRef.current);
|
load(objurlRef.current);
|
||||||
play();
|
play();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error("播放音频失败", e);
|
logger.error("播放音频失败", e);
|
||||||
setPause(true);
|
setPause(true);
|
||||||
setLocale(null);
|
setLanguage(null);
|
||||||
|
|
||||||
setProcessing(false);
|
setProcessing(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -142,7 +139,7 @@ export default function TextSpeakerPage() {
|
|||||||
|
|
||||||
const handleInputChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
|
const handleInputChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
textRef.current = e.target.value.trim();
|
textRef.current = e.target.value.trim();
|
||||||
setLocale(null);
|
setLanguage(null);
|
||||||
setIPA("");
|
setIPA("");
|
||||||
if (objurlRef.current) URL.revokeObjectURL(objurlRef.current);
|
if (objurlRef.current) URL.revokeObjectURL(objurlRef.current);
|
||||||
objurlRef.current = null;
|
objurlRef.current = null;
|
||||||
@@ -163,7 +160,7 @@ export default function TextSpeakerPage() {
|
|||||||
const handleUseItem = (item: z.infer<typeof TextSpeakerItemSchema>) => {
|
const handleUseItem = (item: z.infer<typeof TextSpeakerItemSchema>) => {
|
||||||
if (textareaRef.current) textareaRef.current.value = item.text;
|
if (textareaRef.current) textareaRef.current.value = item.text;
|
||||||
textRef.current = item.text;
|
textRef.current = item.text;
|
||||||
setLocale(item.locale);
|
setLanguage(item.locale);
|
||||||
setIPA(item.ipa || "");
|
setIPA(item.ipa || "");
|
||||||
if (objurlRef.current) URL.revokeObjectURL(objurlRef.current);
|
if (objurlRef.current) URL.revokeObjectURL(objurlRef.current);
|
||||||
objurlRef.current = null;
|
objurlRef.current = null;
|
||||||
@@ -178,11 +175,11 @@ export default function TextSpeakerPage() {
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let theLocale = locale;
|
let theLanguage = language;
|
||||||
if (!theLocale) {
|
if (!theLanguage) {
|
||||||
const tmp_locale = await genLocale(textRef.current.slice(0, 30));
|
const tmp_language = await genLanguage(textRef.current.slice(0, 30));
|
||||||
setLocale(tmp_locale);
|
setLanguage(tmp_language);
|
||||||
theLocale = tmp_locale;
|
theLanguage = tmp_language;
|
||||||
}
|
}
|
||||||
|
|
||||||
let theIPA = ipa;
|
let theIPA = ipa;
|
||||||
@@ -205,19 +202,19 @@ export default function TextSpeakerPage() {
|
|||||||
} else if (theIPA.length === 0) {
|
} else if (theIPA.length === 0) {
|
||||||
save.push({
|
save.push({
|
||||||
text: textRef.current,
|
text: textRef.current,
|
||||||
locale: theLocale,
|
locale: theLanguage as string,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
save.push({
|
save.push({
|
||||||
text: textRef.current,
|
text: textRef.current,
|
||||||
locale: theLocale,
|
locale: theLanguage as string,
|
||||||
ipa: theIPA,
|
ipa: theIPA,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
setIntoLocalStorage(save);
|
setIntoLocalStorage(save);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error("保存到本地存储失败", e);
|
logger.error("保存到本地存储失败", e);
|
||||||
setLocale(null);
|
setLanguage(null);
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ 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/browser/localStorageOperators";
|
import { tlsoPush, tlso } from "@/lib/browser/localStorageOperators";
|
||||||
import { getTTSAudioUrl } from "@/lib/browser/tts";
|
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import { Plus, Trash } from "lucide-react";
|
import { Plus, Trash } from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
@@ -24,6 +23,7 @@ import FolderSelector from "./FolderSelector";
|
|||||||
import { createPair } from "@/lib/server/services/pairService";
|
import { createPair } from "@/lib/server/services/pairService";
|
||||||
import { shallowEqual } from "@/lib/utils";
|
import { shallowEqual } from "@/lib/utils";
|
||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
import { getTTSUrl } from "@/lib/server/bigmodel/tts";
|
||||||
|
|
||||||
export default function TranslatorPage() {
|
export default function TranslatorPage() {
|
||||||
const t = useTranslations("translator");
|
const t = useTranslations("translator");
|
||||||
@@ -50,13 +50,8 @@ export default function TranslatorPage() {
|
|||||||
|
|
||||||
const tts = async (text: string, locale: string) => {
|
const tts = async (text: string, locale: string) => {
|
||||||
if (lastTTS.current.text !== text) {
|
if (lastTTS.current.text !== text) {
|
||||||
const shortName = VOICES.find((v) => v.locale === locale)?.short_name;
|
|
||||||
if (!shortName) {
|
|
||||||
toast.error("Voice not found");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const url = await getTTSAudioUrl(text, shortName);
|
const url = await getTTSUrl(text, locale);
|
||||||
await load(url);
|
await load(url);
|
||||||
lastTTS.current.text = text;
|
lastTTS.current.text = text;
|
||||||
lastTTS.current.url = url;
|
lastTTS.current.url = url;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import AuthForm from "./AuthForm";
|
|||||||
|
|
||||||
export default async function AuthPage(
|
export default async function AuthPage(
|
||||||
props: {
|
props: {
|
||||||
searchParams: Promise<{ [key: string]: string | string[] | undefined; }>
|
searchParams: Promise<{ [key: string]: string | string[] | undefined; }>;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
const searchParams = await props.searchParams;
|
const searchParams = await props.searchParams;
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ export interface TextPair {
|
|||||||
id: number;
|
id: number;
|
||||||
text1: string;
|
text1: string;
|
||||||
text2: string;
|
text2: string;
|
||||||
locale1: string;
|
language1: string;
|
||||||
locale2: string;
|
language2: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function InFolder({ folderId }: { folderId: number }) {
|
export default function InFolder({ folderId }: { folderId: number }) {
|
||||||
@@ -146,8 +146,8 @@ export default function InFolder({ folderId }: { folderId: number }) {
|
|||||||
await createPair({
|
await createPair({
|
||||||
text1: text1,
|
text1: text1,
|
||||||
text2: text2,
|
text2: text2,
|
||||||
locale1: locale1,
|
language1: locale1,
|
||||||
locale2: locale2,
|
language2: locale2,
|
||||||
folder: {
|
folder: {
|
||||||
connect: {
|
connect: {
|
||||||
id: folderId,
|
id: folderId,
|
||||||
|
|||||||
@@ -25,11 +25,11 @@ export default function TextPairCard({
|
|||||||
<div className="flex items-start justify-between mb-3">
|
<div className="flex items-start justify-between mb-3">
|
||||||
<div className="flex items-center gap-2 text-xs text-gray-500">
|
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||||
<span className="px-2 py-1 bg-gray-100 rounded-md">
|
<span className="px-2 py-1 bg-gray-100 rounded-md">
|
||||||
{textPair.locale1.toUpperCase()}
|
{textPair.language1.toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
<span>→</span>
|
<span>→</span>
|
||||||
<span className="px-2 py-1 bg-gray-100 rounded-md">
|
<span className="px-2 py-1 bg-gray-100 rounded-md">
|
||||||
{textPair.locale2.toUpperCase()}
|
{textPair.language2.toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ export default function UpdateTextPairModal({
|
|||||||
const t = useTranslations("folder_id");
|
const t = useTranslations("folder_id");
|
||||||
const input1Ref = useRef<HTMLInputElement>(null);
|
const input1Ref = useRef<HTMLInputElement>(null);
|
||||||
const input2Ref = useRef<HTMLInputElement>(null);
|
const input2Ref = useRef<HTMLInputElement>(null);
|
||||||
const [locale1, setLocale1] = useState(textPair.locale1);
|
const [locale1, setLocale1] = useState(textPair.language1);
|
||||||
const [locale2, setLocale2] = useState(textPair.locale2);
|
const [locale2, setLocale2] = useState(textPair.language2);
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -111,6 +111,9 @@ export async function signInAction(prevState: SignUpState, formData: FormData) {
|
|||||||
|
|
||||||
redirect(redirectTo || "/");
|
redirect(redirectTo || "/");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message.includes('NEXT_REDIRECT')) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: "登录失败,请检查您的邮箱和密码"
|
message: "登录失败,请检查您的邮箱和密码"
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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) => {
|
export const genTranslation = async (text: string, targetLanguage: string) => {
|
||||||
return await getAnswer(
|
return await getAnswer(
|
||||||
`
|
`
|
||||||
|
|||||||
248
src/lib/server/bigmodel/tts.ts
Normal file
248
src/lib/server/bigmodel/tts.ts
Normal 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user