码桶

发现社区成员的开源项目

PageRules.jsx25.2 KB
import React, { useState, useEffect, useCallback } from 'react';
import { RefreshCw, FileText, Plus, Edit2, Trash2, AlertTriangle, X, Play, Pause, ChevronDown } from 'lucide-react';
import TabSkeleton from './TabSkeleton';

const ACTION_TYPES = [
    { id: 'forwarding_url', label: 'Forwarding URL', fields: ['status_code', 'url'] },
    { id: 'always_use_https', label: 'Always Use HTTPS', fields: [] },
    { id: 'cache_level', label: 'Cache Level', fields: ['select'], options: ['bypass', 'basic', 'simplified', 'aggressive', 'cache_everything'] },
    { id: 'browser_cache_ttl', label: 'Browser Cache TTL', fields: ['number'], unit: 'seconds' },
    { id: 'edge_cache_ttl', label: 'Edge Cache TTL', fields: ['number'], unit: 'seconds' },
    { id: 'ssl', label: 'SSL', fields: ['select'], options: ['off', 'flexible', 'full', 'strict'] },
    { id: 'minify', label: 'Auto Minify', fields: ['minify'] },
    { id: 'disable_security', label: 'Disable Security', fields: [] },
    { id: 'disable_performance', label: 'Disable Performance', fields: [] },
    { id: 'browser_check', label: 'Browser Integrity Check', fields: ['select'], options: ['on', 'off'] },
    { id: 'rocket_loader', label: 'Rocket Loader', fields: ['select'], options: ['on', 'off'] },
    { id: 'email_obfuscation', label: 'Email Obfuscation', fields: ['select'], options: ['on', 'off'] },
    { id: 'security_level', label: 'Security Level', fields: ['select'], options: ['essentially_off', 'low', 'medium', 'high', 'under_attack'] },
];

const FORWARD_STATUS_CODES = [301, 302];

