Parcourir la source

feat(sub): compact subscription rows with per-link email + PQ QR hide

Mirror the ClientInfoModal redesign on the public SubPage so the
subscription viewer reads as a tight `[PROTO] [remark] [copy] [QR]`
row per link instead of raw URL cards.

- subService.GetSubs now returns the per-link email list alongside the
  links, threaded through subController and BuildPageData into the
  `emails` field on subData (env.d.ts updated). Public links.go is
  updated to ignore the new return.
- SubPage strips the client email from each row title using the
  matched per-link email (same trimEmail behaviour as the modal), and
  hides the QR button for post-quantum links (`pqv=`, `mlkem768`,
  `mldsa65`) since the encoded URL won't fit in a single QR.
MHSanaei il y a 16 heures
Parent
commit
e7ac1fadaa

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

@@ -16,6 +16,7 @@ interface SubPageData {
   subClashUrl?: string;
   subTitle?: string;
   links?: string[];
+  emails?: string[];
   datepicker?: 'gregorian' | 'jalalian';
   downloadByte?: string | number;
   uploadByte?: string | number;

+ 42 - 34
frontend/src/pages/sub/SubPage.css

@@ -61,58 +61,66 @@
 
 .links-section {
   margin-top: 16px;
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
 }
 
-.link-row {
-  position: relative;
-  margin-bottom: 16px;
-  text-align: center;
-}
-
-.link-tag {
-  margin-bottom: -10px;
-  position: relative;
-  z-index: 2;
-  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
-}
-
-.link-box {
-  cursor: pointer;
-  border-radius: 12px;
-  padding: 22px 18px 14px;
-  margin-top: -10px;
-  word-break: break-all;
-  font-size: 13px;
-  line-height: 1.5;
-  text-align: left;
-  font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
-  transition: background 120ms ease, border-color 120ms ease;
-  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.08);
+.sub-link-row {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 8px 12px;
+  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;
 }
 
-.link-box:hover {
+.sub-link-row:hover {
   background: rgba(0, 0, 0, 0.05);
   border-color: rgba(0, 0, 0, 0.14);
 }
 
-.link-copy-icon {
-  margin-right: 6px;
-  opacity: 0.6;
-}
-
-.is-dark .link-box {
+.is-dark .sub-link-row {
   background: rgba(0, 0, 0, 0.2);
   border-color: rgba(255, 255, 255, 0.1);
-  color: rgba(255, 255, 255, 0.85);
 }
 
-.is-dark .link-box:hover {
+.is-dark .sub-link-row:hover {
   background: rgba(0, 0, 0, 0.3);
   border-color: rgba(255, 255, 255, 0.2);
 }
 
+.sub-link-tag {
+  margin: 0;
+  flex-shrink: 0;
+  font-weight: 600;
+  letter-spacing: 0.3px;
+}
+
+.sub-link-title {
+  flex: 1;
+  min-width: 0;
+  font-size: 13px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.sub-link-actions {
+  display: flex;
+  gap: 4px;
+  flex-shrink: 0;
+}
+
+.sub-link-qr-popover {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 6px;
+}
+
 .apps-row {
   margin-top: 24px;
 }

+ 123 - 17
frontend/src/pages/sub/SubPage.tsx

@@ -23,6 +23,7 @@ import {
   DownOutlined,
   MoonFilled,
   MoonOutlined,
+  QrcodeOutlined,
   SunOutlined,
   TranslationOutlined,
 } from '@ant-design/icons';
@@ -51,6 +52,7 @@ const subJsonUrl = subData.subJsonUrl || '';
 const subClashUrl = subData.subClashUrl || '';
 const subTitle = subData.subTitle || '';
 const links: string[] = Array.isArray(subData.links) ? subData.links : [];
+const linkEmails: string[] = Array.isArray(subData.emails) ? subData.emails : [];
 const datepicker = subData.datepicker || 'gregorian';
 
 const isUnlimited = totalByte <= 0 && expireMs === 0;
@@ -65,18 +67,72 @@ const isActive = (() => {
   return true;
 })();
 
-function linkName(link: string, idx: number): string {
-  if (!link) return `Link ${idx + 1}`;
-  const hashIdx = link.indexOf('#');
-  if (hashIdx >= 0 && hashIdx + 1 < link.length) {
+const PROTOCOL_COLORS: Record<string, string> = {
+  VLESS: 'blue',
+  VMESS: 'geekblue',
+  TROJAN: 'volcano',
+  SS: 'magenta',
+  HYSTERIA: 'cyan',
+  HY2: 'green',
+};
+
+// Same idea as ClientInfoModal.trimEmail — strip the client email
+// suffix from the remark so the row title isn't ugly twice.
+function trimEmail(remark: string, email: string): string {
+  if (!email) return remark;
+  const e = email.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+  return remark
+    .replace(new RegExp(`[-_.\\s|]+${e}$`), '')
+    .replace(new RegExp(`^${e}[-_.\\s|]+`), '')
+    .trim();
+}
+
+// Post-quantum keys blow up the encoded URL past what a single QR can
+// hold. The algorithm names don't appear as plain text in the URL —
+// they ride inside query params: mldsa65Verify → `pqv=<base64>`,
+// ML-KEM-768 → `encryption=mlkem768x25519plus.<...>`. The literal
+// substrings are also matched in case a config (e.g. wireguard) embeds
+// them directly.
+function isPostQuantumLink(link: string): boolean {
+  if (/[?&]pqv=/.test(link)) return true;
+  if (link.includes('mlkem768') || link.includes('mldsa65')) return true;
+  if (link.includes('ML-KEM-768')) return true;
+  return false;
+}
+
+function parseLinkMeta(link: string, idx: number): { protocol: string; remark: string } {
+  const fallback = `Link ${idx + 1}`;
+  if (!link) return { protocol: 'LINK', remark: fallback };
+  const schemeMatch = /^([a-z0-9]+):\/\//i.exec(link);
+  const scheme = schemeMatch?.[1]?.toLowerCase() ?? '';
+  const protocolMap: Record<string, string> = {
+    vless: 'VLESS',
+    vmess: 'VMESS',
+    trojan: 'TROJAN',
+    ss: 'SS',
+    hysteria: 'HYSTERIA',
+    hysteria2: 'HY2',
+    hy2: 'HY2',
+  };
+  const protocol = protocolMap[scheme] ?? scheme.toUpperCase() ?? 'LINK';
+
+  let remark = '';
+  if (scheme === 'vmess') {
     try {
-      return decodeURIComponent(link.slice(hashIdx + 1));
-    } catch {
-      return link.slice(hashIdx + 1);
+      const body = link.slice('vmess://'.length).split('#')[0];
+      const json = JSON.parse(atob(body)) as { ps?: unknown };
+      if (typeof json?.ps === 'string') remark = json.ps;
+    } catch { /* fall through */ }
+  }
+  if (!remark) {
+    const hashIdx = link.indexOf('#');
+    if (hashIdx >= 0 && hashIdx + 1 < link.length) {
+      const raw = link.slice(hashIdx + 1);
+      try { remark = decodeURIComponent(raw); }
+      catch { remark = raw; }
     }
   }
-  const proto = link.split('://')[0];
-  return `${proto.toUpperCase()} ${idx + 1}`;
+  return { protocol, remark: remark || fallback };
 }
 
 export default function SubPage() {
@@ -344,15 +400,65 @@ export default function SubPage() {
 
                 {links.length > 0 && (
                   <div className="links-section">
-                    {links.map((link, idx) => (
-                      <div key={link} className="link-row" onClick={() => copy(link)}>
-                        <Tag color="purple" className="link-tag">{linkName(link, idx)}</Tag>
-                        <div className="link-box">
-                          <CopyOutlined className="link-copy-icon" />
-                          {link}
+                    {links.map((link, idx) => {
+                      const meta = parseLinkMeta(link, idx);
+                      const rowTitle = trimEmail(meta.remark, linkEmails[idx] || '') || meta.remark;
+                      const canQr = !isPostQuantumLink(link);
+                      return (
+                        <div key={link} className="sub-link-row">
+                          <Tag
+                            color={PROTOCOL_COLORS[meta.protocol] ?? 'default'}
+                            className="sub-link-tag"
+                          >
+                            {meta.protocol}
+                          </Tag>
+                          <span className="sub-link-title" title={meta.remark}>
+                            {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
+                                      color={PROTOCOL_COLORS[meta.protocol] ?? 'default'}
+                                      className="qr-tag"
+                                    >
+                                      {meta.remark}
+                                    </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>
-                      </div>
-                    ))}
+                      );
+                    })}
                   </div>
                 )}
 

+ 1 - 1
sub/links.go

@@ -28,7 +28,7 @@ func (p *LinkProvider) build(host string) *SubService {
 
 func (p *LinkProvider) SubLinksForSubId(host, subId string) ([]string, error) {
 	svc := p.build(host)
-	links, _, _, err := svc.GetSubs(subId, host)
+	links, _, _, _, err := svc.GetSubs(subId, host)
 	if err != nil {
 		return nil, err
 	}

+ 3 - 2
sub/subController.go

@@ -115,7 +115,7 @@ func (a *SUBController) initRouter(g *gin.RouterGroup) {
 func (a *SUBController) subs(c *gin.Context) {
 	subId := c.Param("subid")
 	scheme, host, hostWithPort, hostHeader := a.subService.ResolveRequest(c)
-	subs, lastOnline, traffic, err := a.subService.GetSubs(subId, host)
+	subs, emails, lastOnline, traffic, err := a.subService.GetSubs(subId, host)
 	if err != nil || len(subs) == 0 {
 		writeSubError(c, err)
 	} else {
@@ -139,7 +139,7 @@ func (a *SUBController) subs(c *gin.Context) {
 				basePath = "/"
 			}
 			basePathStr := basePath.(string)
-			page := a.subService.BuildPageData(subId, hostHeader, traffic, lastOnline, subs, subURL, subJsonURL, subClashURL, basePathStr, a.subTitle, a.subSupportUrl)
+			page := a.subService.BuildPageData(subId, hostHeader, traffic, lastOnline, subs, emails, subURL, subJsonURL, subClashURL, basePathStr, a.subTitle, a.subSupportUrl)
 			a.serveSubPage(c, basePathStr, page)
 			return
 		}
@@ -213,6 +213,7 @@ func (a *SUBController) serveSubPage(c *gin.Context, basePath string, page PageD
 		"subJsonUrl":   page.SubJsonUrl,
 		"subClashUrl":  page.SubClashUrl,
 		"links":        page.Result,
+		"emails":       page.Emails,
 		"datepicker":   datepicker,
 	}
 	subDataJSON, err := json.Marshal(subData)

+ 9 - 5
sub/subService.go

@@ -57,20 +57,21 @@ func (s *SubService) PrepareForRequest(host string) {
 }
 
 // GetSubs retrieves subscription links for a given subscription ID and host.
-func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.ClientTraffic, error) {
+func (s *SubService) GetSubs(subId string, host string) ([]string, []string, int64, xray.ClientTraffic, error) {
 	s.PrepareForRequest(host)
 	var result []string
+	var emails []string
 	var traffic xray.ClientTraffic
 	var lastOnline int64
 	var hasEnabledClient bool
 	var clientTraffics []xray.ClientTraffic
 	inbounds, err := s.getInboundsBySubId(subId)
 	if err != nil {
-		return nil, 0, traffic, err
+		return nil, nil, 0, traffic, err
 	}
 
 	if len(inbounds) == 0 {
-		return nil, 0, traffic, nil
+		return nil, nil, 0, traffic, nil
 	}
 
 	s.datepicker, err = s.settingService.GetDatepicker()
@@ -99,6 +100,7 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.C
 					hasEnabledClient = true
 				}
 				result = append(result, s.GetLink(inbound, client.Email))
+				emails = append(emails, client.Email)
 				var ct xray.ClientTraffic
 				ct, clientTraffics = s.appendUniqueTraffic(seenEmails, clientTraffics, inbound.ClientStats, client.Email)
 				if ct.LastOnline > lastOnline {
@@ -130,7 +132,7 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.C
 		}
 	}
 	traffic.Enable = hasEnabledClient
-	return result, lastOnline, traffic, nil
+	return result, emails, lastOnline, traffic, nil
 }
 
 func subscriptionExpiryFromClient(nowMs, expiryTime int64) int64 {
@@ -1708,6 +1710,7 @@ type PageData struct {
 	SubTitle      string
 	SubSupportUrl string
 	Result        []string
+	Emails        []string
 }
 
 // ResolveRequest extracts scheme and host info from request/headers consistently.
@@ -1821,7 +1824,7 @@ func (s *SubService) joinPathWithID(basePath, subId string) string {
 
 // BuildPageData parses header and prepares the template view model.
 // BuildPageData constructs page data for rendering the subscription information page.
-func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray.ClientTraffic, lastOnline int64, subs []string, subURL, subJsonURL, subClashURL string, basePath string, subTitle string, subSupportUrl string) PageData {
+func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray.ClientTraffic, lastOnline int64, subs []string, emails []string, subURL, subJsonURL, subClashURL string, basePath string, subTitle string, subSupportUrl string) PageData {
 	download := common.FormatTraffic(traffic.Down)
 	upload := common.FormatTraffic(traffic.Up)
 	total := "∞"
@@ -1860,6 +1863,7 @@ func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray
 		SubTitle:      subTitle,
 		SubSupportUrl: subSupportUrl,
 		Result:        subs,
+		Emails:        emails,
 	}
 }