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;
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;

View file

@ -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>
)}
</>
)}
</>

View file

@ -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;

View file

@ -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">

View file

@ -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(),