1
0

8 Ревизии c5b71041d3 ... 867a145979

Автор SHA1 Съобщение Дата
  MHSanaei 867a145979 feat(clients): add inbound filter + mobile page-size control преди 21 часа
  MHSanaei 6185db586a fix(clients): drop tombstone gate that blocked re-import after delete преди 21 часа
  MHSanaei 4c71669815 fix(clients): match by email when client identifier is stale преди 23 часа
  Sanaei c6123f9628 fix(frontend): resolve lazy chunk URLs against runtime base path (#4505) преди 23 часа
  MHSanaei 2ed85aadda v3.1.0 преди 1 ден
  Sanaei b71ed1e3ee feat(bash): prompt for PostgreSQL (#4472) преди 1 ден
  Sanaei 95aebf1d83 i18n: translate hardcoded inbound action + security warning strings (#4502) преди 1 ден
  Sanaei 09df07ddf5 perf(frontend): lazy-load modals + split heavy vendor chunks (#4501) преди 1 ден

+ 1 - 1
config/version

@@ -1 +1 @@
-3.0.2
+3.1.0

+ 20 - 0
frontend/src/components/LazyMount.tsx

@@ -0,0 +1,20 @@
+import { Suspense, useEffect, useState, type ReactNode } from 'react';
+
+interface LazyMountProps {
+  when: boolean;
+  fallback?: ReactNode;
+  children: ReactNode;
+}
+
+// Mounts children only after `when` first becomes true and keeps them mounted
+// thereafter, so React.lazy modals get loaded on demand but their close
+// animations still play out. Pair with `lazy(() => import(...))` modal imports
+// on heavy list pages to keep the initial bundle small.
+export default function LazyMount({ when, fallback = null, children }: LazyMountProps) {
+  const [mounted, setMounted] = useState(when);
+  useEffect(() => {
+    if (when && !mounted) setMounted(true);
+  }, [when, mounted]);
+  if (!mounted) return null;
+  return <Suspense fallback={fallback}>{children}</Suspense>;
+}

+ 3 - 0
frontend/src/hooks/useClients.ts

@@ -60,6 +60,7 @@ export interface ClientQueryParams {
   search?: string;
   filter?: string;
   protocol?: string;
+  inbound?: number;
   sort?: string;
   order?: 'ascend' | 'descend';
 }
@@ -107,6 +108,7 @@ export function useClients() {
         && (prev.search ?? '') === (next.search ?? '')
         && (prev.filter ?? '') === (next.filter ?? '')
         && (prev.protocol ?? '') === (next.protocol ?? '')
+        && (prev.inbound ?? 0) === (next.inbound ?? 0)
         && (prev.sort ?? '') === (next.sort ?? '')
         && (prev.order ?? '') === (next.order ?? '')
       ) return prev;
@@ -136,6 +138,7 @@ export function useClients() {
     if (p.search) sp.set('search', p.search);
     if (p.filter) sp.set('filter', p.filter);
     if (p.protocol) sp.set('protocol', p.protocol);
+    if (p.inbound && p.inbound > 0) sp.set('inbound', String(p.inbound));
     if (p.sort) sp.set('sort', p.sort);
     if (p.order) sp.set('order', p.order);
     return sp.toString();

+ 12 - 0
frontend/src/pages/clients/ClientsPage.css

@@ -145,6 +145,18 @@
   padding: 4px 4px 8px;
 }
 
+.card-pagination {
+  display: flex;
+  justify-content: center;
+  flex-wrap: wrap;
+  padding: 4px 0 8px;
+}
+
+.card-pagination .ant-pagination-options-size-changer,
+.card-pagination .ant-pagination-options-size-changer .ant-select-selector {
+  min-width: 88px !important;
+}
+
 .bulk-count {
   font-size: 12px;
   background: rgba(22, 119, 255, 0.12);

+ 116 - 57
frontend/src/pages/clients/ClientsPage.tsx

@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useMemo, useState } from 'react';
+import { lazy, useCallback, useEffect, useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import {
   Badge,
@@ -11,6 +11,7 @@ import {
   Input,
   Layout,
   Modal,
+  Pagination,
   Popover,
   Radio,
   Row,
@@ -51,11 +52,12 @@ import AppSidebar from '@/components/AppSidebar';
 import CustomStatistic from '@/components/CustomStatistic';
 import { IntlUtil, SizeFormatter } from '@/utils';
 import { setMessageInstance } from '@/utils/messageBus';
-import ClientFormModal from './ClientFormModal';
-import ClientInfoModal from './ClientInfoModal';
-import ClientQrModal from './ClientQrModal';
-import ClientBulkAddModal from './ClientBulkAddModal';
-import ClientBulkAdjustModal from './ClientBulkAdjustModal';
+import LazyMount from '@/components/LazyMount';
+const ClientFormModal = lazy(() => import('./ClientFormModal'));
+const ClientInfoModal = lazy(() => import('./ClientInfoModal'));
+const ClientQrModal = lazy(() => import('./ClientQrModal'));
+const ClientBulkAddModal = lazy(() => import('./ClientBulkAddModal'));
+const ClientBulkAdjustModal = lazy(() => import('./ClientBulkAdjustModal'));
 import '@/styles/page-cards.css';
 import './ClientsPage.css';
 
@@ -70,19 +72,22 @@ interface FilterState {
   searchKey: string;
   filterBy: string;
   protocolFilter?: string;
+  inboundFilter?: number;
 }
 
 function readFilterState(): FilterState {
   try {
     const raw = JSON.parse(localStorage.getItem(FILTER_STATE_KEY) || '{}');
+    const inb = typeof raw.inboundFilter === 'number' && raw.inboundFilter > 0 ? raw.inboundFilter : undefined;
     return {
       enableFilter: !!raw.enableFilter,
       searchKey: raw.searchKey || '',
       filterBy: raw.filterBy || '',
       protocolFilter: raw.protocolFilter,
+      inboundFilter: inb,
     };
   } catch {
-    return { enableFilter: false, searchKey: '', filterBy: '', protocolFilter: undefined };
+    return { enableFilter: false, searchKey: '', filterBy: '', protocolFilter: undefined, inboundFilter: undefined };
   }
 }
 
@@ -131,6 +136,7 @@ export default function ClientsPage() {
   const [searchKey, setSearchKey] = useState(initial.searchKey);
   const [filterBy, setFilterBy] = useState(initial.filterBy);
   const [protocolFilter, setProtocolFilter] = useState<string | undefined>(initial.protocolFilter);
+  const [inboundFilter, setInboundFilter] = useState<number | undefined>(initial.inboundFilter);
 
   const [sortColumn, setSortColumn] = useState<string | null>(null);
   const [sortOrder, setSortOrder] = useState<'ascend' | 'descend' | null>(null);
@@ -142,9 +148,9 @@ export default function ClientsPage() {
 
   useEffect(() => {
     localStorage.setItem(FILTER_STATE_KEY, JSON.stringify({
-      enableFilter, searchKey, filterBy, protocolFilter,
+      enableFilter, searchKey, filterBy, protocolFilter, inboundFilter,
     }));
-  }, [enableFilter, searchKey, filterBy, protocolFilter]);
+  }, [enableFilter, searchKey, filterBy, protocolFilter, inboundFilter]);
 
   useEffect(() => {
     const handle = window.setTimeout(() => setDebouncedSearch(searchKey), 300);
@@ -155,7 +161,7 @@ export default function ClientsPage() {
     // Reset to page 1 whenever a filter or sort changes — otherwise an empty
     // result set on a high page number leaves the user staring at "no clients".
     setCurrentPage(1);
-  }, [debouncedSearch, enableFilter, filterBy, protocolFilter, sortColumn, sortOrder]);
+  }, [debouncedSearch, enableFilter, filterBy, protocolFilter, inboundFilter, sortColumn, sortOrder]);
 
   useEffect(() => {
     setQuery({
@@ -164,10 +170,11 @@ export default function ClientsPage() {
       search: enableFilter ? '' : debouncedSearch,
       filter: enableFilter ? (filterBy || '') : '',
       protocol: protocolFilter || '',
+      inbound: inboundFilter,
       sort: sortColumn || undefined,
       order: sortOrder || undefined,
     });
-  }, [setQuery, currentPage, tablePageSize, enableFilter, debouncedSearch, filterBy, protocolFilter, sortColumn, sortOrder]);
+  }, [setQuery, currentPage, tablePageSize, enableFilter, debouncedSearch, filterBy, protocolFilter, inboundFilter, sortColumn, sortOrder]);
 
   useEffect(() => {
     if (pageSize > 0) {
@@ -731,13 +738,37 @@ export default function ClientsPage() {
                         )}
                         <Select
                           value={protocolFilter}
-                          onChange={(v) => setProtocolFilter(v)}
+                          onChange={(v) => {
+                            setProtocolFilter(v);
+                            if (v && inboundFilter) {
+                              const ib = inbounds.find((x) => x.id === inboundFilter);
+                              if (!ib || ib.protocol !== v) setInboundFilter(undefined);
+                            }
+                          }}
                           allowClear
                           placeholder={t('pages.inbounds.protocol')}
                           size={isMobile ? 'small' : 'middle'}
                           style={{ width: 150 }}
                           options={protocolOptions.map((p) => ({ value: p, label: p }))}
                         />
+                        <Select
+                          value={inboundFilter}
+                          onChange={(v) => setInboundFilter(v)}
+                          allowClear
+                          showSearch
+                          optionFilterProp="label"
+                          placeholder={t('inbounds')}
+                          size={isMobile ? 'small' : 'middle'}
+                          style={{ minWidth: 160, maxWidth: 240 }}
+                          options={inbounds
+                            .filter((ib) => !protocolFilter || ib.protocol === protocolFilter)
+                            .map((ib) => ({
+                              value: ib.id,
+                              label: ib.remark
+                                ? `${ib.remark} (${ib.protocol || ''}${ib.port ? `:${ib.port}` : ''})`
+                                : `#${ib.id} ${ib.protocol || ''}${ib.port ? `:${ib.port}` : ''}`,
+                            }))}
+                        />
                       </div>
 
                       {!isMobile ? (
@@ -783,6 +814,24 @@ export default function ClientsPage() {
                                 <div>{t('pages.clients.empty')}</div>
                               </div>
                             )}
+                            {filteredClients.length > 0 && (
+                              <div className="card-pagination">
+                                <Pagination
+                                  current={currentPage}
+                                  pageSize={tablePageSize}
+                                  total={filtered}
+                                  showSizeChanger={filtered > 10}
+                                  pageSizeOptions={['10', '25', '50', '100', '200']}
+                                  hideOnSinglePage={filtered <= tablePageSize}
+                                  size="small"
+                                  showTotal={(n) => `${n}`}
+                                  onChange={(p, s) => {
+                                    setCurrentPage(p);
+                                    if (s && s !== tablePageSize) setTablePageSize(s);
+                                  }}
+                                />
+                              </div>
+                            )}
                             {filteredClients.map((row) => {
                               const bucket = clientBucket(row);
                               return (
@@ -853,51 +902,61 @@ export default function ClientsPage() {
           </Layout.Content>
         </Layout>
 
-        <ClientFormModal
-          open={formOpen}
-          mode={formMode}
-          client={editingClient}
-          attachedIds={editingAttachedIds}
-          inbounds={inbounds}
-          ipLimitEnable={ipLimitEnable}
-          tgBotEnable={tgBotEnable}
-          save={onSave}
-          onOpenChange={setFormOpen}
-        />
-        <ClientInfoModal
-          open={infoOpen}
-          client={infoClient}
-          inboundsById={inboundsById}
-          isOnline={infoClient ? isOnline(infoClient.email) : false}
-          subSettings={subSettings}
-          onOpenChange={setInfoOpen}
-        />
-        <ClientQrModal
-          open={qrOpen}
-          client={qrClient}
-          subSettings={subSettings}
-          onOpenChange={setQrOpen}
-        />
-        <ClientBulkAddModal
-          open={bulkAddOpen}
-          inbounds={inbounds}
-          ipLimitEnable={ipLimitEnable}
-          onOpenChange={setBulkAddOpen}
-          onSaved={() => setBulkAddOpen(false)}
-        />
-        <ClientBulkAdjustModal
-          open={bulkAdjustOpen}
-          count={selectedRowKeys.length}
-          onOpenChange={setBulkAdjustOpen}
-          onSubmit={async (addDays, addBytes) => {
-            const msg = await bulkAdjust([...selectedRowKeys], addDays, addBytes);
-            if (msg?.success) {
-              setSelectedRowKeys([]);
-              return msg.obj ?? { adjusted: 0 };
-            }
-            return null;
-          }}
-        />
+        <LazyMount when={formOpen}>
+          <ClientFormModal
+            open={formOpen}
+            mode={formMode}
+            client={editingClient}
+            attachedIds={editingAttachedIds}
+            inbounds={inbounds}
+            ipLimitEnable={ipLimitEnable}
+            tgBotEnable={tgBotEnable}
+            save={onSave}
+            onOpenChange={setFormOpen}
+          />
+        </LazyMount>
+        <LazyMount when={infoOpen}>
+          <ClientInfoModal
+            open={infoOpen}
+            client={infoClient}
+            inboundsById={inboundsById}
+            isOnline={infoClient ? isOnline(infoClient.email) : false}
+            subSettings={subSettings}
+            onOpenChange={setInfoOpen}
+          />
+        </LazyMount>
+        <LazyMount when={qrOpen}>
+          <ClientQrModal
+            open={qrOpen}
+            client={qrClient}
+            subSettings={subSettings}
+            onOpenChange={setQrOpen}
+          />
+        </LazyMount>
+        <LazyMount when={bulkAddOpen}>
+          <ClientBulkAddModal
+            open={bulkAddOpen}
+            inbounds={inbounds}
+            ipLimitEnable={ipLimitEnable}
+            onOpenChange={setBulkAddOpen}
+            onSaved={() => setBulkAddOpen(false)}
+          />
+        </LazyMount>
+        <LazyMount when={bulkAdjustOpen}>
+          <ClientBulkAdjustModal
+            open={bulkAdjustOpen}
+            count={selectedRowKeys.length}
+            onOpenChange={setBulkAdjustOpen}
+            onSubmit={async (addDays, addBytes) => {
+              const msg = await bulkAdjust([...selectedRowKeys], addDays, addBytes);
+              if (msg?.success) {
+                setSelectedRowKeys([]);
+                return msg.obj ?? { adjusted: 0 };
+              }
+              return null;
+            }}
+          />
+        </LazyMount>
       </Layout>
     </ConfigProvider>
   );

+ 92 - 81
frontend/src/pages/inbounds/InboundsPage.tsx

@@ -1,5 +1,5 @@
 /* eslint-disable @typescript-eslint/no-explicit-any */
-import { useCallback, useEffect, useMemo, useState } from 'react';
+import { lazy, useCallback, useEffect, useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import {
   Card,
@@ -28,14 +28,15 @@ import { useWebSocket } from '@/hooks/useWebSocket';
 import { useNodes } from '@/hooks/useNodes';
 import AppSidebar from '@/components/AppSidebar';
 import CustomStatistic from '@/components/CustomStatistic';
-import TextModal from '@/components/TextModal';
-import PromptModal from '@/components/PromptModal';
+const TextModal = lazy(() => import('@/components/TextModal'));
+const PromptModal = lazy(() => import('@/components/PromptModal'));
 
 import { useInbounds } from './useInbounds';
 import InboundList from './InboundList';
-import InboundFormModal from './InboundFormModal';
-import InboundInfoModal from './InboundInfoModal';
-import QrCodeModal from './QrCodeModal';
+import LazyMount from '@/components/LazyMount';
+const InboundFormModal = lazy(() => import('./InboundFormModal'));
+const InboundInfoModal = lazy(() => import('./InboundInfoModal'));
+const QrCodeModal = lazy(() => import('./QrCodeModal'));
 import '@/styles/page-cards.css';
 import './InboundsPage.css';
 
@@ -234,15 +235,15 @@ export default function InboundsPage() {
   const exportInboundLinks = useCallback((dbInbound: any) => {
     const projected = checkFallback(dbInbound);
     openText({
-      title: 'Export inbound links',
+      title: t('pages.inbounds.exportLinksTitle'),
       content: projected.genInboundLinks(remarkModel, hostOverrideFor(dbInbound)),
       fileName: projected.remark || 'inbound',
     });
-  }, [checkFallback, remarkModel, hostOverrideFor, openText]);
+  }, [checkFallback, remarkModel, hostOverrideFor, openText, t]);
 
   const exportInboundClipboard = useCallback((dbInbound: any) => {
-    openText({ title: 'Inbound JSON', content: JSON.stringify(dbInbound, null, 2) });
-  }, [openText]);
+    openText({ title: t('pages.inbounds.inboundJsonTitle'), content: JSON.stringify(dbInbound, null, 2) });
+  }, [openText, t]);
 
   const exportInboundSubs = useCallback((dbInbound: any) => {
     const inbound = dbInbound.toInbound();
@@ -254,11 +255,11 @@ export default function InboundsPage() {
       }
     }
     openText({
-      title: 'Export subscription links',
+      title: t('pages.inbounds.exportSubsTitle'),
       content: [...new Set(subLinks)].join('\n'),
       fileName: `${dbInbound.remark || 'inbound'}-Subs`,
     });
-  }, [subSettings, openText]);
+  }, [subSettings, openText, t]);
 
   const exportAllLinks = useCallback(async () => {
     const hydrated = await Promise.all(
@@ -269,8 +270,8 @@ export default function InboundsPage() {
       const projected = checkFallback(ib);
       out.push(projected.genInboundLinks(remarkModel, hostOverrideFor(ib)));
     }
-    openText({ title: 'Export all inbound links', content: out.join('\r\n'), fileName: 'All-Inbounds' });
-  }, [dbInbounds, hydrateInbound, checkFallback, remarkModel, hostOverrideFor, openText]);
+    openText({ title: t('pages.inbounds.exportAllLinksTitle'), content: out.join('\r\n'), fileName: 'All-Inbounds' });
+  }, [dbInbounds, hydrateInbound, checkFallback, remarkModel, hostOverrideFor, openText, t]);
 
   const exportAllSubs = useCallback(async () => {
     const hydrated = await Promise.all(
@@ -286,8 +287,8 @@ export default function InboundsPage() {
         }
       }
     }
-    openText({ title: 'Export all subscription links', content: [...new Set(out)].join('\r\n'), fileName: 'All-Inbounds-Subs' });
-  }, [dbInbounds, hydrateInbound, subSettings, openText]);
+    openText({ title: t('pages.inbounds.exportAllSubsTitle'), content: [...new Set(out)].join('\r\n'), fileName: 'All-Inbounds-Subs' });
+  }, [dbInbounds, hydrateInbound, subSettings, openText, t]);
 
   const importInbound = useCallback(() => {
     openPrompt({
@@ -320,37 +321,37 @@ export default function InboundsPage() {
 
   const confirmDelete = useCallback((dbInbound: any) => {
     modal.confirm({
-      title: `Delete inbound "${dbInbound.remark}"?`,
-      content: 'This removes the inbound and all its clients. This cannot be undone.',
-      okText: 'Delete',
+      title: t('pages.inbounds.deleteConfirmTitle', { remark: dbInbound.remark }),
+      content: t('pages.inbounds.deleteConfirmContent'),
+      okText: t('delete'),
       okType: 'danger',
-      cancelText: 'Cancel',
+      cancelText: t('cancel'),
       onOk: async () => {
         const msg = await HttpUtil.post(`/panel/api/inbounds/del/${dbInbound.id}`);
         if (msg?.success) await refresh();
       },
     });
-  }, [modal, refresh]);
+  }, [modal, refresh, t]);
 
   const confirmResetTraffic = useCallback((dbInbound: any) => {
     modal.confirm({
-      title: `Reset traffic for "${dbInbound.remark}"?`,
-      content: 'Resets up/down counters to 0 for this inbound.',
-      okText: 'Reset',
-      cancelText: 'Cancel',
+      title: t('pages.inbounds.resetConfirmTitle', { remark: dbInbound.remark }),
+      content: t('pages.inbounds.resetConfirmContent'),
+      okText: t('reset'),
+      cancelText: t('cancel'),
       onOk: async () => {
         const msg = await HttpUtil.post(`/panel/api/inbounds/${dbInbound.id}/resetTraffic`);
         if (msg?.success) await refresh();
       },
     });
-  }, [modal, refresh]);
+  }, [modal, refresh, t]);
 
   const confirmClone = useCallback((dbInbound: any) => {
     modal.confirm({
-      title: `Clone inbound "${dbInbound.remark}"?`,
-      content: 'Creates a copy with a new port and an empty client list.',
-      okText: 'Clone',
-      cancelText: 'Cancel',
+      title: t('pages.inbounds.cloneConfirmTitle', { remark: dbInbound.remark }),
+      content: t('pages.inbounds.cloneConfirmContent'),
+      okText: t('pages.inbounds.clone'),
+      cancelText: t('cancel'),
       onOk: async () => {
         const baseInbound = dbInbound.toInbound();
         let clonedSettings: string;
@@ -379,7 +380,7 @@ export default function InboundsPage() {
         if (msg?.success) await refresh();
       },
     });
-  }, [modal, refresh]);
+  }, [modal, refresh, t]);
 
   const onGeneralAction = useCallback((key: GeneralAction) => {
     switch (key) {
@@ -517,56 +518,66 @@ export default function InboundsPage() {
           </Layout.Content>
         </Layout>
 
-        <InboundFormModal
-          open={formOpen}
-          onClose={() => setFormOpen(false)}
-          onSaved={refresh}
-          mode={formMode}
-          dbInbound={formDbInbound}
-          dbInbounds={dbInbounds as any[]}
-          availableNodes={nodesList}
-        />
-        <InboundInfoModal
-          open={infoOpen}
-          onClose={() => setInfoOpen(false)}
-          dbInbound={infoDbInbound}
-          clientIndex={infoClientIndex}
-          remarkModel={remarkModel}
-          expireDiff={expireDiff}
-          trafficDiff={trafficDiff}
-          ipLimitEnable={ipLimitEnable}
-          tgBotEnable={tgBotEnable}
-          subSettings={subSettings}
-          lastOnlineMap={lastOnlineMap}
-          nodeAddress={infoNodeAddress}
-        />
-        <QrCodeModal
-          open={qrOpen}
-          onClose={() => setQrOpen(false)}
-          dbInbound={qrDbInbound}
-          client={null}
-          remarkModel={remarkModel}
-          nodeAddress={qrNodeAddress}
-          subSettings={subSettings}
-        />
-
-        <TextModal
-          open={textOpen}
-          onClose={() => setTextOpen(false)}
-          title={textTitle}
-          content={textContent}
-          fileName={textFileName}
-        />
-        <PromptModal
-          open={promptOpen}
-          onClose={() => setPromptOpen(false)}
-          title={promptTitle}
-          okText={promptOkText}
-          type={promptType}
-          initialValue={promptInitial}
-          loading={promptLoading}
-          onConfirm={onPromptConfirm}
-        />
+        <LazyMount when={formOpen}>
+          <InboundFormModal
+            open={formOpen}
+            onClose={() => setFormOpen(false)}
+            onSaved={refresh}
+            mode={formMode}
+            dbInbound={formDbInbound}
+            dbInbounds={dbInbounds as any[]}
+            availableNodes={nodesList}
+          />
+        </LazyMount>
+        <LazyMount when={infoOpen}>
+          <InboundInfoModal
+            open={infoOpen}
+            onClose={() => setInfoOpen(false)}
+            dbInbound={infoDbInbound}
+            clientIndex={infoClientIndex}
+            remarkModel={remarkModel}
+            expireDiff={expireDiff}
+            trafficDiff={trafficDiff}
+            ipLimitEnable={ipLimitEnable}
+            tgBotEnable={tgBotEnable}
+            subSettings={subSettings}
+            lastOnlineMap={lastOnlineMap}
+            nodeAddress={infoNodeAddress}
+          />
+        </LazyMount>
+        <LazyMount when={qrOpen}>
+          <QrCodeModal
+            open={qrOpen}
+            onClose={() => setQrOpen(false)}
+            dbInbound={qrDbInbound}
+            client={null}
+            remarkModel={remarkModel}
+            nodeAddress={qrNodeAddress}
+            subSettings={subSettings}
+          />
+        </LazyMount>
+
+        <LazyMount when={textOpen}>
+          <TextModal
+            open={textOpen}
+            onClose={() => setTextOpen(false)}
+            title={textTitle}
+            content={textContent}
+            fileName={textFileName}
+          />
+        </LazyMount>
+        <LazyMount when={promptOpen}>
+          <PromptModal
+            open={promptOpen}
+            onClose={() => setPromptOpen(false)}
+            title={promptTitle}
+            okText={promptOkText}
+            type={promptType}
+            initialValue={promptInitial}
+            loading={promptLoading}
+            onConfirm={onPromptConfirm}
+          />
+        </LazyMount>
       </Layout>
     </ConfigProvider>
   );

+ 86 - 69
frontend/src/pages/index/IndexPage.tsx

@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useMemo, useState } from 'react';
+import { lazy, useCallback, useEffect, useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import {
   Button,
@@ -40,18 +40,19 @@ import { useStatus } from '@/hooks/useStatus';
 import { useMediaQuery } from '@/hooks/useMediaQuery';
 import AppSidebar from '@/components/AppSidebar';
 import CustomStatistic from '@/components/CustomStatistic';
-import JsonEditor from '@/components/JsonEditor';
+import LazyMount from '@/components/LazyMount';
 import { setMessageInstance } from '@/utils/messageBus';
 import StatusCard from './StatusCard';
 import XrayStatusCard from './XrayStatusCard';
-import PanelUpdateModal from './PanelUpdateModal';
 import type { PanelUpdateInfo } from './PanelUpdateModal';
-import LogModal from './LogModal';
-import BackupModal from './BackupModal';
-import SystemHistoryModal from './SystemHistoryModal';
-import XrayMetricsModal from './XrayMetricsModal';
-import XrayLogModal from './XrayLogModal';
-import VersionModal from './VersionModal';
+const JsonEditor = lazy(() => import('@/components/JsonEditor'));
+const PanelUpdateModal = lazy(() => import('./PanelUpdateModal'));
+const LogModal = lazy(() => import('./LogModal'));
+const BackupModal = lazy(() => import('./BackupModal'));
+const SystemHistoryModal = lazy(() => import('./SystemHistoryModal'));
+const XrayMetricsModal = lazy(() => import('./XrayMetricsModal'));
+const XrayLogModal = lazy(() => import('./XrayLogModal'));
+const VersionModal = lazy(() => import('./VersionModal'));
 import '@/styles/page-cards.css';
 import './IndexPage.css';
 
@@ -435,67 +436,83 @@ export default function IndexPage() {
           </Layout.Content>
         </Layout>
 
-        <PanelUpdateModal
-          open={panelUpdateOpen}
-          info={panelUpdateInfo}
-          onClose={() => setPanelUpdateOpen(false)}
-          onBusy={setBusy}
-        />
-        <LogModal open={logsOpen} onClose={() => setLogsOpen(false)} />
-        <BackupModal
-          open={backupOpen}
-          basePath={basePath}
-          onClose={() => setBackupOpen(false)}
-          onBusy={setBusy}
-        />
-        <SystemHistoryModal
-          open={sysHistoryOpen}
-          status={status}
-          onClose={() => setSysHistoryOpen(false)}
-        />
-        <XrayMetricsModal open={xrayMetricsOpen} onClose={() => setXrayMetricsOpen(false)} />
-        <XrayLogModal open={xrayLogsOpen} onClose={() => setXrayLogsOpen(false)} />
-        <VersionModal
-          open={versionOpen}
-          status={status}
-          onClose={() => setVersionOpen(false)}
-          onBusy={setBusy}
-        />
-
-        <Modal
-          open={configTextOpen}
-          title={t('pages.index.config')}
-          width={isMobile ? '100%' : 900}
-          style={isMobile ? { top: 20, maxWidth: 'calc(100vw - 16px)' } : undefined}
-          onCancel={() => setConfigTextOpen(false)}
-          footer={[
-            <Button
-              key="download"
-              onClick={downloadConfig}
-              size={isMobile ? 'small' : 'middle'}
-              icon={<CloudDownloadOutlined />}
-            >
-              {isMobile ? 'Download' : 'config.json'}
-            </Button>,
-            <Button
-              key="copy"
-              type="primary"
-              onClick={copyConfig}
-              size={isMobile ? 'small' : 'middle'}
-              icon={<CopyOutlined />}
-            >
-              Copy
-            </Button>,
-          ]}
-        >
-          <JsonEditor
-            value={configText}
-            onChange={setConfigText}
-            minHeight={isMobile ? '300px' : '420px'}
-            maxHeight={isMobile ? '500px' : '720px'}
-            readOnly
+        <LazyMount when={panelUpdateOpen}>
+          <PanelUpdateModal
+            open={panelUpdateOpen}
+            info={panelUpdateInfo}
+            onClose={() => setPanelUpdateOpen(false)}
+            onBusy={setBusy}
+          />
+        </LazyMount>
+        <LazyMount when={logsOpen}>
+          <LogModal open={logsOpen} onClose={() => setLogsOpen(false)} />
+        </LazyMount>
+        <LazyMount when={backupOpen}>
+          <BackupModal
+            open={backupOpen}
+            basePath={basePath}
+            onClose={() => setBackupOpen(false)}
+            onBusy={setBusy}
           />
-        </Modal>
+        </LazyMount>
+        <LazyMount when={sysHistoryOpen}>
+          <SystemHistoryModal
+            open={sysHistoryOpen}
+            status={status}
+            onClose={() => setSysHistoryOpen(false)}
+          />
+        </LazyMount>
+        <LazyMount when={xrayMetricsOpen}>
+          <XrayMetricsModal open={xrayMetricsOpen} onClose={() => setXrayMetricsOpen(false)} />
+        </LazyMount>
+        <LazyMount when={xrayLogsOpen}>
+          <XrayLogModal open={xrayLogsOpen} onClose={() => setXrayLogsOpen(false)} />
+        </LazyMount>
+        <LazyMount when={versionOpen}>
+          <VersionModal
+            open={versionOpen}
+            status={status}
+            onClose={() => setVersionOpen(false)}
+            onBusy={setBusy}
+          />
+        </LazyMount>
+
+        <LazyMount when={configTextOpen}>
+          <Modal
+            open={configTextOpen}
+            title={t('pages.index.config')}
+            width={isMobile ? '100%' : 900}
+            style={isMobile ? { top: 20, maxWidth: 'calc(100vw - 16px)' } : undefined}
+            onCancel={() => setConfigTextOpen(false)}
+            footer={[
+              <Button
+                key="download"
+                onClick={downloadConfig}
+                size={isMobile ? 'small' : 'middle'}
+                icon={<CloudDownloadOutlined />}
+              >
+                {isMobile ? 'Download' : 'config.json'}
+              </Button>,
+              <Button
+                key="copy"
+                type="primary"
+                onClick={copyConfig}
+                size={isMobile ? 'small' : 'middle'}
+                icon={<CopyOutlined />}
+              >
+                Copy
+              </Button>,
+            ]}
+          >
+            <JsonEditor
+              value={configText}
+              onChange={setConfigText}
+              minHeight={isMobile ? '300px' : '420px'}
+              maxHeight={isMobile ? '500px' : '720px'}
+              readOnly
+            />
+          </Modal>
+        </LazyMount>
       </Layout>
     </ConfigProvider>
   );

+ 8 - 8
frontend/src/pages/settings/SettingsPage.tsx

@@ -175,14 +175,14 @@ export default function SettingsPage() {
   const confAlerts = useMemo<string[]>(() => {
     const out: string[] = [];
     if (window.location.protocol !== 'https:') {
-      out.push('Panel is served over plain HTTP — set up TLS for production.');
+      out.push(t('pages.settings.warnHttp'));
     }
     if (allSetting.webPort === 2053) {
-      out.push('Default port 2053 is well-known — change it to a random port.');
+      out.push(t('pages.settings.warnDefaultPort'));
     }
     const segs = window.location.pathname.split('/').length < 4;
     if (segs && allSetting.webBasePath === '/') {
-      out.push('Default base path "/" is well-known — change it to a random path.');
+      out.push(t('pages.settings.warnDefaultBasePath'));
     }
     if (allSetting.subEnable) {
       let subPath = allSetting.subPath;
@@ -190,7 +190,7 @@ export default function SettingsPage() {
         try { subPath = new URL(allSetting.subURI).pathname; } catch { /* noop */ }
       }
       if (subPath === '/sub/') {
-        out.push('Default subscription path "/sub/" is well-known — change it.');
+        out.push(t('pages.settings.warnDefaultSubPath'));
       }
     }
     if (allSetting.subJsonEnable) {
@@ -199,11 +199,11 @@ export default function SettingsPage() {
         try { p = new URL(allSetting.subJsonURI).pathname; } catch { /* noop */ }
       }
       if (p === '/json/') {
-        out.push('Default JSON subscription path "/json/" is well-known — change it.');
+        out.push(t('pages.settings.warnDefaultJsonPath'));
       }
     }
     return out;
-  }, [allSetting]);
+  }, [allSetting, t]);
 
   const pageClass = useMemo(() => {
     const classes = ['settings-page'];
@@ -286,10 +286,10 @@ export default function SettingsPage() {
                       closable
                       className="conf-alert"
                       onClose={() => setAlertVisible(false)}
-                      title="Security warnings"
+                      title={t('pages.settings.securityWarnings')}
                       description={(
                         <>
-                          <b>Your panel may be exposed:</b>
+                          <b>{t('pages.settings.panelExposed')}</b>
                           <ul>
                             {confAlerts.map((msg, i) => <li key={i}>{msg}</li>)}
                           </ul>

+ 27 - 1
frontend/vite.config.js

@@ -152,6 +152,16 @@ export default defineConfig({
       '@': path.resolve(__dirname, 'src'),
     },
   },
+  experimental: {
+    renderBuiltUrl(filename, { hostType }) {
+      if (hostType === 'js') {
+        return {
+          runtime: `((window.X_UI_BASE_PATH||'/')+${JSON.stringify(filename)})`,
+        };
+      }
+      return undefined;
+    },
+  },
   build: {
     outDir,
     emptyOutDir: true,
@@ -174,7 +184,16 @@ export default defineConfig({
         manualChunks(id) {
           if (!id.includes('node_modules')) return undefined;
           if (id.includes('/node_modules/antd/')) return 'vendor-antd';
-          if (id.includes('/@ant-design/icons/')) return 'vendor-icons';
+          if (id.includes('/@ant-design/icons/') || id.includes('/@ant-design/icons-svg/')) return 'vendor-icons';
+          if (
+            id.includes('/node_modules/@rc-component/')
+            || id.includes('/node_modules/rc-')
+            || id.includes('/@ant-design/cssinjs')
+            || id.includes('/@ant-design/colors')
+            || id.includes('/@ant-design/fast-color')
+            || id.includes('/@ant-design/react-slick')
+            || id.includes('/@ctrl/tinycolor')
+          ) return 'vendor-antd';
           if (
             id.includes('/node_modules/react-i18next/')
             || id.includes('/node_modules/i18next/')
@@ -184,6 +203,13 @@ export default defineConfig({
             || id.includes('/node_modules/react-dom/')
             || id.includes('/node_modules/scheduler/')
           ) return 'vendor-react';
+          if (
+            id.includes('/node_modules/codemirror/')
+            || id.includes('/node_modules/@codemirror/')
+            || id.includes('/node_modules/@lezer/')
+          ) return 'vendor-codemirror';
+          if (id.includes('/node_modules/persian-calendar-suite/')) return 'vendor-jalali';
+          if (id.includes('/node_modules/otpauth/')) return 'vendor-otpauth';
           if (id.includes('dayjs')) return 'vendor-dayjs';
           if (id.includes('axios')) return 'vendor-axios';
           return 'vendor';

+ 151 - 0
install.sh

@@ -110,6 +110,83 @@ gen_random_string() {
         | head -c "$length"
 }
 
+install_postgres_local() {
+    local pg_user="xui"
+    local pg_db="xui"
+    local pg_pass
+    pg_pass=$(gen_random_string 24)
+
+    case "${release}" in
+        ubuntu | debian | armbian)
+            apt-get update >&2 && apt-get install -y -q postgresql >&2 || return 1
+            ;;
+        fedora | amzn | virtuozzo | rhel | almalinux | rocky | ol)
+            dnf install -y -q postgresql-server postgresql-contrib >&2 || return 1
+            [[ -d /var/lib/pgsql/data && -f /var/lib/pgsql/data/PG_VERSION ]] || postgresql-setup --initdb >&2 || return 1
+            ;;
+        centos)
+            if [[ "${VERSION_ID}" =~ ^7 ]]; then
+                yum install -y postgresql-server postgresql-contrib >&2 || return 1
+            else
+                dnf install -y -q postgresql-server postgresql-contrib >&2 || return 1
+            fi
+            [[ -d /var/lib/pgsql/data && -f /var/lib/pgsql/data/PG_VERSION ]] || postgresql-setup --initdb >&2 || return 1
+            ;;
+        arch | manjaro | parch)
+            pacman -Syu --noconfirm postgresql >&2 || return 1
+            if [[ ! -f /var/lib/postgres/data/PG_VERSION ]]; then
+                sudo -u postgres initdb -D /var/lib/postgres/data >&2 || return 1
+            fi
+            ;;
+        opensuse-tumbleweed | opensuse-leap)
+            zypper -q install -y postgresql-server postgresql-contrib >&2 || return 1
+            if [[ ! -f /var/lib/pgsql/data/PG_VERSION ]]; then
+                install -d -o postgres -g postgres -m 700 /var/lib/pgsql/data >&2 || return 1
+                su - postgres -c "initdb -D /var/lib/pgsql/data" >&2 || return 1
+            fi
+            ;;
+        alpine)
+            apk add --no-cache postgresql postgresql-contrib >&2 || return 1
+            if [[ ! -f /var/lib/postgresql/data/PG_VERSION ]]; then
+                /etc/init.d/postgresql setup >&2 || return 1
+            fi
+            rc-update add postgresql default >&2 2> /dev/null || true
+            rc-service postgresql start >&2 || return 1
+            ;;
+        *)
+            echo -e "${red}Unsupported distro for automatic PostgreSQL install: ${release}${plain}" >&2
+            return 1
+            ;;
+    esac
+
+    if [[ "${release}" != "alpine" ]]; then
+        systemctl enable --now postgresql >&2 || return 1
+    fi
+
+    # Wait briefly for the server to accept connections.
+    local i
+    for i in 1 2 3 4 5; do
+        sudo -u postgres psql -tAc 'SELECT 1' > /dev/null 2>&1 && break
+        sleep 1
+    done
+
+    # Idempotent role/db creation.
+    sudo -u postgres psql -tAc "SELECT 1 FROM pg_roles WHERE rolname='${pg_user}'" 2> /dev/null \
+        | grep -q 1 \
+        || sudo -u postgres psql -c "CREATE USER ${pg_user} WITH PASSWORD '${pg_pass}';" >&2 || return 1
+
+    sudo -u postgres psql -tAc "SELECT 1 FROM pg_database WHERE datname='${pg_db}'" 2> /dev/null \
+        | grep -q 1 \
+        || sudo -u postgres psql -c "CREATE DATABASE ${pg_db} OWNER ${pg_user};" >&2 || return 1
+
+    sudo -u postgres psql -c "ALTER USER ${pg_user} WITH PASSWORD '${pg_pass}';" >&2 || return 1
+
+    local pg_pass_enc
+    pg_pass_enc=$(printf '%s' "${pg_pass}" | sed -e 's/%/%25/g' -e 's/:/%3A/g' -e 's/@/%40/g' -e 's|/|%2F|g' -e 's/?/%3F/g' -e 's/#/%23/g')
+    echo "postgres://${pg_user}:${pg_pass_enc}@127.0.0.1:5432/${pg_db}?sslmode=disable"
+    return 0
+}
+
 install_acme() {
     echo -e "${green}Installing acme.sh for SSL certificate management...${plain}"
     cd ~ || return 1
@@ -741,6 +818,79 @@ config_after_install() {
             local config_username=$(gen_random_string 10)
             local config_password=$(gen_random_string 10)
 
+            local db_label="SQLite (/etc/x-ui/x-ui.db)"
+            echo ""
+            echo -e "${green}═══════════════════════════════════════════${plain}"
+            echo -e "${green}     Database Selection                    ${plain}"
+            echo -e "${green}═══════════════════════════════════════════${plain}"
+            echo -e "  1) SQLite     (default — recommended for < 1000 clients)"
+            echo -e "  2) PostgreSQL (recommended for high client counts / many nodes)"
+            read -rp "Choose [1]: " db_choice
+            db_choice="${db_choice:-1}"
+            if [[ "$db_choice" == "2" ]]; then
+                local xui_env_file
+                case "${release}" in
+                    ubuntu | debian | armbian)
+                        xui_env_file="/etc/default/x-ui"
+                        ;;
+                    arch | manjaro | parch | alpine)
+                        xui_env_file="/etc/conf.d/x-ui"
+                        ;;
+                    *)
+                        xui_env_file="/etc/sysconfig/x-ui"
+                        ;;
+                esac
+
+                local xui_dsn=""
+                local pg_mode=""
+                while [[ -z "$xui_dsn" ]]; do
+                    echo ""
+                    echo -e "  1) Install PostgreSQL locally and create a dedicated user/db (recommended)"
+                    echo -e "  2) Use an existing PostgreSQL server (enter DSN)"
+                    read -rp "Choose [1]: " pg_mode
+                    pg_mode="${pg_mode:-1}"
+                    if [[ "$pg_mode" == "2" ]]; then
+                        while [[ -z "$xui_dsn" ]]; do
+                            read -rp "Enter PostgreSQL DSN (postgres://user:pass@host:port/dbname?sslmode=disable): " xui_dsn
+                            xui_dsn="${xui_dsn// /}"
+                        done
+                        db_label="PostgreSQL (external)"
+                    else
+                        echo -e "${yellow}Installing PostgreSQL — this may take a moment...${plain}"
+                        if xui_dsn=$(install_postgres_local); then
+                            db_label="PostgreSQL ([email protected]:5432/xui)"
+                        else
+                            echo ""
+                            echo -e "${red}PostgreSQL installation failed.${plain}"
+                            echo -e "  1) Retry local install"
+                            echo -e "  2) Enter an external DSN instead"
+                            echo -e "  3) Abort install"
+                            echo -e "  4) Fall back to SQLite"
+                            read -rp "Choose [1]: " pg_fail
+                            pg_fail="${pg_fail:-1}"
+                            case "$pg_fail" in
+                                2) pg_mode="2" ;;
+                                3) echo -e "${red}Install aborted.${plain}"; exit 1 ;;
+                                4) db_choice="1"; xui_dsn=""; break ;;
+                                *) xui_dsn="" ;;
+                            esac
+                        fi
+                    fi
+                done
+                if [[ -n "$xui_dsn" ]]; then
+                    install -d -m 755 "$(dirname "$xui_env_file")"
+                    umask 077
+                    cat > "$xui_env_file" << EOF
+XUI_DB_TYPE=postgres
+XUI_DB_DSN=${xui_dsn}
+EOF
+                    chmod 600 "$xui_env_file"
+                    umask 022
+                    export XUI_DB_TYPE=postgres
+                    export XUI_DB_DSN="${xui_dsn}"
+                fi
+            fi
+
             read -rp "Would you like to customize the Panel Port settings? (If not, a random port will be applied) [y/n]: " config_confirm
             if [[ "${config_confirm}" == "y" || "${config_confirm}" == "Y" ]]; then
                 read -rp "Please set up the panel port: " config_port
@@ -775,6 +925,7 @@ config_after_install() {
             echo -e "${green}Password:    ${config_password}${plain}"
             echo -e "${green}Port:        ${config_port}${plain}"
             echo -e "${green}WebBasePath: ${config_webBasePath}${plain}"
+            echo -e "${green}Database:    ${db_label}${plain}"
             echo -e "${green}Access URL:  ${SSL_SCHEME}://${SSL_HOST}:${config_port}/${config_webBasePath}${plain}"
             echo -e "${green}API Token:   ${config_apiToken}${plain}"
             echo -e "${green}═══════════════════════════════════════════${plain}"

+ 27 - 0
update.sh

@@ -105,6 +105,31 @@ gen_random_string() {
         | head -c "$length"
 }
 
+xui_env_file_path() {
+    case "${release}" in
+        ubuntu | debian | armbian)
+            echo "/etc/default/x-ui"
+            ;;
+        arch | manjaro | parch | alpine)
+            echo "/etc/conf.d/x-ui"
+            ;;
+        *)
+            echo "/etc/sysconfig/x-ui"
+            ;;
+    esac
+}
+
+load_xui_env() {
+    local env_file
+    env_file="$(xui_env_file_path)"
+    if [[ -r "$env_file" ]]; then
+        set -a
+        # shellcheck disable=SC1090
+        source "$env_file"
+        set +a
+    fi
+}
+
 install_base() {
     echo -e "${green}Updating and install dependency packages...${plain}"
     case "${release}" in
@@ -775,6 +800,8 @@ config_after_update() {
 update_x-ui() {
     cd ${xui_folder%/x-ui}/
 
+    load_xui_env
+
     if [ -f "${xui_folder}/x-ui" ]; then
         current_xui_version=$(${xui_folder}/x-ui -v)
         echo -e "${green}Current x-ui version: ${current_xui_version}${plain}"

+ 15 - 4
web/controller/inbound.go

@@ -166,6 +166,7 @@ func (a *InboundController) addInbound(c *gin.Context) {
 		a.xrayService.SetToNeedRestart()
 	}
 	a.broadcastInboundsUpdate(user.Id)
+	notifyClientsChanged()
 }
 
 // delInbound deletes an inbound configuration by its ID.
@@ -186,6 +187,7 @@ func (a *InboundController) delInbound(c *gin.Context) {
 	}
 	user := session.GetLoginUser(c)
 	a.broadcastInboundsUpdate(user.Id)
+	notifyClientsChanged()
 }
 
 // updateInbound updates an existing inbound configuration.
@@ -221,6 +223,7 @@ func (a *InboundController) updateInbound(c *gin.Context) {
 	}
 	user := session.GetLoginUser(c)
 	a.broadcastInboundsUpdate(user.Id)
+	notifyClientsChanged()
 }
 
 // setInboundEnable flips only the enable flag of an inbound. This is a
@@ -299,6 +302,9 @@ func (a *InboundController) importInbound(c *gin.Context) {
 	user := session.GetLoginUser(c)
 	inbound.Id = 0
 	inbound.UserId = user.Id
+	if inbound.NodeID != nil && *inbound.NodeID == 0 {
+		inbound.NodeID = nil
+	}
 	if inbound.Tag == "" {
 		if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" {
 			inbound.Tag = fmt.Sprintf("inbound-%v", inbound.Port)
@@ -312,12 +318,17 @@ func (a *InboundController) importInbound(c *gin.Context) {
 		inbound.ClientStats[index].Enable = true
 	}
 
-	needRestart := false
-	inbound, needRestart, err = a.inboundService.AddInbound(inbound)
-	jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundCreateSuccess"), inbound, err)
-	if err == nil && needRestart {
+	inbound, needRestart, err := a.inboundService.AddInbound(inbound)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundCreateSuccess"), inbound, nil)
+	if needRestart {
 		a.xrayService.SetToNeedRestart()
 	}
+	a.broadcastInboundsUpdate(user.Id)
+	notifyClientsChanged()
 }
 
 // resolveHost mirrors what sub.SubService.ResolveRequest does for the host

+ 40 - 3
web/service/client.go

@@ -208,9 +208,6 @@ func (s *ClientService) SyncInbound(tx *gorm.DB, inboundId int, clients []model.
 			return err
 		}
 		if errors.Is(err, gorm.ErrRecordNotFound) {
-			if isClientEmailTombstoned(email) {
-				continue
-			}
 			if err := tx.Create(incoming).Error; err != nil {
 				return err
 			}
@@ -831,6 +828,7 @@ type ClientPageParams struct {
 	Search   string `form:"search"`
 	Filter   string `form:"filter"`
 	Protocol string `form:"protocol"`
+	Inbound  int    `form:"inbound"`
 	Sort     string `form:"sort"`
 	Order    string `form:"order"`
 }
@@ -931,6 +929,9 @@ func (s *ClientService) ListPaged(inboundSvc *InboundService, settingSvc *Settin
 		if params.Protocol != "" && !clientMatchesProtocol(c, params.Protocol, protocolByInbound) {
 			continue
 		}
+		if params.Inbound > 0 && !clientMatchesInbound(c, params.Inbound) {
+			continue
+		}
 		if params.Filter != "" && !clientMatchesBucket(c, params.Filter, onlineSet, nowMs, expireDiffMs, trafficDiffBytes) {
 			continue
 		}
@@ -1049,6 +1050,18 @@ func clientMatchesProtocol(c ClientWithAttachments, protocol string, byInbound m
 	return false
 }
 
+func clientMatchesInbound(c ClientWithAttachments, inboundId int) bool {
+	if inboundId <= 0 {
+		return true
+	}
+	for _, id := range c.InboundIds {
+		if id == inboundId {
+			return true
+		}
+	}
+	return false
+}
+
 func clientMatchesBucket(c ClientWithAttachments, bucket string, onlineSet map[string]struct{}, nowMs, expireDiffMs, trafficDiffBytes int64) bool {
 	if bucket == "" {
 		return true
@@ -1615,6 +1628,30 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
 		}
 	}
 
+	if clientIndex == -1 {
+		var rec model.ClientRecord
+		var lookupErr error
+		switch oldInbound.Protocol {
+		case "trojan":
+			lookupErr = database.GetDB().Where("password = ?", clientId).First(&rec).Error
+		case "shadowsocks":
+			lookupErr = database.GetDB().Where("email = ?", clientId).First(&rec).Error
+		case "hysteria", "hysteria2":
+			lookupErr = database.GetDB().Where("auth = ?", clientId).First(&rec).Error
+		default:
+			lookupErr = database.GetDB().Where("uuid = ?", clientId).First(&rec).Error
+		}
+		if lookupErr == nil && rec.Email != "" {
+			for index, oldClient := range oldClients {
+				if oldClient.Email == rec.Email {
+					oldEmail = oldClient.Email
+					clientIndex = index
+					break
+				}
+			}
+		}
+	}
+
 	if newClientId == "" || clientIndex == -1 {
 		return false, common.NewError("empty client ID")
 	}

+ 18 - 0
web/translation/ar-EG.json

@@ -283,6 +283,17 @@
       "modifyInbound": "تعديل الإدخال",
       "deleteInbound": "حذف الإدخال",
       "deleteInboundContent": "متأكد إنك عايز تحذف الإدخال؟",
+      "deleteConfirmTitle": "حذف الإدخال \"{remark}\"؟",
+      "deleteConfirmContent": "سيؤدي هذا إلى إزالة الإدخال وجميع عملائه. لا يمكن التراجع.",
+      "resetConfirmTitle": "إعادة تعيين ترافيك \"{remark}\"؟",
+      "resetConfirmContent": "يعيد عدادات الإرسال/الاستقبال لهذا الإدخال إلى 0.",
+      "cloneConfirmTitle": "نسخ الإدخال \"{remark}\"؟",
+      "cloneConfirmContent": "ينشئ نسخة بمنفذ جديد وقائمة عملاء فارغة.",
+      "exportLinksTitle": "تصدير روابط الإدخال",
+      "exportSubsTitle": "تصدير روابط الاشتراك",
+      "exportAllLinksTitle": "تصدير كل روابط الإدخالات",
+      "exportAllSubsTitle": "تصدير كل روابط الاشتراكات",
+      "inboundJsonTitle": "JSON الإدخال",
       "deleteClient": "حذف العميل",
       "deleteClientContent": "متأكد إنك عايز تحذف العميل؟",
       "resetTrafficContent": "متأكد إنك عايز تعيد ضبط الترافيك؟",
@@ -583,6 +594,13 @@
       "resetDefaultConfig": "استرجاع الافتراضي",
       "panelSettings": "عام",
       "securitySettings": "المصادقة",
+      "securityWarnings": "تحذيرات الأمان",
+      "panelExposed": "قد تكون لوحتك مكشوفة:",
+      "warnHttp": "اللوحة تُقدَّم عبر HTTP عادي — قم بإعداد TLS للإنتاج.",
+      "warnDefaultPort": "المنفذ الافتراضي 2053 معروف — غيّره إلى منفذ عشوائي.",
+      "warnDefaultBasePath": "المسار الأساسي الافتراضي \"/\" معروف — غيّره إلى مسار عشوائي.",
+      "warnDefaultSubPath": "مسار الاشتراك الافتراضي \"/sub/\" معروف — قم بتغييره.",
+      "warnDefaultJsonPath": "مسار اشتراك JSON الافتراضي \"/json/\" معروف — قم بتغييره.",
       "TGBotSettings": "بوت Telegram",
       "panelListeningIP": "IP الاستماع",
       "panelListeningIPDesc": "عنوان IP للبانل. (سيبه فاضي عشان يستمع على كل الـ IPs)",

+ 18 - 0
web/translation/en-US.json

@@ -283,6 +283,17 @@
       "modifyInbound": "Modify Inbound",
       "deleteInbound": "Delete Inbound",
       "deleteInboundContent": "Are you sure you want to delete this inbound?",
+      "deleteConfirmTitle": "Delete inbound \"{remark}\"?",
+      "deleteConfirmContent": "This removes the inbound and all its clients. This cannot be undone.",
+      "resetConfirmTitle": "Reset traffic for \"{remark}\"?",
+      "resetConfirmContent": "Resets up/down counters to 0 for this inbound.",
+      "cloneConfirmTitle": "Clone inbound \"{remark}\"?",
+      "cloneConfirmContent": "Creates a copy with a new port and an empty client list.",
+      "exportLinksTitle": "Export inbound links",
+      "exportSubsTitle": "Export subscription links",
+      "exportAllLinksTitle": "Export all inbound links",
+      "exportAllSubsTitle": "Export all subscription links",
+      "inboundJsonTitle": "Inbound JSON",
       "deleteClient": "Delete Client",
       "deleteClientContent": "Are you sure you want to delete this client?",
       "resetTrafficContent": "Are you sure you want to reset traffic?",
@@ -583,6 +594,13 @@
       "resetDefaultConfig": "Reset to Default",
       "panelSettings": "General",
       "securitySettings": "Authentication",
+      "securityWarnings": "Security warnings",
+      "panelExposed": "Your panel may be exposed:",
+      "warnHttp": "Panel is served over plain HTTP — set up TLS for production.",
+      "warnDefaultPort": "Default port 2053 is well-known — change it to a random port.",
+      "warnDefaultBasePath": "Default base path \"/\" is well-known — change it to a random path.",
+      "warnDefaultSubPath": "Default subscription path \"/sub/\" is well-known — change it.",
+      "warnDefaultJsonPath": "Default JSON subscription path \"/json/\" is well-known — change it.",
       "TGBotSettings": "Telegram Bot",
       "panelListeningIP": "Listen IP",
       "panelListeningIPDesc": "The IP address for the web panel. (leave blank to listen on all IPs)",

+ 18 - 0
web/translation/es-ES.json

@@ -283,6 +283,17 @@
       "modifyInbound": "Modificar Entrada",
       "deleteInbound": "Eliminar Entrada",
       "deleteInboundContent": "¿Confirmar eliminación de entrada?",
+      "deleteConfirmTitle": "¿Eliminar el inbound \"{remark}\"?",
+      "deleteConfirmContent": "Esto elimina el inbound y todos sus clientes. No se puede deshacer.",
+      "resetConfirmTitle": "¿Restablecer el tráfico de \"{remark}\"?",
+      "resetConfirmContent": "Restablece los contadores de subida/bajada a 0 para este inbound.",
+      "cloneConfirmTitle": "¿Clonar el inbound \"{remark}\"?",
+      "cloneConfirmContent": "Crea una copia con un puerto nuevo y una lista de clientes vacía.",
+      "exportLinksTitle": "Exportar enlaces del inbound",
+      "exportSubsTitle": "Exportar enlaces de suscripción",
+      "exportAllLinksTitle": "Exportar todos los enlaces de inbound",
+      "exportAllSubsTitle": "Exportar todos los enlaces de suscripción",
+      "inboundJsonTitle": "JSON del inbound",
       "deleteClient": "Eliminar cliente",
       "deleteClientContent": "¿Está seguro de que desea eliminar el cliente?",
       "resetTrafficContent": "¿Confirmar restablecimiento de tráfico?",
@@ -583,6 +594,13 @@
       "resetDefaultConfig": "Restablecer a Configuración Predeterminada",
       "panelSettings": "Configuraciones del Panel",
       "securitySettings": "Configuraciones de Seguridad",
+      "securityWarnings": "Advertencias de seguridad",
+      "panelExposed": "Es posible que su panel esté expuesto:",
+      "warnHttp": "El panel se sirve por HTTP sin cifrar — configure TLS para producción.",
+      "warnDefaultPort": "El puerto por defecto 2053 es conocido — cámbielo a uno aleatorio.",
+      "warnDefaultBasePath": "La ruta base por defecto \"/\" es conocida — cámbiela a una ruta aleatoria.",
+      "warnDefaultSubPath": "La ruta de suscripción por defecto \"/sub/\" es conocida — cámbiela.",
+      "warnDefaultJsonPath": "La ruta de suscripción JSON por defecto \"/json/\" es conocida — cámbiela.",
       "TGBotSettings": "Configuraciones de Bot de Telegram",
       "panelListeningIP": "IP de Escucha del Panel",
       "panelListeningIPDesc": "Dejar en blanco por defecto para monitorear todas las IPs.",

+ 18 - 0
web/translation/fa-IR.json

@@ -283,6 +283,17 @@
       "modifyInbound": "ویرایش ورودی",
       "deleteInbound": "حذف ورودی",
       "deleteInboundContent": "آیا مطمئن به حذف ورودی هستید؟",
+      "deleteConfirmTitle": "اینباند «{remark}» حذف شود؟",
+      "deleteConfirmContent": "این اینباند و تمام کلاینت‌های آن حذف می‌شود. این عمل غیرقابل بازگشت است.",
+      "resetConfirmTitle": "ترافیک اینباند «{remark}» صفر شود؟",
+      "resetConfirmContent": "شمارنده‌های ارسال/دریافت این اینباند به صفر برمی‌گردد.",
+      "cloneConfirmTitle": "اینباند «{remark}» کپی شود؟",
+      "cloneConfirmContent": "یک نسخه با پورت جدید و لیست کلاینت خالی ساخته می‌شود.",
+      "exportLinksTitle": "خروجی لینک‌های اینباند",
+      "exportSubsTitle": "خروجی لینک‌های ساب",
+      "exportAllLinksTitle": "خروجی لینک‌های همه اینباندها",
+      "exportAllSubsTitle": "خروجی لینک‌های ساب همه اینباندها",
+      "inboundJsonTitle": "JSON اینباند",
       "deleteClient": "حذف کاربر",
       "deleteClientContent": "آیا مطمئن به حذف کاربر هستید؟",
       "resetTrafficContent": "آیا مطمئن به ریست ترافیک هستید؟",
@@ -583,6 +594,13 @@
       "resetDefaultConfig": "برگشت به پیش‌فرض",
       "panelSettings": "پیکربندی",
       "securitySettings": "احرازهویت",
+      "securityWarnings": "هشدارهای امنیتی",
+      "panelExposed": "ممکن است پنل شما در معرض خطر باشد:",
+      "warnHttp": "پنل از طریق HTTP ساده ارائه می‌شود — برای محیط عملیاتی TLS فعال کنید.",
+      "warnDefaultPort": "پورت پیش‌فرض 2053 شناخته‌شده است — آن را به یک پورت تصادفی تغییر دهید.",
+      "warnDefaultBasePath": "مسیر پایه پیش‌فرض «/» شناخته‌شده است — آن را به یک مسیر تصادفی تغییر دهید.",
+      "warnDefaultSubPath": "مسیر ساب پیش‌فرض «/sub/» شناخته‌شده است — تغییرش دهید.",
+      "warnDefaultJsonPath": "مسیر JSON ساب پیش‌فرض «/json/» شناخته‌شده است — تغییرش دهید.",
       "TGBotSettings": "ربات تلگرام",
       "panelListeningIP": "آدرس آی‌پی",
       "panelListeningIPDesc": "آدرس آی‌پی برای وب پنل. برای گوش‌دادن به‌تمام آی‌پی‌ها خالی‌بگذارید",

+ 18 - 0
web/translation/id-ID.json

@@ -283,6 +283,17 @@
       "modifyInbound": "Ubah Masuk",
       "deleteInbound": "Hapus Masuk",
       "deleteInboundContent": "Apakah Anda yakin ingin menghapus masuk?",
+      "deleteConfirmTitle": "Hapus inbound \"{remark}\"?",
+      "deleteConfirmContent": "Tindakan ini menghapus inbound beserta semua kliennya. Tidak dapat dibatalkan.",
+      "resetConfirmTitle": "Reset trafik \"{remark}\"?",
+      "resetConfirmContent": "Mengatur ulang counter unggah/unduh ke 0 untuk inbound ini.",
+      "cloneConfirmTitle": "Klon inbound \"{remark}\"?",
+      "cloneConfirmContent": "Membuat salinan dengan port baru dan daftar klien kosong.",
+      "exportLinksTitle": "Ekspor tautan inbound",
+      "exportSubsTitle": "Ekspor tautan langganan",
+      "exportAllLinksTitle": "Ekspor semua tautan inbound",
+      "exportAllSubsTitle": "Ekspor semua tautan langganan",
+      "inboundJsonTitle": "JSON inbound",
       "deleteClient": "Hapus Klien",
       "deleteClientContent": "Apakah Anda yakin ingin menghapus klien?",
       "resetTrafficContent": "Apakah Anda yakin ingin mereset traffic?",
@@ -583,6 +594,13 @@
       "resetDefaultConfig": "Reset ke Default",
       "panelSettings": "Umum",
       "securitySettings": "Otentikasi",
+      "securityWarnings": "Peringatan keamanan",
+      "panelExposed": "Panel Anda mungkin terekspos:",
+      "warnHttp": "Panel disajikan melalui HTTP biasa — siapkan TLS untuk produksi.",
+      "warnDefaultPort": "Port default 2053 sudah umum diketahui — ubah ke port acak.",
+      "warnDefaultBasePath": "Base path default \"/\" sudah umum diketahui — ubah ke path acak.",
+      "warnDefaultSubPath": "Path langganan default \"/sub/\" sudah umum diketahui — ubahlah.",
+      "warnDefaultJsonPath": "Path langganan JSON default \"/json/\" sudah umum diketahui — ubahlah.",
       "TGBotSettings": "Bot Telegram",
       "panelListeningIP": "IP Pendengar",
       "panelListeningIPDesc": "Alamat IP untuk panel web. (biarkan kosong untuk mendengarkan semua IP)",

+ 18 - 0
web/translation/ja-JP.json

@@ -283,6 +283,17 @@
       "modifyInbound": "インバウンド修正",
       "deleteInbound": "インバウンド削除",
       "deleteInboundContent": "インバウンドを削除してもよろしいですか?",
+      "deleteConfirmTitle": "インバウンド「{remark}」を削除しますか?",
+      "deleteConfirmContent": "インバウンドと関連付けされたすべてのクライアントを削除します。元に戻せません。",
+      "resetConfirmTitle": "「{remark}」のトラフィックをリセットしますか?",
+      "resetConfirmContent": "このインバウンドの送受信カウンタを 0 にリセットします。",
+      "cloneConfirmTitle": "インバウンド「{remark}」を複製しますか?",
+      "cloneConfirmContent": "新しいポートと空のクライアント一覧でコピーを作成します。",
+      "exportLinksTitle": "インバウンドリンクのエクスポート",
+      "exportSubsTitle": "サブスクリプションリンクのエクスポート",
+      "exportAllLinksTitle": "全インバウンドリンクのエクスポート",
+      "exportAllSubsTitle": "全サブスクリプションリンクのエクスポート",
+      "inboundJsonTitle": "インバウンド JSON",
       "deleteClient": "クライアント削除",
       "deleteClientContent": "クライアントを削除してもよろしいですか?",
       "resetTrafficContent": "トラフィックをリセットしてもよろしいですか?",
@@ -583,6 +594,13 @@
       "resetDefaultConfig": "デフォルト設定にリセット",
       "panelSettings": "一般",
       "securitySettings": "セキュリティ設定",
+      "securityWarnings": "セキュリティ警告",
+      "panelExposed": "パネルが露出している可能性があります:",
+      "warnHttp": "パネルが平文 HTTP で提供されています — 本番環境には TLS を設定してください。",
+      "warnDefaultPort": "デフォルトポート 2053 はよく知られています — ランダムなポートに変更してください。",
+      "warnDefaultBasePath": "デフォルトのベースパス \"/\" はよく知られています — ランダムなパスに変更してください。",
+      "warnDefaultSubPath": "デフォルトのサブスクリプションパス \"/sub/\" はよく知られています — 変更してください。",
+      "warnDefaultJsonPath": "デフォルトの JSON サブスクリプションパス \"/json/\" はよく知られています — 変更してください。",
       "TGBotSettings": "Telegramボット設定",
       "panelListeningIP": "パネル監視IP",
       "panelListeningIPDesc": "デフォルトではすべてのIPを監視する",

+ 18 - 0
web/translation/pt-BR.json

@@ -283,6 +283,17 @@
       "modifyInbound": "Modificar Inbound",
       "deleteInbound": "Excluir Inbound",
       "deleteInboundContent": "Tem certeza de que deseja excluir o inbound?",
+      "deleteConfirmTitle": "Excluir o inbound \"{remark}\"?",
+      "deleteConfirmContent": "Isto remove o inbound e todos os seus clientes. Não é possível desfazer.",
+      "resetConfirmTitle": "Redefinir o tráfego de \"{remark}\"?",
+      "resetConfirmContent": "Zera os contadores de envio/recebimento para este inbound.",
+      "cloneConfirmTitle": "Clonar o inbound \"{remark}\"?",
+      "cloneConfirmContent": "Cria uma cópia com uma nova porta e lista de clientes vazia.",
+      "exportLinksTitle": "Exportar links do inbound",
+      "exportSubsTitle": "Exportar links de assinatura",
+      "exportAllLinksTitle": "Exportar todos os links de inbound",
+      "exportAllSubsTitle": "Exportar todos os links de assinatura",
+      "inboundJsonTitle": "JSON do inbound",
       "deleteClient": "Excluir Cliente",
       "deleteClientContent": "Tem certeza de que deseja excluir o cliente?",
       "resetTrafficContent": "Tem certeza de que deseja redefinir o tráfego?",
@@ -583,6 +594,13 @@
       "resetDefaultConfig": "Redefinir para Padrão",
       "panelSettings": "Geral",
       "securitySettings": "Autenticação",
+      "securityWarnings": "Avisos de segurança",
+      "panelExposed": "Seu painel pode estar exposto:",
+      "warnHttp": "O painel é servido por HTTP simples — configure TLS para produção.",
+      "warnDefaultPort": "A porta padrão 2053 é bem conhecida — altere para uma porta aleatória.",
+      "warnDefaultBasePath": "O caminho base padrão \"/\" é bem conhecido — altere para um caminho aleatório.",
+      "warnDefaultSubPath": "O caminho de assinatura padrão \"/sub/\" é bem conhecido — altere-o.",
+      "warnDefaultJsonPath": "O caminho de assinatura JSON padrão \"/json/\" é bem conhecido — altere-o.",
       "TGBotSettings": "Bot do Telegram",
       "panelListeningIP": "IP de Escuta",
       "panelListeningIPDesc": "O endereço IP para o painel web. (deixe em branco para escutar em todos os IPs)",

+ 18 - 0
web/translation/ru-RU.json

@@ -283,6 +283,17 @@
       "modifyInbound": "Изменить подключение",
       "deleteInbound": "Удалить подключение",
       "deleteInboundContent": "Вы уверены, что хотите удалить подключение?",
+      "deleteConfirmTitle": "Удалить подключение \"{remark}\"?",
+      "deleteConfirmContent": "Подключение и все его клиенты будут удалены. Это действие нельзя отменить.",
+      "resetConfirmTitle": "Сбросить трафик \"{remark}\"?",
+      "resetConfirmContent": "Сбрасывает счётчики отправки/получения этого подключения до 0.",
+      "cloneConfirmTitle": "Клонировать подключение \"{remark}\"?",
+      "cloneConfirmContent": "Создаёт копию с новым портом и пустым списком клиентов.",
+      "exportLinksTitle": "Экспортировать ссылки подключения",
+      "exportSubsTitle": "Экспортировать ссылки подписки",
+      "exportAllLinksTitle": "Экспортировать все ссылки подключений",
+      "exportAllSubsTitle": "Экспортировать все ссылки подписок",
+      "inboundJsonTitle": "JSON подключения",
       "deleteClient": "Удалить клиента",
       "deleteClientContent": "Вы уверены, что хотите удалить клиента?",
       "resetTrafficContent": "Вы уверены, что хотите сбросить трафик?",
@@ -583,6 +594,13 @@
       "resetDefaultConfig": "Восстановить настройки по умолчанию",
       "panelSettings": "Панель",
       "securitySettings": "Учетная запись",
+      "securityWarnings": "Предупреждения безопасности",
+      "panelExposed": "Ваша панель может быть открыта:",
+      "warnHttp": "Панель работает по обычному HTTP — настройте TLS для продакшна.",
+      "warnDefaultPort": "Стандартный порт 2053 широко известен — измените его на случайный.",
+      "warnDefaultBasePath": "Базовый путь по умолчанию \"/\" широко известен — измените его на случайный.",
+      "warnDefaultSubPath": "Путь подписки по умолчанию \"/sub/\" широко известен — измените его.",
+      "warnDefaultJsonPath": "JSON-путь подписки по умолчанию \"/json/\" широко известен — измените его.",
       "TGBotSettings": "Telegram-Бот",
       "panelListeningIP": "IP-адрес для управления панелью",
       "panelListeningIPDesc": "Оставьте пустым для подключения с любого IP",

+ 18 - 0
web/translation/tr-TR.json

@@ -283,6 +283,17 @@
       "modifyInbound": "Geleni Düzenle",
       "deleteInbound": "Geleni Sil",
       "deleteInboundContent": "Geleni silmek istediğinizden emin misiniz?",
+      "deleteConfirmTitle": "\"{remark}\" inbound silinsin mi?",
+      "deleteConfirmContent": "Bu işlem inbound'u ve tüm istemcilerini siler. Geri alınamaz.",
+      "resetConfirmTitle": "\"{remark}\" trafiği sıfırlansın mı?",
+      "resetConfirmContent": "Bu inbound için gönderme/alma sayaçlarını 0'a sıfırlar.",
+      "cloneConfirmTitle": "\"{remark}\" inbound klonlansın mı?",
+      "cloneConfirmContent": "Yeni bir port ve boş istemci listesiyle bir kopya oluşturur.",
+      "exportLinksTitle": "Inbound bağlantılarını dışa aktar",
+      "exportSubsTitle": "Abonelik bağlantılarını dışa aktar",
+      "exportAllLinksTitle": "Tüm inbound bağlantılarını dışa aktar",
+      "exportAllSubsTitle": "Tüm abonelik bağlantılarını dışa aktar",
+      "inboundJsonTitle": "Inbound JSON",
       "deleteClient": "Müşteriyi Sil",
       "deleteClientContent": "Müşteriyi silmek istediğinizden emin misiniz?",
       "resetTrafficContent": "Trafiği sıfırlamak istediğinizden emin misiniz?",
@@ -583,6 +594,13 @@
       "resetDefaultConfig": "Varsayılana Sıfırla",
       "panelSettings": "Genel",
       "securitySettings": "Kimlik Doğrulama",
+      "securityWarnings": "Güvenlik uyarıları",
+      "panelExposed": "Paneliniz açıkta olabilir:",
+      "warnHttp": "Panel düz HTTP üzerinden sunuluyor — üretim için TLS kurun.",
+      "warnDefaultPort": "Varsayılan port 2053 yaygın olarak biliniyor — rastgele bir porta değiştirin.",
+      "warnDefaultBasePath": "Varsayılan temel yol \"/\" yaygın olarak biliniyor — rastgele bir yola değiştirin.",
+      "warnDefaultSubPath": "Varsayılan abonelik yolu \"/sub/\" yaygın olarak biliniyor — değiştirin.",
+      "warnDefaultJsonPath": "Varsayılan JSON abonelik yolu \"/json/\" yaygın olarak biliniyor — değiştirin.",
       "TGBotSettings": "Telegram Bot",
       "panelListeningIP": "Dinleme IP",
       "panelListeningIPDesc": "Web paneli için IP adresi. (tüm IP'leri dinlemek için boş bırakın)",

+ 18 - 0
web/translation/uk-UA.json

@@ -283,6 +283,17 @@
       "modifyInbound": "Змінити вхідний",
       "deleteInbound": "Видалити вхідні",
       "deleteInboundContent": "Ви впевнені, що хочете видалити вхідні?",
+      "deleteConfirmTitle": "Видалити вхідні \"{remark}\"?",
+      "deleteConfirmContent": "Це видалить вхідні та всіх його клієнтів. Цю дію неможливо скасувати.",
+      "resetConfirmTitle": "Скинути трафік \"{remark}\"?",
+      "resetConfirmContent": "Скидає лічильники відправки/отримання цього вхідного до 0.",
+      "cloneConfirmTitle": "Клонувати вхідні \"{remark}\"?",
+      "cloneConfirmContent": "Створює копію з новим портом і порожнім списком клієнтів.",
+      "exportLinksTitle": "Експортувати посилання вхідних",
+      "exportSubsTitle": "Експортувати посилання підписок",
+      "exportAllLinksTitle": "Експортувати всі посилання вхідних",
+      "exportAllSubsTitle": "Експортувати всі посилання підписок",
+      "inboundJsonTitle": "JSON вхідних",
       "deleteClient": "Видалити клієнта",
       "deleteClientContent": "Ви впевнені, що хочете видалити клієнт?",
       "resetTrafficContent": "Ви впевнені, що хочете скинути трафік?",
@@ -583,6 +594,13 @@
       "resetDefaultConfig": "Відновити значення за замовчуванням",
       "panelSettings": "Загальні",
       "securitySettings": "Автентифікація",
+      "securityWarnings": "Попередження безпеки",
+      "panelExposed": "Ваша панель може бути відкрита:",
+      "warnHttp": "Панель працює через звичайний HTTP — налаштуйте TLS для продакшну.",
+      "warnDefaultPort": "Стандартний порт 2053 широко відомий — змініть його на випадковий.",
+      "warnDefaultBasePath": "Базовий шлях за замовчуванням \"/\" широко відомий — змініть його на випадковий.",
+      "warnDefaultSubPath": "Шлях підписки за замовчуванням \"/sub/\" широко відомий — змініть його.",
+      "warnDefaultJsonPath": "JSON-шлях підписки за замовчуванням \"/json/\" широко відомий — змініть його.",
       "TGBotSettings": "Telegram Бот",
       "panelListeningIP": "Слухати IP",
       "panelListeningIPDesc": "IP-адреса для веб-панелі. (залиште порожнім, щоб слухати всі IP-адреси)",

+ 18 - 0
web/translation/vi-VN.json

@@ -283,6 +283,17 @@
       "modifyInbound": "Chỉnh sửa điểm vào (Inbound)",
       "deleteInbound": "Xóa điểm vào (Inbound)",
       "deleteInboundContent": "Xác nhận xóa điểm vào? (Inbound)",
+      "deleteConfirmTitle": "Xóa inbound \"{remark}\"?",
+      "deleteConfirmContent": "Hành động này xóa inbound và toàn bộ khách hàng của nó. Không thể hoàn tác.",
+      "resetConfirmTitle": "Đặt lại lưu lượng của \"{remark}\"?",
+      "resetConfirmContent": "Đặt lại bộ đếm lên/xuống về 0 cho inbound này.",
+      "cloneConfirmTitle": "Sao chép inbound \"{remark}\"?",
+      "cloneConfirmContent": "Tạo bản sao với cổng mới và danh sách khách hàng trống.",
+      "exportLinksTitle": "Xuất liên kết inbound",
+      "exportSubsTitle": "Xuất liên kết đăng ký",
+      "exportAllLinksTitle": "Xuất tất cả liên kết inbound",
+      "exportAllSubsTitle": "Xuất tất cả liên kết đăng ký",
+      "inboundJsonTitle": "JSON inbound",
       "deleteClient": "Xóa người dùng",
       "deleteClientContent": "Bạn có chắc chắn muốn xóa người dùng không?",
       "resetTrafficContent": "Xác nhận đặt lại lưu lượng?",
@@ -583,6 +594,13 @@
       "resetDefaultConfig": "Đặt lại cấu hình mặc định",
       "panelSettings": "Bảng điều khiển",
       "securitySettings": "Bảo mật",
+      "securityWarnings": "Cảnh báo bảo mật",
+      "panelExposed": "Bảng điều khiển của bạn có thể bị lộ:",
+      "warnHttp": "Panel đang chạy trên HTTP thuần — thiết lập TLS cho môi trường thật.",
+      "warnDefaultPort": "Cổng mặc định 2053 đã quá phổ biến — đổi sang cổng ngẫu nhiên.",
+      "warnDefaultBasePath": "Đường dẫn cơ sở mặc định \"/\" đã quá phổ biến — đổi sang đường dẫn ngẫu nhiên.",
+      "warnDefaultSubPath": "Đường dẫn đăng ký mặc định \"/sub/\" đã quá phổ biến — đổi nó.",
+      "warnDefaultJsonPath": "Đường dẫn đăng ký JSON mặc định \"/json/\" đã quá phổ biến — đổi nó.",
       "TGBotSettings": "Bot Telegram",
       "panelListeningIP": "IP Nghe của bảng điều khiển",
       "panelListeningIPDesc": "Mặc định để trống để nghe tất cả các IP.",

+ 18 - 0
web/translation/zh-CN.json

@@ -283,6 +283,17 @@
       "modifyInbound": "修改入站",
       "deleteInbound": "删除入站",
       "deleteInboundContent": "确定要删除入站吗?",
+      "deleteConfirmTitle": "删除入站 \"{remark}\"?",
+      "deleteConfirmContent": "将删除此入站及其所有客户端。该操作不可撤销。",
+      "resetConfirmTitle": "重置 \"{remark}\" 的流量?",
+      "resetConfirmContent": "将此入站的上/下行计数器清零。",
+      "cloneConfirmTitle": "克隆入站 \"{remark}\"?",
+      "cloneConfirmContent": "使用新端口和空客户端列表创建副本。",
+      "exportLinksTitle": "导出入站链接",
+      "exportSubsTitle": "导出订阅链接",
+      "exportAllLinksTitle": "导出所有入站链接",
+      "exportAllSubsTitle": "导出所有订阅链接",
+      "inboundJsonTitle": "入站 JSON",
       "deleteClient": "删除客户端",
       "deleteClientContent": "确定要删除客户端吗?",
       "resetTrafficContent": "确定要重置流量吗?",
@@ -583,6 +594,13 @@
       "resetDefaultConfig": "重置为默认配置",
       "panelSettings": "常规",
       "securitySettings": "安全设定",
+      "securityWarnings": "安全警告",
+      "panelExposed": "您的面板可能已暴露:",
+      "warnHttp": "面板通过明文 HTTP 提供服务 — 生产环境请配置 TLS。",
+      "warnDefaultPort": "默认端口 2053 众所周知 — 请更改为随机端口。",
+      "warnDefaultBasePath": "默认根路径 \"/\" 众所周知 — 请更改为随机路径。",
+      "warnDefaultSubPath": "默认订阅路径 \"/sub/\" 众所周知 — 请更改。",
+      "warnDefaultJsonPath": "默认 JSON 订阅路径 \"/json/\" 众所周知 — 请更改。",
       "TGBotSettings": "Telegram 机器人配置",
       "panelListeningIP": "面板监听 IP",
       "panelListeningIPDesc": "默认留空监听所有 IP",

+ 18 - 0
web/translation/zh-TW.json

@@ -283,6 +283,17 @@
       "modifyInbound": "修改入站",
       "deleteInbound": "刪除入站",
       "deleteInboundContent": "確定要刪除入站嗎?",
+      "deleteConfirmTitle": "刪除入站「{remark}」?",
+      "deleteConfirmContent": "將刪除此入站及其所有客戶端。此操作無法復原。",
+      "resetConfirmTitle": "重置「{remark}」的流量?",
+      "resetConfirmContent": "將此入站的上/下行計數器歸零。",
+      "cloneConfirmTitle": "複製入站「{remark}」?",
+      "cloneConfirmContent": "使用新連接埠和空客戶端清單建立副本。",
+      "exportLinksTitle": "匯出入站連結",
+      "exportSubsTitle": "匯出訂閱連結",
+      "exportAllLinksTitle": "匯出所有入站連結",
+      "exportAllSubsTitle": "匯出所有訂閱連結",
+      "inboundJsonTitle": "入站 JSON",
       "deleteClient": "刪除客戶端",
       "deleteClientContent": "確定要刪除客戶端嗎?",
       "resetTrafficContent": "確定要重置流量嗎?",
@@ -583,6 +594,13 @@
       "resetDefaultConfig": "重置為預設配置",
       "panelSettings": "常規",
       "securitySettings": "安全設定",
+      "securityWarnings": "安全警告",
+      "panelExposed": "您的面板可能已暴露:",
+      "warnHttp": "面板透過明文 HTTP 提供服務 — 生產環境請設定 TLS。",
+      "warnDefaultPort": "預設連接埠 2053 廣為人知 — 請更改為隨機連接埠。",
+      "warnDefaultBasePath": "預設根路徑 \"/\" 廣為人知 — 請更改為隨機路徑。",
+      "warnDefaultSubPath": "預設訂閱路徑 \"/sub/\" 廣為人知 — 請更改。",
+      "warnDefaultJsonPath": "預設 JSON 訂閱路徑 \"/json/\" 廣為人知 — 請更改。",
       "TGBotSettings": "Telegram 機器人配置",
       "panelListeningIP": "面板監聽 IP",
       "panelListeningIPDesc": "預設留空監聽所有 IP",

+ 27 - 0
x-ui.sh

@@ -179,6 +179,20 @@ delete_script() {
     exit 1
 }
 
+xui_env_file_path() {
+    case "${release}" in
+        ubuntu | debian | armbian)
+            echo "/etc/default/x-ui"
+            ;;
+        arch | manjaro | parch | alpine)
+            echo "/etc/conf.d/x-ui"
+            ;;
+        *)
+            echo "/etc/sysconfig/x-ui"
+            ;;
+    esac
+}
+
 uninstall() {
     confirm "Are you sure you want to uninstall the panel? xray will also uninstalled!" "n"
     if [[ $? != 0 ]]; then
@@ -202,6 +216,7 @@ uninstall() {
 
     rm /etc/x-ui/ -rf
     rm ${xui_folder}/ -rf
+    rm -f "$(xui_env_file_path)"
 
     echo ""
     echo -e "Uninstalled Successfully.\n"
@@ -289,6 +304,18 @@ check_config() {
     fi
     LOGI "${info}"
 
+    local db_env_file
+    db_env_file="$(xui_env_file_path)"
+    if [[ -r "$db_env_file" ]] && grep -q '^XUI_DB_TYPE=postgres' "$db_env_file"; then
+        local dsn
+        dsn="$(grep -E '^XUI_DB_DSN=' "$db_env_file" | head -1 | cut -d= -f2-)"
+        local dsn_safe
+        dsn_safe="$(echo "$dsn" | sed -E 's|(://[^:/@]+:)[^@]+@|\1****@|')"
+        echo -e "${green}Database: PostgreSQL — ${dsn_safe}${plain}"
+    else
+        echo -e "${green}Database: SQLite (/etc/x-ui/x-ui.db)${plain}"
+    fi
+
     local existing_webBasePath=$(echo "$info" | grep -Eo 'webBasePath: .+' | awk '{print $2}')
     local existing_port=$(echo "$info" | grep -Eo 'port: .+' | awk '{print $2}')
     local existing_cert=$(${xui_folder}/x-ui setting -getCert true | grep 'cert:' | awk -F': ' '{print $2}' | tr -d '[:space:]')