feat: добавлены функции для работы с пользовательскими rule-sets
Добавлены новые API-методы для получения и сохранения пользовательских rule-sets. Обновлены компоненты для работы с этими данными, включая интерфейс для добавления и удаления rule-sets. Refs: None
This commit is contained in:
@@ -14,6 +14,7 @@ export const settings = {
|
|||||||
cachePath: process.env.SING_BOX_CACHE || "/var/lib/sing-box/cache.db",
|
cachePath: process.env.SING_BOX_CACHE || "/var/lib/sing-box/cache.db",
|
||||||
statePath: path.join(dataDir, "state.json"),
|
statePath: path.join(dataDir, "state.json"),
|
||||||
customRulesPath: path.join(dataDir, "custom-rules.json"),
|
customRulesPath: path.join(dataDir, "custom-rules.json"),
|
||||||
|
customRuleSetsPath: path.join(dataDir, "custom-rule-sets.json"),
|
||||||
subscriptionCachePath: path.join(dataDir, "subscription-cache.json"),
|
subscriptionCachePath: path.join(dataDir, "subscription-cache.json"),
|
||||||
hwidPath: path.join(dataDir, "hwid"),
|
hwidPath: path.join(dataDir, "hwid"),
|
||||||
routingRuDirect: String(process.env.ROUTING_RU_DIRECT || "true") !== "false",
|
routingRuDirect: String(process.env.ROUTING_RU_DIRECT || "true") !== "false",
|
||||||
|
|||||||
@@ -309,6 +309,9 @@ function normalizeCustomRules(input) {
|
|||||||
networks: normalizeList(rule.networks).filter((network) =>
|
networks: normalizeList(rule.networks).filter((network) =>
|
||||||
["tcp", "udp"].includes(network),
|
["tcp", "udp"].includes(network),
|
||||||
),
|
),
|
||||||
|
ruleSets: normalizeList(rule.ruleSets).filter((tag) =>
|
||||||
|
/^[a-z0-9][a-z0-9-]*$/i.test(tag),
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,6 +427,28 @@ async function handleApi(req, res) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (req.method === "GET" && req.url === "/api/rule-sets") {
|
||||||
|
return sendJson(res, 200, {
|
||||||
|
success: true,
|
||||||
|
ruleSets: readJson(settings.customRuleSetsPath, []),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "PUT" && req.url === "/api/rule-sets") {
|
||||||
|
const body = await readBody(req);
|
||||||
|
const rawSets = Array.isArray(body.ruleSets) ? body.ruleSets : [];
|
||||||
|
const normalized = rawSets
|
||||||
|
.filter((rs) => rs && rs.tag && rs.url)
|
||||||
|
.map((rs) => ({
|
||||||
|
tag: String(rs.tag).trim(),
|
||||||
|
url: String(rs.url).trim(),
|
||||||
|
format: rs.format === "source" ? "source" : "binary",
|
||||||
|
}))
|
||||||
|
.filter((rs) => /^[a-z0-9][a-z0-9-]*$/i.test(rs.tag));
|
||||||
|
writeJson(settings.customRuleSetsPath, normalized);
|
||||||
|
return sendJson(res, 200, { success: true, ruleSets: normalized });
|
||||||
|
}
|
||||||
|
|
||||||
if (req.method === "POST" && req.url === "/api/route/check") {
|
if (req.method === "POST" && req.url === "/api/route/check") {
|
||||||
const body = await readBody(req);
|
const body = await readBody(req);
|
||||||
const host = String(body.host || "").trim();
|
const host = String(body.host || "").trim();
|
||||||
|
|||||||
@@ -33,10 +33,19 @@ function findOutbound(subscriptionConfig, selectedTag) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ruleSets() {
|
function readCustomRuleSets() {
|
||||||
if (!settings.routingRuDirect) return [];
|
try {
|
||||||
|
if (!fs.existsSync(settings.customRuleSetsPath)) return [];
|
||||||
|
const data = JSON.parse(fs.readFileSync(settings.customRuleSetsPath, "utf8"));
|
||||||
|
return Array.isArray(data) ? data : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
function ruleSets(customRuleSets = []) {
|
||||||
|
const builtIn = settings.routingRuDirect
|
||||||
|
? [
|
||||||
{
|
{
|
||||||
type: "remote",
|
type: "remote",
|
||||||
tag: "geoip-ru",
|
tag: "geoip-ru",
|
||||||
@@ -51,7 +60,23 @@ function ruleSets() {
|
|||||||
url: "https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-category-ru.srs",
|
url: "https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-category-ru.srs",
|
||||||
download_detour: "direct",
|
download_detour: "direct",
|
||||||
},
|
},
|
||||||
];
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const custom = (Array.isArray(customRuleSets) ? customRuleSets : [])
|
||||||
|
.filter((rs) => rs.tag && rs.url)
|
||||||
|
.map((rs) => ({
|
||||||
|
type: "remote",
|
||||||
|
tag: String(rs.tag).trim(),
|
||||||
|
format: rs.format || "binary",
|
||||||
|
url: String(rs.url).trim(),
|
||||||
|
download_detour: "direct",
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Пользовательские rule-sets не должны дублировать встроенные
|
||||||
|
const builtInTags = new Set(builtIn.map((rs) => rs.tag));
|
||||||
|
const merged = [...builtIn, ...custom.filter((rs) => !builtInTags.has(rs.tag))];
|
||||||
|
return merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
function uniqueClean(values) {
|
function uniqueClean(values) {
|
||||||
@@ -91,13 +116,17 @@ function toSingboxRule(customRule, vpnTag) {
|
|||||||
if (ports.length) rule.port = ports;
|
if (ports.length) rule.port = ports;
|
||||||
if (networks.length) rule.network = networks;
|
if (networks.length) rule.network = networks;
|
||||||
|
|
||||||
|
const ruleSetsRef = uniqueClean(customRule.ruleSets);
|
||||||
|
if (ruleSetsRef.length) rule.rule_set = ruleSetsRef;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!rule.domain &&
|
!rule.domain &&
|
||||||
!rule.domain_suffix &&
|
!rule.domain_suffix &&
|
||||||
!rule.domain_keyword &&
|
!rule.domain_keyword &&
|
||||||
!rule.ip_cidr &&
|
!rule.ip_cidr &&
|
||||||
!rule.port &&
|
!rule.port &&
|
||||||
!rule.network
|
!rule.network &&
|
||||||
|
!rule.rule_set
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -144,6 +173,8 @@ export function buildGatewayConfig(subscriptionConfig, selectedTag) {
|
|||||||
vpnOutbound.packet_encoding = "xudp";
|
vpnOutbound.packet_encoding = "xudp";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const customRuleSets = readCustomRuleSets();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
log: {
|
log: {
|
||||||
level: settings.logLevel,
|
level: settings.logLevel,
|
||||||
@@ -182,7 +213,7 @@ export function buildGatewayConfig(subscriptionConfig, selectedTag) {
|
|||||||
{ type: "block", tag: "block" },
|
{ type: "block", tag: "block" },
|
||||||
],
|
],
|
||||||
route: {
|
route: {
|
||||||
rule_set: ruleSets(),
|
rule_set: ruleSets(customRuleSets),
|
||||||
rules: routeRules(subscriptionConfig.customRules, vpnOutbound.tag),
|
rules: routeRules(subscriptionConfig.customRules, vpnOutbound.tag),
|
||||||
final: vpnOutbound.tag,
|
final: vpnOutbound.tag,
|
||||||
auto_detect_interface: true,
|
auto_detect_interface: true,
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ export const api = {
|
|||||||
conflicts: () => request("/api/rules/conflicts"),
|
conflicts: () => request("/api/rules/conflicts"),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
ruleSets: {
|
||||||
|
get: () => request("/api/rule-sets"),
|
||||||
|
save: (ruleSets) =>
|
||||||
|
request("/api/rule-sets", { method: "PUT", body: JSON.stringify({ ruleSets }) }),
|
||||||
|
},
|
||||||
|
|
||||||
subscription: {
|
subscription: {
|
||||||
fetch: (url) =>
|
fetch: (url) =>
|
||||||
request("/api/subscription/fetch", {
|
request("/api/subscription/fetch", {
|
||||||
|
|||||||
@@ -102,11 +102,16 @@ export function RoutingPage({
|
|||||||
const [editingId, setEditingId] = useState(null);
|
const [editingId, setEditingId] = useState(null);
|
||||||
const [showTemplates, setShowTemplates] = useState(false);
|
const [showTemplates, setShowTemplates] = useState(false);
|
||||||
const [conflicts, setConflicts] = useState([]);
|
const [conflicts, setConflicts] = useState([]);
|
||||||
|
const [availableRuleSets, setAvailableRuleSets] = useState([]);
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.ruleSets.get().then((data) => setAvailableRuleSets(data.ruleSets || [])).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const t = setTimeout(() => {
|
const t = setTimeout(() => {
|
||||||
@@ -216,6 +221,7 @@ export function RoutingPage({
|
|||||||
onUpdate={onUpdate}
|
onUpdate={onUpdate}
|
||||||
onClose={() => setEditingId(null)}
|
onClose={() => setEditingId(null)}
|
||||||
onRemove={onRemove}
|
onRemove={onRemove}
|
||||||
|
availableRuleSets={availableRuleSets}
|
||||||
/>
|
/>
|
||||||
<TemplatesModal open={showTemplates} onClose={() => setShowTemplates(false)} onAdd={onAddTemplate} />
|
<TemplatesModal open={showTemplates} onClose={() => setShowTemplates(false)} onAdd={onAddTemplate} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ import { ChipsInput } from './ChipsInput.jsx';
|
|||||||
import { isValidCidr, isValidPort, ruleErrors, hasErrors } from '../utils/validation.js';
|
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 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 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 [view, setView] = useState(mode); // builder | json
|
||||||
const [jsonDraft, setJsonDraft] = useState(() => JSON.stringify(rule, null, 2));
|
const [jsonDraft, setJsonDraft] = useState(() => JSON.stringify(rule, null, 2));
|
||||||
const [jsonError, setJsonError] = useState('');
|
const [jsonError, setJsonError] = useState('');
|
||||||
@@ -61,6 +63,41 @@ export function RuleEditor({ rule, onUpdate, onClose, onRemove, mode = 'builder'
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="field">
|
||||||
<span className="field-label">Домены (точное совпадение)</span>
|
<span className="field-label">Домены (точное совпадение)</span>
|
||||||
<ChipsInput
|
<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;
|
if (!rule) return null;
|
||||||
const errors = ruleErrors(rule);
|
const errors = ruleErrors(rule);
|
||||||
const invalid = hasErrors(errors);
|
const invalid = hasErrors(errors);
|
||||||
@@ -181,7 +218,7 @@ export function RuleEditorDrawer({ rule, onUpdate, onClose, onRemove }) {
|
|||||||
</div>
|
</div>
|
||||||
<button className="btn btn-ghost sm" onClick={onClose}>Закрыть</button>
|
<button className="btn btn-ghost sm" onClick={onClose}>Закрыть</button>
|
||||||
</div>
|
</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">
|
<div className="drawer-foot">
|
||||||
<button className="btn btn-danger" onClick={() => { if (confirm('Удалить правило?')) { onRemove(rule.id); onClose(); } }}>Удалить</button>
|
<button className="btn btn-danger" onClick={() => { if (confirm('Удалить правило?')) { onRemove(rule.id); onClose(); } }}>Удалить</button>
|
||||||
<div className="btn-group">
|
<div className="btn-group">
|
||||||
|
|||||||
@@ -2,6 +2,24 @@ import React, { useEffect, useState } from 'react';
|
|||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
import { formatRelative } from '../utils/format.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 }) {
|
function SubscriptionCard({ state, subscriptionUrl, setSubscriptionUrl, busy, onFetch, onForget, pushToast }) {
|
||||||
const [editing, setEditing] = useState(!state?.hasSubscription);
|
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 }) {
|
function PortsCard({ state }) {
|
||||||
return (
|
return (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
@@ -165,6 +320,7 @@ export function SettingsPage({
|
|||||||
onClearConfig={onClearConfig}
|
onClearConfig={onClearConfig}
|
||||||
pushToast={pushToast}
|
pushToast={pushToast}
|
||||||
/>
|
/>
|
||||||
|
<RuleSetsCard pushToast={pushToast} />
|
||||||
<PortsCard state={state} />
|
<PortsCard state={state} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user