This commit is contained in:
2025-12-29 11:42:58 +08:00
parent d3e1cd9092
commit cbb5d25e54
12 changed files with 111 additions and 183 deletions

View File

@@ -24,11 +24,7 @@
"noFoldersYet": "No folders yet",
"folderInfo": "{id}. {name} ({totalPairs})",
"enterFolderName": "Enter folder name:",
"confirmDelete": "Type \"{name}\" to delete:",
"createFolderSuccess": "Folder created successfully",
"deleteFolderSuccess": "Folder deleted successfully",
"createFolderError": "Failed to create folder",
"deleteFolderError": "Failed to delete folder"
"confirmDelete": "Type \"{name}\" to delete:"
},
"folder_id": {
"unauthorized": "You are not the owner of this folder",
@@ -82,10 +78,6 @@
"description": "Under development, stay tuned"
}
},
"login": {
"loading": "Loading...",
"githubLogin": "GitHub Login"
},
"auth": {
"title": "Authentication",
"signIn": "Sign In",
@@ -93,18 +85,11 @@
"email": "Email",
"password": "Password",
"confirmPassword": "Confirm Password",
"name": "Name",
"signInButton": "Sign In",
"signUpButton": "Sign Up",
"noAccount": "Don't have an account?",
"hasAccount": "Already have an account?",
"signInWithGitHub": "Sign In with GitHub",
"signUpWithGitHub": "Sign Up with GitHub",
"invalidEmail": "Please enter a valid email address",
"passwordTooShort": "Password must be at least 8 characters",
"passwordsNotMatch": "Passwords do not match",
"signInFailed": "Sign in failed, please check your email and password",
"signUpFailed": "Sign up failed, please try again later",
"nameRequired": "Please enter your name",
"emailRequired": "Please enter your email",
"passwordRequired": "Please enter your password",
@@ -151,42 +136,10 @@
"next": "Next",
"restart": "Restart",
"autoPause": "Auto Pause ({enabled})",
"playbackSpeed": "Playback Speed",
"subtitleSettings": "Subtitle Settings",
"fontSize": "Font Size",
"backgroundColor": "Background Color",
"textColor": "Text Color",
"fontFamily": "Font Family",
"opacity": "Opacity",
"position": "Position",
"top": "Top",
"center": "Center",
"bottom": "Bottom",
"keyboardShortcuts": "Keyboard Shortcuts",
"uploadVideoAndSubtitle": "Please upload video and subtitle files",
"uploadVideoFile": "Please upload video file",
"uploadSubtitleFile": "Please upload subtitle file",
"processingSubtitle": "Processing subtitle file...",
"needBothFiles": "Both video and subtitle files are required to start learning",
"videoFile": "Video File",
"subtitleFile": "Subtitle File",
"uploaded": "Uploaded",
"notUploaded": "Not Uploaded",
"upload": "Upload",
"autoPauseStatus": "Auto Pause: {enabled}",
"on": "On",
"off": "Off",
"videoUploadFailed": "Video upload failed",
"subtitleUploadFailed": "Subtitle upload failed",
"subtitleLoadSuccess": "Subtitle file loaded successfully",
"subtitleLoadFailed": "Subtitle file loading failed",
"shortcuts": {
"playPause": "Play/Pause",
"next": "Next",
"previous": "Previous",
"restart": "Restart",
"autoPause": "Toggle Auto Pause"
}
"subtitleUploadFailed": "Subtitle upload failed"
},
"text_speaker": {
"generateIPA": "Generate IPA",

View File

@@ -24,11 +24,7 @@
"noFoldersYet": "还没有文件夹",
"folderInfo": "{id}. {name} ({totalPairs})",
"enterFolderName": "输入文件夹名称:",
"confirmDelete": "输入 \"{name}\" 以删除:",
"createFolderSuccess": "文件夹创建成功",
"deleteFolderSuccess": "文件夹删除成功",
"createFolderError": "创建文件夹失败",
"deleteFolderError": "删除文件夹失败"
"confirmDelete": "输入 \"{name}\" 以删除:"
},
"folder_id": {
"unauthorized": "您不是此文件夹的所有者",
@@ -82,10 +78,6 @@
"description": "开发中,敬请期待"
}
},
"login": {
"loading": "加载中...",
"githubLogin": "GitHub登录"
},
"auth": {
"title": "登录",
"signIn": "登录",
@@ -93,28 +85,18 @@
"email": "邮箱",
"password": "密码",
"confirmPassword": "确认密码",
"name": "用户名",
"signInButton": "登录",
"signUpButton": "注册",
"noAccount": "还没有账户?",
"hasAccount": "已有账户?",
"signInWithGitHub": "使用GitHub登录",
"signUpWithGitHub": "使用GitHub注册",
"invalidEmail": "请输入有效的邮箱地址",
"passwordTooShort": "密码至少需要8个字符",
"passwordsNotMatch": "两次输入的密码不匹配",
"signInFailed": "登录失败,请检查您的邮箱和密码",
"signUpFailed": "注册失败,请稍后再试",
"nameRequired": "请输入用户名",
"emailRequired": "请输入邮箱",
"passwordRequired": "请输入密码",
"confirmPasswordRequired": "请确认密码"
"confirmPasswordRequired": "请确认密码",
"loading": "加载中..."
},
"memorize": {
"choose": {
"back": "返回",
"choose": "选择"
},
"folder_selector": {
"selectFolder": "选择文件夹",
"noFolders": "未找到文件夹",
@@ -155,41 +137,10 @@
"next": "下句",
"restart": "句首",
"autoPause": "自动暂停({enabled})",
"playbackSpeed": "播放速度",
"subtitleSettings": "字幕设置",
"fontSize": "字体大小",
"backgroundColor": "背景颜色",
"textColor": "文字颜色",
"fontFamily": "字体",
"opacity": "透明度",
"position": "位置",
"top": "顶部",
"center": "居中",
"bottom": "底部",
"keyboardShortcuts": "键盘快捷键",
"uploadVideoAndSubtitle": "请上传视频和字幕文件",
"uploadVideoFile": "请上传视频文件",
"uploadSubtitleFile": "请上传字幕文件",
"processingSubtitle": "字幕文件正在处理中...",
"needBothFiles": "需要同时上传视频和字幕文件才能开始学习",
"videoFile": "视频文件",
"subtitleFile": "字幕文件",
"uploaded": "已上传",
"notUploaded": "未上传",
"autoPauseStatus": "自动暂停: {enabled}",
"on": "开",
"off": "关",
"videoUploadFailed": "视频上传失败",
"subtitleUploadFailed": "字幕上传失败",
"subtitleLoadSuccess": "字幕文件加载成功",
"subtitleLoadFailed": "字幕文件加载失败",
"shortcuts": {
"playPause": "播放/暂停",
"next": "下一句",
"previous": "上一句",
"restart": "句首",
"autoPause": "切换自动暂停"
}
"subtitleUploadFailed": "字幕上传失败"
},
"text_speaker": {
"generateIPA": "生成IPA",

View File

@@ -1,4 +1,5 @@
import { SubtitleEntry } from "../types/subtitle";
import { logger } from "@/lib/logger";
export function parseSrt(data: string): SubtitleEntry[] {
const lines = data.split(/\r?\n/);
@@ -93,7 +94,7 @@ export async function loadSubtitle(url: string): Promise<SubtitleEntry[]> {
const data = await response.text();
return parseSrt(data);
} catch (error) {
console.error('Failed to load subtitle:', error);
logger.error('加载字幕失败', error);
return [];
}
}

View File

@@ -17,6 +17,7 @@ import { useTranslations } from "next-intl";
import { getLocalStorageOperator } from "@/lib/browser/localStorageOperators";
import { getTTSAudioUrl } from "@/lib/browser/tts";
import { genIPA, genLocale } from "@/lib/server/translatorActions";
import { logger } from "@/lib/logger";
import PageLayout from "@/components/ui/PageLayout";
export default function TextSpeakerPage() {
@@ -75,7 +76,7 @@ export default function TextSpeakerPage() {
setIPA(data.ipa);
})
.catch((e) => {
console.error(e);
logger.error("生成 IPA 失败", e);
setIPA("");
});
}
@@ -96,7 +97,6 @@ export default function TextSpeakerPage() {
try {
let theLocale = locale;
if (!theLocale) {
console.log("downloading text info");
const tmp_locale = await genLocale(textRef.current.slice(0, 30));
setLocale(tmp_locale);
theLocale = tmp_locale;
@@ -123,8 +123,7 @@ export default function TextSpeakerPage() {
load(objurlRef.current);
play();
} catch (e) {
console.error(e);
logger.error("播放音频失败", e);
setPause(true);
setLocale(null);
@@ -181,7 +180,6 @@ export default function TextSpeakerPage() {
try {
let theLocale = locale;
if (!theLocale) {
console.log("downloading text info");
const tmp_locale = await genLocale(textRef.current.slice(0, 30));
setLocale(tmp_locale);
theLocale = tmp_locale;
@@ -218,7 +216,7 @@ export default function TextSpeakerPage() {
}
setIntoLocalStorage(save);
} catch (e) {
console.error(e);
logger.error("保存到本地存储失败", e);
setLocale(null);
} finally {
setSaving(false);

View File

@@ -8,6 +8,7 @@ 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";
import { useEffect, useRef, useState } from "react";
@@ -67,7 +68,7 @@ export default function TranslatorPage() {
lastTTS.current.url = url;
} catch (error) {
toast.error("Failed to generate audio");
console.error(error);
logger.error("生成音频失败", error);
}
}
await play();

View File

@@ -8,6 +8,7 @@ import {
Trash2,
} from "lucide-react";
import { useEffect, useState } from "react";
import { logger } from "@/lib/logger";
import { useRouter } from "next/navigation";
import { Folder } from "../../../generated/prisma/browser";
import {
@@ -101,7 +102,7 @@ export default function FoldersClient({ userId }: { userId: string }) {
setLoading(false);
})
.catch((error) => {
console.error(error);
logger.error("加载文件夹失败", error);
toast.error("加载出错,请重试。");
});
}, [userId]);
@@ -111,7 +112,7 @@ export default function FoldersClient({ userId }: { userId: string }) {
const updatedFolders = await getFoldersWithTotalPairsByUserId(userId);
setFolders(updatedFolders);
} catch (error) {
console.error(error);
logger.error("更新文件夹失败", error);
}
};

View File

@@ -1,9 +1,9 @@
import { LightButton } from "@/components/ui/buttons";
import Input from "@/components/ui/Input";
import { LocaleSelector } from "@/components/ui/LocaleSelector";
import { X } from "lucide-react";
import { useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { LOCALES } from "@/config/locales";
interface AddTextPairModalProps {
isOpen: boolean;
@@ -16,53 +16,6 @@ interface AddTextPairModalProps {
) => void;
}
const COMMON_LOCALES = [
{ label: "中文", value: "zh-CN" },
{ label: "英文", value: "en-US" },
{ label: "意大利语", value: "it-IT" },
{ label: "日语", value: "ja-JP" },
{ label: "其他", value: "other" },
];
interface LocaleSelectorProps {
value: string;
onChange: (val: string) => void;
}
function LocaleSelector({ value, onChange }: LocaleSelectorProps) {
const isCommonLocale = COMMON_LOCALES.some((l) => l.value === value && l.value !== "other");
const showFullList = value === "other" || !isCommonLocale;
return (
<div>
<select
value={isCommonLocale ? value : "other"}
onChange={(e) => onChange(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-[#35786f]"
>
{COMMON_LOCALES.map((locale) => (
<option key={locale.value} value={locale.value}>
{locale.label}
</option>
))}
</select>
{showFullList && (
<select
value={value === "other" ? LOCALES[0] : value}
onChange={(e) => onChange(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-[#35786f] mt-2"
>
{LOCALES.map((locale) => (
<option key={locale} value={locale}>
{locale}
</option>
))}
</select>
)}
</div>
);
}
export default function AddTextPairModal({
isOpen,
onClose,

View File

@@ -13,6 +13,7 @@ import TextPairCard from "./TextPairCard";
import { useTranslations } from "next-intl";
import PageLayout from "@/components/ui/PageLayout";
import { GreenButton } from "@/components/ui/buttons";
import { logger } from "@/lib/logger";
import { IconButton } from "@/components/ui/buttons";
import CardList from "@/components/ui/CardList";
@@ -38,7 +39,7 @@ export default function InFolder({ folderId }: { folderId: number }) {
const data = await getPairsByFolderId(folderId);
setTextPairs(data as TextPair[]);
} catch (error) {
console.error("Failed to fetch text pairs:", error);
logger.error("获取文本对失败", error);
} finally {
setLoading(false);
}
@@ -51,7 +52,7 @@ export default function InFolder({ folderId }: { folderId: number }) {
const data = await getPairsByFolderId(folderId);
setTextPairs(data as TextPair[]);
} catch (error) {
console.error("Failed to fetch text pairs:", error);
logger.error("获取文本对失败", error);
}
};

View File

@@ -1,7 +1,8 @@
import { LightButton } from "@/components/ui/buttons";
import Input from "@/components/ui/Input";
import { LocaleSelector } from "@/components/ui/LocaleSelector";
import { X } from "lucide-react";
import { useRef } from "react";
import { useRef, useState } from "react";
import { PairUpdateInput } from "../../../../generated/prisma/models";
import { TextPair } from "./InFolder";
import { useTranslations } from "next-intl";
@@ -22,23 +23,23 @@ export default function UpdateTextPairModal({
const t = useTranslations("folder_id");
const input1Ref = useRef<HTMLInputElement>(null);
const input2Ref = useRef<HTMLInputElement>(null);
const input3Ref = useRef<HTMLInputElement>(null);
const input4Ref = useRef<HTMLInputElement>(null);
const [locale1, setLocale1] = useState(textPair.locale1);
const [locale2, setLocale2] = useState(textPair.locale2);
if (!isOpen) return null;
const handleUpdate = () => {
if (
!input1Ref.current?.value ||
!input2Ref.current?.value ||
!input3Ref.current?.value ||
!input4Ref.current?.value
!locale1 ||
!locale2
)
return;
const text1 = input1Ref.current.value;
const text2 = input2Ref.current.value;
const locale1 = input3Ref.current.value;
const locale2 = input4Ref.current.value;
if (
typeof text1 === "string" &&
typeof text2 === "string" &&
@@ -50,8 +51,6 @@ export default function UpdateTextPairModal({
locale2.trim() !== ""
) {
onUpdate(textPair.id, { text1, text2, locale1, locale2 });
input1Ref.current.value = "";
input2Ref.current.value = "";
}
};
return (
@@ -90,19 +89,11 @@ export default function UpdateTextPairModal({
</div>
<div>
{t("locale1")}
<Input
defaultValue={textPair.locale1}
ref={input3Ref}
className="w-full"
></Input>
<LocaleSelector value={locale1} onChange={setLocale1} />
</div>
<div>
{t("locale2")}
<Input
defaultValue={textPair.locale2}
ref={input4Ref}
className="w-full"
></Input>
<LocaleSelector value={locale2} onChange={setLocale2} />
</div>
</div>
<LightButton onClick={handleUpdate}>{t("update")}</LightButton>

View File

@@ -0,0 +1,48 @@
import { LOCALES } from "@/config/locales";
const COMMON_LOCALES = [
{ label: "中文", value: "zh-CN" },
{ label: "英文", value: "en-US" },
{ label: "意大利语", value: "it-IT" },
{ label: "日语", value: "ja-JP" },
{ label: "其他", value: "other" },
];
interface LocaleSelectorProps {
value: string;
onChange: (val: string) => void;
}
export function LocaleSelector({ value, onChange }: LocaleSelectorProps) {
const isCommonLocale = COMMON_LOCALES.some((l) => l.value === value && l.value !== "other");
const showFullList = value === "other" || !isCommonLocale;
return (
<div>
<select
value={isCommonLocale ? value : "other"}
onChange={(e) => onChange(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-[#35786f]"
>
{COMMON_LOCALES.map((locale) => (
<option key={locale.value} value={locale.value}>
{locale.label}
</option>
))}
</select>
{showFullList && (
<select
value={value === "other" ? LOCALES[0] : value}
onChange={(e) => onChange(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-[#35786f] mt-2"
>
{LOCALES.map((locale) => (
<option key={locale} value={locale}>
{locale}
</option>
))}
</select>
)}
</div>
);
}

View File

@@ -6,6 +6,7 @@ import {
} from "@/lib/interfaces";
import z from "zod";
import { shallowEqual } from "../utils";
import { logger } from "@/lib/logger";
export const getLocalStorageOperator = <T extends z.ZodTypeAny>(
key: string,
@@ -24,14 +25,14 @@ export const getLocalStorageOperator = <T extends z.ZodTypeAny>(
if (result.success) {
return result.data;
} else {
console.error(
logger.error(
"Invalid data structure in localStorage:",
result.error,
);
return [] as z.infer<T>;
}
} catch (e) {
console.error(`Failed to parse ${key} data:`, e);
logger.error(`Failed to parse ${key} data:`, e);
return [] as z.infer<T>;
}
},

29
src/lib/logger.ts Normal file
View File

@@ -0,0 +1,29 @@
/**
* 统一的日志工具
* 在生产环境中可以通过环境变量控制日志级别
*/
type LogLevel = 'info' | 'warn' | 'error';
const isDevelopment = process.env.NODE_ENV === 'development';
export const logger = {
error: (message: string, error?: unknown) => {
if (isDevelopment) {
console.error(message, error);
}
// 在生产环境中,这里可以发送到错误追踪服务(如 Sentry
},
warn: (message: string, data?: unknown) => {
if (isDevelopment) {
console.warn(message, data);
}
},
info: (message: string, data?: unknown) => {
if (isDevelopment) {
console.info(message, data);
}
},
};