将next-auth替换为better-auth

This commit is contained in:
2025-12-10 17:54:14 +08:00
parent db96b86e65
commit 881d9ca921
45 changed files with 2225 additions and 623 deletions

View File

@@ -3,10 +3,10 @@ ZHIPU_API_KEY=
ZHIPU_MODEL_NAME= ZHIPU_MODEL_NAME=
// Auth // Auth
AUTH_SECRET= BETTER_AUTH_SECRET=
BETTER_AUTH_URL=
GITHUB_CLIENT_ID= GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET= GITHUB_CLIENT_SECRET=
NEXTAUTH_URL=
// Database // Database
DATABASE_URL= DATABASE_URL=

10
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,10 @@
{
"typescript.tsdk": "node_modules/typescript/lib",
"editor.defaultFormatter": null,
"[typescript]": {
"editor.defaultFormatter": "vscode.typescript-language-features"
},
"[typescriptreact]": {
"editor.defaultFormatter": "vscode.typescript-language-features"
}
}

View File

@@ -1,25 +1,16 @@
import { dirname } from "path"; import { defineConfig, globalIgnores } from 'eslint/config'
import { fileURLToPath } from "url"; import nextVitals from 'eslint-config-next/core-web-vitals'
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url); const eslintConfig = defineConfig([
const __dirname = dirname(__filename); ...nextVitals,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
'.next/**',
'out/**',
'build/**',
'next-env.d.ts',
]),
])
const compat = new FlatCompat({ export default eslintConfig
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
{
ignores: [
"node_modules/**",
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
],
},
];
export default eslintConfig;

View File

@@ -105,7 +105,7 @@
"title": "learn-languages", "title": "learn-languages",
"about": "About", "about": "About",
"sourceCode": "GitHub", "sourceCode": "GitHub",
"login": "Login", "sign_in": "Sign In",
"profile": "Profile", "profile": "Profile",
"folders": "Folders" "folders": "Folders"
}, },

View File

@@ -109,7 +109,7 @@
"title": "学语言", "title": "学语言",
"about": "关于", "about": "关于",
"sourceCode": "源码", "sourceCode": "源码",
"login": "登录", "sign_in": "登录",
"profile": "个人资料", "profile": "个人资料",
"folders": "文件夹" "folders": "文件夹"
}, },

View File

@@ -14,29 +14,33 @@
"@prisma/adapter-pg": "^7.1.0", "@prisma/adapter-pg": "^7.1.0",
"@prisma/client": "^7.1.0", "@prisma/client": "^7.1.0",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"better-auth": "^1.4.6",
"dotenv": "^17.2.3",
"edge-tts-universal": "^1.3.3", "edge-tts-universal": "^1.3.3",
"lucide-react": "^0.553.0", "lucide-react": "^0.556.0",
"next": "16.0.7", "next": "16.0.8",
"next-auth": "5.0.0-beta.30",
"next-intl": "^4.5.8", "next-intl": "^4.5.8",
"pg": "^8.16.3", "pg": "^8.16.3",
"react": "19.2.1", "react": "19.2.1",
"react-dom": "19.2.1", "react-dom": "19.2.1",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"unstorage": "^1.17.3", "unstorage": "^1.17.3",
"zod": "^3.25.76" "zod": "^4.1.13"
}, },
"devDependencies": { "devDependencies": {
"@better-auth/cli": "^1.4.6",
"@eslint/eslintrc": "^3.3.3", "@eslint/eslintrc": "^3.3.3",
"@tailwindcss/postcss": "^4.1.17", "@tailwindcss/postcss": "^4.1.17",
"@types/bcryptjs": "^2.4.6", "@types/node": "^24.10.2",
"@types/node": "^20.19.25",
"@types/react": "19.2.7", "@types/react": "19.2.7",
"@types/react-dom": "19.2.3", "@types/react-dom": "19.2.3",
"@typescript-eslint/eslint-plugin": "^8.49.0",
"@typescript-eslint/parser": "^8.49.0",
"babel-plugin-react-compiler": "^1.0.0", "babel-plugin-react-compiler": "^1.0.0",
"eslint": "^9.39.1", "eslint": "^9.39.1",
"eslint-config-next": "16.0.7", "eslint-config-next": "16.0.8",
"prisma": "^6.19.0", "eslint-plugin-react": "^7.37.5",
"prisma": "^7.1.0",
"tailwindcss": "^4.1.17", "tailwindcss": "^4.1.17",
"typescript": "^5.9.3" "typescript": "^5.9.3"
}, },
@@ -44,6 +48,9 @@
"overrides": { "overrides": {
"@types/react": "19.2.7", "@types/react": "19.2.7",
"@types/react-dom": "19.2.3" "@types/react-dom": "19.2.3"
} },
"ignoredBuiltDependencies": [
"@prisma/client"
]
} }
} }

