blob: c855394ed0aeb7ce657e7d2ffd979ff11f24a259 [file] [log] [blame] [raw]
import { useState, useEffect } from 'react';
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
IconButton,
Button,
Box,
TablePagination,
Paper,
InputAdornment,
Select,
MenuItem,
FormControl,
InputLabel,
Tooltip,
Typography,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
DialogContentText,
useTheme,
useMediaQuery,
} from '@mui/material';
import {
Delete as DeleteIcon,
Add as AddIcon,
Save as SaveIcon,
Search as SearchIcon,
Warning as WarningIcon,
InfoOutlined,
} from '@mui/icons-material';
import config from '../config';
const styles = {
ipContainer: {
display: 'flex',
gap: '8px',
alignItems: 'flex-start',
width: '100%'
},
selectControl: {
minWidth: '180px',
marginRight: '8px'
},
textField: {
flex: 1,
'& .MuiInputBase-input': {
height: '40px'
}
}
};
// 添加一个检查重复的通用函数
const checkDuplicates = (records) => {
const warnings = {};
let hasError = false;
// 创建域名映射,记录每个域名出现的位置
const domainMap = new Map();
records.forEach((record, idx) => {
const domain = record.domain?.trim();
if (domain) {
if (domainMap.has(domain)) {
const existingIndexes = domainMap.get(domain);
existingIndexes.push(idx);
domainMap.set(domain, existingIndexes);
hasError = true;
} else {
domainMap.set(domain, [idx]);
}
}
});
// 标记所有重复的域名
domainMap.forEach((indexes, domain) => {
if (indexes.length > 1) {
indexes.forEach(idx => {
warnings[idx] = `与其他 ${indexes.length - 1} 条记录重复`;
});
}
});
return { warnings, hasError };
};
// 在表格中显示警告图标时,需要考虑分页
const getPageIndex = (globalIndex, page, rowsPerPage) => {
return globalIndex - (page * rowsPerPage);
};
// 添加 IP 地址验证函数
const isValidIPv4 = (ip) => {
const pattern = /^(\d{1,3}\.){3}\d{1,3}$/;
if (!pattern.test(ip)) return false;
const parts = ip.split('.');
return parts.every(part => {
const num = parseInt(part, 10);
return num >= 0 && num <= 255;
});
};
function DnsTable({ hosts, hints = {}, onSave }) {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const [records, setRecords] = useState([]);
const [filteredRecords, setFilteredRecords] = useState([]);
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(10);
const [domainSearch, setDomainSearch] = useState('');
const [ipSearch, setIpSearch] = useState('');
const [duplicateWarnings, setDuplicateWarnings] = useState({});
const [ipErrors, setIpErrors] = useState({});
const [versions, setVersions] = useState({ cn: 0, oversea: 0 });
const [ipPresets] = useState({
'国内解锁集群': '10.100.253.2',
'海外解锁集群': '10.100.253.1',
'自定义': 'custom'
});
const [deleteConfirm, setDeleteConfirm] = useState({
open: false,
index: null,
domain: ''
});
const [hasChanges, setHasChanges] = useState(false);
const [originalRecords, setOriginalRecords] = useState(null);
useEffect(() => {
console.log('Received hosts:', hosts);
if (!hosts) return;
// 使用立即执行的异步函数
(async () => {
try {
// 从 API 响应中获取版本号
const response = await fetch(`${config.api.baseUrl}/api/config`);
const data = await response.json();
setVersions({
cn: data.cnVersion,
oversea: data.overseaVersion
});
} catch (error) {
console.error('Error fetching versions:', error);
}
})();
// 修改数据处理逻辑
const initialRecords = Object.entries(hosts).map(([domain, ip]) => ({
domain,
// 如果是字符串,说明是从海外配置来的
cnIp: typeof ip === 'string' ? '' : (ip.cnIp || ''),
overseasIp: typeof ip === 'string' ? ip : (ip.overseasIp || ''),
isNew: false
}));
console.log('Initial records:', initialRecords);
setRecords(initialRecords);
setFilteredRecords(initialRecords);
setOriginalRecords(JSON.stringify(initialRecords));
setHasChanges(false);
}, [hosts]);
// 在组件加载时检查重复
useEffect(() => {
if (!records.length) return;
const { warnings } = checkDuplicates(records);
setDuplicateWarnings(warnings);
}, [records]);
// 检查是否有更改的函数
const checkForChanges = (newRecords) => {
if (!originalRecords) return false;
// 比较当前记录和原始记录
const currentRecords = newRecords.filter(r => r.domain.trim()).map(r => ({
domain: r.domain,
cnIp: r.cnIp,
overseasIp: r.overseasIp
}));
const hasChanged = JSON.stringify(currentRecords) !== originalRecords;
setHasChanges(hasChanged);
};
const handleAddRow = () => {
const newRecords = [...records, {
domain: '',
cnIp: '10.100.253.2',
overseasIp: '10.100.253.2',
syncIp: true,
isNew: true
}];
setRecords(newRecords);
setFilteredRecords(newRecords);
const newPage = Math.floor((newRecords.length - 1) / rowsPerPage);
setPage(newPage);
checkForChanges(newRecords);
};
const handleDeleteClick = (index) => {
const actualIndex = page * rowsPerPage + index;
const domain = records[actualIndex].domain;
setDeleteConfirm({
open: true,
index,
domain
});
};
const handleDeleteConfirm = () => {
const index = deleteConfirm.index;
const actualIndex = page * rowsPerPage + index;
const newRecords = records.filter((_, idx) => idx !== actualIndex);
setRecords(newRecords);
applyFilters(newRecords);
setDeleteConfirm({ open: false, index: null, domain: '' });
checkForChanges(newRecords);
};
const handleDeleteCancel = () => {
setDeleteConfirm({ open: false, index: null, domain: '' });
};
const handleDomainChange = (index, value) => {
const actualIndex = page * rowsPerPage + index;
const newRecords = [...records];
newRecords[actualIndex].domain = value;
setRecords(newRecords);
applyFilters(newRecords);
checkForChanges(newRecords);
};
const handleIpChange = (index, value, type) => {
const actualIndex = page * rowsPerPage + index;
const newRecords = [...records];
const stringValue = String(value || '');
// 验证 IP 地址
const isValid = isValidIPv4(stringValue);
const newIpErrors = { ...ipErrors };
if (!isValid && stringValue.trim() !== '') {
newIpErrors[`${actualIndex}-${type}`] = '请输入有效的 IPv4 地址';
} else {
delete newIpErrors[`${actualIndex}-${type}`];
}
setIpErrors(newIpErrors);
if (type === 'cn') {
newRecords[actualIndex].cnIp = stringValue;
if (newRecords[actualIndex].syncIp) {
newRecords[actualIndex].overseasIp = stringValue;
if (!isValid && stringValue.trim() !== '') {
newIpErrors[`${actualIndex}-overseas`] = '请输入有效的 IPv4 地址';
} else {
delete newIpErrors[`${actualIndex}-overseas`];
}
}
} else if (type === 'overseas') {
newRecords[actualIndex].overseasIp = stringValue;
if (newRecords[actualIndex].syncIp) {
newRecords[actualIndex].cnIp = stringValue;
if (!isValid && stringValue.trim() !== '') {
newIpErrors[`${actualIndex}-cn`] = '请输入有效的 IPv4 地址';
} else {
delete newIpErrors[`${actualIndex}-cn`];
}
}
}
setRecords(newRecords);
applyFilters(newRecords);
checkForChanges(newRecords);
};
const handleSyncChange = (index, value) => {
const actualIndex = page * rowsPerPage + index;
const newRecords = [...records];
newRecords[actualIndex].syncIp = value;
if (value) {
newRecords[actualIndex].overseasIp = newRecords[actualIndex].cnIp;
}
setRecords(newRecords);
applyFilters(newRecords);
checkForChanges(newRecords);
};
const handleSave = async () => {
try {
// 构建新的 hosts 对象
const newHosts = {
cn: {},
overseas: {}
};
records.forEach(record => {
if (record.domain.trim()) { // 只保存有域名的记录
newHosts.cn[record.domain] = record.cnIp;
newHosts.overseas[record.domain] = record.overseasIp;
}
});
const response = await onSave(newHosts);
if (response.message === '配置未发生变化') {
alert('配置未发生变化,无需保存');
return;
}
alert('保存成功');
} catch (error) {
console.error('Save error:', error);
alert('保存失败');
}
};
const applyFilters = (currentRecords) => {
console.log('Applying filters to:', currentRecords);
if (!currentRecords) return;
let filtered = currentRecords;
if (domainSearch) {
filtered = filtered.filter(record =>
(record.domain || '').toLowerCase().includes(domainSearch.toLowerCase())
);
console.log('After domain filter:', filtered);
}
if (ipSearch) {
const searchLower = ipSearch.toLowerCase();
console.log('Searching IP:', searchLower);
filtered = filtered.filter(record => {
const cnIp = String(record.cnIp || '');
const overseasIp = String(record.overseasIp || '');
const matches = cnIp.toLowerCase().includes(searchLower) ||
overseasIp.toLowerCase().includes(searchLower);
console.log('Record:', record, 'Matches:', matches);
return matches;
});
console.log('After IP filter:', filtered);
}
// 检查重复
const { warnings } = checkDuplicates(currentRecords);
setDuplicateWarnings(warnings);
console.log('Final filtered records:', filtered);
setFilteredRecords(filtered);
};
useEffect(() => {
applyFilters(records);
}, [domainSearch, ipSearch]);
// 计算是否有错误(域名重复或IP格式错误)
const hasErrors = Object.keys(duplicateWarnings).length > 0 ||
Object.keys(ipErrors).length > 0;
return (
<Box>
{/* 提示信息框 - 移动端优化 */}
{hints && (
<Box
sx={{
mb: 3,
p: isMobile ? 1.5 : 2,
backgroundColor: 'info.lighter',
borderRadius: 2,
border: '1px solid',
borderColor: 'info.light',
display: 'flex',
gap: isMobile ? 1 : 2,
alignItems: 'flex-start'
}}
>
<InfoOutlined sx={{
color: 'info.main',
display: isMobile ? 'none' : 'block' // 在移动端隐藏图标
}} />
<Box>
<Typography
variant={isMobile ? "body1" : "subtitle2"}
sx={{ mb: 1, color: 'info.dark' }}
>
配置说明
</Typography>
<Typography
variant="body2"
color="info.dark"
sx={{ mb: 0.5 }}
>
域名格式:{hints.domain || '加载中...'}
</Typography>
<Typography variant="body2" color="info.dark">
IP地址:{hints.ip?.cn || '加载中...'} / {hints.ip?.overseas || '加载中...'}
</Typography>
</Box>
</Box>
)}
{/* DNS 列表 - 移动端优化 */}
<TableContainer
component={Paper}
sx={{
maxWidth: '100vw',
overflow: 'auto'
}}
>
<Table size={isMobile ? "small" : "medium"}>
<TableHead>
<TableRow>
{!isMobile && <TableCell width="40px">状态</TableCell>}
<TableCell sx={{ minWidth: isMobile ? 280 : 'auto' }}>
<Typography>域名</Typography>
<TextField
fullWidth
size="small"
margin="dense"
placeholder="搜索域名..."
value={domainSearch}
onChange={(e) => setDomainSearch(e.target.value)}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
),
}}
/>
</TableCell>
<TableCell sx={{ minWidth: isMobile ? 280 : 'auto' }}>
<Typography>IP地址</Typography>
<TextField
fullWidth
size="small"
margin="dense"
placeholder="搜索IP..."
value={ipSearch}
onChange={(e) => setIpSearch(e.target.value)}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
),
}}
/>
</TableCell>
<TableCell align="center" sx={{ width: isMobile ? 50 : 'auto' }}>操作</TableCell>
</TableRow>
</TableHead>
<TableBody>
{filteredRecords
.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage)
.map((record, index) => {
const globalIndex = page * rowsPerPage + index;
return (
<TableRow key={index}>
{!isMobile ? (
<TableCell>
{duplicateWarnings[globalIndex] && (
<Tooltip title={duplicateWarnings[globalIndex]} arrow>
<WarningIcon
sx={{
color: 'error.main',
verticalAlign: 'middle',
cursor: 'pointer'
}}
/>
</Tooltip>
)}
</TableCell>
) : null}
<TableCell>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{isMobile && duplicateWarnings[globalIndex] && (
<Tooltip title={duplicateWarnings[globalIndex]} arrow>
<WarningIcon
sx={{
color: 'error.main',
cursor: 'pointer',
flexShrink: 0
}}
/>
</Tooltip>
)}
<TextField
fullWidth
size="small"
value={record.domain}
onChange={(e) => handleDomainChange(index, e.target.value)}
sx={{
'& .MuiInputBase-input': {
fontSize: isMobile ? '0.875rem' : 'inherit',
padding: isMobile ? '8px 10px' : undefined
}
}}
/>
</Box>
</TableCell>
<TableCell>
<Box sx={{
display: 'flex',
gap: 1,
alignItems: 'center',
flexDirection: isMobile ? 'column' : 'row',
py: isMobile ? 1 : 0 // 添加垂直内边距
}}>
<FormControl
size="small"
sx={{
width: isMobile ? '100%' : 120,
mb: isMobile ? 1 : 0,
'& .MuiOutlinedInput-root': {
backgroundColor: 'background.paper' // 添加背景色
}
}}
>
<InputLabel>配置方式</InputLabel>
<Select
value={record.syncIp ? 'sync' : 'separate'}
onChange={(e) => handleSyncChange(index, e.target.value === 'sync')}
label="配置方式"
>
<MenuItem value="sync">统一配置</MenuItem>
<MenuItem value="separate">分开配置</MenuItem>
</Select>
</FormControl>
{record.syncIp ? (
<TextField
sx={{
flex: 1,
width: isMobile ? '100%' : 'auto',
'& .MuiOutlinedInput-root': {
backgroundColor: 'background.paper'
}
}}
size="small"
value={record.cnIp}
onChange={(e) => handleIpChange(index, e.target.value, 'cn')}
placeholder="统一IP地址..."
label="统一IP"
/>
) : (
<Box sx={{
display: 'flex',
gap: 1,
width: '100%',
flexDirection: isMobile ? 'column' : 'row'
}}>
<TextField
sx={{
flex: 1,
'& .MuiOutlinedInput-root': {
backgroundColor: 'background.paper'
}
}}
size="small"
value={record.cnIp}
onChange={(e) => handleIpChange(index, e.target.value, 'cn')}
placeholder="国内IP地址..."
label="国内IP"
error={!!ipErrors[`${globalIndex}-cn`]}
helperText={ipErrors[`${globalIndex}-cn`]}
/>
<TextField
sx={{
flex: 1,
'& .MuiOutlinedInput-root': {
backgroundColor: 'background.paper'
}
}}
size="small"
value={record.overseasIp}
onChange={(e) => handleIpChange(index, e.target.value, 'overseas')}
placeholder="海外IP地址..."
label="海外IP"
error={!!ipErrors[`${globalIndex}-overseas`]}
helperText={ipErrors[`${globalIndex}-overseas`]}
/>
</Box>
)}
</Box>
</TableCell>
<TableCell align="center">
<IconButton
color="error"
onClick={() => handleDeleteClick(index)}
>
<DeleteIcon />
</IconButton>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
{/* 分页控件 - 移动端优化 */}
<TablePagination
component="div"
count={filteredRecords.length}
page={page}
onPageChange={(e, newPage) => setPage(newPage)}
rowsPerPage={rowsPerPage}
onRowsPerPageChange={(e) => {
setRowsPerPage(parseInt(e.target.value, 10));
setPage(0);
}}
labelRowsPerPage={isMobile ? "行数" : "每页行数"}
sx={{
'.MuiTablePagination-selectLabel': {
margin: isMobile ? 0 : undefined,
},
'.MuiTablePagination-displayedRows': {
margin: isMobile ? 0 : undefined,
}
}}
/>
{/* 按钮组 - 移动端优化 */}
<Box sx={{
display: 'flex',
gap: 2,
mt: 2,
justifyContent: 'flex-end',
flexDirection: isMobile ? 'column' : 'row',
'& > button': {
width: isMobile ? '100%' : 'auto'
}
}}>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={handleAddRow}
>
添加
</Button>
<Button
variant="contained"
startIcon={<SaveIcon />}
onClick={handleSave}
disabled={hasErrors || !hasChanges}
color="primary"
>
保存
</Button>
</Box>
{/* 删除确认对话框 */}
<Dialog
open={deleteConfirm.open}
onClose={handleDeleteCancel}
aria-labelledby="delete-dialog-title"
aria-describedby="delete-dialog-description"
>
<DialogTitle id="delete-dialog-title">
确认删除
</DialogTitle>
<DialogContent>
<DialogContentText id="delete-dialog-description">
{deleteConfirm.domain ?
`确定要删除域名 "${deleteConfirm.domain}" 的配置吗?` :
'确定要删除这条配置吗?'
}
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={handleDeleteCancel} color="primary">
取消
</Button>
<Button onClick={handleDeleteConfirm} color="error" autoFocus>
删除
</Button>
</DialogActions>
</Dialog>
</Box>
);
}
export default DnsTable;