Migrate project from typing_practiceweb

This commit is contained in:
2026-05-14 16:04:14 +08:00
parent dbc5425706
commit 0e87d5546d
225 changed files with 92811 additions and 0 deletions

View File

@@ -0,0 +1,343 @@
import React, { useState, useEffect } from 'react';
import { adminApi, CodeExample, PracticeLevel } from '../api/admin';
import {
Container,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Button,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
Select,
MenuItem,
FormControl,
InputLabel,
SelectChangeEvent,
FormHelperText,
} from '@mui/material';
interface EditingExample {
_id?: string;
title?: string;
content: string;
level: PracticeLevel | '';
description?: string;
difficulty?: number;
createdAt?: Date;
updatedAt?: Date;
}
const AdminCodeManager: React.FC = () => {
const [codeExamples, setCodeExamples] = useState<CodeExample[]>([]);
const [open, setOpen] = useState(false);
const [editingExample, setEditingExample] = useState<EditingExample>({
content: '',
level: '',
description: '',
difficulty: 1
});
const [isEditing, setIsEditing] = useState(false);
useEffect(() => {
fetchCodeExamples();
}, []);
const fetchCodeExamples = async () => {
try {
const data = await adminApi.getCodeExamples();
setCodeExamples(data);
} catch (error) {
console.error('Error fetching code examples:', error);
}
};
const handleOpen = (example?: CodeExample) => {
if (example) {
setEditingExample({
...example,
content: example.content,
level: example.level,
description: example.description || '',
difficulty: example.difficulty || 1
});
setIsEditing(true);
} else {
setEditingExample({
content: '',
level: '',
description: '',
difficulty: 1
});
setIsEditing(false);
}
setOpen(true);
};
const handleClose = () => {
setOpen(false);
setEditingExample({
content: '',
level: '',
description: '',
difficulty: 1
});
setIsEditing(false);
};
const handleSave = async () => {
try {
if (editingExample.level === '') {
alert('请选择代码类型');
return;
}
let processedContent = editingExample.content;
if (editingExample.level === 'keyword') {
processedContent = editingExample.content
.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0)
.join('\n');
} else {
processedContent = editingExample.content.trim();
}
const dataToSave = {
title: editingExample.title || '',
content: processedContent,
level: editingExample.level,
description: editingExample.description || '',
difficulty: editingExample.difficulty || 1
};
if (isEditing && editingExample._id) {
await adminApi.updateCodeExample(editingExample._id, dataToSave);
} else {
await adminApi.createCodeExample(dataToSave);
}
handleClose();
fetchCodeExamples();
} catch (error) {
console.error('Error saving code example:', error);
}
};
const handleDelete = async (id: string) => {
if (window.confirm('确定要删除这个代码示例吗?')) {
try {
await adminApi.deleteCodeExample(id);
fetchCodeExamples();
} catch (error) {
console.error('Error deleting code example:', error);
}
}
};
const getTemplateForLevel = (level: PracticeLevel): string => {
switch(level) {
case 'keyword':
return '请输入关键字,每行一个:\n\nfor\nwhile\nif\nelse\nreturn';
case 'basic':
return `// 基础算法示例 - 冒泡排序
void bubbleSort(int arr[], int n) {
for(int i = 0; i < n-1; i++) {
for(int j = 0; j < n-i-1; j++) {
if(arr[j] > arr[j+1]) {
int temp = arr[j];
arr[j] = arr[j+1];
arr[j+1] = temp;
}
}
}
}`;
case 'intermediate':
return `// 中级算法示例 - 快速排序
int partition(int arr[], int low, int high) {
int pivot = arr[high];
int i = (low - 1);
for(int j = low; j <= high - 1; j++) {
if(arr[j] < pivot) {
i++;
swap(&arr[i], &arr[j]);
}
}
swap(&arr[i + 1], &arr[high]);
return (i + 1);
}`;
case 'advanced':
return `// 高级算法示例 - 红黑树节点插入
void insertFixup(Node* k) {
Node* u;
while(k->parent->color == RED) {
if(k->parent == k->parent->parent->right) {
u = k->parent->parent->left;
if(u->color == RED) {
u->color = BLACK;
k->parent->color = BLACK;
k->parent->parent->color = RED;
k = k->parent->parent;
}
}
// ... 其余情况处理
}
root->color = BLACK;
}`;
}
};
const handleLevelChange = (event: SelectChangeEvent) => {
const newLevel = event.target.value as PracticeLevel;
setEditingExample({
...editingExample,
level: newLevel,
content: getTemplateForLevel(newLevel)
});
};
return (
<Container>
<Button
variant="contained"
color="primary"
onClick={() => handleOpen()}
style={{ margin: '20px 0' }}
>
</Button>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
</TableRow>
</TableHead>
<TableBody>
{codeExamples.map((example) => (
<TableRow key={example._id}>
<TableCell>{example.title}</TableCell>
<TableCell>{example.level}</TableCell>
<TableCell>
<Button onClick={() => handleOpen(example)}></Button>
<Button onClick={() => handleDelete(example._id)}></Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth>
<DialogTitle>
{isEditing ? '编辑代码示例' : '添加新代码示例'}
</DialogTitle>
<DialogContent>
<TextField
fullWidth
label="标题"
value={editingExample.title || ''}
onChange={(e) => setEditingExample({
...editingExample,
title: e.target.value
})}
margin="normal"
helperText="请输入算法名称,例如:'冒泡排序'"
/>
<TextField
fullWidth
label="描述"
value={editingExample.description || ''}
onChange={(e) => setEditingExample({
...editingExample,
description: e.target.value
})}
margin="normal"
multiline
rows={2}
helperText="简要描述算法的功能和用途"
/>
<FormControl fullWidth margin="normal">
<InputLabel></InputLabel>
<Select
value={editingExample.level || ''}
onChange={handleLevelChange}
label="代码类型"
>
<MenuItem value="keyword"></MenuItem>
<MenuItem value="basic"></MenuItem>
<MenuItem value="intermediate"></MenuItem>
<MenuItem value="advanced"></MenuItem>
</Select>
<FormHelperText>
{editingExample.level === 'keyword'
? '每个关键字占一行'
: '请输入一个完整的函数实现'}
</FormHelperText>
</FormControl>
{editingExample.level !== 'keyword' && (
<FormControl fullWidth margin="normal">
<InputLabel></InputLabel>
<Select
value={editingExample.difficulty || 1}
onChange={(e) => setEditingExample({
...editingExample,
difficulty: Number(e.target.value)
})}
label="难度等级"
>
{[1,2,3,4,5].map(level => (
<MenuItem key={level} value={level}>
{level} {level === 1 ? '(最简单)' : level === 5 ? '(最难)' : ''}
</MenuItem>
))}
</Select>
<FormHelperText></FormHelperText>
</FormControl>
)}
<TextField
fullWidth
label="代码内容"
multiline
rows={12}
value={editingExample.content || ''}
onChange={(e) => setEditingExample({
...editingExample,
content: e.target.value
})}
margin="normal"
placeholder={getTemplateForLevel(editingExample.level as PracticeLevel)}
helperText={
editingExample.level === 'keyword'
? '每行输入一个关键字'
: '请输入完整的函数实现,包含函数声明和实现'
}
/>
</DialogContent>
<DialogActions>
<Button onClick={handleClose}></Button>
<Button onClick={handleSave} color="primary"></Button>
</DialogActions>
</Dialog>
</Container>
);
};
export default AdminCodeManager;

View File

@@ -0,0 +1,90 @@
// src/components/AdminDashboard.tsx
import React, { useState } from 'react';
import {
Container,
Paper,
Tabs,
Tab,
Box,
Typography,
} from '@mui/material';
import AdminUserManager from './AdminUserManager';
import AdminCodeManager from './AdminCodeManager';
import AdminPracticeRecords from './AdminPracticeRecords';
import AdminOAuth2Manager from './AdminOAuth2Manager';
import AdminVocabularyManager from './AdminVocabularyManager';
interface TabPanelProps {
children?: React.ReactNode;
index: number;
value: number;
}
function TabPanel(props: TabPanelProps) {
const { children, value, index, ...other } = props;
return (
<div
role="tabpanel"
hidden={value !== index}
{...other}
>
{value === index && (
<Box sx={{ p: 3 }}>
{children}
</Box>
)}
</div>
);
}
const AdminDashboard: React.FC = () => {
const [tabValue, setTabValue] = useState(0);
const user = JSON.parse(localStorage.getItem('user') || '{}');
const handleChange = (event: React.SyntheticEvent, newValue: number) => {
setTabValue(newValue);
};
return (
<Container>
<Paper sx={{ width: '100%', marginTop: 2 }}>
<Typography variant="h4" component="h1" sx={{ p: 2 }}>
</Typography>
<Tabs
value={tabValue}
onChange={handleChange}
indicatorColor="primary"
textColor="primary"
>
<Tab label="用户管理" />
<Tab label="代码管理" />
<Tab label="练习记录" />
<Tab label="单词库管理" />
{user.username === 'bobcoc' && <Tab label="OAuth2管理" />}
</Tabs>
<TabPanel value={tabValue} index={0}>
<AdminUserManager />
</TabPanel>
<TabPanel value={tabValue} index={1}>
<AdminCodeManager />
</TabPanel>
<TabPanel value={tabValue} index={2}>
<AdminPracticeRecords />
</TabPanel>
<TabPanel value={tabValue} index={3}>
<AdminVocabularyManager />
</TabPanel>
{user.username === 'bobcoc' && (
<TabPanel value={tabValue} index={4}>
<AdminOAuth2Manager />
</TabPanel>
)}
</Paper>
</Container>
);
};
export default AdminDashboard;

View File

@@ -0,0 +1,306 @@
import React, { useState, useEffect } from 'react';
import { message } from 'antd';
import axios from 'axios';
import { adminApi } from '../api/admin';
interface OAuth2Client {
_id: string;
name: string;
clientId: string;
redirectUris: string[];
scope: string[];
createdAt: string;
}
interface CreateClientData {
name: string;
redirectUris: string[];
scope: string;
}
const AdminOAuth2Manager: React.FC = () => {
const [clients, setClients] = useState<OAuth2Client[]>([]);
const [loading, setLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedClient, setSelectedClient] = useState<OAuth2Client | null>(null);
const [formData, setFormData] = useState<CreateClientData>({
name: '',
redirectUris: [''],
scope: '',
});
// 获取客户端列表
const fetchClients = async () => {
try {
setLoading(true);
const clients = await adminApi.getOAuth2Clients();
setClients(clients);
} catch (error) {
console.error('获取客户端列表错误:', error);
message.error(`获取客户端列表失败: ${error.response?.data?.message || error.message}`);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchClients();
}, []);
// 创建新客户端
const handleCreate = async () => {
try {
const response = await adminApi.createOAuth2Client({
...formData,
redirectUris: formData.redirectUris.filter(uri => uri.trim() !== '')
});
message.success('创建成功');
setClients([...clients, response]);
setIsModalOpen(false);
resetForm();
} catch (error) {
console.error('创建失败:', error);
message.error('创建失败');
}
};
// 更新客户端
const handleUpdate = async () => {
if (!selectedClient) return;
try {
await adminApi.updateOAuth2Client(selectedClient.clientId, {
...formData,
redirectUris: formData.redirectUris.filter(uri => uri.trim() !== ''),
scope: formData.scope.split(' ')
});
message.success('更新成功');
fetchClients();
setIsModalOpen(false);
resetForm();
} catch (error) {
message.error('更新失败');
}
};
// 删除客户端
const handleDelete = async (clientId: string) => {
try {
await adminApi.deleteOAuth2Client(clientId);
message.success('删除成功');
setClients(clients.filter(client => client.clientId !== clientId));
} catch (error) {
message.error('删除失败');
}
};
const resetForm = () => {
setFormData({
name: '',
redirectUris: [''],
scope: '',
});
setSelectedClient(null);
};
return (
<div className="container mx-auto px-4 py-8">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-semibold text-gray-800">OAuth2 </h2>
<button
onClick={() => {
resetForm();
setIsModalOpen(true);
}}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
</button>
</div>
{loading ? (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div>
</div>
) : (
<div className="bg-white rounded-lg shadow overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Client ID
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
URI
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{clients.map((client) => (
<tr key={client.clientId}>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">{client.name}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-500">{client.clientId}</div>
</td>
<td className="px-6 py-4">
<div className="text-sm text-gray-500">
{client.redirectUris.join(', ')}
</div>
</td>
<td className="px-6 py-4">
<div className="text-sm text-gray-500">
{Array.isArray(client.scope)
? client.scope.join(' ')
: typeof client.scope === 'string'
? client.scope
: ''}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
onClick={() => {
setSelectedClient(client);
setFormData({
name: client.name,
redirectUris: client.redirectUris,
scope: client.scope.join(' '),
});
setIsModalOpen(true);
}}
className="text-indigo-600 hover:text-indigo-900 mr-4"
>
</button>
<button
onClick={() => handleDelete(client.clientId)}
className="text-red-600 hover:text-red-900"
>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* 创建/编辑模态框 */}
{isModalOpen && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
<h3 className="text-lg font-medium text-gray-900 mb-4">
{selectedClient ? '编辑客户端' : '创建客户端'}
</h3>
<form onSubmit={(e) => {
e.preventDefault();
selectedClient ? handleUpdate() : handleCreate();
}}>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
URI
</label>
{formData.redirectUris.map((uri, index) => (
<div key={index} className="flex mt-1">
<input
type="url"
value={uri}
onChange={(e) => {
const newUris = [...formData.redirectUris];
newUris[index] = e.target.value;
setFormData({ ...formData, redirectUris: newUris });
}}
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
required
/>
<button
type="button"
onClick={() => {
const newUris = formData.redirectUris.filter((_, i) => i !== index);
setFormData({ ...formData, redirectUris: newUris });
}}
className="ml-2 text-red-600 hover:text-red-900"
>
</button>
</div>
))}
<button
type="button"
onClick={() => setFormData({
...formData,
redirectUris: [...formData.redirectUris, '']
})}
className="mt-2 text-sm text-blue-600 hover:text-blue-500"
>
URI
</button>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
</label>
<input
type="text"
value={formData.scope}
onChange={(e) => setFormData({ ...formData, scope: e.target.value })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
placeholder="用空格分隔多个权限read write profile"
required
/>
</div>
</div>
<div className="mt-6 flex justify-end space-x-3">
<button
type="button"
onClick={() => {
setIsModalOpen(false);
resetForm();
}}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md shadow-sm hover:bg-gray-50"
>
</button>
<button
type="submit"
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md shadow-sm hover:bg-blue-700"
>
{selectedClient ? '保存' : '创建'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
export default AdminOAuth2Manager;

View File

@@ -0,0 +1,167 @@
import React, { useEffect, useState } from 'react';
import { DatePicker } from 'antd';
import Spin from 'antd/es/spin';
import Table from 'antd/es/table';
import Select from 'antd/lib/select';
import Card from 'antd/lib/card';
import type { ColumnsType } from 'antd/es/table';
import Tag from 'antd/es/tag';
import { adminApi, PracticeRecord, User } from '../api/admin';
import Input from 'antd/lib/input';
import { SearchOutlined } from '@ant-design/icons';
const AdminPracticeRecords: React.FC = () => {
const [loading, setLoading] = useState(true);
const [records, setRecords] = useState<PracticeRecord[]>([]);
const [users, setUsers] = useState<User[]>([]);
const [selectedDate, setSelectedDate] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState('');
// 获取用户列表
const fetchUsers = async () => {
try {
const response = await adminApi.getUsers();
setUsers(response);
} catch (error) {
console.error('获取用户列表失败:', error);
}
};
// 获取练习记录
const fetchPracticeRecords = async () => {
try {
setLoading(true);
const params: Record<string, string> = {};
if (selectedDate) {
params.date = selectedDate;
}
if (searchTerm) {
params.search = searchTerm;
}
const response = await adminApi.getPracticeRecords(params);
setRecords(response);
} catch (error) {
console.error('获取练习记录失败:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchUsers();
}, []);
useEffect(() => {
fetchPracticeRecords();
}, [selectedDate, searchTerm]);
const getAccuracyColor = (accuracy: number) => {
if (accuracy >= 95) return 'green';
if (accuracy >= 85) return 'blue';
if (accuracy >= 75) return 'orange';
return 'red';
};
const columns: ColumnsType<PracticeRecord> = [
{
title: '学生姓名',
dataIndex: 'fullname',
key: 'fullname',
sorter: (a, b) => a.fullname.localeCompare(b.fullname),
},
{
title: '练习类型',
dataIndex: 'type',
key: 'type',
render: (type) => (
<Tag color="blue">
{type === 'keyword' ? '关键字练习' : '代码练习'}
</Tag>
),
},
{
title: '练习时间',
dataIndex: 'createdAt',
key: 'createdAt',
render: (date) => new Date(date).toLocaleString('zh-CN'),
sorter: (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
defaultSortOrder: 'descend',
},
{
title: '打字速度',
dataIndex: ['stats', 'wordsPerMinute'],
key: 'speed',
render: (wpm) => `${Math.round(wpm)} WPM`,
},
{
title: '准确率',
dataIndex: ['stats', 'accuracy'],
key: 'accuracy',
render: (accuracy) => (
<Tag color={getAccuracyColor(accuracy)}>
{accuracy.toFixed(2)}%
</Tag>
),
},
{
title: '练习时长',
dataIndex: ['stats', 'duration'],
key: 'duration',
render: (duration) => `${Math.round(duration)}`,
},
{
title: '总字数',
dataIndex: ['stats', 'totalWords'],
key: 'totalWords',
render: (total) => `${total}`,
},
{
title: '正确字数',
dataIndex: ['stats', 'correctWords'],
key: 'correctWords',
render: (correct) => `${correct}`,
},
];
return (
<Card title="学生练习记录">
<div style={{ marginBottom: 16 }}>
<DatePicker
onChange={(date) => setSelectedDate(date?.format('YYYY-MM-DD') || null)}
style={{ marginRight: 16 }}
/>
<Input
placeholder="搜索学生姓名或用户名..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
style={{ width: 200 }}
prefix={
<SearchOutlined style={{ color: 'rgba(0,0,0,.25)' }} />
}
/>
</div>
{loading ? (
<div style={{ textAlign: 'center', padding: 24 }}>
<Spin size="large" />
</div>
) : (
<Table
columns={columns}
dataSource={records}
rowKey="_id"
pagination={{
defaultPageSize: 10,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `${total} 条记录`,
}}
/>
)}
</Card>
);
};
export default AdminPracticeRecords;

View File

@@ -0,0 +1,796 @@
import React, { useState, useEffect } from 'react';
import { adminApi, User, UserUpdateData } from '../api/admin';
import { AxiosError } from 'axios';
import * as XLSX from 'xlsx';
interface ImportResult {
success: {
count: number;
users: string[];
};
failed: {
count: number;
details: Array<{
row: number;
username: string;
reason: string;
}>;
};
}
const ExcelFormatTooltip: React.FC = () => (
<div className="absolute bottom-full mb-2 w-64 bg-gray-800 text-white text-sm rounded p-2 shadow-lg">
<p className="font-medium mb-1">Excel文件格式要求</p>
<ul className="list-disc list-inside text-xs space-y-1">
<li>username, email, password, fullname</li>
<li>isAdmin (true/false)</li>
<li>username3-20</li>
<li>email</li>
</ul>
</div>
);
const ImportReport: React.FC<{
result: ImportResult;
onClose: () => void;
}> = ({ result, onClose }) => {
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl w-full max-w-2xl max-h-[80vh] flex flex-col">
<div className="px-6 py-4 border-b border-gray-200 flex justify-between items-center">
<h3 className="text-lg font-medium text-gray-900"></h3>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-500"
>
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="flex-1 overflow-y-auto p-6">
<div className="grid grid-cols-2 gap-4 mb-6">
<div className="bg-green-50 p-4 rounded-lg">
<div className="text-green-800 font-medium"></div>
<div className="text-3xl font-bold text-green-600">{result.success.count}</div>
</div>
<div className="bg-red-50 p-4 rounded-lg">
<div className="text-red-800 font-medium"></div>
<div className="text-3xl font-bold text-red-600">{result.failed.count}</div>
</div>
</div>
{result.success.count > 0 && (
<div className="mt-6">
<h4 className="text-lg font-medium text-gray-900 mb-3"></h4>
<div className="bg-gray-50 rounded-lg p-4 max-h-[200px] overflow-y-auto">
<div className="flex flex-wrap gap-2">
{result.success.users.map((username, index) => (
<span
key={index}
className="inline-flex items-center px-2.5 py-0.5 rounded-md text-sm bg-green-100 text-green-800"
>
{username}
</span>
))}
</div>
</div>
</div>
)}
{result.failed.count > 0 && (
<div className="mt-6">
<h4 className="text-lg font-medium text-gray-900 mb-3"></h4>
<div className="bg-white shadow rounded-lg overflow-hidden">
<div className="max-h-[200px] overflow-y-auto">
{result.failed.details.map((item, index) => (
<div
key={index}
className="px-4 py-3 border-b last:border-b-0 hover:bg-gray-50"
>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<span className="text-gray-500 text-sm"> {item.row}</span>
<span className="font-medium">{item.username}</span>
</div>
<span className="text-red-600 text-sm">{item.reason}</span>
</div>
</div>
))}
</div>
</div>
</div>
)}
</div>
<div className="px-6 py-4 border-t border-gray-200">
<button
onClick={onClose}
className="w-full px-4 py-2 bg-gray-800 text-white rounded-lg hover:bg-gray-700 transition-colors"
>
</button>
</div>
</div>
</div>
);
};
const ImportProgress: React.FC<{ progress: number }> = ({ progress }) => (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-8 max-w-md w-full mx-4">
<div className="text-center mb-4">
<h3 className="text-xl font-medium text-gray-900 mb-2"></h3>
<p className="text-sm text-gray-500"></p>
</div>
<div className="w-full bg-gray-200 rounded-full h-3 mb-2">
<div
className="bg-blue-600 h-3 rounded-full transition-all duration-300 ease-in-out"
style={{ width: `${progress}%` }}
></div>
</div>
<p className="text-right text-sm text-gray-600 font-medium">{progress}%</p>
</div>
</div>
);
const AdminUserManager: React.FC = () => {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [isEditing, setIsEditing] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [newPassword, setNewPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [userToDelete, setUserToDelete] = useState<User | null>(null);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [backendError, setBackendError] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage] = useState(10);
// 导入相关状态
const [importing, setImporting] = useState(false);
const [importProgress, setImportProgress] = useState(0);
const [importResult, setImportResult] = useState<ImportResult | null>(null);
const [showImportReport, setShowImportReport] = useState(false);
const [showFormatTooltip, setShowFormatTooltip] = useState(false);
const validateEmail = (email: string): string | null => {
if (!email) {
return '邮箱是必填项';
}
if (!email.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)) {
return '请输入有效的邮箱地址';
}
return null;
};
const validateUsername = (username: string): string | null => {
if (!username) {
return '用户名是必填项';
}
if (username.length < 2 || username.length > 20) {
return '用户名长度应在2-20个字符之间';
}
return null;
};
useEffect(() => {
fetchUsers();
}, []);
useEffect(() => {
if (backendError) {
const timer = setTimeout(() => {
setBackendError(null);
}, 1000);
return () => clearTimeout(timer);
}
}, [backendError]);
const fetchUsers = async () => {
try {
setLoading(true);
const data = await adminApi.getUsers();
setUsers(data);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : '获取用户数据失败');
console.error('Error fetching users:', err);
} finally {
setLoading(false);
}
};
const handleExcelImport = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
setImporting(true);
setImportProgress(0);
setImportResult(null);
const importResult: ImportResult = {
success: { count: 0, users: [] },
failed: { count: 0, details: [] }
};
try {
const reader = new FileReader();
reader.onload = async (e) => {
try {
const data = new Uint8Array(e.target?.result as ArrayBuffer);
const workbook = XLSX.read(data, { type: 'array' });
const worksheet = workbook.Sheets[workbook.SheetNames[0]];
const jsonData = XLSX.utils.sheet_to_json(worksheet);
const totalRows = jsonData.length;
for (let i = 0; i < jsonData.length; i++) {
const row = jsonData[i] as any;
// 更新进度
const progress = Math.round(((i + 1) / totalRows) * 100);
console.log('Import progress:', progress);
setImportProgress(progress);
await new Promise(resolve => setTimeout(resolve, 100));
if (!row.username || !row.email || !row.password || !row.fullname) {
importResult.failed.details.push({
row: i + 2,
username: row.username || '未提供',
reason: '数据格式不完整,必须包含 username、email、password 和 fullname'
});
importResult.failed.count++;
continue;
}
if (!row.email.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)) {
importResult.failed.details.push({
row: i + 2,
username: row.username,
reason: '邮箱格式无效'
});
importResult.failed.count++;
continue;
}
const userData = {
username: row.username,
email: row.email,
password: row.password,
fullname: row.fullname,
isAdmin: Boolean(row.isAdmin),
};
try {
await adminApi.createUser(userData);
importResult.success.count++;
importResult.success.users.push(userData.username);
} catch (err) {
let errorMessage = '未知错误';
if (err instanceof AxiosError) {
errorMessage = err.response?.data?.message || err.message;
} else if (err instanceof Error) {
errorMessage = err.message;
}
importResult.failed.details.push({
row: i + 2,
username: userData.username,
reason: errorMessage
});
importResult.failed.count++;
}
}
setImportResult(importResult);
setShowImportReport(true);
await fetchUsers();
} catch (err) {
console.error('Excel处理错误:', err);
} finally {
setImporting(false);
}
};
reader.readAsArrayBuffer(file);
} catch (err) {
console.error('文件读取错误:', err);
setImporting(false);
}
event.target.value = '';
};
const handleEdit = (user: User) => {
setSelectedUser(user);
setIsEditing(true);
};
const handleUpdate = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedUser) return;
setBackendError(null);
if (!selectedUser.username) {
setError('用户名是必填项');
return;
}
if (selectedUser.username.length < 2 || selectedUser.username.length > 20) {
setError('用户名长度应在2-20个字符之间');
return;
}
try {
const updateData: UserUpdateData = {
username: selectedUser.username,
email: selectedUser.email,
fullname: selectedUser.fullname,
isAdmin: selectedUser.isAdmin,
};
if (newPassword) {
updateData.password = newPassword;
}
await adminApi.updateUser(selectedUser._id, updateData);
setIsEditing(false);
setNewPassword('');
await fetchUsers();
} catch (err) {
if (err instanceof AxiosError) {
setBackendError(err.response?.data?.message || '更新失败');
} else {
setBackendError('更新失败');
}
}
};
const handleDelete = async () => {
if (!userToDelete) return;
try {
await adminApi.deleteUser(userToDelete._id);
setUsers(prevUsers => prevUsers.filter(user => user._id !== userToDelete._id));
setIsDeleteModalOpen(false);
setUserToDelete(null);
} catch (err) {
if (err instanceof AxiosError) {
setBackendError(err.response?.data?.message || '删除失败');
} else {
setBackendError('删除失败');
}
}
};
// 计算分页数据
const indexOfLastItem = currentPage * itemsPerPage;
const indexOfFirstItem = indexOfLastItem - itemsPerPage;
const filteredUsers = users.filter(user =>
user.username.toLowerCase().includes(searchTerm.toLowerCase()) ||
user.email.toLowerCase().includes(searchTerm.toLowerCase()) ||
user.fullname.toLowerCase().includes(searchTerm.toLowerCase())
);
const currentUsers = filteredUsers.slice(indexOfFirstItem, indexOfLastItem);
const totalPages = Math.ceil(filteredUsers.length / itemsPerPage);
// 分页处理函数
const handlePageChange = (pageNumber: number) => {
setCurrentPage(pageNumber);
};
// 分页控制组件
const Pagination: React.FC = () => {
// 计算要显示的页码范围
const getPageRange = () => {
const range: number[] = [];
const showPages = 10; // 显示的页码数量
const sidePages = Math.floor(showPages / 2);
let start = currentPage - sidePages;
let end = currentPage + sidePages;
if (start < 1) {
start = 1;
end = Math.min(showPages, totalPages);
}
if (end > totalPages) {
end = totalPages;
start = Math.max(1, totalPages - showPages + 1);
}
for (let i = start; i <= end; i++) {
range.push(i);
}
return range;
};
return (
<div className="flex items-center justify-between px-4 py-3 bg-white border-t border-gray-200 sm:px-6">
<div className="flex-1 flex items-center justify-between">
<div>
<p className="text-sm text-gray-700">
<span className="font-medium">{indexOfFirstItem + 1}</span> {' '}
<span className="font-medium">
{Math.min(indexOfLastItem, filteredUsers.length)}
</span>{' '}
{' '}
<span className="font-medium">{filteredUsers.length}</span>
</p>
</div>
<div>
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
{/* 首页按钮 */}
<button
onClick={() => handlePageChange(1)}
disabled={currentPage === 1}
className={`relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium ${currentPage === 1
? 'text-gray-300 cursor-not-allowed'
: 'text-gray-500 hover:bg-gray-50'
}`}
>
<span className="sr-only"></span>
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M15.707 15.707a1 1 0 01-1.414 0l-5-5a1 1 0 010-1.414l5-5a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 010 1.414z" clipRule="evenodd" />
<path fillRule="evenodd" d="M9.707 15.707a1 1 0 01-1.414 0l-5-5a1 1 0 010-1.414l5-5a1 1 0 111.414 1.414L5.414 10l4.293 4.293a1 1 0 010 1.414z" clipRule="evenodd" />
</svg>
</button>
{/* 页码按钮 */}
{getPageRange().map((pageNum) => (
<button
key={pageNum}
onClick={() => handlePageChange(pageNum)}
className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium
${currentPage === pageNum
? 'z-10 bg-blue-50 border-blue-500 text-blue-600'
: 'bg-white border-gray-300 text-gray-500 hover:bg-gray-50'
}`}
>
{pageNum}
</button>
))}
{/* 末页按钮 */}
<button
onClick={() => handlePageChange(totalPages)}
disabled={currentPage === totalPages}
className={`relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium ${currentPage === totalPages
? 'text-gray-300 cursor-not-allowed'
: 'text-gray-500 hover:bg-gray-50'
}`}
>
<span className="sr-only"></span>
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M4.293 15.707a1 1 0 001.414 0l5-5a1 1 0 000-1.414l-5-5a1 1 0 00-1.414 1.414L8.586 10l-4.293 4.293a1 1 0 000 1.414z" clipRule="evenodd" />
<path fillRule="evenodd" d="M10.293 15.707a1 1 0 001.414 0l5-5a1 1 0 000-1.414l-5-5a1 1 0 00-1.414 1.414L14.586 10l-4.293 4.293a1 1 0 000 1.414z" clipRule="evenodd" />
</svg>
</button>
</nav>
</div>
</div>
</div>
);
};
return (
<>
<div className="container mx-auto px-4 py-8">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
<h2 className="text-xl font-semibold text-gray-800"></h2>
<div className="flex flex-col sm:flex-row gap-4">
<div className="relative">
<input
type="file"
id="excel-upload"
className="hidden"
accept=".xlsx,.xls"
onChange={handleExcelImport}
/>
<div className="flex items-center gap-2">
<label
htmlFor="excel-upload"
className="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-green-600 border border-transparent rounded-lg hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 cursor-pointer"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
Excel
</label>
<button
type="button"
className="text-gray-400 hover:text-gray-600"
onMouseEnter={() => setShowFormatTooltip(true)}
onMouseLeave={() => setShowFormatTooltip(false)}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
</div>
{showFormatTooltip && <ExcelFormatTooltip />}
</div>
<div className="relative w-full sm:w-64">
<input
type="text"
placeholder="搜索用户..."
className="w-full h-9 pl-8 pr-4 text-sm border border-gray-200 rounded-lg focus:outline-none focus:border-blue-500 transition-colors"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
<svg
className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
</div>
</div>
{loading ? (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div>
</div>
) : error ? (
<div className="text-red-500 text-center">{error}</div>
) : (
<div className="bg-white rounded-lg shadow overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{currentUsers.map((user) => (
<tr key={user._id}>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">{user.username}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-500">{user.email}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">{user.fullname}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-500">
{new Date(user.createdAt).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${user.isAdmin
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800'
}`}>
{user.isAdmin ? '管理员' : '普通用户'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
onClick={() => handleEdit(user)}
className="text-indigo-600 hover:text-indigo-900 mr-4"
>
</button>
<button
onClick={() => {
setUserToDelete(user);
setIsDeleteModalOpen(true);
}}
className="text-red-600 hover:text-red-900"
>
</button>
</td>
</tr>
))}
</tbody>
</table>
{!loading && !error && filteredUsers.length > 0 && <Pagination />}
</div>
)}
</div>
{/* 在最外层渲染模态框 */}
{importing && (
<ImportProgress progress={importProgress} />
)}
{showImportReport && importResult && (
<ImportReport
result={importResult}
onClose={() => setShowImportReport(false)}
/>
)}
{/* 编辑用户模态框 */}
{isEditing && selectedUser && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
<h3 className="text-lg font-medium text-gray-900 mb-4"></h3>
<form onSubmit={handleUpdate}>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">
</label>
<input
type="text"
value={selectedUser.username}
onChange={(e) =>
setSelectedUser({ ...selectedUser, username: e.target.value })
}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
</label>
<input
type="email"
value={selectedUser.email}
onChange={(e) =>
setSelectedUser({ ...selectedUser, email: e.target.value })
}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
</label>
<input
type="text"
value={selectedUser.fullname}
onChange={(e) =>
setSelectedUser({ ...selectedUser, fullname: e.target.value })
}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
</label>
<div className="relative">
<input
type={showPassword ? "text" : "password"}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
placeholder="留空表示不修改密码"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute inset-y-0 right-0 pr-3 flex items-center"
>
<svg
className="h-5 w-5 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d={
showPassword
? "M15 12a3 3 0 11-6 0 3 3 0 016 0z M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
: "M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
}
/>
</svg>
</button>
</div>
</div>
<div className="flex items-center">
<input
type="checkbox"
checked={selectedUser.isAdmin}
onChange={(e) =>
setSelectedUser({ ...selectedUser, isAdmin: e.target.checked })
}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label className="ml-2 block text-sm text-gray-900">
</label>
</div>
</div>
<div className="mt-6 flex justify-end space-x-3">
<button
type="button"
onClick={() => {
setIsEditing(false);
setNewPassword('');
}}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
</button>
<button
type="submit"
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
</button>
</div>
</form>
</div>
</div>
)}
{/* 删除确认模态框 */}
{isDeleteModalOpen && userToDelete && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-sm w-full mx-4">
<h3 className="text-lg font-medium text-gray-900 mb-4"></h3>
<p className="text-sm text-gray-500">
"{userToDelete.username}"
</p>
<div className="mt-6 flex justify-end space-x-3">
<button
onClick={() => setIsDeleteModalOpen(false)}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
</button>
<button
onClick={handleDelete}
className="px-4 py-2 text-sm font-medium text-white bg-red-600 border border-transparent rounded-md shadow-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
>
</button>
</div>
</div>
</div>
)}
{backendError && (
<div className="fixed bottom-4 right-4 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative" role="alert">
<strong className="font-bold"></strong>
<span className="block sm:inline">{backendError}</span>
</div>
)}
</>
);
};
export default AdminUserManager;

View File

@@ -0,0 +1,573 @@
import React, { useState, useEffect, useRef } from 'react';
import {
Button,
Table,
Upload,
Input,
Form,
Modal,
message,
Tooltip,
Popconfirm,
Space
} from 'antd';
import type { FormInstance } from 'antd';
import { UploadOutlined, DeleteOutlined, SearchOutlined, InfoCircleOutlined } from '@ant-design/icons';
import { adminApi } from '../api/admin';
import type { WordSet as ApiWordSet } from '../api/admin';
import type { UploadFile } from 'antd/lib/upload/interface';
import type { RcFile } from 'antd/lib/upload';
import type { ColumnsType } from 'antd/es/table';
import TextArea from 'antd/lib/input/TextArea';
// 使用本地接口解决类型不匹配问题
interface WordSetLocal {
_id: string;
name: string;
description: string;
totalWords: number;
createdAt: string;
owner: {
_id: string;
username: string;
fullname: string;
};
}
interface UploadFormValues {
name: string;
description?: string;
}
// 添加 Word 类型定义
interface Word {
_id: string;
word: string;
translation: string;
pronunciation?: string;
example?: string;
wordSet: string;
}
const AdminVocabularyManager: React.FC = () => {
const [wordSets, setWordSets] = useState<WordSetLocal[]>([]);
const [loading, setLoading] = useState(false);
const [uploadModalVisible, setUploadModalVisible] = useState(false);
const [searchText, setSearchText] = useState('');
const [form] = Form.useForm();
const fileInputRef = useRef<HTMLInputElement>(null);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [fileName, setFileName] = useState<string>('');
const [editModalVisible, setEditModalVisible] = useState(false);
const [editingWordSet, setEditingWordSet] = useState<WordSetLocal | null>(null);
const [words, setWords] = useState<Word[]>([]);
const [filteredWords, setFilteredWords] = useState<Word[]>([]);
const [pageSize, setPageSize] = useState<number>(50);
// 获取单词集列表
const fetchWordSets = async () => {
try {
setLoading(true);
const data = await adminApi.getVocabularyWordSets();
// 将API返回的数据转换为本地类型
const localData: WordSetLocal[] = data.map(item => ({
...item,
description: item.description || ''
}));
setWordSets(localData);
} catch (error) {
message.error('获取单词集列表失败');
console.error('获取单词集列表失败:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchWordSets();
}, []);
// 获取单词集中的单词
const fetchWords = async (wordSetId: string) => {
console.log('开始获取单词列表wordSetId:', wordSetId);
try {
const data = await adminApi.getWordsByWordSetId(wordSetId);
console.log('获取到的单词列表数据:', data); // 添加调试日志
setWords(data);
setFilteredWords([]); // 清空过滤的单词列表
} catch (error) {
message.error('获取单词失败');
console.error('获取单词失败:', error);
}
};
// 上传前检查文件
const checkFile = (file: File): boolean => {
const isCsv = file.type === 'text/csv' || file.name.endsWith('.csv');
if (!isCsv) {
message.error('只能上传CSV文件!');
return false;
}
const isLt5M = file.size / 1024 / 1024 < 5;
if (!isLt5M) {
message.error('文件必须小于5MB!');
return false;
}
return true;
};
// 处理文件选择
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (checkFile(file)) {
setSelectedFile(file);
setFileName(file.name);
} else {
// 清空选择的文件
e.target.value = '';
setSelectedFile(null);
setFileName('');
}
};
// 处理上传
const handleUpload = async (values: UploadFormValues) => {
const { name, description } = values;
if (!selectedFile) {
message.error('请选择文件');
return;
}
try {
setLoading(true);
const formData = new FormData();
formData.append('file', selectedFile);
formData.append('name', name);
if (description) {
formData.append('description', description);
}
await adminApi.uploadVocabularyFile(formData);
message.success('上传成功');
setUploadModalVisible(false);
form.resetFields();
setSelectedFile(null);
setFileName('');
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
fetchWordSets();
} catch (error) {
message.error('上传失败');
console.error('上传失败:', error);
} finally {
setLoading(false);
}
};
// 处理删除
const handleDelete = async (id: string) => {
try {
setLoading(true);
await adminApi.deleteVocabularyWordSet(id);
message.success('删除成功');
fetchWordSets();
} catch (error) {
message.error('删除失败');
console.error('删除失败:', error);
} finally {
setLoading(false);
}
};
// 打开编辑模态框
const openEditModal = async (wordSet: WordSetLocal) => {
console.log('打开编辑模态框wordSet:', wordSet);
setEditingWordSet(wordSet);
setEditModalVisible(true);
await fetchWords(wordSet._id);
};
// 提交编辑
const handleEditSubmit = async (values: WordSetLocal) => {
try {
setLoading(true);
await adminApi.updateVocabularyWordSet(editingWordSet._id, values);
message.success('更新成功');
setEditModalVisible(false);
fetchWordSets();
} catch (error) {
message.error('更新失败');
console.error('更新失败:', error);
} finally {
setLoading(false);
}
};
// 提交单词编辑
const handleWordEditSubmit = async (updatedWords: any[]) => {
try {
setLoading(true);
// 只发送修改过的单词
const modifiedWords = updatedWords.filter(word => {
const originalWord = words.find(w => w._id === word._id);
return originalWord && (
word.word !== originalWord.word ||
word.translation !== originalWord.translation ||
word.pronunciation !== originalWord.pronunciation ||
word.example !== originalWord.example
);
});
console.log('修改过的单词:', modifiedWords); // 添加调试日志
if (modifiedWords.length === 0) {
message.info('没有修改任何内容');
setEditModalVisible(false);
return;
}
await adminApi.updateWords(modifiedWords);
message.success('单词更新成功');
setEditModalVisible(false);
// 重新加载单词列表
if (editingWordSet) {
const response = await adminApi.getVocabularyWordSetDetails(editingWordSet._id);
setWords(response.words);
}
} catch (error) {
console.error('更新单词失败:', error);
message.error('更新单词失败');
} finally {
setLoading(false);
}
};
// 过滤数据
const filteredWordSets = wordSets.filter(
wordSet =>
wordSet.name.toLowerCase().includes(searchText.toLowerCase()) ||
(wordSet.description && wordSet.description.toLowerCase().includes(searchText.toLowerCase())) ||
(wordSet.owner.username && wordSet.owner.username.toLowerCase().includes(searchText.toLowerCase())) ||
(wordSet.owner.fullname && wordSet.owner.fullname.toLowerCase().includes(searchText.toLowerCase()))
);
const columns: ColumnsType<WordSetLocal> = [
{
title: '单词集名称',
dataIndex: 'name',
key: 'name',
},
{
title: '描述',
dataIndex: 'description',
key: 'description',
render: (text) => text || '-',
},
{
title: '单词数量',
dataIndex: 'totalWords',
key: 'totalWords',
sorter: (a, b) => a.totalWords - b.totalWords,
},
{
title: '所有者',
dataIndex: ['owner', 'fullname'],
key: 'owner',
render: (text, record) => `${text || record.owner.username}`,
},
{
title: '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
render: (text) => new Date(text).toLocaleString(),
sorter: (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
},
{
title: '操作',
key: 'action',
render: (_, record) => (
<Space size="middle">
<Button onClick={() => openEditModal(record)}></Button>
<Popconfirm
title="确定要删除这个单词集吗?"
onConfirm={() => handleDelete(record._id)}
okText="是"
cancelText="否"
>
<Button danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
<Input
placeholder="搜索单词集..."
onChange={e => setSearchText(e.target.value)}
style={{ width: 250 }}
prefix={<SearchOutlined />}
/>
<Button
type="primary"
icon={<UploadOutlined />}
onClick={() => setUploadModalVisible(true)}
>
</Button>
</div>
<Table
columns={columns}
dataSource={filteredWordSets}
rowKey="_id"
loading={loading}
/>
<Modal
title="上传单词集"
open={uploadModalVisible}
onCancel={() => {
setUploadModalVisible(false);
form.resetFields();
setSelectedFile(null);
setFileName('');
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}}
footer={null}
>
<Form form={form} onFinish={handleUpload} layout="vertical">
<Form.Item
name="name"
label="单词集名称"
rules={[{ required: true, message: '请输入单词集名称' }]}
>
<Input placeholder="请输入单词集名称" />
</Form.Item>
<Form.Item
name="description"
label="描述"
>
<Input.TextArea placeholder="请输入描述" />
</Form.Item>
<Form.Item
label="CSV文件"
required
tooltip={{
title: '文件格式要求CSV文件包含word(单词)、translation(翻译)、pronunciation(发音,可选)、example(例句,可选)列',
icon: <InfoCircleOutlined />
}}
>
<div className="upload-container">
<input
type="file"
ref={fileInputRef}
accept=".csv"
onChange={handleFileSelect}
style={{ display: 'none' }}
/>
<Button
onClick={() => fileInputRef.current?.click()}
icon={<UploadOutlined />}
>
</Button>
<span style={{ marginLeft: 8 }}>
{fileName || '未选择文件'}
</span>
</div>
</Form.Item>
<Form.Item style={{ marginBottom: 0, textAlign: 'right' }}>
<Button style={{ marginRight: 8 }} onClick={() => {
setUploadModalVisible(false);
form.resetFields();
setSelectedFile(null);
setFileName('');
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}}>
</Button>
<Button type="primary" htmlType="submit" loading={loading} disabled={!selectedFile}>
</Button>
</Form.Item>
</Form>
</Modal>
<Modal
title="编辑单词集"
open={editModalVisible}
onCancel={() => setEditModalVisible(false)}
footer={null}
width="90vw"
style={{
top: 20,
maxHeight: '90vh',
overflow: 'auto'
}}
>
{/* 添加调试信息 */}
<div>
<p>: {words.length}</p>
<p>: {filteredWords.length}</p>
</div>
<Form
initialValues={editingWordSet}
onFinish={handleEditSubmit}
layout="vertical"
>
<Form.Item
name="name"
label="单词集名称"
rules={[{ required: true, message: '请输入单词集名称' }]}
>
<Input />
</Form.Item>
<Form.Item
name="description"
label="描述"
>
<Input.TextArea />
</Form.Item>
<div style={{ marginBottom: 16 }}>
<Input.Search
placeholder="搜索单词..."
onSearch={value => {
const filtered = words.filter(w =>
w.word.toLowerCase().includes(value.toLowerCase()) ||
w.translation.toLowerCase().includes(value.toLowerCase()) ||
w.pronunciation?.toLowerCase().includes(value.toLowerCase())
);
setFilteredWords(filtered);
}}
style={{ width: 300 }}
/>
</div>
<Table
dataSource={filteredWords.length > 0 ? filteredWords : words}
columns={[
{
title: '单词',
dataIndex: 'word',
key: 'word',
width: '12%',
render: (text: string, record: Word) => (
<Input
defaultValue={text}
onChange={(e) => {
const newWords = words.map(w =>
w._id === record._id ? { ...w, word: e.target.value } : w
);
setWords(newWords);
}}
/>
),
},
{
title: '翻译',
dataIndex: 'translation',
key: 'translation',
width: '30%',
render: (text, record) => (
<Input.TextArea
defaultValue={text}
autoSize={{ minRows: 1, maxRows: 3 }}
onChange={(e) => {
const newWords = words.map(w =>
w._id === record._id ? { ...w, translation: e.target.value } : w
);
setWords(newWords);
}}
/>
),
},
{
title: '发音',
dataIndex: 'pronunciation',
key: 'pronunciation',
width: '12%',
render: (text, record) => (
<Input
defaultValue={text}
placeholder="请输入发音"
onChange={(e) => {
const newWords = words.map(w =>
w._id === record._id ? { ...w, pronunciation: e.target.value } : w
);
setWords(newWords);
}}
/>
),
},
{
title: '例句',
dataIndex: 'example',
key: 'example',
width: '46%',
render: (text, record) => (
<Input.TextArea
defaultValue={text}
placeholder="请输入例句"
autoSize={{ minRows: 1, maxRows: 3 }}
onChange={(e) => {
const newWords = words.map(w =>
w._id === record._id ? { ...w, example: e.target.value } : w
);
setWords(newWords);
}}
/>
),
},
]}
rowKey="_id"
pagination={{
pageSize: pageSize,
showSizeChanger: true,
showQuickJumper: true,
pageSizeOptions: ['10', '20', '50', '100'],
onShowSizeChange: (current, size) => {
setPageSize(size);
}
}}
scroll={{ y: 'calc(80vh - 300px)' }}
/>
<div style={{
textAlign: 'right',
marginTop: 16,
position: 'sticky',
bottom: 0,
padding: '16px 0',
background: '#fff',
borderTop: '1px solid #f0f0f0'
}}>
<Button onClick={() => setEditModalVisible(false)} style={{ marginRight: 8 }}>
</Button>
<Button type="primary" onClick={() => handleWordEditSubmit(words)} loading={loading}>
</Button>
</div>
</Form>
</Modal>
</div>
);
};
export default AdminVocabularyManager;

View File

@@ -0,0 +1,113 @@
// src/components/ChangePassword.tsx
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { TextField, Button, Typography, Paper, Grid } from '@mui/material';
import message from 'antd/es/message';
import { api, ApiError } from '../api/apiClient';
import { API_PATHS } from '../config';
const ChangePassword: React.FC = () => {
const [oldPassword, setOldPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!oldPassword || !newPassword || !confirmPassword) {
message.error('请填写所有字段');
return;
}
if (newPassword.length < 6) {
message.error('新密码长度至少6个字符');
return;
}
if (newPassword !== confirmPassword) {
message.error('两次输入的新密码不一致');
return;
}
setLoading(true);
try {
await api.post(`${API_PATHS.AUTH}/change-password`, {
oldPassword,
newPassword
});
message.success('密码修改成功');
navigate('/', { replace: true });
} catch (error: any) {
if (error instanceof ApiError) {
if (error.statusCode === 401) {
// 认证错误已经由 AuthWrapper 处理,这里不需要额外处理
console.log('Authentication error handled by AuthWrapper');
} else if (error.response?.message === 'Invalid old password') {
message.error('原密码错误');
} else {
message.error(error.message);
}
} else {
message.error('修改失败,请稍后重试');
}
} finally {
setLoading(false);
}
};
return (
<Grid container justifyContent="center" style={{ marginTop: '10%' }}>
<Grid item xs={10} sm={6} md={4}>
<Paper style={{ padding: 20 }}>
<Typography variant="h5" align="center" gutterBottom>
</Typography>
<form onSubmit={handleSubmit}>
<TextField
label="原密码"
type="password"
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
fullWidth
margin="normal"
disabled={loading}
/>
<TextField
label="新密码"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
fullWidth
margin="normal"
disabled={loading}
/>
<TextField
label="确认新密码"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
fullWidth
margin="normal"
disabled={loading}
/>
<Button
type="submit"
variant="contained"
color="primary"
fullWidth
style={{ marginTop: 16 }}
disabled={loading}
>
{loading ? '提交中...' : '确认修改'}
</Button>
</form>
</Paper>
</Grid>
</Grid>
);
};
export default ChangePassword;

28
src/components/Footer.tsx Normal file
View File

@@ -0,0 +1,28 @@
import React from 'react';
const Footer = () => (
<footer style={{ textAlign: 'center', marginTop: '40px', padding: '20px', backgroundColor: '#f8f9fa', borderTop: '1px solid #e9ecef' }}>
<p style={{ margin: '10px 0', fontSize: '14px', color: '#6c757d' }}>
<a href="https://github.com/bobcoc/typing_practiceweb" target="_blank" rel="noopener noreferrer" style={{ marginLeft: '8px', color: '#007bff', textDecoration: 'none' }}>
</a>
<span style={{ margin: '0 8px' }}> | </span>
<a href="https://www.21cnjy.com" target="_blank" rel="noopener noreferrer" style={{ marginLeft: '8px', color: '#007bff', textDecoration: 'none' }}>
</a>
</p>
<p style={{ margin: '10px 0', fontSize: '14px', color: '#6c757d' }}>线
</p>
<p style={{ marginTop: '10px', fontSize: '14px', color: '#6c757d' }}>
:
<a href="mailto:smshine@qq.com" style={{ color: '#007bff', textDecoration: 'none' }}>
smshine@qq.com
</a>
</p>
</footer>
);
export default Footer;

14
src/components/Home.tsx Normal file
View File

@@ -0,0 +1,14 @@
import React from 'react';
import LandingPage from './LandingPage';
/**
* Home组件
*
* 这个组件实际上只是LandingPage组件的包装器
* 为了保持代码一致性我们将继续使用LandingPage作为首页内容
*/
const Home: React.FC = () => {
return <LandingPage />;
};
export default Home;

View File

@@ -0,0 +1,5 @@
/* K-Means Demo 组件专用样式 - 消息从底部弹出 */
.kmeans-demo-container .ant-message {
top: auto !important;
bottom: 50px !important;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,58 @@
import React, { useState, useEffect } from 'react';
import { message } from 'antd';
import Button from 'antd/lib/button';
import TextArea from 'antd/lib/input/TextArea';
import { api } from '../api/apiClient';
import { API_PATHS } from '../config';
const KeywordManager: React.FC = () => {
const [keywords, setKeywords] = useState('');
useEffect(() => {
fetchKeywords();
}, []);
const fetchKeywords = async () => {
try {
const response = await api.get<{ content: string }>(API_PATHS.KEYWORDS);
setKeywords(response.content);
} catch (error) {
console.error('Error fetching keywords:', error);
message.error('获取关键词列表失败');
}
};
const handleSubmit = async () => {
try {
await api.post(API_PATHS.KEYWORDS, { content: keywords });
message.success('保存成功');
} catch (error) {
console.error('Error saving keywords:', error);
message.error('保存失败');
}
};
return (
<div>
<h2></h2>
<div>
<TextArea
rows={10}
placeholder="请输入关键字,每行一个"
value={keywords}
onChange={(e) => setKeywords(e.target.value)}
/>
</div>
<div style={{ marginTop: '16px' }}>
<Button
type="primary"
onClick={handleSubmit}
>
</Button>
</div>
</div>
);
};
export default KeywordManager;

View File

@@ -0,0 +1,171 @@
import React, { useState, useEffect } from 'react';
import { Container, Typography, Card, CardContent, Grid, Button, Box, CircularProgress } from '@mui/material';
import { useNavigate } from 'react-router-dom';
import { QAConfig, defaultQaConfig, getQaConfig } from '../config/qaConfig';
const LandingPage: React.FC = () => {
const navigate = useNavigate();
const [config, setConfig] = useState<QAConfig>(defaultQaConfig);
const [loading, setLoading] = useState(true);
useEffect(() => {
const loadConfig = async () => {
try {
setLoading(true);
const remoteConfig = await getQaConfig();
setConfig(remoteConfig);
} catch (error) {
console.error('Error loading config:', error);
} finally {
setLoading(false);
}
};
loadConfig();
}, []);
if (loading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<CircularProgress />
</Box>
);
}
const firstSection = config.qaContent.slice(0, config.firstSectionCount);
const secondSection = config.qaContent.slice(
config.firstSectionCount,
config.firstSectionCount + config.secondSectionCount
);
const thirdSection = config.qaContent.slice(
config.firstSectionCount + config.secondSectionCount,
config.firstSectionCount + config.secondSectionCount + config.thirdSectionCount
);
return (
<Container maxWidth="lg" sx={{ py: 8 }}>
{/* 头部区域 */}
<Box sx={{ mb: 8, textAlign: 'center' }}>
<Typography variant="h3" component="h1" gutterBottom>
AI教育 ·
</Typography>
<Typography variant="h5" color="text.secondary" paragraph>
</Typography>
</Box>
{/* 第一部分 QA内容 */}
<Box sx={{ mb: 8 }}>
<Grid container spacing={4}>
{firstSection.map((qa, index) => (
<Grid item xs={12} md={6} key={index}>
<Card sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<CardContent>
<Typography variant="overline" color="primary">
{qa.category}
</Typography>
<Typography variant="h6" component="h2" gutterBottom>
{qa.question}
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ whiteSpace: 'pre-line' }}>
{qa.answer}
</Typography>
</CardContent>
</Card>
</Grid>
))}
</Grid>
</Box>
{/* 第二部分 - AI平台 */}
<Box sx={{ mb: 8, textAlign: 'center' }}>
<Typography variant="h3" component="h2" gutterBottom>
{config.secondSectionTitle}
</Typography>
<Typography variant="h5" color="text.secondary" paragraph>
{config.secondSectionSubtitle}
</Typography>
</Box>
{/* 第二部分 QA内容 */}
<Box sx={{ mb: 8 }}>
<Grid container spacing={4}>
{secondSection.map((qa, index) => (
<Grid item xs={12} md={6} key={index}>
<Card sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<CardContent>
<Typography variant="overline" color="primary">
{qa.category}
</Typography>
<Typography variant="h6" component="h2" gutterBottom>
{qa.question}
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ whiteSpace: 'pre-line' }}>
{qa.answer}
</Typography>
</CardContent>
</Card>
</Grid>
))}
</Grid>
{/* AI平台按钮 */}
<Box sx={{ mt: 4, textAlign: 'center' }}>
<Button
variant="contained"
size="large"
onClick={() => window.open('https://c.d1kt.cn', '_blank')}
sx={{ mt: 2 }}
>
AI平台
</Button>
</Box>
</Box>
{/* 第三部分 - 课程中心 */}
<Box sx={{ mb: 8, textAlign: 'center' }}>
<Typography variant="h3" component="h2" gutterBottom>
{config.thirdSectionTitle}
</Typography>
<Typography variant="h5" color="text.secondary" paragraph>
{config.thirdSectionSubtitle}
</Typography>
</Box>
{/* 第三部分 QA内容 */}
<Box sx={{ mb: 8 }}>
<Grid container spacing={4}>
{thirdSection.map((qa, index) => (
<Grid item xs={12} md={6} key={index}>
<Card sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<CardContent>
<Typography variant="overline" color="primary">
{qa.category}
</Typography>
<Typography variant="h6" component="h2" gutterBottom>
{qa.question}
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ whiteSpace: 'pre-line' }}>
{qa.answer}
</Typography>
</CardContent>
</Card>
</Grid>
))}
</Grid>
{/* 课程中心按钮 */}
<Box sx={{ mt: 4, textAlign: 'center' }}>
<Button
variant="contained"
size="large"
onClick={() => window.open('https://m.d1kt.cn', '_blank')}
sx={{ mt: 2 }}
>
</Button>
</Box>
</Box>
</Container>
);
};
export default LandingPage;

