Bladeren bron

feat(sub): clash row + reorganise SubPage around Subscription info

ClientInfoModal:
- Add a Clash / Mihomo row to the subscription section, gated on
  subClashEnable + subClashURI from /panel/setting/defaultSettings.
  Defaults payload schema is widened to carry subClashURI/subClashEnable.

SubPage:
- Drop the rectangular QR-codes header that used to sit at the very
  top of the card. The subscription info table now leads, followed by
  Divider("Copy URL") + per-protocol link rows (already converted to
  the compact ClientInfoModal pattern), then a new Divider("Subscription")
  + compact rows for the SUB / JSON / CLASH URLs with copy + QR-popover
  actions. The apps dropdown row remains the footer.

CSS clean-up: removed the now-unused .qr-row/.qr-col/.qr-box/.qr-code
rules; kept .qr-tag and trimmed the info-table top gap. Added a
.sub-link-anchor underline-on-hover style for the new URL rows.
MHSanaei 16 uur geleden
bovenliggende
commit
6c279d48fd

+ 12 - 1
frontend/src/hooks/useClients.ts

@@ -34,6 +34,8 @@ interface SubSettings {
   subURI: string;
   subJsonURI: string;
   subJsonEnable: boolean;
+  subClashURI: string;
+  subClashEnable: boolean;
 }
 
 export interface ClientQueryParams {
@@ -157,7 +159,16 @@ export function useClients() {
     subURI: (defaults.subURI as string) || '',
     subJsonURI: (defaults.subJsonURI as string) || '',
     subJsonEnable: !!defaults.subJsonEnable,
-  }), [defaults.subEnable, defaults.subURI, defaults.subJsonURI, defaults.subJsonEnable]);
+    subClashURI: (defaults.subClashURI as string) || '',
+    subClashEnable: !!defaults.subClashEnable,
+  }), [
+    defaults.subEnable,
+    defaults.subURI,
+    defaults.subJsonURI,
+    defaults.subJsonEnable,
+    defaults.subClashURI,
+    defaults.subClashEnable,
+  ]);
 
   const ipLimitEnable = !!defaults.ipLimitEnable;
   const tgBotEnable = !!defaults.tgBotEnable;

+ 47 - 1
frontend/src/pages/clients/ClientInfoModal.tsx

