before refractor
This commit is contained in:
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,321 +1,398 @@
|
||||
"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 = [
|
||||
{ code: "english", name: "英语" },
|
||||
{ code: "chinese", name: "中文" },
|
||||
{ code: "japanese", name: "日语" },
|
||||
{ code: "korean", name: "韩语" },
|
||||
{ code: "french", name: "法语" },
|
||||
{ code: "german", name: "德语" },
|
||||
{ code: "italian", name: "意大利语" },
|
||||
{ code: "spanish", name: "西班牙语" },
|
||||
{ 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;
|
||||
ipa: string;
|
||||
definition: string;
|
||||
partOfSpeech: string;
|
||||
example: string;
|
||||
};
|
||||
|
||||
type DictionaryPhraseEntry = {
|
||||
definition: string;
|
||||
example: string;
|
||||
definition: string;
|
||||
example: string;
|
||||
};
|
||||
|
||||
type DictionaryErrorResponse = {
|
||||
error: string;
|
||||
error: string;
|
||||
};
|
||||
|
||||
type DictionarySuccessResponse = {
|
||||
standardForm: string;
|
||||
entries: (DictionaryWordEntry | DictionaryPhraseEntry)[];
|
||||
standardForm: string;
|
||||
entries: (DictionaryWordEntry | DictionaryPhraseEntry)[];
|
||||
};
|
||||
|
||||
type DictionaryResponse = DictionarySuccessResponse | DictionaryErrorResponse;
|
||||
|
||||
// 类型守卫:判断是否为单词条目
|
||||
function isWordEntry(entry: DictionaryWordEntry | DictionaryPhraseEntry): entry is DictionaryWordEntry {
|
||||
return "ipa" in entry && "partOfSpeech" in entry;
|
||||
return "ipa" in entry && "partOfSpeech" in entry;
|
||||
}
|
||||
|
||||
// 类型守卫:判断是否为错误响应
|
||||
function isErrorResponse(response: DictionaryResponse): response is DictionaryErrorResponse {
|
||||
return "error" in response;
|
||||
return "error" in response;
|
||||
}
|
||||
|
||||
export default function Dictionary() {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searchResult, setSearchResult] = useState<DictionaryResponse | null>(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 [searchQuery, setSearchQuery] = useState("");
|
||||
const [searchResult, setSearchResult] = useState<DictionaryResponse | null>(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 [selectedFolderId, setSelectedFolderId] = useState<number | null>(null);
|
||||
const [folders, setFolders] = useState<Folder[]>([]);
|
||||
const { data: session } = authClient.useSession();
|
||||
|
||||
const handleSearch = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!searchQuery.trim()) return;
|
||||
// 加载用户的文件夹列表
|
||||
useEffect(() => {
|
||||
if (session) {
|
||||
getFoldersByUserId(session.user.id as string)
|
||||
.then((loadedFolders) => {
|
||||
setFolders(loadedFolders);
|
||||
// 如果有文件夹且未选择,默认选择第一个
|
||||
if (loadedFolders.length > 0 && !selectedFolderId) {
|
||||
setSelectedFolderId(loadedFolders[0].id);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [session, selectedFolderId]);
|
||||
|
||||
setIsSearching(true);
|
||||
setHasSearched(true);
|
||||
setSearchResult(null);
|
||||
const handleSearch = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!searchQuery.trim()) return;
|
||||
|
||||
try {
|
||||
// 使用查询语言和释义语言
|
||||
const result = await lookUp(searchQuery, queryLang, definitionLang);
|
||||
|
||||
// 检查是否为错误响应
|
||||
if (isErrorResponse(result)) {
|
||||
toast.error(result.error);
|
||||
setIsSearching(true);
|
||||
setHasSearched(true);
|
||||
setSearchResult(null);
|
||||
} else {
|
||||
setSearchResult(result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("词典查询失败:", error);
|
||||
toast.error("查询失败,请稍后重试");
|
||||
setSearchResult(null);
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-64px)] bg-[#35786f]">
|
||||
{/* 搜索区域 */}
|
||||
<div className="flex items-center justify-center px-4 py-12">
|
||||
<Container className="max-w-3xl w-full p-4">
|
||||
{/* 页面标题 */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-gray-800 mb-4">
|
||||
词典
|
||||
</h1>
|
||||
<p className="text-gray-700 text-lg">
|
||||
查询单词和短语,提供详细的释义和例句
|
||||
</p>
|
||||
</div>
|
||||
try {
|
||||
// 使用查询语言和释义语言
|
||||
const result = await lookUp(searchQuery, queryLang, definitionLang);
|
||||
|
||||
{/* 搜索表单 */}
|
||||
<form onSubmit={handleSearch} className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => 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"
|
||||
/>
|
||||
<LightButton
|
||||
type="submit"
|
||||
disabled={isSearching || !searchQuery.trim()}
|
||||
className="px-6 py-3"
|
||||
>
|
||||
{isSearching ? "查询中..." : "查询"}
|
||||
</LightButton>
|
||||
</form>
|
||||
// 检查是否为错误响应
|
||||
if (isErrorResponse(result)) {
|
||||
toast.error(result.error);
|
||||
setSearchResult(null);
|
||||
} else {
|
||||
setSearchResult(result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("词典查询失败:", error);
|
||||
toast.error("查询失败,请稍后重试");
|
||||
setSearchResult(null);
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
{/* 语言设置 */}
|
||||
<div className="mt-4 bg-white/20 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-gray-800 font-semibold">语言设置</span>
|
||||
<LightButton
|
||||
onClick={() => setShowLangSettings(!showLangSettings)}
|
||||
className="text-sm px-4 py-2"
|
||||
>
|
||||
{showLangSettings ? "收起" : "展开"}
|
||||
</LightButton>
|
||||
</div>
|
||||
|
||||
{showLangSettings && (
|
||||
<div className="space-y-4">
|
||||
{/* 查询语言 */}
|
||||
<div>
|
||||
<label className="block text-gray-700 text-sm mb-2">
|
||||
查询语言 (你要查询的单词/短语是什么语言)
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{POPULAR_LANGUAGES.map((lang) => (
|
||||
<LightButton
|
||||
key={lang.code}
|
||||
selected={queryLang === lang.code}
|
||||
onClick={() => setQueryLang(lang.code)}
|
||||
className="text-sm px-3 py-1"
|
||||
>
|
||||
{lang.name}
|
||||
</LightButton>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={queryLang}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 释义语言 */}
|
||||
<div>
|
||||
<label className="block text-gray-700 text-sm mb-2">
|
||||
释义语言 (你希望用什么语言查看释义)
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{POPULAR_LANGUAGES.map((lang) => (
|
||||
<LightButton
|
||||
key={lang.code}
|
||||
selected={definitionLang === lang.code}
|
||||
onClick={() => setDefinitionLang(lang.code)}
|
||||
className="text-sm px-3 py-1"
|
||||
>
|
||||
{lang.name}
|
||||
</LightButton>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={definitionLang}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 当前设置显示 */}
|
||||
<div className="text-center text-gray-700 text-sm pt-2 border-t border-gray-300">
|
||||
当前设置:查询 <span className="font-semibold">{POPULAR_LANGUAGES.find(l => l.code === queryLang)?.name || queryLang}</span>
|
||||
,释义 <span className="font-semibold">{POPULAR_LANGUAGES.find(l => l.code === definitionLang)?.name || definitionLang}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 搜索提示 */}
|
||||
<div className="mt-4 text-center text-gray-700 text-sm">
|
||||
<p>试试搜索:hello, look up, dictionary</p>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
|
||||
{/* 搜索结果区域 */}
|
||||
<div className="flex-1 px-4 pb-12">
|
||||
<Container className="max-w-3xl w-full p-4">
|
||||
{isSearching && (
|
||||
<div className="text-center py-8">
|
||||
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-white"></div>
|
||||
<p className="mt-4 text-white">加载中...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isSearching && hasSearched && !searchResult && (
|
||||
<div className="text-center py-12 bg-white/20 rounded-lg">
|
||||
<p className="text-gray-800 text-xl">未找到结果</p>
|
||||
<p className="text-gray-600 mt-2">尝试其他单词或短语</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isSearching && searchResult && !isErrorResponse(searchResult) && (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-lg p-6 shadow-lg">
|
||||
{/* 标准形式标题 */}
|
||||
<div className="mb-6">
|
||||
<h2 className="text-3xl font-bold text-gray-800 mb-2">
|
||||
{searchResult.standardForm}
|
||||
</h2>
|
||||
{searchResult.standardForm !== searchQuery && (
|
||||
<p className="text-gray-500 text-sm">
|
||||
原始输入: {searchQuery}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 条目列表 */}
|
||||
<div className="space-y-6">
|
||||
{searchResult.entries.map((entry, index) => (
|
||||
<div key={index} className="border-t border-gray-200 pt-4">
|
||||
{isWordEntry(entry) ? (
|
||||
// 单词条目
|
||||
<div>
|
||||
{/* 音标和词性 */}
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
{entry.ipa && (
|
||||
<span className="text-gray-600 text-lg">
|
||||
{entry.ipa}
|
||||
</span>
|
||||
)}
|
||||
{entry.partOfSpeech && (
|
||||
<span className="px-3 py-1 bg-[#35786f] text-white text-sm rounded-full">
|
||||
{entry.partOfSpeech}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 释义 */}
|
||||
<div className="mb-3">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-1">
|
||||
释义
|
||||
</h3>
|
||||
<p className="text-gray-800">{entry.definition}</p>
|
||||
</div>
|
||||
|
||||
{/* 例句 */}
|
||||
{entry.example && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-1">
|
||||
例句
|
||||
</h3>
|
||||
<p className="text-gray-700 pl-4 border-l-4 border-[#35786f]">
|
||||
{entry.example}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// 短语条目
|
||||
<div>
|
||||
{/* 释义 */}
|
||||
<div className="mb-3">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-1">
|
||||
释义
|
||||
</h3>
|
||||
<p className="text-gray-800">{entry.definition}</p>
|
||||
</div>
|
||||
|
||||
{/* 例句 */}
|
||||
{entry.example && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-1">
|
||||
例句
|
||||
</h3>
|
||||
<p className="text-gray-700 pl-4 border-l-4 border-[#35786f]">
|
||||
{entry.example}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-64px)] bg-[#35786f]">
|
||||
{/* 搜索区域 */}
|
||||
<div className="flex items-center justify-center px-4 py-12">
|
||||
<Container className="max-w-3xl w-full p-4">
|
||||
{/* 页面标题 */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-gray-800 mb-4">
|
||||
词典
|
||||
</h1>
|
||||
<p className="text-gray-700 text-lg">
|
||||
查询单词和短语,提供详细的释义和例句
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasSearched && (
|
||||
<div className="text-center py-12 bg-white/20 rounded-lg">
|
||||
<div className="text-6xl mb-4">📚</div>
|
||||
<p className="text-gray-800 text-xl mb-2">欢迎使用词典</p>
|
||||
<p className="text-gray-600">在上方搜索框中输入单词或短语开始查询</p>
|
||||
{/* 搜索表单 */}
|
||||
<form onSubmit={handleSearch} className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => 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"
|
||||
/>
|
||||
<LightButton
|
||||
type="submit"
|
||||
disabled={isSearching || !searchQuery.trim()}
|
||||
className="px-6 py-3"
|
||||
>
|
||||
{isSearching ? "查询中..." : "查询"}
|
||||
</LightButton>
|
||||
</form>
|
||||
|
||||
{/* 语言设置 */}
|
||||
<div className="mt-4 bg-white/20 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-gray-800 font-semibold">语言设置</span>
|
||||
<LightButton
|
||||
onClick={() => setShowLangSettings(!showLangSettings)}
|
||||
className="text-sm px-4 py-2"
|
||||
>
|
||||
{showLangSettings ? "收起" : "展开"}
|
||||
</LightButton>
|
||||
</div>
|
||||
|
||||
{showLangSettings && (
|
||||
<div className="space-y-4">
|
||||
{/* 查询语言 */}
|
||||
<div>
|
||||
<label className="block text-gray-700 text-sm mb-2">
|
||||
查询语言 (你要查询的单词/短语是什么语言)
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{POPULAR_LANGUAGES.map((lang) => (
|
||||
<LightButton
|
||||
key={lang.code}
|
||||
selected={queryLang === lang.code}
|
||||
onClick={() => setQueryLang(lang.code)}
|
||||
className="text-sm px-3 py-1"
|
||||
>
|
||||
{lang.name}
|
||||
</LightButton>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={queryLang}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 释义语言 */}
|
||||
<div>
|
||||
<label className="block text-gray-700 text-sm mb-2">
|
||||
释义语言 (你希望用什么语言查看释义)
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{POPULAR_LANGUAGES.map((lang) => (
|
||||
<LightButton
|
||||
key={lang.code}
|
||||
selected={definitionLang === lang.code}
|
||||
onClick={() => setDefinitionLang(lang.code)}
|
||||
className="text-sm px-3 py-1"
|
||||
>
|
||||
{lang.name}
|
||||
</LightButton>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={definitionLang}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 当前设置显示 */}
|
||||
<div className="text-center text-gray-700 text-sm pt-2 border-t border-gray-300">
|
||||
当前设置:查询 <span className="font-semibold">{POPULAR_LANGUAGES.find(l => l.code === queryLang)?.name || queryLang}</span>
|
||||
,释义 <span className="font-semibold">{POPULAR_LANGUAGES.find(l => l.code === definitionLang)?.name || definitionLang}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 搜索提示 */}
|
||||
<div className="mt-4 text-center text-gray-700 text-sm">
|
||||
<p>试试搜索:hello, look up, dictionary</p>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
)}
|
||||
</Container>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
{/* 搜索结果区域 */}
|
||||
<div className="flex-1 px-4 pb-12">
|
||||
<Container className="max-w-3xl w-full p-4">
|
||||
{isSearching && (
|
||||
<div className="text-center py-8">
|
||||
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-white"></div>
|
||||
<p className="mt-4 text-white">加载中...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isSearching && hasSearched && !searchResult && (
|
||||
<div className="text-center py-12 bg-white/20 rounded-lg">
|
||||
<p className="text-gray-800 text-xl">未找到结果</p>
|
||||
<p className="text-gray-600 mt-2">尝试其他单词或短语</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isSearching && searchResult && !isErrorResponse(searchResult) && (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-lg p-6 shadow-lg">
|
||||
{/* 标题和保存按钮 */}
|
||||
<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>
|
||||
{searchResult.standardForm !== searchQuery && (
|
||||
<p className="text-gray-500 text-sm">
|
||||
原始输入: {searchQuery}
|
||||
</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">
|
||||
{searchResult.entries.map((entry, index) => (
|
||||
<div key={index} className="border-t border-gray-200 pt-4">
|
||||
{isWordEntry(entry) ? (
|
||||
// 单词条目
|
||||
<div>
|
||||
{/* 音标和词性 */}
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
{entry.ipa && (
|
||||
<span className="text-gray-600 text-lg">
|
||||
{entry.ipa}
|
||||
</span>
|
||||
)}
|
||||
{entry.partOfSpeech && (
|
||||
<span className="px-3 py-1 bg-[#35786f] text-white text-sm rounded-full">
|
||||
{entry.partOfSpeech}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 释义 */}
|
||||
<div className="mb-3">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-1">
|
||||
释义
|
||||
</h3>
|
||||
<p className="text-gray-800">{entry.definition}</p>
|
||||
</div>
|
||||
|
||||
{/* 例句 */}
|
||||
{entry.example && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-1">
|
||||
例句
|
||||
</h3>
|
||||
<p className="text-gray-700 pl-4 border-l-4 border-[#35786f]">
|
||||
{entry.example}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// 短语条目
|
||||
<div>
|
||||
{/* 释义 */}
|
||||
<div className="mb-3">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-1">
|
||||
释义
|
||||
</h3>
|
||||
<p className="text-gray-800">{entry.definition}</p>
|
||||
</div>
|
||||
|
||||
{/* 例句 */}
|
||||
{entry.example && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-1">
|
||||
例句
|
||||
</h3>
|
||||
<p className="text-gray-700 pl-4 border-l-4 border-[#35786f]">
|
||||
{entry.example}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasSearched && (
|
||||
<div className="text-center py-12 bg-white/20 rounded-lg">
|
||||
<div className="text-6xl mb-4">📚</div>
|
||||
<p className="text-gray-800 text-xl mb-2">欢迎使用词典</p>
|
||||
<p className="text-gray-600">在上方搜索框中输入单词或短语开始查询</p>
|
||||
</div>
|
||||
)}
|
||||
</Container>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -5,19 +5,19 @@ import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export interface SignUpFormData {
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface SignUpState {
|
||||
success?: boolean;
|
||||
message?: string;
|
||||
errors?: {
|
||||
username?: string[];
|
||||
email?: string[];
|
||||
password?: string[];
|
||||
};
|
||||
success?: boolean;
|
||||
message?: string;
|
||||
errors?: {
|
||||
username?: string[];
|
||||
email?: string[];
|
||||
password?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export async function signUpAction(prevState: SignUpState, formData: FormData) {
|
||||
@@ -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: "登录失败,请检查您的邮箱和密码"
|
||||
|
||||
@@ -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) => {
|
||||
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