mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-03 03:35:04 +03:00
feat(hosts): add drag-and-drop and global subscription ordering (#6895)
Hosts already had one saved order on the Hosts page, but subscriptions honoured it only inside each inbound, so a host of a later inbound could never come before one of an earlier inbound (#6894). A new opt-in setting makes raw/base64, Clash/Mihomo and JSON subscriptions follow the Hosts page order across inbounds; the default keeps today's inbound-first output unchanged. The Hosts table gains drag-and-drop reordering next to the Up/Down buttons, on a pointer-reorder hook the Routing Rules table now shares.
This commit is contained in:
@@ -2,6 +2,24 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.host-drag-handle {
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
.host-drag-handle:focus-visible {
|
||||
outline: 2px solid var(--ant-color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.host-dragging {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.host-drop-before > td {
|
||||
border-top: 2px solid var(--ant-color-primary) !important;
|
||||
}
|
||||
.host-drop-after > td {
|
||||
border-bottom: 2px solid var(--ant-color-primary) !important;
|
||||
}
|
||||
|
||||
.host-remark-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Card, Popover, Space, Switch, Table, Tag, Tooltip } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
@@ -8,11 +8,13 @@ import {
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
GlobalOutlined,
|
||||
HolderOutlined,
|
||||
PlusOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import type { HostRecord } from '@/api/queries/useHostsQuery';
|
||||
import type { InboundOption } from '@/schemas/client';
|
||||
import { usePointerRowReorder, type PointerRowDrop } from '@/hooks/usePointerRowReorder';
|
||||
import './HostList.css';
|
||||
|
||||
interface HostListProps {
|
||||
@@ -27,6 +29,8 @@ interface HostListProps {
|
||||
onDelete: (host: HostRecord) => void;
|
||||
onToggleEnable: (host: HostRecord, next: boolean) => void;
|
||||
onMove: (host: HostRecord, dir: 'up' | 'down') => void;
|
||||
onReorder: (groupIds: string[]) => void;
|
||||
reordering?: boolean;
|
||||
onBulkEnable: (enable: boolean) => void;
|
||||
onBulkDelete: () => void;
|
||||
}
|
||||
@@ -52,10 +56,19 @@ export function sortHosts(hosts: HostRecord[]): HostRecord[] {
|
||||
const sa = a.sortOrder ?? 0;
|
||||
const sb = b.sortOrder ?? 0;
|
||||
if (sa !== sb) return sa - sb;
|
||||
return (a.remark || '').localeCompare(b.remark || '');
|
||||
const ar = a.remark || '';
|
||||
const br = b.remark || '';
|
||||
if (ar !== br) return ar < br ? -1 : 1;
|
||||
return a.groupId < b.groupId ? -1 : a.groupId > b.groupId ? 1 : 0;
|
||||
});
|
||||
}
|
||||
|
||||
function hostDropDestination(keys: string[], { from, to, after }: PointerRowDrop<string>) {
|
||||
const source = keys.indexOf(from);
|
||||
const slot = keys.indexOf(to) + (after ? 1 : 0);
|
||||
return slot - (source < slot ? 1 : 0);
|
||||
}
|
||||
|
||||
export default function HostList(props: HostListProps) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
@@ -70,6 +83,8 @@ export default function HostList(props: HostListProps) {
|
||||
onDelete,
|
||||
onToggleEnable,
|
||||
onMove,
|
||||
onReorder,
|
||||
reordering,
|
||||
onBulkEnable,
|
||||
onBulkDelete,
|
||||
} = props;
|
||||
@@ -81,23 +96,47 @@ export default function HostList(props: HostListProps) {
|
||||
}, [inboundOptions]);
|
||||
|
||||
const sorted = useMemo(() => sortHosts(hosts), [hosts]);
|
||||
const tableRef = useRef<HTMLDivElement>(null);
|
||||
const keys = sorted.map((h) => h.groupId);
|
||||
const { drag, onPointerDown: onHandlePointerDown } = usePointerRowReorder({
|
||||
containerRef: tableRef,
|
||||
getRowKey: (row) => keys.find((key) => key === row.dataset.rowKey) ?? null,
|
||||
disabled: reordering || loading,
|
||||
isNoop: (drop) => keys.indexOf(drop.from) === hostDropDestination(keys, drop),
|
||||
onDrop: (drop) => {
|
||||
const ids = [...keys];
|
||||
const destination = hostDropDestination(keys, drop);
|
||||
ids.splice(ids.indexOf(drop.from), 1);
|
||||
ids.splice(destination, 0, drop.from);
|
||||
onReorder(ids);
|
||||
},
|
||||
});
|
||||
|
||||
const columns: ColumnsType<HostRecord> = [
|
||||
{
|
||||
title: t('pages.hosts.fields.actions'),
|
||||
key: 'actions',
|
||||
width: 168,
|
||||
width: 196,
|
||||
render: (_, h, idx) => {
|
||||
const count = sorted.length;
|
||||
return (
|
||||
<Space size={2}>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
className="host-drag-handle"
|
||||
icon={<HolderOutlined aria-hidden />}
|
||||
aria-label={t('pages.hosts.dragToReorder', { name: h.remark || h.groupId })}
|
||||
disabled={reordering || loading}
|
||||
onPointerDown={(event) => onHandlePointerDown(h.groupId, event)}
|
||||
/>
|
||||
<Tooltip title={t('pages.hosts.moveUp')}>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<ArrowUpOutlined aria-hidden />}
|
||||
aria-label={t('pages.hosts.moveUp')}
|
||||
disabled={idx === 0}
|
||||
disabled={reordering || loading || idx === 0}
|
||||
onClick={() => onMove(h, 'up')}
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -107,7 +146,7 @@ export default function HostList(props: HostListProps) {
|
||||
type="text"
|
||||
icon={<ArrowDownOutlined aria-hidden />}
|
||||
aria-label={t('pages.hosts.moveDown')}
|
||||
disabled={idx >= count - 1}
|
||||
disabled={reordering || loading || idx >= count - 1}
|
||||
onClick={() => onMove(h, 'down')}
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -305,7 +344,7 @@ export default function HostList(props: HostListProps) {
|
||||
);
|
||||
|
||||
return (
|
||||
<Card size="small" hoverable title={toolbar} className="hosts-card">
|
||||
<Card size="small" hoverable title={toolbar} className="hosts-card" ref={tableRef}>
|
||||
<Table<HostRecord>
|
||||
rowKey="groupId"
|
||||
size="small"
|
||||
@@ -313,6 +352,12 @@ export default function HostList(props: HostListProps) {
|
||||
columns={columns}
|
||||
dataSource={sorted}
|
||||
pagination={false}
|
||||
rowClassName={(h) =>
|
||||
[
|
||||
drag?.from === h.groupId ? 'host-dragging' : '',
|
||||
drag?.to === h.groupId ? (drag.after ? 'host-drop-after' : 'host-drop-before') : '',
|
||||
].join(' ')
|
||||
}
|
||||
scroll={{ x: 'max-content' }}
|
||||
rowSelection={{
|
||||
selectedRowKeys: selectedGroupIds,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Button,
|
||||
@@ -45,6 +45,35 @@ export default function HostsPage() {
|
||||
const [formMode, setFormMode] = useState<'add' | 'edit'>('add');
|
||||
const [formHost, setFormHost] = useState<HostRecord | null>(null);
|
||||
const [selectedGroupIds, setSelectedGroupIds] = useState<string[]>([]);
|
||||
const [pendingOrder, setPendingOrder] = useState<string[] | null>(null);
|
||||
const savingOrder = useRef(false);
|
||||
const visibleHosts = useMemo(
|
||||
() =>
|
||||
pendingOrder
|
||||
? hosts.map((h) => ({ ...h, sortOrder: pendingOrder.indexOf(h.groupId) }))
|
||||
: hosts,
|
||||
[hosts, pendingOrder],
|
||||
);
|
||||
|
||||
const onReorder = useCallback(
|
||||
async (groupIds: string[]) => {
|
||||
const current = sortHosts(hosts).map((h) => h.groupId);
|
||||
if (savingOrder.current || current.every((id, i) => id === groupIds[i])) return;
|
||||
savingOrder.current = true;
|
||||
setPendingOrder(groupIds);
|
||||
try {
|
||||
const msg = await reorder(groupIds);
|
||||
if (msg?.success) messageApi.success(t('pages.hosts.toasts.update'));
|
||||
else messageApi.error(msg?.msg || t('somethingWentWrong'));
|
||||
} catch {
|
||||
messageApi.error(t('somethingWentWrong'));
|
||||
} finally {
|
||||
savingOrder.current = false;
|
||||
setPendingOrder(null);
|
||||
}
|
||||
},
|
||||
[hosts, reorder, messageApi, t],
|
||||
);
|
||||
|
||||
const onAdd = useCallback(() => {
|
||||
setFormMode('add');
|
||||
@@ -99,9 +128,9 @@ export default function HostsPage() {
|
||||
if (idx < 0 || swapWith < 0 || swapWith >= sorted.length) return;
|
||||
const groupIds = sorted.map((h) => h.groupId);
|
||||
[groupIds[idx], groupIds[swapWith]] = [groupIds[swapWith], groupIds[idx]];
|
||||
await reorder(groupIds);
|
||||
await onReorder(groupIds);
|
||||
},
|
||||
[hosts, reorder],
|
||||
[hosts, onReorder],
|
||||
);
|
||||
|
||||
const onBulkEnable = useCallback(
|
||||
@@ -207,7 +236,7 @@ export default function HostsPage() {
|
||||
|
||||
<Col span={24}>
|
||||
<HostList
|
||||
hosts={hosts}
|
||||
hosts={visibleHosts}
|
||||
inboundOptions={inboundOptions}
|
||||
loading={loading}
|
||||
isMobile={isMobile}
|
||||
@@ -218,6 +247,8 @@ export default function HostsPage() {
|
||||
onDelete={onDelete}
|
||||
onToggleEnable={onToggleEnable}
|
||||
onMove={onMove}
|
||||
onReorder={onReorder}
|
||||
reordering={pendingOrder !== null}
|
||||
onBulkEnable={onBulkEnable}
|
||||
onBulkDelete={onBulkDelete}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user