View File

@@ -0,0 +1,259 @@
// src/components/Leaderboard.tsx
import React, { useState, useEffect } from 'react';
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Typography,
Pagination,
Box,
Tabs,
Tab,
Chip,
CircularProgress,
FormControl,
Select,
MenuItem
} from '@mui/material';
import { API_BASE_URL, API_PREFIX, API_PATHS } from '../config';
import type { ChipProps } from '@mui/material/Chip';
import type { SxProps, Theme } from '@mui/material/styles';
// 类型定义
interface PracticeRecord {
userId: string;
username: string;
fullname: string;
stats: {
totalWords: number;
accuracy: number;
wordsPerMinute: number;
duration: number;
practiceCount: number;
lastPractice: string;
};
}
interface LeaderboardResponse {
records: PracticeRecord[];
total: number;
currentPage: number;
totalPages: number;
sortField: string;
sortLabel: string;
}
interface PracticeType {
value: 'keyword' | 'basic' | 'intermediate' | 'advanced';
label: string;
}
const practiceTypes: PracticeType[] = [
{ value: 'keyword', label: '关键字' },
{ value: 'basic', label: '基础算法' },
{ value: 'intermediate', label: '中级算法' },
{ value: 'advanced', label: '高级算法' }
];
const sortOptions = [
{ value: 'totalWords', label: '总单词数' },
{ value: 'accuracy', label: '平均正确率' },
{ value: 'duration', label: '练习总时长' },
{ value: 'speed', label: '平均速度' }
];
// 排名徽章配置
const rankBadges: Array<{
label: string;
color: ChipProps['color'];
sx?: SxProps<Theme>;
}> = [
{ label: '🥇', color: 'success', sx: { ml: 1 } },
{ label: '🥈', color: 'primary', sx: { ml: 1 } },
{ label: '🥉', color: 'default', sx: { ml: 1 } }
];
const Leaderboard: React.FC = () => {
const [records, setRecords] = useState<PracticeRecord[]>([]);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [practiceType, setPracticeType] = useState<PracticeType['value']>('keyword');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [sortBy, setSortBy] = useState('totalWords');
useEffect(() => {
fetchLeaderboard();
}, [page, practiceType, sortBy]);
const fetchLeaderboard = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(
`${API_BASE_URL}${API_PREFIX}${API_PATHS.LEADERBOARD}/${practiceType}?page=${page}&sortBy=${sortBy}`
);
if (!response.ok) {
throw new Error('获取排行榜失败');
}
const data: LeaderboardResponse = await response.json();
setRecords(data.records);
setTotalPages(data.totalPages);
} catch (error) {
console.error('获取排行榜失败:', error);
setError(error instanceof Error ? error.message : '获取排行榜失败');
} finally {
setLoading(false);
}
};
const handlePageChange = (_event: React.ChangeEvent<unknown>, value: number) => {
setPage(value);
};
const handleTypeChange = (_event: React.SyntheticEvent, newValue: string) => {
setPracticeType(newValue as PracticeType['value']);
setPage(1);
};
const formatDuration = (seconds: number): string => {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainingSeconds = Math.round(seconds % 60);
if (hours > 0) {
return `${hours}小时${minutes}${remainingSeconds}`;
}
return `${minutes}${remainingSeconds}`;
};
if (loading) {
return (
<Box display="flex" justifyContent="center" alignItems="center" minHeight="60vh">
<CircularProgress />
</Box>
);
}
if (error) {
return (
<Box display="flex" justifyContent="center" alignItems="center" minHeight="60vh">
<Typography color="error">{error}</Typography>
</Box>
);
}
return (
<Box sx={{ padding: 3 }}>
<Typography variant="h4" gutterBottom align="center">
</Typography>
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}>
<Tabs
value={practiceType}
onChange={handleTypeChange}
centered
textColor="primary"
indicatorColor="primary"
>
{practiceTypes.map(type => (
<Tab
key={type.value}
value={type.value}
label={type.label}
/>
))}
</Tabs>
</Box>
<Box sx={{ mb: 3, display: 'flex', justifyContent: 'flex-end' }}>
<FormControl variant="outlined" size="small">
<Select
value={sortBy}
onChange={(e) => {
setSortBy(e.target.value);
setPage(1);
}}
sx={{ minWidth: 150 }}
>
{sortOptions.map(option => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</Select>
</FormControl>
</Box>
<TableContainer component={Paper} sx={{ mb: 3 }}>
<Table>
<TableHead>
<TableRow>
<TableCell align="center"></TableCell>
<TableCell></TableCell>
<TableCell align="center"></TableCell>
<TableCell align="center"></TableCell>
<TableCell align="center"></TableCell>
<TableCell align="center">(/)</TableCell>
<TableCell align="center"></TableCell>
<TableCell align="center"></TableCell>
</TableRow>
</TableHead>
<TableBody>
{records.map((record, index) => (
<TableRow
key={record.userId}
sx={page === 1 && index < 3 ? { backgroundColor: 'rgba(0, 0, 0, 0.06)' } : {}}
>
<TableCell align="center">
{(page - 1) * 10 + index + 1}
{(page === 1) && index < 3 && (
<Chip
size="small"
label={rankBadges[index].label}
color={rankBadges[index].color}
sx={rankBadges[index].sx}
/>
)}
</TableCell>
<TableCell>{record.fullname || record.username}</TableCell>
<TableCell align="center">{record.stats.practiceCount}</TableCell>
<TableCell align="center">
<Chip
label={`${record.stats.accuracy.toFixed(1)}%`}
color={record.stats.accuracy > 95 ? 'success' : 'default'}
size="small"
/>
</TableCell>
<TableCell align="center">{record.stats.totalWords}</TableCell>
<TableCell align="center">{Math.round(record.stats.wordsPerMinute)}</TableCell>
<TableCell align="center">{formatDuration(record.stats.duration)}</TableCell>
<TableCell align="center">
{new Date(record.stats.lastPractice).toLocaleString()}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<Box display="flex" justifyContent="center" mt={3} mb={3}>
<Pagination
count={totalPages}
page={page}
onChange={handlePageChange}
color="primary"
size="large"
/>
</Box>
</Box>
);
};
export default Leaderboard;

173
src/components/Login.tsx Normal file
View File

@@ -0,0 +1,173 @@
// src/components/Login.tsx
import React, { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { TextField, Button, Typography, Paper, Grid } from '@mui/material';
import message from 'antd/es/message';
import { api, ApiError } from '../api/apiClient';
import { API_PATHS } from '../config';
interface LoginResponse {
token: string;
user: {
_id: string;
username: string;
fullname: string;
email: string;
isAdmin: boolean;
};
}
interface SessKeyResponse {
sesskey: string;
}
const Login: React.FC = () => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const handleLogin = async (e?: React.FormEvent) => {
// 如果是表单提交,阻止默认行为
if (e) {
e.preventDefault();
}
if (!username || !password) {
message.error('请填写所有字段');
return;
}
if (username.length < 2 || username.length > 20) {
message.error('用户名长度应在2-20个字符之间');
return;
}
if (password.length < 6) {
message.error('密码长度至少6个字符');
return;
}
setLoading(true);
try {
// 获取 URL 中的 redirect 参数
const params = new URLSearchParams(window.location.search);
const redirectUrl = params.get('redirect');
const response = await api.post<LoginResponse>(`${API_PATHS.AUTH}/login`, {
username,
password
});
// 保存 token 到 localStorage
localStorage.setItem('token', response.token);
localStorage.setItem('user', JSON.stringify(response.user));
// 同时保存 token 到 cookie确保跨域请求也能携带 token
// secure 表示只在 HTTPS 下发送,根据环境设置
// 设置 cookie 最大有效期为 7 天
const secure = window.location.protocol === 'https:' ? '; secure' : '';
const maxAge = 60 * 60 * 24 * 7; // 7天单位
document.cookie = `token=${response.token}; path=/; max-age=${maxAge}${secure}`;
window.dispatchEvent(new Event('user-login'));
message.success('登录成功');
// 如果有 redirect 参数且是 OAuth2 相关的路径,则跳转
if (redirectUrl && redirectUrl.includes('oauth2/authorize')) {
console.log('Original redirect URL:', redirectUrl);
// 直接使用原始的重定向 URL在 cookie 中已经有 token 了
console.log('Redirecting to OAuth flow:', redirectUrl);
window.location.href = redirectUrl;
} else {
// 否则跳转到首页
navigate('/', { replace: true });
}
} catch (error: any) {
if (error instanceof ApiError) {
let errorMessage = error.message;
if (error.response?.message === 'User not found') {
errorMessage = '用户不存在';
} else if (error.response?.message === 'Password mismatch') {
errorMessage = '密码错误';
}
message.error(errorMessage);
} else {
const errorMessage = error?.message || '登录失败,请稍后重试';
message.error(errorMessage);
}
} finally {
setLoading(false);
}
};
const silentMoodleLogin = async () => {
try {
const { sesskey } = await api.get<SessKeyResponse>(`${API_PATHS.AUTH2}/moodle-sesskey`);
if (!sesskey) {
console.error('Failed to get sesskey');
return;
}
// 使用获取到的 sesskey 构建完整的登录 URL
const iframe = document.createElement('iframe');
iframe.style.display = 'none';
iframe.src = `https://m.d1kt.cn/auth/oauth2/login.php?id=1&wantsurl=/&sesskey=${sesskey}`;
document.body.appendChild(iframe);
} catch (error) {
console.error('Silent Moodle login error:', error);
}
};
return (
<Grid container justifyContent="center" style={{ marginTop: '10%' }}>
<Grid item xs={10} sm={6} md={4}>
<Paper style={{ padding: 20 }}>
<Typography variant="h5" align="center" gutterBottom>
</Typography>
<form onSubmit={handleLogin}>
<TextField
label="用户名"
value={username}
onChange={(e) => setUsername(e.target.value)}
fullWidth
margin="normal"
disabled={loading}
autoComplete="username"
/>
<TextField
label="密码"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
fullWidth
margin="normal"
disabled={loading}
autoComplete="current-password"
/>
<Button
type="submit"
variant="contained"
color="primary"
fullWidth
style={{ marginTop: 16 }}
disabled={loading}
>
{loading ? '登录中...' : '登录'}
</Button>
</form>
<Typography align="center" style={{ marginTop: 16 }}>
<Link to="/register"></Link>
</Typography>
</Paper>
</Grid>
</Grid>
);
};
export default Login;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,255 @@
// src/components/MinesweeperLeaderboard.tsx
import React, { useState, useEffect } from 'react';
import {
Box,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Typography,
Pagination,
Tabs,
Tab,
Chip,
CircularProgress
} from '@mui/material';
import EmojiEventsIcon from '@mui/icons-material/EmojiEvents';
import { API_BASE_URL } from '../config';
type Difficulty = 'beginner' | 'intermediate' | 'expert' | 'brutal';
interface LeaderboardRecord {
userId: string;
username: string;
fullname: string;
bestTime: number;
totalGames: number;
wonGames: number;
winRate: number;
lastPlayed: string;
}
interface LeaderboardResponse {
records: LeaderboardRecord[];
total: number;
currentPage: number;
totalPages: number;
difficulty: string;
}
const DIFFICULTY_LABELS: Record<Difficulty, string> = {
beginner: '初级 (9×9, 10雷)',
intermediate: '中级 (16×16, 40雷)',
expert: '高级 (16×30, 99雷)',
brutal: '残酷 (24×30, 200雷)'
};
const MinesweeperLeaderboard: React.FC = () => {
const [difficulty, setDifficulty] = useState<Difficulty>('beginner');
const [records, setRecords] = useState<LeaderboardRecord[]>([]);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetchLeaderboard();
}, [difficulty, page]);
const fetchLeaderboard = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(
`${API_BASE_URL}/api/minesweeper/leaderboard/${difficulty}?page=${page}&limit=10`
);
if (!response.ok) {
throw new Error('获取排行榜失败');
}
const data: LeaderboardResponse = await response.json();
setRecords(data.records);
setTotalPages(data.totalPages);
} catch (error) {
console.error('获取排行榜失败:', error);
setError(error instanceof Error ? error.message : '获取排行榜失败');
} finally {
setLoading(false);
}
};
const handleDifficultyChange = (_event: React.SyntheticEvent, newValue: Difficulty) => {
setDifficulty(newValue);
setPage(1);
};
const handlePageChange = (_event: React.ChangeEvent<unknown>, value: number) => {
setPage(value);
};
const formatTime = (seconds: number): string => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
const getRankBadge = (rank: number) => {
if (rank === 1) return { emoji: '🥇', color: 'success' as const };
if (rank === 2) return { emoji: '🥈', color: 'primary' as const };
if (rank === 3) return { emoji: '🥉', color: 'default' as const };
return null;
};
if (loading && records.length === 0) {
return (
<Box display="flex" justifyContent="center" alignItems="center" minHeight="60vh">
<CircularProgress />
</Box>
);
}
if (error) {
return (
<Box display="flex" justifyContent="center" alignItems="center" minHeight="60vh">
<Typography color="error">{error}</Typography>
</Box>
);
}
return (
<Box sx={{ padding: 3 }}>
<Box display="flex" alignItems="center" justifyContent="center" mb={3}>
<EmojiEventsIcon sx={{ fontSize: 40, color: 'gold', mr: 1 }} />
<Typography variant="h4">
</Typography>
</Box>
{/* 难度选择 */}
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}>
<Tabs
value={difficulty}
onChange={handleDifficultyChange}
centered
textColor="primary"
indicatorColor="primary"
>
<Tab value="beginner" label={DIFFICULTY_LABELS.beginner} />
<Tab value="intermediate" label={DIFFICULTY_LABELS.intermediate} />
<Tab value="expert" label={DIFFICULTY_LABELS.expert} />
<Tab value="brutal" label={DIFFICULTY_LABELS.brutal} />
</Tabs>
</Box>
{/* 排行榜表格 */}
<TableContainer component={Paper} sx={{ mb: 3 }}>
<Table>
<TableHead>
<TableRow>
<TableCell align="center" width="80px"></TableCell>
<TableCell></TableCell>
<TableCell align="center"></TableCell>
<TableCell align="center"></TableCell>
<TableCell align="center"></TableCell>
<TableCell align="center"></TableCell>
<TableCell align="center"></TableCell>
</TableRow>
</TableHead>
<TableBody>
{records.length === 0 ? (
<TableRow>
<TableCell colSpan={7} align="center">
<Typography variant="body2" color="textSecondary" py={3}>
</Typography>
</TableCell>
</TableRow>
) : (
records.map((record, index) => {
const rank = (page - 1) * 10 + index + 1;
const badge = page === 1 ? getRankBadge(rank) : null;
return (
<TableRow
key={record.userId}
sx={badge ? { backgroundColor: 'rgba(255, 215, 0, 0.1)' } : {}}
>
<TableCell align="center">
<Box display="flex" alignItems="center" justifyContent="center" gap={0.5}>
<Typography fontWeight={badge ? 'bold' : 'normal'}>
{rank}
</Typography>
{badge && (
<Chip
size="small"
label={badge.emoji}
color={badge.color}
/>
)}
</Box>
</TableCell>
<TableCell>
<Typography fontWeight={badge ? 'bold' : 'normal'}>
{record.fullname || record.username}
</Typography>
</TableCell>
<TableCell align="center">
<Chip
label={formatTime(record.bestTime)}
color={rank <= 3 && page === 1 ? 'success' : 'default'}
size="small"
/>
</TableCell>
<TableCell align="center">{record.totalGames}</TableCell>
<TableCell align="center">{record.wonGames}</TableCell>
<TableCell align="center">
<Chip
label={`${record.winRate.toFixed(1)}%`}
color={record.winRate >= 50 ? 'success' : 'default'}
size="small"
/>
</TableCell>
<TableCell align="center">
{new Date(record.lastPlayed).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})}
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</TableContainer>
{/* 分页 */}
{totalPages > 1 && (
<Box display="flex" justifyContent="center" mt={3} mb={3}>
<Pagination
count={totalPages}
page={page}
onChange={handlePageChange}
color="primary"
size="large"
/>
</Box>
)}
{/* 说明 */}
<Paper sx={{ padding: 2, marginTop: 3, backgroundColor: '#f5f5f5' }}>
<Typography variant="body2" color="textSecondary">
💡
</Typography>
</Paper>
</Box>
);
};
export default MinesweeperLeaderboard;

View File

@@ -0,0 +1,59 @@
import React, { useMemo } from 'react';
import { Tabs } from 'antd';
import { useLocation } from 'react-router-dom';
import MinesweeperGame from './MinesweeperGame';
import MinesweeperLeaderboard from './MinesweeperLeaderboard';
import SpectatorMinesweeper from './SpectatorMinesweeper';
const MinesweeperTabs: React.FC = () => {
const location = useLocation();
// 检查是否在旁观模式
const isSpectatorMode = location.pathname.startsWith('/spectate/');
const roomId = isSpectatorMode ? location.pathname.split('/')[2] : null;
// 选项卡内容
const items = useMemo(() => {
if (isSpectatorMode && roomId) {
// 旁观模式:只显示旁观组件
return [
{
key: 'spectator',
label: '旁观模式',
children: <SpectatorMinesweeper roomId={roomId} />,
},
];
} else {
// 正常模式:显示游戏和排行榜
return [
{
key: 'game',
label: '扫雷游戏',
children: <MinesweeperGame />,
},
{
key: 'leaderboard',
label: '排行榜',
children: <MinesweeperLeaderboard />,
},
];
}
}, [isSpectatorMode, roomId]);
return (
<div style={{ maxWidth: 1400, margin: '0 auto', padding: '24px 0' }}>
{isSpectatorMode && roomId ? (
<SpectatorMinesweeper roomId={roomId} />
) : (
<Tabs
defaultActiveKey="game"
items={items}
tabBarGutter={32}
type="line"
/>
)}
</div>
);
};
export default MinesweeperTabs;

281
src/components/Navbar.css Normal file
View File

@@ -0,0 +1,281 @@
.logo-container {
width: 300px;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
background: #ffffff;
font-family: 'Segoe UI', Arial, sans-serif;
padding: 0 20px;
}
.logo {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
padding: 0;
}
.icon {
position: relative;
width: 36px;
height: 36px;
display: flex;
align-items: center;
margin-top: 4px;
}
.book {
position: absolute;
width: 30px;
height: 24px;
border: 3px solid #4CAF50;
border-radius: 3px;
transform: rotate(-10deg);
transition: all 0.5s ease;
transform-origin: bottom left;
bottom: 0;
}
.bulb {
position: absolute;
top: -8px;
left: 8px;
width: 16px;
height: 16px;
background: #4CAF50;
border-radius: 50%;
transition: all 0.3s ease;
}
.rays {
position: absolute;
top: -10px;
left: 6px;
width: 20px;
height: 20px;
}
.ray {
position: absolute;
width: 2px;
height: 6px;
background: #4CAF50;
opacity: 0;
}
/* 创建8个光线 */
.ray:nth-child(1) { transform: translate(9px, 0) rotate(0deg); }
.ray:nth-child(2) { transform: translate(16px, 3px) rotate(45deg); }
.ray:nth-child(3) { transform: translate(19px, 10px) rotate(90deg); }
.ray:nth-child(4) { transform: translate(16px, 17px) rotate(135deg); }
.ray:nth-child(5) { transform: translate(9px, 20px) rotate(180deg); }
.ray:nth-child(6) { transform: translate(2px, 17px) rotate(225deg); }
.ray:nth-child(7) { transform: translate(-1px, 10px) rotate(270deg); }
.ray:nth-child(8) { transform: translate(2px, 3px) rotate(315deg); }
@keyframes charge {
0% {
transform: translateY(10px);
opacity: 0;
}
50% {
opacity: 1;
}
100% {
transform: translateY(-5px);
opacity: 0;
}
}
@keyframes glow {
0%, 100% {
opacity: 0;
transform: scale(1);
}
50% {
opacity: 1;
transform: scale(1.2);
}
}
.logo:hover .bulb {
background: #FFD700;
box-shadow: 0 0 15px rgba(255, 215, 0, 0.5);
}
.logo:hover .ray {
animation: glow 1.5s infinite;
background: #FFD700;
}
.logo:hover .ray:nth-child(2) { animation-delay: 0.2s; }
.logo:hover .ray:nth-child(3) { animation-delay: 0.4s; }
.logo:hover .ray:nth-child(4) { animation-delay: 0.6s; }
.logo:hover .ray:nth-child(5) { animation-delay: 0.8s; }
.logo:hover .ray:nth-child(6) { animation-delay: 1.0s; }
.logo:hover .ray:nth-child(7) { animation-delay: 1.2s; }
.logo:hover .ray:nth-child(8) { animation-delay: 1.4s; }
.brand-text {
display: flex;
flex-direction: column;
gap: 0px;
justify-content: center;
}
.d1kt {
font-size: 26px;
font-weight: 300;
color: #FF7043;
letter-spacing: 1px;
line-height: 1;
margin: 0;
}
.one {
color: #4CAF50;
font-weight: 500;
transition: all 0.3s ease;
}
.cn {
font-size: 20px;
color: #9E9E9E;
transition: all 0.3s ease;
}
.tagline {
font-size: 12px;
color: #757575;
letter-spacing: 1px;
margin: 0;
line-height: 1;
position: relative;
margin-top: 1px;
}
.tagline:after {
content: '';
position: absolute;
bottom: -2px;
left: 0;
width: 0;
height: 1px;
background: #4CAF50;
transition: width 0.3s ease;
pointer-events: none;
}
.logo:hover .tagline:after {
width: 100%;
}
.logo:hover .book {
transform: rotate(-5deg) translateY(-2px);
box-shadow: 0 5px 15px rgba(76,175,80,0.2);
}
.logo:hover .d1kt {
color: #FF8A65;
}
.logo:hover .one {
color: #66BB6A;
}
.charging-dots {
position: absolute;
top: -5px;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
opacity: 0;
}
.dot {
width: 4px;
height: 4px;
background: #FFD700;
border-radius: 50%;
margin: 2px 0;
}
.logo:hover .charging-dots {
opacity: 1;
}
.logo:hover .dot {
animation: charge 1.5s infinite;
}
.logo:hover .dot:nth-child(2) {
animation-delay: 0.5s;
}
.logo:hover .dot:nth-child(3) {
animation-delay: 1s;
}
/* 导航菜单样式覆盖 */
.ant-menu-horizontal {
line-height: 68px !important;
border-bottom: none !important;
}
.ant-menu-horizontal > .ant-menu-item,
.ant-menu-horizontal > .ant-menu-submenu {
color: #666;
padding: 0 20px !important;
margin: 0 4px !important;
position: relative !important;
}
.ant-menu-horizontal > .ant-menu-item:hover,
.ant-menu-horizontal > .ant-menu-submenu:hover {
color: #4CAF50;
}
.ant-menu-horizontal > .ant-menu-item-selected {
color: #4CAF50 !important;
}
.ant-menu-horizontal > .ant-menu-item::after,
.ant-menu-horizontal > .ant-menu-submenu::after {
display: none !important;
}
.ant-menu-horizontal > .ant-menu-item-selected::after {
display: block !important;
border-bottom: 2px solid #4CAF50 !important;
bottom: 8px !important;
}
.navbar-header.ant-layout-header {
background: #ffffff !important;
height: 68px !important;
line-height: 68px !important;
padding: 4px 0 !important;
}
/* 增加选择器特异性 */
.navbar-brand .logo-container {
width: 300px;
height: 120px;
display: flex;
align-items: center;
justify-content: center;
background: #ffffff;
font-family: 'Segoe UI', Arial, sans-serif;
}
.navbar-brand .logo {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
padding: 0;
}

242
src/components/Navbar.tsx Normal file
View File

@@ -0,0 +1,242 @@
// src/components/NavBar.tsx
import React, { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import Menu from 'antd/es/menu';
import Layout from 'antd/es/layout';
import message from 'antd/es/message';
import type { MenuProps } from 'antd/es/menu';
import './Navbar.css';
const { Header } = Layout;
interface UserInfo {
username: string;
fullname: string;
isAdmin?: boolean;
}
const NavBar: React.FC = () => {
const navigate = useNavigate();
const [user, setUser] = useState<UserInfo | null>(null);
const currentPath = window.location.pathname;
// 添加一个函数来检查是否为打字练习相关页面
const isTypingRelatedPage = (path: string): boolean => {
const typingPaths = ['/typing', '/leaderboard', '/practice-history'];
return typingPaths.some(p => path.startsWith(p));
};
useEffect(() => {
// 统一处理用户状态更新
const updateUserState = () => {
const userStr = localStorage.getItem('user');
if (userStr) {
try {
const userInfo = JSON.parse(userStr);
setUser(userInfo);
} catch (error) {
console.error('Failed to parse user info:', error);
setUser(null);
}
} else {
setUser(null);
}
};
// 初始化用户状态
updateUserState();
// 监听所有用户状态变化事件
window.addEventListener('storage', updateUserState);
window.addEventListener('user-login', updateUserState);
window.addEventListener('user-logout', updateUserState);
return () => {
window.removeEventListener('storage', updateUserState);
window.removeEventListener('user-login', updateUserState);
window.removeEventListener('user-logout', updateUserState);
};
}, []);
const handleLogout = async () => {
try {
// 调用后端登出接口
await fetch('/api/auth/logout', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
// 清除 localStorage 中的数据
localStorage.removeItem('token');
localStorage.removeItem('user');
// 清除 cookie 中的 token
document.cookie = 'token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; secure;';
document.cookie = 'connect.sid=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; secure;';
// 触发登出事件
window.dispatchEvent(new Event('user-logout'));
message.success('已退出登录');
navigate('/login', { replace: true });
} catch (error) {
console.error('登出失败:', error);
message.error('登出过程中发生错误');
// 即使后端请求失败,也要清除本地数据
localStorage.removeItem('token');
localStorage.removeItem('user');
document.cookie = 'token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; secure;';
document.cookie = 'connect.sid=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; secure;';
window.dispatchEvent(new Event('user-logout'));
navigate('/login', { replace: true });
}
};
// 使用 MenuProps['items'] 作为类型
const getMenuItems = (): NonNullable<MenuProps['items']> => {
const baseItems = [
{
key: 'd1ktc',
label: <a href="https://c.d1kt.cn" target="_blank" rel="noopener noreferrer">AI平台</a>,
},
{
key: 'd1kt',
label: <a href="https://m.d1kt.cn" target="_blank" rel="noopener noreferrer"></a>,
},
// 打字练习分组
{
key: '/typing',
label: <Link to="/typing"></Link>,
},
// 游戏分组
{
key: 'games',
label: '游戏',
children: [
{
key: '/minesweeper',
label: <Link to="/minesweeper"></Link>,
},
{
key: '/tower-defense/play',
label: <Link to="/tower-defense/play"></Link>,
},
{
key: '/sudoku',
label: <Link to="/sudoku"></Link>,
}
]
},
// 词汇学习菜单项
{
key: '/vocabulary-study',
label: <Link to="/vocabulary-study"></Link>,
},
];
const authenticatedItems = user ? [
...(user.isAdmin ? [{
key: '/admin',
label: <Link to="/admin"></Link>,
}] : []),
{
key: '/profile',
label: user.fullname || user.username,
style: { marginLeft: 'auto' },
children: [ // 添加子菜单
{
key: '/change-password',
label: <Link to="/change-password"></Link>,
},
{
key: 'logout',
label: '退出登录',
onClick: handleLogout,
}
]
}
] : [
{
key: '/login',
label: <Link to="/login"></Link>,
style: { marginLeft: 'auto' },
}
];
return [...baseItems, ...authenticatedItems];
};
// 选中逻辑:如果当前路径是/typing、/leaderboard、/practice-history则高亮/typing-group和对应子项
let selectedKeys: string[] = [window.location.pathname];
if (['/typing', '/leaderboard', '/practice-history'].includes(window.location.pathname)) {
selectedKeys = [window.location.pathname, '/typing-group'];
}
// 游戏相关页面的高亮逻辑
if (
window.location.pathname.startsWith('/minesweeper') ||
window.location.pathname.startsWith('/tower-defense')
) {
selectedKeys = [window.location.pathname, 'games'];
}
return (
<Header className="navbar-header" style={{
padding: '8px 0',
background: '#ffffff',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
height: '100px'
}}>
<div style={{
display: 'flex',
alignItems: 'center',
height: '100%',
maxWidth: '1200px',
margin: '0 auto',
width: '100%'
}}>
<Link to="/" style={{ textDecoration: 'none' }}>
<div className="logo-container">
<div className="logo">
<div className="icon">
<div className="book"></div>
<div className="bulb"></div>
<div className="rays">
{[...Array(8)].map((_, i) => (
<div key={i} className="ray"></div>
))}
</div>
<div className="charging-dots">
{[...Array(3)].map((_, i) => (
<div key={i} className="dot"></div>
))}
</div>
</div>
<div className="brand-text">
<div className="d1kt">
d<span className="one">1</span>kt<span className="cn">.cn</span>
</div>
<div className="tagline">AI教育· </div>
</div>
</div>
</div>
</Link>
<Menu
mode="horizontal"
selectedKeys={selectedKeys}
items={getMenuItems()}
style={{
background: 'transparent',
borderBottom: 'none',
flex: 1,
minWidth: 0
}}
/>
</div>
</Header>
);
};
export default NavBar;

887
src/components/Practice.tsx Normal file
View File

@@ -0,0 +1,887 @@
// src/components/Practice.tsx
import React, { useState, useEffect, useRef} from 'react';
import message from 'antd/es/message';
import Input from 'antd/lib/input';
import Card from 'antd/lib/card';
import Progress from 'antd/lib/progress';
import Modal from 'antd/lib/modal';
import Row from 'antd/lib/row';
import Col from 'antd/lib/col';
import Button from 'antd/lib/button';
import { useNavigate, useParams } from 'react-router-dom';
import { api, ApiError, authEvents} from '../api/apiClient';
import { API_PATHS } from '../config';
import type { ChangeEvent, KeyboardEvent, ClipboardEvent } from 'react';
import VirtualKeyboard from './VirtualKeyboard';
import CryptoJS from 'crypto-js';
import { SoundOutlined, SettingOutlined } from '@ant-design/icons';
import Select from 'antd/lib/select';
import Slider from 'antd/lib/slider';
interface PracticeStats {
totalWords: number;
correctWords: number;
accuracy: number;
wordsPerMinute: number;
startTime: Date;
endTime?: Date;
duration: number;
}
interface CodeExampleResponse {
title: string;
content: string;
}
interface KeywordsResponse {
title: string;
content: string;
}
const Practice: React.FC = () => {
const [enterCount, setEnterCount] = useState(0);
const timeOffsetRef = useRef<number>(0);
const [timeOffset, setTimeOffset] = useState<number>(0);
const navigate = useNavigate();
const { level } = useParams<{ level: string }>();
const [content, setContent] = useState<string>('');
const [currentKeyword, setCurrentKeyword] = useState<string>('');
const [userInput, setUserInput] = useState<string>('');
const textAreaRef = useRef<HTMLTextAreaElement>(null);
const codePreviewRef = useRef<HTMLPreElement>(null);
// 将这两个状态定义移到组件开头
const [activeKey, setActiveKey] = useState<string | null>(null);
const [lastKey, setLastKey] = useState<string | null>(null);
const [shiftPressed, setShiftPressed] = useState(false);
const [lastComboShift, setLastComboShift] = useState<string | null>(null); // 记录最后组合键中的shift
const [lastNormalKey, setLastNormalKey] = useState<string | null>(null); // 记录最后按下的普通键
// 添加实际按键计数器状态
const [actualKeyCount, setActualKeyCount] = useState<number>(0);
const [stats, setStats] = useState<PracticeStats>({
totalWords: 0,
correctWords: 0,
accuracy: 0,
wordsPerMinute: 0,
startTime: new Date(),
duration: 0,
});
const [isModalVisible, setIsModalVisible] = useState(false);
const [timer, setTimer] = useState<NodeJS.Timeout | null>(null);
const [loading, setLoading] = useState(false);
const [title, setTitle] = useState<string>('');
const [codeComment, setCodeComment] = useState<string>('');
const [codeContent, setCodeContent] = useState<string>('');
// 添加调试信息的状态
const [debugInfo, setDebugInfo] = useState<{
keyCount: number;
inputLength: number;
lastInputChange: string;
timestamp: number;
}>({
keyCount: 0,
inputLength: 0,
lastInputChange: '',
timestamp: Date.now()
});
// 添加新的状态
const [currentKeywordToType, setCurrentKeywordToType] = useState<string>(''); // 用户需要输入的部分
// 防止复制粘贴的事件处理函数
const handlePaste = (e: ClipboardEvent) => {
e.preventDefault();
message.warning('练习模式下不允许粘贴');
};
const handleCopy = (e: ClipboardEvent) => {
e.preventDefault();
message.warning('练习模式下不允许复制');
};
const preventContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
};
useEffect(() => {
fetchContent();
const intervalTimer = setInterval(() => {
// 直接使用 ref 中的值
updateTimer();
}, 1000);
setTimer(intervalTimer);
const handleAuthError = (error: ApiError) => {
message.error(error.message);
setTimeout(() => {
navigate('/login');
}, 1500);
};
authEvents.onAuthError.add(handleAuthError);
return () => {
if (intervalTimer) {
clearInterval(intervalTimer);
}
authEvents.onAuthError.delete(handleAuthError);
};
}, [navigate]);
const updateTimer = () => {
const currentOffset = timeOffsetRef.current;
setStats(prev => {
const currentServerTime = Date.now() + currentOffset;
const duration = (currentServerTime - prev.startTime.getTime()) / 1000;
const wordsPerMinute = (prev.totalWords / duration) * 60;
return { ...prev, duration, wordsPerMinute };
});
};
const fetchContent = async () => {
try {
setLoading(true);
const { serverTime } = await api.get<{ serverTime: number }>(API_PATHS.SYSTEM.SERVER_TIME);
// 计算本地时间和服务器时间的差值
const localTime = Date.now();
const offset = serverTime - localTime;
timeOffsetRef.current = offset;
setTimeOffset(offset);
setStats(prev => ({
...prev,
startTime: new Date(serverTime)
}));
const endpoint = level === 'keyword'
? API_PATHS.KEYWORDS
: `${API_PATHS.CODE_EXAMPLES}/${level}`;
const response = await api.get<KeywordsResponse | CodeExampleResponse>(endpoint);
if (level === 'keyword') {
const keywordArray = response.content
.split('\n')
.filter(k => k.trim() !== '');
setContent(response.content);
getRandomKeyword(keywordArray);
} else {
const lines = response.content.split('\n');
const commentLine = lines.find(line => line.trim().startsWith('//')) || '';
const codeLines = lines.filter(line => !line.trim().startsWith('//'));
setCodeComment(commentLine);
setCodeContent(codeLines.join('\n'));
setContent(codeLines.join('\n'));
setTitle(level === 'keyword'?'':':' + response.title);
}
} catch (error) {
message.error('获取内容失败');
} finally {
setLoading(false);
}
};
const getRandomKeyword = (keywordArray: string[]) => {
if (keywordArray.length === 0) {
setCurrentKeyword('');
setCurrentKeywordToType('');
return;
}
const randomIndex = Math.floor(Math.random() * keywordArray.length);
const selectedKeyword = keywordArray[randomIndex];
const keywordParts = selectedKeyword.split(',');
setCurrentKeyword(selectedKeyword); // 保持完整内容显示
setCurrentKeywordToType(keywordParts[0].trim()); // 设置用户需要输入的部分
};
const handleInputChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value;
const oldLength = userInput.length;
const newLength = newValue.length;
setUserInput(newValue);
if (level !== 'keyword') {
updateStats(newValue);
}
};
const handleKeywordInputChange = (e: ChangeEvent<HTMLInputElement>) => {
setUserInput(e.target.value);
};
// 添加一个工具函数来过滤不可见字符
const removeInvisibleChars = (str: string): string => {
return str.replace(/\s+/g, ''); // 移除所有空白字符(空格、换行、制表符等)
};
// 修改 handleKeyDown 函数
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement | HTMLInputElement>) => {
// 处理 shift 状态
if (e.key === 'Shift') {
setShiftPressed(true);
const shiftKey = e.location === 1 ? 'leftshift' : 'rightshift';
setActiveKey(shiftKey);
// 如果没有其他键被按下则更新lastComboShift
if (!lastNormalKey) {
setLastComboShift(shiftKey);
setLastKey(shiftKey);
}
} else {
// 对于非 shift 键
const key = e.key.toLowerCase();
setActiveKey(key);
setLastNormalKey(key);
// 如果当前按着 shift记录组合键状态
if (shiftPressed) {
const currentShiftKey = lastComboShift || (e.getModifierState('Shift') && e.location === 1 ? 'leftshift' : 'rightshift');
setLastComboShift(currentShiftKey);
setLastKey(key); // 保持最新按下的键的状态
} else {
// 如果没有按 shift清除组合键状态
setLastComboShift(null);
setLastKey(key);
}
}
// 设置当前按下的键(用于虚拟键盘显示)
const key = e.key === 'Shift'
? (e.location === 1 ? 'leftshift' : 'rightshift')
: e.key.toLowerCase();
setActiveKey(key);
if (level !== 'keyword') {
const visibleInputLength = removeInvisibleChars(userInput).length;
const visibleContentLength = removeInvisibleChars(content).length;
console.log(visibleInputLength,visibleContentLength,actualKeyCount);
if (visibleInputLength > actualKeyCount + 20) {
message.error('检测到异常输入行为,练习记录将不被保存');
setIsModalVisible(false);
navigate('/practice-history');
return;
}
}
// 处理关键字模式的回车键
if (level === 'keyword' && e.key === 'Enter') {
setEnterCount(prev => prev + 9937);
// 检查是否作弊
if (userInput.length > actualKeyCount + 3) {
message.error('检测到异常输入行为,请重新输入');
setUserInput('');
setActualKeyCount(0);
return;
}
const isCorrect = userInput.trim() === currentKeywordToType; // 使用 currentKeywordToType 进行验证
updateKeywordStats(isCorrect);
setUserInput('');
setActualKeyCount(0);
getRandomKeyword(content.split('\n').filter(k => k.trim() !== ''));
return;
}
// 计数有效的键盘输入
if (!e.ctrlKey && !e.metaKey) {
if (e.key.length === 1) { // 普通字符输入
setActualKeyCount(prev => prev + 1);
}
}
// 处理代码模式的特殊键
if (level !== 'keyword' && e.currentTarget instanceof HTMLTextAreaElement) {
// 处理 Tab 键
if (e.key === 'Tab') {
e.preventDefault();
const start = e.currentTarget.selectionStart;
const end = e.currentTarget.selectionEnd;
const value = e.currentTarget.value;
const newValue = value.substring(0, start) + ' ' + value.substring(end);
setUserInput(newValue);
if (textAreaRef.current) {
textAreaRef.current.value = newValue;
textAreaRef.current.selectionStart = textAreaRef.current.selectionEnd = start + 4;
}
}
// 处理回车键
if (e.key === 'Enter') {
e.preventDefault();
const start = e.currentTarget.selectionStart;
const value = e.currentTarget.value;
const lineStart = value.lastIndexOf('\n', start - 1) + 1;
const currentLine = value.slice(lineStart, start);
const indentMatch = currentLine.match(/^\s*/);
const indent = indentMatch ? indentMatch[0] : '';
const needsExtraIndent = value.slice(Math.max(0, start - 1), start) === '{';
const extraIndent = needsExtraIndent ? ' ' : '';
const newValue = value.substring(0, start) + '\n' + indent + extraIndent + value.substring(start);
setUserInput(newValue);
const newPosition = start + 1 + indent.length + extraIndent.length;
if (textAreaRef.current) {
textAreaRef.current.value = newValue;
textAreaRef.current.selectionStart = textAreaRef.current.selectionEnd = newPosition;
}
}
// 处理右大括号 }
if (e.key === '}') {
const start = e.currentTarget.selectionStart;
const value = e.currentTarget.value;
const lineStart = value.lastIndexOf('\n', start - 1) + 1;
const currentLine = value.slice(lineStart, start);
if (/^\s*$/.test(currentLine)) {
e.preventDefault();
const newIndent = currentLine.slice(0, Math.max(0, currentLine.length - 4));
const newValue = value.substring(0, lineStart) + newIndent + '}' + value.substring(start);
setUserInput(newValue);
if (textAreaRef.current) {
textAreaRef.current.value = newValue;
textAreaRef.current.selectionStart = textAreaRef.current.selectionEnd = lineStart + newIndent.length + 1;
}
}
}
}
};
// 修改 handleKeyUp 函数
const handleKeyUp = (e: React.KeyboardEvent<HTMLTextAreaElement | HTMLInputElement>) => {
if (e.key === 'Shift') {
setShiftPressed(false);
setActiveKey(null);
// 松开 shift 时清除所有状态
setLastKey(null);
setLastComboShift(null);
setLastNormalKey(null);
} else {
const key = e.key.toLowerCase();
setActiveKey(null);
// 松开普通键时,如果当前没有按着 shift就清除最后的普通键记录
if (!shiftPressed) {
setLastNormalKey(null);
setLastComboShift(null);
setLastKey(key);
}
}
};
const updateKeywordStats = (isCorrect: boolean) => {
setStats(prev => ({
...prev,
totalWords: prev.totalWords + 1,
correctWords: prev.correctWords + (isCorrect ? 1 : 0),
accuracy: ((prev.correctWords + (isCorrect ? 1 : 0)) / (prev.totalWords + 1)) * 100,
}));
if (isCorrect) {
message.success('正确!');
} else {
message.error(`错误! 正确答案是: ${currentKeywordToType}`);
}
};
const updateStats = (currentInput: string) => {
const processCode = (code: string) => {
// 1. 标准化代码,处理不影响语法的空格差异
let processed = code
// 移除所有多余空格,包括行首行尾
.replace(/^\s+|\s+$/gm, '')
// 将多个空格替换为单个空格
.replace(/\s+/g, ' ')
// 处理冒周围的空格(构造函数初始化列表)
.replace(/\s*:\s*/g, ':')
// 处理等号周围的空格
.replace(/\s*=\s*/g, '=')
// 处理逗号后的空格
.replace(/,\s*/g, ',')
// 处理分号后的空格
.replace(/;\s*/g, ';')
// 处理括号周围的空格
.replace(/\s*\(\s*/g, '(')
.replace(/\s*\)\s*/g, ')')
.replace(/\s*{\s*/g, '{')
.replace(/\s*}\s*/g, '}')
// 处理运算符周围的空格
.replace(/\s*([+\-*/<>])\s*/g, '$1');
// 2. 分割成标记
return processed.split(/([{}()[\]*,;=<>])/g)
.filter(token => token.trim() !== '')
.map(token => token.trim());
};
// 处理输入和目标代码
const inputTokens = processCode(currentInput);
const contentTokens = processCode(content);
// 只比较已输入的部分
const tokensToCompare = contentTokens.slice(0, inputTokens.length);
// 计算正确的标记数
let correctCount = 0;
inputTokens.forEach((token, index) => {
if (index < tokensToCompare.length && token === tokensToCompare[index]) {
correctCount++;
}
});
// 更新统计信息
setStats(prev => ({
...prev,
totalWords: inputTokens.length || 1,
correctWords: correctCount,
accuracy: (correctCount / (inputTokens.length || 1)) * 100,
}));
};
const handleExit = () => {
setIsModalVisible(true);
};
// 修改 confirmExit 函数,补全被截断的部分
const confirmExit = async () => {
const accuracyThreshold = 90;
if (stats.accuracy < accuracyThreshold) {
message.warning(`因为你的准确率未达到${accuracyThreshold}%,所以本次练习不保存记录`);
setIsModalVisible(false);
navigate('/practice-history');
return;
}
if (level !== 'keyword') {
const visibleInputLength = removeInvisibleChars(userInput).length;
const visibleContentLength = removeInvisibleChars(content).length;
console.log(visibleInputLength,visibleContentLength,actualKeyCount);
if (visibleInputLength > actualKeyCount + 20) {
message.error('检测到异常输入行为,练习记录将不被保存');
setIsModalVisible(false);
navigate('/practice-history');
return;
}
}
if(level === 'keyword' &&(stats.totalWords*9937!==enterCount || Math.abs(stats.correctWords/stats.totalWords -stats.accuracy/100)>0.005)){
console.log(stats.totalWords*9937,enterCount,stats.correctWords/stats.totalWords,stats.accuracy/100);
message.error('数据异常,保存失败');
setIsModalVisible(false);
navigate('/practice-history');
return;
}
try {
const { serverTime } = await api.get<{ serverTime: number }>(API_PATHS.SYSTEM.SERVER_TIME);
const practiceData = {
type: level,
stats: {
totalWords: stats.totalWords,
correctWords: stats.correctWords,
accuracy: stats.accuracy,
wordsPerMinute: stats.wordsPerMinute,
startTime: stats.startTime,
endTime: new Date(serverTime),
duration: (serverTime - stats.startTime.getTime()) / 1000
}
};
const orderedData = JSON.stringify(practiceData, Object.keys(practiceData).sort());
// 3. 生成签名
const signature = CryptoJS.SHA256(orderedData + serverTime).toString();
// 4. 发送数据
await api.post(API_PATHS.PRACTICE_RECORDS, {
...practiceData,
timestamp: serverTime,
signature
});
message.success('练习记录已保存');
navigate('/practice-history');
} catch (error) {
console.error('保存失败,错误详情:', {
error,
errorType: error.constructor.name,
errorMessage: error instanceof Error ? error.message : String(error),
errorStack: error instanceof Error ? error.stack : undefined,
stats: {
accuracy: stats.accuracy,
duration: stats.duration,
totalWords: stats.totalWords,
correctWords: stats.correctWords,
startTime: stats.startTime.toISOString(),
level,
inputLength: userInput.length
},
auth: {
hasToken: !!localStorage.getItem('token'),
hasUser: !!localStorage.getItem('user'),
tokenExpired: false // 如果有token解析功能可以添加过期检查
},
requestInfo: {
endpoint: API_PATHS.PRACTICE_RECORDS,
timestamp: new Date().toISOString()
}
});
if (error instanceof ApiError) {
message.error(`保存失败: ${error.message}`);
// 如果是认证错误,重定向到登录页
if (error.statusCode === 401) {
localStorage.setItem('redirectPath', window.location.pathname);
navigate('/login');
return;
}
if (error.statusCode === 409) {
message.warning('该练习记录已经保存,请勿重复提交');
}
} else {
message.error('保存失败,请稍后重试');
}
} finally {
setIsModalVisible(false);
}
};
const renderStats = () => (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
marginBottom: 20,
gap: 40,
width: '100%'
}}>
<Progress
type="circle"
percent={Math.round(stats.accuracy)}
format={(percent?: number) => `正确率: ${percent || 0}%`}
width={120}
/>
<div>
<p style={{ margin: '5px 0' }}>: {stats.totalWords}</p>
<p style={{ margin: '5px 0' }}>: {stats.correctWords}</p>
<p style={{ margin: '5px 0' }}>: {Math.round(stats.wordsPerMinute)}</p>
<p style={{ margin: '5px 0' }}>: {Math.round(stats.duration)}</p>
</div>
</div>
);
// 添加播放声音的函数
const [voices, setVoices] = useState<SpeechSynthesisVoice[]>([]);
const [selectedVoice, setSelectedVoice] = useState<string>('');
// 在 useEffect 中初始化声音列表
useEffect(() => {
const loadVoices = () => {
const availableVoices = window.speechSynthesis.getVoices();
const englishVoices = availableVoices.filter(voice =>
voice.lang.includes('en')
);
setVoices(englishVoices);
if (englishVoices.length > 0) {
setSelectedVoice(englishVoices[0].name);
}
};
// Chrome 需要这个事件
if (window.speechSynthesis) {
window.speechSynthesis.onvoiceschanged = loadVoices;
loadVoices();
}
}, []);
// 修改后的播放函数
const playWordSound = (word: string) => {
if (!('speechSynthesis' in window)) {
message.warning('您的浏览器不支持语音合成功能');
return;
}
const utterance = new SpeechSynthesisUtterance(word);
// 使用选中的声音
const voice = voices.find(v => v.name === selectedVoice);
if (voice) {
utterance.voice = voice;
}
utterance.lang = 'en-US';
utterance.rate = 0.9;
utterance.pitch = 1;
utterance.volume = 1;
window.speechSynthesis.speak(utterance);
};
// 在设置区域添加声音选择器
const VoiceSelector = () => (
<Select
value={selectedVoice}
onChange={setSelectedVoice}
style={{ width: 200 }}
>
{voices.map(voice => (
<Select.Option key={voice.name} value={voice.name}>
{voice.name} ({voice.lang})
</Select.Option>
))}
</Select>
);
const [isSettingsVisible, setIsSettingsVisible] = useState(false);
if (loading) {
return (
<Card>
<div style={{ textAlign: 'center', padding: '20px' }}>
...
</div>
</Card>
);
}
return (
<Card title={`${level === 'keyword' ? '关键字' : '代码'}打字练习 ${title}`}>
{level === 'keyword' ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', maxWidth: '1000px', margin: '0 auto' }}>
{renderStats()}
<div style={{
margin: '20px 0',
fontSize: '24px',
fontFamily: 'monospace',
display: 'flex',
alignItems: 'center',
gap: '10px'
}}>
{currentKeyword ? (
<>
{currentKeyword}
{currentKeyword.includes(',') && (
<>
<SoundOutlined
style={{
cursor: 'pointer',
fontSize: '20px',
color: '#1890ff'
}}
onClick={() => playWordSound(currentKeyword.split(',')[0])}
/>
<SettingOutlined
style={{
cursor: 'pointer',
fontSize: '18px',
color: '#666'
}}
onClick={() => setIsSettingsVisible(true)}
/>
</>
)}
</>
) : '没有可用的关键字'}
</div>
<div style={{ display: 'flex', alignItems: 'center', width: '80%', maxWidth: '600px', marginBottom: 20 }}>
<Input
value={userInput}
onChange={handleKeywordInputChange}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
onPaste={handlePaste}
onCopy={handleCopy}
onContextMenu={preventContextMenu}
placeholder="输入关键字,按回车确认"
style={{ flex: 1, marginRight: '10px' }}
autoFocus
/>
<Button type="primary" danger onClick={handleExit} style={{ width: '120px' }}>
</Button>
</div>
</div>
) : (
<div style={{
maxWidth: 1200,
margin: '0 auto',
padding: '20px 0',
display: 'flex',
flexDirection: 'column',
alignItems: 'center'
}}>
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
marginBottom: 20,
gap: 40,
width: '100%'
}}>
<Progress
type="circle"
percent={Math.round(stats.accuracy)}
format={(percent?: number) => `正确率: ${percent || 0}%`}
width={120}
/>
<div>
<p style={{ margin: '5px 0' }}>: {stats.totalWords}</p>
<p style={{ margin: '5px 0' }}>: {stats.correctWords}</p>
<p style={{ margin: '5px 0' }}>: {Math.round(stats.wordsPerMinute)}</p>
<p style={{ margin: '5px 0' }}>: {Math.round(stats.duration)}</p>
</div>
</div>
<div style={{ width: 1200 }}>
{codeComment && (
<div style={{
background: '#f5f5f5',
padding: '8px 16px',
marginBottom: 20,
borderRadius: 4,
color: '#666',
fontFamily: 'Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace',
fontSize: '14px'
}}>
{codeComment}
</div>
)}
<Row gutter={16} style={{ justifyContent: 'space-between' }}>
<Col style={{ width: 584 }}>
<div style={{
height: 400,
background: '#f5f5f5',
borderRadius: 4,
overflow: 'hidden'
}}>
<pre style={{
height: '100%',
margin: 0,
padding: 16,
whiteSpace: 'pre-wrap',
wordWrap: 'break-word',
fontSize: '14px',
lineHeight: '1.5',
fontFamily: 'Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace',
overflow: 'auto'
}}>
{codeContent}
</pre>
</div>
</Col>
<Col style={{ width: 584 }}>
<div style={{
height: 400,
background: '#fff',
borderRadius: 4,
}}>
<Input.TextArea
ref={textAreaRef}
value={userInput}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
onPaste={handlePaste}
onCopy={handleCopy}
onContextMenu={preventContextMenu}
placeholder="在此输入代码"
style={{
height: '100%',
fontSize: '14px',
lineHeight: '1.5',
fontFamily: 'Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace',
resize: 'none',
padding: 16,
border: '1px solid #d9d9d9',
borderRadius: 4,
whiteSpace: 'pre',
tabSize: 2
}}
autoFocus
/>
</div>
</Col>
</Row>
<div style={{
display: 'flex',
justifyContent: 'flex-end',
marginTop: 20
}}>
<Button
type="primary"
danger
onClick={handleExit}
style={{
width: 120,
height: 32
}}
>
</Button>
</div>
</div>
</div>
)}
<Modal
title="确认结束练习"
open={isModalVisible}
onOk={confirmExit}
onCancel={() => setIsModalVisible(false)}
>
<p></p>
<p>: {Math.round(stats.accuracy)}%</p>
<p>: {Math.round(stats.wordsPerMinute)}</p>
</Modal>
<div style={{ marginTop: 20 }}>
<VirtualKeyboard
activeKey={activeKey}
lastKey={lastKey}
shiftPressed={shiftPressed}
lastComboShift={lastComboShift}
/>
</div>
<Modal
title="语音设置"
open={isSettingsVisible}
onOk={() => setIsSettingsVisible(false)}
onCancel={() => setIsSettingsVisible(false)}
style={{ zIndex: 1001 }}
>
<div style={{ marginBottom: 16 }}>
<div style={{ marginBottom: 8 }}></div>
<Select
value={selectedVoice}
onChange={setSelectedVoice}
style={{ width: '100%' }}
dropdownStyle={{ zIndex: 1002 }}
listHeight={300}
optionFilterProp="children"
showSearch
>
{voices.map(voice => (
<Select.Option key={voice.name} value={voice.name}>
{voice.name} ({voice.lang})
</Select.Option>
))}
</Select>
</div>
<div>
<div style={{ marginBottom: 8 }}></div>
<Slider
min={0.5}
max={2}
step={0.1}
defaultValue={0.9}
onChange={value => {
// 可以添加语速设置
}}
/>
</div>
</Modal>
</Card>
);
};
export default Practice;

