fix(frontend): hover cards, balancer load, routing dnd, modal a11y, outbound crash

- ClientsPage/SettingsPage/XrayPage: add hoverable to bottom card/tabs so
  hover affordance matches the top card
- BalancerFormModal: lazy-init useState from props + destroyOnHidden so
  the form mounts with saved values instead of relying on a useEffect
  sync that could miss the first open
- RoutingTab: rewrite pointer drag — handlers are now defined inside the
  pointerdown closure so addEventListener/removeEventListener match;
  drag state lives on a ref (from/to/moved) so onUp reads the real
  indices, not stale closure values. Adds setPointerCapture so Windows
  and touch keep delivering events when the cursor leaves the handle.
- OutboundFormModal/InboundFormModal: blur the focused input before
  switching tabs to silence the aria-hidden-on-focused-element warning
- utils.isArrEmpty: return true for undefined/null arrays — the old form
  treated undefined as "not empty" which crashed VLESSSettings.fromJson
  when json.vnext was missing
This commit is contained in:
MHSanaei 2026-05-22 03:31:51 +02:00
parent 2a96ac9721
commit 20a3d00bf1
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
8 changed files with 68 additions and 45 deletions

View file

@ -687,6 +687,7 @@ export default function ClientsPage() {
<Col span={24}>
<Card
size="small"
hoverable
title={
<div className="card-toolbar">
<Button type="primary" size="small" icon={<PlusOutlined />} onClick={onAdd}>

View file

@ -701,6 +701,9 @@ export default function InboundFormModal({
}, [t, refresh, parseAdvancedSliceWithLabel, messageApi]);
const handleTabChange = (next: string) => {
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
if (activeTabKey === 'advanced' && next !== 'advanced') {
if (!applyAdvancedJsonToBasic()) return;
}

View file

@ -320,12 +320,14 @@ export default function SettingsPage() {
</Col>
<Col span={24}>
<Tabs
activeKey={activeTabKey}
onChange={onTabChange}
className={isMobile ? 'icons-only' : ''}
items={tabItems}
/>
<Card hoverable>
<Tabs
activeKey={activeTabKey}
onChange={onTabChange}
className={isMobile ? 'icons-only' : ''}
items={tabItems}
/>
</Card>
</Col>
</Row>
</>

View file

@ -34,10 +34,10 @@ export default function BalancerFormModal({
onConfirm,
}: BalancerFormModalProps) {
const { t } = useTranslation();
const [tag, setTag] = useState('');
const [strategy, setStrategy] = useState('random');
const [selector, setSelector] = useState<string[]>([]);
const [fallbackTag, setFallbackTag] = useState('');
const [tag, setTag] = useState(() => balancer?.tag || '');
const [strategy, setStrategy] = useState(() => balancer?.strategy || 'random');
const [selector, setSelector] = useState<string[]>(() => [...(balancer?.selector || [])]);
const [fallbackTag, setFallbackTag] = useState(() => balancer?.fallbackTag || '');
const isEdit = balancer != null;
@ -98,6 +98,7 @@ export default function BalancerFormModal({
cancelText={t('close')}
okButtonProps={{ disabled: !isValid }}
mask={{ closable: false }}
destroyOnHidden
onOk={submit}
onCancel={onClose}
>

View file

@ -138,6 +138,9 @@ export default function OutboundFormModal({
}
function onTabChange(key: string) {
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
if (revertingTabRef.current) {
revertingTabRef.current = false;
setActiveKey(key);

View file

@ -76,7 +76,12 @@ export default function RoutingTab({
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const [dropTargetIndex, setDropTargetIndex] = useState<number | null>(null);
const dragStateRef = useRef<{ startY: number; moved: boolean }>({ startY: 0, moved: false });
const dragRef = useRef<{ from: number | null; to: number | null; startY: number; moved: boolean }>({
from: null,
to: null,
startY: 0,
moved: false,
});
const rules = useMemo(
() => (templateSettings?.routing?.rules || []) as RoutingRule[],
@ -214,43 +219,49 @@ export default function RoutingTab({
function onHandlePointerDown(idx: number, ev: React.PointerEvent) {
if (ev.button != null && ev.button !== 0) return;
ev.preventDefault();
try {
(ev.currentTarget as Element).setPointerCapture(ev.pointerId);
} catch { /* ignore */ }
dragRef.current = { from: idx, to: idx, startY: ev.clientY, moved: false };
setDraggedIndex(idx);
setDropTargetIndex(idx);
dragStateRef.current = { startY: ev.clientY, moved: false };
document.addEventListener('pointermove', onDragMove);
document.addEventListener('pointerup', onDragUp);
document.addEventListener('pointercancel', onDragUp);
}
function onDragMove(ev: PointerEvent) {
setDraggedIndex((dragged) => {
if (dragged == null) return dragged;
const state = dragStateRef.current;
if (!state.moved && Math.abs(ev.clientY - state.startY) < 5) return dragged;
const onMove = (e: PointerEvent) => {
const state = dragRef.current;
if (state.from == null) return;
if (!state.moved && Math.abs(e.clientY - state.startY) < 5) return;
state.moved = true;
const el = document.elementFromPoint(ev.clientX, ev.clientY);
if (!el) return dragged;
const el = document.elementFromPoint(e.clientX, e.clientY);
if (!el) return;
const target = el.closest('[data-row-key]');
if (!target) return dragged;
const idx = Number(target.getAttribute('data-row-key'));
if (Number.isFinite(idx)) setDropTargetIndex(idx);
return dragged;
});
}
function onDragUp() {
document.removeEventListener('pointermove', onDragMove);
document.removeEventListener('pointerup', onDragUp);
document.removeEventListener('pointercancel', onDragUp);
const from = draggedIndex;
const to = dropTargetIndex;
setDraggedIndex(null);
setDropTargetIndex(null);
if (!dragStateRef.current.moved || from == null || to == null || from === to) return;
mutate((tt) => {
const list = tt.routing?.rules;
if (!list) return;
const [moved] = list.splice(from, 1);
list.splice(to, 0, moved);
});
if (!target) return;
const newIdx = Number(target.getAttribute('data-row-key'));
if (Number.isFinite(newIdx) && newIdx !== state.to) {
state.to = newIdx;
setDropTargetIndex(newIdx);
}
};
const onUp = () => {
document.removeEventListener('pointermove', onMove);
document.removeEventListener('pointerup', onUp);
document.removeEventListener('pointercancel', onUp);
const { from, to, moved } = dragRef.current;
dragRef.current = { from: null, to: null, startY: 0, moved: false };
setDraggedIndex(null);
setDropTargetIndex(null);
if (!moved || from == null || to == null || from === to) return;
mutate((tt) => {
const list = tt.routing?.rules;
if (!list) return;
const [movedItem] = list.splice(from, 1);
list.splice(to, 0, movedItem);
});
};
document.addEventListener('pointermove', onMove);
document.addEventListener('pointerup', onUp);
document.addEventListener('pointercancel', onUp);
}
function ruleCriteriaChips(rule: RuleRow) {

View file

@ -305,6 +305,7 @@ export default function XrayPage() {
</Col>
<Col span={24}>
<Card hoverable>
<Tabs
activeKey={activeTabKey}
onChange={onTabChange}
@ -444,6 +445,7 @@ export default function XrayPage() {
},
]}
/>
</Card>
</Col>
</Row>
)}

View file

@ -238,7 +238,7 @@ export class ObjectUtil {
}
static isArrEmpty(arr) {
return !this.isEmpty(arr) && arr.length === 0;
return !Array.isArray(arr) || arr.length === 0;
}
static copyArr(dest, src) {