5 Commits 5008906c4c ... 7ef22f94c9

Author SHA1 Message Date
  Sanaei 7ef22f94c9 fix(logger): fix data race in InitLogger 11 hours ago
  Sanaei ec9fbae645 v3.8.5 11 hours ago
  Sanaei e26cf1d3ed feat(sub): redesign the subscription page around usage, tabs and app imports 12 hours ago
  Sanaei 01ce2bcecb feat(api-docs): split the API docs page into tabs 14 hours ago
  Sanaei c9e62451e6 fix(outbounds): keep subscription tags on their server when reality params rotate 14 hours ago
51 changed files with 2177 additions and 917 deletions
  1. 2 0
      frontend/src/env.d.ts
  2. 2 3
      frontend/src/pages/api-docs/ApiDocsPage.css
  3. 104 31
      frontend/src/pages/api-docs/ApiDocsPage.tsx
  4. 1 1
      frontend/src/pages/inbounds/qr/QrPanel.css
  5. 1 1
      frontend/src/pages/inbounds/qr/QrPanel.tsx
  6. 60 0
      frontend/src/pages/sub/SubAppsTab.tsx
  7. 80 0
      frontend/src/pages/sub/SubConfigsTab.tsx
  8. 112 0
      frontend/src/pages/sub/SubHeader.tsx
  9. 127 0
      frontend/src/pages/sub/SubHero.tsx
  10. 87 0
      frontend/src/pages/sub/SubLinksTab.tsx
  11. 650 57
      frontend/src/pages/sub/SubPage.css
  12. 161 584
      frontend/src/pages/sub/SubPage.tsx
  13. 69 0
      frontend/src/pages/sub/SubQrButton.tsx
  14. 0 87
      frontend/src/pages/sub/SubUsageSummary.css
  15. 0 96
      frontend/src/pages/sub/SubUsageSummary.tsx
  16. 25 0
      frontend/src/pages/sub/app-icons/README.md
  17. 1 0
      frontend/src/pages/sub/app-icons/happ.svg
  18. BIN
      frontend/src/pages/sub/app-icons/incy.webp
  19. BIN
      frontend/src/pages/sub/app-icons/shadowrocket.webp
  20. 1 0
      frontend/src/pages/sub/app-icons/sing-box.svg
  21. BIN
      frontend/src/pages/sub/app-icons/streisand.webp
  22. BIN
      frontend/src/pages/sub/app-icons/v2box.webp
  23. 1 0
      frontend/src/pages/sub/app-icons/v2rayng.svg
  24. 1 0
      frontend/src/pages/sub/app-icons/v2raytun.svg
  25. 20 0
      frontend/src/pages/sub/appIcons.ts
  26. 93 0
      frontend/src/pages/sub/subPageModel.ts
  27. 4 4
      frontend/src/test/qr-panel-readable.test.tsx
  28. 28 0
      frontend/src/test/sub-app-icons.test.ts
  29. 135 0
      frontend/src/test/sub-page-model.test.ts
  30. 1 1
      internal/config/version
  31. 32 17
      internal/logger/logger.go
  32. 30 0
      internal/logger/logger_test.go
  33. 4 0
      internal/sub/controller.go
  34. 3 0
      internal/sub/info_endpoint_test.go
  35. 16 1
      internal/util/link/outbound.go
  36. 11 0
      internal/util/link/outbound_test.go
  37. 18 6
      internal/web/service/outbound_subscription.go
  38. 113 0
      internal/web/service/outbound_subscription_test.go
  39. 14 2
      internal/web/translation/ar-EG.json
  40. 15 3
      internal/web/translation/en-US.json
  41. 14 2
      internal/web/translation/es-ES.json
  42. 15 3
      internal/web/translation/fa-IR.json
  43. 14 2
      internal/web/translation/id-ID.json
  44. 14 2
      internal/web/translation/ja-JP.json
  45. 14 2
      internal/web/translation/pt-BR.json
  46. 14 2
      internal/web/translation/ru-RU.json
  47. 14 2
      internal/web/translation/tr-TR.json
  48. 14 2
      internal/web/translation/uk-UA.json
  49. 14 2
      internal/web/translation/vi-VN.json
  50. 14 2
      internal/web/translation/zh-CN.json
  51. 14 2
      internal/web/translation/zh-TW.json

+ 2 - 0
frontend/src/env.d.ts

@@ -15,6 +15,8 @@ interface SubPageData {
   subJsonUrl?: string;
   subClashUrl?: string;
   subTitle?: string;
+  subSupportUrl?: string;
+  subUpdates?: number;
   links?: string[];
   emails?: string[];
   datepicker?: 'gregorian' | 'jalalian';

+ 2 - 3
frontend/src/pages/api-docs/ApiDocsPage.css

@@ -45,15 +45,14 @@
 }
 
 .api-docs-page .websocket-events {
-  margin-bottom: 16px;
   padding: 20px;
   background: var(--bg-card);
   border: 1px solid var(--ant-color-border-secondary);
   border-radius: 8px;
 }
 
