mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-16 15:17:14 +00:00
feat(sub): add dummy info node and status configs for subscriptions (#6412)
* feat(settings): add subInfoNodeEnable and status template settings * feat(sub): add dummy info node and status configs for raw links * feat(sub): support dummy info node in clash and json subscriptions * feat(ui): add subscription info node switch and status templates to settings * style: apply gofumpt formatting * fix(sub): address review feedback on subscription info node - Restore GetSubs contract to avoid unintended remark expansions on non-subscription-body calls. - Exclude dummy info node from Clash PROXY select group when active server nodes exist. - Track hasEnabledClient and set traffic.Enable in JSON and Clash paths so status tokens evaluate correctly. - Deterministically sort client emails across subscriptions before selecting primaryEmail. - Consolidate duplicated info-node evaluation logic into resolveInfoNodeRemark helper. - Remove redundant pure-getter test from setting_sub_info_node_test.go. Co-Authored-By: Claude Code <noreply@anthropic.com> --------- Co-authored-by: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -58,9 +58,11 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"subEnable": false,
|
||||
"subEnableRouting": false,
|
||||
"subEncrypt": false,
|
||||
"subExpiredTemplate": "",
|
||||
"subHideSettings": false,
|
||||
"subIncyEnableRouting": false,
|
||||
"subIncyRoutingRules": "",
|
||||
"subInfoNodeEnable": false,
|
||||
"subJsonAlwaysArray": false,
|
||||
"subJsonAutoDetect": false,
|
||||
"subJsonEnable": false,
|
||||
@@ -81,6 +83,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"subSupportUrl": "",
|
||||
"subThemeDir": "",
|
||||
"subTitle": "",
|
||||
"subTrafficDepletedTemplate": "",
|
||||
"subURI": "",
|
||||
"subUpdates": 0,
|
||||
"tgBotAPIServer": "",
|
||||
@@ -172,9 +175,11 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"subEnable": false,
|
||||
"subEnableRouting": false,
|
||||
"subEncrypt": false,
|
||||
"subExpiredTemplate": "",
|
||||
"subHideSettings": false,
|
||||
"subIncyEnableRouting": false,
|
||||
"subIncyRoutingRules": "",
|
||||
"subInfoNodeEnable": false,
|
||||
"subJsonAlwaysArray": false,
|
||||
"subJsonAutoDetect": false,
|
||||
"subJsonEnable": false,
|
||||
@@ -195,6 +200,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"subSupportUrl": "",
|
||||
"subThemeDir": "",
|
||||
"subTitle": "",
|
||||
"subTrafficDepletedTemplate": "",
|
||||
"subURI": "",
|
||||
"subUpdates": 0,
|
||||
"tgBotAPIServer": "",
|
||||
|
||||
@@ -191,6 +191,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subEncrypt": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subExpiredTemplate": {
|
||||
"type": "string"
|
||||
},
|
||||
"subHideSettings": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -200,6 +203,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subIncyRoutingRules": {
|
||||
"type": "string"
|
||||
},
|
||||
"subInfoNodeEnable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subJsonAlwaysArray": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -262,6 +268,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subTitle": {
|
||||
"type": "string"
|
||||
},
|
||||
"subTrafficDepletedTemplate": {
|
||||
"type": "string"
|
||||
},
|
||||
"subURI": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -407,9 +416,11 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subEnable",
|
||||
"subEnableRouting",
|
||||
"subEncrypt",
|
||||
"subExpiredTemplate",
|
||||
"subHideSettings",
|
||||
"subIncyEnableRouting",
|
||||
"subIncyRoutingRules",
|
||||
"subInfoNodeEnable",
|
||||
"subJsonAlwaysArray",
|
||||
"subJsonAutoDetect",
|
||||
"subJsonEnable",
|
||||
@@ -430,6 +441,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subSupportUrl",
|
||||
"subThemeDir",
|
||||
"subTitle",
|
||||
"subTrafficDepletedTemplate",
|
||||
"subURI",
|
||||
"subUpdates",
|
||||
"tgBotAPIServer",
|
||||
@@ -670,6 +682,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subEncrypt": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subExpiredTemplate": {
|
||||
"type": "string"
|
||||
},
|
||||
"subHideSettings": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -679,6 +694,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subIncyRoutingRules": {
|
||||
"type": "string"
|
||||
},
|
||||
"subInfoNodeEnable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subJsonAlwaysArray": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -741,6 +759,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subTitle": {
|
||||
"type": "string"
|
||||
},
|
||||
"subTrafficDepletedTemplate": {
|
||||
"type": "string"
|
||||
},
|
||||
"subURI": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -893,9 +914,11 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subEnable",
|
||||
"subEnableRouting",
|
||||
"subEncrypt",
|
||||
"subExpiredTemplate",
|
||||
"subHideSettings",
|
||||
"subIncyEnableRouting",
|
||||
"subIncyRoutingRules",
|
||||
"subInfoNodeEnable",
|
||||
"subJsonAlwaysArray",
|
||||
"subJsonAutoDetect",
|
||||
"subJsonEnable",
|
||||
@@ -916,6 +939,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subSupportUrl",
|
||||
"subThemeDir",
|
||||
"subTitle",
|
||||
"subTrafficDepletedTemplate",
|
||||
"subURI",
|
||||
"subUpdates",
|
||||
"tgBotAPIServer",
|
||||
|
||||
@@ -66,9 +66,11 @@ export interface AllSetting {
|
||||
subEnable: boolean;
|
||||
subEnableRouting: boolean;
|
||||
subEncrypt: boolean;
|
||||
subExpiredTemplate: string;
|
||||
subHideSettings: boolean;
|
||||
subIncyEnableRouting: boolean;
|
||||
subIncyRoutingRules: string;
|
||||
subInfoNodeEnable: boolean;
|
||||
subJsonAlwaysArray: boolean;
|
||||
subJsonAutoDetect: boolean;
|
||||
subJsonEnable: boolean;
|
||||
@@ -89,6 +91,7 @@ export interface AllSetting {
|
||||
subSupportUrl: string;
|
||||
subThemeDir: string;
|
||||
subTitle: string;
|
||||
subTrafficDepletedTemplate: string;
|
||||
subURI: string;
|
||||
subUpdates: number;
|
||||
tgBotAPIServer: string;
|
||||
@@ -181,9 +184,11 @@ export interface AllSettingView {
|
||||
subEnable: boolean;
|
||||
subEnableRouting: boolean;
|
||||
subEncrypt: boolean;
|
||||
subExpiredTemplate: string;
|
||||
subHideSettings: boolean;
|
||||
subIncyEnableRouting: boolean;
|
||||
subIncyRoutingRules: string;
|
||||
subInfoNodeEnable: boolean;
|
||||
subJsonAlwaysArray: boolean;
|
||||
subJsonAutoDetect: boolean;
|
||||
subJsonEnable: boolean;
|
||||
@@ -204,6 +209,7 @@ export interface AllSettingView {
|
||||
subSupportUrl: string;
|
||||
subThemeDir: string;
|
||||
subTitle: string;
|
||||
subTrafficDepletedTemplate: string;
|
||||
subURI: string;
|
||||
subUpdates: number;
|
||||
tgBotAPIServer: string;
|
||||
|
||||
@@ -82,9 +82,11 @@ export const AllSettingSchema = z.object({
|
||||
subEnable: z.boolean(),
|
||||
subEnableRouting: z.boolean(),
|
||||
subEncrypt: z.boolean(),
|
||||
subExpiredTemplate: z.string(),
|
||||
subHideSettings: z.boolean(),
|
||||
subIncyEnableRouting: z.boolean(),
|
||||
subIncyRoutingRules: z.string(),
|
||||
subInfoNodeEnable: z.boolean(),
|
||||
subJsonAlwaysArray: z.boolean(),
|
||||
subJsonAutoDetect: z.boolean(),
|
||||
subJsonEnable: z.boolean(),
|
||||
@@ -105,6 +107,7 @@ export const AllSettingSchema = z.object({
|
||||
subSupportUrl: z.string(),
|
||||
subThemeDir: z.string(),
|
||||
subTitle: z.string(),
|
||||
subTrafficDepletedTemplate: z.string(),
|
||||
subURI: z.string(),
|
||||
subUpdates: z.number().int().min(0).max(525600),
|
||||
tgBotAPIServer: z.string(),
|
||||
@@ -198,9 +201,11 @@ export const AllSettingViewSchema = z.object({
|
||||
subEnable: z.boolean(),
|
||||
subEnableRouting: z.boolean(),
|
||||
subEncrypt: z.boolean(),
|
||||
subExpiredTemplate: z.string(),
|
||||
subHideSettings: z.boolean(),
|
||||
subIncyEnableRouting: z.boolean(),
|
||||
subIncyRoutingRules: z.string(),
|
||||
subInfoNodeEnable: z.boolean(),
|
||||
subJsonAlwaysArray: z.boolean(),
|
||||
subJsonAutoDetect: z.boolean(),
|
||||
subJsonEnable: z.boolean(),
|
||||
@@ -221,6 +226,7 @@ export const AllSettingViewSchema = z.object({
|
||||
subSupportUrl: z.string(),
|
||||
subThemeDir: z.string(),
|
||||
subTitle: z.string(),
|
||||
subTrafficDepletedTemplate: z.string(),
|
||||
subURI: z.string(),
|
||||
subUpdates: z.number().int().min(0).max(525600),
|
||||
tgBotAPIServer: z.string(),
|
||||
|
||||
@@ -16,6 +16,10 @@ export class AllSetting {
|
||||
trafficDiff = 0;
|
||||
remarkTemplate = '{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D';
|
||||
subShowIdentityOnAllLinks = false;
|
||||
subInfoNodeEnable = false;
|
||||
subExpiredTemplate = '⛔ {{EMAIL}} | Expired: {{EXPIRE_DATE}}';
|
||||
subTrafficDepletedTemplate =
|
||||
'🚫 {{EMAIL}} | Traffic Depleted | {{TRAFFIC_USED}}/{{TRAFFIC_TOTAL}}';
|
||||
datepicker: 'gregorian' | 'jalalian' = 'gregorian';
|
||||
tgBotEnable = false;
|
||||
tgBotToken = '';
|
||||
|
||||
@@ -182,6 +182,41 @@ export default function SubscriptionGeneralTab({
|
||||
/>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.subInfoNodeEnable')}
|
||||
description={t('pages.settings.subInfoNodeEnableDesc')}
|
||||
>
|
||||
<Switch
|
||||
checked={allSetting.subInfoNodeEnable}
|
||||
onChange={(v) => updateSetting({ subInfoNodeEnable: v })}
|
||||
/>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.subExpiredTemplate')}
|
||||
description={t('pages.settings.subExpiredTemplateDesc')}
|
||||
>
|
||||
<RemarkTemplateField
|
||||
value={allSetting.subExpiredTemplate}
|
||||
onChange={(v) => updateSetting({ subExpiredTemplate: v })}
|
||||
maxLength={256}
|
||||
/>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.subTrafficDepletedTemplate')}
|
||||
description={t('pages.settings.subTrafficDepletedTemplateDesc')}
|
||||
>
|
||||
<RemarkTemplateField
|
||||
value={allSetting.subTrafficDepletedTemplate}
|
||||
onChange={(v) => updateSetting({ subTrafficDepletedTemplate: v })}
|
||||
maxLength={256}
|
||||
/>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.subUpdates')}
|
||||
|
||||
@@ -21,6 +21,9 @@ export const AllSettingSchema = z
|
||||
trafficDiff: nonNegativeInt.max(100).optional(),
|
||||
remarkTemplate: z.string().optional(),
|
||||
subShowIdentityOnAllLinks: z.boolean().optional(),
|
||||
subInfoNodeEnable: z.boolean().optional(),
|
||||
subExpiredTemplate: z.string().optional(),
|
||||
subTrafficDepletedTemplate: z.string().optional(),
|
||||
datepicker: z.enum(['gregorian', 'jalalian']).optional(),
|
||||
tgBotEnable: z.boolean().optional(),
|
||||
tgBotToken: z.string().optional(),
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { AllSettingSchema } from '@/schemas/setting';
|
||||
import { AllSetting } from '@/models/setting';
|
||||
|
||||
describe('subInfoNode settings', () => {
|
||||
it('defaults on AllSetting', () => {
|
||||
const s = new AllSetting();
|
||||
expect(s.subInfoNodeEnable).toBe(false);
|
||||
expect(s.subExpiredTemplate).toBe('⛔ {{EMAIL}} | Expired: {{EXPIRE_DATE}}');
|
||||
expect(s.subTrafficDepletedTemplate).toBe(
|
||||
'🚫 {{EMAIL}} | Traffic Depleted | {{TRAFFIC_USED}}/{{TRAFFIC_TOTAL}}',
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts valid values in the settings schema', () => {
|
||||
const r = AllSettingSchema.safeParse({
|
||||
subInfoNodeEnable: true,
|
||||
subExpiredTemplate: 'custom expired',
|
||||
subTrafficDepletedTemplate: 'custom depleted',
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects invalid types', () => {
|
||||
expect(AllSettingSchema.safeParse({ subInfoNodeEnable: 'true' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user