View File

@@ -0,0 +1,130 @@
// src/components/PracticeHistory.tsx
import React, { useEffect, useState } from 'react';
import Table from 'antd/es/table';
import Card from 'antd/es/card';
import Tag from 'antd/es/tag';
import Spin from 'antd/es/spin';
import type { ColumnsType } from 'antd/es/table';
import { api, ApiError } from '../api/apiClient';
import { message } from 'antd';
import { API_PATHS } from '../config';
interface PracticeRecord {
_id: string;
type: string;
stats: {
totalWords: number;
correctWords: number;
accuracy: number;
wordsPerMinute: number;
startTime: string;
endTime: string;
duration: number;
};
createdAt: string;
}
const PracticeHistory: React.FC = () => {
const [loading, setLoading] = useState(true);
const [records, setRecords] = useState<PracticeRecord[]>([]);
const fetchPracticeRecords = async () => {
try {
const response = await api.get<PracticeRecord[]>(`${API_PATHS.PRACTICE_RECORDS}/my-records`);
setRecords(response);
} catch (error) {
console.error('Error fetching practice records:', error);
message.error('获取练习记录失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchPracticeRecords();
}, []);
const getAccuracyColor = (accuracy: number) => {
if (accuracy >= 95) return 'green';
if (accuracy >= 85) return 'blue';
if (accuracy >= 75) return 'orange';
return 'red';
};
const columns: ColumnsType<PracticeRecord> = [
{
title: '练习类型',
dataIndex: 'type',
key: 'type',
render: (type) => (
<Tag color="blue">
{type === 'keyword' ? '关键字练习' : '代码练习'}
</Tag>
),
},
{
title: '练习时间',
dataIndex: 'createdAt',
key: 'createdAt',
render: (date) => new Date(date).toLocaleString('zh-CN'),
sorter: (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
},
{
title: '打字速度',
dataIndex: ['stats', 'wordsPerMinute'],
key: 'speed',
render: (wpm) => `${Math.round(wpm)} WPM`,
sorter: (a, b) => a.stats.wordsPerMinute - b.stats.wordsPerMinute,
},
{
title: '准确率',
dataIndex: ['stats', 'accuracy'],
key: 'accuracy',
render: (accuracy) => (
<Tag color={getAccuracyColor(accuracy)}>
{accuracy.toFixed(2)}%
</Tag>
),
sorter: (a, b) => a.stats.accuracy - b.stats.accuracy,
},
{
title: '练习时长',
dataIndex: ['stats', 'duration'],
key: 'duration',
render: (duration) => `${Math.round(duration)}`,
},
{
title: '总字数',
dataIndex: ['stats', 'totalWords'],
key: 'totalWords',
},
{
title: '正确字数',
dataIndex: ['stats', 'correctWords'],
key: 'correctWords',
},
];
return (
<Card title="练习历史记录" className="m-4">
{loading ? (
<div className="flex justify-center items-center p-8">
<Spin size="large" />
</div>
) : (
<Table
columns={columns}
dataSource={records}
rowKey="_id"
pagination={{
defaultPageSize: 10,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `${total} 条记录`,
}}
/>
)}
</Card>
);
};
export default PracticeHistory;

176
src/components/Register.tsx Normal file
View File

@@ -0,0 +1,176 @@
import React, { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { TextField, Button, Typography, Paper, Grid } from '@mui/material';
import { api } from '../api/apiClient';
import { API_PATHS } from '../config';
import message from 'antd/es/message';
import { RegisterFormValues, LoginResponse } from '../types/auth';
interface RegisterResponse {
token: string;
user: {
_id: string;
username: string;
fullname: string;
isAdmin: boolean;
};
}
const Register: React.FC = () => {
const [formData, setFormData] = useState<RegisterFormValues>({
username: '',
email: '',
fullname: '',
password: '',
confirmPassword: ''
});
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData({
...formData,
[e.target.name]: e.target.value
});
};
const handleRegister = async () => {
if (!formData.username || !formData.password || !formData.confirmPassword|| !formData.email|| !formData.fullname) {
message.error('请填写必填字段');
return;
}
if (formData.password !== formData.confirmPassword) {
message.error('两次输入的密码不匹配');
return;
}
if (formData.username.length < 2 || formData.username.length > 20) {
message.error('用户名长度应在2-20个字符之间');
return;
}
if (formData.password.length < 6) {
message.error('密码长度至少6个字符');
return;
}
if (!formData.email.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)) {
message.error('请输入有效的邮箱地址');
return;
}
if (formData.fullname.length < 2 || formData.fullname.length > 50) { // 新增验证
message.error('姓名长度应在2-50个字符之间');
return;
}
setLoading(true);
try {
// 构造发送的数据,排除 confirmPassword
const { confirmPassword, ...registerData } = formData;
const response = await api.post<LoginResponse>(
`${API_PATHS.AUTH}/register`,
registerData
);
localStorage.setItem('token', response.token);
localStorage.setItem('user', JSON.stringify(response.user));
message.success('注册成功');
navigate('/', { replace: true });
window.location.reload();
} catch (error: any) {
console.error('Registration failed:', error);
const errorMessage = error.response?.data?.error
|| error.response?.data?.message
|| error.response?.data
|| error.message
|| '注册失败,请稍后重试';
message.error(errorMessage);
} finally {
setLoading(false);
}
};
return (
<Grid container justifyContent="center" style={{ marginTop: '10%' }}>
<Grid item xs={10} sm={6} md={4}>
<Paper style={{ padding: 20 }}>
<Typography variant="h5" align="center" gutterBottom>
</Typography>
<TextField
label="用户名"
name="username"
value={formData.username}
onChange={handleChange}
fullWidth
margin="normal"
required
disabled={loading}
/>
<TextField
label="姓名"
name="fullname"
value={formData.fullname}
onChange={handleChange}
fullWidth
margin="normal"
required
disabled={loading}
/>
<TextField
label="邮箱"
name="email"
type="email"
value={formData.email}
onChange={handleChange}
fullWidth
margin="normal"
required
disabled={loading}
/>
<TextField
label="密码"
name="password"
type="password"
value={formData.password}
onChange={handleChange}
fullWidth
margin="normal"
required
disabled={loading}
/>
<TextField
label="确认密码"
name="confirmPassword"
type="password"
value={formData.confirmPassword}
onChange={handleChange}
fullWidth
margin="normal"
required
disabled={loading}
/>
<Button
variant="contained"
color="primary"
fullWidth
onClick={handleRegister}
style={{ marginTop: 16 }}
disabled={loading}
>
{loading ? '注册中...' : '注册'}
</Button>
<Typography align="center" style={{ marginTop: 16 }}>
<Link to="/login"></Link>
</Typography>
</Paper>
</Grid>
</Grid>
);
};
export default Register;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,95 @@
import React, { useState, useEffect } from 'react';
import { Table, Input, Button } from 'antd';
import { api, ApiError } from '../api/apiClient';
import { API_PATHS } from '../config';
interface StudentData {
name: string;
url_path: string;
is_absent: number;
exam_number: string;
}
const StudentSearch: React.FC = () => {
const [searchTerm, setSearchTerm] = useState('');
const [filteredData, setFilteredData] = useState<StudentData[]>([]);
const [allStudents, setAllStudents] = useState<StudentData[]>([]);
const [visitorIp, setVisitorIp] = useState<string>('');
useEffect(() => {
// 读取 JSON 文件
fetch('/output.json')
.then(response => response.json())
.then(data => setAllStudents(data))
.catch(error => console.error('Error loading JSON:', error));
// 获取访问者IP
const fetchVisitorIp = async () => {
try {
const response = await api.get<{ ip: string }>(`${API_PATHS.VISITOR}/ip`);
setVisitorIp(response.ip);
} catch (error) {
if (error instanceof ApiError) {
console.error('API Error fetching IP:', error.message);
} else {
console.error('Error fetching IP:', error);
}
}
};
fetchVisitorIp();
}, []);
const handleSearch = () => {
const filtered = allStudents.filter(student =>
student.exam_number.includes(searchTerm)
);
setFilteredData(filtered);
};
const columns = [
{
title: '姓名',
dataIndex: 'name',
key: 'name',
},
{
title: '是否缺考',
dataIndex: 'is_absent',
key: 'is_absent',
render: (isAbsent: number) => isAbsent === 0 ? '未缺考' : '缺考',
},
{
title: '学号',
dataIndex: 'exam_number',
key: 'exam_number',
},
{
title: '试卷',
dataIndex: 'url_path',
key: 'url_path',
render: (urlPath: string) => (
<a href={`https://yjpic.21cnjy.com/${urlPath}`} target="_blank" rel="noopener noreferrer">
</a>
),
},
];
return (
<div>
<div style={{ marginBottom: 16 }}>
<strong>IP地址:</strong> {visitorIp || '加载中...'}
</div>
<Input
placeholder="输入学号进行搜索"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
style={{ width: 200, marginRight: 10 }}
/>
<Button onClick={handleSearch}></Button>
<Table dataSource={filteredData} columns={columns} rowKey="exam_number" />
</div>
);
};
export default StudentSearch;

View File

@@ -0,0 +1,610 @@
import React, { useState, useMemo, useEffect } from 'react';
import { API_BASE_URL } from '../config';
import {
GameBoard,
Difficulty,
GameMode,
STANDARD_REGION_MAP,
generatePuzzle,
getRegionId,
getRegionNeighbors,
getRegionMapByMode,
} from './sudokuEngine';
type HighlightType = 'none' | 'row' | 'col' | 'box' | 'sameNumber';
const SudokuGame: React.FC = () => {
// 游戏难度
const [difficulty, setDifficulty] = useState<Difficulty>('hard');
// 游戏模式:标准或不规则
const [gameMode, setGameMode] = useState<GameMode>('standard');
// 使用 useMemo 确保 regionMap 随 gameMode 变化
const regionMap = useMemo(() => getRegionMapByMode(gameMode), [gameMode]);
const [board, setBoard] = useState<GameBoard>(() => generatePuzzle('hard', STANDARD_REGION_MAP));
const [fixedCells, setFixedCells] = useState<boolean[][]>(() =>
board.map(row => row.map(cell => cell !== 0))
);
// 选中的格子 [row, col]
const [selectedCell, setSelectedCell] = useState<[number, number] | null>(null);
// 游戏完成状态
const [isGameComplete, setIsGameComplete] = useState(false);
// 完成对话框显示状态
const [showCompleteDialog, setShowCompleteDialog] = useState(false);
const [timeElapsed, setTimeElapsed] = useState(0);
const [isTimerRunning, setIsTimerRunning] = useState(true);
// 配置选项
const [config, setConfig] = useState({
highlightRegion: true, // 突出显示区域(行、列、宫)
highlightSameNumbers: true, // 突出显示相同数字
showRemainingCount: true, // 显示剩余数字数量
});
// 检查在位置 (row, col) 放置数字 num 是否有效
const isValid = (row: number, col: number, num: number): boolean => {
for (let c = 0; c < 9; c++) {
const cell = board[row][c];
if (c !== col && typeof cell === 'number' && cell === num) return false;
}
for (let r = 0; r < 9; r++) {
const cell = board[r][col];
if (r !== row && typeof cell === 'number' && cell === num) return false;
}
const targetRegion = getRegionId(regionMap, row, col);
for (let r = 0; r < 9; r++) {
for (let c = 0; c < 9; c++) {
const cell = board[r][c];
if ((r !== row || c !== col) && getRegionId(regionMap, r, c) === targetRegion && typeof cell === 'number' && cell === num) return false;
}
}
return true;
};
// 检查数独是否完成
const isComplete = (): boolean => {
for (let r = 0; r < 9; r++) {
for (let c = 0; c < 9; c++) {
const cell = board[r][c];
if (typeof cell !== 'number') return false;
if (!isValid(r, c, cell)) return false;
}
}
return true;
};
const getCellValue = (cell: number): number | null => {
return cell === 0 ? null : cell;
};
const formatTime = (seconds: number): string => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
const saveGameRecord = async (won: boolean, timeSeconds: number) => {
try {
const token = window.localStorage.getItem('token');
if (!token) {
console.log('未登录,不保存数独记录');
return;
}
const response = await fetch(`${API_BASE_URL}/api/sudoku/record`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
difficulty,
timeSeconds,
won
})
});
if (response.ok) {
console.log('数独游戏记录保存成功');
} else {
console.warn('保存数独记录失败', response.statusText);
}
} catch (error) {
console.error('保存数独记录失败:', error);
}
};
const handleNumberInput = (num: number) => {
if (!selectedCell || isGameComplete) return;
const [row, col] = selectedCell;
if (fixedCells[row][col]) return;
const newBoard = board.map(row => [...row]);
newBoard[row][col] = num;
setBoard(newBoard);
// 检查是否完成
setTimeout(() => {
if (checkIsComplete(newBoard)) {
setIsGameComplete(true);
setIsTimerRunning(false);
setShowCompleteDialog(true);
saveGameRecord(true, timeElapsed);
}
}, 0);
};
const checkIsComplete = (boardToCheck: GameBoard): boolean => {
for (let r = 0; r < 9; r++) {
for (let c = 0; c < 9; c++) {
const cell = boardToCheck[r][c];
if (cell === 0) return false;
if (!isValidInBoard(r, c, cell, boardToCheck)) return false;
}
}
return true;
};
const isValidInBoard = (row: number, col: number, num: number, boardToCheck: GameBoard): boolean => {
for (let c = 0; c < 9; c++) {
if (c !== col && boardToCheck[row][c] === num) return false;
}
for (let r = 0; r < 9; r++) {
if (r !== row && boardToCheck[r][col] === num) return false;
}
// 使用 regionMap 检查不规则区域
const targetRegion = getRegionId(regionMap, row, col);
for (let r = 0; r < 9; r++) {
for (let c = 0; c < 9; c++) {
if ((r !== row || c !== col) && getRegionId(regionMap, r, c) === targetRegion && boardToCheck[r][c] === num) return false;
}
}
return true;
};
const handleClear = () => {
if (!selectedCell || isGameComplete) return;
const [row, col] = selectedCell;
const newBoard = board.map(row => [...row]);
newBoard[row][col] = 0;
setBoard(newBoard);
};
const createNewGame = (level: Difficulty, mode: GameMode = gameMode) => {
const nextRegionMap = getRegionMapByMode(mode);
const newPuzzle = generatePuzzle(level, nextRegionMap);
setBoard(newPuzzle);
setFixedCells(newPuzzle.map(row => row.map(cell => cell !== 0)));
setSelectedCell(null);
setIsGameComplete(false);
setShowCompleteDialog(false);
setTimeElapsed(0);
setIsTimerRunning(true);
};
const handleNewGame = () => {
createNewGame(difficulty);
};
const handleDifficultyChange = (level: Difficulty) => {
setDifficulty(level);
createNewGame(level);
};
const handleModeChange = (mode: GameMode) => {
setGameMode(mode);
createNewGame(difficulty, mode);
};
const handleCellClick = (row: number, col: number) => {
setSelectedCell([row, col]);
};
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!selectedCell) return;
const [row, col] = selectedCell;
switch (e.key) {
case 'ArrowUp':
e.preventDefault();
setSelectedCell([Math.max(0, row - 1), col]);
break;
case 'ArrowDown':
e.preventDefault();
setSelectedCell([Math.min(8, row + 1), col]);
break;
case 'ArrowLeft':
e.preventDefault();
setSelectedCell([row, Math.max(0, col - 1)]);
break;
case 'ArrowRight':
e.preventDefault();
setSelectedCell([row, Math.min(8, col + 1)]);
break;
case 'Backspace':
case 'Delete':
case ' ':
e.preventDefault();
handleClear();
break;
case '1':
case '2':
case '3':
case '4':
case '5':
case '6':
case '7':
case '8':
case '9':
e.preventDefault();
handleNumberInput(Number(e.key));
break;
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [selectedCell, board]);
useEffect(() => {
if (!isTimerRunning) return;
const intervalId = window.setInterval(() => {
setTimeElapsed((prev) => prev + 1);
}, 1000);
return () => window.clearInterval(intervalId);
}, [isTimerRunning]);
const getCellHighlightType = (row: number, col: number): HighlightType => {
if (!selectedCell) return 'none';
const [selRow, selCol] = selectedCell;
const cell = board[row][col];
const selectedValue = board[selRow][selCol];
if (config.highlightRegion) {
const inSameRow = row === selRow;
const inSameCol = col === selCol;
const inSameBox = getRegionId(regionMap, row, col) === getRegionId(regionMap, selRow, selCol);
if (inSameBox) return 'box';
if (inSameRow) return 'row';
if (inSameCol) return 'col';
}
if (config.highlightSameNumbers && selectedValue !== 0) {
if (cell === selectedValue) {
return 'sameNumber';
}
}
return 'none';
};
const highlightedCells = useMemo(() => {
const highlighted = new Set<string>();
if (!selectedCell) return highlighted;
for (let r = 0; r < 9; r++) {
for (let c = 0; c < 9; c++) {
if (getCellHighlightType(r, c) !== 'none') {
highlighted.add(`${r}-${c}`);
}
}
}
return highlighted;
}, [board, selectedCell, config]);
// 计算剩余未填写的数字数量
const remainingCount = useMemo(() => {
let count = 0;
for (let r = 0; r < 9; r++) {
for (let c = 0; c < 9; c++) {
if (board[r][c] === 0) count++;
}
}
return count;
}, [board]);
// 获取单元格样式 - 支持不规则区域的边框显示
const getCellStyle = (row: number, col: number): React.CSSProperties => {
const cell = board[row][col];
const isFixed = fixedCells[row][col];
const highlightType = getCellHighlightType(row, col);
const isSelected = selectedCell && selectedCell[0] === row && selectedCell[1] === col;
const displayValue = getCellValue(cell);
// 基础背景色 - 根据高亮类型设置不同颜色
let backgroundColor = 'white';
if (isSelected) {
backgroundColor = '#69b1ff'; // 选中时的深蓝色
} else if (highlightType === 'box') {
backgroundColor = '#d9f7be'; // 宫 - 浅绿色
} else if (highlightType === 'row') {
backgroundColor = '#bae7ff'; // 行 - 浅蓝色
} else if (highlightType === 'col') {
backgroundColor = '#ffe58f'; // 列 - 浅黄色
} else if (highlightType === 'sameNumber') {
backgroundColor = '#ff4444'; // 相同数字 - 更醒目的红色
} else if (isFixed) {
backgroundColor = '#f0f0f0'; // 初始固定格子
}
const thickBorder = '2px solid #666';
const thinBorder = '1px solid #bbb';
// 使用 getRegionNeighbors 判断边框粗细,支持不规则区域
const neighbors = getRegionNeighbors(regionMap, row, col);
const borderRight = col < 8 ? (neighbors.right ? thickBorder : thinBorder) : thickBorder;
const borderBottom = row < 8 ? (neighbors.bottom ? thickBorder : thinBorder) : thickBorder;
const borderLeft = col > 0 ? (neighbors.left ? thickBorder : thinBorder) : thickBorder;
const borderTop = row > 0 ? (neighbors.top ? thickBorder : thinBorder) : thickBorder;
return {
width: '40px',
height: '40px',
backgroundColor,
borderRight,
borderBottom,
borderLeft,
borderTop,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '18px',
cursor: 'pointer',
color: isFixed ? '#333' : '#1890ff',
position: 'relative' as const,
};
};
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '20px', padding: '20px' }}>
{/* 顶部控制和配置区 */}
<div style={{ display: 'flex', gap: '20px', flexWrap: 'wrap', justifyContent: 'center' }}>
<div style={{ display: 'flex', gap: '10px' }}>
<button
onClick={handleNewGame}
style={{
padding: '10px 20px',
fontSize: '16px',
cursor: 'pointer',
backgroundColor: '#1890ff',
color: 'white',
border: 'none',
borderRadius: '4px',
}}
>
</button>
<button
onClick={handleClear}
style={{
padding: '10px 20px',
fontSize: '16px',
cursor: 'pointer',
backgroundColor: '#ff4d4f',
color: 'white',
border: 'none',
borderRadius: '4px',
}}
>
</button>
</div>
<div style={{ display: 'flex', gap: '5px', alignItems: 'center' }}>
<span>:</span>
{(['easy', 'medium', 'hard'] as const).map((d) => (
<button
key={d}
onClick={() => handleDifficultyChange(d)}
style={{
padding: '5px 10px',
fontSize: '14px',
cursor: 'pointer',
backgroundColor: difficulty === d ? '#1890ff' : '#f0f0f0',
color: difficulty === d ? 'white' : '#333',
border: 'none',
borderRadius: '4px',
}}
>
{d === 'easy' ? '简单' : d === 'medium' ? '中等' : '困难'}
</button>
))}
</div>
<div style={{ display: 'flex', gap: '5px', alignItems: 'center' }}>
<span>:</span>
{(['standard', 'irregular'] as const).map((m) => (
<button
key={m}
onClick={() => handleModeChange(m)}
style={{
padding: '5px 10px',
fontSize: '14px',
cursor: 'pointer',
backgroundColor: gameMode === m ? '#52c41a' : '#f0f0f0',
color: gameMode === m ? 'white' : '#333',
border: 'none',
borderRadius: '4px',
}}
>
{m === 'standard' ? '标准' : '不规则'}
</button>
))}
</div>
</div>
{/* 配置选项区 */}
<div style={{ display: 'flex', gap: '15px', flexWrap: 'wrap', justifyContent: 'center', padding: '10px', backgroundColor: '#fafafa', borderRadius: '8px' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
<input
type="checkbox"
checked={config.highlightRegion}
onChange={(e) => setConfig({ ...config, highlightRegion: e.target.checked })}
/>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
<input
type="checkbox"
checked={config.highlightSameNumbers}
onChange={(e) => setConfig({ ...config, highlightSameNumbers: e.target.checked })}
/>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
<input
type="checkbox"
checked={config.showRemainingCount}
onChange={(e) => setConfig({ ...config, showRemainingCount: e.target.checked })}
/>
</label>
</div>
{/* 数独棋盘 */}
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(9, 40px)',
gridTemplateRows: 'repeat(9, 40px)',
border: '2px solid #333',
backgroundColor: '#fff',
}}
>
{board.map((row, rowIndex) =>
row.map((cell, colIndex) => {
const displayValue = getCellValue(cell);
const cellStyle = getCellStyle(rowIndex, colIndex);
return (
<div
key={`${rowIndex}-${colIndex}`}
onClick={() => handleCellClick(rowIndex, colIndex)}
style={cellStyle}
>
{displayValue !== null ? displayValue : ''}
</div>
);
})
)}
</div>
<div style={{ display: 'flex', gap: '30px', alignItems: 'center', flexWrap: 'wrap', justifyContent: 'center' }}>
<div style={{ padding: '15px 25px', backgroundColor: '#f5f5f5', borderRadius: '8px', textAlign: 'center' }}>
<span style={{ fontSize: '14px', color: '#666', display: 'block', marginBottom: '8px' }}>
{selectedCell ? '按键盘 1-9 输入数字' : '选择一个格子后输入数字'}
</span>
<span style={{ fontSize: '16px', color: '#666', display: 'block', marginBottom: '8px' }}>
<strong style={{ color: '#1890ff', fontSize: '20px' }}>{formatTime(timeElapsed)}</strong>
</span>
{config.showRemainingCount && (
<span style={{ fontSize: '16px', color: '#666' }}>
<strong style={{ color: '#1890ff', fontSize: '20px' }}>{remainingCount}</strong>
</span>
)}
</div>
</div>
{/* 快捷键提示 */}
<div style={{ marginTop: '20px', padding: '10px', backgroundColor: '#f9f9f9', borderRadius: '4px', fontSize: '12px', color: '#666' }}>
<strong>:</strong> 1-9 | /Backspace |
</div>
{/* 完成成功对话框 */}
{showCompleteDialog && (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
}}>
<div style={{
backgroundColor: 'white',
borderRadius: '8px',
padding: '40px',
textAlign: 'center',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
maxWidth: '400px',
}}>
<div style={{
fontSize: '48px',
marginBottom: '20px',
}}>
🎉
</div>
<h2 style={{
margin: '0 0 20px 0',
color: '#1890ff',
fontSize: '24px',
}}>
</h2>
<p style={{
margin: '0 0 30px 0',
color: '#666',
fontSize: '16px',
lineHeight: '1.6',
}}>
</p>
<button
onClick={() => setShowCompleteDialog(false)}
style={{
padding: '12px 32px',
fontSize: '16px',
cursor: 'pointer',
backgroundColor: '#1890ff',
color: 'white',
border: 'none',
borderRadius: '4px',
marginRight: '10px',
}}
>
</button>
<button
onClick={handleNewGame}
style={{
padding: '12px 32px',
fontSize: '16px',
cursor: 'pointer',
backgroundColor: '#52c41a',
color: 'white',
border: 'none',
borderRadius: '4px',
}}
>
</button>
</div>
</div>
)}
</div>
);
};
export default SudokuGame;

