mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-05 20:54:14 +00:00
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:
parent
87eaa79e5d
commit
6c279d48fd
5 changed files with 233 additions and 139 deletions
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue