mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-13 17:46:02 +00:00
outbound: mobile style
This commit is contained in:
parent
c718e7ca5b
commit
c88627a839
4 changed files with 210 additions and 20 deletions
2
web/assets/css/custom.min.css
vendored
2
web/assets/css/custom.min.css
vendored
File diff suppressed because one or more lines are too long
|
|
@ -55,7 +55,7 @@
|
||||||
</a-popover>
|
</a-popover>
|
||||||
</template>
|
</template>
|
||||||
<template slot="client" slot-scope="text, client">
|
<template slot="client" slot-scope="text, client">
|
||||||
<a-space direction="horizontal" :size="2">
|
<a-space direction="horizontal" :size="2" style="flex-wrap:nowrap;min-width:0">
|
||||||
<a-tooltip>
|
<a-tooltip>
|
||||||
<template slot="title">
|
<template slot="title">
|
||||||
<template v-if="isClientDepleted(record, client.email)">{{ i18n "depleted" }}</template>
|
<template v-if="isClientDepleted(record, client.email)">{{ i18n "depleted" }}</template>
|
||||||
|
|
@ -65,8 +65,10 @@
|
||||||
<a-badge :class="isClientOnline(client.email)? 'online-animation' : ''"
|
<a-badge :class="isClientOnline(client.email)? 'online-animation' : ''"
|
||||||
:color="client.enable ? statsExpColor(record, client.email) : themeSwitcher.isDarkTheme ? '#2c3950' : '#bcbcbc'"></a-badge>
|
:color="client.enable ? statsExpColor(record, client.email) : themeSwitcher.isDarkTheme ? '#2c3950' : '#bcbcbc'"></a-badge>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
<a-space direction="vertical" :size="2">
|
<a-space direction="vertical" :size="2" style="min-width:0;overflow:hidden">
|
||||||
<span class="client-email">[[ client.email ]]</span>
|
<a-tooltip :title="client.email" :overlay-class-name="themeSwitcher.currentTheme">
|
||||||
|
<span class="client-email">[[ client.email ]]</span>
|
||||||
|
</a-tooltip>
|
||||||
<template v-if="client.comment && client.comment.trim()">
|
<template v-if="client.comment && client.comment.trim()">
|
||||||
<a-tooltip v-if="client.comment.length > 50" :overlay-class-name="themeSwitcher.currentTheme">
|
<a-tooltip v-if="client.comment.length > 50" :overlay-class-name="themeSwitcher.currentTheme">
|
||||||
<template slot="title">
|
<template slot="title">
|
||||||
|
|
@ -184,20 +186,20 @@
|
||||||
</a-dropdown>
|
</a-dropdown>
|
||||||
</template>
|
</template>
|
||||||
<template slot="info" slot-scope="text, client, index">
|
<template slot="info" slot-scope="text, client, index">
|
||||||
<a-popover placement="bottomRight" :overlay-class-name="themeSwitcher.currentTheme" trigger="click">
|
<a-popover :placement="isMobile ? 'bottomLeft' : 'bottomRight'" :overlay-class-name="themeSwitcher.currentTheme" trigger="click">
|
||||||
<template slot="content">
|
<template slot="content">
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="3" :style="{ textAlign: 'center' }">{{ i18n "pages.inbounds.traffic" }}</td>
|
<td colspan="3" :style="{ textAlign: 'center' }">{{ i18n "pages.inbounds.traffic" }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="80px" :style="{ margin: '0', textAlign: 'right', fontSize: '1em' }"> [[
|
<td width="65px" :style="{ margin: '0', textAlign: 'right', fontSize: '1em' }"> [[
|
||||||
SizeFormatter.sizeFormat(getUpStats(record, client.email) + getDownStats(record, client.email)) ]] </td>
|
SizeFormatter.sizeFormat(getUpStats(record, client.email) + getDownStats(record, client.email)) ]] </td>
|
||||||
<td width="120px" v-if="!client.enable">
|
<td width="90px" v-if="!client.enable">
|
||||||
<a-progress :stroke-color="themeSwitcher.isDarkTheme ? 'rgb(72 84 105)' : '#bcbcbc'" :show-info="false"
|
<a-progress :stroke-color="themeSwitcher.isDarkTheme ? 'rgb(72 84 105)' : '#bcbcbc'" :show-info="false"
|
||||||
:percent="statsProgress(record, client.email)" />
|
:percent="statsProgress(record, client.email)" />
|
||||||
</td>
|
</td>
|
||||||
<td width="120px" v-else-if="client.totalGB > 0">
|
<td width="90px" v-else-if="client.totalGB > 0">
|
||||||
<a-popover :overlay-class-name="themeSwitcher.currentTheme">
|
<a-popover :overlay-class-name="themeSwitcher.currentTheme">
|
||||||
<template slot="content" v-if="client.email">
|
<template slot="content" v-if="client.email">
|
||||||
<table cellpadding="2" width="100%">
|
<table cellpadding="2" width="100%">
|
||||||
|
|
@ -216,11 +218,11 @@
|
||||||
:percent="statsProgress(record, client.email)" />
|
:percent="statsProgress(record, client.email)" />
|
||||||
</a-popover>
|
</a-popover>
|
||||||
</td>
|
</td>
|
||||||
<td width="120px" v-else class="infinite-bar">
|
<td width="90px" v-else class="infinite-bar">
|
||||||
<a-progress :stroke-color="themeSwitcher.isDarkTheme ? '#2c1e32':'#F2EAF1'" :show-info="false"
|
<a-progress :stroke-color="themeSwitcher.isDarkTheme ? '#2c1e32':'#F2EAF1'" :show-info="false"
|
||||||
:percent="100"></a-progress>
|
:percent="100"></a-progress>
|
||||||
</td>
|
</td>
|
||||||
<td width="80px">
|
<td width="60px">
|
||||||
<template v-if="client.totalGB > 0">[[ client._totalGB + "GB" ]]</template>
|
<template v-if="client.totalGB > 0">[[ client._totalGB + "GB" ]]</template>
|
||||||
<span v-else class="tr-infinity-ch">∞</span>
|
<span v-else class="tr-infinity-ch">∞</span>
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -233,9 +235,9 @@
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<template v-if="client.expiryTime !=0 && client.reset >0">
|
<template v-if="client.expiryTime !=0 && client.reset >0">
|
||||||
<td width="80px" :style="{ margin: '0', textAlign: 'right', fontSize: '1em' }"> [[
|
<td width="65px" :style="{ margin: '0', textAlign: 'right', fontSize: '1em' }"> [[
|
||||||
IntlUtil.formatRelativeTime(client.expiryTime) ]] </td>
|
IntlUtil.formatRelativeTime(client.expiryTime) ]] </td>
|
||||||
<td width="120px" class="infinite-bar">
|
<td width="90px" class="infinite-bar">
|
||||||
<a-popover :overlay-class-name="themeSwitcher.currentTheme">
|
<a-popover :overlay-class-name="themeSwitcher.currentTheme">
|
||||||
<template slot="content">
|
<template slot="content">
|
||||||
<span v-if="client.expiryTime < 0">{{ i18n "pages.client.delayedStart" }}</span>
|
<span v-if="client.expiryTime < 0">{{ i18n "pages.client.delayedStart" }}</span>
|
||||||
|
|
@ -245,7 +247,7 @@
|
||||||
:percent="expireProgress(client.expiryTime, client.reset)" />
|
:percent="expireProgress(client.expiryTime, client.reset)" />
|
||||||
</a-popover>
|
</a-popover>
|
||||||
</td>
|
</td>
|
||||||
<td width="60px">[[ client.reset + "d" ]]</td>
|
<td width="50px">[[ client.reset + "d" ]]</td>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<td colspan="3" :style="{ textAlign: 'center' }">
|
<td colspan="3" :style="{ textAlign: 'center' }">
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,7 @@
|
||||||
<a-col :xs="24" :sm="14" :lg="14">
|
<a-col :xs="24" :sm="14" :lg="14">
|
||||||
<a-space direction="horizontal" size="small" class="outbounds-toolbar">
|
<a-space direction="horizontal" size="small" class="outbounds-toolbar">
|
||||||
<a-button type="primary" icon="plus" @click="addOutbound">
|
<a-button type="primary" icon="plus" @click="addOutbound">
|
||||||
<span v-if="!isMobile">{{ i18n
|
<span v-if="!isMobile">{{ i18n "pages.xray.outbound.addOutbound" }}</span>
|
||||||
"pages.xray.outbound.addOutbound" }}</span>
|
|
||||||
</a-button>
|
</a-button>
|
||||||
<a-button type="primary" icon="cloud" @click="showWarp()">WARP</a-button>
|
<a-button type="primary" icon="cloud" @click="showWarp()">WARP</a-button>
|
||||||
<a-button type="primary" icon="api" @click="showNord()">NordVPN</a-button>
|
<a-button type="primary" icon="api" @click="showNord()">NordVPN</a-button>
|
||||||
|
|
@ -25,9 +24,114 @@
|
||||||
</a-button-group>
|
</a-button-group>
|
||||||
</a-col>
|
</a-col>
|
||||||
</a-row>
|
</a-row>
|
||||||
<a-table :columns="outboundColumns" :row-key="r => r.key"
|
|
||||||
|
<!-- Mobile: card list -->
|
||||||
|
<template v-if="isMobile">
|
||||||
|
<div v-if="outboundData.length === 0" class="outbound-card-empty">—</div>
|
||||||
|
<div v-for="(outbound, index) in outboundData" :key="outbound.key" class="outbound-card">
|
||||||
|
<!-- card header: number + tag + protocol pills + action menu -->
|
||||||
|
<div class="outbound-card-header">
|
||||||
|
<div class="outbound-card-identity">
|
||||||
|
<div class="outbound-card-title">
|
||||||
|
<span class="outbound-card-num">[[ index + 1 ]]</span>
|
||||||
|
<a-tooltip :title="outbound.tag" :overlay-class-name="themeSwitcher.currentTheme">
|
||||||
|
<span class="outbound-tag">[[ outbound.tag ]]</span>
|
||||||
|
</a-tooltip>
|
||||||
|
</div>
|
||||||
|
<div class="outbound-protocol-cell">
|
||||||
|
<span class="outbound-pill" :class="outboundProtocolTone(outbound.protocol)">
|
||||||
|
[[ outbound.protocol ]]
|
||||||
|
</span>
|
||||||
|
<template v-if="[Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(outbound.protocol)">
|
||||||
|
<span class="outbound-pill" :class="outboundNetworkTone(outbound.streamSettings.network)">
|
||||||
|
[[ outbound.streamSettings.network ]]
|
||||||
|
</span>
|
||||||
|
<span class="outbound-pill" :class="outboundSecurityTone(outbound.streamSettings.security)"
|
||||||
|
v-if="isOutboundSecurityVisible(outbound.streamSettings.security)">
|
||||||
|
[[ outbound.streamSettings.security ]]
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a-dropdown :trigger="['click']">
|
||||||
|
<a-button shape="circle" size="small" class="outbound-action-btn"
|
||||||
|
@click="e => e.preventDefault()">
|
||||||
|
<a-icon type="more"></a-icon>
|
||||||
|
</a-button>
|
||||||
|
<a-menu slot="overlay" :theme="themeSwitcher.currentTheme">
|
||||||
|
<a-menu-item v-if="index > 0" @click="setFirstOutbound(index)">
|
||||||
|
<a-icon type="vertical-align-top"></a-icon>
|
||||||
|
<span>{{ i18n "pages.xray.rules.first" }}</span>
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item @click="editOutbound(index)">
|
||||||
|
<a-icon type="edit"></a-icon>
|
||||||
|
<span>{{ i18n "edit" }}</span>
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item @click="resetOutboundTraffic(index)">
|
||||||
|
<a-icon type="retweet"></a-icon>
|
||||||
|
<span>{{ i18n "pages.inbounds.resetTraffic" }}</span>
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item @click="deleteOutbound(index)">
|
||||||
|
<span :style="{ color: '#FF4D4F' }">
|
||||||
|
<a-icon type="delete"></a-icon>
|
||||||
|
<span>{{ i18n "delete" }}</span>
|
||||||
|
</span>
|
||||||
|
</a-menu-item>
|
||||||
|
</a-menu>
|
||||||
|
</a-dropdown>
|
||||||
|
</div>
|
||||||
|
<!-- address pills -->
|
||||||
|
<div class="outbound-address-list" v-if="outboundAddresses(outbound).length > 0">
|
||||||
|
<a-tooltip v-for="addr in outboundAddresses(outbound)" :key="addr"
|
||||||
|
:title="addr" :overlay-class-name="themeSwitcher.currentTheme">
|
||||||
|
<span class="outbound-address-pill">[[ addr ]]</span>
|
||||||
|
</a-tooltip>
|
||||||
|
</div>
|
||||||
|
<!-- card footer: traffic + test -->
|
||||||
|
<div class="outbound-card-footer">
|
||||||
|
<div class="outbound-traffic-cell">
|
||||||
|
<span class="outbound-traffic-up">
|
||||||
|
<a-icon type="arrow-up"></a-icon>
|
||||||
|
[[ SizeFormatter.sizeFormat(findOutboundUp(outbound)) ]]
|
||||||
|
</span>
|
||||||
|
<span class="outbound-traffic-sep" aria-hidden="true"></span>
|
||||||
|
<span class="outbound-traffic-down">
|
||||||
|
<a-icon type="arrow-down"></a-icon>
|
||||||
|
[[ SizeFormatter.sizeFormat(findOutboundDown(outbound)) ]]
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="outbound-card-test">
|
||||||
|
<div v-if="outboundTestResult(index)">
|
||||||
|
<span v-if="outboundTestResult(index).success"
|
||||||
|
class="outbound-result-pill outbound-result-ok">
|
||||||
|
<a-icon type="check-circle" theme="filled"></a-icon>
|
||||||
|
[[ outboundTestResult(index).delay ]] ms
|
||||||
|
</span>
|
||||||
|
<a-tooltip v-else :title="outboundTestResult(index).error"
|
||||||
|
:overlay-class-name="themeSwitcher.currentTheme">
|
||||||
|
<span class="outbound-result-pill outbound-result-fail">
|
||||||
|
<a-icon type="close-circle" theme="filled"></a-icon>
|
||||||
|
{{ i18n "pages.xray.outbound.testFailed" }}
|
||||||
|
</span>
|
||||||
|
</a-tooltip>
|
||||||
|
</div>
|
||||||
|
<a-icon type="loading" class="outbound-result-loading"
|
||||||
|
v-else-if="isOutboundTesting(index)"></a-icon>
|
||||||
|
<a-button type="primary" shape="circle" size="small" icon="thunderbolt"
|
||||||
|
class="outbound-test-btn"
|
||||||
|
:loading="isOutboundTesting(index)"
|
||||||
|
@click="testOutbound(index)"
|
||||||
|
:disabled="isOutboundUntestable(outbound) || isOutboundTesting(index)">
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Desktop: table -->
|
||||||
|
<a-table v-if="!isMobile" :columns="outboundColumns" :row-key="r => r.key"
|
||||||
:data-source="outboundData"
|
:data-source="outboundData"
|
||||||
:scroll="isMobile ? { x: 720 } : {}"
|
:scroll="{}"
|
||||||
:pagination="false"
|
:pagination="false"
|
||||||
:indent-size="0"
|
:indent-size="0"
|
||||||
class="outbounds-table"
|
class="outbounds-table"
|
||||||
|
|
|
||||||
|
|
@ -169,8 +169,8 @@
|
||||||
// network + security pills sit underneath it. Width chosen so the three
|
// network + security pills sit underneath it. Width chosen so the three
|
||||||
// longest tonal pills (e.g. vless + httpupgrade + reality) fit on a
|
// longest tonal pills (e.g. vless + httpupgrade + reality) fit on a
|
||||||
// single line without wrapping.
|
// single line without wrapping.
|
||||||
{ title: '{{ i18n "pages.xray.outbound.tag"}}', align: 'left', width: 280, scopedSlots: { customRender: 'identity' } },
|
{ title: '{{ i18n "pages.xray.outbound.tag"}}', align: 'left', width: 190, scopedSlots: { customRender: 'identity' } },
|
||||||
{ title: '{{ i18n "pages.xray.outbound.address"}}', align: 'left', scopedSlots: { customRender: 'address' } },
|
{ title: '{{ i18n "pages.xray.outbound.address"}}', align: 'left', width: 230, scopedSlots: { customRender: 'address' } },
|
||||||
{ title: '{{ i18n "pages.inbounds.traffic" }}', align: 'left', width: 190, scopedSlots: { customRender: 'traffic' } },
|
{ title: '{{ i18n "pages.inbounds.traffic" }}', align: 'left', width: 190, scopedSlots: { customRender: 'traffic' } },
|
||||||
{ title: '{{ i18n "pages.xray.outbound.testResult" }}', align: 'left', width: 130, scopedSlots: { customRender: 'testResult' } },
|
{ title: '{{ i18n "pages.xray.outbound.testResult" }}', align: 'left', width: 130, scopedSlots: { customRender: 'testResult' } },
|
||||||
{ title: '{{ i18n "pages.xray.outbound.test" }}', align: 'center', width: 70, scopedSlots: { customRender: 'test' } },
|
{ title: '{{ i18n "pages.xray.outbound.test" }}', align: 'center', width: 70, scopedSlots: { customRender: 'test' } },
|
||||||
|
|
@ -1992,7 +1992,7 @@
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
max-width: 240px;
|
max-width: 190px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|
@ -2273,5 +2273,89 @@
|
||||||
}
|
}
|
||||||
.light .xray-page .routing-target-icon { color: rgba(0, 0, 0, 0.45); }
|
.light .xray-page .routing-target-icon { color: rgba(0, 0, 0, 0.45); }
|
||||||
|
|
||||||
|
/* ───────── Mobile outbound cards ───────── */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.xray-page .outbounds-toolbar-right { text-align: left; }
|
||||||
|
|
||||||
|
.xray-page .outbound-card-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px;
|
||||||
|
color: rgba(255, 255, 255, 0.35);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.light .xray-page .outbound-card-empty { color: rgba(0, 0, 0, 0.35); }
|
||||||
|
|
||||||
|
.xray-page .outbound-card {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
.light .xray-page .outbound-card {
|
||||||
|
background: rgba(0, 0, 0, 0.025);
|
||||||
|
border-color: rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.xray-page .outbound-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xray-page .outbound-card-identity {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xray-page .outbound-card-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xray-page .outbound-card-num {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(255, 255, 255, 0.35);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.light .xray-page .outbound-card-num { color: rgba(0, 0, 0, 0.35); }
|
||||||
|
|
||||||
|
.xray-page .outbound-card .outbound-tag { font-size: 14px; }
|
||||||
|
|
||||||
|
.xray-page .outbound-card .outbound-protocol-cell { flex-wrap: wrap; }
|
||||||
|
|
||||||
|
.xray-page .outbound-card-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xray-page .outbound-card .outbound-traffic-cell {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xray-page .outbound-card-test {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
{{ template "page/body_end" .}}
|
{{ template "page/body_end" .}}
|
||||||
Loading…
Reference in a new issue