| 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; |