View File

@@ -0,0 +1,238 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Typography,
Pagination,
Tabs,
Tab,
Chip,
CircularProgress
} from '@mui/material';
import EmojiEventsIcon from '@mui/icons-material/EmojiEvents';
import { API_BASE_URL } from '../config';
type Difficulty = 'easy' | 'medium' | 'hard';
interface LeaderboardRecord {
userId: string;
username: string;
fullname: string;
bestTime: number;
totalGames: number;
wonGames: number;
winRate: number;
lastPlayed: string;
}
interface LeaderboardResponse {
records: LeaderboardRecord[];
total: number;
currentPage: number;
totalPages: number;
difficulty: string;
}
const DIFFICULTY_LABELS: Record<Difficulty, string> = {
easy: '简单',
medium: '中等',
hard: '困难'
};
const SudokuLeaderboard: React.FC = () => {
const [difficulty, setDifficulty] = useState<Difficulty>('hard');
const [records, setRecords] = useState<LeaderboardRecord[]>([]);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetchLeaderboard();
}, [difficulty, page]);
const fetchLeaderboard = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(
`${API_BASE_URL}/api/sudoku/leaderboard/${difficulty}?page=${page}&limit=10`
);
if (!response.ok) {
throw new Error('获取排行榜失败');
}
const data: LeaderboardResponse = await response.json();
setRecords(data.records);
setTotalPages(data.totalPages);
} catch (error) {
console.error('获取排行榜失败:', error);
setError(error instanceof Error ? error.message : '获取排行榜失败');
} finally {
setLoading(false);
}
};
const handleDifficultyChange = (_event: React.SyntheticEvent, newValue: Difficulty) => {
setDifficulty(newValue);
setPage(1);
};
const handlePageChange = (_event: React.ChangeEvent<unknown>, value: number) => {
setPage(value);
};
const formatTime = (seconds: number): string => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
const getRankBadge = (rank: number) => {
if (rank === 1) return { emoji: '🥇', color: 'success' as const };
if (rank === 2) return { emoji: '🥈', color: 'primary' as const };
if (rank === 3) return { emoji: '🥉', color: 'default' as const };
return null;
};
if (loading && records.length === 0) {
return (
<Box display="flex" justifyContent="center" alignItems="center" minHeight="60vh">
<CircularProgress />
</Box>
);
}
if (error) {
return (
<Box display="flex" justifyContent="center" alignItems="center" minHeight="60vh">
<Typography color="error">{error}</Typography>
</Box>
);
}
return (
<Box sx={{ padding: 3 }}>
<Box display="flex" alignItems="center" justifyContent="center" mb={3}>
<EmojiEventsIcon sx={{ fontSize: 40, color: 'gold', mr: 1 }} />
<Typography variant="h4"></Typography>
</Box>
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}>
<Tabs
value={difficulty}
onChange={handleDifficultyChange}
centered
textColor="primary"
indicatorColor="primary"
>
<Tab value="easy" label={DIFFICULTY_LABELS.easy} />
<Tab value="medium" label={DIFFICULTY_LABELS.medium} />
<Tab value="hard" label={DIFFICULTY_LABELS.hard} />
</Tabs>
</Box>
<TableContainer component={Paper} sx={{ mb: 3 }}>
<Table>
<TableHead>
<TableRow>
<TableCell align="center" width="80px"></TableCell>
<TableCell></TableCell>
<TableCell align="center"></TableCell>
<TableCell align="center"></TableCell>
<TableCell align="center"></TableCell>
<TableCell align="center"></TableCell>
<TableCell align="center"></TableCell>
</TableRow>
</TableHead>
<TableBody>
{records.length === 0 ? (
<TableRow>
<TableCell colSpan={7} align="center">
<Typography variant="body2" color="textSecondary" py={3}>
</Typography>
</TableCell>
</TableRow>
) : (
records.map((record, index) => {
const rank = (page - 1) * 10 + index + 1;
const badge = page === 1 ? getRankBadge(rank) : null;
return (
<TableRow
key={record.userId}
sx={badge ? { backgroundColor: 'rgba(255, 215, 0, 0.1)' } : {}}
>
<TableCell align="center">
<Box display="flex" alignItems="center" justifyContent="center" gap={0.5}>
<Typography fontWeight={badge ? 'bold' : 'normal'}>{rank}</Typography>
{badge && <Chip size="small" label={badge.emoji} color={badge.color} />}
</Box>
</TableCell>
<TableCell>
<Typography fontWeight={badge ? 'bold' : 'normal'}>
{record.fullname || record.username}
</Typography>
</TableCell>
<TableCell align="center">
<Chip
label={formatTime(record.bestTime)}
color={rank <= 3 && page === 1 ? 'success' : 'default'}
size="small"
/>
</TableCell>
<TableCell align="center">{record.totalGames}</TableCell>
<TableCell align="center">{record.wonGames}</TableCell>
<TableCell align="center">
<Chip
label={`${record.winRate.toFixed(1)}%`}
color={record.winRate >= 50 ? 'success' : 'default'}
size="small"
/>
</TableCell>
<TableCell align="center">
{new Date(record.lastPlayed).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})}
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</TableContainer>
{totalPages > 1 && (
<Box display="flex" justifyContent="center" mt={3} mb={3}>
<Pagination
count={totalPages}
page={page}
onChange={handlePageChange}
color="primary"
size="large"
/>
</Box>
)}
<Paper sx={{ padding: 2, marginTop: 3, backgroundColor: '#f5f5f5' }}>
<Typography variant="body2" color="textSecondary">
💡
</Typography>
</Paper>
</Box>
);
};
export default SudokuLeaderboard;

View File

@@ -0,0 +1,34 @@
import React, { useMemo } from 'react';
import { Tabs } from 'antd';
import SudokuGame from './SudokuGame';
import SudokuLeaderboard from './SudokuLeaderboard';
const SudokuTabs: React.FC = () => {
const items = useMemo(() => {
return [
{
key: 'game',
label: '数独游戏',
children: <SudokuGame />,
},
{
key: 'leaderboard',
label: '排行榜',
children: <SudokuLeaderboard />,
},
];
}, []);
return (
<div style={{ maxWidth: 1400, margin: '0 auto', padding: '24px 0' }}>
<Tabs
defaultActiveKey="game"
items={items}
tabBarGutter={32}
type="line"
/>
</div>
);
};
export default SudokuTabs;

View File

@@ -0,0 +1,695 @@
// src/components/TowerDefense/TowerDefenseEngine.ts
/* eslint-disable */
/**
* 塔防游戏引擎 - TypeScript 完整移植版
* 基于 oldj.net 的 HTML5 Tower Defense 源代码逻辑
*/
export interface GameStats {
money: number;
score: number;
life: number;
wave: number;
difficulty: number;
}
export class TowerDefenseEngine {
canvas: HTMLCanvasElement;
ctx: CanvasRenderingContext2D;
retina: number = window.devicePixelRatio || 1;
// 核心状态
money: number = 500;
score: number = 0;
life: number = 100;
wave: number = 0;
difficulty: number = 1.0;
wave_damage: number = 0;
is_paused: boolean = true;
is_debug: boolean = false;
fps: number = 0;
exp_fps: number = 24;
step_time: number = 36;
grid_size: number = 32;
padding: number = 10;
global_speed: number = 0.1;
// 内部组件
stage: any;
eventManager: any;
lang: any;
_st: any = null;
iframe: number = 0;
last_iframe_time: number = 0;
// 回调
onGameOver: (score: number, wave: number) => void;
onUpdateStats: (stats: GameStats) => void;
// UI 交互
public selectedTowerType: string = "cannon";
constructor(canvas: HTMLCanvasElement, callbacks: {
onGameOver: (s: number, w: number) => void,
onUpdateStats: (stats: GameStats) => void
}) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d')!;
this.onGameOver = callbacks.onGameOver;
this.onUpdateStats = callbacks.onUpdateStats;
this.grid_size = 32 * this.retina;
this.padding = 10 * this.retina;
this.initEngine();
this.setupEvents();
}
private setupEvents() {
this.canvas.onmousemove = (e) => {
if (this.is_paused) return;
const rect = this.canvas.getBoundingClientRect();
const x = (e.clientX - rect.left) * this.retina;
const y = (e.clientY - rect.top) * this.retina;
this.eventManager.hover(x, y);
};
this.canvas.onclick = (e) => {
if (this.is_paused) return;
const rect = this.canvas.getBoundingClientRect();
const x = (e.clientX - rect.left) * this.retina;
const y = (e.clientY - rect.top) * this.retina;
this.eventManager.click(x, y);
};
}
private initEngine() {
const self = this;
// --- TD.lang 移植 ---
this.lang = {
mix: (r: any, s: any) => {
if (!s || !r) return r;
for (let p in s) if (s.hasOwnProperty(p)) r[p] = s[p];
return r;
},
rndStr: (n: number = 16) => {
let chars = "1234567890abcdefghijklmnopqrstuvwxyz", a = [];
for (let i = 0; i < n; i++) a.push(chars.charAt(Math.floor(Math.random() * chars.length)));
return a.join("");
},
each: (list: any[], f: Function) => list && list.forEach((v, i) => f(v, i)),
any: (list: any[], f: Function) => {
for (let i = 0; i < (list ? list.length : 0); i++) if (f(list[i])) return list[i];
return null;
},
shift: (list: any[], f: Function) => {
while (list && list.length > 0) f(list.shift());
},
rndSort: (list: any[]) => [...list].sort(() => Math.random() - 0.5),
rgb2Arr: (rgb: string) => {
if (rgb.length != 7) return [0, 0, 0];
return [parseInt(rgb.substr(1, 2), 16), parseInt(rgb.substr(3, 2), 16), parseInt(rgb.substr(5, 2), 16)];
},
nullFunc: () => {}
};
// --- TD.eventManager 移植 ---
this.eventManager = {
ex: -1, ey: -1, _registers: {} as any, current_type: "hover",
isOn: (el: any) => this.eventManager.ex > el.x && this.eventManager.ex < el.x2 && this.eventManager.ey > el.y && this.eventManager.ey < el.y2,
on: (el: any, type: string, f: Function) => {
this.eventManager._registers[el.id + "::" + type] = [el, type, f];
},
clear: () => { this.eventManager._registers = {}; },
hover: (x: number, y: number) => {
if (this.eventManager.current_type == "click") return;
this.eventManager.current_type = "hover";
this.eventManager.ex = x; this.eventManager.ey = y;
},
click: (x: number, y: number) => {
this.eventManager.current_type = "click";
this.eventManager.ex = x; this.eventManager.ey = y;
},
step: () => {
if (!this.eventManager.current_type) return;
for (let k in this.eventManager._registers) {
const [el, et, f] = this.eventManager._registers[k];
if (!el.is_valid || !el.is_visiable) continue;
const is_on = this.eventManager.isOn(el);
if (this.eventManager.current_type != "click") {
if (et == "hover" && el.is_hover && is_on) f();
else if (et == "enter" && !el.is_hover && is_on) { el.is_hover = true; f(); }
else if (et == "out" && el.is_hover && !is_on) { el.is_hover = false; f(); }
} else if (is_on && et == "click") f();
}
this.eventManager.current_type = "";
}
};
// --- FindWay 移植 ---
const FindWay = function(this: any, w: number, h: number, x1: number, y1: number, x2: number, y2: number, fPassable: Function) {
this.m = []; this.w = w; this.h = h; this.x1 = x1; this.y1 = y1; this.x2 = x2; this.y2 = y2;
this.way = []; this.fPassable = fPassable; this.is_arrived = false; this.is_blocked = false;
this.init = () => {
if (this.x1 == this.x2 && this.y1 == this.y2) { this.way = [[this.x1, this.y1]]; return; }
for (let i = 0; i < this.w * this.h; i++) this.m[i] = -2;
let current = [[this.x1, this.y1]], dist = 0;
this.m[this.y1 * this.w + this.x1] = 0;
while (current.length > 0) {
dist++; let nextStep: any[] = [];
for (let [cx, cy] of current) {
[[cx, cy-1], [cx+1, cy], [cx, cy+1], [cx-1, cy]].forEach(([nx, ny]) => {
if (nx >= 0 && nx < this.w && ny >= 0 && ny < this.h && this.m[ny*this.w+nx] == -2) {
if (fPassable(nx, ny)) {
this.m[ny*this.w+nx] = dist; nextStep.push([nx, ny]);
if (nx == this.x2 && ny == this.y2) { this.is_arrived = true; }
} else this.m[ny*this.w+nx] = -1;
}
});
}
current = nextStep; if (this.is_arrived) break;
}
if (this.is_arrived) this.findPath(); else this.is_blocked = true;
};
this.findPath = () => {
let x = this.x2, y = this.y2;
while (x != this.x1 || y != this.y1) {
this.way.unshift([x, y]);
let minV = -1, next_p: number[] | null = null;
[[x, y-1], [x+1, y], [x, y+1], [x-1, y]].forEach(([nx, ny]) => {
if (nx>=0 && nx<this.w && ny>=0 && ny<this.h) {
let v = this.m[ny*this.w+nx];
if (v >= 0 && (minV == -1 || v < minV)) { minV = v; next_p = [nx, ny]; }
}
});
if (next_p) {
x = next_p[0];
y = next_p[1];
} else break;
}
};
this.init();
} as any;
// --- 基类 Element ---
class Element {
id: string; is_valid = true; is_visiable = true; is_paused = false; is_hover = false;
x: number; y: number; width: number; height: number; cx: number = 0; cy: number = 0; x2: number = 0; y2: number = 0;
step_level: number; render_level: number; scene: any;
constructor(id: string, cfg: any) {
this.id = id || "el-" + self.lang.rndStr();
this.x = cfg.x || 0; this.y = cfg.y || 0;
this.width = cfg.width || 0; this.height = cfg.height || 0;
this.step_level = cfg.step_level || 1;
this.render_level = cfg.render_level || 0;
this.calculatePos();
}
calculatePos() {
this.cx = this.x + this.width / 2; this.cy = this.y + this.height / 2;
this.x2 = this.x + this.width; this.y2 = this.y + this.height;
}
on(type: string, f: Function) { self.eventManager.on(this, type, f); }
addToScene(s: any, sl: number, rl: number) {
this.scene = s; this.step_level = sl || this.step_level; this.render_level = rl || this.render_level;
s.addElement(this, this.step_level, this.render_level);
}
del() { this.is_valid = false; }
step() {}
render() {}
}
// --- Grid 类 ---
class Grid extends Element {
map: any; mx: number; my: number; passable_flag = 1; build_flag = 1; building: any = null;
is_entrance = false; is_exit = false;
constructor(id: string, cfg: any) {
super(id, cfg);
this.map = cfg.map; this.mx = cfg.mx; this.my = cfg.my;
this.width = self.grid_size; this.height = self.grid_size;
if (this.map) {
this.calculatePos();
}
this.on("enter", () => this.onEnter());
this.on("out", () => this.onOut());
this.on("click", () => this.onClick());
}
calculatePos() {
if (!this.map) return;
this.x = this.map.x + this.mx * self.grid_size;
this.y = this.map.y + this.my * self.grid_size;
super.calculatePos();
}
onEnter() {
if (self.stage.mode == "build" && this.build_flag == 1) {
this.map.pre_building.is_visiable = true;
this.map.pre_building.locate(this);
}
}
onOut() {
if (self.stage.mode == "build" && this.map.pre_building.grid === this) {
this.map.pre_building.is_visiable = false;
}
}
onClick() {
if (self.stage.mode == "build" && this.build_flag == 1) {
if (this.checkBlock()) return;
this.buyBuilding(this.map.pre_building.type);
}
}
checkBlock() {
if (this.is_entrance || this.is_exit) return true;
let fw = new FindWay(this.map.grid_x, this.map.grid_y, this.map.entrance.mx, this.map.entrance.my, this.map.exit.mx, this.map.exit.my, (x: number, y: number) => {
return !(x == this.mx && y == this.my) && this.map.checkPassable(x, y);
});
return fw.is_blocked;
}
buyBuilding(type: string) {
const cost = self.getBuildingCost(type);
if (self.money >= cost) {
self.money -= cost;
this.addBuilding(type);
self.updateStats();
}
}
addBuilding(type: string) {
const b = new Building("b-"+self.lang.rndStr(), { type, map: this.map, grid: this });
b.locate(this);
this.map.buildings.push(b);
this.building = b; this.build_flag = 2;
this.scene.addElement(b, 1, 3);
this.map.monsters.forEach((m: any) => m.findWay());
}
render() {
const ctx = self.ctx;
if (this.is_hover) { ctx.fillStyle = "rgba(255, 255, 200, 0.3)"; ctx.fillRect(this.x, this.y, this.width, this.height); }
if (this.is_entrance || this.is_exit) {
ctx.fillStyle = "#ccc"; ctx.fillRect(this.x, this.y, this.width, this.height);
ctx.fillStyle = this.is_entrance ? "#fff" : "#666";
ctx.beginPath(); ctx.arc(this.cx, this.cy, self.grid_size*0.3, 0, Math.PI*2); ctx.fill();
}
ctx.strokeStyle = "#eee"; ctx.lineWidth = 1; ctx.strokeRect(this.x, this.y, this.width, this.height);
}
}
// --- Building 类 ---
class Building extends Element {
type: string; map: any; grid: any; target: any = null; range: number; range_px: number; damage: number; speed: number; bullet_speed: number;
_fire_wait: number = 0; _fire_wait2: number = 0; color: string; is_weapon: boolean; muzzle: number[] = [0, 0]; is_pre_building: boolean = false;
constructor(id: string, cfg: any) {
super(id, cfg);
this.type = cfg.type; this.map = cfg.map; this.grid = cfg.grid;
this.is_pre_building = !!cfg.is_pre_building;
const attr = self.getBuildingAttr(this.type);
this.range = attr.range; this.damage = attr.damage; this.speed = attr.speed; this.bullet_speed = attr.bullet_speed;
this.range_px = this.range * self.grid_size;
this.color = attr.color;
this.is_weapon = this.range > 0 && this.type !== "wall" && !this.is_pre_building;
this._fire_wait = Math.floor(24 / this.speed);
this._fire_wait2 = this._fire_wait;
}
locate(grid: any) { this.grid = grid; this.x = grid.x; this.y = grid.y; this.calculatePos(); }
getTargetPosition() {
if (!this.target) {
const grid = this.map && this.map.is_main_map ? this.map.entrance : this.grid;
return [grid.cx, grid.cy];
}
return [this.target.cx, this.target.cy];
}
step() {
if (!this.is_weapon) return;
this.findTarget();
if (this.target) {
this._fire_wait--;
if (this._fire_wait <= 0) { this.fire(); this._fire_wait = this._fire_wait2; }
}
}
getMonsterEntrancePriority(monster: any) {
if (!monster || !monster.is_valid) return -1;
let remaining = monster.way ? monster.way.length : 0;
if (monster.next_grid) {
const dx = monster.next_grid.cx - monster.cx;
const dy = monster.next_grid.cy - monster.cy;
remaining += Math.sqrt(dx * dx + dy * dy) / self.grid_size;
} else if (monster.map && monster.map.exit) {
const dx = monster.map.exit.cx - monster.cx;
const dy = monster.map.exit.cy - monster.cy;
remaining += Math.sqrt(dx * dx + dy * dy) / self.grid_size;
}
return -remaining;
}
findTarget() {
if (!this.map) return;
const range2 = Math.pow(this.range_px, 2);
let bestTarget: any = null;
let bestPriority = -1;
let bestDistance2 = Infinity;
this.map.monsters.forEach((m: any) => {
if (!m || !m.is_valid) return;
const distance2 = Math.pow(m.cx - this.cx, 2) + Math.pow(m.cy - this.cy, 2);
if (distance2 > range2) return;
const priority = this.getMonsterEntrancePriority(m);
if (!bestTarget || priority > bestPriority || (priority === bestPriority && distance2 < bestDistance2)) {
bestTarget = m;
bestPriority = priority;
bestDistance2 = distance2;
}
});
this.target = bestTarget;
}
fire() {
if (!this.target || !this.target.is_valid) return;
const muzzle = this.muzzle && this.muzzle.length === 2 ? this.muzzle : [this.cx, this.cy];
new Bullet("", { building: this, target: this.target, damage: this.damage, speed: this.bullet_speed, x: muzzle[0], y: muzzle[1] });
}
render() {
const ctx = self.ctx;
self.renderBuildingVisual(this);
if (this.is_hover || (this.grid && this.grid.is_hover)) {
ctx.beginPath(); ctx.arc(this.cx, this.cy, this.range_px, 0, Math.PI*2);
ctx.fillStyle = "rgba(187, 141, 32, 0.15)"; ctx.fill();
ctx.strokeStyle = "#bb8d20"; ctx.stroke();
}
}
}
// --- Monster 类 ---
class Monster extends Element {
idx: number; hp: number; maxHp: number; speed: number; money: number; damage: number; shield: number;
grid: any; next_grid: any = null; way: any[] = []; color: string; r: number;
constructor(id: string, cfg: any) {
super(id, cfg);
this.idx = cfg.idx;
const attr = self.getMonsterAttr(this.idx);
this.hp = this.maxHp = Math.floor(attr.life * (self.difficulty + 1) * 0.75);
this.speed = Math.max(1, attr.speed * self.global_speed * (Math.random() * 0.5 + 0.75));
this.shield = Math.max(0, attr.shield || 0);
this.money = attr.money || 10;
this.damage = attr.damage || 1;
this.color = attr.color || "#00f";
// 半径与原版一致的限制
const baseR = Math.floor(this.damage * 1.2 * self.retina);
this.r = Math.min(Math.max(baseR, 4 * self.retina), self.grid_size / 2 - 4 * self.retina);
this.width = this.height = this.r * 2;
}
beAddToGrid(grid: any) { this.grid = grid; this.x = grid.x; this.y = grid.y; this.cx = grid.cx; this.cy = grid.cy; this.calculatePos(); this.findWay(); }
findWay() {
let fw = new FindWay(this.grid.map.grid_x, this.grid.map.grid_y, this.grid.mx, this.grid.my, this.grid.map.exit.mx, this.grid.map.exit.my, (x: number, y: number) => this.grid.map.checkPassable(x, y));
this.way = fw.way;
}
step() {
if (!this.next_grid) {
if (this.way.length > 0) {
let next = this.way.shift();
this.next_grid = this.grid.map.getGrid(next[0], next[1]);
} else {
self.life -= this.damage; self.wave_damage += this.damage; this.del(); self.updateStats();
if (self.life <= 0) self.gameOver(); return;
}
}
let dx = this.next_grid.cx - this.cx, dy = this.next_grid.cy - this.cy, dist = Math.sqrt(dx*dx + dy*dy);
if (dist < this.speed) { this.cx = this.next_grid.cx; this.cy = this.next_grid.cy; this.grid = this.next_grid; this.next_grid = null; }
else { this.cx += (dx/dist)*this.speed; this.cy += (dy/dist)*this.speed; }
this.x = this.cx - this.width/2; this.y = this.cy - this.height/2; this.calculatePos();
}
beHit(damage: number) {
const minDamage = Math.ceil(damage * 0.1);
const realDamage = Math.max(minDamage, damage - this.shield);
this.hp -= realDamage;
if (this.hp <= 0) { this.del(); self.money += this.money; self.score += Math.floor(Math.sqrt(realDamage)); self.updateStats(); }
}
render() {
if (!this.is_valid || !this.grid) return;
const ctx = self.ctx;
ctx.strokeStyle = "#000";
ctx.lineWidth = 1 * self.retina;
ctx.fillStyle = this.color;
ctx.beginPath();
ctx.arc(this.cx, this.cy, this.r, 0, Math.PI * 2, true);
ctx.closePath();
ctx.fill();
ctx.stroke();
const s = Math.floor(self.grid_size / 4);
const l = s * 2 - 2 * self.retina;
ctx.fillStyle = "#000";
ctx.beginPath();
ctx.fillRect(this.cx - s, this.cy - this.r - 6, s * 2, 4 * self.retina);
ctx.closePath();
ctx.fillStyle = "#f00";
ctx.beginPath();
ctx.fillRect(this.cx - s + self.retina, this.cy - this.r - (6 - self.retina), (this.hp/this.maxHp)*l, 2 * self.retina);
ctx.closePath();
}
}
// --- Bullet 类 ---
class Bullet extends Element {
target: any; damage: number; speed: number; vx = 0; vy = 0;
constructor(id: string, cfg: any) {
super(id, cfg);
this.target = cfg.target; this.damage = cfg.damage; this.speed = cfg.speed;
let dx = this.target.cx - this.x, dy = this.target.cy - this.y, dist = Math.sqrt(dx*dx + dy*dy);
let s = this.speed * self.global_speed * 10;
this.vx = (dx/dist)*s; this.vy = (dy/dist)*s;
cfg.building.map.scene.addElement(this, 1, 5);
}
step() {
this.x += this.vx; this.y += this.vy; this.calculatePos();
if (this.x < 0 || this.x > self.canvas.width || this.y < 0 || this.y > self.canvas.height) { this.del(); return; }
let hit = self.lang.any(this.scene.elements[1], (el: any) => el instanceof Monster && Math.sqrt(Math.pow(el.cx-this.cx, 2)+Math.pow(el.cy-this.cy, 2)) < el.width);
if (hit) { hit.beHit(this.damage); this.del(); }
}
render() { const ctx = self.ctx; ctx.fillStyle = "#000"; ctx.beginPath(); ctx.arc(this.cx, this.cy, 2, 0, Math.PI*2); ctx.fill(); }
}
// --- Map 类 ---
class Map extends Element {
grid_x: number; grid_y: number; grids: any[] = []; entrance: any; exit: any; buildings: any[] = []; monsters: any[] = [];
pre_building: any; is_main_map = false;
constructor(id: string, cfg: any) {
super(id, cfg);
this.grid_x = cfg.grid_x; this.grid_y = cfg.grid_y;
this.is_main_map = true;
this.width = this.grid_x * self.grid_size;
this.height = this.grid_y * self.grid_size;
this.calculatePos();
for (let i = 0; i < this.grid_x * this.grid_y; i++) {
this.grids.push(new Grid(this.id+"-g-"+i, { map: this, mx: i % this.grid_x, my: Math.floor(i / this.grid_x) }));
}
this.entrance = this.getGrid(cfg.entrance[0], cfg.entrance[1]); this.entrance.is_entrance = true;
this.exit = this.getGrid(cfg.exit[0], cfg.exit[1]); this.exit.is_exit = true;
this.pre_building = new Building("pre", { type: "cannon", map: this, is_pre_building: true });
this.pre_building.is_visiable = false;
this.scene && this.scene.addElement && this.scene.addElement(this.pre_building, 1, 10);
}
getGrid(x: number, y: number) { return this.grids[y * this.grid_x + x]; }
checkPassable(x: number, y: number) { let g = this.getGrid(x, y); return g && g.passable_flag == 1 && g.build_flag != 2; }
step() {
this.buildings = this.buildings.filter(b => b.is_valid);
this.monsters = this.monsters.filter(m => m.is_valid);
}
render() { self.ctx.strokeStyle = "#000"; self.ctx.strokeRect(this.x, this.y, this.width, this.height); }
}
// --- Scene 类 ---
class Scene {
elements: any = {};
addElement(el: any, sl: number, rl: number) {
if (!this.elements[sl]) this.elements[sl] = [];
this.elements[sl].push(el);
}
step() {
for (let sl in this.elements) {
this.elements[sl] = this.elements[sl].filter((el: any) => el.is_valid);
this.elements[sl].forEach((el: any) => el.step());
}
}
render() {
let all: any[] = [];
for (let sl in this.elements) all = all.concat(this.elements[sl]);
all.sort((a, b) => (a.render_level || 0) - (b.render_level || 0));
all.forEach(el => el.render());
}
}
// --- Stage 类 ---
this.stage = {
mode: "normal", scene: new Scene(),
init: () => {
const map = new Map("main-map", { x: self.padding, y: self.padding, grid_x: 16, grid_y: 16, entrance: [0, 0], exit: [15, 15] });
this.stage.map = map;
this.stage.scene.addElement(map, 1, 1);
map.grids.forEach(g => this.stage.scene.addElement(g, 1, 2));
this.stage.scene.addElement(map.pre_building, 1, 10);
},
step: () => { this.stage.scene.step(); },
render: () => { this.stage.scene.render(); }
};
(this as any).Monster = Monster; // 暴露给内部使用
this.stage.init();
}
// --- 属性定义移植 ---
private getBuildingAttr(type: string) {
const attrs: any = {
"cannon": { damage: 12, range: 4, speed: 2, bullet_speed: 6, cost: 300, color: "#393" },
"LMG": { damage: 5, range: 5, speed: 3, bullet_speed: 6, cost: 100, color: "#36f" },
"HMG": { damage: 30, range: 3, speed: 3, bullet_speed: 5, cost: 800, color: "#933" },
"wall": { damage: 0, range: 0, speed: 0, bullet_speed: 0, cost: 5, color: "#666" }
};
return attrs[type] || attrs["cannon"];
}
private getBuildingCost(type: string) { return this.getBuildingAttr(type).cost; }
private getMonsterAttr(idx: number) {
const attrs = [
{ name: "monster 1", life: 50, speed: 3, damage: 1, shield: 0, money: 5, color: "#58f" },
{ name: "monster 2", life: 50, speed: 6, damage: 2, shield: 1, money: 8, color: "#3af" },
{ name: "monster speed", life: 50, speed: 12, damage: 3, shield: 1, money: 10, color: "#38c" },
{ name: "monster life", life: 500, speed: 5, damage: 3, shield: 1, money: 20, color: "#36a" },
{ name: "monster shield", life: 50, speed: 5, damage: 3, shield: 20, money: 15, color: "#269" },
{ name: "monster damage", life: 50, speed: 7, damage: 10, shield: 2, money: 25, color: "#d33" },
];
return attrs[idx % attrs.length];
}
// --- 渲染移植 ---
private renderBuildingVisual(b: any) {
const ctx = this.ctx;
const gs = this.grid_size;
const gs2 = gs / 2;
const r = this.retina;
const lineTo2 = (x0: number, y0: number, x1: number, y1: number, len: number) => {
let x2 = x0, y2 = y0;
if (x0 == x1) {
x2 = x0; y2 = y1 > y0 ? y0 + len : y0 - len;
} else if (y0 == y1) {
y2 = y0; x2 = x1 > x0 ? x0 + len : x0 - len;
} else {
const a = (y0 - y1) / (x0 - x1);
const b0 = y0 - x0 * a;
const a2 = a * a + 1;
const b2 = 2 * (a * (b0 - y0) - x0);
const c2 = Math.pow(b0 - y0, 2) + x0 * x0 - Math.pow(len, 2);
let p = Math.pow(b2, 2) - 4 * a2 * c2;
if (p < 0) return [x0, y0];
p = Math.sqrt(p);
let xt = (-b2 + p) / (2 * a2);
if ((x1 - x0 > 0 && xt - x0 > 0) || (x1 - x0 < 0 && xt - x0 < 0)) {
x2 = xt; y2 = a * x2 + b0;
} else {
x2 = (-b2 - p) / (2 * a2); y2 = a * x2 + b0;
}
}
ctx.lineCap = "round";
ctx.moveTo(x0, y0); ctx.lineTo(x2, y2);
return [x2, y2];
};
const renderMap: any = {
"cannon": () => {
const tp = b.getTargetPosition();
ctx.fillStyle = "#393"; ctx.strokeStyle = "#000";
ctx.beginPath(); ctx.lineWidth = r; ctx.arc(b.cx, b.cy, gs2 - 5, 0, Math.PI * 2, true); ctx.closePath(); ctx.fill(); ctx.stroke();
ctx.lineWidth = 3 * r; ctx.beginPath(); ctx.moveTo(b.cx, b.cy); b.muzzle = lineTo2(b.cx, b.cy, tp[0], tp[1], gs2); ctx.closePath(); ctx.stroke();
ctx.lineWidth = r; ctx.fillStyle = "#060"; ctx.beginPath(); ctx.arc(b.cx, b.cy, 7 * r, 0, Math.PI * 2, true); ctx.closePath(); ctx.fill(); ctx.stroke();
ctx.fillStyle = "#cec"; ctx.beginPath(); ctx.arc(b.cx + 2, b.cy - 2, 3 * r, 0, Math.PI * 2, true); ctx.closePath(); ctx.fill();
},
"LMG": () => {
const tp = b.getTargetPosition();
ctx.fillStyle = "#36f"; ctx.strokeStyle = "#000";
ctx.beginPath(); ctx.lineWidth = r; ctx.arc(b.cx, b.cy, 7 * r, 0, Math.PI * 2, true); ctx.closePath(); ctx.fill(); ctx.stroke();
ctx.lineWidth = 2 * r; ctx.beginPath(); ctx.moveTo(b.cx, b.cy); b.muzzle = lineTo2(b.cx, b.cy, tp[0], tp[1], gs2); ctx.closePath(); ctx.fill(); ctx.stroke();
ctx.lineWidth = r; ctx.fillStyle = "#66c"; ctx.beginPath(); ctx.arc(b.cx, b.cy, 5 * r, 0, Math.PI * 2, true); ctx.closePath(); ctx.fill(); ctx.stroke();
ctx.fillStyle = "#ccf"; ctx.beginPath(); ctx.arc(b.cx + 1, b.cy - 1, 2 * r, 0, Math.PI * 2, true); ctx.closePath(); ctx.fill();
},
"HMG": () => {
const tp = b.getTargetPosition();
ctx.fillStyle = "#933"; ctx.strokeStyle = "#000";
ctx.beginPath(); ctx.lineWidth = r; ctx.arc(b.cx, b.cy, gs2 - 2, 0, Math.PI * 2, true); ctx.closePath(); ctx.fill(); ctx.stroke();
ctx.lineWidth = 5 * r; ctx.beginPath(); ctx.moveTo(b.cx, b.cy); b.muzzle = lineTo2(b.cx, b.cy, tp[0], tp[1], gs2); ctx.closePath(); ctx.fill(); ctx.stroke();
ctx.lineWidth = r; ctx.fillStyle = "#630"; ctx.beginPath(); ctx.arc(b.cx, b.cy, gs2 - 5 * r, 0, Math.PI * 2, true); ctx.closePath(); ctx.fill(); ctx.stroke();
ctx.fillStyle = "#960"; ctx.beginPath(); ctx.arc(b.cx + 1, b.cy - 1, 8 * r, 0, Math.PI * 2, true); ctx.closePath(); ctx.fill();
ctx.fillStyle = "#fcc"; ctx.beginPath(); ctx.arc(b.cx + 3, b.cy - 3, 4 * r, 0, Math.PI * 2, true); ctx.closePath(); ctx.fill();
},
"wall": () => {
ctx.lineWidth = r; ctx.fillStyle = "#666"; ctx.strokeStyle = "#000";
ctx.fillRect(b.cx - gs2 + 1, b.cy - gs2 + 1, gs - 1, gs - 1);
ctx.beginPath();
ctx.moveTo(b.cx - gs2 + 0.5, b.cy - gs2 + 0.5);
ctx.lineTo(b.cx - gs2 + 0.5, b.cy + gs2 + 0.5);
ctx.lineTo(b.cx + gs2 + 0.5, b.cy + gs2 + 0.5);
ctx.lineTo(b.cx + gs2 + 0.5, b.cy - gs2 + 0.5);
ctx.lineTo(b.cx - gs2 + 0.5, b.cy - gs2 + 0.5);
ctx.moveTo(b.cx - gs2 + 0.5, b.cy + gs2 + 0.5);
ctx.lineTo(b.cx + gs2 + 0.5, b.cy - gs2 + 0.5);
ctx.moveTo(b.cx - gs2 + 0.5, b.cy - gs2 + 0.5);
ctx.lineTo(b.cx + gs2 + 0.5, b.cy + gs2 + 0.5);
ctx.closePath();
ctx.stroke();
}
};
(renderMap[b.type] || renderMap["wall"])();
}
private updateStats() {
this.onUpdateStats({ money: this.money, score: this.score, life: this.life, wave: this.wave, difficulty: this.difficulty });
}
// --- 主循环 ---
start() {
this.is_paused = false;
this.last_iframe_time = Date.now();
this.updateStats();
this.step();
}
private step() {
if (this.is_paused) return;
this.iframe++;
if (this.iframe % 50 == 0) {
let t = Date.now(); this.fps = Math.round(50000 / (t - this.last_iframe_time)); this.last_iframe_time = t;
}
// 生成怪物逻辑
if (this.stage.map.monsters.length == 0) {
this.wave++; this.difficulty *= 1.1;
for(let i=0; i<5+this.wave; i++) {
setTimeout(() => {
if (this.is_paused) return;
const m = new (this as any).Monster("", { idx: Math.floor(Math.random()*3), step_level: 1, render_level: 4 });
m.beAddToGrid(this.stage.map.entrance);
}, i * 1000);
}
this.updateStats();
}
this.eventManager.step();
this.stage.step();
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.stage.render();
this._st = setTimeout(() => this.step(), this.step_time);
}
private gameOver() { this.is_paused = true; this.onGameOver(this.score, this.wave); }
destroy() { this.is_paused = true; if (this._st) clearTimeout(this._st); }
}