-.api-docs-page .websocket-events h2 {
-  margin-top: 0;
+.api-docs-page .swagger-ui .section-tabs {
+  margin-top: 20px;
 }
 
 .api-docs-page .websocket-events pre {

+ 104 - 31
frontend/src/pages/api-docs/ApiDocsPage.tsx

@@ -1,6 +1,6 @@
 import { useMemo } from 'react';
 import { useTranslation } from 'react-i18next';
-import { Card, Col, ConfigProvider, Layout, Row, Typography } from 'antd';
+import { Card, Col, ConfigProvider, Layout, Row, Tabs, Typography } from 'antd';
 import SwaggerUI from 'swagger-ui-react';
 import 'swagger-ui-react/swagger-ui.css';
 
@@ -14,6 +14,61 @@ const basePath = window.X_UI_BASE_PATH || '';
 const openApiUrl = `${basePath}panel/api/openapi.json`;
 const websocketEvents = buildWebSocketEvents(EXAMPLES);
 
+interface TaggedOperations {
+  keySeq: () => { first: () => string | undefined };
+  filter: (keep: (operations: unknown, tag: string) => boolean) => TaggedOperations;
+}
+
+interface LayoutSelectors {
+  currentFilter: () => string | false;
+}
+
+interface SectionTabsProps {
+  specSelectors: { tags: () => { toJS: () => { name: string }[] } };
+  layoutSelectors: LayoutSelectors;
+  layoutActions: { updateFilter: (tag: string) => void };
+}
+
+function SectionTabs({ specSelectors, layoutSelectors, layoutActions }: SectionTabsProps) {
+  const tags = specSelectors
+    .tags()
+    .toJS()
+    .map((tag) => tag.name);
+  return (
+    <div className="wrapper section-tabs">
+      <Tabs
+        size="small"
+        activeKey={layoutSelectors.currentFilter() || tags[0]}
+        onChange={layoutActions.updateFilter}
+        items={tags.map((tag) => ({ key: tag, label: tag }))}
+      />
+    </div>
+  );
+}
+
+// Shows one tag at a time, the first until a tab is picked. Swagger's own filter is a
+// substring match ("Settings" would also show "Xray Settings") and no-op while unset.
+const sectionTabsPlugin = {
+  statePlugins: {
+    spec: {
+      wrapSelectors: {
+        taggedOperations:
+          (
+            select: (...args: unknown[]) => TaggedOperations,
+            system: { getSystem: () => { layoutSelectors: LayoutSelectors } },
+          ) =>
+          (...args: unknown[]) => {
+            const operations = select(...args);
+            const active =
+              system.getSystem().layoutSelectors.currentFilter() || operations.keySeq().first();
+            return operations.filter((_, tag) => tag === active);
+          },
+      },
+    },
+  },
+  components: { FilterContainer: SectionTabs },
+};
+
 export default function ApiDocsPage() {
   const { isDark, isUltra, antdThemeConfig } = useTheme();
   const { t } = useTranslation();
@@ -32,36 +87,54 @@ export default function ApiDocsPage() {
 
         <Layout className="content-shell">
           <Layout.Content className="content-area">
-            <section className="websocket-events" aria-labelledby="websocket-events-title">
-              <Typography.Title id="websocket-events-title" level={2}>
-                WebSocket events
-              </Typography.Title>
-              <Typography.Paragraph>
-                After the cookie-authenticated <Typography.Text code>GET /ws</Typography.Text>{' '}
-                upgrade, every server message uses{' '}
-                <Typography.Text code>{'{ type, payload, time }'}</Typography.Text>. The time value
-                is Unix milliseconds.
-              </Typography.Paragraph>
-              <Row gutter={[12, 12]}>
-                {websocketEvents.map((event) => (
-                  <Col key={event.type} xs={24} sm={12} xl={8}>
-                    <Card size="small" title={<Typography.Text code>{event.type}</Typography.Text>}>
-                      <Typography.Paragraph>{event.summary}</Typography.Paragraph>
-                      <pre>{JSON.stringify(event.example, null, 2)}</pre>
-                    </Card>
-                  </Col>
-                ))}
-              </Row>
-            </section>
-            <div className="docs-wrapper" role="region" aria-label={t('menu.apiDocs')}>
-              <SwaggerUI
-                url={openApiUrl}
-                docExpansion="list"
-                deepLinking={false}
-                tryItOutEnabled
-                persistAuthorization
-              />
-            </div>
+            <Tabs
+              items={[
+                {
+                  key: 'panel-api',
+                  label: '3X-UI Panel API',
+                  children: (
+                    <div className="docs-wrapper" role="region" aria-label={t('menu.apiDocs')}>
+                      <SwaggerUI
+                        url={openApiUrl}
+                        docExpansion="list"
+                        deepLinking={false}
+                        plugins={[sectionTabsPlugin]}
+                        tryItOutEnabled
+                        persistAuthorization
+                      />
+                    </div>
+                  ),
+                },
+                {
+                  key: 'websocket-events',
+                  label: 'WebSocket events',
+                  children: (
+                    <section className="websocket-events">
+                      <Typography.Paragraph>
+                        After the cookie-authenticated{' '}
+                        <Typography.Text code>GET /ws</Typography.Text> upgrade, every server
+                        message uses{' '}
+                        <Typography.Text code>{'{ type, payload, time }'}</Typography.Text>. The
+                        time value is Unix milliseconds.
+                      </Typography.Paragraph>
+                      <Row gutter={[12, 12]}>
+                        {websocketEvents.map((event) => (
+                          <Col key={event.type} xs={24} sm={12} xl={8}>
+                            <Card
+                              size="small"
+                              title={<Typography.Text code>{event.type}</Typography.Text>}
+                            >
+                              <Typography.Paragraph>{event.summary}</Typography.Paragraph>
+                              <pre>{JSON.stringify(event.example, null, 2)}</pre>
+                            </Card>
+                          </Col>
+                        ))}
+                      </Row>
+                    </section>
+                  ),
+                },
+              ]}
+            />
           </Layout.Content>
         </Layout>
       </Layout>

+ 1 - 1
frontend/src/pages/inbounds/qr/QrPanel.css

@@ -28,7 +28,7 @@
 .qr-panel-canvas .qr-code {
   cursor: pointer;
   background: #fff;
-  border-radius: 4px;
+  border-radius: 8px;
   line-height: 0;
 }
 

+ 1 - 1
frontend/src/pages/inbounds/qr/QrPanel.tsx

@@ -141,7 +141,7 @@ export default function QrPanel({
               value={value}
               size={size}
               errorLevel="L"
-              marginSize={4}
+              marginSize={2}
               type="svg"
               bordered={false}
               color="#000000"

+ 60 - 0
frontend/src/pages/sub/SubAppsTab.tsx

@@ -0,0 +1,60 @@
+import { useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Button, Segmented } from 'antd';
+import { AndroidOutlined, AppleOutlined } from '@ant-design/icons';
+
+import { APP_ICONS } from './appIcons';
+import type { AppPlatform, SubApp } from './subPageModel';
+
+interface SubAppsTabProps {
+  apps: Record<AppPlatform, SubApp[]>;
+  initialPlatform: AppPlatform;
+  onOpen: (url: string) => void;
+}
+
+const PLATFORM_OPTIONS = [
+  { value: 'android' as const, label: 'Android', icon: <AndroidOutlined /> },
+  { value: 'ios' as const, label: 'iOS', icon: <AppleOutlined /> },
+];
+
+function AppIcon({ name }: { name: string }) {
+  const icon = APP_ICONS[name];
+  if (!icon) {
+    return (
+      <span className="sub-app-mark" aria-hidden="true">
+        {name.charAt(0)}
+      </span>
+    );
+  }
+  if (icon.tinted) {
+    const mask = `url("${icon.src}")`;
+    return (
+      <span className="sub-app-mark" aria-hidden="true">
+        <span className="sub-app-glyph" style={{ maskImage: mask, WebkitMaskImage: mask }} />
+      </span>
+    );
+  }
+  return <img className="sub-app-logo" src={icon.src} alt="" width={32} height={32} />;
+}
+
+export default function SubAppsTab({ apps, initialPlatform, onOpen }: SubAppsTabProps) {
+  const { t } = useTranslation();
+  const [platform, setPlatform] = useState<AppPlatform>(initialPlatform);
+
+  return (
+    <div className="sub-apps">
+      <Segmented<AppPlatform> value={platform} onChange={setPlatform} options={PLATFORM_OPTIONS} />
+      <div className="sub-app-grid">
+        {apps[platform].map((app) => (
+          <div key={app.name} className="sub-row">
+            <AppIcon name={app.name} />
+            <span className="sub-app-name">{app.name}</span>
+            <Button type="primary" size="small" onClick={() => onOpen(app.url)}>
+              {t('add')}
+            </Button>
+          </div>
+        ))}
+      </div>
+    </div>
+  );
+}

+ 80 - 0
frontend/src/pages/sub/SubConfigsTab.tsx

@@ -0,0 +1,80 @@
+import { Fragment } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Button, Tag } from 'antd';
+import { CopyOutlined } from '@ant-design/icons';
+
+import ConfigBlock from '@/components/clients/ConfigBlock';
+import {
+  amneziawgConfigFromLink,
+  isPostQuantumLink,
+  wireguardConfigFromLink,
+} from '@/lib/xray/inbound-link';
+import { LinkTags, parseLinkParts } from '@/lib/xray/link-label';
+import SubQrButton from './SubQrButton';
+
+interface SubConfigsTabProps {
+  links: string[];
+  onCopy: (value: string, toast?: string) => void;
+}
+
+export default function SubConfigsTab({ links, onCopy }: SubConfigsTabProps) {
+  const { t } = useTranslation();
+
+  return (
+    <div className="sub-rows">
+      <div className="sub-configs-bar">
+        <Button
+          icon={<CopyOutlined />}
+          onClick={() => onCopy(links.join('\n'), t('subscription.copyAllConfigsCopied'))}
+        >
+          {t('subscription.copyAllConfigs')}
+        </Button>
+      </div>
+      {links.map((link, idx) => {
+        const parts = parseLinkParts(link);
+        const rowTitle = parts?.remark || `Link ${idx + 1}`;
+        const isWireguardLink = link.startsWith('wireguard://') || link.startsWith('wg://');
+        const isAmneziawgLink = link.startsWith('vpn://');
+        return (
+          <Fragment key={link}>
+            <div className="sub-row">
+              {parts ? <LinkTags parts={parts} /> : <Tag className="sub-row-tag">LINK</Tag>}
+              <span className="sub-row-title" dir="auto" title={rowTitle}>
+                {rowTitle}
+              </span>
+              <div className="sub-row-actions">
+                <Button
+                  icon={<CopyOutlined />}
+                  onClick={() => onCopy(link)}
+                  aria-label={t('copy')}
+                  title={t('copy')}
+                />
+                {!isPostQuantumLink(link) && (
+                  <SubQrButton value={link} label={rowTitle} onCopy={onCopy} />
+                )}
+              </div>
+            </div>
+            {isWireguardLink && (
+              <ConfigBlock
+                label={t('pages.clients.wireguardConfig')}
+                text={wireguardConfigFromLink(link, rowTitle)}
+                fileName={`${rowTitle || 'peer'}.conf`}
+                qrRemark={rowTitle}
+                tagColor="cyan"
+              />
+            )}
+            {isAmneziawgLink && (
+              <ConfigBlock
+                label={t('pages.clients.amneziaWgConfig')}
+                text={amneziawgConfigFromLink(link)}
+                fileName={`${rowTitle || 'peer'}.conf`}
+                qrRemark={rowTitle}
+                tagColor="purple"
+              />
+            )}
+          </Fragment>
+        );
+      })}
+    </div>
+  );
+}

+ 112 - 0
frontend/src/pages/sub/SubHeader.tsx

@@ -0,0 +1,112 @@
+import { useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Button, Menu, Popover, Space } from 'antd';
+import {
+  MoonFilled,
+  MoonOutlined,
+  SunOutlined,
+  TranslationOutlined,
+  WifiOutlined,
+} from '@ant-design/icons';
+
+import { LanguageManager } from '@/utils';
+import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
+
+interface SubHeaderProps {
+  title: string;
+  sId: string;
+  email: string;
+  lang: string;
+  onLangChange: (lang: string) => void;
+}
+
+export default function SubHeader({ title, sId, email, lang, onLangChange }: SubHeaderProps) {
+  const { t } = useTranslation();
+  const { isDark, isUltra, toggleTheme, toggleUltra } = useTheme();
+
+  const cycleTheme = () => {
+    pauseAnimationsUntilLeave('sub-theme-cycle');
+    if (!isDark) {
+      toggleTheme();
+      if (isUltra) toggleUltra();
+    } else if (!isUltra) {
+      toggleUltra();
+    } else {
+      toggleUltra();
+      toggleTheme();
+    }
+  };
+
+  const langMenuItems = useMemo(
+    () =>
+      (LanguageManager.supportedLanguages as { value: string; name: string; icon: string }[]).map(
+        (l) => ({
+          key: l.value,
+          label: (
+            <Space size={8}>
+              <span aria-hidden="true">{l.icon}</span>
+              <span>{l.name}</span>
+            </Space>
+          ),
+        }),
+      ),
+    [],
+  );
+
+  const themeIcon = !isDark ? <SunOutlined /> : !isUltra ? <MoonOutlined /> : <MoonFilled />;
+  const initial = Array.from(title)[0]?.toUpperCase();
+
+  return (
+    <header className="sub-header">
+      <div className="sub-brand">
+        <span className="sub-brand-mark" aria-hidden="true">
+          {initial ?? <WifiOutlined />}
+        </span>
+        <div className="sub-brand-text">
+          <div className="sub-brand-title" dir="auto">
+            {title || t('subscription.title')}
+          </div>
+          <div className="sub-brand-id">
+            <bdi>{email ? `${sId} - ${email}` : sId}</bdi>
+          </div>
+        </div>
+      </div>
+      <div className="sub-toolbar">
+        <Button
+          id="sub-theme-cycle"
+          shape="circle"
+          size="large"
+          className="toolbar-btn"
+          aria-label={t('menu.theme')}
+          title={t('menu.theme')}
+          icon={themeIcon}
+          onClick={cycleTheme}
+        />
+        <Popover
+          rootClassName={isDark ? 'dark' : 'light'}
+          placement="bottomRight"
+          trigger="click"
+          styles={{ content: { padding: 4 } }}
+          content={
+            <Menu
+              mode="vertical"
+              selectable
+              selectedKeys={[lang]}
+              items={langMenuItems}
+              onClick={({ key }) => onLangChange(key)}
+              style={{ border: 'none', minWidth: 160 }}
+            />
+          }
+        >
+          <Button
+            shape="circle"
+            size="large"
+            className="toolbar-btn"
+            aria-label={t('pages.settings.language')}
+            icon={<TranslationOutlined />}
+          />
+        </Popover>
+      </div>
+    </header>
+  );
+}

+ 127 - 0
frontend/src/pages/sub/SubHero.tsx

@@ -0,0 +1,127 @@
+import type { ReactNode } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Progress, Tag, theme } from 'antd';
+
+import { IntlUtil } from '@/utils';
+import type { CalendarKind } from '@/utils';
+import { usagePercent } from './subPageModel';
+import type { SubStatus } from './subPageModel';
+
+interface SubHeroProps {
+  status: SubStatus;
+  daysLeft: number | null;
+  usedByte: number;
+  totalByte: number;
+  expireMs: number;
+  lastOnlineMs: number;
+  download: string;
+  upload: string;
+  used: string;
+  total: string;
+  remained: string;
+  datepicker: CalendarKind;
+  lang: string;
+}
+
+const STATUS_TAGS: Record<SubStatus, { color: string; label: string }> = {
+  active: { color: 'green', label: 'subscription.active' },
+  unlimited: { color: 'purple', label: 'subscription.unlimited' },
+  expired: { color: 'red', label: 'subscription.expired' },
+  depleted: { color: 'red', label: 'subscription.depleted' },
+  disabled: { color: 'red', label: 'subscription.inactive' },
+};
+
+// FormatTraffic renders "37.60GB"; the amount and unit are sized apart.
+function splitSize(label: string): [string, string] {
+  const match = /^([\d.,]+)\s*(\D*)$/.exec(label.trim());
+  return match ? [match[1], match[2]] : [label, ''];
+}
+
+export default function SubHero({
+  status,
+  daysLeft,
+  usedByte,
+  totalByte,
+  expireMs,
+  lastOnlineMs,
+  download,
+  upload,
+  used,
+  total,
+  remained,
+  datepicker,
+  lang,
+}: SubHeroProps) {
+  const { t } = useTranslation();
+  const { token } = theme.useToken();
+
+  const hasQuota = totalByte > 0;
+  const healthy = status === 'active' || status === 'unlimited';
+  const pct = usagePercent(usedByte, totalByte);
+  const ringColor =
+    !healthy || pct >= 90 ? token.colorError : pct >= 75 ? token.colorWarning : token.colorPrimary;
+  const [amount, unit] = splitSize(hasQuota ? remained : used);
+  const formatDate = (ms: number) => IntlUtil.formatDate(ms, datepicker, lang);
+  const statusTag = STATUS_TAGS[status];
+
+  const stats: { key: string; label: string; value: ReactNode }[] = [
+    { key: 'days', label: t('subscription.daysLeft'), value: daysLeft ?? '∞' },
+    {
+      key: 'expiry',
+      label: t('subscription.expiry'),
+      value: expireMs > 0 ? formatDate(expireMs) : t('subscription.noExpiry'),
+    },
+    {
+      key: 'status',
+      label: t('subscription.status'),
+      value: <Tag color={statusTag.color}>{t(statusTag.label)}</Tag>,
+    },
+    { key: 'down', label: t('subscription.downloaded'), value: <bdi>{download}</bdi> },
+    { key: 'up', label: t('subscription.uploaded'), value: <bdi>{upload}</bdi> },
+    { key: 'total', label: t('subscription.totalQuota'), value: <bdi>{total}</bdi> },
+    {
+      key: 'lastOnline',
+      label: t('lastOnline'),
+      value: lastOnlineMs > 0 ? formatDate(lastOnlineMs) : '-',
+    },
+  ];
+
+  return (
+    <section className={healthy ? 'sub-hero' : 'sub-hero is-alert'}>
+      <Progress
+        type="circle"
+        className="sub-ring"
+        percent={pct}
+        status="normal"
+        size={156}
+        strokeColor={ringColor}
+        format={() => (
+          <span className="sub-ring-center">
+            <span className="sub-ring-value">{hasQuota ? `${pct.toFixed(1)}%` : '∞'}</span>
+            <span className="sub-ring-label">
+              {hasQuota ? t('usage') : t('subscription.unlimited')}
+            </span>
+          </span>
+        )}
+      />
+      <div className="sub-hero-summary">
+        <div className="sub-label">{hasQuota ? t('remained') : t('usage')}</div>
+        <bdi className="sub-big">
+          <span className="sub-big-num">{amount}</span>
+          {unit && <span className="sub-big-unit">{unit}</span>}
+        </bdi>
+        <div className="sub-muted">
+          {hasQuota ? t('subscription.ofTotal', { total }) : t('subscription.unlimited')}
+        </div>
+        <dl className="sub-stats">
+          {stats.map((stat) => (
+            <div key={stat.key} className="sub-stat">
+              <dt className="sub-label">{stat.label}</dt>
+              <dd className="sub-stat-value">{stat.value}</dd>
+            </div>
+          ))}
+        </dl>
+      </div>
+    </section>
+  );
+}

+ 87 - 0
frontend/src/pages/sub/SubLinksTab.tsx

@@ -0,0 +1,87 @@
+import { useTranslation } from 'react-i18next';
+import { Button, QRCode, Tag } from 'antd';
+import { CopyOutlined, DownloadOutlined } from '@ant-design/icons';
+
+import SubQrButton from './SubQrButton';
+
+interface SubLinksTabProps {
+  subUrl: string;
+  subJsonUrl: string;
+  subClashUrl: string;
+  onCopy: (value: string) => void;
+}
+
+const appendRawView = (url: string) => `${url}${url.includes('?') ? '&' : '?'}view=raw`;
+
+export default function SubLinksTab({ subUrl, subJsonUrl, subClashUrl, onCopy }: SubLinksTabProps) {
+  const { t } = useTranslation();
+  const subLabel = t('pages.settings.subSettings');
+  const rows = [
+    { kind: 'SUB', color: 'green', url: subUrl, title: subLabel, downloadable: false },
+    {
+      kind: 'JSON',
+      color: 'purple',
+      url: subJsonUrl,
+      title: `${subLabel} JSON`,
+      downloadable: true,
+    },
+    { kind: 'CLASH', color: 'gold', url: subClashUrl, title: 'Clash / Mihomo', downloadable: true },
+  ].filter((row) => row.url);
+
+  return (
+    <div className="sub-rows">
+      {rows.map((row) => (
+        <div key={row.kind} className="sub-row">
+          <Tag color={row.color} className="sub-row-tag">
+            {row.kind}
+          </Tag>
+          <div className="sub-row-main">
+            <a href={row.url} target="_blank" rel="noopener noreferrer" className="sub-row-title">
+              {row.title}
+            </a>
+            <div className="sub-row-url" dir="ltr" title={row.url}>
+              {row.url}
+            </div>
+          </div>
+          <div className="sub-row-actions">
+            {row.downloadable && (
+              <Button
+                href={appendRawView(row.url)}
+                target="_blank"
+                rel="noopener noreferrer"
+                icon={<DownloadOutlined />}
+                aria-label={t('download')}
+                title={t('download')}
+              />
+            )}
+            <Button
+              icon={<CopyOutlined />}
+              onClick={() => onCopy(row.url)}
+              aria-label={t('copy')}
+              title={t('copy')}
+            />
+            <SubQrButton value={row.url} label={row.title} onCopy={onCopy} />
+          </div>
+        </div>
+      ))}
+      {subUrl && (
+        <div className="sub-qr-card">
+          <div className="sub-qr-code">
+            <QRCode
+              value={subUrl}
+              size={112}
+              type="svg"
+              bordered={false}
+              color="#000000"
+              bgColor="#ffffff"
+            />
+          </div>
+          <div>
+            <div className="sub-qr-title">{t('subscription.scanTitle')}</div>
+            <div className="sub-muted">{t('subscription.scanHint')}</div>
+          </div>
+        </div>
+      )}
+    </div>
+  );
+}

+ 650 - 57
frontend/src/pages/sub/SubPage.css

@@ -1,18 +1,78 @@
 .subscription-page {
-  --bg-page: #e6e8ec;
-  --bg-card: #ffffff;
+  /* --sub-grad-* paints graphics, --sub-ink-* paints text: the cyan end darkens
+     so text clears 4.5:1. --sub-accent is ACCENT.primary in SubPage.tsx. */
+  --sub-grad-from: #8b5cf6;
+  --sub-grad-to: #06b6d4;
+  --sub-ink-from: #6d28d9;
+  --sub-ink-to: #0e7490;
+  --sub-accent: #7c3aed;
+  --bg-page: linear-gradient(135deg, #e9e4ff 0%, #ddeefc 52%, #e2f7f2 100%);
+  --sub-card-bg: rgba(255, 255, 255, 0.72);
+  --sub-card-border: rgba(255, 255, 255, 0.7);
+  --sub-card-shadow: 0 1px 3px rgba(15, 23, 42, 0.05), 0 20px 56px rgba(124, 58, 237, 0.16);
+  --sub-card-sheen: linear-gradient(
+    135deg,
+    rgba(255, 255, 255, 0.75),
+    rgba(255, 255, 255, 0) 42%,
+    rgba(124, 58, 237, 0.22) 88%
+  );
+  --sub-blob-1: rgba(139, 92, 246, 0.55);
+  --sub-blob-2: rgba(6, 182, 212, 0.45);
+  --sub-grid: rgba(124, 58, 237, 0.06);
+  --sub-hairline: linear-gradient(90deg, rgba(139, 92, 246, 0.4), rgba(6, 182, 212, 0.4));
+  --sub-tile-bg: rgba(124, 58, 237, 0.05);
+  --sub-tile-border: rgba(124, 58, 237, 0.12);
+  --sub-row-bg: rgba(124, 58, 237, 0.045);
+  --sub-row-border: rgba(124, 58, 237, 0.11);
+  --sub-row-bg-hover: rgba(124, 58, 237, 0.09);
+  --sub-row-border-hover: rgba(124, 58, 237, 0.3);
+  --sub-row-glow: rgba(124, 58, 237, 0.28);
+  --sub-glass-bg: rgba(255, 255, 255, 0.6);
+
+  position: relative;
   min-height: 100vh;
   background: var(--bg-page);
 }
 
 .subscription-page.is-dark {
-  --bg-page: #1a1b1f;
-  --bg-card: #23252b;
+  --sub-grad-from: #a78bfa;
+  --sub-grad-to: #22d3ee;
+  --sub-ink-from: #c4b5fd;
+  --sub-ink-to: #67e8f9;
+  --sub-accent: #a78bfa;
+  --bg-page: radial-gradient(ellipse 120% 90% at 18% -10%, #1f1740 0%, #16171d 52%, #101116 100%);
+  --sub-card-bg: rgba(35, 37, 43, 0.62);
+  --sub-card-border: rgba(255, 255, 255, 0.08);
+  --sub-card-shadow: 0 1px 3px rgba(0, 0, 0, 0.4), 0 24px 64px rgba(109, 40, 217, 0.24);
+  --sub-card-sheen: linear-gradient(
+    135deg,
+    rgba(255, 255, 255, 0.16),
+    rgba(255, 255, 255, 0) 42%,
+    rgba(167, 139, 250, 0.4) 88%
+  );
+  --sub-blob-1: rgba(139, 92, 246, 0.4);
+  --sub-blob-2: rgba(34, 211, 238, 0.26);
+  --sub-grid: rgba(255, 255, 255, 0.035);
+  --sub-hairline: linear-gradient(90deg, rgba(167, 139, 250, 0.45), rgba(34, 211, 238, 0.45));
+  --sub-tile-bg: rgba(167, 139, 250, 0.07);
+  --sub-tile-border: rgba(167, 139, 250, 0.14);
+  --sub-row-bg: rgba(167, 139, 250, 0.06);
+  --sub-row-border: rgba(167, 139, 250, 0.12);
+  --sub-row-bg-hover: rgba(167, 139, 250, 0.12);
+  --sub-row-border-hover: rgba(167, 139, 250, 0.35);
+  --sub-row-glow: rgba(139, 92, 246, 0.45);
+  --sub-glass-bg: rgba(255, 255, 255, 0.06);
 }
 
 .subscription-page.is-dark.is-ultra {
-  --bg-page: #000;
-  --bg-card: #101013;
+  --bg-page: radial-gradient(ellipse 120% 90% at 18% -10%, #120a2b 0%, #050509 55%, #000 100%);
+  --sub-card-bg: rgba(16, 16, 19, 0.68);
+  --sub-card-border: rgba(255, 255, 255, 0.055);
+  --sub-card-shadow: 0 1px 3px rgba(0, 0, 0, 0.6), 0 24px 64px rgba(88, 28, 135, 0.3);
+  --sub-blob-1: rgba(139, 92, 246, 0.22);
+  --sub-blob-2: rgba(34, 211, 238, 0.14);
+  --sub-grid: rgba(255, 255, 255, 0.022);
+  --sub-glass-bg: rgba(255, 255, 255, 0.04);
 }
 
 .subscription-page .ant-layout,
@@ -20,112 +80,645 @@
   background: transparent;
 }
 
-.subscription-page .content {
-  padding: 24px 12px;
+/* aurora backdrop */
+.sub-aurora {
+  position: fixed;
+  inset: 0;
+  z-index: 0;
+  overflow: hidden;
+  pointer-events: none;
 }
 
-.subscription-card {
-  margin-top: 8px;
+.sub-aurora-grid {
+  position: absolute;
+  inset: 0;
+  background-image:
+    linear-gradient(var(--sub-grid) 1px, transparent 1px),
+    linear-gradient(90deg, var(--sub-grid) 1px, transparent 1px);
+  background-size: 48px 48px;
+  background-position: center;
+  -webkit-mask-image: radial-gradient(ellipse at 50% 30%, black 20%, transparent 72%);
+  mask-image: radial-gradient(ellipse at 50% 30%, black 20%, transparent 72%);
 }
 
-.qr-tag {
-  width: 100%;
-  text-align: center;
-  margin: 0;
+.sub-aurora::before,
+.sub-aurora::after {
+  content: '';
+  position: absolute;
+  width: 70vmax;
+  height: 70vmax;
+  max-width: 820px;
+  max-height: 820px;
+  border-radius: 50%;
+  filter: blur(80px);
+  will-change: transform;
+}
+
+.sub-aurora::before {
+  top: -22vmax;
+  left: -16vmax;
+  background: radial-gradient(circle, var(--sub-blob-1) 0%, transparent 65%);
+  animation: sub-blob-a 28s ease-in-out infinite alternate;
+}
+
+.sub-aurora::after {
+  bottom: -24vmax;
+  right: -18vmax;
+  background: radial-gradient(circle, var(--sub-blob-2) 0%, transparent 65%);
+  animation: sub-blob-b 34s ease-in-out infinite alternate;
+}
+
+@keyframes sub-blob-a {
+  0% {
+    transform: translate(0, 0) scale(1);
+  }
+  100% {
+    transform: translate(16vw, 14vh) scale(1.18);
+  }
+}
+
+@keyframes sub-blob-b {
+  0% {
+    transform: translate(0, 0) scale(1);
+  }
+  100% {
+    transform: translate(-14vw, -12vh) scale(1.15);
+  }
+}
+
+.sub-content {
+  position: relative;
+  z-index: 1;
+  padding: 32px 16px;
+}
+
+.sub-card {
+  max-width: 880px;
+  margin: 0 auto;
+}
+
+.subscription-page .sub-card {
+  position: relative;
+  border-radius: 20px;
+  border: 1px solid var(--sub-card-border);
+  background: var(--sub-card-bg);
+  box-shadow: var(--sub-card-shadow);
+  -webkit-backdrop-filter: blur(24px) saturate(180%);
+  backdrop-filter: blur(24px) saturate(180%);
+}
+
+/* Hairline gradient rim: a padded sheen layer with its own middle masked out. */
+.subscription-page .sub-card::before {
+  content: '';
+  position: absolute;
+  inset: 0;
+  z-index: 0;
+  border-radius: inherit;
+  padding: 1px;
+  background: var(--sub-card-sheen);
+  -webkit-mask:
+    linear-gradient(#000 0 0) content-box,
+    linear-gradient(#000 0 0);
+  -webkit-mask-composite: xor;
+  mask-composite: exclude;
+  pointer-events: none;
+}
+
+.sub-card > .ant-card-body {
+  position: relative;
+  z-index: 1;
+  padding: 28px;
+}
+
+.sub-label,
+.sub-muted {
+  font-size: 12px;
+  color: var(--ant-color-text-tertiary);
+}
+
+.sub-muted {
+  font-size: 13px;
 }
 
-.info-table {
-  margin-top: 4px;
+/* Gradient hairline shared by the header, the stats grid and the footer. */
+.sub-header::after,
+.sub-stats::before,
+.sub-footer::before {
+  content: '';
+  position: absolute;
+  inset-inline: 0;
+  height: 1px;
+  background: var(--sub-hairline);
+  opacity: 0.7;
 }
 
-.links-section {
+/* header */
+.sub-header {
+  position: relative;
   display: flex;
-  flex-direction: column;
-  gap: 8px;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+  padding-bottom: 20px;
+  margin-bottom: 24px;
 }
 
-.sub-link-anchor {
-  color: inherit;
-  text-decoration: none;
+.sub-header::after {
+  bottom: 0;
 }
 
-.sub-link-anchor:hover {
-  text-decoration: underline;
+.sub-brand {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  min-width: 0;
 }
 
-.sub-link-row {
+.sub-brand-mark {
+  width: 44px;
+  height: 44px;
+  flex-shrink: 0;
   display: flex;
   align-items: center;
+  justify-content: center;
+  border-radius: 13px;
+  background: linear-gradient(135deg, var(--sub-grad-from), var(--sub-grad-to));
+  box-shadow: 0 8px 20px -8px var(--sub-row-glow);
+  color: #fff;
+  font-size: 20px;
+  font-weight: 600;
+}
+
+.sub-brand-text {
+  min-width: 0;
+}
+
+.sub-brand-title,
+.sub-brand-id {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.sub-brand-title {
+  font-size: 18px;
+  font-weight: 600;
+  line-height: 1.3;
+  color: var(--ant-color-text);
+}
+
+.sub-brand-id {
+  font-size: 12px;
+  color: var(--ant-color-text-tertiary);
+}
+
+.sub-toolbar {
+  display: flex;
   gap: 8px;
-  padding: 8px 12px;
+  flex-shrink: 0;
+}
+
+.toolbar-btn {
+  width: 40px;
+  height: 40px;
+  min-width: 40px;
+  border-radius: 50%;
+  padding: 0;
+}
+
+.toolbar-btn .anticon {
+  font-size: 18px;
+}
+
+.subscription-page .toolbar-btn {
+  border-color: var(--sub-row-border);
+  background: var(--sub-glass-bg);
+  color: var(--sub-accent);
+  -webkit-backdrop-filter: blur(8px);
+  backdrop-filter: blur(8px);
+}
+
+.subscription-page .toolbar-btn:hover {
+  border-color: var(--sub-row-border-hover);
+  background: var(--sub-row-bg-hover);
+  color: var(--sub-accent);
+}
+
+.sub-announce {
+  margin-bottom: 24px;
+}
+
+/* usage hero */
+.sub-hero {
+  display: grid;
+  grid-template-columns: auto minmax(0, 1fr);
+  gap: 32px;
+  align-items: center;
+}
+
+.sub-ring .ant-progress-text {
+  color: var(--ant-color-text);
+}
+
+.sub-ring-center {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 4px;
+  line-height: 1.1;
+}
+
+.sub-ring-value {
+  font-size: 26px;
+  font-weight: 600;
+  font-variant-numeric: tabular-nums;
+}
+
+.sub-ring-label {
+  font-size: 12px;
+  color: var(--ant-color-text-tertiary);
+}
+
+.sub-big {
+  display: inline-flex;
+  align-items: baseline;
+  gap: 6px;
+  margin: 2px 0;
+  line-height: 1.1;
+}
+
+.sub-big-num {
+  font-size: 44px;
+  font-weight: 700;
+  letter-spacing: -0.02em;
+  font-variant-numeric: tabular-nums;
+  background: linear-gradient(135deg, var(--sub-ink-from), var(--sub-ink-to));
+  -webkit-background-clip: text;
+  background-clip: text;
+  -webkit-text-fill-color: transparent;
+  color: var(--sub-ink-from);
+}
+
+.sub-big-unit {
+  font-size: 18px;
+  color: var(--ant-color-text-secondary);
+}
+
+.sub-hero.is-alert .sub-big-num {
+  background: none;
+  -webkit-text-fill-color: var(--ant-color-error);
+  color: var(--ant-color-error);
+}
+
+.sub-stats {
+  position: relative;
+  display: grid;
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+  gap: 10px;
+  margin: 20px 0 0;
+  padding-top: 20px;
+}
+
+.sub-stats::before {
+  top: 0;
+}
+
+.sub-stat {
+  padding: 10px 12px;
+  border-radius: 12px;
+  border: 1px solid var(--sub-tile-border);
+  background: var(--sub-tile-bg);
+}
+
+.sub-stat-value {
+  margin: 2px 0 0;
+  font-size: 14px;
+  font-weight: 600;
+  color: var(--ant-color-text);
+  font-variant-numeric: tabular-nums;
+  overflow-wrap: anywhere;
+}
+
+.sub-stat-value .ant-tag {
+  margin: 0;
+}
+
+/* tabs */
+.sub-tabs {
+  margin-top: 28px;
+}
+
+.sub-tabs.ant-tabs .ant-tabs-ink-bar {
+  height: 3px;
+  border-radius: 2px;
+  background: linear-gradient(90deg, var(--sub-grad-from), var(--sub-grad-to));
+}
+
+.sub-tab-count {
+  margin-inline-start: 6px;
+  padding: 0 7px;
   border-radius: 10px;
-  background: rgba(0, 0, 0, 0.03);
-  border: 1px solid rgba(0, 0, 0, 0.08);
-  transition:
-    background 120ms ease,
-    border-color 120ms ease;
+  font-size: 12px;
+  background: var(--sub-tile-bg);
+  border: 1px solid var(--sub-tile-border);
+  color: var(--sub-accent);
 }
 
-.sub-link-row:hover {
-  background: rgba(0, 0, 0, 0.05);
-  border-color: rgba(0, 0, 0, 0.14);
+.sub-rows {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
 }
 
-.is-dark .sub-link-row {
-  background: rgba(0, 0, 0, 0.2);
-  border-color: rgba(255, 255, 255, 0.1);
+.sub-row {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  min-width: 0;
+  padding: 10px 12px;
+  border-radius: 12px;
+  background: var(--sub-row-bg);
+  border: 1px solid var(--sub-row-border);
+  transition:
+    background 160ms ease,
+    border-color 160ms ease,
+    box-shadow 160ms ease,
+    transform 160ms ease;
 }
 
-.is-dark .sub-link-row:hover {
-  background: rgba(0, 0, 0, 0.3);
-  border-color: rgba(255, 255, 255, 0.2);
+.sub-row:hover {
+  background: var(--sub-row-bg-hover);
+  border-color: var(--sub-row-border-hover);
+  box-shadow: 0 8px 20px -14px var(--sub-row-glow);
+  transform: translateY(-1px);
 }
 
-.sub-link-tag {
+.sub-row-tag {
   margin: 0;
   flex-shrink: 0;
   font-weight: 600;
   letter-spacing: 0.3px;
 }
 
-.sub-link-title {
+.sub-row-main {
+  flex: 1;
+  min-width: 0;
+}
+
+.sub-row-title {
+  display: block;
+  font-size: 14px;
+  color: var(--ant-color-text);
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.sub-row > .sub-row-title {
   flex: 1;
   min-width: 0;
   font-size: 13px;
+}
+
+a.sub-row-title:hover {
+  color: var(--sub-accent);
+}
+
+.sub-row-url {
+  font-size: 12px;
+  color: var(--ant-color-text-tertiary);
   overflow: hidden;
   text-overflow: ellipsis;
   white-space: nowrap;
+  text-align: left;
 }
 
-.sub-link-actions {
+[dir='rtl'] .sub-row-url,
+[dir='rtl'] .sub-row > .sub-row-title {
+  text-align: right;
+}
+
+.sub-row-actions {
   display: flex;
   gap: 4px;
   flex-shrink: 0;
 }
 
-.sub-link-qr-popover {
+.sub-qr-modal .ant-modal-title {
+  font-size: 20px;
+  font-weight: 600;
+}
+
+.sub-qr-modal .ant-modal-close {
+  width: 36px;
+  height: 36px;
+  border: 1px solid var(--ant-color-border-secondary);
+  border-radius: 50%;
+}
+
+.sub-qr-modal-hint {
+  margin: 2px 0 20px;
+}
+
+.sub-qr-modal-code {
+  width: fit-content;
+  margin: 0 auto 20px;
+  border-radius: 8px;
+  background: #fff;
+  line-height: 0;
+}
+
+.sub-qr-modal-code canvas {
+  display: block;
+}
+
+.sub-qr-modal-link {
+  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+  font-size: 13px;
+}
+
+.sub-qr-modal-actions {
+  display: flex;
+  gap: 12px;
+  margin-top: 20px;
+}
+
+.sub-qr-modal-actions > .ant-btn:first-child {
+  flex: 1;
+}
+
+.sub-qr-card {
+  display: flex;
+  align-items: center;
+  gap: 16px;
+  margin-top: 8px;
+  padding: 16px;
+  border-radius: 14px;
+  border: 1px dashed var(--sub-row-border-hover);
+  background: var(--sub-row-bg);
+}
+
+.sub-qr-code {
+  flex-shrink: 0;
+  padding: 6px;
+  border-radius: 8px;
+  background: #fff;
+  line-height: 0;
+}
+
+.sub-qr-title {
+  margin-bottom: 4px;
+  font-size: 15px;
+  font-weight: 600;
+  color: var(--ant-color-text);
+}
+
+/* apps */
+.sub-apps {
   display: flex;
   flex-direction: column;
+  align-items: flex-start;
+  gap: 16px;
+}
+
+.sub-app-grid {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 8px;
+  width: 100%;
+}
+
+.sub-app-mark {
+  width: 32px;
+  height: 32px;
+  flex-shrink: 0;
+  display: flex;
   align-items: center;
-  gap: 6px;
+  justify-content: center;
+  border-radius: 9px;
+  border: 1px solid var(--sub-tile-border);
+  background: linear-gradient(135deg, var(--sub-row-bg-hover), var(--sub-tile-bg));
+  color: var(--sub-accent);
+  font-weight: 600;
 }
 
-.apps-row {
-  margin-top: 24px;
+.sub-app-glyph {
+  width: 22px;
+  height: 22px;
+  background: currentColor;
+  -webkit-mask-position: center;
+  mask-position: center;
+  -webkit-mask-repeat: no-repeat;
+  mask-repeat: no-repeat;
+  -webkit-mask-size: contain;
+  mask-size: contain;
 }
 
-.app-col {
-  text-align: center;
+.sub-app-logo {
+  width: 32px;
+  height: 32px;
+  flex-shrink: 0;
+  border-radius: 9px;
+  object-fit: cover;
+  box-shadow: 0 0 0 1px var(--sub-tile-border);
 }
 
-.toolbar-btn {
-  width: 40px;
-  height: 40px;
-  min-width: 40px;
-  border-radius: 50%;
-  padding: 0;
+.sub-app-name {
+  flex: 1;
+  min-width: 0;
+  font-size: 14px;
+  color: var(--ant-color-text);
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
 }
 
-.toolbar-btn .anticon {
-  font-size: 18px;
+/* configs */
+.sub-configs-bar {
+  display: flex;
+  justify-content: flex-end;
+  margin-bottom: 4px;
+}
+
+/* footer */
+.sub-footer {
+  position: relative;
+  display: flex;
+  flex-wrap: wrap;
+  justify-content: space-between;
+  gap: 8px 16px;
+  margin-top: 28px;
+  padding-top: 16px;
+  font-size: 12px;
+  color: var(--ant-color-text-tertiary);
+}
+
+.sub-footer::before {
+  top: 0;
+}
+
+.sub-footer > span,
+.sub-footer > a {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+}
+
+@media (prefers-reduced-motion: reduce) {
+  .sub-aurora::before,
+  .sub-aurora::after {
+    animation: none;
+  }
+
+  .sub-row:hover {
+    transform: none;
+  }
+}
+
+@media (max-width: 576px) {
+  .sub-content {
+    padding: 16px 8px;
+  }
+
+  .sub-card > .ant-card-body {
+    padding: 16px;
+  }
+
+  /* One static blob: two animated 70vmax blurs drop frames on low-end phones. */
+  .sub-aurora::before {
+    animation: none;
+  }
+
+  .sub-aurora::after {
+    display: none;
+  }
+
+  .sub-hero {
+    grid-template-columns: minmax(0, 1fr);
+    gap: 20px;
+  }
+
+  .sub-ring {
+    justify-self: center;
+  }
+
+  .sub-big-num {
+    font-size: 36px;
+  }
+
+  .sub-stats {
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+  }
+
+  .sub-app-grid {
+    grid-template-columns: minmax(0, 1fr);
+  }
+
+  .sub-tabs .ant-tabs-tab-icon {
+    display: none;
+  }
+
+  .sub-qr-card {
+    display: none;
+  }
 }

+ 161 - 584
frontend/src/pages/sub/SubPage.tsx

@@ -1,98 +1,89 @@
-import { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
+import { useCallback, useEffect, useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
+import { Alert, Card, ConfigProvider, Layout, Tabs, message } from 'antd';
+import type { TabsProps } from 'antd';
 import {
-  Alert,
-  Button,
-  Card,
-  Col,
-  ConfigProvider,
-  Descriptions,
-  Divider,
-  Dropdown,
-  Layout,
-  Menu,
-  message,
-  Popover,
-  QRCode,
-  Row,
-  Space,
-  Tag,
-  Tooltip,
-} from 'antd';
-import {
-  AndroidOutlined,
-  AppleOutlined,
-  CopyOutlined,
-  DownOutlined,
-  DownloadOutlined,
-  MoonFilled,
-  MoonOutlined,
-  QrcodeOutlined,
-  SunOutlined,
-  TranslationOutlined,
+  AppstoreOutlined,
+  ClockCircleOutlined,
+  CustomerServiceOutlined,
+  LinkOutlined,
+  UnorderedListOutlined,
 } from '@ant-design/icons';
 
-import { ClipboardManager, IntlUtil, LanguageManager } from '@/utils';
-import {
-  amneziawgConfigFromLink,
-  isPostQuantumLink,
-  wireguardConfigFromLink,
-} from '@/lib/xray/inbound-link';
-import { LinkTags, parseLinkParts } from '@/lib/xray/link-label';
-import ConfigBlock from '@/components/clients/ConfigBlock';
+import { ClipboardManager, LanguageManager } from '@/utils';
 import { setMessageInstance } from '@/utils/messageBus';
-import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
-import { useMediaQuery } from '@/hooks/useMediaQuery';
-import SubUsageSummary from './SubUsageSummary';
+import { useTheme } from '@/hooks/useTheme';
+import SubAppsTab from './SubAppsTab';
+import SubConfigsTab from './SubConfigsTab';
+import SubHeader from './SubHeader';
+import SubHero from './SubHero';
+import SubLinksTab from './SubLinksTab';
+import { buildSubApps, daysUntil, detectPlatform, resolveSubStatus } from './subPageModel';
 import './SubPage.css';
 
-const QR_SIZE = 240;
-
 const subData = window.__SUB_PAGE_DATA__ || {};
 
 const sId = subData.sId || '';
-const enabled = !!subData.enabled;
-const download = subData.download || '0';
-const upload = subData.upload || '0';
-const total = subData.total || '∞';
-const used = subData.used || '0';
-const remained = subData.remained || '';
-const totalByte = Number(subData.totalByte || 0);
-const expireMs = Number(subData.expire || 0) * 1000;
-const lastOnlineMs = Number(subData.lastOnline || 0);
 const subUrl = subData.subUrl || '';
 const subJsonUrl = subData.subJsonUrl || '';
 const subClashUrl = subData.subClashUrl || '';
 const subTitle = subData.subTitle || '';
+const subSupportUrl = subData.subSupportUrl || '';
+const updateHours = Number(subData.subUpdates || 0);
+const announce = subData.announce || '';
 const links: string[] = Array.isArray(subData.links) ? subData.links : [];
 const linkEmails: string[] = Array.isArray(subData.emails) ? subData.emails : [];
-const subEmail = [...new Set(linkEmails.filter(Boolean))].join(', ');
-const datepicker = subData.datepicker || 'gregorian';
-const announce = subData.announce || '';
-
-const appendRawView = (url: string) => `${url}${url.includes('?') ? '&' : '?'}view=raw`;
-
-const isUnlimited = totalByte <= 0 && expireMs === 0;
-const isActive = (() => {
-  if (!enabled) return false;
-  if (totalByte > 0) {
-    const usedByteCalc =
-      Number(subData.usedByte || 0) ||
-      Number(subData.downloadByte || 0) + Number(subData.uploadByte || 0);
-    if (usedByteCalc >= totalByte) return false;
-  }
-  if (expireMs > 0 && Date.now() >= expireMs) return false;
-  return true;
-})();
+const totalByte = Number(subData.totalByte || 0);
+const usedByte =
+  Number(subData.usedByte || 0) ||
+  Number(subData.downloadByte || 0) + Number(subData.uploadByte || 0);
+const expireMs = Number(subData.expire || 0) * 1000;
+const clientEmail = [...new Set(linkEmails.filter(Boolean))].join(', ');
+const loadedAt = Date.now();
+
+const heroData = {
+  status: resolveSubStatus({ enabled: !!subData.enabled, usedByte, totalByte, expireMs }, loadedAt),
+  daysLeft: daysUntil(expireMs, loadedAt),
+  usedByte,
+  totalByte,
+  expireMs,
+  lastOnlineMs: Number(subData.lastOnline || 0),
+  download: subData.download || '0',
+  upload: subData.upload || '0',
+  used: subData.used || '0',
+  total: subData.total || '∞',
+  remained: subData.remained || '',
+  datepicker: subData.datepicker || 'gregorian',
+};
+
+const apps = buildSubApps({ subUrl, sId, subTitle });
+const initialPlatform = detectPlatform(navigator.userAgent);
+const RTL_LANGUAGES = new Set(['fa-IR', 'ar-EG']);
+
+// The sub page runs its own violet accent, so every antd control on it picks the
+// hue up instead of the panel blue useTheme pins. Mirrored in SubPage.css.
+const ACCENT = {
+  light: {
+    primary: '#7c3aed',
+    hover: '#8b5cf6',
+    active: '#6d28d9',
+    rail: 'rgba(124, 58, 237, 0.16)',
+  },
+  dark: {
+    primary: '#a78bfa',
+    hover: '#c4b5fd',
+    active: '#8b5cf6',
+    rail: 'rgba(167, 139, 250, 0.18)',
+  },
+};
 
 export default function SubPage() {
   const { t } = useTranslation();
-  const { isDark, isUltra, toggleTheme, toggleUltra, antdThemeConfig } = useTheme();
+  const { isDark, isUltra, antdThemeConfig } = useTheme();
   const [messageApi, messageContextHolder] = message.useMessage();
   useEffect(() => {
     setMessageInstance(messageApi);
   }, [messageApi]);
-  const { isMobile } = useMediaQuery(576);
   const [lang, setLang] = useState<string>(() => LanguageManager.getLanguage('subscription'));
 
   const onLangChange = useCallback((next: string) => {
@@ -100,538 +91,124 @@ export default function SubPage() {
     LanguageManager.setLanguage(next, 'subscription');
   }, []);
 
-  const cycleTheme = useCallback(() => {
-    pauseAnimationsUntilLeave('sub-theme-cycle');
-    if (!isDark) {
-      toggleTheme();
-      if (isUltra) toggleUltra();
-    } else if (!isUltra) {
-      toggleUltra();
-    } else {
-      toggleUltra();
-      toggleTheme();
-    }
-  }, [isDark, isUltra, toggleTheme, toggleUltra]);
-
   const copy = useCallback(
-    async (value: string) => {
+    async (value: string, toast?: string) => {
       if (!value) return;
       const ok = await ClipboardManager.copyText(value);
-      if (ok) messageApi.success(t('copied'));
+      if (ok) messageApi.success(toast ?? t('copied'));
     },
     [t, messageApi],
   );
 
-  const copyAll = useCallback(async () => {
-    if (links.length === 0) return;
-    const allLinks = links.join('\n');
-    const ok = await ClipboardManager.copyText(allLinks);
-    if (ok) messageApi.success(t('subscription.copyAllConfigsCopied'));
-  }, [t, messageApi]);
-
   const open = useCallback((url: string) => {
-    if (!url) return;
-    window.open(url, '_blank');
-  }, []);
-
-  const shadowrocketUrl = useMemo(() => {
-    if (!subUrl) return '';
-    const separator = subUrl.includes('?') ? '&' : '?';
-    const rawUrl = subUrl + separator + 'flag=shadowrocket';
-    const base64Url = btoa(rawUrl);
-    const remark = encodeURIComponent(subTitle || sId || 'Subscription');
-    return `shadowrocket://add/sub://${base64Url}?remark=${remark}`;
+    if (url) window.open(url, '_blank');
   }, []);
 
-  const v2boxUrl = useMemo(
-    () => `v2box://install-sub?url=${encodeURIComponent(subUrl)}&name=${encodeURIComponent(sId)}`,
-    [],
-  );
-  const streisandUrl = useMemo(() => `streisand://import/${encodeURIComponent(subUrl)}`, []);
-  const happUrl = useMemo(() => `happ://add/${subUrl}`, []);
-  const incyUrl = useMemo(() => `incy://add/${subUrl}`, []);
-
-  const pageClass = useMemo(() => {
-    const classes = ['subscription-page'];
-    if (isDark) classes.push('is-dark');
-    if (isUltra) classes.push('is-ultra');
-    return classes.join(' ');
-  }, [isDark, isUltra]);
-
-  const descriptionsItems = useMemo(() => {
-    const items = [
-      { key: 'subId', label: t('subscription.subId'), children: sId },
-      ...(subEmail ? [{ key: 'email', label: t('subscription.email'), children: subEmail }] : []),
-      {
-        key: 'status',
-        label: t('subscription.status'),
-        children: !enabled ? (
-          <Tag color="red">{t('subscription.inactive')}</Tag>
-        ) : isUnlimited ? (
-          <Tag color="purple">{t('subscription.unlimited')}</Tag>
-        ) : (
-          <Tag color={isActive ? 'green' : 'red'}>
-            {isActive ? t('subscription.active') : t('subscription.inactive')}
-          </Tag>
+  const tabs = useMemo(() => {
+    const items: NonNullable<TabsProps['items']> = [];
+    if (subUrl || subJsonUrl || subClashUrl) {
+      items.push({
+        key: 'subscription',
+        icon: <LinkOutlined />,
+        label: t('subscription.tabLinks'),
+        children: (
+          <SubLinksTab
+            subUrl={subUrl}
+            subJsonUrl={subJsonUrl}
+            subClashUrl={subClashUrl}
+            onCopy={copy}
+          />
         ),
-      },
-      { key: 'down', label: t('subscription.downloaded'), children: download },
-      { key: 'up', label: t('subscription.uploaded'), children: upload },
-      { key: 'used', label: t('usage'), children: used },
-      { key: 'total', label: t('subscription.totalQuota'), children: total },
-    ];
-    if (totalByte > 0) {
-      items.push({ key: 'remained', label: t('remained'), children: remained });
+      });
+    }
+    if (subUrl) {
+      items.push({
+        key: 'apps',
+        icon: <AppstoreOutlined />,
+        label: t('subscription.tabApps'),
+        children: <SubAppsTab apps={apps} initialPlatform={initialPlatform} onOpen={open} />,
+      });
+    }
+    if (links.length > 0) {
+      items.push({
+        key: 'configs',
+        icon: <UnorderedListOutlined />,
+        label: (
+          <>
+            {t('subscription.tabConfigs')}
+            <span className="sub-tab-count">{links.length}</span>
+          </>
+        ),
+        children: <SubConfigsTab links={links} onCopy={copy} />,
+      });
     }
-    items.push({
-      key: 'lastOnline',
-      label: t('lastOnline'),
-      children: lastOnlineMs > 0 ? IntlUtil.formatDate(lastOnlineMs, datepicker, lang) : '-',
-    });
-    items.push({
-      key: 'expiry',
-      label: t('subscription.expiry'),
-      children:
-        expireMs === 0
-          ? t('subscription.noExpiry')
-          : IntlUtil.formatDate(expireMs, datepicker, lang),
-    });
     return items;
-  }, [t, lang]);
-
-  const androidMenuItems = useMemo(
-    () => [
-      {
-        key: 'android-v2box',
-        label: 'V2Box',
-        onClick: () =>
-          open(
-            `v2box://install-sub?url=${encodeURIComponent(subUrl)}&name=${encodeURIComponent(sId)}`,
-          ),
+  }, [t, copy, open]);
+
+  const direction = RTL_LANGUAGES.has(lang) ? 'rtl' : 'ltr';
+  const pageClass = ['subscription-page', isDark && 'is-dark', isUltra && 'is-ultra']
+    .filter(Boolean)
+    .join(' ');
+
+  const themeConfig = useMemo(() => {
+    const accent = isDark ? ACCENT.dark : ACCENT.light;
+    const primary = {
+      colorPrimary: accent.primary,
+      colorPrimaryHover: accent.hover,
+      colorPrimaryActive: accent.active,
+    };
+    return {
+      ...antdThemeConfig,
+      token: {
+        ...antdThemeConfig.token,
+        ...primary,
+        colorLink: accent.primary,
+        colorInfo: accent.primary,
       },
-      {
-        key: 'android-v2rayng',
-        label: 'V2RayNG',
-        onClick: () => open(`v2rayng://install-config?url=${encodeURIComponent(subUrl)}`),
+      components: {
+        ...antdThemeConfig.components,
+        Button: { ...antdThemeConfig.components?.Button, ...primary },
+        Progress: { ...antdThemeConfig.components?.Progress, remainingColor: accent.rail },
       },
-      { key: 'android-singbox', label: 'Sing-box', onClick: () => copy(subUrl) },
-      { key: 'android-v2raytun', label: 'V2RayTun', onClick: () => copy(subUrl) },
-      { key: 'android-npvtunnel', label: 'NPV Tunnel', onClick: () => copy(subUrl) },
-      { key: 'android-happ', label: 'Happ', onClick: () => open(`happ://add/${subUrl}`) },
-      { key: 'android-incy', label: 'Incy', onClick: () => open(`incy://add/${subUrl}`) },
-    ],
-    [copy, open],
-  );
-
-  const iosMenuItems = useMemo(
-    () => [
-      { key: 'ios-shadowrocket', label: 'Shadowrocket', onClick: () => open(shadowrocketUrl) },
-      { key: 'ios-v2box', label: 'V2Box', onClick: () => open(v2boxUrl) },
-      { key: 'ios-streisand', label: 'Streisand', onClick: () => open(streisandUrl) },
-      { key: 'ios-v2raytun', label: 'V2RayTun', onClick: () => copy(subUrl) },
-      { key: 'ios-npvtunnel', label: 'NPV Tunnel', onClick: () => copy(subUrl) },
-      { key: 'ios-happ', label: 'Happ', onClick: () => open(happUrl) },
-      { key: 'ios-incy', label: 'Incy', onClick: () => open(incyUrl) },
-    ],
-    [copy, open, shadowrocketUrl, v2boxUrl, streisandUrl, happUrl, incyUrl],
-  );
-
-  const langMenuItems = useMemo(
-    () =>
-      (LanguageManager.supportedLanguages as { value: string; name: string; icon: string }[]).map(
-        (l) => ({
-          key: l.value,
-          label: (
-            <Space size={8}>
-              <span aria-hidden="true">{l.icon}</span>
-              <span>{l.name}</span>
-            </Space>
-          ),
-        }),
-      ),
-    [],
-  );
-
-  const themeIcon = !isDark ? <SunOutlined /> : !isUltra ? <MoonOutlined /> : <MoonFilled />;
-
-  const cardTitle = (
-    <Space>
-      <span>{t('subscription.title')}</span>
-      <Tag>{sId}</Tag>
-    </Space>
-  );
-
-  const cardExtra = (
-    <Space size={8} align="center">
-      <Button
-        shape="circle"
-        size="large"
-        className="toolbar-btn"
-        aria-label={t('menu.theme')}
-        title={t('menu.theme')}
-        icon={themeIcon}
-        onClick={cycleTheme}
-      />
-      <Popover
-        rootClassName={isDark ? 'dark' : 'light'}
-        placement="bottomRight"
-        trigger="click"
-        styles={{ content: { padding: 4 } }}
-        content={
-          <Menu
-            mode="vertical"
-            selectable
-            selectedKeys={[lang]}
-            items={langMenuItems}
-            onClick={({ key }) => onLangChange(key)}
-            style={{ border: 'none', minWidth: 160 }}
-          />
-        }
-      >
-        <Button
-          shape="circle"
-          size="large"
-          className="toolbar-btn"
-          aria-label={t('pages.settings.language')}
-          icon={<TranslationOutlined />}
-        />
-      </Popover>
-    </Space>
-  );
+    };
+  }, [antdThemeConfig, isDark]);
 
   return (
-    <ConfigProvider theme={antdThemeConfig}>
+    <ConfigProvider theme={themeConfig} direction={direction}>
       {messageContextHolder}
-      <Layout className={pageClass}>
-        <Layout.Content className="content">
-          <Row justify="center">
-            <Col xs={24} sm={22} md={18} lg={14} xl={12}>
-              <Card hoverable className="subscription-card" title={cardTitle} extra={cardExtra}>
-                {announce && (
-                  <Alert type="info" showIcon title={announce} style={{ marginBottom: 16 }} />
+      <Layout className={pageClass} dir={direction}>
+        <div className="sub-aurora" aria-hidden="true">
+          <span className="sub-aurora-grid" />
+        </div>
+        <Layout.Content className="sub-content">
+          <Card className="sub-card">
+            <SubHeader
+              title={subTitle}
+              sId={sId}
+              email={clientEmail}
+              lang={lang}
+              onLangChange={onLangChange}
+            />
+            {announce && <Alert type="info" showIcon title={announce} className="sub-announce" />}
+            <SubHero {...heroData} lang={lang} />
+            {tabs.length > 0 && <Tabs className="sub-tabs" tabBarGutter={24} items={tabs} />}
+            {(updateHours > 0 || subSupportUrl) && (
+              <footer className="sub-footer">
+                {updateHours > 0 && (
+                  <span>
+                    <ClockCircleOutlined />
+                    {t('subscription.updateInterval', { hours: updateHours })}
+                  </span>
                 )}
-                <Descriptions
-                  bordered
-                  column={1}
-                  size="small"
-                  className="info-table"
-                  items={descriptionsItems}
-                />
-
-                <SubUsageSummary
-                  usedByte={
-                    Number(subData.usedByte || 0) ||
-                    Number(subData.downloadByte || 0) + Number(subData.uploadByte || 0)
-                  }
-                  totalByte={totalByte}
-                  usedLabel={used}
-                  totalLabel={total}
-                  remainedLabel={remained}
-                  expireMs={expireMs}
-                  isActive={isActive}
-                />
-
-                {(subUrl || subJsonUrl || subClashUrl) && (
-                  <>
-                    <Divider>{t('subscription.title')}</Divider>
-                    <div className="links-section">
-                      {subUrl && (
-                        <div className="sub-link-row">
-                          <Tag color="green" className="sub-link-tag">
-                            SUB
-                          </Tag>
-                          <a
-                            href={subUrl}
-                            target="_blank"
-                            rel="noopener noreferrer"
-                            className="sub-link-title sub-link-anchor"
-                            title={subUrl}
-                          >
-                            {sId}
-                          </a>
-                          <div className="sub-link-actions">
-                            <Button
-                              size="small"
-                              icon={<CopyOutlined />}
-                              onClick={() => copy(subUrl)}
-                              aria-label={t('copy')}
-                              title={t('copy')}
-                            />
-                            <Popover
-                              trigger="click"
-                              placement="left"
-                              destroyOnHidden
-                              content={
-                                <div className="sub-link-qr-popover">
-                                  <Tag color="green" className="qr-tag">
-                                    {t('pages.settings.subSettings')}
-                                  </Tag>
-                                  <QRCode
-                                    value={subUrl}
-                                    size={QR_SIZE}
-                                    type="svg"
-                                    bordered={false}
-                                    color="#000000"
-                                    bgColor="#ffffff"
-                                  />
-                                </div>
-                              }
-                            >
-                              <Button
-                                size="small"
-                                icon={<QrcodeOutlined />}
-                                aria-label="QR"
-                                title="QR"
-                              />
-                            </Popover>
-                          </div>
-                        </div>
-                      )}
-                      {subJsonUrl && (
-                        <div className="sub-link-row">
-                          <Tag color="purple" className="sub-link-tag">
-                            JSON
-                          </Tag>
-                          <a
-                            href={subJsonUrl}
-                            target="_blank"
-                            rel="noopener noreferrer"
-                            className="sub-link-title sub-link-anchor"
-                            title={subJsonUrl}
-                          >
-                            {sId}
-                          </a>
-                          <div className="sub-link-actions">
-                            <Button
-                              size="small"
-                              href={appendRawView(subJsonUrl)}
-                              target="_blank"
-                              rel="noopener noreferrer"
-                              icon={<DownloadOutlined />}
-                              aria-label={t('download')}
-                              title={t('download')}
-                            />
-                            <Button
-                              size="small"
-                              icon={<CopyOutlined />}
-                              onClick={() => copy(subJsonUrl)}
-                              aria-label={t('copy')}
-                              title={t('copy')}
-                            />
-                            <Popover
-                              trigger="click"
-                              placement="left"
-                              destroyOnHidden
-                              content={
-                                <div className="sub-link-qr-popover">
-                                  <Tag color="purple" className="qr-tag">
-                                    {t('pages.settings.subSettings')} JSON
-                                  </Tag>
-                                  <QRCode
-                                    value={subJsonUrl}
-                                    size={QR_SIZE}
-                                    type="svg"
-                                    bordered={false}
-                                    color="#000000"
-                                    bgColor="#ffffff"
-                                  />
-                                </div>
-                              }
-                            >
-                              <Button
-                                size="small"
-                                icon={<QrcodeOutlined />}
-                                aria-label="QR"
-                                title="QR"
-                              />
-                            </Popover>
-                          </div>
-                        </div>
-                      )}
-                      {subClashUrl && (
-                        <div className="sub-link-row">
-                          <Tooltip title="Clash / Mihomo">
-                            <Tag color="gold" className="sub-link-tag">
-                              CLASH
-                            </Tag>
-                          </Tooltip>
-                          <a
-                            href={subClashUrl}
-                            target="_blank"
-                            rel="noopener noreferrer"
-                            className="sub-link-title sub-link-anchor"
-                            title={subClashUrl}
-                          >
-                            {sId}
-                          </a>
-                          <div className="sub-link-actions">
-                            <Button
-                              size="small"
-                              href={appendRawView(subClashUrl)}
-                              target="_blank"
-                              rel="noopener noreferrer"
-                              icon={<DownloadOutlined />}
-                              aria-label={t('download')}
-                              title={t('download')}
-                            />
-                            <Button
-                              size="small"
-                              icon={<CopyOutlined />}
-                              onClick={() => copy(subClashUrl)}
-                              aria-label={t('copy')}
-                              title={t('copy')}
-                            />
-                            <Popover
-                              trigger="click"
-                              placement="left"
-                              destroyOnHidden
-                              content={
-                                <div className="sub-link-qr-popover">
-                                  <Tag color="gold" className="qr-tag">
-                                    Clash / Mihomo
-                                  </Tag>
-                                  <QRCode
-                                    value={subClashUrl}
-                                    size={QR_SIZE}
-                                    type="svg"
-                                    bordered={false}
-                                    color="#000000"
-                                    bgColor="#ffffff"
-                                  />
-                                </div>
-                              }
-                            >
-                              <Button
-                                size="small"
-                                icon={<QrcodeOutlined />}
-                                aria-label="QR"
-                                title="QR"
-                              />
-                            </Popover>
-                          </div>
-                        </div>
-                      )}
-                    </div>
-                  </>
+                {subSupportUrl && (
+                  <a href={subSupportUrl} target="_blank" rel="noopener noreferrer">
+                    <CustomerServiceOutlined />
+                    {t('subscription.support')}
+                  </a>
                 )}
-
-                {links.length > 0 && (
-                  <>
-                    <Divider>{t('pages.inbounds.copyLink')}</Divider>
-                    <div className="links-section">
-                      <div className="sub-link-row">
-                        <span className="sub-link-title">{t('subscription.copyAllConfigs')}</span>
-                        <div className="sub-link-actions">
-                          <Button
-                            size="small"
-                            icon={<CopyOutlined />}
-                            onClick={copyAll}
-                            aria-label={t('subscription.copyAllConfigs')}
-                            title={t('subscription.copyAllConfigs')}
-                          />
-                        </div>
-                      </div>
-                      {links.map((link, idx) => {
-                        const parts = parseLinkParts(link);
-                        const fallback = `Link ${idx + 1}`;
-                        const rowTitle = parts?.remark || fallback;
-                        const qrLabel = parts?.remark || rowTitle;
-                        const canQr = !isPostQuantumLink(link);
-                        const isWireguardLink =
-                          link.startsWith('wireguard://') || link.startsWith('wg://');
-                        const isAmneziawgLink = link.startsWith('vpn://');
-                        return (
-                          <Fragment key={link}>
-                            <div className="sub-link-row">
-                              {parts ? (
-                                <LinkTags parts={parts} />
-                              ) : (
-                                <Tag className="sub-link-tag">LINK</Tag>
-                              )}
-                              <span className="sub-link-title" title={rowTitle}>
-                                {rowTitle}
-                              </span>
-                              <div className="sub-link-actions">
-                                <Button
-                                  size="small"
-                                  icon={<CopyOutlined />}
-                                  onClick={() => copy(link)}
-                                  aria-label={t('copy')}
-                                  title={t('copy')}
-                                />
-                                {canQr && (
-                                  <Popover
-                                    trigger="click"
-                                    placement="left"
-                                    destroyOnHidden
-                                    content={
-                                      <div className="sub-link-qr-popover">
-                                        <Tag className="qr-tag">{qrLabel}</Tag>
-                                        <QRCode
-                                          value={link}
-                                          size={220}
-                                          type="svg"
-                                          bordered={false}
-                                          color="#000000"
-                                          bgColor="#ffffff"
-                                        />
-                                      </div>
-                                    }
-                                  >
-                                    <Button
-                                      size="small"
-                                      icon={<QrcodeOutlined />}
-                                      aria-label="QR"
-                                      title="QR"
-                                    />
-                                  </Popover>
-                                )}
-                              </div>
-                            </div>
-                            {isWireguardLink && (
-                              <ConfigBlock
-                                label={t('pages.clients.wireguardConfig')}
-                                text={wireguardConfigFromLink(link, rowTitle)}
-                                fileName={`${rowTitle || 'peer'}.conf`}
-                                qrRemark={rowTitle}
-                                tagColor="cyan"
-                              />
-                            )}
-                            {isAmneziawgLink && (
-                              <ConfigBlock
-                                label={t('pages.clients.amneziaWgConfig')}
-                                text={amneziawgConfigFromLink(link)}
-                                fileName={`${rowTitle || 'peer'}.conf`}
-                                qrRemark={rowTitle}
-                                tagColor="purple"
-                              />
-                            )}
-                          </Fragment>
-                        );
-                      })}
-                    </div>
-                  </>
-                )}
-
-                <Row gutter={[8, 8]} justify="center" className="apps-row">
-                  <Col xs={24} sm={12} className="app-col">
-                    <Dropdown trigger={['click']} menu={{ items: androidMenuItems }}>
-                      <Button block={isMobile} size="large" type="primary">
-                        <AndroidOutlined /> Android <DownOutlined />
-                      </Button>
-                    </Dropdown>
-                  </Col>
-                  <Col xs={24} sm={12} className="app-col">
-                    <Dropdown trigger={['click']} menu={{ items: iosMenuItems }}>
-                      <Button block={isMobile} size="large" type="primary">
-                        <AppleOutlined /> iOS <DownOutlined />
-                      </Button>
-                    </Dropdown>
-                  </Col>
-                </Row>
-              </Card>
-            </Col>
-          </Row>
+              </footer>
+            )}
+          </Card>
         </Layout.Content>
       </Layout>
     </ConfigProvider>

+ 69 - 0
frontend/src/pages/sub/SubQrButton.tsx

@@ -0,0 +1,69 @@
+import { useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Button, Input, Modal, QRCode } from 'antd';
+import { CopyOutlined, DownloadOutlined, QrcodeOutlined } from '@ant-design/icons';
+
+interface SubQrButtonProps {
+  value: string;
+  label: string;
+  onCopy: (value: string) => void;
+}
+
+export default function SubQrButton({ value, label, onCopy }: SubQrButtonProps) {
+  const { t } = useTranslation();
+  const [open, setOpen] = useState(false);
+  const qrRef = useRef<HTMLDivElement>(null);
+
+  const saveQr = () => {
+    const canvas = qrRef.current?.querySelector('canvas');
+    if (!canvas) return;
+    const link = document.createElement('a');
+    link.href = canvas.toDataURL('image/png');
+    link.download = `${label || 'qrcode'}.png`;
+    link.click();
+  };
+
+  return (
+    <>
+      <Button icon={<QrcodeOutlined />} aria-label="QR" title="QR" onClick={() => setOpen(true)} />
+      <Modal
+        open={open}
+        onCancel={() => setOpen(false)}
+        footer={null}
+        width={440}
+        centered
+        destroyOnHidden
+        rootClassName="sub-qr-modal"
+        title={t('subscription.qrTitle')}
+      >
+        <p className="sub-muted sub-qr-modal-hint">{t('subscription.qrHint')}</p>
+        <div ref={qrRef} className="sub-qr-modal-code">
+          <QRCode
+            value={value}
+            size={240}
+            type="canvas"
+            marginSize={2}
+            bordered={false}
+            color="#000000"
+            bgColor="#ffffff"
+          />
+        </div>
+        <Input.TextArea
+          className="sub-qr-modal-link"
+          value={value}
+          readOnly
+          dir="ltr"
+          autoSize={{ minRows: 2, maxRows: 5 }}
+        />
+        <div className="sub-qr-modal-actions">
+          <Button type="primary" size="large" icon={<CopyOutlined />} onClick={() => onCopy(value)}>
+            {t('copy')}
+          </Button>
+          <Button size="large" icon={<DownloadOutlined />} onClick={saveQr}>
+            {t('subscription.saveQr')}
+          </Button>
+        </div>
+      </Modal>
+    </>
+  );
+}

+ 0 - 87
frontend/src/pages/sub/SubUsageSummary.css

@@ -1,87 +0,0 @@
-.usage-summary {
-  margin-top: 12px;
-  padding: 14px 16px;
-  background: var(--ant-color-fill-alter);
-  border: 1px solid var(--ant-color-border-secondary);
-  border-radius: 12px;
-}
-
-.usage-summary.is-inactive {
-  opacity: 0.7;
-  border-color: var(--ant-color-error-border);
-}
-
-.usage-summary-head {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  gap: 12px;
-  margin-bottom: 8px;
-}
-
-.usage-summary-labels {
-  display: flex;
-  align-items: baseline;
-  gap: 6px;
-  font-variant-numeric: tabular-nums;
-  min-width: 0;
-}
-
-.usage-summary-used {
-  font-size: 18px;
-  font-weight: 700;
-  color: var(--ant-color-text);
-}
-
-.usage-summary-sep {
-  color: var(--ant-color-text-quaternary);
-  font-size: 16px;
-}
-
-.usage-summary-total {
-  font-size: 14px;
-  color: var(--ant-color-text-secondary);
-  font-weight: 500;
-}
-
-.usage-summary-chips {
-  display: flex;
-  align-items: center;
-  gap: 6px;
-  flex-shrink: 0;
-}
-
-.usage-summary-chips .ant-tag {
-  margin: 0;
-}
-
-.usage-summary-bar.ant-progress {
-  margin-bottom: 6px;
-}
-
-.usage-summary-bar .ant-progress-outer {
-  padding-inline-end: 0;
-}
-
-.usage-summary-bar .ant-progress-inner {
-  background: var(--ant-color-fill-secondary);
-}
-
-.usage-summary-foot {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  font-size: 12px;
-  color: var(--ant-color-text-tertiary);
-  font-variant-numeric: tabular-nums;
-  min-height: 16px;
-}
-
-.usage-summary-remained::before {
-  content: '';
-}
-
-.usage-summary-pct {
-  font-weight: 600;
-  color: var(--ant-color-text-secondary);
-}

+ 0 - 96
frontend/src/pages/sub/SubUsageSummary.tsx

@@ -1,96 +0,0 @@
-import { useMemo } from 'react';
-import { useTranslation } from 'react-i18next';
-import { Progress, Tag } from 'antd';
-import { ClockCircleOutlined, ThunderboltOutlined } from '@ant-design/icons';
-
-import './SubUsageSummary.css';
-
-interface SubUsageSummaryProps {
-  usedByte: number;
-  totalByte: number;
-  usedLabel: string;
-  totalLabel: string;
-  remainedLabel: string;
-  expireMs: number;
-  isActive: boolean;
-}
-
-function pickStrokeColor(pct: number): { from: string; to: string } {
-  if (pct >= 90) return { from: '#ff7875', to: '#ff4d4f' };
-  if (pct >= 75) return { from: '#ffc53d', to: '#fa8c16' };
-  return { from: '#5fc983', to: '#36b37e' };
-}
-
-function formatExpiryChip(expireMs: number): { label: string; color: string } | null {
-  if (expireMs <= 0) return null;
-  const diff = expireMs - Date.now();
-  if (diff <= 0) return { label: 'Expired', color: 'red' };
-  const days = Math.floor(diff / 86400000);
-  if (days >= 1) return { label: `${days}d`, color: days <= 3 ? 'orange' : 'blue' };
-  const hours = Math.max(1, Math.floor(diff / 3600000));
-  return { label: `${hours}h`, color: 'orange' };
-}
-
-export default function SubUsageSummary({
-  usedByte,
-  totalByte,
-  usedLabel,
-  totalLabel,
-  remainedLabel,
-  expireMs,
-  isActive,
-}: SubUsageSummaryProps) {
-  const { t } = useTranslation();
-  const pct = useMemo(() => {
-    if (totalByte <= 0) return 0;
-    const v = (usedByte / totalByte) * 100;
-    if (!Number.isFinite(v)) return 0;
-    return Math.max(0, Math.min(100, v));
-  }, [usedByte, totalByte]);
-
-  const expiry = formatExpiryChip(expireMs);
-  const isUnlimited = totalByte <= 0;
-  const stroke = pickStrokeColor(pct);
-
-  return (
-    <div className={`usage-summary ${!isActive ? 'is-inactive' : ''}`}>
-      <div className="usage-summary-head">
-        <div className="usage-summary-labels">
-          <span className="usage-summary-used">{usedLabel}</span>
-          <span className="usage-summary-sep">/</span>
-          <span className="usage-summary-total">{isUnlimited ? '∞' : totalLabel}</span>
-        </div>
-        <div className="usage-summary-chips">
-          {isUnlimited && (
-            <Tag color="purple" icon={<ThunderboltOutlined />}>
-              {t('subscription.unlimited')}
-            </Tag>
-          )}
-          {expiry && (
-            <Tag color={expiry.color} icon={<ClockCircleOutlined />}>
-              {expiry.label}
-            </Tag>
-          )}
-        </div>
-      </div>
-      {!isUnlimited && (
-        <Progress
-          percent={pct}
-          showInfo={false}
-          strokeColor={{ '0%': stroke.from, '100%': stroke.to }}
-          railColor="var(--ant-color-fill-secondary)"
-          strokeWidth={10}
-          className="usage-summary-bar"
-        />
-      )}
-      <div className="usage-summary-foot">
-        {!isUnlimited && (
-          <>
-            <span className="usage-summary-remained">{remainedLabel}</span>
-            <span className="usage-summary-pct">{pct.toFixed(1)}%</span>
-          </>
-        )}
-      </div>
-    </div>
-  );
-}

+ 25 - 0
frontend/src/pages/sub/app-icons/README.md

@@ -0,0 +1,25 @@
+# Subscription page app icons
+
+## Line icons — Arcticons (CC BY-SA 4.0)
+
+`happ.svg`, `sing-box.svg`, `v2rayng.svg` and `v2raytun.svg` come from
+[Arcticons](https://github.com/Arcticons-Team/Arcticons) by Donnnno and the
+Arcticons contributors (as listed on svgicons.com), licensed under
+[CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/).
+
+Changes from the originals: the stroke colour is `currentColor` so the page can
+tint them to its theme, the stroke width is raised from 1 to 2 so they stay
+legible at tile size, and the style classes and ids were removed. These modified
+files are distributed under CC BY-SA 4.0 as well.
+
+## App icons
+
+The remaining files are each app's own icon, downscaled to 96 px, used only to
+identify the app a button opens. They remain the trademarks of their owners.
+
+| File                | Source                                                                    |
+| ------------------- | ------------------------------------------------------------------------- |
+| `shadowrocket.webp` | App Store listing [id932747118](https://apps.apple.com/app/id932747118)   |
+| `streisand.webp`    | App Store listing [id6450534064](https://apps.apple.com/app/id6450534064) |
+| `v2box.webp`        | App Store listing [id6446814690](https://apps.apple.com/app/id6446814690) |
+| `incy.webp`         | App Store listing [id6756943388](https://apps.apple.com/app/id6756943388) |

+ 1 - 0
frontend/src/pages/sub/app-icons/happ.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><polyline points="11.9387 18.636 11.2805 19.2322 13.2164 6.3148 21.6618 6.3148 21.082 10.1846"/><polyline points="20.2585 28.1999 18.113 42.5 9.6674 42.5 10.3198 38.1088"/><polyline points="28.1728 36.5585 27.2773 42.5 35.7228 42.5 37.9603 27.5876 36.0158 29.2999"/><polyline points="21.0144 27.3598 27.8555 27.3598 26.2122 38.3731 36.0158 29.2999 39.1998 8.0038 29.2593 17.99 28.8029 21.0714 27.2773 21.0714 26.4784 21.8658"/><polygon points="26.4784 21.8658 20.2243 28.1543 18.9689 28.1543 18.752 29.6379 8.8002 39.6355 11.9387 18.636 21.7423 9.5743 19.9047 21.8658 26.4784 21.8658"/><polyline points="38.0794 9.1294 38.6261 5.5 30.1808 5.5 28.1701 18.9112 29.2593 17.99"/></svg>

BIN
frontend/src/pages/sub/app-icons/incy.webp


BIN
frontend/src/pages/sub/app-icons/shadowrocket.webp


+ 1 - 0
frontend/src/pages/sub/app-icons/sing-box.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M40.7241,15.6976l-15.4896-10.8095c-.7416-.5175-1.7273-.5175-2.4689,0L7.2759,15.6976c-.9141.6379-.9141,1.9909,0,2.6288l15.4896,10.8095c.7416.5175,1.7273.5175,2.4689,0l15.4896-10.8095c.9141-.6379.9141-1.9909,0-2.6288Z"/><path d="M41.4096,17.012v13.976c0,.4977-.2285.9954-.6855,1.3144l-15.4896,10.8095c-.7416.5175-1.7273.5175-2.4689,0l-15.4896-10.8095c-.457-.3189-.6855-.8167-.6855-1.3144h0s0-13.976,0-13.976"/><line x1="24" y1="29.524" x2="24" y2="43.5"/><path d="M11.8734,12.4893l18.3951,13.1335v3.8047c0,.6869.7673,1.0951,1.3369.7111l4.3991-2.9651c.2961-.1996.4735-.5332.4735-.8903v-4.9939l-18.3951-13.1335"/></svg>

BIN
frontend/src/pages/sub/app-icons/streisand.webp


BIN
frontend/src/pages/sub/app-icons/v2box.webp


+ 1 - 0
frontend/src/pages/sub/app-icons/v2rayng.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="m9.6185,41.4866V10.6142h-4.1185v-4.0722h10.2641v14.3936c.6957-.6404,1.2872-1.1794,1.8728-1.7248,3.5152-3.2745,7.0291-6.5504,10.5432-9.826.9416-.8777,1.8767-1.7624,2.8301-2.6271.1437-.1303.3712-.2375.5603-.2382,3.5668-.0136,10.9295,0,10.9295,0-10.9555,11.6366-21.8724,23.2736-32.8815,34.9672Z"/></svg>

+ 1 - 0
frontend/src/pages/sub/app-icons/v2raytun.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><rect x="5.5" y="5.5" width="37" height="37" rx="4" ry="4"/><polyline points="28.9436 11.2023 20.4652 36.7977 11.9867 11.2023"/><path d="M28.3091,29.0207c0-2.3774,2.1537-4.2518,4.6165-3.7785,1.6155.3105,2.9055,1.7076,3.0663,3.3447.1195,1.2178-.2658,2.4194-1.1069,3.1576-1.5583,1.3675-6.5759,5.053-6.5759,5.053h7.7042"/></svg>

+ 20 - 0
frontend/src/pages/sub/appIcons.ts

@@ -0,0 +1,20 @@
+import happ from './app-icons/happ.svg';
+import incy from './app-icons/incy.webp';
+import shadowrocket from './app-icons/shadowrocket.webp';
+import singBox from './app-icons/sing-box.svg';
+import streisand from './app-icons/streisand.webp';
+import v2box from './app-icons/v2box.webp';
+import v2rayng from './app-icons/v2rayng.svg';
+import v2raytun from './app-icons/v2raytun.svg';
+
+// Tinted entries are Arcticons line art drawn in the theme colour; the rest are full-colour app icons.
+export const APP_ICONS: Record<string, { src: string; tinted: boolean }> = {
+  V2Box: { src: v2box, tinted: false },
+  V2RayNG: { src: v2rayng, tinted: true },
+  'Sing-box': { src: singBox, tinted: true },
+  V2RayTun: { src: v2raytun, tinted: true },
+  Happ: { src: happ, tinted: true },
+  Incy: { src: incy, tinted: false },
+  Shadowrocket: { src: shadowrocket, tinted: false },
+  Streisand: { src: streisand, tinted: false },
+};

+ 93 - 0
frontend/src/pages/sub/subPageModel.ts

@@ -0,0 +1,93 @@
+const DAY_MS = 86_400_000;
+
+export type SubStatus = 'active' | 'unlimited' | 'expired' | 'depleted' | 'disabled';
+
+export interface SubUsage {
+  enabled: boolean;
+  usedByte: number;
+  totalByte: number;
+  expireMs: number;
+}
+
+export function resolveSubStatus(sub: SubUsage, now: number): SubStatus {
+  if (!sub.enabled) return 'disabled';
+  if (sub.expireMs > 0 && now >= sub.expireMs) return 'expired';
+  if (sub.totalByte > 0 && sub.usedByte >= sub.totalByte) return 'depleted';
+  if (sub.totalByte <= 0 && sub.expireMs === 0) return 'unlimited';
+  return 'active';
+}
+
+export function daysUntil(expireMs: number, now: number): number | null {
+  if (expireMs <= 0) return null;
+  return Math.max(0, Math.ceil((expireMs - now) / DAY_MS));
+}
+
+export function usagePercent(usedByte: number, totalByte: number): number {
+  if (totalByte <= 0) return 0;
+  const pct = (usedByte / totalByte) * 100;
+  return Number.isFinite(pct) ? Math.min(100, Math.max(0, pct)) : 0;
+}
+
+export type AppPlatform = 'android' | 'ios';
+
+export function detectPlatform(userAgent: string): AppPlatform {
+  // iPadOS sends a Macintosh UA, and App Store clients also run on Apple-silicon Macs.
+  if (/iphone|ipad|ipod|macintosh/i.test(userAgent)) return 'ios';
+  return 'android';
+}
+
+export interface SubApp {
+  name: string;
+  url: string;
+}
+
+export interface SubAppSource {
+  subUrl: string;
+  sId: string;
+  subTitle: string;
+}
+
+export function buildSubApps({
+  subUrl,
+  sId,
+  subTitle,
+}: SubAppSource): Record<AppPlatform, SubApp[]> {
+  const encSub = encodeURIComponent(subUrl);
+  const profileName = encodeURIComponent(subTitle || sId);
+
+  const v2box = {
+    name: 'V2Box',
+    url: `v2box://install-sub?url=${encSub}&name=${encodeURIComponent(sId)}`,
+  };
+  const singBox = {
+    name: 'Sing-box',
+    url: `sing-box://import-remote-profile?url=${encSub}#${profileName}`,
+  };
+  const v2raytun = { name: 'V2RayTun', url: `v2raytun://import/${subUrl}` };
+  const happ = { name: 'Happ', url: `happ://add/${subUrl}` };
+  const incy = { name: 'Incy', url: `incy://add/${subUrl}` };
+  const rocketSource = `${subUrl}${subUrl.includes('?') ? '&' : '?'}flag=shadowrocket`;
+  const rocketRemark = encodeURIComponent(subTitle || sId || 'Subscription');
+
+  return {
+    android: [
+      v2box,
+      { name: 'V2RayNG', url: `v2rayng://install-config?url=${encSub}` },
+      singBox,
+      v2raytun,
+      happ,
+      incy,
+    ],
+    ios: [
+      {
+        name: 'Shadowrocket',
+        url: `shadowrocket://add/sub://${btoa(rocketSource)}?remark=${rocketRemark}`,
+      },
+      v2box,
+      { name: 'Streisand', url: `streisand://import/${encSub}` },
+      v2raytun,
+      happ,
+      incy,
+    ],
+  };
+}

+ 4 - 4
frontend/src/test/qr-panel-readable.test.tsx

@@ -81,9 +81,9 @@ describe('QrPanel dense AmneziaWG config', () => {
     const completeQr = qrGeometry(complete);
     const shorterQr = qrGeometry(withoutDisableCookies);
 
-    expect(completeQr.viewBox).toBe('0 0 105 105');
-    expect(shorterQr.viewBox).toBe('0 0 101 101');
-    expect(completeQr.foreground).toMatch(/^M4 4h7/);
-    expect(shorterQr.foreground).toMatch(/^M4 4h7/);
+    expect(completeQr.viewBox).toBe('0 0 101 101');
+    expect(shorterQr.viewBox).toBe('0 0 97 97');
+    expect(completeQr.foreground).toMatch(/^M2 2h7/);
+    expect(shorterQr.foreground).toMatch(/^M2 2h7/);
   });
 });

+ 28 - 0
frontend/src/test/sub-app-icons.test.ts

@@ -0,0 +1,28 @@
+import { describe, expect, it } from 'vitest';
+
+import { APP_ICONS } from '@/pages/sub/appIcons';
+import { buildSubApps } from '@/pages/sub/subPageModel';
+
+describe('APP_ICONS', () => {
+  it('has an icon for every app the subscription page offers on every platform', () => {
+    const apps = buildSubApps({
+      subUrl: 'https://sub.example.com/sub/abc',
+      sId: 'abc',
+      subTitle: '',
+    });
+    const names = [...new Set(Object.values(apps).flatMap((list) => list.map((app) => app.name)))];
+
+    expect(names.filter((name) => !APP_ICONS[name]?.src)).toEqual([]);
+  });
+
+  it('carries no icon for an app the page no longer offers', () => {
+    const apps = buildSubApps({
+      subUrl: 'https://sub.example.com/sub/abc',
+      sId: 'abc',
+      subTitle: '',
+    });
+    const offered = new Set(Object.values(apps).flatMap((list) => list.map((app) => app.name)));
+
+    expect(Object.keys(APP_ICONS).filter((name) => !offered.has(name))).toEqual([]);
+  });
+});

+ 135 - 0
frontend/src/test/sub-page-model.test.ts

@@ -0,0 +1,135 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+  buildSubApps,
+  daysUntil,
+  detectPlatform,
+  resolveSubStatus,
+  usagePercent,
+} from '@/pages/sub/subPageModel';
+
+const DAY = 86_400_000;
+const NOW = Date.UTC(2026, 8, 15, 12, 0, 0);
+
+describe('resolveSubStatus', () => {
+  const base = { enabled: true, usedByte: 10, totalByte: 100, expireMs: NOW + DAY };
+
+  it.each([
+    [
+      'disabled wins over expiry and quota',
+      { ...base, enabled: false, expireMs: NOW - DAY },
+      'disabled',
+    ],
+    ['expired at the expiry instant', { ...base, expireMs: NOW }, 'expired'],
+    ['expired beats depleted', { ...base, usedByte: 100, expireMs: NOW - DAY }, 'expired'],
+    ['depleted once usage reaches the quota', { ...base, usedByte: 100 }, 'depleted'],
+    [
+      'unlimited with neither quota nor expiry',
+      { ...base, totalByte: 0, expireMs: 0 },
+      'unlimited',
+    ],
+    ['active without quota but with a future expiry', { ...base, totalByte: 0 }, 'active'],
+    ['active inside quota and before expiry', base, 'active'],
+  ] as const)('%s', (_name, input, want) => {
+    expect(resolveSubStatus(input, NOW)).toBe(want);
+  });
+});
+
+describe('daysUntil', () => {
+  it('is null for a subscription that never expires', () => {
+    expect(daysUntil(0, NOW)).toBeNull();
+  });
+
+  it('rounds the last partial day up to 1', () => {
+    expect(daysUntil(NOW + 3 * 3_600_000, NOW)).toBe(1);
+  });
+
+  it('counts whole days', () => {
+    expect(daysUntil(NOW + 23 * DAY, NOW)).toBe(23);
+  });
+
+  it('stays at 0 after expiry', () => {
+    expect(daysUntil(NOW - DAY, NOW)).toBe(0);
+  });
+});
+
+describe('usagePercent', () => {
+  it('is 0 without a quota instead of NaN or Infinity', () => {
+    expect(usagePercent(5_000, 0)).toBe(0);
+  });
+
+  it('clamps an over-quota client to 100', () => {
+    expect(usagePercent(150, 100)).toBe(100);
+  });
+});
+
+describe('detectPlatform', () => {
+  it.each([
+    [
+      'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 Chrome/128.0 Mobile Safari/537.36',
+      'android',
+    ],
+    [
+      'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148',
+      'ios',
+    ],
+    [
+      'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/17.5 Safari/605.1.15',
+      'ios',
+    ],
+    [
+      'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/128.0 Safari/537.36',
+      'android',
+    ],
+    ['Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/128.0 Safari/537.36', 'android'],
+  ])('%s -> %s', (ua, want) => {
+    expect(detectPlatform(ua)).toBe(want);
+  });
+});
+
+describe('buildSubApps', () => {
+  const subUrl = 'https://sub.example.com/sub/abc';
+  const encSub = encodeURIComponent(subUrl);
+  const sub = { subUrl, sId: 'abc', subTitle: 'Nova Net' };
+
+  it('offers Android and iOS app lists only', () => {
+    expect(Object.keys(buildSubApps(sub))).toEqual(['android', 'ios']);
+  });
+
+  it('gives every Android app a one-tap import link', () => {
+    expect(buildSubApps(sub).android).toEqual([
+      { name: 'V2Box', url: `v2box://install-sub?url=${encSub}&name=abc` },
+      { name: 'V2RayNG', url: `v2rayng://install-config?url=${encSub}` },
+      { name: 'Sing-box', url: `sing-box://import-remote-profile?url=${encSub}#Nova%20Net` },
+      { name: 'V2RayTun', url: `v2raytun://import/${subUrl}` },
+      { name: 'Happ', url: `happ://add/${subUrl}` },
+      { name: 'Incy', url: `incy://add/${subUrl}` },
+    ]);
+  });
+
+  it('gives every iOS app a one-tap import link', () => {
+    const rocket = Buffer.from(`${subUrl}?flag=shadowrocket`).toString('base64');
+    expect(buildSubApps(sub).ios).toEqual([
+      { name: 'Shadowrocket', url: `shadowrocket://add/sub://${rocket}?remark=Nova%20Net` },
+      { name: 'V2Box', url: `v2box://install-sub?url=${encSub}&name=abc` },
+      { name: 'Streisand', url: `streisand://import/${encSub}` },
+      { name: 'V2RayTun', url: `v2raytun://import/${subUrl}` },
+      { name: 'Happ', url: `happ://add/${subUrl}` },
+      { name: 'Incy', url: `incy://add/${subUrl}` },
+    ]);
+  });
+
+  it('names the sing-box profile after the subscription id when there is no title', () => {
+    expect(buildSubApps({ ...sub, subTitle: '' }).android[2].url).toBe(
+      `sing-box://import-remote-profile?url=${encSub}#abc`,
+    );
+  });
+
+  it('appends flag=shadowrocket with & when the subscription URL already has a query', () => {
+    const withQuery = { ...sub, subUrl: `${subUrl}?token=1` };
+    const rocket = Buffer.from(`${subUrl}?token=1&flag=shadowrocket`).toString('base64');
+    expect(buildSubApps(withQuery).ios[0].url).toBe(
+      `shadowrocket://add/sub://${rocket}?remark=Nova%20Net`,
+    );
+  });
+});

+ 1 - 1
internal/config/version

@@ -1 +1 @@
-3.8.0
+3.8.5

+ 32 - 17
internal/logger/logger.go

@@ -8,6 +8,7 @@ import (
 	"path/filepath"
 	"runtime"
 	"sync"
+	"sync/atomic"
 	"time"
 
 	"github.com/op/go-logging"
@@ -30,10 +31,13 @@ const (
 )
 
 var (
-	// Initialized to a usable default so logging never nil-derefs before InitLogger
-	// runs — the "migrate" and "setting" CLI subcommands log without calling it.
-	logger     = logging.MustGetLogger("x-ui")
-	fileRotate *lumberjack.Logger // nil when file backend disabled
+	// InitLogger swaps the handle while other goroutines are logging, so it is
+	// published atomically — a plain assignment is an unsafe publication.
+	logger atomic.Pointer[logging.Logger]
+
+	// fileRotateMu guards fileRotate against a concurrent InitLogger/CloseLogger.
+	fileRotateMu sync.Mutex
+	fileRotate   *lumberjack.Logger // nil when file backend disabled
 
 	// logBuffer maintains recent log entries in memory for web UI retrieval;
 	// logBufferMu guards it — written from many goroutines, read by the web UI.
@@ -45,6 +49,12 @@ var (
 	}
 )
 
+// A usable default so logging never nil-derefs before InitLogger runs — the
+// "migrate" and "setting" CLI subcommands log without calling it.
+func init() {
+	logger.Store(logging.MustGetLogger("x-ui"))
+}
+
 // InitLogger initializes dual logging backends: console/syslog and file.
 // Console logging uses the specified level, file logging always uses DEBUG level.
 func InitLogger(level logging.Level) {
@@ -66,7 +76,7 @@ func InitLogger(level logging.Level) {
 
 	multiBackend := logging.MultiLogger(backends...)
 	newLogger.SetBackend(multiBackend)
-	logger = newLogger
+	logger.Store(newLogger)
 }
 
 // initDefaultBackend creates the console/syslog logging backend.
@@ -104,7 +114,7 @@ func initFileBackend() logging.Backend {
 	}
 
 	logPath := filepath.Join(logDir, logFileName)
-	fileRotate = &lumberjack.Logger{
+	rotate := &lumberjack.Logger{
 		Filename:   logPath,
 		MaxSize:    maxLogFileMB,
 		MaxBackups: maxLogBackups,
@@ -112,8 +122,11 @@ func initFileBackend() logging.Backend {
 		LocalTime:  true,
 		Compress:   compressRotated,
 	}
+	fileRotateMu.Lock()
+	fileRotate = rotate
+	fileRotateMu.Unlock()
 
-	backend := logging.NewLogBackend(fileRotate, "", 0)
+	backend := logging.NewLogBackend(rotate, "", 0)
 	return logging.NewBackendFormatter(backend, newFormatter(true))
 }
 
@@ -129,6 +142,8 @@ func newFormatter(withTime bool) logging.Formatter {
 // CloseLogger closes the rotating log writer and cleans up resources.
 // Should be called during application shutdown.
 func CloseLogger() {
+	fileRotateMu.Lock()
+	defer fileRotateMu.Unlock()
 	if fileRotate != nil {
 		_ = fileRotate.Close()
 		fileRotate = nil
@@ -137,61 +152,61 @@ func CloseLogger() {
 
 // Debug logs a debug message and adds it to the log buffer.
 func Debug(args ...any) {
-	logger.Debug(args...)
+	logger.Load().Debug(args...)
 	addToBuffer("DEBUG", fmt.Sprint(args...))
 }
 
 // Debugf logs a formatted debug message and adds it to the log buffer.
 func Debugf(format string, args ...any) {
-	logger.Debugf(format, args...)
+	logger.Load().Debugf(format, args...)
 	addToBuffer("DEBUG", fmt.Sprintf(format, args...))
 }
 
 // Info logs an info message and adds it to the log buffer.
 func Info(args ...any) {
-	logger.Info(args...)
+	logger.Load().Info(args...)
 	addToBuffer("INFO", fmt.Sprint(args...))
 }
 
 // Infof logs a formatted info message and adds it to the log buffer.
 func Infof(format string, args ...any) {
-	logger.Infof(format, args...)
+	logger.Load().Infof(format, args...)
 	addToBuffer("INFO", fmt.Sprintf(format, args...))
 }
 
 // Notice logs a notice message and adds it to the log buffer.
 func Notice(args ...any) {
-	logger.Notice(args...)
+	logger.Load().Notice(args...)
 	addToBuffer("NOTICE", fmt.Sprint(args...))
 }
 
 // Noticef logs a formatted notice message and adds it to the log buffer.
 func Noticef(format string, args ...any) {
-	logger.Noticef(format, args...)
+	logger.Load().Noticef(format, args...)
 	addToBuffer("NOTICE", fmt.Sprintf(format, args...))
 }
 
 // Warning logs a warning message and adds it to the log buffer.
 func Warning(args ...any) {
-	logger.Warning(args...)
+	logger.Load().Warning(args...)
 	addToBuffer("WARNING", fmt.Sprint(args...))
 }
 
 // Warningf logs a formatted warning message and adds it to the log buffer.
 func Warningf(format string, args ...any) {
-	logger.Warningf(format, args...)
+	logger.Load().Warningf(format, args...)
 	addToBuffer("WARNING", fmt.Sprintf(format, args...))
 }
 
 // Error logs an error message and adds it to the log buffer.
 func Error(args ...any) {
-	logger.Error(args...)
+	logger.Load().Error(args...)
 	addToBuffer("ERROR", fmt.Sprint(args...))
 }
 
 // Errorf logs a formatted error message and adds it to the log buffer.
 func Errorf(format string, args ...any) {
-	logger.Errorf(format, args...)
+	logger.Load().Errorf(format, args...)
 	addToBuffer("ERROR", fmt.Sprintf(format, args...))
 }
 

+ 30 - 0
internal/logger/logger_test.go

@@ -2,7 +2,10 @@ package logger
 
 import (
 	"fmt"
+	"sync"
 	"testing"
+
+	golog "github.com/op/go-logging"
 )
 
 // TestGetLogs_ReturnsAtMostC guards the documented "up to c entries" contract.
@@ -28,3 +31,30 @@ func TestGetLogs_ReturnsAtMostC(t *testing.T) {
 		}
 	}
 }
+
+// InitLogger replaces the package logger while other goroutines are already
+// logging — CI caught that as a data race between InitLogger and Warningf.
+func TestInitLoggerConcurrentWithLogging(t *testing.T) {
+	t.Setenv("XUI_LOG_FOLDER", t.TempDir())
+
+	stop := make(chan struct{})
+	var logging sync.WaitGroup
+	logging.Add(1)
+	go func() {
+		defer logging.Done()
+		for {
+			select {
+			case <-stop:
+				return
+			default:
+				Warningf("concurrent %s", "log")
+			}
+		}
+	}()
+
+	for range 10 {
+		InitLogger(golog.CRITICAL)
+	}
+	close(stop)
+	logging.Wait()
+}

+ 4 - 0
internal/sub/controller.go

@@ -13,6 +13,7 @@ import (
 	"os"
 	"path/filepath"
 	"regexp"
+	"strconv"
 	"strings"
 	"sync"
 	"time"
@@ -660,6 +661,8 @@ func (a *SUBController) subPageContext(page PageData) map[string]any {
 	if datepicker == "" {
 		datepicker = "gregorian"
 	}
+	subUpdates, _ := a.settingService.GetSubUpdates()
+	updateHours, _ := strconv.Atoi(subUpdates)
 
 	return map[string]any{
 		"sId":           page.SId,
@@ -680,6 +683,7 @@ func (a *SUBController) subPageContext(page PageData) map[string]any {
 		"subClashUrl":   page.SubClashUrl,
 		"subTitle":      page.SubTitle,
 		"subSupportUrl": page.SubSupportUrl,
+		"subUpdates":    updateHours,
 		"links":         page.Result,
 		"emails":        page.Emails,
 		"datepicker":    datepicker,

+ 3 - 0
internal/sub/info_endpoint_test.go

@@ -76,6 +76,9 @@ func TestSubInfoEndpoint_ServesStatusJSONEvenForBrowsers(t *testing.T) {
 			t.Fatalf("info payload missing %q; body=%s", key, w.Body.String())
 		}
 	}
+	if info["subUpdates"] != float64(12) {
+		t.Fatalf("subUpdates = %v, want the default 12-hour interval", info["subUpdates"])
+	}
 }
 
 func TestSubInfoEndpoint_UnknownSubIs404(t *testing.T) {

+ 16 - 1
internal/util/link/outbound.go

@@ -48,6 +48,7 @@ func ParseSubscriptionBody(body []byte) ([]Outbound, []string, error) {
 	lines := splitLines(text)
 	var outbounds []Outbound
 	var identities []string
+	seen := map[string]int{}
 
 	for _, ln := range lines {
 		ln = strings.TrimSpace(ln)
@@ -59,8 +60,14 @@ func ParseSubscriptionBody(body []byte) ([]Outbound, []string, error) {
 			// Ignore unparseable lines (comments, unsupported protocols, etc.)
 			continue
 		}
+		identity := res.Identity
+		// A repeated identity would share one stored tag, shifting both tags on every refresh.
+		if n := seen[res.Identity]; n > 0 {
+			identity = fmt.Sprintf("%s#%d", res.Identity, n)
+		}
+		seen[res.Identity]++
 		outbounds = append(outbounds, res.Outbound)
-		identities = append(identities, res.Identity)
+		identities = append(identities, identity)
 	}
 	return outbounds, identities, nil
 }
@@ -1047,10 +1054,18 @@ func firstParam(p url.Values, keys ...string) string {
 	return ""
 }
 
+// realityPerRequestParams are picked per request by subscription servers (3x-ui randomizes
+// sid/sni, older releases spx too), so they must not split one server into new identities.
+var realityPerRequestParams = map[string]bool{"sid": true, "sni": true, "spx": true}
+
 func canonicalQuery(p url.Values) string {
 	// Sort keys for stable identity
+	reality := p.Get("security") == "reality"
 	keys := make([]string, 0, len(p))
 	for k := range p {
+		if reality && realityPerRequestParams[k] {
+			continue
+		}
 		keys = append(keys, k)
 	}
 	// simple sort

+ 11 - 0
internal/util/link/outbound_test.go

@@ -24,6 +24,17 @@ func TestParseVmessLink(t *testing.T) {
 	}
 }
 
+func TestLinkIdentityKeepsTLSServerName(t *testing.T) {
+	a, errA := ParseLink("vless://[email protected]:443?type=ws&security=tls&sni=a.example.com#node")
+	b, errB := ParseLink("vless://[email protected]:443?type=ws&security=tls&sni=b.example.com#node")
+	if errA != nil || errB != nil {
+		t.Fatalf("parse vless: %v, %v", errA, errB)
+	}
+	if a.Identity == b.Identity {
+		t.Fatalf("TLS links for different SNIs share identity %q", a.Identity)
+	}
+}
+
 func TestParseVlessLink(t *testing.T) {
 	link := "vless://[email protected]:443?type=ws&security=tls&path=/&host=ex.com#node1"
 	res, err := ParseLink(link)

+ 18 - 6
internal/web/service/outbound_subscription.go

@@ -419,24 +419,36 @@ func (s *OutboundSubscriptionService) fetchAndStore(sub *model.OutboundSubscript
 		}
 	}
 
+	// Drop core-rejected links before tagging: prevTagByIndex indexes the persisted
+	// (filtered) list, so positions must be counted in that same list.
+	var droppedByCore []string
+	keptLinks, keptIdentities := parsed[:0], identities[:0]
+	for i, ob := range parsed {
+		if _, dropped := filterOutboundsRejectedByCore(fmt.Sprintf("outbound sub %d", sub.Id), []any{map[string]any(ob)}); len(dropped) > 0 {
+			droppedByCore = append(droppedByCore, dropped...)
+			continue
+		}
+		keptLinks = append(keptLinks, ob)
+		keptIdentities = append(keptIdentities, identities[i])
+	}
+
 	// Assign tags with stability (identity reuse, positional fallback, then a
 	// fresh allocation), keeping tags unique within this batch. Extracted into a
 	// pure function so it can be unit-tested without network/DB. Tags are written
 	// back into the parsed outbounds in place.
-	assigned := assignStableTags(parsed, identities, prev, prevTagByIndex, sub.Id, sub.TagPrefix)
+	assigned := assignStableTags(keptLinks, keptIdentities, prev, prevTagByIndex, sub.Id, sub.TagPrefix)
 
 	// Persist identities for next time
 	newIdent := map[string]string{}
-	for i, id := range identities {
+	for i, id := range keptIdentities {
 		newIdent[id] = assigned[i]
 	}
 	identJSON, _ := json.Marshal(newIdent)
 
-	asAny := make([]any, len(parsed))
-	for i := range parsed {
-		asAny[i] = map[string]any(parsed[i])
+	kept := make([]any, len(keptLinks))
+	for i := range keptLinks {
+		kept[i] = map[string]any(keptLinks[i])
 	}
-	kept, droppedByCore := filterOutboundsRejectedByCore(fmt.Sprintf("outbound sub %d", sub.Id), asAny)
 
 	// Persist the outbounds (as compact JSON array)
 	obsJSON, _ := json.Marshal(kept)

+ 113 - 0
internal/web/service/outbound_subscription_test.go

@@ -2,10 +2,14 @@ package service
 
 import (
 	"bytes"
+	"encoding/base64"
 	"errors"
+	"fmt"
+	"maps"
 	"net/http"
 	"net/http/httptest"
 	"slices"
+	"strings"
 	"testing"
 
 	"gorm.io/gorm"
@@ -128,6 +132,115 @@ func TestOutboundSubscriptionRefreshUsesCustomUserAgent(t *testing.T) {
 	}
 }
 
+// serveOutboundSubscription seeds a subscription whose URL returns body(n) for the n-th fetch.
+func serveOutboundSubscription(t *testing.T, tagPrefix string, body func(n int) string) int {
+	t.Helper()
+	requests := 0
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		requests++
+		_, _ = w.Write([]byte(body(requests)))
+	}))
+	t.Cleanup(server.Close)
+	sub := &model.OutboundSubscription{Url: server.URL, AllowPrivate: true, TagPrefix: tagPrefix}
+	if err := database.GetDB().Create(sub).Error; err != nil {
+		t.Fatalf("seed subscription: %v", err)
+	}
+	return sub.Id
+}
+
+func refreshOutboundTags(t *testing.T, subID int) (tags []string, byAddress map[string]string) {
+	t.Helper()
+	obs, err := (&OutboundSubscriptionService{}).Refresh(subID)
+	if err != nil {
+		t.Fatalf("Refresh: %v", err)
+	}
+	byAddress = map[string]string{}
+	for _, ob := range obs {
+		m := ob.(map[string]any)
+		tag, _ := m["tag"].(string)
+		address, _ := m["settings"].(map[string]any)["address"].(string)
+		tags = append(tags, tag)
+		byAddress[address] = tag
+	}
+	return tags, byAddress
+}
+
+func TestOutboundSubscriptionRefreshKeepsTagsWhenRealityParamsRotate(t *testing.T) {
+	setupSettingTestDB(t)
+	pbk := base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{7}, 32))
+	type server struct{ remark, address string }
+	var servers []server
+	// A 3x-ui upstream picks sid and sni at random per request, and older releases spx too (#6556).
+	subID := serveOutboundSubscription(t, "sub", func(n int) string {
+		lines := make([]string, 0, len(servers))
+		for _, s := range servers {
+			lines = append(lines, fmt.Sprintf(
+				"vless://00000000-0000-4000-8000-000000000000@%s:443?type=tcp&security=reality&pbk=%s&fp=chrome&sni=sni%d.example.com&sid=%02x&spx=%%2F%d#%s",
+				s.address, pbk, n, n, n, s.remark))
+		}
+		return strings.Join(lines, "\n")
+	})
+
+	steps := []struct {
+		name    string
+		servers []server
+		want    map[string]string
+	}{
+		{
+			"initial fetch",
+			[]server{{"France", "1.1.1.1"}, {"Germany", "8.8.8.8"}, {"Sweden", "9.9.9.9"}},
+			map[string]string{"1.1.1.1": "sub-france", "8.8.8.8": "sub-germany", "9.9.9.9": "sub-sweden"},
+		},
+		{
+			"France removed",
+			[]server{{"Germany", "8.8.8.8"}, {"Sweden", "9.9.9.9"}},
+			map[string]string{"8.8.8.8": "sub-germany", "9.9.9.9": "sub-sweden"},
+		},
+		{
+			"new France added first",
+			[]server{{"France", "1.0.0.1"}, {"Germany", "8.8.8.8"}, {"Sweden", "9.9.9.9"}},
+			map[string]string{"1.0.0.1": "sub-france", "8.8.8.8": "sub-germany", "9.9.9.9": "sub-sweden"},
+		},
+	}
+	for _, step := range steps {
+		servers = step.servers
+		if _, got := refreshOutboundTags(t, subID); !maps.Equal(got, step.want) {
+			t.Fatalf("%s: tags by address = %v, want %v", step.name, got, step.want)
+		}
+	}
+}
+
+func TestOutboundSubscriptionRefreshKeepsTagsOfRepeatedLink(t *testing.T) {
+	setupSettingTestDB(t)
+	const link = "vless://[email protected]:443?security=tls&type=tcp"
+	subID := serveOutboundSubscription(t, "p-", func(int) string { return link + "#A\n" + link + "#B" })
+
+	want := []string{"p-a", "p-b"}
+	for refresh := 1; refresh <= 3; refresh++ {
+		if got, _ := refreshOutboundTags(t, subID); !slices.Equal(got, want) {
+			t.Fatalf("refresh %d: tags = %v, want %v", refresh, got, want)
+		}
+	}
+}
+
+func TestOutboundSubscriptionRefreshAlignsPositionsPastCoreRejectedLink(t *testing.T) {
+	setupSettingTestDB(t)
+	// The unencrypted first link is dropped by the core; B and C then rotate their UUID.
+	subID := serveOutboundSubscription(t, "p-", func(n int) string {
+		uuid := fmt.Sprintf("00000000-0000-4000-8000-%012d", n)
+		return "vless://[email protected]:443?security=none&type=tcp#Plain\n" +
+			"vless://" + uuid + "@8.8.8.8:443?security=tls&type=tcp#B\n" +
+			"vless://" + uuid + "@9.9.9.9:443?security=tls&type=tcp#C"
+	})
+
+	want := map[string]string{"8.8.8.8": "p-b", "9.9.9.9": "p-c"}
+	for refresh := 1; refresh <= 2; refresh++ {
+		if _, got := refreshOutboundTags(t, subID); !maps.Equal(got, want) {
+			t.Fatalf("refresh %d: tags by address = %v, want %v", refresh, got, want)
+		}
+	}
+}
+
 func TestReadBoundedOutboundSubscriptionBody(t *testing.T) {
 	t.Run("accepts body at the limit", func(t *testing.T) {
 		want := bytes.Repeat([]byte("a"), int(maxOutboundSubscriptionBytes))

+ 14 - 2
internal/web/translation/ar-EG.json

@@ -80,7 +80,6 @@
   "somethingWentWrong": "حدث خطأ ما",
   "subscription": {
     "title": "معلومات الاشتراك",
-    "subId": "معرّف الاشتراك",
     "status": "الحالة",
     "downloaded": "التنزيل",
     "uploaded": "الرفع",
@@ -93,7 +92,20 @@
     "noExpiry": "بدون انتهاء",
     "copyAllConfigs": "نسخ جميع الإعدادات",
     "copyAllConfigsCopied": "تم نسخ جميع الإعدادات",
-    "email": "البريد"
+    "daysLeft": "الأيام المتبقية",
+    "expired": "منتهي الصلاحية",
+    "depleted": "نفدت البيانات",
+    "ofTotal": "من {total}",
+    "tabLinks": "الاشتراك",
+    "tabApps": "التطبيقات",
+    "tabConfigs": "الكونفيجات",
+    "scanTitle": "امسح بهاتفك",
+    "scanHint": "امسح هذا الرمز في تطبيق VPN لإضافة الاشتراك دون نسخ الرابط.",
+    "updateInterval": "تحديث تلقائي كل {hours} ساعة",
+    "support": "الدعم",
+    "qrTitle": "امسح. استورد. اتصل.",
+    "qrHint": "امسح الرمز بتطبيق العميل. لا تشارك هذا الرمز مع أحد.",
+    "saveQr": "حفظ QR"
   },
   "menu": {
     "theme": "الثيم",

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

@@ -80,8 +80,6 @@
   "somethingWentWrong": "Something went wrong",
   "subscription": {
     "title": "Subscription info",
-    "subId": "Subscription ID",
-    "email": "Email",
     "status": "Status",
     "downloaded": "Downloaded",
     "uploaded": "Uploaded",
@@ -93,7 +91,21 @@
     "unlimited": "Unlimited",
     "noExpiry": "No expiry",
     "copyAllConfigs": "Copy All Configs",
-    "copyAllConfigsCopied": "All configs copied"
+    "copyAllConfigsCopied": "All configs copied",
+    "daysLeft": "Days left",
+    "expired": "Expired",
+    "depleted": "Data used up",
+    "ofTotal": "of {total}",
+    "tabLinks": "Subscription",
+    "tabApps": "Apps",
+    "tabConfigs": "Configs",
+    "scanTitle": "Scan with your phone",
+    "scanHint": "Scan this code in your VPN app to add the subscription without copying the link.",
+    "updateInterval": "Auto-updates every {hours} hours",
+    "support": "Support",
+    "qrTitle": "Scan. Import. Connect.",
+    "qrHint": "Scan with your client app. Keep this code private.",
+    "saveQr": "Save QR"
   },
   "menu": {
     "theme": "Theme",

+ 14 - 2
internal/web/translation/es-ES.json

@@ -80,7 +80,6 @@
   "somethingWentWrong": "Algo salió mal",
   "subscription": {
     "title": "Información de suscripción",
-    "subId": "ID de suscripción",
     "status": "Estado",
     "downloaded": "Descargado",
     "uploaded": "Subido",
@@ -93,7 +92,20 @@
     "noExpiry": "Sin caducidad",
     "copyAllConfigs": "Copiar Todas las Configuraciones",
     "copyAllConfigsCopied": "Todas las configuraciones copiadas",
-    "email": "Email"
+    "daysLeft": "Días restantes",
+    "expired": "Caducada",
+    "depleted": "Datos agotados",
+    "ofTotal": "de {total}",
+    "tabLinks": "Suscripción",
+    "tabApps": "Aplicaciones",
+    "tabConfigs": "Configuraciones",
+    "scanTitle": "Escanea con tu teléfono",
+    "scanHint": "Escanea este código en tu aplicación VPN para añadir la suscripción sin copiar el enlace.",
+    "updateInterval": "Se actualiza automáticamente cada {hours} horas",
+    "support": "Soporte",
+    "qrTitle": "Escanea. Importa. Conéctate.",
+    "qrHint": "Escanéalo con tu aplicación cliente. Mantén este código en privado.",
+    "saveQr": "Guardar QR"
   },
   "menu": {
     "theme": "Tema",

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

@@ -80,8 +80,6 @@
   "somethingWentWrong": "مشکلی پیش آمد",
   "subscription": {
     "title": "اطلاعات سابسکریپشن",
-    "subId": "شناسه اشتراک",
-    "email": "ایمیل",
     "status": "وضعیت",
     "downloaded": "دانلود",
     "uploaded": "آپلود",
@@ -93,7 +91,21 @@
     "unlimited": "نامحدود",
     "noExpiry": "بدون انقضا",
     "copyAllConfigs": "کپی همه کانفیگ‌ها",
-    "copyAllConfigsCopied": "همه کانفیگ‌ها کپی شدند"
+    "copyAllConfigsCopied": "همه کانفیگ‌ها کپی شدند",
+    "daysLeft": "روز باقی‌مانده",
+    "expired": "منقضی شده",
+    "depleted": "حجم تمام شده",
+    "ofTotal": "از {total}",
+    "tabLinks": "اشتراک",
+    "tabApps": "اپلیکیشن‌ها",
+    "tabConfigs": "کانفیگ‌ها",
+    "scanTitle": "اسکن با گوشی",
+    "scanHint": "این کد را در اپلیکیشن VPN اسکن کنید تا اشتراک بدون کپی‌کردن لینک اضافه شود.",
+    "updateInterval": "به‌روزرسانی خودکار هر {hours} ساعت",
+    "support": "پشتیبانی",
+    "qrTitle": "اسکن. افزودن. اتصال.",
+    "qrHint": "با اپلیکیشن کلاینت اسکن کنید. این کد را به کسی ندهید.",
+    "saveQr": "ذخیره QR"
   },
   "menu": {
     "theme": "تم",

+ 14 - 2
internal/web/translation/id-ID.json

@@ -80,7 +80,6 @@
   "somethingWentWrong": "Terjadi kesalahan",
   "subscription": {
     "title": "Info langganan",
-    "subId": "ID langganan",
     "status": "Status",
     "downloaded": "Diunduh",
     "uploaded": "Diunggah",
@@ -93,7 +92,20 @@
     "noExpiry": "Tanpa kedaluwarsa",
     "copyAllConfigs": "Salin Semua Konfigurasi",
     "copyAllConfigsCopied": "Semua konfigurasi tersalin",
-    "email": "Email"
+    "daysLeft": "Sisa hari",
+    "expired": "Kedaluwarsa",
+    "depleted": "Kuota habis",
+    "ofTotal": "dari {total}",
+    "tabLinks": "Langganan",
+    "tabApps": "Aplikasi",
+    "tabConfigs": "Konfigurasi",
+    "scanTitle": "Pindai dengan ponsel",
+    "scanHint": "Pindai kode ini di aplikasi VPN untuk menambahkan langganan tanpa menyalin tautan.",
+    "updateInterval": "Diperbarui otomatis setiap {hours} jam",
+    "support": "Dukungan",
+    "qrTitle": "Pindai. Impor. Terhubung.",
+    "qrHint": "Pindai dengan aplikasi klien Anda. Jaga kerahasiaan kode ini.",
+    "saveQr": "Simpan QR"
   },
   "menu": {
     "theme": "Tema",

+ 14 - 2
internal/web/translation/ja-JP.json

@@ -80,7 +80,6 @@
   "somethingWentWrong": "エラーが発生しました",
   "subscription": {
     "title": "サブスクリプション情報",
-    "subId": "サブスクリプションID",
     "status": "ステータス",
     "downloaded": "ダウンロード",
     "uploaded": "アップロード",
@@ -93,7 +92,20 @@
     "noExpiry": "期限なし",
     "copyAllConfigs": "すべての設定をコピー",
     "copyAllConfigsCopied": "すべての設定をコピーしました",
-    "email": "メール"
+    "daysLeft": "残り日数",
+    "expired": "期限切れ",
+    "depleted": "データ上限到達",
+    "ofTotal": "/ {total}",
+    "tabLinks": "サブスクリプション",
+    "tabApps": "アプリ",
+    "tabConfigs": "設定",
+    "scanTitle": "スマートフォンでスキャン",
+    "scanHint": "VPN アプリでこのコードをスキャンすると、リンクをコピーせずにサブスクリプションを追加できます。",
+    "updateInterval": "{hours} 時間ごとに自動更新",
+    "support": "サポート",
+    "qrTitle": "スキャン。インポート。接続。",
+    "qrHint": "クライアントアプリでスキャンしてください。このコードは他人に見せないでください。",
+    "saveQr": "QR を保存"
   },
   "menu": {
     "theme": "テーマ",

+ 14 - 2
internal/web/translation/pt-BR.json

@@ -80,7 +80,6 @@
   "somethingWentWrong": "Algo deu errado",
   "subscription": {
     "title": "Informações da assinatura",
-    "subId": "ID da assinatura",
     "status": "Status",
     "downloaded": "Baixado",
     "uploaded": "Enviado",
@@ -93,7 +92,20 @@
     "noExpiry": "Sem validade",
     "copyAllConfigs": "Copiar Todas as Configurações",
     "copyAllConfigsCopied": "Todas as configurações copiadas",
-    "email": "Email"
+    "daysLeft": "Dias restantes",
+    "expired": "Expirada",
+    "depleted": "Dados esgotados",
+    "ofTotal": "de {total}",
+    "tabLinks": "Assinatura",
+    "tabApps": "Aplicativos",
+    "tabConfigs": "Configurações",
+    "scanTitle": "Escaneie com o celular",
+    "scanHint": "Escaneie este código no seu aplicativo de VPN para adicionar a assinatura sem copiar o link.",
+    "updateInterval": "Atualiza automaticamente a cada {hours} horas",
+    "support": "Suporte",
+    "qrTitle": "Escaneie. Importe. Conecte.",
+    "qrHint": "Escaneie com seu aplicativo cliente. Mantenha este código em sigilo.",
+    "saveQr": "Salvar QR"
   },
   "menu": {
     "theme": "Tema",

+ 14 - 2
internal/web/translation/ru-RU.json

@@ -80,7 +80,6 @@
   "somethingWentWrong": "Что-то пошло не так",
   "subscription": {
     "title": "Информация о подписке",
-    "subId": "ID подписки",
     "status": "Статус",
     "downloaded": "Загружено",
     "uploaded": "Отправлено",
@@ -93,7 +92,20 @@
     "noExpiry": "Бессрочно",
     "copyAllConfigs": "Копировать все конфигурации",
     "copyAllConfigsCopied": "Все конфигурации скопированы",
-    "email": "Email"
+    "daysLeft": "Осталось дней",
+    "expired": "Истёк",
+    "depleted": "Трафик исчерпан",
+    "ofTotal": "из {total}",
+    "tabLinks": "Подписка",
+    "tabApps": "Приложения",
+    "tabConfigs": "Конфиги",
+    "scanTitle": "Сканируйте телефоном",
+    "scanHint": "Отсканируйте этот код в VPN-приложении, чтобы добавить подписку без копирования ссылки.",
+    "updateInterval": "Автообновление каждые {hours} ч",
+    "support": "Поддержка",
+    "qrTitle": "Сканируйте. Импортируйте. Подключайтесь.",
+    "qrHint": "Отсканируйте в клиентском приложении. Никому не показывайте этот код.",
+    "saveQr": "Сохранить QR"
   },
   "menu": {
     "theme": "Тема",

+ 14 - 2
internal/web/translation/tr-TR.json

@@ -80,7 +80,6 @@
   "somethingWentWrong": "Bir hata oluştu",
   "subscription": {
     "title": "Abonelik Bilgisi",
-    "subId": "Abonelik Kimliği",
     "status": "Durum",
     "downloaded": "İndirilen",
     "uploaded": "Yüklenen",
@@ -93,7 +92,20 @@
     "noExpiry": "Süresiz",
     "copyAllConfigs": "Tüm Yapılandırmaları Kopyala",
     "copyAllConfigsCopied": "Tüm yapılandırmalar kopyalandı",
-    "email": "E-posta"
+    "daysLeft": "Kalan gün",
+    "expired": "Süresi doldu",
+    "depleted": "Kota doldu",
+    "ofTotal": "/ {total}",
+    "tabLinks": "Abonelik",
+    "tabApps": "Uygulamalar",
+    "tabConfigs": "Yapılandırmalar",
+    "scanTitle": "Telefonunuzla tarayın",
+    "scanHint": "Bağlantıyı kopyalamadan aboneliği eklemek için bu kodu VPN uygulamanızda tarayın.",
+    "updateInterval": "Her {hours} saatte bir otomatik güncellenir",
+    "support": "Destek",
+    "qrTitle": "Tara. İçe aktar. Bağlan.",
+    "qrHint": "İstemci uygulamanızla tarayın. Bu kodu kimseyle paylaşmayın.",
+    "saveQr": "QR'ı kaydet"
   },
   "menu": {
     "theme": "Tema",

+ 14 - 2
internal/web/translation/uk-UA.json

@@ -80,7 +80,6 @@
   "somethingWentWrong": "Щось пішло не так",
   "subscription": {
     "title": "Інформація про підписку",
-    "subId": "ID підписки",
     "status": "Статус",
     "downloaded": "Завантажено",
     "uploaded": "Відвантажено",
@@ -93,7 +92,20 @@
     "noExpiry": "Без строку",
     "copyAllConfigs": "Копіювати всі конфігурації",
     "copyAllConfigsCopied": "Всі конфігурації скопійовано",
-    "email": "Email"
+    "daysLeft": "Залишилось днів",
+    "expired": "Термін дії минув",
+    "depleted": "Трафік вичерпано",
+    "ofTotal": "з {total}",
+    "tabLinks": "Підписка",
+    "tabApps": "Застосунки",
+    "tabConfigs": "Конфіги",
+    "scanTitle": "Скануйте телефоном",
+    "scanHint": "Відскануйте цей код у VPN-застосунку, щоб додати підписку без копіювання посилання.",
+    "updateInterval": "Автооновлення кожні {hours} год",
+    "support": "Підтримка",
+    "qrTitle": "Скануйте. Імпортуйте. Підключайтеся.",
+    "qrHint": "Відскануйте в клієнтському застосунку. Нікому не показуйте цей код.",
+    "saveQr": "Зберегти QR"
   },
   "menu": {
     "theme": "Тема",

+ 14 - 2
internal/web/translation/vi-VN.json

@@ -80,7 +80,6 @@
   "somethingWentWrong": "Đã xảy ra lỗi",
   "subscription": {
     "title": "Thông tin đăng ký",
-    "subId": "ID đăng ký",
     "status": "Trạng thái",
     "downloaded": "Đã tải xuống",
     "uploaded": "Đã tải lên",
@@ -93,7 +92,20 @@
     "noExpiry": "Không hết hạn",
     "copyAllConfigs": "Sao chép tất cả cấu hình",
     "copyAllConfigsCopied": "Đã sao chép tất cả cấu hình",
-    "email": "Email"
+    "daysLeft": "Số ngày còn lại",
+    "expired": "Đã hết hạn",
+    "depleted": "Hết dung lượng",
+    "ofTotal": "trên {total}",
+    "tabLinks": "Gói đăng ký",
+    "tabApps": "Ứng dụng",
+    "tabConfigs": "Cấu hình",
+    "scanTitle": "Quét bằng điện thoại",
+    "scanHint": "Quét mã này trong ứng dụng VPN để thêm gói đăng ký mà không cần sao chép liên kết.",
+    "updateInterval": "Tự động cập nhật mỗi {hours} giờ",
+    "support": "Hỗ trợ",
+    "qrTitle": "Quét. Nhập. Kết nối.",
+    "qrHint": "Quét bằng ứng dụng khách. Không chia sẻ mã này cho người khác.",
+    "saveQr": "Lưu QR"
   },
   "menu": {
     "theme": "Chủ đề",

+ 14 - 2
internal/web/translation/zh-CN.json

@@ -80,7 +80,6 @@
   "somethingWentWrong": "出了点问题",
   "subscription": {
     "title": "订阅信息",
-    "subId": "订阅 ID",
     "status": "状态",
     "downloaded": "已下载",
     "uploaded": "已上传",
@@ -93,7 +92,20 @@
     "noExpiry": "无到期",
     "copyAllConfigs": "复制全部配置",
     "copyAllConfigsCopied": "已复制全部配置",
-    "email": "邮箱"
+    "daysLeft": "剩余天数",
+    "expired": "已过期",
+    "depleted": "流量已用完",
+    "ofTotal": "共 {total}",
+    "tabLinks": "订阅",
+    "tabApps": "应用",
+    "tabConfigs": "配置",
+    "scanTitle": "用手机扫码",
+    "scanHint": "在 VPN 应用中扫描此二维码,无需复制链接即可添加订阅。",
+    "updateInterval": "每 {hours} 小时自动更新",
+    "support": "支持",
+    "qrTitle": "扫码。导入。连接。",
+    "qrHint": "使用客户端应用扫描。请勿泄露此二维码。",
+    "saveQr": "保存二维码"
   },
   "menu": {
     "theme": "主题",

+ 14 - 2
internal/web/translation/zh-TW.json

@@ -80,7 +80,6 @@
   "somethingWentWrong": "發生錯誤",
   "subscription": {
     "title": "訂閱資訊",
-    "subId": "訂閱 ID",
     "status": "狀態",
     "downloaded": "已下載",
     "uploaded": "已上傳",
@@ -93,7 +92,20 @@
     "noExpiry": "無到期",
     "copyAllConfigs": "複製全部配置",
     "copyAllConfigsCopied": "已複製全部配置",
-    "email": "電子郵件"
+    "daysLeft": "剩餘天數",
+    "expired": "已過期",
+    "depleted": "流量已用完",
+    "ofTotal": "共 {total}",
+    "tabLinks": "訂閱",
+    "tabApps": "應用程式",
+    "tabConfigs": "設定檔",
+    "scanTitle": "用手機掃描",
+    "scanHint": "在 VPN 應用程式中掃描此 QR 碼,無需複製連結即可新增訂閱。",
+    "updateInterval": "每 {hours} 小時自動更新",
+    "support": "支援",
+    "qrTitle": "掃描。匯入。連線。",
+    "qrHint": "使用用戶端應用程式掃描。請勿外洩此 QR 碼。",
+    "saveQr": "儲存 QR 碼"
   },
   "menu": {
     "theme": "主題",