const PageRules = ({ zone, getHeaders, authFetch, t, showToast, openConfirm }) => {
    const af = authFetch || fetch;
    const [rules, setRules] = useState([]);
    const [loading, setLoading] = useState(true);
    const [fetchError, setFetchError] = useState(null);
    const [showModal, setShowModal] = useState(false);
    const [editRule, setEditRule] = useState(null);
    const [saving, setSaving] = useState(false);
    const [togglingRule, setTogglingRule] = useState({});

    // Form state
    const [urlPattern, setUrlPattern] = useState('');
    const [actions, setActions] = useState([]);
    const [ruleStatus, setRuleStatus] = useState('active');

    const fetchRules = useCallback(async () => {
        setLoading(true);
        setFetchError(null);
        try {
            const res = await af(`/api/zones/${zone.id}/pagerules`, { headers: getHeaders() });
            const data = await res.json();
            if (data.success) {
                setRules(data.rules || []);
            } else {
                setFetchError(data.errors?.[0]?.message || data.error || t('errorOccurred'));
            }
        } catch (e) {
            setFetchError(t('errorOccurred'));
        }
        setLoading(false);
    }, [zone.id]);

    useEffect(() => { fetchRules(); }, [fetchRules]);

    const openAdd = () => {
        setEditRule(null);
        setUrlPattern(`*${zone.name}/*`);
        setActions([{ id: 'always_use_https', value: null }]);
        setRuleStatus('active');
        setShowModal(true);
    };

    const openEdit = (rule) => {
        setEditRule(rule);
        setUrlPattern(rule.targets?.[0]?.constraint?.value || '');
        setActions(rule.actions.map(a => {
            if (a.id === 'forwarding_url') return { id: a.id, value: a.value };
            if (a.id === 'minify') return { id: a.id, value: a.value };
            return { id: a.id, value: a.value ?? null };
        }));
        setRuleStatus(rule.status || 'active');
        setShowModal(true);
    };

    const addAction = () => {
        const usedIds = actions.map(a => a.id);
        const available = ACTION_TYPES.find(at => !usedIds.includes(at.id));
        if (available) {
            let defaultVal = null;
            if (available.fields.includes('select')) defaultVal = available.options[0];
            if (available.id === 'forwarding_url') defaultVal = { status_code: 301, url: '' };
            if (available.id === 'minify') defaultVal = { js: 'off', css: 'off', html: 'off' };
            if (available.fields.includes('number')) defaultVal = 3600;
            setActions([...actions, { id: available.id, value: defaultVal }]);
        }
    };

    const removeAction = (idx) => setActions(actions.filter((_, i) => i !== idx));

    const updateAction = (idx, field, val) => {
        setActions(actions.map((a, i) => {
            if (i !== idx) return a;
            if (field === 'id') {
                const meta = ACTION_TYPES.find(at => at.id === val);
                let defaultVal = null;
                if (meta?.fields.includes('select')) defaultVal = meta.options[0];
                if (val === 'forwarding_url') defaultVal = { status_code: 301, url: '' };
                if (val === 'minify') defaultVal = { js: 'off', css: 'off', html: 'off' };
                if (meta?.fields.includes('number')) defaultVal = 3600;
                return { id: val, value: defaultVal };
            }
            return { ...a, value: val };
        }));
    };

    const handleSave = async () => {
        if (!urlPattern.trim()) { showToast('URL pattern is required', 'error'); return; }
        if (actions.length === 0) { showToast('At least one action is required', 'error'); return; }
        setSaving(true);
        try {
            const payload = {
                action: editRule ? 'update' : 'create',
                targets: [{ target: 'url', constraint: { operator: 'matches', value: urlPattern.trim() } }],
                actions: actions.map(a => {
                    if (a.value === null || a.value === undefined) return { id: a.id };
                    return { id: a.id, value: a.value };
                }),
                status: ruleStatus,
            };
            if (editRule) payload.ruleId = editRule.id;
            const res = await af(`/api/zones/${zone.id}/pagerules`, {
                method: 'POST',
                headers: getHeaders(true),
                body: JSON.stringify(payload)
            });
            const data = await res.json();
            if (data.success) {
                showToast(t('prSaved'));
                setShowModal(false);
                fetchRules();
            } else {
                showToast(data.errors?.[0]?.message || t('errorOccurred'), 'error');
            }
        } catch (e) {
            showToast(t('errorOccurred'), 'error');
        }
        setSaving(false);
    };

    const handleDelete = (rule) => {
        openConfirm(t('prDeleteConfirm'), async () => {
            try {
                const res = await af(`/api/zones/${zone.id}/pagerules`, {
                    method: 'POST', headers: getHeaders(true),
                    body: JSON.stringify({ action: 'delete', ruleId: rule.id })
                });
                const data = await res.json();
                if (data.success) { showToast(t('prDeleted')); fetchRules(); }
                else showToast(data.errors?.[0]?.message || t('errorOccurred'), 'error');
            } catch (e) { showToast(t('errorOccurred'), 'error'); }
        });
    };

    const handleToggle = async (rule) => {
        const newStatus = rule.status === 'active' ? 'disabled' : 'active';
        setTogglingRule(prev => ({ ...prev, [rule.id]: true }));
        try {
            const res = await af(`/api/zones/${zone.id}/pagerules`, {
                method: 'POST', headers: getHeaders(true),
                body: JSON.stringify({ action: 'toggle', ruleId: rule.id, status: newStatus })
            });
            const data = await res.json();
            if (data.success) {
                setRules(prev => prev.map(r => r.id === rule.id ? { ...r, status: newStatus } : r));
                showToast(t('updateSuccess'));
            } else showToast(data.errors?.[0]?.message || t('errorOccurred'), 'error');
        } catch (e) { showToast(t('errorOccurred'), 'error'); }
        setTogglingRule(prev => ({ ...prev, [rule.id]: false }));
    };

    const formatActionLabel = (action) => {
        const meta = ACTION_TYPES.find(at => at.id === action.id);
        const label = meta?.label || action.id;
        if (action.id === 'forwarding_url') return `${label}: ${action.value?.status_code} → ${action.value?.url || ''}`;
        if (action.id === 'minify') return `${label}: JS=${action.value?.js} CSS=${action.value?.css} HTML=${action.value?.html}`;
        if (action.value !== null && action.value !== undefined) return `${label}: ${action.value}`;
        return label;
    };

    if (loading && rules.length === 0 && !fetchError) {
        return <TabSkeleton variant="list" />;
    }

    if (fetchError && rules.length === 0) {
        return (
            <div style={{ display: 'flex', flexDirection: 'column', gap: '6px', padding: '0.75rem 1rem', borderRadius: '8px', background: 'rgba(239, 68, 68, 0.08)', border: '1px solid rgba(239, 68, 68, 0.3)', fontSize: '0.8125rem', color: 'var(--error, #ef4444)' }}>
                <div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}><AlertTriangle size={16} /><span>{t('loadSettingsError').replace('{error}', fetchError)}</span></div>
                <button className="btn btn-outline" onClick={fetchRules} style={{ alignSelf: 'flex-start', padding: '4px 12px', fontSize: '0.75rem', marginTop: '4px' }}><RefreshCw size={12} /> {t('refresh') || 'Retry'}</button>
            </div>
        );
    }

    return (
        <div className="tab-enter" style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
            {/* Header */}
            <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
                <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
                    <FileText size={20} color="var(--primary)" />
                    <h3 style={{ fontSize: '1rem', margin: 0 }}>{t('prTitle')}</h3>
                    <span className="badge" style={{ fontSize: '0.65rem', padding: '1px 6px' }}>{rules.length}</span>
                </div>
                <div style={{ display: 'flex', gap: '0.5rem' }}>
                    <button className="btn btn-outline" onClick={fetchRules} disabled={loading} style={{ padding: '4px 8px', height: 'auto', fontSize: '0.75rem' }}>
                        <RefreshCw size={12} className={loading ? 'spin' : ''} />
                    </button>
                    <button className="btn" onClick={openAdd}
                        style={{ display: 'flex', alignItems: 'center', gap: '6px', background: 'var(--primary)', color: 'white', border: 'none', padding: '0.4rem 0.75rem', fontSize: '0.8125rem', fontWeight: 600, borderRadius: '8px' }}>
                        <Plus size={14} /> {t('prAddRule')}
                    </button>
                </div>
            </div>
            <p style={{ fontSize: '0.75rem', color: 'var(--text-muted)', margin: '-1rem 0 0 0', lineHeight: 1.5 }}>{t('prDesc')}</p>

            {/* Rules list */}
            {rules.length === 0 ? (
                <div className="glass-card" style={{ padding: '2rem', textAlign: 'center' }}>
                    <FileText size={32} color="var(--text-muted)" style={{ marginBottom: '0.75rem', opacity: 0.5 }} />
                    <p style={{ fontSize: '0.875rem', color: 'var(--text-muted)' }}>{t('prNoRules')}</p>
                </div>
            ) : (
                <div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
                    {rules.map((rule, idx) => (
                        <div key={rule.id} className="glass-card" style={{ padding: '1rem', background: rule.status === 'active' ? 'var(--subtle-bg)' : 'transparent', opacity: rule.status === 'active' ? 1 : 0.7 }}>
                            <div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: '8px' }}>
                                <div style={{ flex: 1, minWidth: 0 }}>
                                    <div style={{ display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '0.5rem', flexWrap: 'wrap' }}>
                                        <span className="badge" style={{ fontSize: '0.6rem', padding: '1px 6px' }}>#{idx + 1}</span>
                                        <code style={{ fontSize: '0.8125rem', fontWeight: 600, color: 'var(--primary)', wordBreak: 'break-all' }}>
                                            {rule.targets?.[0]?.constraint?.value || 'N/A'}
                                        </code>
                                        <span className={`badge ${rule.status === 'active' ? 'badge-green' : 'badge-orange'}`} style={{ fontSize: '0.6rem', padding: '1px 6px' }}>
                                            {rule.status === 'active' ? t('prActive') : t('prPaused')}
                                        </span>
                                    </div>
                                    <div style={{ display: 'flex', gap: '0.35rem', flexWrap: 'wrap' }}>
                                        {rule.actions.map((action, ai) => (
                                            <span key={ai} style={{ fontSize: '0.7rem', padding: '2px 8px', borderRadius: '4px', background: 'var(--select-active-bg)', color: 'var(--text)', whiteSpace: 'nowrap' }}>
                                                {formatActionLabel(action)}
                                            </span>
                                        ))}
                                    </div>
                                </div>
                                <div style={{ display: 'flex', gap: '0.25rem', flexShrink: 0 }}>
                                    <button className="btn btn-outline" onClick={() => handleToggle(rule)} disabled={togglingRule[rule.id]} style={{ padding: '0.35rem', borderRadius: '6px' }} title={rule.status === 'active' ? 'Pause' : 'Enable'}>
                                        {togglingRule[rule.id] ? <RefreshCw size={14} className="spin" /> : rule.status === 'active' ? <Pause size={14} /> : <Play size={14} />}
                                    </button>
                                    <button className="btn btn-outline" onClick={() => openEdit(rule)} style={{ padding: '0.35rem', borderRadius: '6px' }} title="Edit"><Edit2 size={14} /></button>
                                    <button className="btn btn-outline" onClick={() => handleDelete(rule)} style={{ padding: '0.35rem', borderRadius: '6px', color: 'var(--error)' }} title="Delete"><Trash2 size={14} /></button>
                                </div>
                            </div>
                        </div>
                    ))}
                </div>
            )}

            {/* Add/Edit Modal */}
            {showModal && (
                <div className="modal-overlay" onClick={() => setShowModal(false)}>
                    <div className="glass-card modal-content" onClick={e => e.stopPropagation()}
                        style={{ width: '100%', maxWidth: '560px', padding: '1.5rem', margin: '2rem auto' }}>
                        <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1.25rem' }}>
                            <h3 style={{ fontSize: '1rem', margin: 0, display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
                                <FileText size={18} color="var(--primary)" />
                                {editRule ? t('prEditRule') || 'Edit Page Rule' : t('prAddRule')}
                            </h3>
                            <button onClick={() => setShowModal(false)} style={{ border: 'none', background: 'transparent', cursor: 'pointer', padding: '4px', display: 'flex' }}><X size={18} color="var(--text-muted)" /></button>
                        </div>

                        <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
                            {/* URL Pattern */}
                            <div>
                                <label style={{ display: 'block', fontSize: '0.75rem', fontWeight: 600, marginBottom: '0.35rem', color: 'var(--text-muted)' }}>{t('prUrlPattern')}</label>
                                <input value={urlPattern} onChange={e => setUrlPattern(e.target.value)}
                                    placeholder={t('prUrlPlaceholder') || '*example.com/path/*'}
                                    style={{ width: '100%', padding: '0.5rem 0.75rem', borderRadius: '8px', border: '1px solid var(--border)', background: 'var(--input-bg)', color: 'var(--text)', fontSize: '0.875rem', fontFamily: 'monospace', boxSizing: 'border-box' }} />
                            </div>

                            {/* Status toggle */}
                            <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
                                <label style={{ fontSize: '0.75rem', fontWeight: 600, color: 'var(--text-muted)' }}>{t('status') || 'Status'}:</label>
                                <button onClick={() => setRuleStatus(ruleStatus === 'active' ? 'disabled' : 'active')}
                                    className={`badge ${ruleStatus === 'active' ? 'badge-green' : 'badge-orange'}`}
                                    style={{ cursor: 'pointer', fontSize: '0.7rem', padding: '2px 8px', border: 'none' }}>
                                    {ruleStatus === 'active' ? t('prActive') : t('prPaused')}
                                </button>
                            </div>

                            {/* Actions */}
                            <div>
                                <label style={{ display: 'block', fontSize: '0.75rem', fontWeight: 600, marginBottom: '0.5rem', color: 'var(--text-muted)' }}>{t('prActions')}</label>
                                <div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
                                    {actions.map((action, idx) => {
                                        const meta = ACTION_TYPES.find(at => at.id === action.id);
                                        const usedIds = actions.map(a => a.id);
                                        return (
                                            <div key={idx} style={{ display: 'flex', gap: '0.5rem', alignItems: 'flex-start', padding: '0.5rem', borderRadius: '8px', border: '1px solid var(--border)', background: 'var(--input-bg)' }}>
                                                <div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
                                                    <select value={action.id} onChange={e => updateAction(idx, 'id', e.target.value)}
                                                        style={{ padding: '0.35rem 0.5rem', borderRadius: '6px', fontSize: '0.8125rem', border: '1px solid var(--border)', background: 'var(--card-bg)', color: 'var(--text)' }}>
                                                        {ACTION_TYPES.filter(at => at.id === action.id || !usedIds.includes(at.id)).map(at => (
                                                            <option key={at.id} value={at.id}>{at.label}</option>
                                                        ))}
                                                    </select>

                                                    {/* Value inputs based on action type */}
                                                    {action.id === 'forwarding_url' && (
                                                        <div style={{ display: 'flex', gap: '0.35rem', flexWrap: 'wrap' }}>
                                                            <select value={action.value?.status_code || 301}
                                                                onChange={e => updateAction(idx, 'value', { ...action.value, status_code: parseInt(e.target.value) })}
                                                                style={{ padding: '0.3rem 0.5rem', borderRadius: '6px', fontSize: '0.75rem', border: '1px solid var(--border)', background: 'var(--card-bg)', color: 'var(--text)' }}>
                                                                {FORWARD_STATUS_CODES.map(c => <option key={c} value={c}>{c} {c === 301 ? '(Permanent)' : '(Temporary)'}</option>)}
                                                            </select>
                                                            <input value={action.value?.url || ''} placeholder="https://example.com/$1"
                                                                onChange={e => updateAction(idx, 'value', { ...action.value, url: e.target.value })}
                                                                style={{ flex: 1, minWidth: '150px', padding: '0.3rem 0.5rem', borderRadius: '6px', fontSize: '0.75rem', border: '1px solid var(--border)', background: 'var(--card-bg)', color: 'var(--text)', fontFamily: 'monospace' }} />
                                                        </div>
                                                    )}
                                                    {meta?.fields.includes('select') && (
                                                        <select value={action.value || meta.options[0]}
                                                            onChange={e => updateAction(idx, 'value', e.target.value)}
                                                            style={{ padding: '0.3rem 0.5rem', borderRadius: '6px', fontSize: '0.75rem', border: '1px solid var(--border)', background: 'var(--card-bg)', color: 'var(--text)' }}>
                                                            {meta.options.map(o => <option key={o} value={o}>{o.replace(/_/g, ' ')}</option>)}
                                                        </select>
                                                    )}
                                                    {meta?.fields.includes('number') && (
                                                        <div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
                                                            <input type="number" value={action.value || 0}
                                                                onChange={e => updateAction(idx, 'value', parseInt(e.target.value) || 0)}
                                                                style={{ width: '100px', padding: '0.3rem 0.5rem', borderRadius: '6px', fontSize: '0.75rem', border: '1px solid var(--border)', background: 'var(--card-bg)', color: 'var(--text)' }} />
                                                            {meta.unit && <span style={{ fontSize: '0.7rem', color: 'var(--text-muted)' }}>{meta.unit}</span>}
                                                        </div>
                                                    )}
                                                    {action.id === 'minify' && (
                                                        <div style={{ display: 'flex', gap: '0.75rem' }}>
                                                            {['js', 'css', 'html'].map(f => (
                                                                <label key={f} style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '0.75rem', cursor: 'pointer' }}>
                                                                    <input type="checkbox" checked={action.value?.[f] === 'on'}
                                                                        onChange={() => updateAction(idx, 'value', { ...action.value, [f]: action.value?.[f] === 'on' ? 'off' : 'on' })} />
                                                                    {f.toUpperCase()}
                                                                </label>
                                                            ))}
                                                        </div>
                                                    )}
                                                </div>
                                                <button onClick={() => removeAction(idx)} style={{ border: 'none', background: 'transparent', cursor: 'pointer', padding: '4px', display: 'flex', flexShrink: 0 }}>
                                                    <X size={14} color="var(--error)" />
                                                </button>
                                            </div>
                                        );
                                    })}
                                </div>
                                {actions.length < ACTION_TYPES.length && (
                                    <button onClick={addAction} className="btn btn-outline" style={{ marginTop: '0.5rem', fontSize: '0.75rem', padding: '0.35rem 0.75rem', display: 'flex', alignItems: 'center', gap: '4px' }}>
                                        <Plus size={12} /> {t('prAddAction')}
                                    </button>
                                )}
                            </div>
                        </div>

                        <div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.5rem', marginTop: '1.5rem' }}>
                            <button className="btn btn-outline" onClick={() => setShowModal(false)}>{t('cancel')}</button>
                            <button className="btn" onClick={handleSave} disabled={saving}
                                style={{ background: 'var(--primary)', color: 'white', border: 'none', display: 'flex', alignItems: 'center', gap: '6px' }}>
                                {saving && <RefreshCw size={14} className="spin" />}
                                {t('save')}
                            </button>
                        </div>
                    </div>
                </div>
            )}
        </div>
    );
};

export default PageRules;