Przeglądaj źródła

feat(clients,inbound): Auto Renew in Bulk Add + cleaner inbound wire payload

Bulk Add now exposes the same Auto Renew (`reset`, days) input as the
single-client form, applied to every client the batch produces. The
field was already on ClientBulkAddFormSchema's siblings; just wire it
into the schema, the empty-form defaults, the UI, and the bulkCreate
payload. Also relabel "Subscription info" to "Subscription ID" by
switching to the canonical pages.clients.subId key and modernise the
SyncOutlined-in-label random affordance on the same row.

On the inbound submit path, two payload-shape cleanups in
dropLegacyOptionalEmpties:
- streamSettings.hysteriaSettings.auth is a holdover slot whose
  real per-client value lives in settings.clients[*].auth; drop the
  field entirely when empty instead of shipping `"auth": ""`.
- finalmask's `tcp` / `udp` arrays were already dropped together when
  both were empty, but a UDP-only setup still emitted a stray
  `"tcp": []`. Drop each sub-array on its own when empty so a
  Hysteria-style "salamander on udp only" config no longer carries
  the empty tcp sibling.
MHSanaei 9 godzin temu
rodzic
commit
f1e433e839

+ 20 - 3
frontend/src/lib/xray/inbound-form-adapter.ts

@@ -227,15 +227,32 @@ export function dropLegacyOptionalEmpties(
   const fb = settings.fallbacks;
   if (Array.isArray(fb) && fb.length === 0) delete settings.fallbacks;
 
-  // StreamSettings emits `finalmask` only when at least one transport
-  // mask exists (legacy `hasFinalMask`). Otherwise drop the whole block.
   if (stream) {
+    // StreamSettings emits `finalmask` only when at least one transport
+    // mask exists (legacy `hasFinalMask`). Drop the whole block when all
+    // sub-fields are empty; otherwise drop only the empty sub-arrays so
+    // the wire payload doesn't carry a stray `"tcp": []` next to a
+    // populated UDP mask list (and vice versa).
     const fm = stream.finalmask as { tcp?: unknown[]; udp?: unknown[]; quicParams?: unknown } | undefined;
     if (fm && typeof fm === 'object') {
       const hasTcp = Array.isArray(fm.tcp) && fm.tcp.length > 0;
       const hasUdp = Array.isArray(fm.udp) && fm.udp.length > 0;
       const hasQuic = fm.quicParams != null;
-      if (!hasTcp && !hasUdp && !hasQuic) delete stream.finalmask;
+      if (!hasTcp && !hasUdp && !hasQuic) {
+        delete stream.finalmask;
+      } else {
+        if (!hasTcp) delete fm.tcp;
+        if (!hasUdp) delete fm.udp;
+      }
+    }
+
+    // Hysteria's per-client auth lives in settings.clients[*].auth; the
+    // streamSettings.hysteriaSettings.auth slot is a holdover from older
+    // hysteria builds and serves no purpose on the inbound side, so an
+    // empty value shouldn't ride along in the JSON payload.
+    const hs = stream.hysteriaSettings as { auth?: string } | undefined;
+    if (hs && typeof hs === 'object' && (hs.auth === '' || hs.auth == null)) {
+      delete hs.auth;
     }
   }
 }

+ 25 - 10
frontend/src/pages/clients/ClientBulkAddModal.tsx

@@ -1,7 +1,7 @@
 import { useEffect, useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
-import { Form, Input, InputNumber, Modal, Select, Switch, message } from 'antd';
-import { SyncOutlined } from '@ant-design/icons';
+import { Button, Form, Input, InputNumber, Modal, Select, Space, Switch, message } from 'antd';
+import { ReloadOutlined } from '@ant-design/icons';
 import dayjs from 'dayjs';
 import type { Dayjs } from 'dayjs';
 
@@ -41,6 +41,7 @@ function emptyForm(): FormState {
     limitIp: 0,
     totalGB: 0,
     expiryTime: 0,
+    reset: 0,
     inboundIds: [],
   };
 }
@@ -154,6 +155,7 @@ export default function ClientBulkAddModal({
           flow: showFlow ? (form.flow || '') : '',
           totalGB: Math.round((form.totalGB || 0) * SizeFormatter.ONE_GB),
           expiryTime: form.expiryTime,
+          reset: Number(form.reset) || 0,
           limitIp: Number(form.limitIp) || 0,
           comment: form.comment,
           enable: true,
@@ -247,16 +249,18 @@ export default function ClientBulkAddModal({
             </Form.Item>
           )}
 
-          <Form.Item label={
-            <>
-              {t('subscription.title')}
-              <SyncOutlined
-                className="random-icon"
+          <Form.Item label={t('pages.clients.subId')}>
+            <Space.Compact style={{ display: 'flex' }}>
+              <Input
+                value={form.subId}
+                onChange={(e) => update('subId', e.target.value)}
+                style={{ flex: 1 }}
+              />
+              <Button
+                icon={<ReloadOutlined />}
                 onClick={() => update('subId', RandomUtil.randomLowerAndNum(16))}
               />
-            </>
-          }>
-            <Input value={form.subId} onChange={(e) => update('subId', e.target.value)} />
+            </Space.Compact>
           </Form.Item>
 
           <Form.Item label={t('comment')}>
@@ -310,6 +314,17 @@ export default function ClientBulkAddModal({
               />
             </Form.Item>
           )}
+
+          <Form.Item
+            label={t('pages.clients.renew')}
+            tooltip={t('pages.clients.renewDesc')}
+          >
+            <InputNumber
+              value={form.reset}
+              min={0}
+              onChange={(v) => update('reset', Number(v) || 0)}
+            />
+          </Form.Item>
         </Form>
       </Modal>
     </>

+ 1 - 0
frontend/src/schemas/client.ts

@@ -142,6 +142,7 @@ export const ClientBulkAddFormSchema = z.object({
   limitIp: z.number().int().min(0),
   totalGB: z.number().min(0),
   expiryTime: z.number(),
+  reset: z.number().int().min(0),
   inboundIds: z.array(z.number()).min(1, 'pages.clients.selectInbound'),
 });