View File

@@ -0,0 +1,218 @@
import React, { useEffect, useRef, useState } from 'react';
import { submitTowerRecord, saveGame, fetchSaves, fetchSave, deleteSave } from '../api/towerDefense';
import { message, Button, Modal, List, Space } from 'antd';
const TowerDefenseEmbed: React.FC<{ width?: string | number; height?: string | number }> = ({
width = '100%',
height = 640
}) => {
const lastSubmitRef = useRef<{ ts: number; key: string } | null>(null);
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const pendingSaveRef = useRef(false);
const [saves, setSaves] = useState<any[]>([]);
const [loadingSaves, setLoadingSaves] = useState(false);
const [showSavesModal, setShowSavesModal] = useState(false);
useEffect(() => {
const handler = (e: MessageEvent) => {
try {
// origin 校验,确保消息来自同源的游戏 iframe
console.debug('TowerDefenseEmbed received postMessage', e.origin, e.data);
if (e.origin !== window.location.origin) {
console.debug('postMessage origin mismatch', e.origin, window.location.origin);
return;
}
if (!e.data) {
console.debug('postMessage has no data');
return;
}
// 保存/读取进度时iframe 会回传 state
if (e.data.type === 'tower-defense:state') {
const state = e.data.state;
console.debug('Received tower-defense:state', state);
if (pendingSaveRef.current) {
pendingSaveRef.current = false;
message.loading('正在保存进度…');
saveGame(state)
.then(() => {
message.success('进度已保存');
// refresh saves list
return fetchSaves();
})
.then((res: any) => {
// api wrapper sometimes returns { data: { saves: [...] } } or { saves: [...] }
const payload = (res && (res.saves ? res : res.data)) || null;
if (payload && payload.saves) setSaves(payload.saves);
})
.catch((err) => {
console.error('保存游戏进度失败', err);
message.error('保存进度失败:' + (err?.message || '未知错误'));
});
}
return;
}
if (e.data.type !== 'tower-defense:complete') return;
const { wave, score, timeSeconds } = e.data;
if (typeof wave !== 'number' || typeof score !== 'number') return;
const key = `${wave}:${score}:${timeSeconds}`;
const now = Date.now();
// 客户端去重:同一参赛结果在短时间内只提交一次
if (lastSubmitRef.current && lastSubmitRef.current.key === key && (now - lastSubmitRef.current.ts) < 5000) {
message.info('成绩已提交(防止重复)。');
return;
}
lastSubmitRef.current = { ts: now, key };
const msgKey = 'td-submit-' + Date.now();
message.loading('正在提交成绩…');
submitTowerRecord({ wave, score, timeSeconds })
.then(() => {
message.success('塔防成绩已提交到排行榜');
})
.catch((err) => {
console.error('提交塔防成绩失败', err);
message.error('提交成绩失败:' + (err?.message || '未知错误'));
});
} catch (err) {
console.error('处理 postMessage 事件失败', err);
}
};
window.addEventListener('message', handler);
return () => window.removeEventListener('message', handler);
}, []);
const requestSave = () => {
if (!iframeRef.current || !iframeRef.current.contentWindow) {
message.error('无法访问游戏 iframe');
return;
}
pendingSaveRef.current = true;
// 请求 iframe 将当前游戏状态 postMessage 回来
console.debug('Sending postMessage to iframe: request-state', window.location.origin);
iframeRef.current.contentWindow.postMessage({ type: 'tower-defense:request-state' }, window.location.origin);
message.info('正在请求游戏状态以保存…');
};
const openSaves = () => {
setShowSavesModal(true);
setLoadingSaves(true);
fetchSaves()
.then((res: any) => {
console.debug('fetchSaves response', res);
const payload = (res && (res.saves ? res : res.data)) || null;
console.debug('fetchSaves payload', payload);
if (payload && payload.saves) setSaves(payload.saves);
})
.catch((err) => {
console.error('获取保存列表失败', err);
message.error('获取保存列表失败');
})
.finally(() => setLoadingSaves(false));
};
const loadSaveToIframe = (saveId: string) => {
if (!iframeRef.current || !iframeRef.current.contentWindow) {
message.error('无法访问游戏 iframe');
return;
}
message.loading('正在加载存档…');
fetchSave(saveId)
.then((res: any) => {
console.debug('fetchSave response', res);
const payload = res?.save ? res : res?.data;
console.debug('fetchSave payload', payload);
const save = payload?.save;
if (!save) throw new Error('无效的保存项');
iframeRef.current!.contentWindow!.postMessage({ type: 'tower-defense:load', state: save.state }, window.location.origin);
message.success('已向游戏发送加载请求');
setShowSavesModal(false);
})
.catch((err) => {
console.error('加载存档失败', err);
message.error('加载存档失败');
});
};
const iframeStyle: React.CSSProperties = {
width: typeof width === 'number' ? `${width}px` : width,
height: typeof height === 'number' ? `${height}px` : height,
border: 'none'
};
const containerStyle: React.CSSProperties = {
display: 'flex',
justifyContent: 'center'
};
// If width is '100%' we prefer to use a fixed iframe width matching the game's max-width
const finalIframeStyle = { ...iframeStyle };
if (iframeStyle.width === '100%') {
finalIframeStyle.width = '960px';
}
// 使用 PUBLIC_URL 支持不同部署环境
const gameUrl = `${process.env.PUBLIC_URL || ''}/tower-defense/td.html`;
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<Space style={{ marginBottom: 12 }}>
<Button type="primary" onClick={requestSave}></Button>
<Button onClick={openSaves}></Button>
</Space>
<div style={containerStyle}>
<iframe
ref={iframeRef}
title="Tower Defense"
src={gameUrl}
style={finalIframeStyle}
/>
</div>
<Modal
title="已保存的进度"
open={showSavesModal}
onCancel={() => setShowSavesModal(false)}
footer={null}
>
<List
loading={loadingSaves}
dataSource={saves}
renderItem={(item: any) => (
<List.Item
actions={[
<Button key="load" type="link" onClick={() => loadSaveToIframe(item._id)}></Button>,
<Button key="delete" type="link" danger onClick={() => {
// 删除后刷新列表
deleteSave(item._id)
.then(() => {
message.success('存档已删除');
openSaves();
})
.catch((err) => {
console.error('删除存档失败', err);
message.error('删除失败');
});
}}></Button>
]}
>
<List.Item.Meta
title={new Date(item.createdAt).toLocaleString()}
description={item.name}
/>
</List.Item>
)}
/>
</Modal>
</div>
);
};
export default TowerDefenseEmbed;

