Migrate project from typing_practiceweb
This commit is contained in:
343
src/components/AdminCodeManager.tsx
Normal file
343
src/components/AdminCodeManager.tsx
Normal 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;
|
||||
90
src/components/AdminDashboard.tsx
Normal file
90
src/components/AdminDashboard.tsx
Normal 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;
|
||||
306
src/components/AdminOAuth2Manager.tsx
Normal file
306
src/components/AdminOAuth2Manager.tsx
Normal 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;
|
||||
167
src/components/AdminPracticeRecords.tsx
Normal file
167
src/components/AdminPracticeRecords.tsx
Normal 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;
|
||||
796
src/components/AdminUserManager.tsx
Normal file
796
src/components/AdminUserManager.tsx
Normal 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>username:3-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;
|
||||
573
src/components/AdminVocabularyManager.tsx
Normal file
573
src/components/AdminVocabularyManager.tsx
Normal 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;
|
||||
113
src/components/ChangePassword.tsx
Normal file
113
src/components/ChangePassword.tsx
Normal 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
28
src/components/Footer.tsx
Normal 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
14
src/components/Home.tsx
Normal 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;
|
||||
5
src/components/KMeansDemo.css
Normal file
5
src/components/KMeansDemo.css
Normal file
@@ -0,0 +1,5 @@
|
||||
/* K-Means Demo 组件专用样式 - 消息从底部弹出 */
|
||||
.kmeans-demo-container .ant-message {
|
||||
top: auto !important;
|
||||
bottom: 50px !important;
|
||||
}
|
||||
1451
src/components/KMeansDemo.tsx
Normal file
1451
src/components/KMeansDemo.tsx
Normal file
File diff suppressed because it is too large
Load Diff
58
src/components/KeywordManager.tsx
Normal file
58
src/components/KeywordManager.tsx
Normal 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;
|
||||
171
src/components/LandingPage.tsx
Normal file
171
src/components/LandingPage.tsx
Normal 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;
|
||||
259
src/components/Leaderboard.tsx
Normal file
259
src/components/Leaderboard.tsx
Normal 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
173
src/components/Login.tsx
Normal 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;
|
||||
2319
src/components/MinesweeperGame.tsx
Normal file
2319
src/components/MinesweeperGame.tsx
Normal file
File diff suppressed because it is too large
Load Diff
255
src/components/MinesweeperLeaderboard.tsx
Normal file
255
src/components/MinesweeperLeaderboard.tsx
Normal 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;
|
||||
59
src/components/MinesweeperTabs.tsx
Normal file
59
src/components/MinesweeperTabs.tsx
Normal 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
281
src/components/Navbar.css
Normal 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
242
src/components/Navbar.tsx
Normal 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
887
src/components/Practice.tsx
Normal 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;
|
||||
130
src/components/PracticeHistory.tsx
Normal file
130
src/components/PracticeHistory.tsx
Normal 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
176
src/components/Register.tsx
Normal 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;
|
||||
1014
src/components/SpectatorMinesweeper.tsx
Normal file
1014
src/components/SpectatorMinesweeper.tsx
Normal file
File diff suppressed because it is too large
Load Diff
95
src/components/StudentSearch.tsx
Normal file
95
src/components/StudentSearch.tsx
Normal 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;
|
||||
610
src/components/SudokuGame.tsx
Normal file
610
src/components/SudokuGame.tsx
Normal 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;
|
||||
238
src/components/SudokuLeaderboard.tsx
Normal file
238
src/components/SudokuLeaderboard.tsx
Normal 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;
|
||||
34
src/components/SudokuTabs.tsx
Normal file
34
src/components/SudokuTabs.tsx
Normal 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;
|
||||
695
src/components/TowerDefense/TowerDefenseEngine.ts
Normal file
695
src/components/TowerDefense/TowerDefenseEngine.ts
Normal 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); }
|
||||
}
|
||||
218
src/components/TowerDefenseEmbed.tsx
Normal file
218
src/components/TowerDefenseEmbed.tsx
Normal 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;
|
||||
8
src/components/TowerDefenseGame.tsx
Normal file
8
src/components/TowerDefenseGame.tsx
Normal 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;
|
||||
177
src/components/TowerDefenseLeaderboard.tsx
Normal file
177
src/components/TowerDefenseLeaderboard.tsx
Normal 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;
|
||||
39
src/components/TowerDefenseTabs.tsx
Normal file
39
src/components/TowerDefenseTabs.tsx
Normal 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;
|
||||
227
src/components/TypingPractice.tsx
Normal file
227
src/components/TypingPractice.tsx
Normal 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;
|
||||
39
src/components/TypingTabs.tsx
Normal file
39
src/components/TypingTabs.tsx
Normal 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;
|
||||
52
src/components/UserWordPassExport.tsx
Normal file
52
src/components/UserWordPassExport.tsx
Normal 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;
|
||||
254
src/components/VirtualKeyboard.css
Normal file
254
src/components/VirtualKeyboard.css
Normal 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;
|
||||
}
|
||||
266
src/components/VirtualKeyboard.tsx
Normal file
266
src/components/VirtualKeyboard.tsx
Normal 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;
|
||||
1573
src/components/VocabularyStudy.tsx
Normal file
1573
src/components/VocabularyStudy.tsx
Normal file
File diff suppressed because it is too large
Load Diff
12
src/components/styles/logo.css
Normal file
12
src/components/styles/logo.css
Normal 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 ... */
|
||||
73
src/components/sudokuEngine.test.ts
Normal file
73
src/components/sudokuEngine.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
213
src/components/sudokuEngine.ts
Normal file
213
src/components/sudokuEngine.ts
Normal 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;
|
||||
Reference in New Issue
Block a user