@@ -99,6 +99,8 @@ interface SubSettings {
   subURI: string;
   subJsonURI: string;
   subJsonEnable: boolean;
+  subClashURI: string;
+  subClashEnable: boolean;
 }
 
 interface ClientInfoModalProps {
@@ -115,7 +117,14 @@ interface ApiMsg<T = unknown> {
   obj?: T;
 }
 
-const DEFAULT_SUB: SubSettings = { enable: false, subURI: '', subJsonURI: '', subJsonEnable: false };
+const DEFAULT_SUB: SubSettings = {
+  enable: false,
+  subURI: '',
+  subJsonURI: '',
+  subJsonEnable: false,
+  subClashURI: '',
+  subClashEnable: false,
+};
 
 export default function ClientInfoModal({
   open,
@@ -176,6 +185,12 @@ export default function ClientInfoModal({
     return subSettings.subJsonURI + client.subId;
   }, [client?.subId, subSettings?.subJsonEnable, subSettings?.subJsonURI]);
 
+  const subClashLink = useMemo(() => {
+    if (!client?.subId) return '';
+    if (!subSettings?.subClashEnable || !subSettings?.subClashURI) return '';
+    return subSettings.subClashURI + client.subId;
+  }, [client?.subId, subSettings?.subClashEnable, subSettings?.subClashURI]);
+
   const showSubscription = !!(subSettings?.enable && client?.subId);
 
   async function copyValue(text: string) {
@@ -459,6 +474,37 @@ export default function ClientInfoModal({
                     </div>
                   </div>
                 )}
+                {subClashLink && (
+                  <div className="link-row">
+                    <Tooltip title="Clash / Mihomo">
+                      <Tag color="gold" className="link-row-tag">CLASH</Tag>
+                    </Tooltip>
+                    <a
+                      href={subClashLink}
+                      target="_blank"
+                      rel="noopener noreferrer"
+                      className="link-row-title link-row-title-anchor"
+                      title={subClashLink}
+                    >
+                      {client.subId}
+                    </a>
+                    <div className="link-row-actions">
+                      <Tooltip title={t('copy')}>
+                        <Button size="small" icon={<CopyOutlined />} onClick={() => copyValue(subClashLink)} />
+                      </Tooltip>
+                      <Popover
+                        trigger="click"
+                        placement="left"
+                        destroyOnHidden
+                        content={<QrPanel value={subClashLink} remark={`${client.email} — Clash / Mihomo`} size={220} />}
+                      >
+                        <Tooltip title={t('pages.clients.qrCode')}>
+                          <Button size="small" icon={<QrcodeOutlined />} />
+                        </Tooltip>
+                      </Popover>
+                    </div>
+                  </div>
+                )}
               </>
             )}
           </>

+ 10 - 23
frontend/src/pages/sub/SubPage.css

@@ -28,44 +28,31 @@
   margin-top: 8px;
 }
 
-.qr-row {
-  margin-bottom: 12px;
-}
-
-.qr-col {
-  display: flex;
-  justify-content: center;
-}
-
-.qr-box {
-  display: inline-flex;
-  flex-direction: column;
-  align-items: center;
-  gap: 4px;
-  width: 240px;
-}
-
 .qr-tag {
   width: 100%;
   text-align: center;
   margin: 0;
 }
 
-.qr-code {
-  cursor: pointer;
-}
-
 .info-table {
-  margin-top: 12px;
+  margin-top: 4px;
 }
 
 .links-section {
-  margin-top: 16px;
   display: flex;
   flex-direction: column;
   gap: 8px;
 }
 
+.sub-link-anchor {
+  color: inherit;
+  text-decoration: none;
+}
+
+.sub-link-anchor:hover {
+  text-decoration: underline;
+}
+
 .sub-link-row {
   display: flex;
   align-items: center;

+ 162 - 114
frontend/src/pages/sub/SubPage.tsx

@@ -6,6 +6,7 @@ import {
   Col,
   ConfigProvider,
   Descriptions,
+  Divider,
   Dropdown,
   Layout,
   Menu,
@@ -15,6 +16,7 @@ import {
   Row,
   Space,
   Tag,
+  Tooltip,
 } from 'antd';
 import {
   AndroidOutlined,
@@ -333,63 +335,6 @@ export default function SubPage() {
           <Row justify="center">
             <Col xs={24} sm={22} md={18} lg={14} xl={12}>
               <Card hoverable className="subscription-card" title={cardTitle} extra={cardExtra}>
-                <Row gutter={[8, 8]} justify="center" className="qr-row">
-                  <Col xs={24} sm={subJsonUrl || subClashUrl ? 12 : 24} className="qr-col">
-                    <div className="qr-box">
-                      <Tag color="purple" className="qr-tag">{t('pages.settings.subSettings')}</Tag>
-                      <QRCode
-                        className="qr-code"
-                        value={subUrl}
-                        size={QR_SIZE}
-                        type="svg"
-                        bordered={false}
-                        color="#000000"
-                        bgColor="#ffffff"
-                        title={t('copy')}
-                        onClick={() => copy(subUrl)}
-                      />
-                    </div>
-                  </Col>
-                  {subJsonUrl && (
-                    <Col xs={24} sm={12} className="qr-col">
-                      <div className="qr-box">
-                        <Tag color="purple" className="qr-tag">
-                          {t('pages.settings.subSettings')} JSON
-                        </Tag>
-                        <QRCode
-                          className="qr-code"
-                          value={subJsonUrl}
-                          size={QR_SIZE}
-                          type="svg"
-                          bordered={false}
-                          color="#000000"
-                          bgColor="#ffffff"
-                          title={t('copy')}
-                          onClick={() => copy(subJsonUrl)}
-                        />
-                      </div>
-                    </Col>
-                  )}
-                  {subClashUrl && (
-                    <Col xs={24} sm={12} className="qr-col">
-                      <div className="qr-box">
-                        <Tag color="purple" className="qr-tag">Clash / Mihomo</Tag>
-                        <QRCode
-                          className="qr-code"
-                          value={subClashUrl}
-                          size={QR_SIZE}
-                          type="svg"
-                          bordered={false}
-                          color="#000000"
-                          bgColor="#ffffff"
-                          title={t('copy')}
-                          onClick={() => copy(subClashUrl)}
-                        />
-                      </div>
-                    </Col>
-                  )}
-                </Row>
-
                 <Descriptions
                   bordered
                   column={1}
@@ -399,67 +344,170 @@ export default function SubPage() {
                 />
 
                 {links.length > 0 && (
-                  <div className="links-section">
-                    {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"
+                  <>
+                    <Divider>{t('pages.inbounds.copyLink')}</Divider>
+                    <div className="links-section">
+                      {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>
+                  </>
+                )}
+
+                {(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}
                           >
-                            {meta.protocol}
-                          </Tag>
-                          <span className="sub-link-title" title={meta.remark}>
-                            {rowTitle}
-                          </span>
+                            {sId}
+                          </a>
                           <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>
-                            )}
+                            <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>
-                      );
-                    })}
-                  </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" 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" 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>
+                  </>
                 )}
 
                 <Row gutter={[8, 8]} justify="center" className="apps-row">

+ 2 - 0
frontend/src/schemas/defaults.ts

@@ -9,6 +9,8 @@ export const DefaultsPayloadSchema = z.object({
   subURI: z.string().optional(),
   subJsonURI: z.string().optional(),
   subJsonEnable: z.boolean().optional(),
+  subClashURI: z.string().optional(),
+  subClashEnable: z.boolean().optional(),
   pageSize: z.number().optional(),
   remarkModel: z.string().optional(),
   datepicker: z.enum(['gregorian', 'jalalian']).optional(),