View File

@@ -0,0 +1,8 @@
// Legacy TowerDefenseGame has been replaced by TowerDefenseEmbed.
import React from 'react';
const TowerDefenseGame: React.FC = () => {
return <div style={{ padding: 16 }}>Legacy TowerDefenseGame removed. Use TowerDefenseEmbed instead.</div>;
};
export default TowerDefenseGame;

View File

@@ -0,0 +1,177 @@
// src/components/TowerDefenseLeaderboard.tsx
import React, { useState, useEffect } from 'react';
import {
Box,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Typography,
Pagination,
Chip,
CircularProgress
} from '@mui/material';
import EmojiEventsIcon from '@mui/icons-material/EmojiEvents';
import { API_BASE_URL } from '../config';
interface LeaderboardRecord {
userId: string;
username: string;
fullname: string;
bestWave: number;
bestScore: number;
totalGames: number;
lastPlayed: string;
}
interface LeaderboardResponse {
records: LeaderboardRecord[];
total: number;
currentPage: number;
totalPages: number;
}
const TowerDefenseLeaderboard: React.FC = () => {
const [records, setRecords] = useState<LeaderboardRecord[]>([]);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetchLeaderboard();
}, [page]);
const fetchLeaderboard = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(
`${API_BASE_URL}/api/tower-defense/leaderboard?page=${page}&limit=10`
);
if (!response.ok) {
throw new Error('获取排行榜失败');
}
const data: LeaderboardResponse = await response.json();
setRecords(data.records);
setTotalPages(data.totalPages);
} catch (error) {
console.error('获取排行榜失败:', error);
setError(error instanceof Error ? error.message : '获取排行榜失败');
} finally {
setLoading(false);
}
};
const handlePageChange = (_event: React.ChangeEvent<unknown>, value: number) => {
setPage(value);
};
const getRankBadge = (rank: number) => {
if (rank === 1) return { emoji: '🥇', color: 'success' as const };
if (rank === 2) return { emoji: '🥈', color: 'primary' as const };
if (rank === 3) return { emoji: '🥉', color: 'default' as const };
return null;
};
if (loading && records.length === 0) {
return (
<Box display="flex" justifyContent="center" alignItems="center" minHeight="60vh">
<CircularProgress />
</Box>
);
}
if (error) {
return (
<Box display="flex" justifyContent="center" alignItems="center" minHeight="60vh">
<Typography color="error">{error}</Typography>
</Box>
);
}
return (
<Box sx={{ padding: 3 }}>
<Box display="flex" alignItems="center" justifyContent="center" mb={3}>
<EmojiEventsIcon sx={{ fontSize: 40, color: 'gold', mr: 1 }} />
<Typography variant="h4">
</Typography>
</Box>
<TableContainer component={Paper} sx={{ mb: 3 }}>
<Table>
<TableHead>
<TableRow>
<TableCell align="center" width="80px"></TableCell>
<TableCell></TableCell>
<TableCell align="center"></TableCell>
<TableCell align="center"></TableCell>
<TableCell align="center"></TableCell>
<TableCell align="center"></TableCell>
</TableRow>
</TableHead>
<TableBody>
{records.length === 0 ? (
<TableRow>
<TableCell colSpan={6} align="center">
<Typography variant="body2" color="textSecondary" py={3}>
</Typography>
</TableCell>
</TableRow>
) : (
records.map((record, index) => {
const rank = (page - 1) * 10 + index + 1;
const badge = page === 1 ? getRankBadge(rank) : null;
return (
<TableRow
key={record.userId}
sx={badge ? { backgroundColor: 'rgba(255, 215, 0, 0.1)' } : {}}
>
<TableCell align="center">
<Box display="flex" alignItems="center" justifyContent="center" gap={0.5}>
<Typography fontWeight={badge ? 'bold' : 'normal'}>
{rank}
</Typography>
{badge && (
<Chip size="small" label={badge.emoji} color={badge.color} />
)}
</Box>
</TableCell>
<TableCell>
<Typography fontWeight={badge ? 'bold' : 'normal'}>
{record.fullname || record.username}
</Typography>
</TableCell>
<TableCell align="center">
<Chip label={record.bestScore} color="primary" size="small" />
</TableCell>
<TableCell align="center">{record.bestWave}</TableCell>
<TableCell align="center">{record.totalGames}</TableCell>
<TableCell align="center">
{new Date(record.lastPlayed).toLocaleString('zh-CN')}
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</TableContainer>
{totalPages > 1 && (
<Box display="flex" justifyContent="center" mt={3}>
<Pagination count={totalPages} page={page} onChange={handlePageChange} color="primary" />
</Box>
)}
</Box>
);
};
export default TowerDefenseLeaderboard;

View File

@@ -0,0 +1,39 @@
// src/components/TowerDefenseTabs.tsx
import React, { useMemo } from 'react';
import { Tabs, Card } from 'antd';
import TowerDefenseEmbed from './TowerDefenseEmbed';
import TowerDefenseLeaderboard from './TowerDefenseLeaderboard';
const TowerDefenseTabs: React.FC = () => {
const items = useMemo(() => {
return [
{
key: 'game',
label: '塔防游戏',
children: <TowerDefenseEmbed />,
},
{
key: 'leaderboard',
label: '排行榜',
children: <TowerDefenseLeaderboard />,
},
];
}, []);
return (
<div style={{ padding: '24px', maxWidth: 1200, margin: '0 auto' }}>
<Card bordered={false} styles={{ body: { padding: 0 } }}>
<Tabs
defaultActiveKey="game"
items={items}
size="large"
centered
destroyInactiveTabPane={false}
/>
</Card>
</div>
);
};
export default TowerDefenseTabs;

View File

@@ -0,0 +1,227 @@
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Container,
Grid,
Card,
CardContent,
CardActions,
Typography,
Button,
Box,
Skeleton
} from '@mui/material';
import {
Code as CodeIcon,
Keyboard as KeyboardIcon,
School as SchoolIcon,
Psychology as PsychologyIcon
} from '@mui/icons-material';
import { api, ApiError } from '../api/apiClient';
import { message as antdMessage } from 'antd';
import { API_PATHS } from '../config';
// 统计数据类型
interface Statistics {
practiceCount: number;
totalWords: number;
avgAccuracy: number;
todayPracticeTime: number;
accuracyTrend: number[];
lastPracticeDate: string | null;
avgSpeed: number;
}
const TypingPractice: React.FC = () => {
const navigate = useNavigate();
const [stats, setStats] = useState<Statistics>({
practiceCount: 0,
totalWords: 0,
avgAccuracy: 0,
todayPracticeTime: 0,
accuracyTrend: [],
lastPracticeDate: null,
avgSpeed: 0
});
const [loading, setLoading] = useState(true);
// 获取统计数据
const fetchStatistics = async () => {
try {
const response = await api.get(`${API_PATHS.PRACTICE_RECORDS}/statistics`);
setStats(response as Statistics);
} catch (error) {
console.error('Error fetching statistics:', error);
antdMessage.error('获取统计数据失败');
} finally {
setLoading(false);
}
};
// 组件加载时获取数据
useEffect(() => {
const fetchData = async () => {
try {
await fetchStatistics();
} catch (error) {
if (error instanceof ApiError && error.statusCode === 401) {
// 认证错误已经由 AuthWrapper 处理,这里不需要额外处理
console.log('Authentication error handled by AuthWrapper');
}
}
};
fetchData();
}, []);
const practiceTypes = [
{
title: '关键字练习',
description: '练习常用的编程关键字,提高基础输入速度',
icon: <KeyboardIcon sx={{ fontSize: 40 }} />,
level: 'keyword',
color: '#4CAF50'
},
{
title: '基础算法',
description: '练习基础算法实现,如冒泡排序、选择排序等',
icon: <CodeIcon sx={{ fontSize: 40 }} />,
level: 'basic',
color: '#2196F3'
},
{
title: '中级算法',
description: '练习中级算法实现,如快速排序、归并排序等',
icon: <SchoolIcon sx={{ fontSize: 40 }} />,
level: 'intermediate',
color: '#FF9800'
},
{
title: '高级算法',
description: '练习高级算法实现,如红黑树、图算法等',
icon: <PsychologyIcon sx={{ fontSize: 40 }} />,
level: 'advanced',
color: '#F44336'
}
];
return (
<Container maxWidth="lg" sx={{ mt: 4 }}>
<Typography variant="h4" gutterBottom>
Type Practice
</Typography>
<Typography variant="subtitle1" color="textSecondary" paragraph>
</Typography>
<Grid container spacing={4} sx={{ mt: 2 }}>
{practiceTypes.map((type) => (
<Grid item xs={12} sm={6} md={3} key={type.level}>
<Card
sx={{
height: '100%',
display: 'flex',
flexDirection: 'column',
transition: 'transform 0.2s',
'&:hover': {
transform: 'scale(1.02)',
boxShadow: 3
}
}}
>
<Box
sx={{
p: 2,
display: 'flex',
justifyContent: 'center',
bgcolor: type.color,
color: 'white'
}}
>
{type.icon}
</Box>
<CardContent sx={{ flexGrow: 1 }}>
<Typography gutterBottom variant="h5" component="h2">
{type.title}
</Typography>
<Typography variant="body2" color="textSecondary">
{type.description}
</Typography>
</CardContent>
<CardActions>
<Button
size="small"
color="primary"
onClick={() => navigate(`/practice/${type.level}`)}
fullWidth
>
</Button>
</CardActions>
</Card>
</Grid>
))}
</Grid>
{/* 统计信息区域 */}
<Box sx={{ mt: 6, mb: 4 }}>
<Typography variant="h5" gutterBottom>
</Typography>
<Grid container spacing={3}>
{[
{
title: '今日练习时长',
value: loading ? <Skeleton variant="text" width={100} /> : `${stats.todayPracticeTime ?? 0}`,
color: '#4CAF50'
},
{
title: '完成练习数',
value: loading ? <Skeleton variant="text" width={100} /> : stats.practiceCount ?? 0,
color: '#2196F3'
},
{
title: '平均正确率',
value: loading ? <Skeleton variant="text" width={100} /> : `${(stats.avgAccuracy ?? 0).toFixed(1)}%`,
color: '#FF9800'
},
{
title: '练习单词总数',
value: loading ? <Skeleton variant="text" width={100} /> : stats.totalWords ?? 0,
color: '#F44336'
},
{
title: '平均速度',
value: loading ? <Skeleton variant="text" width={100} /> : `${(stats.avgSpeed ?? 0).toFixed(0)} WPM`,
color: '#9C27B0'
}
].map((item, index) => (
<Grid item xs={12} sm={6} md style={{ flex: 1 }} key={index}>
<Card sx={{
height: '100%',
bgcolor: 'background.paper',
transition: 'transform 0.2s',
'&:hover': {
transform: 'scale(1.02)',
boxShadow: 2
}
}}>
<CardContent>
<Typography color="textSecondary" gutterBottom>
{item.title}
</Typography>
<Typography variant="h4" sx={{ color: item.color }}>
{item.value}
</Typography>
</CardContent>
</Card>
</Grid>
))}
</Grid>
</Box>
</Container>
);
};
export default TypingPractice;

View File

@@ -0,0 +1,39 @@
import React, { useMemo } from 'react';
import { Tabs } from 'antd';
import TypingPractice from './TypingPractice'; // 首页/模式选择页
import Leaderboard from './Leaderboard';
import PracticeHistory from './PracticeHistory';
const TypingTabs: React.FC = () => {
// 选项卡内容
const items = useMemo(() => [
{
key: 'home',
label: '打字练习',
children: <TypingPractice />,
},
{
key: 'leaderboard',
label: '排行榜',
children: <Leaderboard />,
},
{
key: 'history',
label: '练习历史',
children: <PracticeHistory />,
},
], []);
return (
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '24px 0' }}>
<Tabs
defaultActiveKey="home"
items={items}
tabBarGutter={32}
type="line"
/>
</div>
);
};
export default TypingTabs;

View File

@@ -0,0 +1,52 @@
import React, { useState } from 'react';
import { Table, Input, Button } from 'antd';
import { api } from '../api/apiClient';
interface UserPassData {
username: string;
fullname: string;
passCount: number;
}
const UserWordPassExport: React.FC = () => {
const [prefix, setPrefix] = useState('');
const [data, setData] = useState<UserPassData[]>([]);
const [loading, setLoading] = useState(false);
const handleSearch = async () => {
setLoading(true);
try {
const result = await api.get<UserPassData[]>(`/user-word-pass?prefix=${encodeURIComponent(prefix)}`);
setData(result);
} catch (e) {
setData([]);
}
setLoading(false);
};
const columns = [
{ title: '姓名', dataIndex: 'fullname', key: 'fullname' },
{ title: '通过单词数', dataIndex: 'passCount', key: 'passCount' },
];
return (
<div>
<Input
placeholder="输入用户名/学号前缀"
value={prefix}
onChange={e => setPrefix(e.target.value)}
style={{ width: 200, marginRight: 10 }}
/>
<Button onClick={handleSearch} loading={loading}></Button>
<Table
dataSource={data}
columns={columns}
rowKey="username"
style={{ marginTop: 20 }}
pagination={{ pageSize: 30 }}
/>
</div>
);
};
export default UserWordPassExport;

View File

@@ -0,0 +1,254 @@
/* VirtualKeyboard.css */
.virtual-keyboard {
padding: 20px;
font-family: Arial, sans-serif;
}
.hands {
display: flex;
justify-content: center;
margin-bottom: 20px;
gap: 40px;
align-items: center;
}
.hand {
display: flex;
align-items: center;
gap: 10px;
}
.fingers {
display: flex;
gap: 10px;
}
/* 左手特定样式 */
.left-hand {
flex-direction: row;
}
/* 右手特定样式 */
.right-hand {
flex-direction: row;
}
/* 手指样式 */
.finger {
width: 40px;
height: 40px;
border-radius: 20px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
transition: all 0.2s ease;
border: 2px solid rgba(0, 0, 0, 0.1);
}
/* 手指颜色对应 */
.finger.pinky {
background-color: #FFE6E6;
border-color: #FFB3B3;
}
.finger.ring {
background-color: #FFF5E6;
border-color: #FFE0B3;
}
.finger.middle {
background-color: #E6FFE6;
border-color: #B3FFB3;
}
.finger.index {
background-color: #E6F3FF;
border-color: #B3D9FF;
}
.finger.thumb {
background-color: #FFFFFF;
border-color: #CCCCCC;
}
/* 键位样式加强 */
.key {
border: 2px solid rgba(0, 0, 0, 0.1);
}
.pink-key {
background-color: #FFE6E6;
border-color: #FFB3B3;
}
.beige-key {
background-color: #FFF5E6;
border-color: #FFE0B3;
}
.green-key {
background-color: #E6FFE6;
border-color: #B3FFB3;
}
.blue-key {
background-color: #E6F3FF;
border-color: #B3D9FF;
}
.default-key {
background-color: #FFFFFF;
border-color: #CCCCCC;
}
/* 激活状态的样式调整 */
.finger.active {
background-color: #2196F3;
border-color: #1976D2;
color: white;
}
.key.active {
background-color: #2196F3 !important;
border-color: #1976D2 !important;
color: white;
}
.finger.active {
background-color: #2196F3;
color: white;
}
.hand-label {
font-size: 14px;
color: #666;
writing-mode: vertical-lr;
text-orientation: upright;
padding: 5px;
}
.keyboard {
width: fit-content;
margin: 0 auto;
background-color: #f5f5f5;
padding: 10px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.keyboard-row {
display: flex;
margin: 2px 0;
}
.key {
position: relative;
min-width: 40px; /* 基础键宽 */
height: 40px;
margin: 2px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
cursor: default;
transition: all 0.2s ease;
user-select: none;
flex-grow: 0;
flex-shrink: 0;
}
/* 添加宽度倍数类 */
.key[data-width="1"] { width: 40px; }
.key[data-width="1.25"] { width: 50px; }
.key[data-width="1.5"] { width: 60px; }
.key[data-width="1.75"] { width: 70px; }
.key[data-width="2"] { width: 80px; }
.key[data-width="2.25"] { width: 90px; }
.key[data-width="2.75"] { width: 120px; }
.key[data-width="6.25"] { width: 207px; }
.key[data-width="2.5"] { width: 95px; }
.key.special-key {
min-width: 60px;
}
.key.active {
background-color: #2196F3 !important;
color: white;
}
.pink-key {
background-color: #FFE6E6;
}
.beige-key {
background-color: #FFF5E6;
}
.green-key {
background-color: #E6FFE6;
}
.blue-key {
background-color: #E6F3FF;
}
.default-key {
background-color: #FFFFFF;
}
.key-content {
position: relative;
width: 100%;
height: 100%;
}
.upper-char {
position: absolute;
top: 4px;
left: 4px;
font-size: 12px;
opacity: 0.6;
transition: all 0.2s ease;
}
.lower-char {
position: absolute;
bottom: 4px;
right: 4px;
font-size: 16px;
transition: all 0.2s ease;
}
.upper-char.active {
font-size: 16px;
opacity: 1;
}
.lower-char.active {
font-size: 16px;
opacity: 1;
}
.lower-char:not(.active) {
font-size: 12px;
opacity: 0.6;
}
.key:not(.active) .upper-char,
.key:not(.active) .lower-char {
color: #333;
}
.key.active .upper-char,
.key.active .lower-char {
color: white;
opacity: 1;
}
.main-char {
font-size: 16px;
}
.special-key .main-char {
font-size: 14px;
}

View File

@@ -0,0 +1,266 @@
// VirtualKeyboard.tsx
import React from 'react';
import './VirtualKeyboard.css';
interface VirtualKeyboardProps {
activeKey: string | null;
lastKey: string | null;
shiftPressed: boolean;
lastComboShift: string | null;
}
const VirtualKeyboard: React.FC<VirtualKeyboardProps> = ({ activeKey, lastKey,shiftPressed,lastComboShift }) => {
// 定义键盘布局
interface KeyConfig {
key: string;
width?: number; // 宽度比例1 表示标准键宽
}
// 修改键盘布局定义
const keyboardLayout: KeyConfig[][] = [
[
{ key: '`' }, { key: '1' }, { key: '2' }, { key: '3' }, { key: '4' },
{ key: '5' }, { key: '6' }, { key: '7' }, { key: '8' }, { key: '9' },
{ key: '0' }, { key: '-' }, { key: '=' }, { key: 'BackSpace', width: 2 }
],
[
{ key: 'Tab', width: 1.5 }, { key: 'q' }, { key: 'w' }, { key: 'e' }, { key: 'r' },
{ key: 't' }, { key: 'y' }, { key: 'u' }, { key: 'i' }, { key: 'o' },
{ key: 'p' }, { key: '[' }, { key: ']' }, { key: '\\', width: 1.5 }
],
[
{ key: 'Caps', width: 1.75 }, { key: 'a' }, { key: 's' }, { key: 'd' }, { key: 'f' },
{ key: 'g' }, { key: 'h' }, { key: 'j' }, { key: 'k' }, { key: 'l' },
{ key: ';' }, { key: '\'' }, { key: 'Enter', width: 2.5 }
],
[
{ key: 'leftshift', width: 2.25 }, { key: 'z' }, { key: 'x' }, { key: 'c' }, { key: 'v' },
{ key: 'b' }, { key: 'n' }, { key: 'm' }, { key: ',' }, { key: '.' },
{ key: '/' }, { key: 'rightshift', width: 2.75 }
],
[
{ key: 'Ctrl', width: 1.25 }, { key: 'Win', width: 1.25 }, { key: 'Alt', width: 1.25 },
{ key: 'Space', width: 6.25 },
{ key: 'Alt', width: 1.25 }, { key: 'Win', width: 1.25 }, { key: 'Menu', width: 1.25 },
{ key: 'Ctrl', width: 1.25 }
]
];
// 定义手指分配
const fingerMap: { [key: string]: string } = {
'`': 'L-ring', '1': 'L-ring', '2': 'L-ring', '3': 'L-middle', '4': 'L-index',
'5': 'L-index', '6': 'R-index', '7': 'R-index', '8': 'R-middle', '9': 'R-ring',
'0': 'R-ring', '-': 'R-ring', '=': 'R-ring',
'q': 'L-pinky', 'w': 'L-ring', 'e': 'L-middle', 'r': 'L-index',
't': 'L-index', 'y': 'R-index', 'u': 'R-index', 'i': 'R-middle',
'o': 'R-ring', 'p': 'R-pinky', '[': 'R-pinky', ']': 'R-pinky', '\\': 'R-pinky',
'a': 'L-pinky', 's': 'L-ring', 'd': 'L-middle', 'f': 'L-index',
'g': 'L-index', 'h': 'R-index', 'j': 'R-index', 'k': 'R-middle',
'l': 'R-ring', ';': 'R-pinky', '\'': 'R-pinky',
'z': 'L-pinky', 'x': 'L-ring', 'c': 'L-middle', 'v': 'L-index',
'b': 'L-index', 'n': 'R-index', 'm': 'R-index', ',': 'R-middle',
'.': 'R-ring', '/': 'R-pinky',
'leftshift': 'L-pinky',
'rightshift': 'R-pinky',
'space': 'R-thumb',
};
// 添加符号映射
const shiftSymbols: { [key: string]: string } = {
'`': '~', '1': '!', '2': '@', '3': '#', '4': '$', '5': '%',
'6': '^', '7': '&', '8': '*', '9': '(', '0': ')', '-': '_',
'=': '+', '[': '{', ']': '}', '\\': '|', ';': ':', '\'': '"',
',': '<', '.': '>', '/': '?'
};
// 扩展特殊键的映射
const getFingerForKey = (key: string): string => {
const lowerKey = key.toLowerCase();
const specialKeyMap: { [key: string]: string } = {
'shift': 'L-pinky',
'caps': 'L-pinky',
'tab': 'L-pinky',
'backspace': 'R-ring',
'enter': 'R-pinky',
'space': 'R-thumb',
};
return specialKeyMap[lowerKey] || fingerMap[lowerKey] || '';
};
const isShiftActive = (): boolean => {
return shiftPressed;
};
const isKeyActive = (key: string): boolean => {
// 如果当前有键被按下,优先显示当前按下的键
if (activeKey !== null) {
if (shiftPressed) {
// 处理符号键
if (shiftSymbols[key]) {
return activeKey === shiftSymbols[key];
}
// 处理字母键
return key.toLowerCase() === activeKey.toLowerCase();
}
return key.toLowerCase() === activeKey.toLowerCase();
}
// 处理组合键状态
if (lastKey !== null) {
// 如果有组合键状态有shift记录
if (lastComboShift) {
// 如果是shift键检查是否是记录的那个shift
if (key === 'leftshift' || key === 'rightshift') {
return key === lastComboShift;
}
// 处理符号键
if (shiftSymbols[key]) {
const shiftSymbol = shiftSymbols[key];
return lastKey === shiftSymbol;
}
// 处理字母键
return key.toLowerCase() === lastKey.toLowerCase();
}
// 如果没有组合键状态,只显示最后按下的键
return key.toLowerCase() === lastKey.toLowerCase();
}
return false;
};
const isFingerActive = (fingerType: string): boolean => {
if (!activeKey && !lastKey) return false;
const currentKey = activeKey || lastKey;
if (!currentKey) return false;
// 特殊处理空格键
if (currentKey === ' ' || currentKey.toLowerCase() === 'space') {
return fingerType === 'R-thumb';
}
// 处理其他键
return getFingerForKey(currentKey) === fingerType;
};
const getKeyClassName = (key: string): string => {
const fingerType = getFingerForKey(key.toLowerCase());
let colorClass = '';
switch (fingerType) {
case 'L-pinky':
case 'R-pinky':
colorClass = 'pink-key';
break;
case 'L-ring':
case 'R-ring':
colorClass = 'beige-key';
break;
case 'L-middle':
case 'R-middle':
colorClass = 'green-key';
break;
case 'L-index':
case 'R-index':
colorClass = 'blue-key';
break;
default:
colorClass = 'default-key';
}
return colorClass;
};
const getDisplayKey = (key: string): string => {
// 对特殊键不做处理
if (key.length > 1) return key;
// 如果 shift 被按下,显示相应的 shift 状态
if (shiftPressed) {
// 对于字母键,显示大写
if (key.match(/[a-z]/i)) {
return key.toUpperCase();
}
// 对于符号键,显示 shift 符号
if (shiftSymbols[key]) {
return shiftSymbols[key];
}
}
return key;
};
const renderKey = (key: string) => {
const isSpecial = key.length > 1;
const hasShiftSymbol = shiftSymbols[key];
const shifted = shiftPressed;
if (isSpecial) {
return <span className="main-char">{key}</span>;
}
if (hasShiftSymbol) {
return (
<div className="key-content">
<span className={`upper-char ${shifted ? 'active' : ''}`}>
{shiftSymbols[key]}
</span>
<span className={`lower-char ${!shifted ? 'active' : ''}`}>
{key}
</span>
</div>
);
}
return <span className="main-char">{getDisplayKey(key)}</span>;
};
return (
<div className="virtual-keyboard">
<div className="hands">
<div className="hand left-hand">
<div className="hand-label"></div>
<div className="fingers">
<div className={`finger pinky ${isFingerActive('L-pinky') ? 'active' : ''}`}></div>
<div className={`finger ring ${isFingerActive('L-ring') ? 'active' : ''}`}></div>
<div className={`finger middle ${isFingerActive('L-middle') ? 'active' : ''}`}></div>
<div className={`finger index ${isFingerActive('L-index') ? 'active' : ''}`}></div>
<div className={`finger thumb ${isFingerActive('L-thumb') ? 'active' : ''}`}></div>
</div>
</div>
<div className="hand right-hand">
<div className="fingers">
<div className={`finger thumb ${isFingerActive('R-thumb') ? 'active' : ''}`}></div>
<div className={`finger index ${isFingerActive('R-index') ? 'active' : ''}`}></div>
<div className={`finger middle ${isFingerActive('R-middle') ? 'active' : ''}`}></div>
<div className={`finger ring ${isFingerActive('R-ring') ? 'active' : ''}`}></div>
<div className={`finger pinky ${isFingerActive('R-pinky') ? 'active' : ''}`}></div>
</div>
<div className="hand-label"></div>
</div>
</div>
<div className="keyboard">
{keyboardLayout.map((row, rowIndex) => (
<div key={rowIndex} className="keyboard-row">
{row.map((keyConfig, keyIndex) => (
<div
key={keyIndex}
className={`key
${isKeyActive(keyConfig.key) ? 'active' : ''}
${keyConfig.key.length > 1 ? 'special-key' : ''}
${getKeyClassName(keyConfig.key)}
${isShiftActive() ? 'shifted' : ''}`}
data-width={keyConfig.width || 1}
>
{renderKey(keyConfig.key)}
</div>
))}
</div>
))}
</div>
</div>
);
};
export default VirtualKeyboard;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,12 @@
/* src/styles/logo.css */
.logo-container {
width: 300px;
height: 120px;
display: flex;
align-items: center;
justify-content: center;
background: #ffffff;
font-family: 'Segoe UI', Arial, sans-serif;
}
/* ... rest of the styles ... */

View File

@@ -0,0 +1,73 @@
import {
createEmptyBoard,
generateFullBoard,
generatePuzzle,
getRegionId,
getRegionNeighbors,
IRREGULAR_REGION_MAP,
isValidRegionMap,
isPlacementValid,
STANDARD_REGION_MAP,
} from './sudokuEngine';
const expectSolvedBoard = (board: number[][], regionMap: number[][]) => {
const expected = [1, 2, 3, 4, 5, 6, 7, 8, 9];
const sortNumbers = (values: number[]) => [...values].sort((a, b) => a - b);
for (let index = 0; index < 9; index++) {
expect(sortNumbers(board[index])).toEqual(expected);
expect(sortNumbers(board.map((row) => row[index]))).toEqual(expected);
const regionValues = board.flatMap((row, rowIndex) =>
row.filter((_, colIndex) => regionMap[rowIndex][colIndex] === index)
);
expect(sortNumbers(regionValues)).toEqual(expected);
}
};
describe('sudokuEngine', () => {
test('standard region map groups cells by 3x3 boxes', () => {
expect(getRegionId(STANDARD_REGION_MAP, 0, 0)).toBe(getRegionId(STANDARD_REGION_MAP, 2, 2));
expect(getRegionId(STANDARD_REGION_MAP, 0, 0)).not.toBe(getRegionId(STANDARD_REGION_MAP, 0, 3));
});
test('irregular region map has 9 connected regions with 9 cells each', () => {
expect(isValidRegionMap(IRREGULAR_REGION_MAP)).toBe(true);
});
test('isPlacementValid uses irregular regions instead of 3x3 boxes', () => {
const board = createEmptyBoard();
board[0][0] = 5;
expect(isPlacementValid(board, 1, 2, 5, IRREGULAR_REGION_MAP)).toBe(false);
const sameStandardBoxDifferentIrregularRegion = [1, 1] as const;
expect(getRegionId(IRREGULAR_REGION_MAP, 0, 0)).not.toBe(
getRegionId(IRREGULAR_REGION_MAP, sameStandardBoxDifferentIrregularRegion[0], sameStandardBoxDifferentIrregularRegion[1])
);
expect(isPlacementValid(board, sameStandardBoxDifferentIrregularRegion[0], sameStandardBoxDifferentIrregularRegion[1], 5, IRREGULAR_REGION_MAP)).toBe(true);
});
test('generatePuzzle creates a 9x9 puzzle with blanks for irregular mode', () => {
const puzzle = generatePuzzle('medium', IRREGULAR_REGION_MAP);
expect(puzzle).toHaveLength(9);
expect(puzzle.every((row) => row.length === 9)).toBe(true);
expect(puzzle.flat().some((cell) => cell === 0)).toBe(true);
});
test('generateFullBoard creates a solved irregular board', () => {
const board = generateFullBoard(IRREGULAR_REGION_MAP);
expectSolvedBoard(board, IRREGULAR_REGION_MAP);
});
test('region neighbors mark borders only when region changes', () => {
const border = getRegionNeighbors(IRREGULAR_REGION_MAP, 0, 0);
expect(border.top).toBe(true);
expect(border.left).toBe(true);
expect(typeof border.right).toBe('boolean');
expect(typeof border.bottom).toBe('boolean');
});
});

View File

@@ -0,0 +1,213 @@
export type GameBoard = number[][];
export type Difficulty = 'easy' | 'medium' | 'hard';
export type GameMode = 'standard' | 'irregular';
export type RegionMap = number[][];
export const createEmptyBoard = (): GameBoard => Array.from({ length: 9 }, () => Array(9).fill(0));
const shuffleArray = (arr: number[]) => {
const result = [...arr];
for (let i = result.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[result[i], result[j]] = [result[j], result[i]];
}
return result;
};
export const STANDARD_REGION_MAP: RegionMap = Array.from({ length: 9 }, (_, row) =>
Array.from({ length: 9 }, (_, col) => Math.floor(row / 3) * 3 + Math.floor(col / 3))
);
export const IRREGULAR_REGION_MAP: RegionMap = [
[0, 0, 0, 0, 0, 1, 1, 1, 1],
[2, 2, 0, 0, 3, 3, 1, 1, 1],
[2, 2, 0, 0, 3, 1, 1, 4, 4],
[2, 3, 3, 3, 3, 4, 4, 4, 4],
[2, 2, 2, 2, 3, 4, 5, 5, 5],
[6, 6, 6, 6, 3, 4, 7, 5, 5],
[6, 6, 6, 8, 8, 4, 7, 5, 5],
[6, 6, 8, 8, 8, 7, 7, 7, 5],
[8, 8, 8, 8, 7, 7, 7, 7, 5],
];
const IRREGULAR_FULL_BOARD: GameBoard = [
[2, 3, 6, 9, 4, 7, 5, 8, 1],
[5, 2, 7, 8, 6, 1, 3, 9, 4],
[3, 4, 1, 5, 9, 6, 2, 7, 8],
[8, 5, 2, 4, 7, 9, 1, 3, 6],
[6, 1, 9, 7, 3, 5, 8, 4, 2],
[1, 7, 5, 2, 8, 4, 9, 6, 3],
[9, 8, 3, 6, 5, 2, 4, 1, 7],
[4, 6, 8, 1, 2, 3, 7, 5, 9],
[7, 9, 4, 3, 1, 8, 6, 2, 5],
];
export const getRegionId = (regionMap: RegionMap, row: number, col: number): number => regionMap[row][col];
export const isValidRegionMap = (regionMap: RegionMap): boolean => {
if (regionMap.length !== 9 || regionMap.some((row) => row.length !== 9)) {
return false;
}
const cellsByRegion = Array.from({ length: 9 }, () => [] as Array<[number, number]>);
for (let row = 0; row < 9; row++) {
for (let col = 0; col < 9; col++) {
const regionId = regionMap[row][col];
if (!Number.isInteger(regionId) || regionId < 0 || regionId > 8) {
return false;
}
cellsByRegion[regionId].push([row, col]);
}
}
return cellsByRegion.every((cells) => cells.length === 9 && isConnectedRegion(cells));
};
const isConnectedRegion = (cells: Array<[number, number]>): boolean => {
if (cells.length === 0) return false;
const cellSet = new Set(cells.map(([row, col]) => `${row}-${col}`));
const seen = new Set<string>();
const queue: Array<[number, number]> = [cells[0]];
seen.add(`${cells[0][0]}-${cells[0][1]}`);
for (let index = 0; index < queue.length; index++) {
const [row, col] = queue[index];
const neighbors: Array<[number, number]> = [
[row - 1, col],
[row + 1, col],
[row, col - 1],
[row, col + 1],
];
for (const [nextRow, nextCol] of neighbors) {
const key = `${nextRow}-${nextCol}`;
if (cellSet.has(key) && !seen.has(key)) {
seen.add(key);
queue.push([nextRow, nextCol]);
}
}
}
return seen.size === cells.length;
};
const isSameRegionMap = (left: RegionMap, right: RegionMap): boolean =>
left.length === right.length &&
left.every((row, rowIndex) => row.length === right[rowIndex].length && row.every((cell, colIndex) => cell === right[rowIndex][colIndex]));
const randomizeDigits = (board: GameBoard): GameBoard => {
const shuffledDigits = shuffleArray([1, 2, 3, 4, 5, 6, 7, 8, 9]);
const digitMap = new Map(shuffledDigits.map((digit, index) => [index + 1, digit]));
return board.map((row) => row.map((cell) => digitMap.get(cell) ?? cell));
};
export const isPlacementValid = (
board: GameBoard,
row: number,
col: number,
num: number,
regionMap: RegionMap
): boolean => {
for (let c = 0; c < 9; c++) {
if (c !== col && board[row][c] === num) return false;
}
for (let r = 0; r < 9; r++) {
if (r !== row && board[r][col] === num) return false;
}
const targetRegion = getRegionId(regionMap, row, col);
for (let r = 0; r < 9; r++) {
for (let c = 0; c < 9; c++) {
if ((r !== row || c !== col) && getRegionId(regionMap, r, c) === targetRegion && board[r][c] === num) {
return false;
}
}
}
return true;
};
const fillBoard = (board: GameBoard, regionMap: RegionMap): boolean => {
for (let row = 0; row < 9; row++) {
for (let col = 0; col < 9; col++) {
if (board[row][col] !== 0) continue;
const numbers = shuffleArray([1, 2, 3, 4, 5, 6, 7, 8, 9]);
for (const num of numbers) {
if (isPlacementValid(board, row, col, num, regionMap)) {
board[row][col] = num;
if (fillBoard(board, regionMap)) {
return true;
}
board[row][col] = 0;
}
}
return false;
}
}
return true;
};
export const generateFullBoard = (regionMap: RegionMap): GameBoard => {
if (!isValidRegionMap(regionMap)) {
throw new Error('Invalid sudoku region map');
}
if (isSameRegionMap(regionMap, IRREGULAR_REGION_MAP)) {
return randomizeDigits(IRREGULAR_FULL_BOARD);
}
const board = createEmptyBoard();
if (!fillBoard(board, regionMap)) {
throw new Error('Unable to generate sudoku board');
}
return board;
};
const getRemovalCount = (difficulty: Difficulty) => {
switch (difficulty) {
case 'easy':
return 35;
case 'medium':
return 45;
case 'hard':
return 55;
default:
return 45;
}
};
export const generatePuzzle = (difficulty: Difficulty, regionMap: RegionMap): GameBoard => {
const fullBoard = generateFullBoard(regionMap);
const puzzle = fullBoard.map((row) => [...row]);
const positions = Array.from({ length: 81 }, (_, idx) => idx);
const toRemove = shuffleArray(positions).slice(0, getRemovalCount(difficulty));
for (const idx of toRemove) {
const row = Math.floor(idx / 9);
const col = idx % 9;
puzzle[row][col] = 0;
}
return puzzle;
};
export const getRegionNeighbors = (regionMap: RegionMap, row: number, col: number) => {
const regionId = getRegionId(regionMap, row, col);
return {
top: row === 0 || getRegionId(regionMap, row - 1, col) !== regionId,
right: col === 8 || getRegionId(regionMap, row, col + 1) !== regionId,
bottom: row === 8 || getRegionId(regionMap, row + 1, col) !== regionId,
left: col === 0 || getRegionId(regionMap, row, col - 1) !== regionId,
};
};
export const getRegionMapByMode = (mode: GameMode): RegionMap =>
mode === 'irregular' ? IRREGULAR_REGION_MAP : STANDARD_REGION_MAP;