码桶
发现社区成员的开源项目
DnsRecordModal.jsx33.1 KB
import React, { useState, useEffect, useRef } from 'react';
import { BookOpen, ChevronDown, Clock, RefreshCw } from 'lucide-react';
import CustomSelect from './CustomSelect.jsx';
const DnsRecordModal = ({ zone, show, editingRecord, onClose, onSubmit, onSchedule, t, showToast }) => {
const defaultRecord = { type: 'A', name: '', content: '', ttl: 1, proxied: true, comment: '', tags: [], priority: 10, data: {} };
const [newRecord, setNewRecord] = useState(defaultRecord);
const [saving, setSaving] = useState(false);
const [showTemplates, setShowTemplates] = useState(false);
const [tagInput, setTagInput] = useState('');
const [showSchedulePicker, setShowSchedulePicker] = useState(false);
const [scheduledAt, setScheduledAt] = useState('');
const templateRef = useRef(null);
// Initialize form when opening or when editingRecord changes
useEffect(() => {
if (show) {
if (editingRecord) {
setNewRecord({
type: editingRecord.type,
name: editingRecord.name,
content: editingRecord.content,
ttl: editingRecord.ttl,
proxied: editingRecord.proxied,
comment: editingRecord.comment || '',
tags: editingRecord.tags || [],
priority: editingRecord.priority || 10,
data: editingRecord.data || {}
});
} else {
setNewRecord(defaultRecord);
}
setTagInput('');
setShowTemplates(false);
setShowSchedulePicker(false);
setScheduledAt('');
}
}, [show, editingRecord]);
// Close templates dropdown on outside click
useEffect(() => {
function handleClickOutside(event) {
if (templateRef.current && !templateRef.current.contains(event.target)) {
setShowTemplates(false);
}
}
if (showTemplates) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [showTemplates]);
// DNS Record Templates
const dnsTemplates = [
{
key: 'spf',
label: t('templateSpf'),
record: { type: 'TXT', name: '@', content: 'v=spf1 mx ~all', ttl: 1, proxied: false, comment: '', tags: [], priority: 10, data: {} }
},
{
key: 'dmarc',
label: t('templateDmarc'),
record: { type: 'TXT', name: '_dmarc', content: `v=DMARC1; p=none; rua=mailto:dmarc@${zone.name}`, ttl: 1, proxied: false, comment: '', tags: [], priority: 10, data: {} }
},
{
key: 'google_mx',
label: t('templateGoogleMx'),
record: { type: 'MX', name: '@', content: 'aspmx.l.google.com', ttl: 1, proxied: false, comment: 'Google Workspace MX (1 of 5)', tags: [], priority: 1, data: {} }
},
{
key: 'www_cname',
label: t('templateWwwCname'),
record: { type: 'CNAME', name: 'www', content: zone.name, ttl: 1, proxied: true, comment: '', tags: [], priority: 10, data: {} }
},
{
key: 'mail_mx',
label: t('templateMailMx'),
record: { type: 'MX', name: '@', content: `mail.${zone.name}`, ttl: 1, proxied: false, comment: '', tags: [], priority: 10, data: {} }
},
{
key: 'google_verify',
label: t('templateGoogleVerify'),
record: { type: 'TXT', name: '@', content: 'google-site-verification=PASTE_CODE_HERE', ttl: 1, proxied: false, comment: '', tags: [], priority: 10, data: {} }
}
];
const applyTemplate = (template) => {
setNewRecord({ ...template.record });
setShowTemplates(false);
showToast(t('templateApplied'));
};
const handleSubmit = async (e) => {
e.preventDefault();
setSaving(true);
try {
await onSubmit(e, newRecord, editingRecord);
} finally {
setSaving(false);
}
};
const handleScheduleSubmit = () => {
if (!scheduledAt) return;
const scheduledDate = new Date(scheduledAt);
if (scheduledDate.getTime() <= Date.now()) {
showToast(t('scheduleMustBeFuture'), 'error');
return;
}
if (onSchedule) {
onSchedule(newRecord, editingRecord, scheduledDate.toISOString());
setShowSchedulePicker(false);
setScheduledAt('');
}
};
// Compute a default datetime-local value (1 hour from now)
const getMinDatetime = () => {
const now = new Date();
now.setMinutes(now.getMinutes() - now.getTimezoneOffset());
return now.toISOString().slice(0, 16);
};
if (!show) return null;
return (
<div
className="modal-overlay"
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div className="glass-card fade-in modal-content" role="dialog" aria-label={editingRecord ? t('editRecord') : t('addModalTitle')} style={{ padding: '2rem', maxWidth: '450px', width: '90%', position: 'relative' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
<h2 style={{ margin: 0 }}>{editingRecord ? t('editRecord') : t('addModalTitle')}</h2>
{!editingRecord && (
<div ref={templateRef} style={{ position: 'relative' }}>
<button
type="button"
className="btn btn-outline"
style={{ padding: '4px 10px', fontSize: '0.75rem', display: 'flex', alignItems: 'center', gap: '4px' }}
onClick={() => setShowTemplates(!showTemplates)}
>
<BookOpen size={14} />
{t('templates')}
<ChevronDown size={12} style={{ transform: showTemplates ? 'rotate(180deg)' : 'rotate(0)', transition: 'transform 0.2s' }} />
</button>
{showTemplates && (
<div className="glass-card fade-in" style={{
position: 'absolute',
top: '110%',
right: 0,
zIndex: 100,
minWidth: '260px',
padding: '0.25rem',
boxShadow: '0 10px 25px -5px rgba(0, 0, 0, 0.15)',
maxHeight: '300px',
overflowY: 'auto'
}}>
{dnsTemplates.map(tmpl => (
<div
key={tmpl.key}
role="button"
tabIndex={0}
onClick={() => applyTemplate(tmpl)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); applyTemplate(tmpl); } }}
style={{
padding: '0.5rem 0.75rem',
cursor: 'pointer',
borderRadius: '6px',
fontSize: '0.8125rem',
display: 'flex',
alignItems: 'center',
gap: '8px',
transition: 'background 0.1s'
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--hover-bg, #f9fafb)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
<span className="badge badge-blue" style={{ fontSize: '0.6rem', padding: '1px 4px', flexShrink: 0 }}>{tmpl.record.type}</span>
<span>{tmpl.label}</span>
</div>
))}
</div>
)}
</div>
)}
</div>
<form onSubmit={handleSubmit}>
<div className="input-row">
<label>{t('type')}</label>
<div style={{ flex: 1 }}>
<CustomSelect
value={newRecord.type}
onChange={(e) => {
const newType = e.target.value;
const proxyableTypes = ['A', 'AAAA', 'CNAME'];
setNewRecord({
...newRecord,
type: newType,
proxied: proxyableTypes.includes(newType) ? newRecord.proxied : false
});
}}
options={['A', 'AAAA', 'CNAME', 'TXT', 'MX', 'NS', 'SRV', 'URI', 'CAA', 'DS', 'TLSA', 'CERT', 'DNSKEY', 'HTTPS', 'LOC', 'NAPTR', 'PTR', 'SMIMEA', 'SSHFP', 'SVCB'].map(t => ({ value: t, label: t }))}
/>
</div>
</div>
<div className="input-row">
<label>{t('name')}</label>
<input type="text" value={newRecord.name} onChange={e => setNewRecord({ ...newRecord, name: e.target.value })} placeholder={newRecord.type === 'SRV' ? '_sip._tcp' : '@'} required />
</div>
{!['SRV', 'CAA', 'URI', 'DS', 'TLSA', 'NAPTR', 'SSHFP', 'HTTPS', 'SVCB'].includes(newRecord.type) && (
<div className="input-row">
<label>{t('content')}</label>
<input type="text" value={newRecord.content} onChange={e => setNewRecord({ ...newRecord, content: e.target.value })} placeholder={newRecord.type === 'LOC' ? '33 40 31 N 106 28 29 W 10m' : 'Value'} required />
</div>
)}
{newRecord.type === 'SRV' && (
<>
<div className="input-row">
<label>{t('service')}</label>
<input type="text" value={newRecord.data?.service || ''} onChange={e => setNewRecord({ ...newRecord, data: { ...newRecord.data, service: e.target.value } })} placeholder="_sip" required />
</div>
<div className="input-row">
<label>{t('protocol')}</label>
<input type="text" value={newRecord.data?.proto || ''} onChange={e => setNewRecord({ ...newRecord, data: { ...newRecord.data, proto: e.target.value } })} placeholder="_tcp" required />
</div>
<div className="input-row">
<label>{t('priority')}</label>
<input type="number" value={newRecord.data?.priority || 10} onChange={e => setNewRecord({ ...newRecord, data: { ...newRecord.data, priority: parseInt(e.target.value) } })} min="0" max="65535" required />
</div>
<div className="input-row">
<label>{t('weight')}</label>
<input type="number" value={newRecord.data?.weight || 5} onChange={e => setNewRecord({ ...newRecord, data: { ...newRecord.data, weight: parseInt(e.target.value) } })} min="0" max="65535" required />
</div>
<div className="input-row">
<label>{t('port')}</label>
<input type="number" value={newRecord.data?.port || 5060} onChange={e => setNewRecord({ ...newRecord, data: { ...newRecord.data, port: parseInt(e.target.value) } })} min="0" max="65535" required />
</div>
<div className="input-row">
<label>{t('target')}</label>
<input type="text" value={newRecord.data?.target || ''} onChange={e => setNewRecord({ ...newRecord, data: { ...newRecord.data, target: e.target.value } })} placeholder="sipserver.example.com" required />
</div>
</>
)}
{newRecord.type === 'URI' && (
<>
<div className="input-row">
<label>{t('priority')}</label>
<input type="number" value={newRecord.data?.priority || 10} onChange={e => setNewRecord({ ...newRecord, data: { ...newRecord.data, priority: parseInt(e.target.value) } })} min="0" max="65535" required />
</div>
<div className="input-row">
<label>{t('weight')}</label>
<input type="number" value={newRecord.data?.weight || 5} onChange={e => setNewRecord({ ...newRecord, data: { ...newRecord.data, weight: parseInt(e.target.value) } })} min="0" max="65535" required />
</div>
<div className="input-row">
<label>{t('target')}</label>
<input type="text" value={newRecord.data?.target || ''} onChange={e => setNewRecord({ ...newRecord, data: { ...newRecord.data, target: e.target.value } })} placeholder="https://example.com" required />
</div>
</>
)}
{newRecord.type === 'CAA' && (
<>
<div className="input-row">
<label>{t('flags')}</label>
<input type="number" value={newRecord.data?.flags || 0} onChange={e => setNewRecord({ ...newRecord, data: { ...newRecord.data, flags: parseInt(e.target.value) } })} min="0" max="255" required />
</div>
<div className="input-row">
<label>{t('tag')}</label>
<CustomSelect
value={newRecord.data?.tag || 'issue'}
onChange={(e) => setNewRecord({ ...newRecord, data: { ...newRecord.data, tag: e.target.value } })}
options={[
{ value: 'issue', label: 'issue' },
{ value: 'issuewild', label: 'issuewild' },
{ value: 'iodef', label: 'iodef' }
]}
/>
</div>
<div className="input-row">
<label>{t('value')}</label>
<input type="text" value={newRecord.data?.value || ''} onChange={e => setNewRecord({ ...newRecord, data: { ...newRecord.data, value: e.target.value } })} placeholder="comodoca.com" required />
</div>
</>
)}
{newRecord.type === 'DS' && (
<>
<div className="input-row">
<label>{t('keyTag')}</label>
<input type="number" value={newRecord.data?.key_tag || ''} onChange={e => setNewRecord({ ...newRecord, data: { ...newRecord.data, key_tag: parseInt(e.target.value) } })} min="0" max="65535" required />
</div>
<div className="input-row">
<label>{t('algorithm')}</label>
<input type="number" value={newRecord.data?.algorithm || ''} onChange={e => setNewRecord({ ...newRecord, data: { ...newRecord.data, algorithm: parseInt(e.target.value) } })} min="0" max="255" required />
</div>
<div className="input-row">
<label>{t('digestType')}</label>
<input type="number" value={newRecord.data?.digest_type || ''} onChange={e => setNewRecord({ ...newRecord, data: { ...newRecord.data, digest_type: parseInt(e.target.value) } })} min="0" max="255" required />
</div>
<div className="input-row">
<label>{t('digest')}</label>
<input type="text" value={newRecord.data?.digest || ''} onChange={e => setNewRecord({ ...newRecord, data: { ...newRecord.data, digest: e.target.value } })} placeholder={t('digest')} required />
</div>
</>
)}
{newRecord.type === 'TLSA' && (
<>
<div className="input-row">
<label>{t('usage')}</label>
<input type="number" value={newRecord.data?.usage || ''} onChange={e => setNewRecord({ ...newRecord, data: { ...newRecord.data, usage: parseInt(e.target.value) } })} min="0" max="255" required />
</div>
<div className="input-row">
<label>{t('selector')}</label>
<input type="number" value={newRecord.data?.selector || ''} onChange={e => setNewRecord({ ...newRecord, data: { ...newRecord.data, selector: parseInt(e.target.value) } })} min="0" max="255" required />
</div>
<div className="input-row">
<label>{t('matchingType')}</label>
<input type="number" value={newRecord.data?.matching_type || ''} onChange={e => setNewRecord({ ...newRecord, data: { ...newRecord.data, matching_type: parseInt(e.target.value) } })} min="0" max="255" required />
</div>
<div className="input-row">
<label>{t('certificate')}</label>
<input type="text" value={newRecord.data?.certificate || ''} onChange={e => setNewRecord({ ...newRecord, data: { ...newRecord.data, certificate: e.target.value } })} placeholder={t('certificate')} required />
</div>
</>
)}
{newRecord.type === 'NAPTR' && (
<>
<div className="input-row">
<label>{t('order')}</label>
<input type="number" value={newRecord.data?.order || 100} onChange={e => setNewRecord({ ...newRecord, data: { ...newRecord.data, order: parseInt(e.target.value) } })} min="0" max="65535" required />
</div>
<div className="input-row">
<label>{t('preference')}</label>
<input type="number" value={newRecord.data?.preference || 10} onChange={e => setNewRecord({ ...newRecord, data: { ...newRecord.data, preference: parseInt(e.target.value) } })} min="0" max="65535" required />
</div>
<div className="input-row">
<label>{t('flags')}</label>
<input type="text" value={newRecord.data?.flags || ''} onChange={e => setNewRecord({ ...newRecord, data: { ...newRecord.data, flags: e.target.value } })} placeholder="S" required />
</div>
<div className="input-row">
<label>{t('service')}</label>
<input type="text" value={newRecord.data?.service || ''} onChange={e => setNewRecord({ ...newRecord, data: { ...newRecord.data, service: e.target.value } })} placeholder="http+E2U" required />
</div>
<div className="input-row">
<label>{t('regex')}</label>
<input type="text" value={newRecord.data?.regex || ''} onChange={e => setNewRecord({ ...newRecord, data: { ...newRecord.data, regex: e.target.value } })} placeholder={t('regex')} />
</div>
<div className="input-row">
<label>{t('replacement')}</label>
<input type="text" value={newRecord.data?.replacement || ''} onChange={e => setNewRecord({ ...newRecord, data: { ...newRecord.data, replacement: e.target.value } })} placeholder="." />
</div>
</>
)}
{newRecord.type === 'SSHFP' && (
<>
<div className="input-row">
<label>{t('algorithm')}</label>
<input type="number" value={newRecord.data?.algorithm || 4} onChange={e => setNewRecord({ ...newRecord, data: { ...newRecord.data, algorithm: parseInt(e.target.value) } })} min="0" max="255" required />
</div>
<div className="input-row">
<label>{t('type')}</label>
<input type="number" value={newRecord.data?.type || 2} onChange={e => setNewRecord({ ...newRecord, data: { ...newRecord.data, type: parseInt(e.target.value) } })} min="0" max="255" required />
</div>
<div className="input-row">
<label>{t('fingerprint')}</label>
<input type="text" value={newRecord.data?.fingerprint || ''} onChange={e => setNewRecord({ ...newRecord, data: { ...newRecord.data, fingerprint: e.target.value } })} placeholder={t('fingerprint')} required />
</div>
</>
)}
{(newRecord.type === 'HTTPS' || newRecord.type === 'SVCB') && (
<>
<div className="input-row">
<label>{t('priority')}</label>
<input type="number" value={newRecord.data?.priority || 1} onChange={e => setNewRecord({ ...newRecord, data: { ...newRecord.data, priority: parseInt(e.target.value) } })} min="0" max="65535" required />
</div>
<div className="input-row">
<label>{t('target')}</label>
<input type="text" value={newRecord.data?.target || ''} onChange={e => setNewRecord({ ...newRecord, data: { ...newRecord.data, target: e.target.value } })} placeholder="example.com" required />
</div>
<div className="input-row">
<label>{t('value')}</label>
<input type="text" value={newRecord.data?.value || ''} onChange={e => setNewRecord({ ...newRecord, data: { ...newRecord.data, value: e.target.value } })} placeholder="alpn=h3,h2" required />
</div>
</>
)}
<div className="input-row">
<label>{t('ttl')}</label>
<div style={{ flex: 1 }}>
<CustomSelect
value={newRecord.ttl}
onChange={(e) => setNewRecord({ ...newRecord, ttl: parseInt(e.target.value) })}
options={[
{ value: 1, label: t('ttlAuto') },
{ value: 60, label: t('ttl1min') },
{ value: 120, label: t('ttl2min') },
{ value: 300, label: t('ttl5min') },
{ value: 600, label: t('ttl10min') },
{ value: 900, label: t('ttl15min') },
{ value: 1800, label: t('ttl30min') },
{ value: 3600, label: t('ttl1h') },
{ value: 7200, label: t('ttl2h') },
{ value: 18000, label: t('ttl5h') },
{ value: 43200, label: t('ttl12h') },
{ value: 86400, label: t('ttl1d') }
]}
/>
</div>
</div>
{['MX'].includes(newRecord.type) && (
<div className="input-row">
<label>{t('priority')}</label>
<input type="number" value={newRecord.priority} onChange={e => setNewRecord({ ...newRecord, priority: parseInt(e.target.value) })} min="0" max="65535" required />
</div>
)}
<div className="input-row">
<label>{t('comment')}</label>
<input
type="text"
value={newRecord.comment || ''}
onChange={e => setNewRecord({ ...newRecord, comment: e.target.value })}
placeholder={t('commentPlaceholder')}
/>
</div>
<div className="input-row" style={{ alignItems: 'flex-start' }}>
<label style={{ paddingTop: '8px' }}>{t('tags')}</label>
<div style={{ flex: 1 }}>
{(newRecord.tags || []).length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px', marginBottom: '6px' }}>
{(newRecord.tags || []).map((tag, idx) => (
<span key={idx} style={{
display: 'inline-flex', alignItems: 'center', gap: '4px',
background: 'var(--select-active-bg, #eef2ff)', color: 'var(--primary)',
padding: '2px 8px', borderRadius: '4px', fontSize: '0.75rem', fontWeight: 500
}}>
{tag}
<span
role="button"
tabIndex={0}
onClick={() => setNewRecord({ ...newRecord, tags: newRecord.tags.filter((_, i) => i !== idx) })}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setNewRecord({ ...newRecord, tags: newRecord.tags.filter((_, i) => i !== idx) }); } }}
style={{ cursor: 'pointer', fontSize: '0.85rem', lineHeight: 1, opacity: 0.7 }}
>×</span>
</span>
))}
</div>
)}
<input
type="text"
value={tagInput}
onChange={e => setTagInput(e.target.value)}
onKeyDown={e => {
if ((e.key === 'Enter' || e.key === ',') && tagInput.trim()) {
e.preventDefault();
const newTag = tagInput.trim().replace(/,/g, '');
if (newTag && !(newRecord.tags || []).includes(newTag)) {
setNewRecord({ ...newRecord, tags: [...(newRecord.tags || []), newTag] });
}
setTagInput('');
}
}}
placeholder={t('tagsPlaceholder')}
/>
</div>
</div>
{['A', 'AAAA', 'CNAME'].includes(newRecord.type) && (
<div className="input-row" style={{ alignItems: 'center' }}>
<label>{t('proxied')}</label>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<label className="toggle-switch" style={{ margin: 0 }}>
<input
type="checkbox"
checked={newRecord.proxied}
onChange={(e) => setNewRecord({ ...newRecord, proxied: e.target.checked })}
/>
<span className="slider"></span>
</label>
<span style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>{t('proxiedHint')}</span>
</div>
</div>
)}
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '1.5rem', flexWrap: 'wrap' }}>
<button type="button" className="btn btn-outline" style={{ flex: 1, minWidth: '80px' }} onClick={onClose}>{t('cancel')}</button>
<button type="submit" className="btn btn-primary" style={{ flex: 1, minWidth: '80px' }} disabled={saving}>
{saving ? <><RefreshCw size={14} className="spin" style={{ marginRight: '6px' }} />{t('saving') || t('save')}</> : t('save')}
</button>
{onSchedule && (
<button
type="button"
className="btn btn-outline"
style={{ flex: 1, minWidth: '80px', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '4px', fontSize: '0.8rem' }}
onClick={() => setShowSchedulePicker(!showSchedulePicker)}
>
<Clock size={14} />
{t('scheduleForLater')}
</button>
)}
</div>
{showSchedulePicker && onSchedule && (
<div style={{ marginTop: '0.75rem', padding: '0.75rem', background: 'var(--hover-bg)', borderRadius: '8px', border: '1px solid var(--border)' }}>
<label style={{ fontSize: '0.75rem', fontWeight: 600, color: 'var(--text-muted)', display: 'block', marginBottom: '0.5rem' }}>
{t('scheduleDateTime')}
</label>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<input
type="datetime-local"
value={scheduledAt}
onChange={e => setScheduledAt(e.target.value)}
min={getMinDatetime()}
style={{ flex: 1, fontSize: '0.8rem' }}
/>
<button
type="button"
className="btn btn-primary"
style={{ padding: '6px 12px', fontSize: '0.8rem', whiteSpace: 'nowrap' }}
onClick={handleScheduleSubmit}
disabled={!scheduledAt}
>
{t('scheduleConfirm')}
</button>
</div>
</div>
)}
</form>
</div>
</div>
);
};
export default DnsRecordModal;