feat: добавлены функции для работы с пользовательскими rule-sets

Добавлены новые API-методы для получения и сохранения пользовательских rule-sets. Обновлены компоненты для работы с этими данными, включая интерфейс для добавления и удаления rule-sets.

Refs: None
This commit is contained in:
2026-05-08 19:49:44 +03:00
parent 3e18b833c6
commit 27b71077b1
7 changed files with 283 additions and 21 deletions

View File

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

View File

@@ -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">

View File

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