Просмотр исходного кода

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.
MHSanaei 1 день назад
Родитель
Сommit
f1a4286e2f
36 измененных файлов с 367 добавлено и 4 удалено
  1. 8 0
      frontend/public/openapi.json
  2. 1 0
      frontend/src/generated/examples.ts
  3. 7 0
      frontend/src/generated/schemas.ts
  4. 1 0
      frontend/src/generated/types.ts
  5. 1 0
      frontend/src/generated/zod.ts
  6. 4 0
      frontend/src/lib/xray/inbound-form-adapter.ts
  7. 3 0
      frontend/src/models/dbinbound.ts
  8. 8 0
      frontend/src/pages/inbounds/form/InboundFormModal.tsx
  9. 6 0
      frontend/src/pages/inbounds/list/InboundList.tsx
  10. 1 0
      frontend/src/pages/inbounds/list/types.ts
  11. 18 2
      frontend/src/pages/inbounds/list/useInboundColumns.tsx
  12. 1 0
      frontend/src/schemas/forms/inbound-form.ts
  13. 10 0
      frontend/src/test/__snapshots__/inbound-form-modal.test.tsx.snap
  14. 33 1
      frontend/src/test/inbound-form-adapter.test.ts
  15. 18 0
      internal/database/db.go
  16. 1 0
      internal/database/model/model.go
  17. 1 1
      internal/sub/service.go
  18. 79 0
      internal/sub/service_sort_test.go
  19. 1 0
      internal/web/runtime/remote.go
  20. 3 0
      internal/web/service/inbound.go
  21. 2 0
      internal/web/service/inbound_node.go
  22. 111 0
      internal/web/service/inbound_sub_sort_test.go
  23. 10 0
      internal/web/service/inbound_util.go
  24. 3 0
      internal/web/translation/ar-EG.json
  25. 3 0
      internal/web/translation/en-US.json
  26. 3 0
      internal/web/translation/es-ES.json
  27. 3 0
      internal/web/translation/fa-IR.json
  28. 3 0
      internal/web/translation/id-ID.json
  29. 3 0
      internal/web/translation/ja-JP.json
  30. 3 0
      internal/web/translation/pt-BR.json
  31. 3 0
      internal/web/translation/ru-RU.json
  32. 3 0
      internal/web/translation/tr-TR.json
  33. 3 0
      internal/web/translation/uk-UA.json
  34. 3 0
      internal/web/translation/vi-VN.json
  35. 3 0
      internal/web/translation/zh-CN.json
  36. 3 0
      internal/web/translation/zh-TW.json

+ 8 - 0
frontend/public/openapi.json

@@ -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",

+ 1 - 0
frontend/src/generated/examples.ts

@@ -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",

+ 7 - 0
frontend/src/generated/schemas.ts

@@ -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",

+ 1 - 0
frontend/src/generated/types.ts

@@ -293,6 +293,7 @@ export interface Inbound {
   shareAddrStrategy: string;
   sniffing: unknown;
   streamSettings: unknown;
+  subSortIndex: number;
   tag: string;
   total: number;
   trafficReset: string;

+ 1 - 0
frontend/src/generated/zod.ts

@@ -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']),

+ 4 - 0
frontend/src/lib/xray/inbound-form-adapter.ts

@@ -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;

+ 3 - 0
frontend/src/models/dbinbound.ts

@@ -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) {

+ 8 - 0
frontend/src/pages/inbounds/form/InboundFormModal.tsx

@@ -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')}

+ 6 - 0
frontend/src/pages/inbounds/list/InboundList.tsx

@@ -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,

+ 1 - 0
frontend/src/pages/inbounds/list/types.ts

@@ -22,6 +22,7 @@ export interface DBInboundRecord extends ProtocolFlags {
   id: number;
   enable: boolean;
   remark: string;
+  subSortIndex: number;
   port: number;
   protocol: string;
   up: number;

+ 18 - 2
frontend/src/pages/inbounds/list/useInboundColumns.tsx

@@ -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]);
 }

+ 1 - 0
frontend/src/schemas/forms/inbound-form.ts

@@ -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>;
 

+ 10 - 0
frontend/src/test/__snapshots__/inbound-form-modal.test.tsx.snap

@@ -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",

+ 33 - 1
frontend/src/test/inbound-form-adapter.test.ts

@@ -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);
+  });
+});

+ 18 - 0
internal/database/db.go

@@ -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

+ 1 - 0
internal/database/model/model.go

@@ -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

+ 1 - 1
internal/sub/service.go

@@ -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
 	}

+ 79 - 0
internal/sub/service_sort_test.go

@@ -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, "[email protected]", "0d68a695-4be1-4d92-a9c3-8c0f1c2cf001"},
+		{"sort-1", 42102, 1, "[email protected]", "0d68a695-4be1-4d92-a9c3-8c0f1c2cf002"},
+		{"sort-2a", 42103, 2, "[email protected]", "0d68a695-4be1-4d92-a9c3-8c0f1c2cf003"},
+		{"sort-2b", 42104, 2, "[email protected]", "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{"[email protected]", "[email protected]", "[email protected]", "[email protected]"}
+	for i, email := range want {
+		if emails[i] != email {
+			t.Fatalf("emails order = %v, want %v (sub_sort_index ASC, id ASC)", emails, want)
+		}
+	}
+}

+ 1 - 0
internal/web/runtime/remote.go

@@ -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)

+ 3 - 0
internal/web/service/inbound.go

@@ -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

+ 2 - 0
internal/web/service/inbound_node.go

@@ -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

+ 111 - 0
internal/web/service/inbound_sub_sort_test.go

@@ -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)
+	}
+}

+ 10 - 0
internal/web/service/inbound_util.go

@@ -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 {

+ 3 - 0
internal/web/translation/ar-EG.json

@@ -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": "عنوان استماع الوارد",

+ 3 - 0
internal/web/translation/en-US.json

@@ -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",

+ 3 - 0
internal/web/translation/es-ES.json

@@ -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",

+ 3 - 0
internal/web/translation/fa-IR.json

@@ -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": "آدرس شنود ورودی",

+ 3 - 0
internal/web/translation/id-ID.json

@@ -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",

+ 3 - 0
internal/web/translation/ja-JP.json

@@ -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": "インバウンドのリッスンアドレス",

+ 3 - 0
internal/web/translation/pt-BR.json

@@ -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",

+ 3 - 0
internal/web/translation/ru-RU.json

@@ -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",

+ 3 - 0
internal/web/translation/tr-TR.json

@@ -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",

+ 3 - 0
internal/web/translation/uk-UA.json

@@ -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",

+ 3 - 0
internal/web/translation/vi-VN.json

@@ -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",

+ 3 - 0
internal/web/translation/zh-CN.json

@@ -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": "入站监听地址",

+ 3 - 0
internal/web/translation/zh-TW.json

@@ -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": "入站監聽地址",