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.
This commit is contained in:
MHSanaei 2026-05-27 03:24:48 +02:00
parent 87eaa79e5d
commit 6c279d48fd
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
5 changed files with 233 additions and 139 deletions

View file

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

View file

@ -99,6 +99,8 @@ interface SubSettings {
subURI: string; subURI: string;
subJsonURI: string; subJsonURI: string;
subJsonEnable: boolean; subJsonEnable: boolean;
subClashURI: string;
subClashEnable: boolean;
} }
interface ClientInfoModalProps { interface ClientInfoModalProps {
@ -115,7 +117,14 @@ interface ApiMsg<T = unknown> {
obj?: T; 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({ export default function ClientInfoModal({
open, open,
@ -176,6 +185,12 @@ export default function ClientInfoModal({
return subSettings.subJsonURI + client.subId; return subSettings.subJsonURI + client.subId;
}, [client?.subId, subSettings?.subJsonEnable, subSettings?.subJsonURI]); }, [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); const showSubscription = !!(subSettings?.enable && client?.subId);
async function copyValue(text: string) { async function copyValue(text: string) {
@ -459,6 +474,37 @@ export default function ClientInfoModal({
</div> </div>
</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>
)}
</> </>
)} )}
</> </>

View file

@ -28,44 +28,31 @@
margin-top: 8px; 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 { .qr-tag {
width: 100%; width: 100%;
text-align: center; text-align: center;
margin: 0; margin: 0;
} }
.qr-code {
cursor: pointer;
}
.info-table { .info-table {
margin-top: 12px; margin-top: 4px;
} }
.links-section { .links-section {
margin-top: 16px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
} }
.sub-link-anchor {
color: inherit;
text-decoration: none;
}
.sub-link-anchor:hover {
text-decoration: underline;
}
.sub-link-row { .sub-link-row {
display: flex; display: flex;
align-items: center; align-items: center;

View file

@ -6,6 +6,7 @@ import {
Col, Col,
ConfigProvider, ConfigProvider,
Descriptions, Descriptions,
Divider,
Dropdown, Dropdown,
Layout, Layout,
Menu, Menu,
@ -15,6 +16,7 @@ import {
Row, Row,
Space, Space,
Tag, Tag,
Tooltip,
} from 'antd'; } from 'antd';
import { import {
AndroidOutlined, AndroidOutlined,
@ -333,63 +335,6 @@ export default function SubPage() {
<Row justify="center"> <Row justify="center">
<Col xs={24} sm={22} md={18} lg={14} xl={12}> <Col xs={24} sm={22} md={18} lg={14} xl={12}>
<Card hoverable className="subscription-card" title={cardTitle} extra={cardExtra}> <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 <Descriptions
bordered bordered
column={1} column={1}
@ -399,67 +344,170 @@ export default function SubPage() {
/> />
{links.length > 0 && ( {links.length > 0 && (
<div className="links-section"> <>
{links.map((link, idx) => { <Divider>{t('pages.inbounds.copyLink')}</Divider>
const meta = parseLinkMeta(link, idx); <div className="links-section">
const rowTitle = trimEmail(meta.remark, linkEmails[idx] || '') || meta.remark; {links.map((link, idx) => {
const canQr = !isPostQuantumLink(link); const meta = parseLinkMeta(link, idx);
return ( const rowTitle = trimEmail(meta.remark, linkEmails[idx] || '') || meta.remark;
<div key={link} className="sub-link-row"> const canQr = !isPostQuantumLink(link);
<Tag return (
color={PROTOCOL_COLORS[meta.protocol] ?? 'default'} <div key={link} className="sub-link-row">
className="sub-link-tag" <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} {sId}
</Tag> </a>
<span className="sub-link-title" title={meta.remark}>
{rowTitle}
</span>
<div className="sub-link-actions"> <div className="sub-link-actions">
<Button <Button size="small" icon={<CopyOutlined />} onClick={() => copy(subUrl)} aria-label={t('copy')} title={t('copy')} />
size="small" <Popover
icon={<CopyOutlined />} trigger="click"
onClick={() => copy(link)} placement="left"
aria-label={t('copy')} destroyOnHidden
title={t('copy')} content={
/> <div className="sub-link-qr-popover">
{canQr && ( <Tag color="green" className="qr-tag">{t('pages.settings.subSettings')}</Tag>
<Popover <QRCode value={subUrl} size={QR_SIZE} type="svg" bordered={false} color="#000000" bgColor="#ffffff" />
trigger="click" </div>
placement="left" }
destroyOnHidden >
content={ <Button size="small" icon={<QrcodeOutlined />} aria-label="QR" title="QR" />
<div className="sub-link-qr-popover"> </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>
); )}
})} {subJsonUrl && (
</div> <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"> <Row gutter={[8, 8]} justify="center" className="apps-row">

View file

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