1834
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,6 @@ export default defineConfig({
migrations: { migrations: {
path: "prisma/migrations", path: "prisma/migrations",
}, },
engine: "classic",
datasource: { datasource: {
url: env("DATABASE_URL"), url: env("DATABASE_URL"),
}, },

View File

@@ -1,71 +0,0 @@
/*
Warnings:
- You are about to drop the `folder` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `text_pair` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropForeignKey
ALTER TABLE "text_pair" DROP CONSTRAINT "fk_text_pairs_folder";
-- DropTable
DROP TABLE "folder";
-- DropTable
DROP TABLE "text_pair";
-- CreateTable
CREATE TABLE "pairs" (
"id" SERIAL NOT NULL,
"locale1" VARCHAR(10) NOT NULL,
"locale2" VARCHAR(10) NOT NULL,
"text1" TEXT NOT NULL,
"text2" TEXT NOT NULL,
"ipa1" TEXT,
"ipa2" TEXT,
"folder_id" INTEGER NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "pairs_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "folders" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"user_id" INTEGER NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "folders_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "users" (
"id" SERIAL NOT NULL,
"email" TEXT NOT NULL,
"name" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "pairs_folder_id_idx" ON "pairs"("folder_id");
-- CreateIndex
CREATE UNIQUE INDEX "pairs_folder_id_locale1_locale2_text1_key" ON "pairs"("folder_id", "locale1", "locale2", "text1");
-- CreateIndex
CREATE INDEX "folders_user_id_idx" ON "folders"("user_id");
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
-- AddForeignKey
ALTER TABLE "pairs" ADD CONSTRAINT "pairs_folder_id_fkey" FOREIGN KEY ("folder_id") REFERENCES "folders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "folders" ADD CONSTRAINT "folders_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -1,3 +1,4 @@
generator client { generator client {
provider = "prisma-client" provider = "prisma-client"
output = "../generated/prisma" output = "../generated/prisma"
@@ -29,7 +30,7 @@ model Pair {
model Folder { model Folder {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String name String
userId Int @map("user_id") userId String @map("user_id")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
@@ -41,13 +42,65 @@ model Folder {
} }
model User { model User {
id Int @id @default(autoincrement()) id String @id
email String @unique name String
name String email String
createdAt DateTime @default(now()) @map("created_at") emailVerified Boolean @default(false)
updatedAt DateTime @updatedAt @map("updated_at") image String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sessions Session[]
accounts Account[]
folders Folder[]
folders Folder[] @@unique([email])
@@map("user")
@@map("users") }
model Session {
id String @id
expiresAt DateTime
token String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
ipAddress String?
userAgent String?
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([token])
@@index([userId])
@@map("session")
}
model Account {
id String @id
accountId String
providerId String
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
accessToken String?
refreshToken String?
idToken String?
accessTokenExpiresAt DateTime?
refreshTokenExpiresAt DateTime?
scope String?
password String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@map("account")
}
model Verification {
id String @id
identifier String
value String
expiresAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([identifier])
@@map("verification")
} }

View File

@@ -1,13 +1,13 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useState } from "react";
import LightButton from "@/components/buttons/LightButton"; import LightButton from "@/components/buttons/LightButton";
import { useAudioPlayer } from "@/hooks/useAudioPlayer"; import { useAudioPlayer } from "@/hooks/useAudioPlayer";
import { getTTSAudioUrl } from "@/lib/browser/tts"; import { getTTSAudioUrl } from "@/lib/browser/tts";
import { VOICES } from "@/config/locales"; import { VOICES } from "@/config/locales";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import localFont from "next/font/local"; import localFont from "next/font/local";
import { isNonNegativeInteger } from "@/lib/utils"; import { isNonNegativeInteger, SeededRandom } from "@/lib/utils";
import { Pair } from "../../../../generated/prisma/browser"; import { Pair } from "../../../../generated/prisma/browser";
const myFont = localFont({ const myFont = localFont({
@@ -27,20 +27,16 @@ const Memorize: React.FC<MemorizeProps> = ({ textPairs }) => {
const [show, setShow] = useState<"question" | "answer">("question"); const [show, setShow] = useState<"question" | "answer">("question");
const { load, play } = useAudioPlayer(); const { load, play } = useAudioPlayer();
const [disorderedTextPairs, setDisorderedTextPairs] = useState<Pair[]>( if (textPairs.length === 0) {
[], return <p>{t("noTextPairs")}</p>;
); }
useEffect(() => { const rng = new SeededRandom(textPairs[0].folderId);
setDisorderedTextPairs(textPairs.toSorted(() => Math.random() - 0.5)); const disorderedTextPairs = textPairs.toSorted(() => rng.next() - 0.5);
}, [textPairs]);
const getTextPairs = () => { textPairs.sort((a, b) => a.id - b.id);
if (disorder) {
return disorderedTextPairs; const getTextPairs = () => disorder ? disorderedTextPairs : textPairs;
}
return textPairs.toSorted((a, b) => a.id - b.id);
};
return ( return (
<> <>
@@ -120,7 +116,7 @@ const Memorize: React.FC<MemorizeProps> = ({ textPairs }) => {
(v) => (v) =>
v.locale === v.locale ===
getTextPairs()[newIndex][ getTextPairs()[newIndex][
reverse ? "locale2" : "locale1" reverse ? "locale2" : "locale1"
], ],
)!.short_name, )!.short_name,
).then((url) => { ).then((url) => {

View File

@@ -1,51 +1,51 @@
"use server";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { import {
getFoldersWithTotalPairsByUserId, getFoldersWithTotalPairsByUserId,
getUserIdByFolderId, getUserIdByFolderId,
} from "@/lib/actions/services/folderService"; } from "@/lib/server/services/folderService";
import { isNonNegativeInteger } from "@/lib/utils"; import { isNonNegativeInteger } from "@/lib/utils";
import FolderSelector from "./FolderSelector"; import FolderSelector from "./FolderSelector";
import Memorize from "./Memorize"; import Memorize from "./Memorize";
import { getPairsByFolderId } from "@/lib/actions/services/pairService"; import { getPairsByFolderId } from "@/lib/server/services/pairService";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { headers } from "next/headers";
export default async function MemorizePage({ export default async function MemorizePage({
searchParams, searchParams,
}: { }: {
searchParams: Promise<{ folder_id?: string }>; searchParams: Promise<{ folder_id?: string; }>;
}) { }) {
const session = await auth(); const session = await auth.api.getSession({ headers: await headers() });
const userId = session?.user?.id; const tParam = (await searchParams).folder_id;
if (!session) {
redirect(
`/login?redirect=/memorize${(await searchParams).folder_id
? `?folder_id=${tParam}`
: ""
}`,
);
}
const t = await getTranslations("memorize.page"); const t = await getTranslations("memorize.page");
const tParam = (await searchParams).folder_id;
const folder_id = tParam const folder_id = tParam
? isNonNegativeInteger(tParam) ? isNonNegativeInteger(tParam)
? parseInt(tParam) ? parseInt(tParam)
: null : null
: null; : null;
if (!userId) {
redirect(
`/login?redirect=/memorize${folder_id ? `?folder_id=${folder_id}` : ""}`,
);
}
const uid = Number(userId);
if (!folder_id) { if (!folder_id) {
return ( return (
<FolderSelector <FolderSelector
folders={await getFoldersWithTotalPairsByUserId(uid)} folders={await getFoldersWithTotalPairsByUserId(session.user.id)}
/> />
); );
} }
const owner = await getUserIdByFolderId(folder_id); const owner = await getUserIdByFolderId(folder_id);
if (owner !== uid) { if (owner !== session.user.id) {
return <p>{t("unauthorized")}</p>; return <p>{t("unauthorized")}</p>;
} }

View File

@@ -1,4 +1,4 @@
import { useState, useRef, forwardRef, useEffect } from "react"; import { useState, useRef, forwardRef, useEffect, useCallback } from "react";
import SubtitleDisplay from "./SubtitleDisplay"; import SubtitleDisplay from "./SubtitleDisplay";
import LightButton from "@/components/buttons/LightButton"; import LightButton from "@/components/buttons/LightButton";
import { getIndex, parseSrt, getNearistIndex } from "../subtitle"; import { getIndex, parseSrt, getNearistIndex } from "../subtitle";
@@ -20,7 +20,7 @@ const VideoPanel = forwardRef<HTMLVideoElement, VideoPanelProps>(
const [spanText, setSpanText] = useState<string>(""); const [spanText, setSpanText] = useState<string>("");
const [subtitle, setSubtitle] = useState<string>(""); const [subtitle, setSubtitle] = useState<string>("");
const parsedSrtRef = useRef< const parsedSrtRef = useRef<
{ start: number; end: number; text: string }[] | null { start: number; end: number; text: string; }[] | null
>(null); >(null);
const rafldRef = useRef<number>(0); const rafldRef = useRef<number>(0);
const ready = useRef({ const ready = useRef({
@@ -31,7 +31,7 @@ const VideoPanel = forwardRef<HTMLVideoElement, VideoPanelProps>(
}, },
}); });
const togglePlayPause = () => { const togglePlayPause = useCallback(() => {
if (!videoUrl) return; if (!videoUrl) return;
const video = videoRef.current; const video = videoRef.current;
@@ -42,7 +42,7 @@ const VideoPanel = forwardRef<HTMLVideoElement, VideoPanelProps>(
video.pause(); video.pause();
} }
setIsPlaying(!video.paused); setIsPlaying(!video.paused);
} }, [videoRef, videoUrl]);
useEffect(() => { useEffect(() => {
const handleKeyDownEvent = (e: globalThis.KeyboardEvent) => { const handleKeyDownEvent = (e: globalThis.KeyboardEvent) => {

View File

@@ -16,7 +16,7 @@ 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 { getTTSAudioUrl } from "@/lib/browser/tts";
import { genIPA, genLocale } from "@/lib/actions/translatorActions"; import { genIPA, genLocale } from "@/lib/server/translatorActions";
export default function TextSpeakerPage() { export default function TextSpeakerPage() {
const t = useTranslations("text_speaker"); const t = useTranslations("text_speaker");

View File

@@ -1,15 +1,17 @@
"use client";
import LightButton from "@/components/buttons/LightButton"; import LightButton from "@/components/buttons/LightButton";
import Container from "@/components/cards/Container"; import Container from "@/components/cards/Container";
import { TranslationHistorySchema } from "@/lib/interfaces"; import { TranslationHistorySchema } from "@/lib/interfaces";
import { useSession } from "next-auth/react";
import { Dispatch, useEffect, useState } from "react"; import { Dispatch, useEffect, useState } from "react";
import z from "zod"; import z from "zod";
import { Folder } from "../../../../generated/prisma/browser"; import { Folder } from "../../../../generated/prisma/browser";
import { getFoldersByUserId } from "@/lib/actions/services/folderService"; import { getFoldersByUserId } from "@/lib/server/services/folderService";
import { Folder as Fd } from "lucide-react"; import { Folder as Fd } from "lucide-react";
import { createPair } from "@/lib/actions/services/pairService"; import { createPair } from "@/lib/server/services/pairService";
import { toast } from "sonner"; import { toast } from "sonner";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { authClient } from "@/lib/auth-client";
interface AddToFolderProps { interface AddToFolderProps {
item: z.infer<typeof TranslationHistorySchema>; item: z.infer<typeof TranslationHistorySchema>;
@@ -17,19 +19,21 @@ interface AddToFolderProps {
} }
const AddToFolder: React.FC<AddToFolderProps> = ({ item, setShow }) => { const AddToFolder: React.FC<AddToFolderProps> = ({ item, setShow }) => {
const session = useSession(); const { data: session } = authClient.useSession();
const [folders, setFolders] = useState<Folder[]>([]); const [folders, setFolders] = useState<Folder[]>([]);
const t = useTranslations("translator.add_to_folder"); const t = useTranslations("translator.add_to_folder");
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
const userId = Number(session.data!.user!.id); if (!session) return;
const userId = session.user.id;
getFoldersByUserId(userId) getFoldersByUserId(userId)
.then(setFolders) .then(setFolders)
.then(() => setLoading(false)); .then(() => setLoading(false));
}, [session.data]); }, [session]);
if (session.status !== "authenticated") {
if (!session) {
return ( return (
<div className="fixed left-0 top-0 z-50 w-screen h-screen bg-black/50 flex justify-center items-center"> <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"> <Container className="p-6">

View File

@@ -1,13 +1,13 @@
import Container from "@/components/cards/Container"; import Container from "@/components/cards/Container";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Folder } from "../../../../generated/prisma/browser"; import { Folder } from "../../../../generated/prisma/browser";
import { getFoldersByUserId } from "@/lib/actions/services/folderService"; import { getFoldersByUserId } from "@/lib/server/services/folderService";
import LightButton from "@/components/buttons/LightButton"; import LightButton from "@/components/buttons/LightButton";
import { Folder as Fd } from "lucide-react"; import { Folder as Fd } from "lucide-react";
interface FolderSelectorProps { interface FolderSelectorProps {
setSelectedFolderId: (id: number) => void; setSelectedFolderId: (id: number) => void;
userId: number; userId: string;
cancel: () => void; cancel: () => void;
} }

View File

@@ -17,18 +17,16 @@ import {
genIPA, genIPA,
genLocale, genLocale,
genTranslation, genTranslation,
} from "@/lib/actions/translatorActions"; } from "@/lib/server/translatorActions";
import { toast } from "sonner"; import { toast } from "sonner";
import FolderSelector from "./FolderSelector"; import FolderSelector from "./FolderSelector";
import { useSession } from "next-auth/react"; import { createPair } from "@/lib/server/services/pairService";
import { createPair } from "@/lib/actions/services/pairService";
import { shallowEqual } from "@/lib/utils"; import { shallowEqual } from "@/lib/utils";
import { authClient } from "@/lib/auth-client";
export default function TranslatorPage() { export default function TranslatorPage() {
const t = useTranslations("translator"); const t = useTranslations("translator");
const session = useSession();
const taref = useRef<HTMLTextAreaElement>(null); const taref = useRef<HTMLTextAreaElement>(null);
const [lang, setLang] = useState<string>("chinese"); const [lang, setLang] = useState<string>("chinese");
const [tresult, setTresult] = useState<string>(""); const [tresult, setTresult] = useState<string>("");
@@ -49,8 +47,9 @@ export default function TranslatorPage() {
}); });
const [autoSave, setAutoSave] = useState(false); const [autoSave, setAutoSave] = useState(false);
const [autoSaveFolderId, setAutoSaveFolderId] = useState<number | null>(null); const [autoSaveFolderId, setAutoSaveFolderId] = useState<number | null>(null);
const { data: session } = authClient.useSession();
useEffect(()=>{ useEffect(() => {
setHistory(tlso.get()); setHistory(tlso.get());
}, []); }, []);
@@ -310,7 +309,7 @@ export default function TranslatorPage() {
checked={autoSave} checked={autoSave}
onChange={(e) => { onChange={(e) => {
const checked = e.target.checked; const checked = e.target.checked;
if (checked === true && !(session.status === "authenticated")) { if (checked === true && !session) {
toast.warning("Please login to enable auto-save"); toast.warning("Please login to enable auto-save");
return; return;
} }
@@ -368,7 +367,7 @@ export default function TranslatorPage() {
)} )}
{autoSave && !autoSaveFolderId && ( {autoSave && !autoSaveFolderId && (
<FolderSelector <FolderSelector
userId={Number(session.data!.user!.id)} userId={session!.user.id as string}
cancel={() => setAutoSave(false)} cancel={() => setAutoSave(false)}
setSelectedFolderId={(id) => setAutoSaveFolderId(id)} setSelectedFolderId={(id) => setAutoSaveFolderId(id)}
/> />

View File

@@ -0,0 +1,4 @@
import { auth } from "@/auth";
import { toNextJsHandler } from "better-auth/next-js";
export const { POST, GET } = toNextJsHandler(auth);

View File

@@ -1,3 +0,0 @@
import { handlers } from "../../../../auth";
export const { GET, POST } = handlers;

View File

@@ -16,7 +16,7 @@ import {
deleteFolderById, deleteFolderById,
getFoldersWithTotalPairsByUserId, getFoldersWithTotalPairsByUserId,
renameFolderById, renameFolderById,
} from "@/lib/actions/services/folderService"; } from "@/lib/server/services/folderService";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -85,7 +85,7 @@ const FolderCard = ({ folder, refresh }: FolderProps) => {
); );
}; };
export default function FoldersClient({ userId }: { userId: number }) { export default function FoldersClient({ userId }: { userId: string }) {
const t = useTranslations("folders"); const t = useTranslations("folders");
const [folders, setFolders] = useState<(Folder & { total: number })[]>( const [folders, setFolders] = useState<(Folder & { total: number })[]>(
[], [],

View File

@@ -9,7 +9,7 @@ import {
createPair, createPair,
deletePairById, deletePairById,
getPairsByFolderId, getPairsByFolderId,
} from "@/lib/actions/services/pairService"; } from "@/lib/server/services/pairService";
import AddTextPairModal from "./AddTextPairModal"; import AddTextPairModal from "./AddTextPairModal";
import TextPairCard from "./TextPairCard"; import TextPairCard from "./TextPairCard";
import LightButton from "@/components/buttons/LightButton"; import LightButton from "@/components/buttons/LightButton";

View File

@@ -1,6 +1,6 @@
import { Edit, Trash2 } from "lucide-react"; import { Edit, Trash2 } from "lucide-react";
import { TextPair } from "./InFolder"; import { TextPair } from "./InFolder";
import { updatePairById } from "@/lib/actions/services/pairService"; import { updatePairById } from "@/lib/server/services/pairService";
import { useState } from "react"; import { useState } from "react";
import UpdateTextPairModal from "./UpdateTextPairModal"; import UpdateTextPairModal from "./UpdateTextPairModal";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";

View File

@@ -1,22 +1,23 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import InFolder from "./InFolder"; import InFolder from "./InFolder";
import { getUserIdByFolderId } from "@/lib/actions/services/folderService"; import { getUserIdByFolderId } from "@/lib/server/services/folderService";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { headers } from "next/headers";
export default async function FoldersPage({ export default async function FoldersPage({
params, params,
}: { }: {
params: Promise<{ folder_id: number }>; params: Promise<{ folder_id: number; }>;
}) { }) {
const session = await auth(); const session = await auth.api.getSession({ headers: await headers() });
const { folder_id } = await params; const { folder_id } = await params;
const t = await getTranslations("folder_id"); const t = await getTranslations("folder_id");
if (!folder_id) { if (!folder_id) {
redirect("/folders"); redirect("/folders");
} }
if (!session?.user?.id) redirect(`/login?redirect=/folders/${folder_id}`); if (!session) redirect(`/login?redirect=/folders/${folder_id}`);
if ((await getUserIdByFolderId(Number(folder_id))) !== Number(session.user.id)) { if ((await getUserIdByFolderId(folder_id)) !== session.user.id) {
return <p>{t("unauthorized")}</p>; return <p>{t("unauthorized")}</p>;
} }
return <InFolder folderId={Number(folder_id)} />; return <InFolder folderId={Number(folder_id)} />;

View File

@@ -1,8 +1,11 @@
import { auth } from "@/auth"; import { auth } from "@/auth";
import FoldersClient from "./FoldersClient"; import FoldersClient from "./FoldersClient";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { headers } from "next/headers";
export default async function FoldersPage() { export default async function FoldersPage() {
const session = await auth(); const session = await auth.api.getSession(
if (!session?.user?.id) redirect(`/login?redirect=/folders`); { headers: await headers() }
return <FoldersClient userId={Number(session.user.id)} />; );
if (!session) redirect(`/signin?redirect=/folders`);
return <FoldersClient userId={session.user.id} />;
} }

View File

@@ -2,7 +2,6 @@ import type { Metadata } from "next";
import "./globals.css"; import "./globals.css";
import type { Viewport } from "next"; import type { Viewport } from "next";
import { NextIntlClientProvider } from "next-intl"; import { NextIntlClientProvider } from "next-intl";
import SessionWrapper from "@/components/SessionWrapper";
import { Navbar } from "@/components/Navbar"; import { Navbar } from "@/components/Navbar";
import { Toaster } from "sonner"; import { Toaster } from "sonner";
@@ -22,16 +21,14 @@ export default async function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<SessionWrapper> <html lang="en">
<html lang="en"> <body className={`antialiased`}>
<body className={`antialiased`}> <NextIntlClientProvider>
<NextIntlClientProvider> <Navbar></Navbar>
<Navbar></Navbar> {children}
{children} <Toaster />
<Toaster /> </NextIntlClientProvider>
</NextIntlClientProvider> </body>
</body> </html>
</html>
</SessionWrapper>
); );
} }

View File

@@ -1,44 +0,0 @@
"use client";
import LightButton from "@/components/buttons/LightButton";
import { Center } from "@/components/Center";
import IMAGES from "@/config/images";
import { signIn, useSession } from "next-auth/react";
import Image from "next/image";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect } from "react";
import { useTranslations } from "next-intl";
export default function LoginPage() {
const session = useSession();
const router = useRouter();
const searchParams = useSearchParams();
const t = useTranslations("login");
useEffect(() => {
if (session.status === "authenticated") {
router.push(searchParams.get("redirect") || "/");
}
}, [session.status, router, searchParams]);
return (
<Center>
{session.status === "loading" ? (
<div>{t("loading")}</div>
) : (
<LightButton
className="flex flex-row p-2 gap-2"
onClick={() => signIn("github")}
>
<Image
src={IMAGES.github_mark}
alt="GitHub Logo"
width={32}
height={32}
/>
<span>{t("githubLogin")}</span>
</LightButton>
)}
</Center>
);
}

View File

@@ -1,10 +1,32 @@
import { useTranslations } from "next-intl"; import { getTranslations } from "next-intl/server";
import Link from "next/link"; import Link from "next/link";
export default function HomePage() { interface LinkAreaProps {
const t = useTranslations("home"); href: string;
function TopArea() { name: string;
return ( description: string;
color: string;
}
function LinkArea({ href, name, description, color }: LinkAreaProps) {
return (
<Link
href={href}
style={{ backgroundColor: color }}
className={`h-32 md:h-64 flex md:justify-center items-center`}
>
<div className="text-white m-8">
<h1 className="md:text-4xl text-3xl">{name}</h1>
<p className="md:text-xl">{description}</p>
</div>
</Link>
);
}
export default async function HomePage() {
const t = await getTranslations("home");
return (
<>
<div className="bg-[#35786f] text-white w-full min-h-[75dvh] flex justify-center items-center"> <div className="bg-[#35786f] text-white w-full min-h-[75dvh] flex justify-center items-center">
<div className="mb-16 mx-16 md:mx-0 md:max-w-[60dvw]"> <div className="mb-16 mx-16 md:mx-0 md:max-w-[60dvw]">
<h1 className="text-6xl md:text-9xl mb-8 font-extrabold"> <h1 className="text-6xl md:text-9xl mb-8 font-extrabold">
@@ -13,37 +35,19 @@ export default function HomePage() {
<p className="text-2xl md:text-5xl font-medium">{t("description")}</p> <p className="text-2xl md:text-5xl font-medium">{t("description")}</p>
</div> </div>
</div> </div>
); <div className="w-full flex justify-center font-serif items-center flex-col min-h-64 h-[25vdh]">
} <p className="text-3xl">{t("fortune.quote")}</p>
interface LinkAreaProps { <cite className="text-[#e9b353] text-xl">{t("fortune.author")}</cite>
href: string; </div>
name: string; <div className="bg-[#bbbbbb] w-full flex justify-center items-center flex-col h-32">
description: string; <div className="w-0 h-0 border-l-40 border-r-40 border-t-30 border-l-transparent border-r-transparent border-t-white"></div>
color: string; </div>
} <div className="w-full grid grid-cols-1 grid-rows-6 md:grid-cols-3"><LinkArea
function LinkArea({ href, name, description, color }: LinkAreaProps) { href="/translator"
return ( name={t("translator.name")}
<Link description={t("translator.description")}
href={href} color="#a56068"
style={{ backgroundColor: color }} ></LinkArea>
className={`h-32 md:h-64 flex md:justify-center items-center`}
>
<div className="text-white m-8">
<h1 className="md:text-4xl text-3xl">{name}</h1>
<p className="md:text-xl">{description}</p>
</div>
</Link>
);
}
function LinkGrid() {
return (
<div className="w-full grid grid-cols-1 grid-rows-6 md:grid-cols-3">
<LinkArea
href="/translator"
name={t("translator.name")}
description={t("translator.description")}
color="#a56068"
></LinkArea>
<LinkArea <LinkArea
href="/text-speaker" href="/text-speaker"
name={t("textSpeaker.name")} name={t("textSpeaker.name")}
@@ -75,29 +79,6 @@ export default function HomePage() {
color="#cab48a" color="#cab48a"
></LinkArea> ></LinkArea>
</div> </div>
);
}
function Fortune() {
return (
<div className="w-full flex justify-center font-serif items-center flex-col min-h-64 h-[25vdh]">
<p className="text-3xl">{t("fortune.quote")}</p>
<cite className="text-[#e9b353] text-xl">{t("fortune.author")}</cite>
</div>
);
}
function Explore() {
return (
<div className="bg-[#bbbbbb] w-full flex justify-center items-center flex-col h-32">
<div className="w-0 h-0 border-l-40 border-r-40 border-t-30 border-l-transparent border-r-transparent border-t-white"></div>
</div>
);
}
return (
<>
<TopArea></TopArea>
<Fortune></Fortune>
<Explore></Explore>
<LinkGrid></LinkGrid>
</> </>
); );
} }

View File

@@ -0,0 +1,20 @@
"use client";
import LightButton from "@/components/buttons/LightButton";
import { authClient } from "@/lib/auth-client";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
export default function LogoutButton() {
const t = useTranslations("profile");
const router = useRouter();
return <LightButton onClick={async () => {
authClient.signOut({
fetchOptions: {
onSuccess: () => {
router.push("/login?redirect=/profile");
}
}
});
}}> {t("logout")}</LightButton >;
}

View File

@@ -1,43 +1,40 @@
"use client";
import { signOut, useSession } from "next-auth/react";
import { usePathname, useRouter } from "next/navigation";
import Image from "next/image"; import Image from "next/image";
import { useEffect } from "react";
import { Center } from "@/components/Center"; import { Center } from "@/components/Center";
import Container from "@/components/cards/Container"; import Container from "@/components/cards/Container";
import LightButton from "@/components/buttons/LightButton"; import { auth } from "@/auth";
import { useTranslations } from "next-intl"; import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation";
import { headers } from "next/headers";
import LogoutButton from "./LogoutButton";
export default function MePage() { export default async function ProfilePage() {
const session = useSession(); const t = await getTranslations("profile");
const router = useRouter();
const pathname = usePathname();
const t = useTranslations("profile");
useEffect(() => { const session = await auth.api.getSession({ headers: await headers() });
if (session.status !== "authenticated") {
router.push(`/login?redirect=${encodeURIComponent(pathname)}`); if (!session) {
} redirect("/signin?redirect=/profile");
}, [session.status, router, pathname]); }
console.log(JSON.stringify(session, null, 2));
return ( return (
<Center> <Center>
<Container className="p-6"> <Container className="p-6">
<h1>{t("myProfile")}</h1> <h1>{t("myProfile")}</h1>
{(session.data?.user?.image as string) && ( {(session.user.image) && (
<Image <Image
width={64} width={64}
height={64} height={64}
alt="User Avatar" alt="User Avatar"
src={session.data?.user?.image as string} src={session.user.image as string}
className="rounded-4xl" className="rounded-4xl"
></Image> ></Image>
)} )}
<p>{session.data?.user?.name}</p> <p>{session.user.name}</p>
<p>{t("email", { email: session.data!.user!.email as string })}</p> <p>{t("email", { email: session.user.email })}</p>
<LightButton onClick={signOut}>{t("logout")}</LightButton> <LogoutButton />
</Container> </Container>
</Center> </Center>
); );
} };

23
src/app/signin/page.tsx Normal file
View File

@@ -0,0 +1,23 @@
import LightButton from "@/components/buttons/LightButton";
import { signInAction } from "@/lib/actions/auth";
import Link from "next/link";
export default function SignInPage() {
return (
<div>
<h1>Sign In</h1>
<form action={signInAction}>
<input type="email"
name="email"
placeholder="Email"
required />
<input type="password"
name="password"
placeholder="Password"
required />
<LightButton type="submit">Sign In</LightButton>
</form>
<Link href={"/signup"}>Do not have an account? Sign up!</Link>
</div>
);
}

33
src/app/signup/page.tsx Normal file
View File

@@ -0,0 +1,33 @@
"use client";
import LightButton from "@/components/buttons/LightButton";
import { signUpAction } from "@/lib/actions/auth";
import Link from "next/link";
export default function SignInPage() {
return (
<div>
<h1>Sign Up</h1>
<form action={signUpAction}>
<input type="text"
name="name"
placeholder="Name"
required
/>
<input type="email"
name="email"
placeholder="Email"
required
/>
<input type="password"
name="password"
placeholder="Password"
required
/>
<LightButton type="submit">Sign Up</LightButton>
</form>
<Link href={"/signin"}>Already have an account? Sign in!</Link>
</div>
);
}

View File

@@ -1,30 +1,20 @@
import NextAuth from "next-auth"; import { betterAuth } from "better-auth";
import GitHub from "next-auth/providers/github"; import { prismaAdapter } from "better-auth/adapters/prisma";
import { createUserIfNotExists, getUserIdByEmail } from "./lib/actions/services/userService"; import { nextCookies } from "better-auth/next-js";
import prisma from "./lib/db";
export const { handlers, auth, signIn, signOut } = NextAuth({ export const auth = betterAuth({
providers: [ database: prismaAdapter(prisma, {
GitHub({ provider: "postgresql"
clientId: process.env.GITHUB_CLIENT_ID!, }),
clientSecret: process.env.GITHUB_CLIENT_SECRET!, emailAndPassword: {
}), enabled: true
], },
socialProviders: {
callbacks: { github: {
async signIn({ user }) { clientId: process.env.GITHUB_CLIENT_ID as string,
if (!user.email) return false; clientSecret: process.env.GITHUB_CLIENT_SECRET as string
await createUserIfNotExists(user.email, user.name);
return true
},
async session({ session }) {
if (session.user?.email) {
const userId = await getUserIdByEmail(session.user.email);
if (userId) {
session.user.id = userId.toString();
}
}
return session;
}, },
}, },
plugins: [nextCookies()]
}); });

View File

@@ -0,0 +1,46 @@
"use client";
import IMAGES from "@/config/images";
import IconClick from "./IconClick";
import { useState } from "react";
import LightButton from "./buttons/LightButton";
export default function LanguageSettings() {
const [showLanguageMenu, setShowLanguageMenu] = useState(false);
const handleLanguageClick = () => {
setShowLanguageMenu((prev) => !prev);
};
const setLocale = async (locale: string) => {
document.cookie = `locale=${locale}`;
window.location.reload();
};
return (
<>
<IconClick
src={IMAGES.language_white}
alt="language"
disableOnHoverBgChange={true}
onClick={handleLanguageClick}
></IconClick>
<div className="relative">
{showLanguageMenu && (
<div>
<div className="absolute top-10 right-0 rounded-md shadow-md flex flex-col gap-2">
<LightButton
className="w-full"
onClick={() => setLocale("en-US")}
>
English
</LightButton>
<LightButton
className="w-full"
onClick={() => setLocale("zh-CN")}
>
</LightButton>
</div>
</div>
)}
</div></>
);
}

View File

@@ -1,26 +1,18 @@
"use client";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import { useTranslations } from "next-intl";
import IconClick from "./IconClick";
import IMAGES from "@/config/images"; import IMAGES from "@/config/images";
import { useState } from "react"; import { Folder, Home } from "lucide-react";
import LightButton from "./buttons/LightButton"; import LanguageSettings from "./LanguageSettings";
import { useSession } from "next-auth/react"; import { auth } from "@/auth";
import { Folder, Home, LoaderCircle } from "lucide-react"; import { headers } from "next/headers";
import { getTranslations } from "next-intl/server";
export async function Navbar() {
const t = await getTranslations("navbar");
const session = await auth.api.getSession({
headers: await headers()
});
export function Navbar() {
const t = useTranslations("navbar");
const [showLanguageMenu, setShowLanguageMenu] = useState(false);
const handleLanguageClick = () => {
setShowLanguageMenu((prev) => !prev);
};
const setLocale = async (locale: string) => {
document.cookie = `locale=${locale}`;
window.location.reload();
};
const session = useSession();
return ( return (
<div className="flex justify-between items-center w-full h-16 px-8 bg-[#35786f] text-white"> <div className="flex justify-between items-center w-full h-16 px-8 bg-[#35786f] text-white">
<Link href={"/"} className="text-xl border-b hidden md:block"> <Link href={"/"} className="text-xl border-b hidden md:block">
@@ -41,47 +33,21 @@ export function Navbar() {
height={24} height={24}
/> />
</Link> </Link>
<div className="relative"> <LanguageSettings />
{showLanguageMenu && (
<div>
<div className="absolute top-10 right-0 rounded-md shadow-md flex flex-col gap-2">
<LightButton
className="w-full"
onClick={() => setLocale("en-US")}
>
English
</LightButton>
<LightButton
className="w-full"
onClick={() => setLocale("zh-CN")}
>
</LightButton>
</div>
</div>
)}
<IconClick
src={IMAGES.language_white}
alt="language"
disableOnHoverBgChange={true}
onClick={handleLanguageClick}
></IconClick>
</div>
<Link href="/folders" className="md:block hidden"> <Link href="/folders" className="md:block hidden">
{t("folders")} {t("folders")}
</Link> </Link>
<Link href="/folders" className="md:hidden block"> <Link href="/folders" className="md:hidden block">
<Folder /> <Folder />
</Link> </Link>
{session?.status === "authenticated" && ( {
<div className="flex gap-2"> (() => {
<Link href="/profile">{t("profile")}</Link> return session &&
</div> <Link href="/profile">{t("profile")}</Link>
)} || <Link href="/signin">{t("sign_in")}</Link>;
{session?.status === "unauthenticated" && (
<Link href="/login">{t("login")}</Link> })()
)} }
{session?.status === "loading" && <LoaderCircle />}
<Link href="/changelog.txt">{t("about")}</Link> <Link href="/changelog.txt">{t("about")}</Link>
<Link <Link
className="hidden md:block" className="hidden md:block"

View File

@@ -1,11 +0,0 @@
"use client";
import { SessionProvider } from "next-auth/react";
export default function SessionWrapper({
children,
}: {
children: React.ReactNode;
}) {
return <SessionProvider>{children}</SessionProvider>;
}

View File

@@ -1,5 +1,3 @@
"use client";
interface ContainerProps { interface ContainerProps {
children?: React.ReactNode; children?: React.ReactNode;
className?: string; className?: string;

43
src/lib/actions/auth.ts Normal file
View File

@@ -0,0 +1,43 @@
"use server";
import { auth } from "@/auth";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
export async function signUpAction(formData: FormData) {
const email = formData.get("email") as string;
const name = formData.get("name") as string;
const password = formData.get("password") as string;
await auth.api.signUpEmail({
body: {
email,
password,
name
}
});
redirect("/");
}
export async function signInAction(formData: FormData) {
const email = formData.get("email") as string;
const password = formData.get("password") as string;
await auth.api.signInEmail({
body: {
email,
password,
}
});
redirect("/");
}
export async function signOutAction() {
await auth.api.signOut({
headers: await headers()
});
redirect("/login");
}

5
src/lib/auth-client.ts Normal file
View File

@@ -0,0 +1,5 @@
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
baseURL: process.env.BETTER_AUTH_URL as string
});

View File

@@ -16,7 +16,7 @@ export const getLocalStorageOperator = <T extends z.ZodTypeAny>(
try { try {
const item = globalThis.localStorage.getItem(key); const item = globalThis.localStorage.getItem(key);
if (!item) return []; if (!item) return [] as z.infer<T>;
const rawData = JSON.parse(item) as z.infer<T>; const rawData = JSON.parse(item) as z.infer<T>;
const result = schema.safeParse(rawData); const result = schema.safeParse(rawData);
@@ -28,11 +28,11 @@ export const getLocalStorageOperator = <T extends z.ZodTypeAny>(
"Invalid data structure in localStorage:", "Invalid data structure in localStorage:",
result.error, result.error,
); );
return []; return [] as z.infer<T>;
} }
} catch (e) { } catch (e) {
console.error(`Failed to parse ${key} data:`, e); console.error(`Failed to parse ${key} data:`, e);
return []; return [] as z.infer<T>;
} }
}, },
set: (data: z.infer<T>) => { set: (data: z.infer<T>) => {

View File

@@ -3,7 +3,7 @@
import { FolderCreateInput, FolderUpdateInput } from "../../../../generated/prisma/models"; import { FolderCreateInput, FolderUpdateInput } from "../../../../generated/prisma/models";
import prisma from "../../db"; import prisma from "../../db";
export async function getFoldersByUserId(userId: number) { export async function getFoldersByUserId(userId: string) {
const folders = await prisma.folder.findMany({ const folders = await prisma.folder.findMany({
where: { where: {
userId: userId, userId: userId,
@@ -23,7 +23,7 @@ export async function renameFolderById(id: number, newName: string) {
}); });
} }
export async function getFoldersWithTotalPairsByUserId(userId: number) { export async function getFoldersWithTotalPairsByUserId(userId: string) {
const folders = await prisma.folder.findMany({ const folders = await prisma.folder.findMany({
where: { userId }, where: { userId },
include: { include: {

View File

@@ -20,3 +20,130 @@ export function shallowEqual<T extends object>(obj1: T, obj2: T): boolean {
return true; return true;
} }
export class SeededRandom {
private seed: number;
private readonly m: number = 0x80000000; // 2^31
private readonly a: number = 1103515245;
private readonly c: number = 12345;
constructor(seed?: number) {
this.seed = seed || Date.now();
}
/**
* 生成0-1之间的随机数
* @returns 0到1之间的随机浮点数
*/
next(): number {
this.seed = (this.a * this.seed + this.c) % this.m;
return this.seed / (this.m - 1);
}
/**
* 生成指定范围的随机整数
* @param min 最小值(包含)
* @param max 最大值(包含)
* @returns [min, max] 范围内的随机整数
*/
nextInt(min: number, max: number): number {
if (min > max) {
throw new Error('min must be less than or equal to max');
}
return Math.floor(this.next() * (max - min + 1)) + min;
}
/**
* 生成指定范围的随机浮点数
* @param min 最小值(包含)
* @param max 最大值(不包含)
* @returns [min, max) 范围内的随机浮点数
*/
nextFloat(min: number, max: number): number {
if (min >= max) {
throw new Error('min must be less than max');
}
return this.next() * (max - min) + min;
}
/**
* 生成固定长度的随机数序列
* @param length 序列长度
* @param min 最小值
* @param max 最大值
* @param type 生成类型:'integer' 或 'float'
* @returns 随机数数组
*/
generateSequence(
length: number,
min: number = 0,
max: number = 1,
type: 'integer' | 'float' = 'integer'
): number[] {
const sequence: number[] = [];
for (let i = 0; i < length; i++) {
if (type === 'integer') {
sequence.push(this.nextInt(min, max));
} else {
sequence.push(this.nextFloat(min, max));
}
}
return sequence;
}
/**
* 重置种子
* @param newSeed 新的种子值
*/
reset(newSeed?: number): void {
this.seed = newSeed || Date.now();
}
/**
* 获取当前种子值
* @returns 当前种子
*/
getSeed(): number {
return this.seed;
}
/**
* 生成随机布尔值
* @param probability 为 true 的概率,默认 0.5
* @returns 随机布尔值
*/
nextBoolean(probability: number = 0.5): boolean {
if (probability < 0 || probability > 1) {
throw new Error('probability must be between 0 and 1');
}
return this.next() < probability;
}
/**
* 从数组中随机选择元素
* @param array 源数组
* @returns 随机选择的元素
*/
choice<T>(array: T[]): T {
if (array.length === 0) {
throw new Error('array cannot be empty');
}
const index = this.nextInt(0, array.length - 1);
return array[index];
}
/**
* 打乱数组Fisher-Yates 洗牌算法)
* @param array 要打乱的数组
* @returns 打乱后的新数组
*/
shuffle<T>(array: T[]): T[] {
const shuffled = [...array];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = this.nextInt(0, i);
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return shuffled;
}
}