Improve local rules persistence and dirty-state handling
This commit is contained in:
@@ -222,6 +222,10 @@ const createLocalRuleDraft = (rule) => ({
|
||||
enabled: rule?.enabled !== false,
|
||||
_key: `route-rule-${localRuleDraftId += 1}`,
|
||||
});
|
||||
const localRuleValues = (rules) => rules
|
||||
.filter((rule) => !rule.removing)
|
||||
.map(({ type, value, enabled }) => ({ type, value, enabled }));
|
||||
const localRulesSignature = (rules) => JSON.stringify(localRuleValues(rules));
|
||||
|
||||
function RuleTypePicker({ value, ruleKey, index, disabled, onChange }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -320,6 +324,9 @@ function LocalRulesPanel({
|
||||
open,
|
||||
rules,
|
||||
blocked,
|
||||
dirty,
|
||||
restartPending,
|
||||
confirmingClose,
|
||||
error,
|
||||
operations,
|
||||
panelRef,
|
||||
@@ -327,6 +334,8 @@ function LocalRulesPanel({
|
||||
onChange,
|
||||
onRemove,
|
||||
onRemoveComplete,
|
||||
onKeepEditing,
|
||||
onDiscard,
|
||||
onClose,
|
||||
onSave,
|
||||
}) {
|
||||
@@ -346,11 +355,34 @@ function LocalRulesPanel({
|
||||
<div className="client-local-rules-sheet">
|
||||
<header className="client-local-rules-header">
|
||||
<span>Маршрутизация</span>
|
||||
<button
|
||||
className="client-local-rules-save"
|
||||
type="submit"
|
||||
form="client-local-rules-form"
|
||||
disabled={blocked || !dirty}
|
||||
>
|
||||
Сохранить
|
||||
</button>
|
||||
<h2 id="local-rules-title">Локальные правила</h2>
|
||||
<p>Эти домены идут напрямую. Остальной трафик — через выбранный VPN.</p>
|
||||
{restartPending && (
|
||||
<p className="client-local-rules-runtime" role="status">
|
||||
Правила сохранены, но начнут работать после запуска или перезапуска sing-box.
|
||||
</p>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<form className="client-local-rules-form" onSubmit={onSave}>
|
||||
<form id="client-local-rules-form" className="client-local-rules-form" onSubmit={onSave}>
|
||||
{confirmingClose && (
|
||||
<section className="client-local-rules-discard" role="alert">
|
||||
<strong>Есть несохранённые настройки</strong>
|
||||
<span>Закрыть редактор и потерять изменения?</span>
|
||||
<div>
|
||||
<button type="button" onClick={onKeepEditing}>Остаться</button>
|
||||
<button type="button" className="is-danger" onClick={onDiscard}>Закрыть без сохранения</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
<section className="client-local-rules-group" aria-labelledby="local-rules-list-title">
|
||||
<span id="local-rules-list-title">Правила</span>
|
||||
<div className="client-local-rules-list">
|
||||
@@ -358,7 +390,7 @@ function LocalRulesPanel({
|
||||
<div
|
||||
className={`client-local-rule${rule.enabled ? '' : ' is-disabled'}${rule.removing ? ' is-removing' : ''}`}
|
||||
key={rule._key}
|
||||
style={{ '--rule-index': index }}
|
||||
style={{ viewTransitionName: rule.removing ? 'none' : rule._key }}
|
||||
inert={rule.removing ? true : undefined}
|
||||
onAnimationEnd={(event) => {
|
||||
if (event.animationName === 'client-local-rule-leave') {
|
||||
@@ -423,7 +455,6 @@ function LocalRulesPanel({
|
||||
<InlineProgress operations={operations} context="routing" />
|
||||
<div className="client-local-rules-actions">
|
||||
<button type="button" onClick={onClose}>Отмена</button>
|
||||
<button className="is-primary" type="submit" disabled={blocked}>Сохранить</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -575,6 +606,7 @@ export function ClientOverviewPage({
|
||||
const [localRulesOpen, setLocalRulesOpen] = useState(false);
|
||||
const [localRulesDraft, setLocalRulesDraft] = useState([]);
|
||||
const [localRulesRevision, setLocalRulesRevision] = useState(state?.revision || 0);
|
||||
const [confirmingLocalRulesClose, setConfirmingLocalRulesClose] = useState(false);
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
||||
const [openInstructionId, setOpenInstructionId] = useState('');
|
||||
const subscriptionInputRef = useRef(null);
|
||||
@@ -584,6 +616,7 @@ export function ClientOverviewPage({
|
||||
const instructionsToggleRef = useRef(null);
|
||||
const localRulesPanelRef = useRef(null);
|
||||
const localRulesToggleRef = useRef(null);
|
||||
const localRulesBaselineRef = useRef('[]');
|
||||
const previousHasSubscriptionRef = useRef(hasSubscription);
|
||||
const validationRequests = useRef(null);
|
||||
if (!validationRequests.current) validationRequests.current = createLatestRequest();
|
||||
@@ -624,6 +657,7 @@ export function ClientOverviewPage({
|
||||
const subscriptionDeleteBlocked = operationBlocked(operations, 'subscriptionDelete');
|
||||
const gatewayAutoBlocked = operationBlocked(operations, 'gatewayAuto');
|
||||
const routeRulesBlocked = operationBlocked(operations, 'routeRules');
|
||||
const localRulesDirty = localRulesSignature(localRulesDraft) !== localRulesBaselineRef.current;
|
||||
|
||||
useEffect(() => {
|
||||
setNow(Date.now());
|
||||
@@ -798,10 +832,13 @@ export function ClientOverviewPage({
|
||||
useEffect(() => {
|
||||
if (!localRulesOpen) return undefined;
|
||||
const closeLocalRules = (event) => {
|
||||
if (event.type === 'keydown' && event.key !== 'Escape') return;
|
||||
if (localRulesPanelRef.current?.contains(event.target)) return;
|
||||
if (localRulesToggleRef.current?.contains(event.target)) return;
|
||||
setLocalRulesOpen(false);
|
||||
if (event.type === 'keydown') {
|
||||
if (event.key !== 'Escape' || event.defaultPrevented) return;
|
||||
} else {
|
||||
if (localRulesPanelRef.current?.contains(event.target)) return;
|
||||
if (localRulesToggleRef.current?.contains(event.target)) return;
|
||||
}
|
||||
requestCloseLocalRules();
|
||||
};
|
||||
document.addEventListener('pointerdown', closeLocalRules);
|
||||
document.addEventListener('keydown', closeLocalRules);
|
||||
@@ -809,7 +846,17 @@ export function ClientOverviewPage({
|
||||
document.removeEventListener('pointerdown', closeLocalRules);
|
||||
document.removeEventListener('keydown', closeLocalRules);
|
||||
};
|
||||
}, [localRulesOpen]);
|
||||
}, [localRulesOpen, localRulesDirty]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!localRulesOpen || !localRulesDirty) return undefined;
|
||||
const warnBeforeUnload = (event) => {
|
||||
event.preventDefault();
|
||||
event.returnValue = '';
|
||||
};
|
||||
window.addEventListener('beforeunload', warnBeforeUnload);
|
||||
return () => window.removeEventListener('beforeunload', warnBeforeUnload);
|
||||
}, [localRulesOpen, localRulesDirty]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!instructionsOpen) return undefined;
|
||||
@@ -905,9 +952,12 @@ export function ClientOverviewPage({
|
||||
}
|
||||
|
||||
function openLocalRules() {
|
||||
const rules = state?.route?.localRules || [];
|
||||
setInstructionsOpen(false);
|
||||
setLocalRulesDraft((state?.route?.localRules || []).map(createLocalRuleDraft));
|
||||
localRulesBaselineRef.current = localRulesSignature(rules);
|
||||
setLocalRulesDraft(rules.map(createLocalRuleDraft));
|
||||
setLocalRulesRevision(state?.revision || 0);
|
||||
setConfirmingLocalRulesClose(false);
|
||||
setLocalRulesOpen(true);
|
||||
}
|
||||
|
||||
@@ -919,10 +969,26 @@ export function ClientOverviewPage({
|
||||
|
||||
async function saveLocalRules(event) {
|
||||
event.preventDefault();
|
||||
const rules = localRulesDraft
|
||||
.filter((rule) => !rule.removing)
|
||||
.map(({ type, value, enabled }) => ({ type, value, enabled }));
|
||||
if (!await onSaveRouteRules(rules, localRulesRevision)) return;
|
||||
const rules = localRuleValues(localRulesDraft);
|
||||
const result = await onSaveRouteRules(rules, localRulesRevision);
|
||||
if (!result) return;
|
||||
localRulesBaselineRef.current = JSON.stringify(rules);
|
||||
setLocalRulesRevision(result.state.revision);
|
||||
setConfirmingLocalRulesClose(false);
|
||||
if (!result.state.route.localRulesPendingRestart) setLocalRulesOpen(false);
|
||||
}
|
||||
|
||||
function requestCloseLocalRules() {
|
||||
if (localRulesDirty) {
|
||||
setConfirmingLocalRulesClose(true);
|
||||
return false;
|
||||
}
|
||||
setLocalRulesOpen(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
function discardLocalRules() {
|
||||
setConfirmingLocalRulesClose(false);
|
||||
setLocalRulesOpen(false);
|
||||
}
|
||||
|
||||
@@ -936,6 +1002,17 @@ export function ClientOverviewPage({
|
||||
)));
|
||||
}
|
||||
|
||||
function finishRemoveLocalRule(ruleKey) {
|
||||
const update = () => flushSync(() => {
|
||||
setLocalRulesDraft((rules) => rules.filter((rule) => rule._key !== ruleKey));
|
||||
});
|
||||
if (!document.startViewTransition || matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
update();
|
||||
return;
|
||||
}
|
||||
document.startViewTransition(update);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`client-shell${hasSubscription ? '' : ' is-first-run'}${showIntro && !hasSubscription ? ' is-intro' : ''}`}>
|
||||
<VersionDisplay isGateway={isGateway} versionInfo={versionInfo} />
|
||||
@@ -954,7 +1031,7 @@ export function ClientOverviewPage({
|
||||
aria-controls="client-instructions"
|
||||
aria-label={instructionsOpen ? 'Закрыть инструкции' : 'Как использовать'}
|
||||
onClick={() => {
|
||||
setLocalRulesOpen(false);
|
||||
if (localRulesOpen && !requestCloseLocalRules()) return;
|
||||
setInstructionsOpen((open) => !open);
|
||||
}}
|
||||
>
|
||||
@@ -967,19 +1044,19 @@ export function ClientOverviewPage({
|
||||
</button>}
|
||||
{hasSubscription && subscriptionContentReady && <button
|
||||
ref={localRulesToggleRef}
|
||||
className={`client-local-rules-toggle${localRulesOpen ? ' is-open' : ''}`}
|
||||
className={`client-local-rules-toggle${localRulesOpen ? ' is-open' : ''}${state?.route?.localRulesPendingRestart ? ' has-pending' : ''}`}
|
||||
type="button"
|
||||
aria-expanded={localRulesOpen}
|
||||
aria-controls="client-local-rules"
|
||||
aria-label={localRulesOpen ? 'Закрыть локальные правила' : 'Настроить локальные правила'}
|
||||
onClick={() => localRulesOpen ? setLocalRulesOpen(false) : openLocalRules()}
|
||||
onClick={() => localRulesOpen ? requestCloseLocalRules() : openLocalRules()}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M4 7h9M17 7h3M4 17h3M11 17h9" />
|
||||
<circle cx="15" cy="7" r="2" />
|
||||
<circle cx="9" cy="17" r="2" />
|
||||
</svg>
|
||||
<span>Локальные правила</span>
|
||||
<span>{state?.route?.localRulesPendingRestart ? 'Правила ждут перезапуска' : 'Локальные правила'}</span>
|
||||
</button>}
|
||||
<main className={`client-panel${showPower ? '' : ' is-setup'}`}>
|
||||
{showPower && (
|
||||
@@ -1355,6 +1432,9 @@ export function ClientOverviewPage({
|
||||
open={localRulesOpen}
|
||||
rules={localRulesDraft}
|
||||
blocked={routeRulesBlocked || localRulesDraft.some((rule) => rule.removing)}
|
||||
dirty={localRulesDirty}
|
||||
restartPending={state?.route?.localRulesPendingRestart === true}
|
||||
confirmingClose={confirmingLocalRulesClose}
|
||||
error={error}
|
||||
operations={operations}
|
||||
panelRef={localRulesPanelRef}
|
||||
@@ -1364,10 +1444,10 @@ export function ClientOverviewPage({
|
||||
])}
|
||||
onChange={changeLocalRule}
|
||||
onRemove={(index) => removeLocalRule(localRulesDraft[index]._key)}
|
||||
onRemoveComplete={(ruleKey) => setLocalRulesDraft((rules) => (
|
||||
rules.filter((rule) => rule._key !== ruleKey)
|
||||
))}
|
||||
onClose={() => setLocalRulesOpen(false)}
|
||||
onRemoveComplete={finishRemoveLocalRule}
|
||||
onKeepEditing={() => setConfirmingLocalRulesClose(false)}
|
||||
onDiscard={discardLocalRules}
|
||||
onClose={requestCloseLocalRules}
|
||||
onSave={saveLocalRules}
|
||||
/>}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user