feat(inbounds): forwarding chip on list rows

Rows whose stored ForwardingSpec is enabled get a volcano chip after
the protocol tags: '转发 → <target remark>' for managed landings
(resolved against the full row set), '转发 → <address>:<port>' for
manual endpoints, '转发 → SK5' when the target row is gone. Text is
ellipsised at 180px with a tooltip so narrow rows never overflow.
Data rides the slim list's existing forwarding field — no API change.
This commit is contained in:
Muse
2026-10-21 21:01:45 +00:00
parent d867b15f4a
commit 051a74be80
6 changed files with 118 additions and 0 deletions
@@ -180,3 +180,11 @@
padding: 4px; padding: 4px;
} }
} }
.forwarding-tag {
max-width: 180px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: top;
}
@@ -137,11 +137,17 @@ export default function InboundList({
if (ok) setSelectedRowKeys([]); if (ok) setSelectedRowKeys([]);
}, [onBulkDelete, selectedRowKeys]); }, [onBulkDelete, selectedRowKeys]);
const inboundsById = useMemo(
() => new Map(dbInbounds.map((ib) => [ib.id, ib] as const)),
[dbInbounds],
);
const columns = useInboundColumns({ const columns = useInboundColumns({
hasAnyRemark, hasAnyRemark,
hasAnySubSortIndex, hasAnySubSortIndex,
hasActiveNode, hasActiveNode,
nodesById, nodesById,
inboundsById,
clientCount, clientCount,
inboundSpeed, inboundSpeed,
subEnable, subEnable,
@@ -104,3 +104,26 @@ export function showQrCodeMenu(dbInbound: DBInboundRecord): boolean {
} }
return false; return false;
} }
// Landing label for the list-row forwarding chip: the managed target's
// remark (falling back to its tag), or the manual endpoint address:port.
// Returns null when the inbound does not forward, so the caller renders
// no chip. The stored spec rides the row as a JSON string.
export function forwardingLandingLabel(
record: DBInboundRecord,
inboundsById: Map<number, DBInboundRecord>,
): string | null {
const spec = coerceInboundJsonField(record.forwarding) as {
mode?: string;
landing?: string;
targetInboundId?: number;
address?: string;
port?: number;
};
if (spec.mode !== 'forward') return null;
if (spec.landing === 'manual') {
return spec.address ? `${spec.address}:${spec.port ?? 0}` : 'SK5';
}
const target = spec.targetInboundId ? inboundsById.get(spec.targetInboundId) : undefined;
return target?.remark || target?.tag || 'SK5';
}
@@ -32,8 +32,10 @@ export interface DBInboundRecord extends ProtocolFlags {
expiryTime: number; expiryTime: number;
_expiryTime: { valueOf(): number } | null; _expiryTime: { valueOf(): number } | null;
nodeId?: number | null; nodeId?: number | null;
tag?: string;
settings: unknown; settings: unknown;
streamSettings: unknown; streamSettings: unknown;
forwarding?: unknown;
} }
export interface ClientCountEntry { export interface ClientCountEntry {
@@ -17,6 +17,7 @@ import {
} from '@/components/utility/speedTagStyle'; } from '@/components/utility/speedTagStyle';
import { InboundSpeedTag, isActiveSpeed } from './InboundSpeedTag'; import { InboundSpeedTag, isActiveSpeed } from './InboundSpeedTag';
import { import {
forwardingLandingLabel,
readStreamHints, readStreamHints,
networkLabel, networkLabel,
networkL4, networkL4,
@@ -31,6 +32,7 @@ interface UseInboundColumnsParams {
hasAnySubSortIndex: boolean; hasAnySubSortIndex: boolean;
hasActiveNode: boolean; hasActiveNode: boolean;
nodesById: Map<number, NodeRecord>; nodesById: Map<number, NodeRecord>;
inboundsById: Map<number, DBInboundRecord>;
clientCount: Record<number, ClientCountEntry>; clientCount: Record<number, ClientCountEntry>;
inboundSpeed: Record<number, InboundSpeedEntry>; inboundSpeed: Record<number, InboundSpeedEntry>;
subEnable: boolean; subEnable: boolean;
@@ -45,6 +47,7 @@ export function useInboundColumns({
hasAnySubSortIndex, hasAnySubSortIndex,
hasActiveNode, hasActiveNode,
nodesById, nodesById,
inboundsById,
clientCount, clientCount,
inboundSpeed, inboundSpeed,
subEnable, subEnable,
@@ -257,6 +260,17 @@ export function useInboundColumns({
</Tag>, </Tag>,
); );
} }
const fwdLabel = forwardingLandingLabel(record, inboundsById);
if (fwdLabel) {
const text = `${t('pages.inbounds.forwardingTab')} → ${fwdLabel}`;
tags.push(
<Tooltip key="fwd" title={text}>
<Tag color="volcano" className="forwarding-tag">
{text}
</Tag>
</Tooltip>,
);
}
return <div className="protocol-tags">{tags}</div>; return <div className="protocol-tags">{tags}</div>;
}, },
}, },
@@ -453,6 +467,7 @@ export function useInboundColumns({
hasAnySubSortIndex, hasAnySubSortIndex,
hasActiveNode, hasActiveNode,
nodesById, nodesById,
inboundsById,
clientCount, clientCount,
inboundSpeed, inboundSpeed,
subEnable, subEnable,
+64
View File
@@ -0,0 +1,64 @@
/// <reference types="vite/client" />
import { describe, expect, it } from 'vitest';
import { forwardingLandingLabel } from '@/pages/inbounds/list/helpers';
import type { DBInboundRecord } from '@/pages/inbounds/list/types';
function rec(partial: Partial<DBInboundRecord>): DBInboundRecord {
return {
id: 1,
enable: true,
remark: '',
subSortIndex: 1,
port: 0,
protocol: 'vless',
up: 0,
down: 0,
total: 0,
expiryTime: 0,
_expiryTime: null,
settings: '',
streamSettings: '',
...partial,
} as DBInboundRecord;
}
const TARGET = rec({ id: 42, remark: 'PPF-L3-sk5', tag: 'inbound-47814', protocol: 'mixed' });
const BY_ID = new Map([[TARGET.id, TARGET]]);
describe('forwardingLandingLabel (list-row chip)', () => {
it('managed landing resolves the target remark', () => {
const row = rec({
forwarding: JSON.stringify({ mode: 'forward', landing: 'managed', targetInboundId: 42 }),
});
expect(forwardingLandingLabel(row, BY_ID)).toBe('PPF-L3-sk5');
});
it('managed landing falls back to SK5 when the target row is gone', () => {
const row = rec({
forwarding: JSON.stringify({ mode: 'forward', landing: 'managed', targetInboundId: 99 }),
});
expect(forwardingLandingLabel(row, BY_ID)).toBe('SK5');
});
it('manual landing shows address:port', () => {
const row = rec({
forwarding: JSON.stringify({
mode: 'forward',
landing: 'manual',
address: '10.240.64.2',
port: 47814,
}),
});
expect(forwardingLandingLabel(row, BY_ID)).toBe('10.240.64.2:47814');
});
it('direct / empty / malformed specs render no chip', () => {
expect(forwardingLandingLabel(rec({ forwarding: '' }), BY_ID)).toBeNull();
expect(
forwardingLandingLabel(rec({ forwarding: JSON.stringify({ mode: 'direct' }) }), BY_ID),
).toBeNull();
expect(forwardingLandingLabel(rec({ forwarding: '{oops' }), BY_ID)).toBeNull();
expect(forwardingLandingLabel(rec({}), BY_ID)).toBeNull();
});
});