2025-09-16

This commit is contained in:
2025-09-16 18:35:23 +08:00
parent d28ea6a7be
commit 09c6bb2a0d
15 changed files with 802 additions and 97 deletions

208
deploy.sh Normal file
View File

@@ -0,0 +1,208 @@
#!/bin/bash
# Next.js 项目部署脚本
set -e # 遇到任何错误立即退出
# ===== 配置区域 =====
SERVER_IP="43.156.84.214"
SERVER_USER="ubuntu"
PROJECT_NAME="learn-languages"
LOCAL_PROJECT_DIR="/home/goddonebianu/Code/learn-languages"
REMOTE_PROJECT_DIR="/home/$SERVER_USER/$PROJECT_NAME"
BRANCH="main" # 要部署的分支
NODE_ENV="production"
PORT="3000" # 应用运行端口
# ===================
# 颜色输出函数
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
log_info() {
echo -e "${GREEN}[INFO]${NC} $1"
}
log_warn() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# 检查本地更改
check_local_changes() {
log_info "检查本地更改..."
if ! git diff --quiet; then
log_warn "发现未提交的更改,建议先提交更改"
read -p "继续部署?(y/N): " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
exit 1
fi
fi
}
# 构建项目
build_project() {
log_info "开始构建项目..."
cd "$LOCAL_PROJECT_DIR"
# 安装依赖
if ! npm ci --only=production; then
log_error "npm install 失败"
exit 1
fi
# 构建项目
if ! npm run build; then
log_error "构建失败"
exit 1
fi
log_info "项目构建成功"
}
# 创建部署包
create_deployment_package() {
log_info "创建部署包..."
local temp_dir=$(mktemp -d)
local package_name="${PROJECT_NAME}-$(date +%Y%m%d-%H%M%S).tar.gz"
# 复制需要的文件
cp -r package.json package-lock.json next.config.js* .next public "$temp_dir/"
# 如果有环境文件也复制
if [ -f .env.production ]; then
cp .env.production "$temp_dir/"
fi
# 创建压缩包
cd "$temp_dir"
tar -czf "/tmp/$package_name" .
echo "$package_name"
}
# 部署到服务器
deploy_to_server() {
local package_name=$1
log_info "开始部署到服务器..."
# 上传部署包
if ! scp "/tmp/$package_name" $SERVER_USER@$SERVER_IP:/tmp/; then
log_error "文件上传失败"
exit 1
fi
# 执行远程部署命令
ssh $SERVER_USER@$SERVER_IP << EOF
set -e
echo "在服务器上执行部署..."
# 创建备份目录
BACKUP_DIR="$REMOTE_PROJECT_DIR-backup-\$(date +%Y%m%d-%H%M%S)"
if [ -d "$REMOTE_PROJECT_DIR" ]; then
mkdir -p "\$BACKUP_DIR"
cp -r "$REMOTE_PROJECT_DIR"/* "\$BACKUP_DIR/" || true
fi
# 创建项目目录
mkdir -p "$REMOTE_PROJECT_DIR"
# 解压新版本
tar -xzf "/tmp/$package_name" -C "$REMOTE_PROJECT_DIR"
# 清理临时文件
rm -f "/tmp/$package_name"
# 安装生产依赖
cd "$REMOTE_PROJECT_DIR"
npm ci --only=production
echo "部署文件准备完成"
EOF
log_info "文件部署完成"
}
# 重启服务
restart_service() {
log_info "重启服务..."
ssh $SERVER_USER@$SERVER_IP << EOF
set -e
cd "$REMOTE_PROJECT_DIR"
# 使用 PM2 重启应用
if command -v pm2 &> /dev/null; then
# 如果应用已经在运行,重新加载
if pm2 list | grep -q "$PROJECT_NAME"; then
pm2 reload $PROJECT_NAME --update-env
else
# 第一次启动
export PORT=$PORT
pm2 start npm --name "$PROJECT_NAME" -- start
pm2 save
pm2 startup 2>/dev/null || true
fi
else
# 如果没有 PM2直接启动不推荐生产环境
echo "警告: 未找到 PM2直接启动进程"
export PORT=$PORT
npm start &
fi
echo "等待服务启动..."
sleep 5
# 检查服务是否正常运行
if curl -f http://localhost:$PORT > /dev/null 2>&1; then
echo "服务启动成功!"
else
echo "服务启动可能有问题,请检查日志"
exit 1
fi
EOF
log_info "服务重启完成"
}
# 清理工作
cleanup() {
log_info "清理临时文件..."
rm -f "/tmp/${PROJECT_NAME}-*.tar.gz" 2>/dev/null || true
}
# 主部署流程
main() {
log_info "开始部署 $PROJECT_NAME$SERVER_IP"
# 检查是否在项目目录
if [ ! -f "$LOCAL_PROJECT_DIR/package.json" ]; then
log_error "请在正确的 Next.js 项目目录中运行"
exit 1
fi
check_local_changes
build_project
local package_name=$(create_deployment_package)
deploy_to_server "$package_name"
restart_service
cleanup
log_info "${GREEN}部署成功完成!${NC}"
log_info "应用地址: http://$SERVER_IP:$PORT"
}
# 错误处理
trap 'log_error "部署过程中断"; cleanup; exit 1' INT TERM
trap 'cleanup' EXIT
# 执行主函数
main "$@"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -1,103 +1,16 @@
import Image from "next/image";
function Link(
{href, label}: {href: string, label: string}
) {
return (
<a className="border-2 border-black m-1 p-5 rounded font-bold hover:bg-gray-200" href={href}>{label}</a>
)
}
export default function Home() {
return (
<div className="font-sans grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20">
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={180}
height={38}
priority
/>
<ol className="font-mono list-inside list-decimal text-sm/6 text-center sm:text-left">
<li className="mb-2 tracking-[-.01em]">
Get started by editing{" "}
<code className="bg-black/[.05] dark:bg-white/[.06] font-mono font-semibold px-1 py-0.5 rounded">
src/app/page.tsx
</code>
.
</li>
<li className="tracking-[-.01em]">
Save and see your changes instantly.
</li>
</ol>
<div className="flex gap-4 items-center flex-col sm:flex-row">
<a
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={20}
height={20}
/>
Deploy now
</a>
<a
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Read our docs
</a>
</div>
</main>
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/file.svg"
alt="File icon"
width={16}
height={16}
/>
Learn
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/window.svg"
alt="Window icon"
width={16}
height={16}
/>
Examples
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/globe.svg"
alt="Globe icon"
width={16}
height={16}
/>
Go to nextjs.org
</a>
</footer>
<div className="w-[500px] m-auto mt-[100px] h-[300px]">
<Link href="/srt-player" label="srt-player"></Link>
<Link href="/word-board" label="word-board"></Link>
</div>
);
}

View File

@@ -0,0 +1,25 @@
'use client'
import { useRef, useState } from "react";
import UploadArea from "./UploadArea";
import VideoPanel from "./VideoPlayer/VideoPanel";
export default function AppCard() {
const videoRef = useRef<HTMLVideoElement>(null);
const [videoUrl, setVideoUrl] = useState<string | null>(null);
const [srtUrl, setSrtUrl] = useState<string | null>(null);
return (
<div className="min-w-[410px] max-h-[1000px] max-w-[1400px] flex items-center flex-col bg-gray-200 rounded shadow-2xl w-8/12 py-12">
<p className="text-4xl font-extrabold">SRT Video Player</p>
<VideoPanel
videoUrl={videoUrl}
srtUrl={srtUrl}
ref={videoRef} />
<UploadArea
setVideoUrl={setVideoUrl}
setSrtUrl={setSrtUrl} />
</div>
);
}

View File

@@ -0,0 +1,7 @@
export default function Button({ label, onClick }: { label: string, onClick?: () => void }) {
return (
<button onClick={onClick} className="m-1 px-2 py-1 rounded bg-white shadow-2xs font-bold hover:bg-gray-300">
{label}
</button>
);
}

View File

@@ -0,0 +1,75 @@
import { useRef, useState } from "react";
import Button from "./Button";
export default function UploadArea(
{
setVideoUrl,
setSrtUrl
}: {
setVideoUrl: (url: string | null) => void;
setSrtUrl: (url: string | null) => void;
}
) {
const inputRef = useRef<HTMLInputElement>(null);
const [videoFile, setVideoFile] = useState<File | null>(null);
const [SrtFile, setSrtFile] = useState<File | null>(null);
const uploadVideo = () => {
const input = inputRef.current;
if (input) {
input.setAttribute('accept', 'video/*');
input.click();
input.onchange = () => {
const file = input.files?.[0];
if (file) {
setVideoFile(file);
setVideoUrl(URL.createObjectURL(file));
}
};
}
}
const uploadSRT = () => {
const input = inputRef.current;
if (input) {
input.setAttribute('accept', '.srt');
input.click();
input.onchange = () => {
const file = input.files?.[0];
if (file) {
setSrtFile(file);
setSrtUrl(URL.createObjectURL(file));
}
};
}
}
return (
<div className="flex flex-col">
<table className="border border-black border-collapse">
<thead>
<tr className="divide-x divide-black">
<th className="border border-black px-2 py-1">File Name</th>
<th className="border border-black px-2 py-1">Type</th>
<th className="border border-black px-2 py-1">Size</th>
<th className="border border-black px-2 py-1">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-black">
<tr className="divide-x divide-black">
<td className="px-2 py-1">{videoFile?.name}</td>
<td className="px-2 py-1">Video</td>
<td className="px-2 py-1">{videoFile ? (videoFile.size / 1024 / 1024).toFixed(2) + 'MB' : null}</td>
<td className="px-2 py-1"><Button label="Upload" onClick={uploadVideo} /></td>
</tr>
<tr className="divide-x divide-black">
<td className="px-2 py-1">{SrtFile?.name}</td>
<td className="px-2 py-1">SRT</td>
<td className="px-2 py-1">{SrtFile ? (SrtFile.size / 1024 / 1024).toFixed(2) + 'MB' : null}</td>
<td className="px-2 py-1"><Button label="Upload" onClick={uploadSRT} /></td>
</tr>
</tbody>
</table>
<input type="file" className="hidden" ref={inputRef} />
</div>
)
}

View File

@@ -0,0 +1,28 @@
export default function SubtitleDisplay({ subtitle }: { subtitle: string }) {
const words = subtitle.match(/\b[\w']+(?:-[\w']+)*\b/g) || [];
const goto = (url: string) => {
window.open(url, '_blank');
}
const inspect = (word: string) => {
return () => {
word = word.toLowerCase();
goto(`https://www.youdao.com/result?word=${word}&lang=en`);
}
}
let i = 0;
return (
<div className="subtitle overflow-y-auto h-16 mt-2 break-words bg-black/50 font-sans text-white text-center text-2xl">
{
words.map((v) => (
<span
onClick={inspect(v)}
key={i++}
className="hover:bg-gray-700 hover:underline hover:cursor-pointer"
>
{v + ' '}
</span>
))
}
</div>
);
}

View File

@@ -0,0 +1,177 @@
import { useState, useRef, forwardRef, useEffect, KeyboardEvent, useCallback } from "react";
import Button from "../Button";
import { getIndex, getNearistIndex, parseSrt } from "../../subtitle";
import SubtitleDisplay from "./SubtitleDisplay";
type VideoPanelProps = {
videoUrl: string | null;
srtUrl: string | null;
};
const VideoPanel = forwardRef<HTMLVideoElement, VideoPanelProps>((
{ videoUrl, srtUrl }, videoRef
) => {
videoRef = videoRef as React.RefObject<HTMLVideoElement>;
const [isPlaying, setIsPlaying] = useState<boolean>(false);
const [srtLength, setSrtLength] = useState<number>(0);
const [progress, setProgress] = useState<number>(-1);
const [autoPause, setAutoPause] = useState<boolean>(true);
const [spanText, setSpanText] = useState<string>('');
const [subtitle, setSubtitle] = useState<string>('');
const parsedSrtRef = useRef<{ start: number; end: number; text: string; }[] | null>(null);
const rafldRef = useRef<number>(0);
const ready = useRef({
'vid': false,
'sub': false,
'all': function () { return this.vid && this.sub }
});
const togglePlayPause = useCallback(() => {
if (!videoUrl) return;
const video = videoRef.current;
if (!video) return;
if (video.paused || video.currentTime === 0) {
video.play();
} else {
video.pause();
}
setIsPlaying(!video.paused);
}, [videoRef, videoUrl]);
useEffect(() => {
const cb = () => {
if (ready.current.all()) {
if (!parsedSrtRef.current) {
;
} else if (isPlaying) {
// 这里负责显示当前时间的字幕与自动暂停
const srt = parsedSrtRef.current;
const ct = videoRef.current?.currentTime as number;
const index = getIndex(srt, ct);
if (index !== null) {
setSubtitle(srt[index].text)
if (autoPause && ct >= (srt[index].end - 0.05) && ct < srt[index].end) {
videoRef.current!.currentTime = srt[index].start;
togglePlayPause();
}
} else {
setSubtitle('');
}
} else {
;
}
}
rafldRef.current = requestAnimationFrame(cb);
}
rafldRef.current = requestAnimationFrame(cb);
return () => {
cancelAnimationFrame(rafldRef.current);
}
}, [autoPause, isPlaying, togglePlayPause, videoRef]);
useEffect(() => {
if (videoUrl && videoRef.current) {
videoRef.current.src = videoUrl;
videoRef.current.load();
setIsPlaying(false);
ready.current['vid'] = true;
}
}, [videoRef, videoUrl]);
useEffect(() => {
if (srtUrl) {
fetch(srtUrl)
.then(response => response.text())
.then(data => {
parsedSrtRef.current = parseSrt(data);
setSrtLength(parsedSrtRef.current.length);
ready.current['sub'] = true;
});
}
}, [srtUrl]);
const timeUpdate = () => {
if (!parsedSrtRef.current || !videoRef.current) return;
const index = getIndex(parsedSrtRef.current, videoRef.current.currentTime);
if (!index) return;
setSpanText(`${index + 1}/${parsedSrtRef.current.length}`)
}
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
if (videoRef.current && parsedSrtRef.current) {
const newProgress = parseInt(e.target.value);
videoRef.current.currentTime = parsedSrtRef.current[newProgress]?.start || 0;
setProgress(newProgress);
}
};
const handleAutoPauseToggle = () => {
setAutoPause(!autoPause);
};
const next = () => {
if (!parsedSrtRef.current || !videoRef.current) return;
const i = getNearistIndex(parsedSrtRef.current, videoRef.current.currentTime);
if (i != null && i + 1 < parsedSrtRef.current.length) {
videoRef.current.currentTime = parsedSrtRef.current[i + 1].start;
videoRef.current.play();
setIsPlaying(true);
}
}
const previous = () => {
if (!parsedSrtRef.current || !videoRef.current) return;
const i = getNearistIndex(parsedSrtRef.current, videoRef.current.currentTime);
if (i != null && i - 1 >= 0) {
videoRef.current.currentTime = parsedSrtRef.current[i - 1].start;
videoRef.current.play();
setIsPlaying(true);
}
}
const restart = () => {
if (!parsedSrtRef.current || !videoRef.current) return;
const i = getNearistIndex(parsedSrtRef.current, videoRef.current.currentTime);
if (i != null && i >= 0) {
videoRef.current.currentTime = parsedSrtRef.current[i].start;
videoRef.current.play();
setIsPlaying(true);
}
}
const handleKeyDownEvent = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'n') {
next();
} else if (e.key === 'p') {
previous();
} else if (e.key === ' ') {
togglePlayPause();
} else if (e.key === 'r') {
restart();
} else if (e.key === 'a') {
; handleAutoPauseToggle();
}
}
return (
<div className="flex flex-col w-9/12" onKeyDown={handleKeyDownEvent}>
<video className="max-h-80" ref={videoRef} onTimeUpdate={timeUpdate}></video>
<SubtitleDisplay subtitle={subtitle}></SubtitleDisplay>
<div className="buttons">
<ul className="">
<Button label={isPlaying ? 'PAUSE' : 'PLAY'} onClick={togglePlayPause}></Button>
<Button label="NEXT" onClick={next}></Button>
<Button label="PREVIOUS" onClick={previous}></Button>
<Button label="RESTART" onClick={restart}></Button>
<Button label={`AUTOPAUSE(${autoPause ? 'Y' : 'N'})`} onClick={handleAutoPauseToggle}></Button>
</ul>
</div>
<input className="seekbar" type="range" min={0} max={srtLength} onChange={handleSeek} step={1} value={progress}></input>
<span>{spanText}</span>
</div>
);
});
VideoPanel.displayName = 'VideoPanel';
export default VideoPanel;

View File

@@ -0,0 +1,34 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "../globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "SRT Video Player",
description: "Practice spoken English",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
</body>
</html>
);
}

View File

@@ -0,0 +1,9 @@
import AppCard from "./components/AppCard";
export default function Home() {
return (
<div className="flex w-screen h-screen items-center justify-center">
<AppCard />
</div>
);
}

View File

@@ -0,0 +1,52 @@
export function parseSrt(data: string) {
const lines = data.split(/\r?\n/);
const result = [];
const re = new RegExp('(\\d{2}:\\d{2}:\\d{2},\\d{3})\\s*-->\\s*(\\d{2}:\\d{2}:\\d{2},\\d{3})');
let i = 0;
while (i < lines.length) {
if (!lines[i].trim()) { i++; continue; }
i++;
if (i >= lines.length) break;
const timeMatch = lines[i].match(re);
if (!timeMatch) { i++; continue; }
const start = toSeconds(timeMatch[1]);
const end = toSeconds(timeMatch[2]);
i++;
let text = '';
while (i < lines.length && lines[i].trim()) {
text += lines[i] + '\n';
i++;
}
result.push({ start, end, text: text.trim() });
i++;
}
return result;
}
export function getNearistIndex(srt: { start: number; end: number; text: string; }[], ct: number) {
for (let i = 0; i < srt.length; i++) {
const s = srt[i];
const l = ct - s.start >= 0;
const r = ct - s.end >= 0;
if (!(l || r)) return i - 1;
if (l && (!r)) return i;
}
}
export function getIndex(srt: { start: number; end: number; text: string; }[], ct: number) {
for (let i = 0; i < srt.length; i++) {
if (ct >= srt[i].start && ct <= srt[i].end) {
return i;
}
}
return null;
}
export function getSubtitle(srt: { start: number; end: number; text: string; }[], currentTime: number) {
return srt.find(sub => currentTime >= sub.start && currentTime <= sub.end) || null;
}
function toSeconds(timeStr: string): number {
const [h, m, s] = timeStr.replace(',', '.').split(':');
return parseFloat((parseInt(h) * 3600 + parseInt(m) * 60 + parseFloat(s)).toFixed(3));
}

View File

@@ -0,0 +1,7 @@
export default function Button({ label, onClick }: { label: string, onClick?: () => void }) {
return (
<button onClick={onClick} className="m-1 px-2 py-1 rounded bg-white shadow-2xs font-bold hover:bg-gray-300">
{label}
</button>
);
}

View File

@@ -0,0 +1,42 @@
'use client';
export default function WordBoard(
{ words }: {
words: [
{
word: string,
x: number,
y: number
}
]
}
) {
const inspect = (word: string) => {
const goto = (url: string) => {
window.open(url, '_blank');
}
return () => {
word = word.toLowerCase();
goto(`https://www.youdao.com/result?word=${word}&lang=en`);
}
}
return (
<div className="relative rounded bg-white w-[1000px] h-[600px]">
{words.map(
(v: {
word: string,
x: number,
y: number
}, i: number) => {
return (<span
style={{
left: `${Math.floor(v.x * (1000 - 18 * v.word.length))}px`,
top: `${Math.floor(v.y * (600 - 30))}px`,
}}
className={`select-none cursor-pointer absolute font-mono text-[30px] border-amber-100 border-1`}
key={i}
onClick={inspect(v.word)}>{v.word}</span>)
})}
</div>
)
}

View File

@@ -0,0 +1,34 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "../globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Word Board",
description: "Word board page",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
</body>
</html>
);
}

View File

@@ -0,0 +1,94 @@
'use client';
import WordBoard from "@/app/word-board/WordBoard";
import Button from "./Button";
import { useRef, useState } from "react";
interface Word {
word: string,
x: number,
y: number
}
export default function Home() {
const inputRef = useRef<HTMLInputElement>(null);
const inputFileRef = useRef<HTMLInputElement>(null);
const initialWords =
[
// 'apple',
// 'banana',
// 'cannon',
// 'desktop',
// 'kernel',
// 'system',
// 'programming',
// 'owe'
] as Array<string>;
const [words, setWords] = useState(
initialWords.map((v: string) => ({
'word': v,
'x': Math.random(),
'y': Math.random()
}))
);
const generateNewWord = (word: string) => {
return {
word: word,
x: Math.random(),
y: Math.random()
} as Word;
}
const insertWord = () => {
if (!inputRef.current) return;
const word = inputRef.current.value.trim();
if (word === '') return;
setWords([...words, generateNewWord(word)]);
inputRef.current.value = '';
}
const deleteWord = () => {
if (!inputRef.current) return;
const word = inputRef.current.value.trim();
if (word === '') return;
setWords(words.filter((v) => v.word !== word));
inputRef.current.value = '';
};
const importWords = () => {
inputFileRef.current?.click();
}
const exportWords = () => {
const blob = new Blob([JSON.stringify(words)], {
type: 'application/json'
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${Date.now()}.json`;
a.style.display = 'none';
a.click();
URL.revokeObjectURL(url);
}
const handleFileChange = () => {
const files = inputFileRef.current?.files;
if (files && files.length > 0) {
const reader = new FileReader();
reader.onload = () => {
if (reader.result && typeof reader.result === 'string')
setWords(JSON.parse(reader.result) as [Word]);
}
reader.readAsText(files[0]);
}
}
return (
<div className="p-5 my-10 mx-auto bg-gray-200 rounded shadow-2xl w-[1050px]">
<WordBoard words={words as [Word]} />
<div className="flex justify-center rounded mt-3 w-[1000px]">
<input ref={inputRef} placeholder="在此插入/删除单词" type="text" className="focus:outline-none border-b-2 border-black" />
<Button label="插入" onClick={insertWord}></Button>
<Button label="删除" onClick={deleteWord}></Button>
<Button label="导入" onClick={importWords}></Button>
<Button label="导出" onClick={exportWords}></Button>
<Button label="删光" onClick={()=>{setWords([] as Array<Word>)}}></Button>
</div>
<input type="file" ref={inputFileRef} className="hidden" accept="application/json" onChange={handleFileChange}></input>
</div>
);
}