mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-28 00:24:19 +00:00
feat(sub): per-inbound sort order for subscription links
Add a subSortIndex field to inbounds that controls the order of links in subscription output only: the raw sub body, the HTML sub page, and the JSON/Clash formats (all served from the same query). Lower values come first; ties keep id order. The panel inbound list is unaffected. The value is editable in the inbound form next to the share-address fields, propagates to nodes via wireInbound, and follows the usual node-sync rules (copied on import, mirrored while not dirty, never a structural change). Rescoped from #5214 by @Ponywka.
This commit is contained in:
@@ -1345,6 +1345,12 @@
|
||||
},
|
||||
"sniffing": {},
|
||||
"streamSettings": {},
|
||||
"subSortIndex": {
|
||||
"description": "1-based sort order of this inbound's links in subscription output only (lower first; ties by id)",
|
||||
"example": 1,
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"tag": {
|
||||
"example": "in-443-tcp",
|
||||
"type": "string"
|
||||
@@ -1385,6 +1391,7 @@
|
||||
"shareAddrStrategy",
|
||||
"sniffing",
|
||||
"streamSettings",
|
||||
"subSortIndex",
|
||||
"tag",
|
||||
"total",
|
||||
"trafficReset",
|
||||
@@ -2153,6 +2160,7 @@
|
||||
"shareAddrStrategy": "node",
|
||||
"sniffing": null,
|
||||
"streamSettings": null,
|
||||
"subSortIndex": 1,
|
||||
"tag": "in-443-tcp",
|
||||
"total": 0,
|
||||
"trafficReset": "never",
|
||||
|
||||
@@ -292,6 +292,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"shareAddrStrategy": "node",
|
||||
"sniffing": null,
|
||||
"streamSettings": null,
|
||||
"subSortIndex": 1,
|
||||
"tag": "in-443-tcp",
|
||||
"total": 0,
|
||||
"trafficReset": "never",
|
||||
|
||||
@@ -1319,6 +1319,12 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
},
|
||||
"sniffing": {},
|
||||
"streamSettings": {},
|
||||
"subSortIndex": {
|
||||
"description": "1-based sort order of this inbound's links in subscription output only (lower first; ties by id)",
|
||||
"example": 1,
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"tag": {
|
||||
"example": "in-443-tcp",
|
||||
"type": "string"
|
||||
@@ -1359,6 +1365,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"shareAddrStrategy",
|
||||
"sniffing",
|
||||
"streamSettings",
|
||||
"subSortIndex",
|
||||
"tag",
|
||||
"total",
|
||||
"trafficReset",
|
||||
|
||||
@@ -293,6 +293,7 @@ export interface Inbound {
|
||||
shareAddrStrategy: string;
|
||||
sniffing: unknown;
|
||||
streamSettings: unknown;
|
||||
subSortIndex: number;
|
||||
tag: string;
|
||||
total: number;
|
||||
trafficReset: string;
|
||||
|
||||
@@ -314,6 +314,7 @@ export const InboundSchema = z.object({
|
||||
shareAddrStrategy: z.enum(['node', 'listen', 'custom']),
|
||||
sniffing: z.unknown(),
|
||||
streamSettings: z.unknown(),
|
||||
subSortIndex: z.number().int().min(1),
|
||||
tag: z.string(),
|
||||
total: z.number().int(),
|
||||
trafficReset: z.enum(['never', 'hourly', 'daily', 'weekly', 'monthly']),
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface RawInboundRow {
|
||||
nodeId?: number | null;
|
||||
shareAddrStrategy?: string;
|
||||
shareAddr?: string;
|
||||
subSortIndex?: number;
|
||||
clientStats?: unknown;
|
||||
}
|
||||
|
||||
@@ -65,6 +66,7 @@ export interface WireInboundPayload {
|
||||
nodeId?: number;
|
||||
shareAddrStrategy: ShareAddrStrategy;
|
||||
shareAddr: string;
|
||||
subSortIndex: number;
|
||||
}
|
||||
|
||||
function coerceJsonObject(value: unknown): Record<string, unknown> {
|
||||
@@ -175,6 +177,7 @@ export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues {
|
||||
nodeId: row.nodeId ?? null,
|
||||
shareAddrStrategy: coerceShareAddrStrategy(row.shareAddrStrategy),
|
||||
shareAddr: row.shareAddr ?? '',
|
||||
subSortIndex: Math.max(1, row.subSortIndex ?? 1),
|
||||
protocol,
|
||||
settings,
|
||||
} as InboundFormValues;
|
||||
@@ -322,6 +325,7 @@ export function formValuesToWirePayload(values: InboundFormValues): WireInboundP
|
||||
tag: values.tag,
|
||||
shareAddrStrategy: values.shareAddrStrategy,
|
||||
shareAddr: values.shareAddr,
|
||||
subSortIndex: values.subSortIndex,
|
||||
};
|
||||
if (values.nodeId != null) payload.nodeId = values.nodeId;
|
||||
return payload;
|
||||
|
||||
@@ -42,6 +42,7 @@ export type DBInboundInit = Partial<{
|
||||
nodeId: number | null;
|
||||
shareAddrStrategy: string;
|
||||
shareAddr: string;
|
||||
subSortIndex: number;
|
||||
originNodeGuid: string;
|
||||
fallbackParent: FallbackParentRef | null;
|
||||
}>;
|
||||
@@ -88,6 +89,7 @@ export class DBInbound {
|
||||
nodeId: number | null;
|
||||
shareAddrStrategy: string;
|
||||
shareAddr: string;
|
||||
subSortIndex: number;
|
||||
originNodeGuid: string;
|
||||
fallbackParent: FallbackParentRef | null;
|
||||
|
||||
@@ -116,6 +118,7 @@ export class DBInbound {
|
||||
this.nodeId = null;
|
||||
this.shareAddrStrategy = "node";
|
||||
this.shareAddr = "";
|
||||
this.subSortIndex = 1;
|
||||
this.originNodeGuid = "";
|
||||
this.fallbackParent = null;
|
||||
if (data == null) {
|
||||
|
||||
@@ -575,6 +575,14 @@ export default function InboundFormModal({
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item
|
||||
name="subSortIndex"
|
||||
label={t('pages.inbounds.form.subSortIndex')}
|
||||
extra={t('pages.inbounds.form.subSortIndexHelp')}
|
||||
>
|
||||
<InputNumber min={1} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="port"
|
||||
label={t('pages.inbounds.port')}
|
||||
|
||||
@@ -93,6 +93,11 @@ export default function InboundList({
|
||||
[dbInbounds],
|
||||
);
|
||||
|
||||
const hasAnySubSortIndex = useMemo(
|
||||
() => dbInbounds.some((i) => (i.subSortIndex ?? 1) > 1),
|
||||
[dbInbounds],
|
||||
);
|
||||
|
||||
const toggleSelect = useCallback((id: number, checked: boolean) => {
|
||||
setSelectedRowKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -115,6 +120,7 @@ export default function InboundList({
|
||||
|
||||
const columns = useInboundColumns({
|
||||
hasAnyRemark,
|
||||
hasAnySubSortIndex,
|
||||
hasActiveNode,
|
||||
nodesById,
|
||||
clientCount,
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface DBInboundRecord extends ProtocolFlags {
|
||||
id: number;
|
||||
enable: boolean;
|
||||
remark: string;
|
||||
subSortIndex: number;
|
||||
port: number;
|
||||
protocol: string;
|
||||
up: number;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, type ReactElement } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Popover, Switch, Tag, type TableColumnType } from 'antd';
|
||||
import { Popover, Switch, Tag, Tooltip, type TableColumnType } from 'antd';
|
||||
import { TeamOutlined } from '@ant-design/icons';
|
||||
|
||||
import { SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
|
||||
@@ -21,6 +21,7 @@ import type { ClientCountEntry, DBInboundRecord, RowAction } from './types';
|
||||
|
||||
interface UseInboundColumnsParams {
|
||||
hasAnyRemark: boolean;
|
||||
hasAnySubSortIndex: boolean;
|
||||
hasActiveNode: boolean;
|
||||
nodesById: Map<number, NodeRecord>;
|
||||
clientCount: Record<number, ClientCountEntry>;
|
||||
@@ -33,6 +34,7 @@ interface UseInboundColumnsParams {
|
||||
|
||||
export function useInboundColumns({
|
||||
hasAnyRemark,
|
||||
hasAnySubSortIndex,
|
||||
hasActiveNode,
|
||||
nodesById,
|
||||
clientCount,
|
||||
@@ -113,6 +115,20 @@ export function useInboundColumns({
|
||||
});
|
||||
}
|
||||
|
||||
if (hasAnySubSortIndex) {
|
||||
cols.push({
|
||||
title: (
|
||||
<Tooltip title={t('pages.inbounds.form.subSortIndex')}>
|
||||
{t('pages.inbounds.subSortIndex')}
|
||||
</Tooltip>
|
||||
),
|
||||
dataIndex: 'subSortIndex',
|
||||
key: 'subSortIndex',
|
||||
align: 'right',
|
||||
width: 70,
|
||||
});
|
||||
}
|
||||
|
||||
cols.push(
|
||||
{
|
||||
title: t('pages.inbounds.port'),
|
||||
@@ -267,5 +283,5 @@ export function useInboundColumns({
|
||||
);
|
||||
|
||||
return cols;
|
||||
}, [t, hasAnyRemark, hasActiveNode, nodesById, clientCount, subEnable, expireDiff, trafficDiff, datepicker, onRowAction, onSwitchEnable]);
|
||||
}, [t, hasAnyRemark, hasAnySubSortIndex, hasActiveNode, nodesById, clientCount, subEnable, expireDiff, trafficDiff, datepicker, onRowAction, onSwitchEnable]);
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ export const InboundDbFieldsSchema = z.object({
|
||||
nodeId: z.number().int().nullable().optional(),
|
||||
shareAddrStrategy: ShareAddrStrategySchema.default('node'),
|
||||
shareAddr: z.string().default(''),
|
||||
subSortIndex: z.number().int().min(1).default(1),
|
||||
});
|
||||
export type InboundDbFields = z.infer<typeof InboundDbFieldsSchema>;
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ exports[`InboundFormModal > field structure is stable for every protocol > http
|
||||
"Protocol",
|
||||
"Address",
|
||||
"Share address strategy",
|
||||
"Subscription sort order",
|
||||
"Port",
|
||||
"Total Flow",
|
||||
"Traffic Reset",
|
||||
@@ -22,6 +23,7 @@ exports[`InboundFormModal > field structure is stable for every protocol > hyste
|
||||
"Protocol",
|
||||
"Address",
|
||||
"Share address strategy",
|
||||
"Subscription sort order",
|
||||
"Port",
|
||||
"Total Flow",
|
||||
"Traffic Reset",
|
||||
@@ -37,6 +39,7 @@ exports[`InboundFormModal > field structure is stable for every protocol > mixed
|
||||
"Protocol",
|
||||
"Address",
|
||||
"Share address strategy",
|
||||
"Subscription sort order",
|
||||
"Port",
|
||||
"Total Flow",
|
||||
"Traffic Reset",
|
||||
@@ -52,6 +55,7 @@ exports[`InboundFormModal > field structure is stable for every protocol > shado
|
||||
"Protocol",
|
||||
"Address",
|
||||
"Share address strategy",
|
||||
"Subscription sort order",
|
||||
"Port",
|
||||
"Total Flow",
|
||||
"Traffic Reset",
|
||||
@@ -67,6 +71,7 @@ exports[`InboundFormModal > field structure is stable for every protocol > troja
|
||||
"Protocol",
|
||||
"Address",
|
||||
"Share address strategy",
|
||||
"Subscription sort order",
|
||||
"Port",
|
||||
"Total Flow",
|
||||
"Traffic Reset",
|
||||
@@ -82,6 +87,7 @@ exports[`InboundFormModal > field structure is stable for every protocol > tun 1
|
||||
"Protocol",
|
||||
"Address",
|
||||
"Share address strategy",
|
||||
"Subscription sort order",
|
||||
"Port",
|
||||
"Total Flow",
|
||||
"Traffic Reset",
|
||||
@@ -97,6 +103,7 @@ exports[`InboundFormModal > field structure is stable for every protocol > tunne
|
||||
"Protocol",
|
||||
"Address",
|
||||
"Share address strategy",
|
||||
"Subscription sort order",
|
||||
"Port",
|
||||
"Total Flow",
|
||||
"Traffic Reset",
|
||||
@@ -112,6 +119,7 @@ exports[`InboundFormModal > field structure is stable for every protocol > vless
|
||||
"Protocol",
|
||||
"Address",
|
||||
"Share address strategy",
|
||||
"Subscription sort order",
|
||||
"Port",
|
||||
"Total Flow",
|
||||
"Traffic Reset",
|
||||
@@ -127,6 +135,7 @@ exports[`InboundFormModal > field structure is stable for every protocol > vmess
|
||||
"Protocol",
|
||||
"Address",
|
||||
"Share address strategy",
|
||||
"Subscription sort order",
|
||||
"Port",
|
||||
"Total Flow",
|
||||
"Traffic Reset",
|
||||
@@ -142,6 +151,7 @@ exports[`InboundFormModal > field structure is stable for every protocol > wireg
|
||||
"Protocol",
|
||||
"Address",
|
||||
"Share address strategy",
|
||||
"Subscription sort order",
|
||||
"Port",
|
||||
"Total Flow",
|
||||
"Traffic Reset",
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
formValuesToWirePayload,
|
||||
type RawInboundRow,
|
||||
} from '@/lib/xray/inbound-form-adapter';
|
||||
import { InboundFormSchema } from '@/schemas/forms/inbound-form';
|
||||
import { InboundDbFieldsSchema, InboundFormSchema } from '@/schemas/forms/inbound-form';
|
||||
import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt';
|
||||
|
||||
// Round-trip: raw DB row → InboundFormValues → wire payload, asserting
|
||||
@@ -262,3 +262,35 @@ describe('formValuesToWirePayload', () => {
|
||||
expect(replay.streamSettings).toEqual(original.streamSettings);
|
||||
});
|
||||
});
|
||||
|
||||
describe('subSortIndex', () => {
|
||||
it('rawInboundToFormValues defaults to 1 when field is absent', () => {
|
||||
const values = rawInboundToFormValues({ ...vlessRow, subSortIndex: undefined });
|
||||
expect(values.subSortIndex).toBe(1);
|
||||
});
|
||||
|
||||
it('rawInboundToFormValues preserves valid values and clamps below-minimum ones to 1', () => {
|
||||
expect(rawInboundToFormValues({ ...vlessRow, subSortIndex: 5 }).subSortIndex).toBe(5);
|
||||
expect(rawInboundToFormValues({ ...vlessRow, subSortIndex: 0 }).subSortIndex).toBe(1);
|
||||
expect(rawInboundToFormValues({ ...vlessRow, subSortIndex: -10 }).subSortIndex).toBe(1);
|
||||
});
|
||||
|
||||
it('formValuesToWirePayload includes subSortIndex in the payload', () => {
|
||||
const values = rawInboundToFormValues({ ...vlessRow, subSortIndex: 3 });
|
||||
const payload = formValuesToWirePayload(values);
|
||||
expect(payload.subSortIndex).toBe(3);
|
||||
});
|
||||
|
||||
it('subSortIndex round-trips through raw → values → payload', () => {
|
||||
const values = rawInboundToFormValues({ ...vlessRow, subSortIndex: 42 });
|
||||
const payload = formValuesToWirePayload(values);
|
||||
const replay = rawInboundToFormValues({ ...vlessRow, subSortIndex: payload.subSortIndex });
|
||||
expect(replay.subSortIndex).toBe(42);
|
||||
});
|
||||
|
||||
it('InboundDbFieldsSchema enforces an integer minimum of 1 and defaults to 1', () => {
|
||||
expect(InboundDbFieldsSchema.partial().safeParse({ subSortIndex: 1.5 }).success).toBe(false);
|
||||
expect(InboundDbFieldsSchema.partial().safeParse({ subSortIndex: 0 }).success).toBe(false);
|
||||
expect(InboundDbFieldsSchema.parse({}).subSortIndex).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -91,6 +91,9 @@ func initModels() error {
|
||||
if err := pruneOrphanedClientInbounds(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := normalizeInboundSubSortIndex(); err != nil {
|
||||
return err
|
||||
}
|
||||
if IsPostgres() {
|
||||
if err := resyncPostgresSequences(db, models); err != nil {
|
||||
log.Printf("Error resyncing postgres sequences: %v", err)
|
||||
@@ -123,6 +126,21 @@ func pruneOrphanedClientInbounds() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeInboundSubSortIndex lifts sub_sort_index values below the 1-based
|
||||
// minimum (rows written by builds that defaulted the column to 0, or by nodes
|
||||
// predating the field) so they cannot sort ahead of explicitly ranked inbounds.
|
||||
func normalizeInboundSubSortIndex() error {
|
||||
res := db.Exec("UPDATE inbounds SET sub_sort_index = 1 WHERE sub_sort_index < 1")
|
||||
if res.Error != nil {
|
||||
log.Printf("Error normalizing inbound sub_sort_index: %v", res.Error)
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected > 0 {
|
||||
log.Printf("Normalized sub_sort_index on %d inbound(s)", res.RowsAffected)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isIgnorableDuplicateColumnErr(err error, mdl any) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
|
||||
@@ -50,6 +50,7 @@ type Inbound struct {
|
||||
Down int64 `json:"down" form:"down"` // Download traffic in bytes
|
||||
Total int64 `json:"total" form:"total"` // Total traffic limit in bytes
|
||||
Remark string `json:"remark" form:"remark" example:"VLESS-443"` // Human-readable remark
|
||||
SubSortIndex int `json:"subSortIndex" form:"subSortIndex" gorm:"default:1" validate:"omitempty,gte=1" example:"1"` // 1-based sort order of this inbound's links in subscription output only (lower first; ties by id)
|
||||
Enable bool `json:"enable" form:"enable" gorm:"index:idx_enable_traffic_reset,priority:1" example:"true"` // Whether the inbound is enabled
|
||||
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
|
||||
TrafficReset string `json:"trafficReset" form:"trafficReset" gorm:"default:never;index:idx_enable_traffic_reset,priority:2" validate:"omitempty,oneof=never hourly daily weekly monthly"` // Traffic reset schedule
|
||||
|
||||
@@ -284,7 +284,7 @@ func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error)
|
||||
WHERE
|
||||
inbounds.protocol in ('vmess','vless','trojan','shadowsocks','hysteria')
|
||||
AND clients.sub_id = ? AND inbounds.enable = ?
|
||||
)`, subId, true).Find(&inbounds).Error
|
||||
)`, subId, true).Order("sub_sort_index ASC").Order("id ASC").Find(&inbounds).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package sub
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
// TestGetSubs_OrdersBySubSortIndexThenId verifies that subscription output
|
||||
// lists inbound links ordered by sub_sort_index ASC, breaking ties by id ASC.
|
||||
// The same query feeds the raw body, the HTML sub page, and the JSON/Clash
|
||||
// formats, so asserting on GetSubs covers all of them.
|
||||
func TestGetSubs_OrdersBySubSortIndexThenId(t *testing.T) {
|
||||
dbDir := t.TempDir()
|
||||
t.Setenv("XUI_DB_FOLDER", dbDir)
|
||||
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
|
||||
t.Fatalf("InitDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
|
||||
const subId = "sub-sort"
|
||||
db := database.GetDB()
|
||||
|
||||
seed := []struct {
|
||||
tag string
|
||||
port int
|
||||
subSortIndex int
|
||||
email string
|
||||
uuid string
|
||||
}{
|
||||
// Created in this order on purpose: without the ORDER BY the links
|
||||
// would come out s3, s1, s2a, s2b (creation order).
|
||||
{"sort-3", 42101, 3, "s3@example.com", "0d68a695-4be1-4d92-a9c3-8c0f1c2cf001"},
|
||||
{"sort-1", 42102, 1, "s1@example.com", "0d68a695-4be1-4d92-a9c3-8c0f1c2cf002"},
|
||||
{"sort-2a", 42103, 2, "s2a@example.com", "0d68a695-4be1-4d92-a9c3-8c0f1c2cf003"},
|
||||
{"sort-2b", 42104, 2, "s2b@example.com", "0d68a695-4be1-4d92-a9c3-8c0f1c2cf004"},
|
||||
}
|
||||
for _, s := range seed {
|
||||
settings := fmt.Sprintf(`{"clients": [{"id": %q, "email": %q, "subId": %q, "enable": true}]}`, s.uuid, s.email, subId)
|
||||
ib := &model.Inbound{
|
||||
UserId: 1,
|
||||
Tag: s.tag,
|
||||
Enable: true,
|
||||
Port: s.port,
|
||||
Protocol: model.VLESS,
|
||||
Settings: settings,
|
||||
StreamSettings: `{"network": "tcp", "security": "none"}`,
|
||||
SubSortIndex: s.subSortIndex,
|
||||
}
|
||||
if err := db.Create(ib).Error; err != nil {
|
||||
t.Fatalf("seed inbound %s: %v", s.tag, err)
|
||||
}
|
||||
client := &model.ClientRecord{Email: s.email, SubID: subId, UUID: s.uuid, Enable: true}
|
||||
if err := db.Create(client).Error; err != nil {
|
||||
t.Fatalf("seed client %s: %v", s.email, err)
|
||||
}
|
||||
if err := db.Create(&model.ClientInbound{ClientId: client.Id, InboundId: ib.Id}).Error; err != nil {
|
||||
t.Fatalf("seed client_inbound %s: %v", s.email, err)
|
||||
}
|
||||
}
|
||||
|
||||
s := NewSubService(false, "-ieo")
|
||||
links, emails, _, _, err := s.GetSubs(subId, "sub.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetSubs: %v", err)
|
||||
}
|
||||
if len(links) != len(seed) {
|
||||
t.Fatalf("links = %d, want %d", len(links), len(seed))
|
||||
}
|
||||
want := []string{"s1@example.com", "s2a@example.com", "s2b@example.com", "s3@example.com"}
|
||||
for i, email := range want {
|
||||
if emails[i] != email {
|
||||
t.Fatalf("emails order = %v, want %v (sub_sort_index ASC, id ASC)", emails, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -490,6 +490,7 @@ func wireInbound(ib *model.Inbound) url.Values {
|
||||
v := url.Values{}
|
||||
v.Set("total", strconv.FormatInt(ib.Total, 10))
|
||||
v.Set("remark", ib.Remark)
|
||||
v.Set("subSortIndex", strconv.Itoa(ib.SubSortIndex))
|
||||
v.Set("enable", strconv.FormatBool(ib.Enable))
|
||||
v.Set("expiryTime", strconv.FormatInt(ib.ExpiryTime, 10))
|
||||
v.Set("listen", ib.Listen)
|
||||
|
||||
@@ -458,6 +458,7 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
|
||||
// Normalize streamSettings based on protocol
|
||||
s.normalizeStreamSettings(inbound)
|
||||
s.normalizeMtprotoSecret(inbound)
|
||||
inbound.SubSortIndex = normalizeSubSortIndex(inbound.SubSortIndex)
|
||||
if err := normalizeInboundShareAddressStrict(inbound); err != nil {
|
||||
return inbound, false, err
|
||||
}
|
||||
@@ -786,6 +787,7 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
|
||||
// Normalize streamSettings based on protocol
|
||||
s.normalizeStreamSettings(inbound)
|
||||
s.normalizeMtprotoSecret(inbound)
|
||||
inbound.SubSortIndex = normalizeSubSortIndex(inbound.SubSortIndex)
|
||||
|
||||
conflict, err := s.checkPortConflict(inbound, inbound.Id)
|
||||
if err != nil {
|
||||
@@ -888,6 +890,7 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
|
||||
|
||||
oldInbound.Total = inbound.Total
|
||||
oldInbound.Remark = inbound.Remark
|
||||
oldInbound.SubSortIndex = inbound.SubSortIndex
|
||||
oldInbound.Enable = inbound.Enable
|
||||
oldInbound.ExpiryTime = inbound.ExpiryTime
|
||||
oldInbound.TrafficReset = inbound.TrafficReset
|
||||
|
||||
@@ -358,6 +358,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||
LastTrafficResetTime: snapIb.LastTrafficResetTime,
|
||||
Enable: snapIb.Enable,
|
||||
Remark: snapIb.Remark,
|
||||
SubSortIndex: normalizeSubSortIndex(snapIb.SubSortIndex),
|
||||
Total: snapIb.Total,
|
||||
ExpiryTime: snapIb.ExpiryTime,
|
||||
Up: snapIb.Up,
|
||||
@@ -382,6 +383,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||
if !dirty {
|
||||
updates["enable"] = snapIb.Enable
|
||||
updates["remark"] = snapIb.Remark
|
||||
updates["sub_sort_index"] = normalizeSubSortIndex(snapIb.SubSortIndex)
|
||||
updates["listen"] = snapIb.Listen
|
||||
updates["port"] = snapIb.Port
|
||||
updates["protocol"] = snapIb.Protocol
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
func makeInboundWithSubSortIndex(tag string, port int, subSortIndex int) *model.Inbound {
|
||||
return &model.Inbound{
|
||||
UserId: 1,
|
||||
Tag: tag,
|
||||
Enable: true,
|
||||
Listen: "0.0.0.0",
|
||||
Port: port,
|
||||
Protocol: model.VLESS,
|
||||
StreamSettings: `{"network":"tcp"}`,
|
||||
Settings: `{"clients":[]}`,
|
||||
SubSortIndex: subSortIndex,
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateInbound_PersistsSubSortIndex verifies that UpdateInbound copies
|
||||
// SubSortIndex from the incoming update payload to the persisted row.
|
||||
func TestUpdateInbound_PersistsSubSortIndex(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
|
||||
ib := makeInboundWithSubSortIndex("in-7001-tcp", 7001, 1)
|
||||
if err := database.GetDB().Create(ib).Error; err != nil {
|
||||
t.Fatalf("create inbound: %v", err)
|
||||
}
|
||||
|
||||
update := *ib
|
||||
update.SubSortIndex = 7
|
||||
|
||||
svc := &InboundService{}
|
||||
got, _, err := svc.UpdateInbound(&update)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateInbound: %v", err)
|
||||
}
|
||||
if got.SubSortIndex != 7 {
|
||||
t.Fatalf("returned SubSortIndex = %d, want 7", got.SubSortIndex)
|
||||
}
|
||||
|
||||
var reloaded model.Inbound
|
||||
if err := database.GetDB().First(&reloaded, ib.Id).Error; err != nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
if reloaded.SubSortIndex != 7 {
|
||||
t.Fatalf("persisted SubSortIndex = %d, want 7", reloaded.SubSortIndex)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateInbound_SubSortIndexClampedToMinimum verifies that values below
|
||||
// the 1-based minimum (0 from clients that predate the field, or negatives)
|
||||
// are clamped to 1 instead of being stored.
|
||||
func TestUpdateInbound_SubSortIndexClampedToMinimum(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
|
||||
ib := makeInboundWithSubSortIndex("in-7002-tcp", 7002, 5)
|
||||
if err := database.GetDB().Create(ib).Error; err != nil {
|
||||
t.Fatalf("create inbound: %v", err)
|
||||
}
|
||||
|
||||
svc := &InboundService{}
|
||||
for _, below := range []int{0, -3} {
|
||||
update := *ib
|
||||
update.SubSortIndex = below
|
||||
|
||||
got, _, err := svc.UpdateInbound(&update)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateInbound(%d): %v", below, err)
|
||||
}
|
||||
if got.SubSortIndex != 1 {
|
||||
t.Fatalf("returned SubSortIndex = %d for input %d, want 1", got.SubSortIndex, below)
|
||||
}
|
||||
|
||||
var reloaded model.Inbound
|
||||
if err := database.GetDB().First(&reloaded, ib.Id).Error; err != nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
if reloaded.SubSortIndex != 1 {
|
||||
t.Fatalf("persisted SubSortIndex = %d for input %d, want 1", reloaded.SubSortIndex, below)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddInbound_SubSortIndexClampedToMinimum verifies the same clamping on
|
||||
// the create path (an omitted form field binds to 0).
|
||||
func TestAddInbound_SubSortIndexClampedToMinimum(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
|
||||
svc := &InboundService{}
|
||||
ib := makeInboundWithSubSortIndex("in-7003-tcp", 7003, 0)
|
||||
got, _, err := svc.AddInbound(ib)
|
||||
if err != nil {
|
||||
t.Fatalf("AddInbound: %v", err)
|
||||
}
|
||||
if got.SubSortIndex != 1 {
|
||||
t.Fatalf("returned SubSortIndex = %d, want 1", got.SubSortIndex)
|
||||
}
|
||||
|
||||
var reloaded model.Inbound
|
||||
if err := database.GetDB().First(&reloaded, got.Id).Error; err != nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
if reloaded.SubSortIndex != 1 {
|
||||
t.Fatalf("persisted SubSortIndex = %d, want 1", reloaded.SubSortIndex)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,16 @@ package service
|
||||
// installs (>32k clients) where even modern SQLite would refuse a single IN.
|
||||
const sqliteMaxVars = 900
|
||||
|
||||
// normalizeSubSortIndex clamps the 1-based subscription sort order. Values
|
||||
// below 1 arrive from clients that predate the field (omitted form key binds
|
||||
// to 0) and must not sort ahead of explicitly ranked inbounds.
|
||||
func normalizeSubSortIndex(v int) int {
|
||||
if v < 1 {
|
||||
return 1
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// uniqueNonEmptyStrings returns a deduplicated copy of in with empty strings
|
||||
// removed, preserving the order of first occurrence.
|
||||
func uniqueNonEmptyStrings(in []string) []string {
|
||||
|
||||
@@ -416,6 +416,7 @@
|
||||
},
|
||||
"telegramDesc": "ادخل ID شات Telegram. (استخدم '/id' في البوت) أو ({'@'}userinfobot)",
|
||||
"subscriptionDesc": "عشان تلاقي رابط الاشتراك، ادخل على 'التفاصيل'. وكمان ممكن تستخدم نفس الاسم لعدة عملاء.",
|
||||
"subSortIndex": "ترتيب الاشتراك",
|
||||
"same": "نفسه",
|
||||
"inboundInfo": "معلومات الإدخال",
|
||||
"exportInbound": "تصدير الإدخال",
|
||||
@@ -595,6 +596,8 @@
|
||||
"shareAddrStrategyHelp": "تحدد العنوان الذي يُكتب في روابط المشاركة المصدّرة ورموز QR ومخرجات الاشتراك.",
|
||||
"shareAddr": "عنوان مشاركة مخصص",
|
||||
"shareAddrHelp": "يُستخدم فقط عندما تكون استراتيجية عنوان المشاركة مخصصة. أدخل اسم مضيف أو عنوان IP بدون بروتوكول أو منفذ.",
|
||||
"subSortIndex": "ترتيب الروابط في الاشتراك",
|
||||
"subSortIndexHelp": "موضع روابط هذا الوارد في مخرجات الاشتراك (صفحة الاشتراك وتطبيقات العملاء). القيم الأقل تظهر أولاً، والقيم المتساوية تحافظ على ترتيب الإنشاء. لا يؤثر على قائمة الواردات في اللوحة.",
|
||||
"shareAddrStrategyOptions": {
|
||||
"node": "عنوان العقدة",
|
||||
"listen": "عنوان استماع الوارد",
|
||||
|
||||
@@ -416,6 +416,7 @@
|
||||
},
|
||||
"telegramDesc": "Please provide Telegram Chat ID. (use '/id' command in the bot) or ({'@'}userinfobot)",
|
||||
"subscriptionDesc": "To find your subscription URL, navigate to the 'Details'. Additionally, you can use the same name for several clients.",
|
||||
"subSortIndex": "Sub order",
|
||||
"same": "Same",
|
||||
"inboundInfo": "Inbound Information",
|
||||
"exportInbound": "Export Inbound",
|
||||
@@ -596,6 +597,8 @@
|
||||
"shareAddrStrategyHelp": "Controls which address is written into exported share links, QR codes, and subscription output.",
|
||||
"shareAddr": "Custom share address",
|
||||
"shareAddrHelp": "Used only when the share address strategy is Custom. Enter a host or IP without a scheme or port.",
|
||||
"subSortIndex": "Subscription sort order",
|
||||
"subSortIndexHelp": "Position of this inbound's links in subscription output (sub page and client apps). Lower values come first; equal values keep creation order. Does not affect the panel inbound list.",
|
||||
"shareAddrStrategyOptions": {
|
||||
"node": "Node address",
|
||||
"listen": "Inbound listen",
|
||||
|
||||
@@ -416,6 +416,7 @@
|
||||
},
|
||||
"telegramDesc": "Por favor, proporciona el ID de Chat de Telegram. (usa el comando '/id' en el bot) o ({'@'}userinfobot)",
|
||||
"subscriptionDesc": "Puedes encontrar tu enlace de suscripción en Detalles, también puedes usar el mismo nombre para varias configuraciones.",
|
||||
"subSortIndex": "Orden sub",
|
||||
"same": "misma",
|
||||
"inboundInfo": "Información de entrada",
|
||||
"exportInbound": "Exportación entrante",
|
||||
@@ -595,6 +596,8 @@
|
||||
"shareAddrStrategyHelp": "Controla qué dirección se escribe en los enlaces compartidos exportados, códigos QR y la salida de suscripción.",
|
||||
"shareAddr": "Dirección compartida personalizada",
|
||||
"shareAddrHelp": "Solo se usa cuando la estrategia de dirección para compartir es Personalizada. Introduce un host o IP sin esquema ni puerto.",
|
||||
"subSortIndex": "Orden en la suscripción",
|
||||
"subSortIndexHelp": "Posición de los enlaces de esta entrada en la salida de la suscripción (página de suscripción y apps cliente). Los valores más bajos van primero; con valores iguales se mantiene el orden de creación. No afecta a la lista de entradas del panel.",
|
||||
"shareAddrStrategyOptions": {
|
||||
"node": "Dirección del nodo",
|
||||
"listen": "Dirección de escucha del inbound",
|
||||
|
||||
@@ -416,6 +416,7 @@
|
||||
},
|
||||
"telegramDesc": "لطفا شناسه گفتگوی تلگرام را وارد کنید. (از دستور '/id' در ربات استفاده کنید) یا ({'@'}userinfobot)",
|
||||
"subscriptionDesc": "شما میتوانید لینک سابسکربپشن خودرا در 'جزئیات' پیدا کنید، همچنین میتوانید از همین نام برای چندین کاربر استفادهکنید",
|
||||
"subSortIndex": "ترتیب اشتراک",
|
||||
"same": "همسان",
|
||||
"inboundInfo": "اطلاعات ورودی",
|
||||
"exportInbound": "استخراج ورودی",
|
||||
@@ -595,6 +596,8 @@
|
||||
"shareAddrStrategyHelp": "مشخص میکند کدام آدرس در لینکهای اشتراکگذاری خروجی، کدهای QR و خروجی اشتراک نوشته شود.",
|
||||
"shareAddr": "آدرس اشتراکگذاری سفارشی",
|
||||
"shareAddrHelp": "فقط زمانی استفاده میشود که راهبرد آدرس اشتراکگذاری روی سفارشی باشد. میزبان یا IP را بدون طرح و پورت وارد کنید.",
|
||||
"subSortIndex": "ترتیب در اشتراک",
|
||||
"subSortIndexHelp": "جایگاه لینکهای این ورودی در خروجی اشتراک (صفحه اشتراک و برنامههای کلاینت). مقدار کمتر اول میآید و مقدارهای برابر ترتیب ایجاد را حفظ میکنند. روی فهرست ورودیهای پنل تأثیری ندارد.",
|
||||
"shareAddrStrategyOptions": {
|
||||
"node": "آدرس نود",
|
||||
"listen": "آدرس شنود ورودی",
|
||||
|
||||
@@ -416,6 +416,7 @@
|
||||
},
|
||||
"telegramDesc": "Harap berikan ID Obrolan Telegram. (gunakan perintah '/id' di bot) atau ({'@'}userinfobot)",
|
||||
"subscriptionDesc": "Untuk menemukan URL langganan Anda, buka 'Rincian'. Selain itu, Anda dapat menggunakan nama yang sama untuk beberapa klien.",
|
||||
"subSortIndex": "Urutan sub",
|
||||
"same": "Sama",
|
||||
"inboundInfo": "Informasi Inbound",
|
||||
"exportInbound": "Ekspor Masuk",
|
||||
@@ -595,6 +596,8 @@
|
||||
"shareAddrStrategyHelp": "Menentukan alamat yang ditulis ke tautan berbagi yang diekspor, kode QR, dan keluaran langganan.",
|
||||
"shareAddr": "Alamat berbagi kustom",
|
||||
"shareAddrHelp": "Hanya digunakan saat strategi alamat berbagi adalah Kustom. Masukkan host atau IP tanpa skema atau port.",
|
||||
"subSortIndex": "Urutan dalam langganan",
|
||||
"subSortIndexHelp": "Posisi tautan inbound ini dalam keluaran langganan (halaman langganan dan aplikasi klien). Nilai lebih kecil tampil lebih dulu; nilai sama mempertahankan urutan pembuatan. Tidak memengaruhi daftar inbound di panel.",
|
||||
"shareAddrStrategyOptions": {
|
||||
"node": "Alamat node",
|
||||
"listen": "Alamat listen inbound",
|
||||
|
||||
@@ -416,6 +416,7 @@
|
||||
},
|
||||
"telegramDesc": "TelegramチャットIDを提供してください。(ボットで'/id'コマンドを使用)または({'@'}userinfobot)",
|
||||
"subscriptionDesc": "サブスクリプションURLを見つけるには、“詳細情報”に移動してください。また、複数のクライアントに同じ名前を使用することができます。",
|
||||
"subSortIndex": "サブ並び順",
|
||||
"same": "同じ",
|
||||
"inboundInfo": "インバウンド情報",
|
||||
"exportInbound": "インバウンドルールをエクスポート",
|
||||
@@ -595,6 +596,8 @@
|
||||
"shareAddrStrategyHelp": "エクスポートされる共有リンク、QRコード、サブスクリプション出力に書き込むアドレスを制御します。",
|
||||
"shareAddr": "カスタム共有アドレス",
|
||||
"shareAddrHelp": "共有アドレス戦略がカスタムの場合のみ使用されます。スキームやポートを含めずにホスト名またはIPを入力してください。",
|
||||
"subSortIndex": "サブスクリプションでの並び順",
|
||||
"subSortIndexHelp": "サブスクリプション出力(サブスクリプションページおよびクライアントアプリ)におけるこのインバウンドのリンクの位置。値が小さいほど先頭に表示され、同じ値の場合は作成順が維持されます。パネルのインバウンド一覧には影響しません。",
|
||||
"shareAddrStrategyOptions": {
|
||||
"node": "ノードアドレス",
|
||||
"listen": "インバウンドのリッスンアドレス",
|
||||
|
||||
@@ -416,6 +416,7 @@
|
||||
},
|
||||
"telegramDesc": "Por favor, forneça o ID do Chat do Telegram. (use o comando '/id' no bot) ou ({'@'}userinfobot)",
|
||||
"subscriptionDesc": "Para encontrar seu URL de assinatura, navegue até 'Detalhes'. Além disso, você pode usar o mesmo nome para vários clientes.",
|
||||
"subSortIndex": "Ordem sub",
|
||||
"same": "Igual",
|
||||
"inboundInfo": "Informações do Inbound",
|
||||
"exportInbound": "Exportar Inbound",
|
||||
@@ -595,6 +596,8 @@
|
||||
"shareAddrStrategyHelp": "Controla qual endereço é gravado nos links de compartilhamento exportados, códigos QR e na saída de assinatura.",
|
||||
"shareAddr": "Endereço de compartilhamento personalizado",
|
||||
"shareAddrHelp": "Usado apenas quando a estratégia de endereço de compartilhamento é Personalizada. Informe um host ou IP sem esquema nem porta.",
|
||||
"subSortIndex": "Ordem na assinatura",
|
||||
"subSortIndexHelp": "Posição dos links desta entrada na saída da assinatura (página de assinatura e aplicativos cliente). Valores menores vêm primeiro; valores iguais mantêm a ordem de criação. Não afeta a lista de entradas do painel.",
|
||||
"shareAddrStrategyOptions": {
|
||||
"node": "Endereço do nó",
|
||||
"listen": "Endereço de escuta do inbound",
|
||||
|
||||
@@ -416,6 +416,7 @@
|
||||
},
|
||||
"telegramDesc": "Пожалуйста, укажите Chat ID Telegram. (используйте команду '/id' в боте) или ({'@'}userinfobot)",
|
||||
"subscriptionDesc": "Вы можете найти свою ссылку подписки в разделе 'Подробнее'",
|
||||
"subSortIndex": "Порядок",
|
||||
"same": "Тот же",
|
||||
"inboundInfo": "Информация о подключении",
|
||||
"exportInbound": "Экспорт подключений",
|
||||
@@ -595,6 +596,8 @@
|
||||
"shareAddrStrategyHelp": "Определяет, какой адрес записывать в экспортируемые ссылки, QR-коды и выдачу подписки.",
|
||||
"shareAddr": "Пользовательский адрес для ссылок",
|
||||
"shareAddrHelp": "Используется только когда стратегия адреса для ссылок — пользовательская. Укажите хост или IP без схемы и порта.",
|
||||
"subSortIndex": "Порядок в подписке",
|
||||
"subSortIndexHelp": "Позиция ссылок этого входящего в выдаче подписки (страница подписки и клиентские приложения). Меньшие значения идут первыми; при равных значениях сохраняется порядок создания. Не влияет на список входящих в панели.",
|
||||
"shareAddrStrategyOptions": {
|
||||
"node": "Адрес узла",
|
||||
"listen": "Адрес прослушивания inbound",
|
||||
|
||||
@@ -416,6 +416,7 @@
|
||||
},
|
||||
"telegramDesc": "Lütfen Telegram Sohbet Kimliği (Chat ID) sağlayın. ({'@'}userinfobot'tan öğrenebilir veya botta '/id' komutunu kullanabilirsiniz.)",
|
||||
"subscriptionDesc": "Abonelik URL'nizi bulmak için 'Detaylar'a gidin. Aynı adı birden fazla kullanıcı için kullanabilirsiniz.",
|
||||
"subSortIndex": "Sıralama",
|
||||
"same": "Aynı",
|
||||
"inboundInfo": "Gelen Bağlantı Bilgileri",
|
||||
"exportInbound": "Gelen Bağlantını Dışa Aktar",
|
||||
@@ -596,6 +597,8 @@
|
||||
"shareAddrStrategyHelp": "Dışa aktarılan paylaşım bağlantılarına, QR kodlarına ve abonelik çıktısına hangi adresin yazılacağını belirler.",
|
||||
"shareAddr": "Özel paylaşım adresi",
|
||||
"shareAddrHelp": "Yalnızca paylaşım adresi stratejisi Özel olduğunda kullanılır. Şema veya port olmadan bir ana makine ya da IP girin.",
|
||||
"subSortIndex": "Abonelikte sıralama",
|
||||
"subSortIndexHelp": "Bu gelen bağlantının linklerinin abonelik çıktısındaki (abonelik sayfası ve istemci uygulamaları) konumu. Küçük değerler önce gelir; eşit değerlerde oluşturulma sırası korunur. Paneldeki gelen bağlantı listesini etkilemez.",
|
||||
"shareAddrStrategyOptions": {
|
||||
"node": "Düğüm adresi",
|
||||
"listen": "Inbound dinleme adresi",
|
||||
|
||||
@@ -416,6 +416,7 @@
|
||||
},
|
||||
"telegramDesc": "Будь ласка, вкажіть ID чату Telegram. (використовуйте команду '/id' у боті) або ({'@'}userinfobot)",
|
||||
"subscriptionDesc": "Щоб знайти URL-адресу вашої підписки, перейдіть до «Деталі». Крім того, ви можете використовувати одне ім'я для кількох клієнтів.",
|
||||
"subSortIndex": "Порядок",
|
||||
"same": "Те саме",
|
||||
"inboundInfo": "Інформація про підключення",
|
||||
"exportInbound": "Експортувати вхідні",
|
||||
@@ -595,6 +596,8 @@
|
||||
"shareAddrStrategyHelp": "Визначає, яку адресу записувати в експортовані посилання поширення, QR-коди та вивід підписки.",
|
||||
"shareAddr": "Користувацька адреса поширення",
|
||||
"shareAddrHelp": "Використовується лише коли стратегія адреси поширення — користувацька. Введіть хост або IP без схеми та порту.",
|
||||
"subSortIndex": "Порядок у підписці",
|
||||
"subSortIndexHelp": "Позиція посилань цього вхідного у виводі підписки (сторінка підписки та клієнтські застосунки). Менші значення йдуть першими; за однакових значень зберігається порядок створення. Не впливає на список вхідних у панелі.",
|
||||
"shareAddrStrategyOptions": {
|
||||
"node": "Адреса вузла",
|
||||
"listen": "Адреса прослуховування inbound",
|
||||
|
||||
@@ -416,6 +416,7 @@
|
||||
},
|
||||
"telegramDesc": "Vui lòng cung cấp ID Trò chuyện Telegram. (sử dụng lệnh '/id' trong bot) hoặc ({'@'}userinfobot)",
|
||||
"subscriptionDesc": "Bạn có thể tìm liên kết gói đăng ký của mình trong Chi tiết, cũng như bạn có thể sử dụng cùng tên cho nhiều cấu hình khác nhau",
|
||||
"subSortIndex": "Thứ tự sub",
|
||||
"same": "Giống nhau",
|
||||
"inboundInfo": "Thông tin Inbound",
|
||||
"exportInbound": "Xuất nhập khẩu",
|
||||
@@ -595,6 +596,8 @@
|
||||
"shareAddrStrategyHelp": "Kiểm soát địa chỉ được ghi vào liên kết chia sẻ đã xuất, mã QR và nội dung đăng ký.",
|
||||
"shareAddr": "Địa chỉ chia sẻ tùy chỉnh",
|
||||
"shareAddrHelp": "Chỉ dùng khi chiến lược địa chỉ chia sẻ là Tùy chỉnh. Nhập host hoặc IP không kèm giao thức hoặc cổng.",
|
||||
"subSortIndex": "Thứ tự trong gói đăng ký",
|
||||
"subSortIndexHelp": "Vị trí liên kết của inbound này trong nội dung gói đăng ký (trang đăng ký và ứng dụng khách). Giá trị nhỏ hơn xếp trước; giá trị bằng nhau giữ thứ tự tạo. Không ảnh hưởng đến danh sách inbound trong bảng điều khiển.",
|
||||
"shareAddrStrategyOptions": {
|
||||
"node": "Địa chỉ node",
|
||||
"listen": "Địa chỉ listen inbound",
|
||||
|
||||
@@ -416,6 +416,7 @@
|
||||
},
|
||||
"telegramDesc": "请提供Telegram聊天ID。(在机器人中使用'/id'命令)或({'@'}userinfobot",
|
||||
"subscriptionDesc": "要找到你的订阅 URL,请导航到“详细信息”。此外,你可以为多个客户端使用相同的名称。",
|
||||
"subSortIndex": "订阅排序",
|
||||
"same": "相同",
|
||||
"inboundInfo": "入站信息",
|
||||
"exportInbound": "导出入站规则",
|
||||
@@ -595,6 +596,8 @@
|
||||
"shareAddrStrategyHelp": "控制导出分享链接、二维码和订阅输出时写入哪个地址。",
|
||||
"shareAddr": "自定义分享地址",
|
||||
"shareAddrHelp": "仅在分享地址策略为自定义时使用。填写不带协议和端口的域名或 IP。",
|
||||
"subSortIndex": "订阅排序",
|
||||
"subSortIndexHelp": "此入站的链接在订阅输出(订阅页面和客户端应用)中的位置。数值越小越靠前;数值相同时保持创建顺序。不影响面板中的入站列表。",
|
||||
"shareAddrStrategyOptions": {
|
||||
"node": "节点地址",
|
||||
"listen": "入站监听地址",
|
||||
|
||||
@@ -416,6 +416,7 @@
|
||||
},
|
||||
"telegramDesc": "請提供Telegram聊天ID。(在機器人中使用'/id'命令)或({'@'}userinfobot",
|
||||
"subscriptionDesc": "要找到你的訂閱 URL,請導航到“詳細資訊”。此外,你可以為多個客戶端使用相同的名稱。",
|
||||
"subSortIndex": "訂閱排序",
|
||||
"same": "相同",
|
||||
"inboundInfo": "入站資訊",
|
||||
"exportInbound": "匯出入站規則",
|
||||
@@ -595,6 +596,8 @@
|
||||
"shareAddrStrategyHelp": "控制匯出分享連結、QR Code 和訂閱輸出時寫入哪個地址。",
|
||||
"shareAddr": "自訂分享地址",
|
||||
"shareAddrHelp": "僅在分享地址策略為自訂時使用。填寫不帶協定和連接埠的網域或 IP。",
|
||||
"subSortIndex": "訂閱排序",
|
||||
"subSortIndexHelp": "此入站的連結在訂閱輸出(訂閱頁面和客戶端應用)中的位置。數值越小越靠前;數值相同時保持建立順序。不影響面板中的入站清單。",
|
||||
"shareAddrStrategyOptions": {
|
||||
"node": "節點地址",
|
||||
"listen": "入站監聽地址",
|
||||
|
||||
Reference in New Issue
Block a user