feat: добавлены функции для работы с пользовательскими rule-sets
Добавлены новые API-методы для получения и сохранения пользовательских rule-sets. Обновлены компоненты для работы с этими данными, включая интерфейс для добавления и удаления rule-sets. Refs: None
This commit is contained in:
@@ -102,11 +102,16 @@ export function RoutingPage({
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [showTemplates, setShowTemplates] = useState(false);
|
||||
const [conflicts, setConflicts] = useState([]);
|
||||
const [availableRuleSets, setAvailableRuleSets] = useState([]);
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
api.ruleSets.get().then((data) => setAvailableRuleSets(data.ruleSets || [])).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const t = setTimeout(() => {
|
||||
@@ -216,6 +221,7 @@ export function RoutingPage({
|
||||
onUpdate={onUpdate}
|
||||
onClose={() => setEditingId(null)}
|
||||
onRemove={onRemove}
|
||||
availableRuleSets={availableRuleSets}
|
||||
/>
|
||||
<TemplatesModal open={showTemplates} onClose={() => setShowTemplates(false)} onAdd={onAddTemplate} />
|
||||
</div>
|
||||
|
||||
@@ -3,9 +3,11 @@ import { ChipsInput } from './ChipsInput.jsx';
|
||||
import { isValidCidr, isValidPort, ruleErrors, hasErrors } from '../utils/validation.js';
|
||||
|
||||
const DOMAIN = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/i;
|
||||
const RULE_SET_TAG = /^[a-z0-9][a-z0-9-]*$/i;
|
||||
const validDomain = (v) => DOMAIN.test(String(v).trim());
|
||||
const validRuleSetTag = (v) => RULE_SET_TAG.test(String(v).trim());
|
||||
|
||||
export function RuleEditor({ rule, onUpdate, onClose, onRemove, mode = 'builder' }) {
|
||||
export function RuleEditor({ rule, onUpdate, onClose, onRemove, mode = 'builder', availableRuleSets = [] }) {
|
||||
const [view, setView] = useState(mode); // builder | json
|
||||
const [jsonDraft, setJsonDraft] = useState(() => JSON.stringify(rule, null, 2));
|
||||
const [jsonError, setJsonError] = useState('');
|
||||
@@ -61,6 +63,41 @@ export function RuleEditor({ rule, onUpdate, onClose, onRemove, mode = 'builder'
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<span className="field-label">Rule-sets (geo-базы)</span>
|
||||
<ChipsInput
|
||||
value={rule.ruleSets || []}
|
||||
onChange={(v) => patch({ ruleSets: v })}
|
||||
placeholder="geosite-runet"
|
||||
validate={validRuleSetTag}
|
||||
/>
|
||||
{availableRuleSets.length > 0 && (
|
||||
<div className="field-hint">
|
||||
Доступны:{' '}
|
||||
{availableRuleSets.map((rs) => (
|
||||
<button
|
||||
key={rs.tag}
|
||||
className="btn btn-ghost sm"
|
||||
style={{ padding: '0 6px', marginRight: 4 }}
|
||||
onClick={() => {
|
||||
const current = new Set(rule.ruleSets || []);
|
||||
if (!current.has(rs.tag)) {
|
||||
patch({ ruleSets: [...(rule.ruleSets || []), rs.tag] });
|
||||
}
|
||||
}}
|
||||
>
|
||||
+ {rs.tag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{availableRuleSets.length === 0 && (
|
||||
<span className="field-hint">
|
||||
Настройте rule-sets в Настройках, затем вводите их теги здесь
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<span className="field-label">Домены (точное совпадение)</span>
|
||||
<ChipsInput
|
||||
@@ -165,7 +202,7 @@ export function RuleEditor({ rule, onUpdate, onClose, onRemove, mode = 'builder'
|
||||
);
|
||||
}
|
||||
|
||||
export function RuleEditorDrawer({ rule, onUpdate, onClose, onRemove }) {
|
||||
export function RuleEditorDrawer({ rule, onUpdate, onClose, onRemove, availableRuleSets = [] }) {
|
||||
if (!rule) return null;
|
||||
const errors = ruleErrors(rule);
|
||||
const invalid = hasErrors(errors);
|
||||
@@ -181,7 +218,7 @@ export function RuleEditorDrawer({ rule, onUpdate, onClose, onRemove }) {
|
||||
</div>
|
||||
<button className="btn btn-ghost sm" onClick={onClose}>Закрыть</button>
|
||||
</div>
|
||||
<RuleEditor rule={rule} onUpdate={onUpdate} onClose={onClose} onRemove={onRemove} />
|
||||
<RuleEditor rule={rule} onUpdate={onUpdate} onClose={onClose} onRemove={onRemove} availableRuleSets={availableRuleSets} />
|
||||
<div className="drawer-foot">
|
||||
<button className="btn btn-danger" onClick={() => { if (confirm('Удалить правило?')) { onRemove(rule.id); onClose(); } }}>Удалить</button>
|
||||
<div className="btn-group">
|
||||
|
||||
@@ -2,6 +2,24 @@ import React, { useEffect, useState } from 'react';
|
||||
import { api } from '../api.js';
|
||||
import { formatRelative } from '../utils/format.js';
|
||||
|
||||
const SUGGESTED_RULE_SETS = [
|
||||
{
|
||||
tag: 'geosite-runet',
|
||||
url: 'https://github.com/runetfreedom/russia-blocked-geosite/releases/latest/download/rule-set/ru.srs',
|
||||
label: 'runetfreedom / RU заблокированные домены',
|
||||
},
|
||||
{
|
||||
tag: 'geoip-ru-loyalsoldier',
|
||||
url: 'https://cdn.jsdelivr.net/gh/Loyalsoldier/sing-box-rules@rule-set/geoip-ru.srs',
|
||||
label: 'Loyalsoldier / geoip-ru',
|
||||
},
|
||||
{
|
||||
tag: 'geosite-category-ru-loyalsoldier',
|
||||
url: 'https://cdn.jsdelivr.net/gh/Loyalsoldier/sing-box-rules@rule-set/geosite-category-ru.srs',
|
||||
label: 'Loyalsoldier / geosite-category-ru',
|
||||
},
|
||||
];
|
||||
|
||||
function SubscriptionCard({ state, subscriptionUrl, setSubscriptionUrl, busy, onFetch, onForget, pushToast }) {
|
||||
const [editing, setEditing] = useState(!state?.hasSubscription);
|
||||
|
||||
@@ -126,6 +144,143 @@ function ConfigCard({ state, busy, onShowConfig, onClearConfig, pushToast }) {
|
||||
);
|
||||
}
|
||||
|
||||
function RuleSetsCard({ pushToast }) {
|
||||
const [ruleSets, setRuleSets] = useState([]);
|
||||
const [newTag, setNewTag] = useState('');
|
||||
const [newUrl, setNewUrl] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.ruleSets.get().then((d) => setRuleSets(d.ruleSets || [])).catch(() => {});
|
||||
}, []);
|
||||
|
||||
async function save(next) {
|
||||
setBusy(true);
|
||||
try {
|
||||
const data = await api.ruleSets.save(next);
|
||||
setRuleSets(data.ruleSets || []);
|
||||
pushToast({ kind: 'success', title: 'Rule-sets сохранены' });
|
||||
} catch (err) {
|
||||
pushToast({ kind: 'danger', title: 'Ошибка', message: err.message });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function addNew() {
|
||||
const tag = newTag.trim();
|
||||
const url = newUrl.trim();
|
||||
if (!tag || !url) return;
|
||||
if (!/^[a-z0-9][a-z0-9-]*$/i.test(tag)) {
|
||||
pushToast({ kind: 'danger', title: 'Невалидный тег', message: 'Только буквы, цифры, дефис' });
|
||||
return;
|
||||
}
|
||||
if (ruleSets.some((rs) => rs.tag === tag)) {
|
||||
pushToast({ kind: 'danger', title: 'Тег уже существует' });
|
||||
return;
|
||||
}
|
||||
const next = [...ruleSets, { tag, url }];
|
||||
setNewTag('');
|
||||
setNewUrl('');
|
||||
save(next);
|
||||
}
|
||||
|
||||
function remove(tag) {
|
||||
save(ruleSets.filter((rs) => rs.tag !== tag));
|
||||
}
|
||||
|
||||
function addSuggested(suggested) {
|
||||
if (ruleSets.some((rs) => rs.tag === suggested.tag)) {
|
||||
pushToast({ kind: 'info', title: `${suggested.tag} уже добавлен` });
|
||||
return;
|
||||
}
|
||||
save([...ruleSets, { tag: suggested.tag, url: suggested.url }]);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h2>Источники (rule-sets)</h2>
|
||||
</div>
|
||||
<small className="muted" style={{ display: 'block', marginBottom: 12 }}>
|
||||
Пользовательские geo-базы в формате <strong>.srs</strong> (sing-box rule-set binary).
|
||||
После добавления тег можно указать в правиле маршрутизации.
|
||||
</small>
|
||||
|
||||
{ruleSets.length > 0 && (
|
||||
<table className="table" style={{ marginBottom: 16 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Тег</th>
|
||||
<th>URL</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ruleSets.map((rs) => (
|
||||
<tr key={rs.tag}>
|
||||
<td className="text-mono">{rs.tag}</td>
|
||||
<td className="muted" style={{ fontSize: 12, wordBreak: 'break-all' }}>{rs.url}</td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
<button className="btn btn-ghost sm" disabled={busy} onClick={() => remove(rs.tag)}>×</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
<div className="field">
|
||||
<span className="field-label">Добавить вручную</span>
|
||||
<div className="flex" style={{ gap: 8, flexWrap: 'wrap' }}>
|
||||
<input
|
||||
className="input"
|
||||
style={{ width: 180 }}
|
||||
placeholder="тег (напр. geosite-runet)"
|
||||
value={newTag}
|
||||
onChange={(e) => setNewTag(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
style={{ flex: 1, minWidth: 200 }}
|
||||
placeholder="https://…/rule-set.srs"
|
||||
value={newUrl}
|
||||
onChange={(e) => setNewUrl(e.target.value)}
|
||||
/>
|
||||
<button className="btn btn-primary" disabled={busy || !newTag || !newUrl} onClick={addNew}>
|
||||
Добавить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<span className="field-label">Готовые источники</span>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{SUGGESTED_RULE_SETS.map((s) => (
|
||||
<div key={s.tag} className="flex" style={{ alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ flex: 1, fontSize: 13 }}>
|
||||
<strong className="text-mono">{s.tag}</strong>
|
||||
<span className="muted" style={{ marginLeft: 8 }}>{s.label}</span>
|
||||
</span>
|
||||
<button
|
||||
className="btn btn-secondary sm"
|
||||
disabled={busy || ruleSets.some((rs) => rs.tag === s.tag)}
|
||||
onClick={() => addSuggested(s)}
|
||||
>
|
||||
{ruleSets.some((rs) => rs.tag === s.tag) ? '✓ добавлен' : '+ Добавить'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<small className="muted" style={{ marginTop: 8, display: 'block' }}>
|
||||
Sing-box скачает эти файлы автоматически при первом запуске.
|
||||
<strong>.dat файлы (v2ray) не поддерживаются</strong> — используйте .srs эквиваленты.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PortsCard({ state }) {
|
||||
return (
|
||||
<div className="card">
|
||||
@@ -165,6 +320,7 @@ export function SettingsPage({
|
||||
onClearConfig={onClearConfig}
|
||||
pushToast={pushToast}
|
||||
/>
|
||||
<RuleSetsCard pushToast={pushToast} />
|
||||
<PortsCard state={state} />
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user