mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-06 21:24:10 +00:00
style(ui): redesign outbounds table for visual consistency
This commit is contained in:
parent
7792715dc4
commit
6166a406b3
2 changed files with 462 additions and 87 deletions
|
|
@ -1,8 +1,8 @@
|
||||||
{{define "settings/xray/outbounds"}}
|
{{define "settings/xray/outbounds"}}
|
||||||
<a-space direction="vertical" size="middle">
|
<a-space direction="vertical" size="middle" class="outbounds-modern">
|
||||||
<a-row>
|
<a-row :gutter="[12, 12]" align="middle" justify="space-between">
|
||||||
<a-col :xs="12" :sm="12" :lg="12">
|
<a-col :xs="24" :sm="14" :lg="14">
|
||||||
<a-space direction="horizontal" size="small">
|
<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>
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
@click="showNord()">NordVPN</a-button>
|
@click="showNord()">NordVPN</a-button>
|
||||||
</a-space>
|
</a-space>
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col :xs="12" :sm="12" :lg="12" :style="{ textAlign: 'right' }">
|
<a-col :xs="24" :sm="10" :lg="10" class="outbounds-toolbar-right">
|
||||||
<a-button-group>
|
<a-button-group>
|
||||||
<a-button icon="sync" @click="refreshOutboundTraffic()"
|
<a-button icon="sync" @click="refreshOutboundTraffic()"
|
||||||
:loading="refreshing"></a-button>
|
:loading="refreshing"></a-button>
|
||||||
|
|
@ -24,106 +24,141 @@
|
||||||
ok-text='{{ i18n "reset"}}'
|
ok-text='{{ i18n "reset"}}'
|
||||||
cancel-text='{{ i18n "cancel"}}'>
|
cancel-text='{{ i18n "cancel"}}'>
|
||||||
<a-icon slot="icon" type="question-circle-o"
|
<a-icon slot="icon" type="question-circle-o"
|
||||||
:style="{ color: themeSwitcher.isDarkTheme ? '#008771' : '#008771' }"></a-icon>
|
:style="{ color: '#008771' }"></a-icon>
|
||||||
<a-button icon="retweet"></a-button>
|
<a-button icon="retweet"></a-button>
|
||||||
</a-popconfirm>
|
</a-popconfirm>
|
||||||
</a-button-group>
|
</a-button-group>
|
||||||
</a-col>
|
</a-col>
|
||||||
</a-row>
|
</a-row>
|
||||||
<a-table :columns="outboundColumns" bordered :row-key="r => r.key"
|
<a-table :columns="outboundColumns" :row-key="r => r.key"
|
||||||
:data-source="outboundData"
|
:data-source="outboundData"
|
||||||
:scroll="isMobile ? {} : { x: 800 }" :pagination="false"
|
:scroll="isMobile ? { x: 720 } : {}"
|
||||||
|
:pagination="false"
|
||||||
:indent-size="0"
|
:indent-size="0"
|
||||||
|
class="outbounds-table"
|
||||||
:locale='{ filterConfirm: `{{ i18n "confirm" }}`, filterReset: `{{ i18n "reset" }}` }'>
|
:locale='{ filterConfirm: `{{ i18n "confirm" }}`, filterReset: `{{ i18n "reset" }}` }'>
|
||||||
<template slot="action" slot-scope="text, outbound, index">
|
<template slot="action" slot-scope="text, outbound, index">
|
||||||
<span>[[ index+1 ]]</span>
|
<div class="outbound-action-cell">
|
||||||
<a-dropdown :trigger="['click']">
|
<span class="outbound-index">[[ index+1 ]]</span>
|
||||||
<a-icon @click="e => e.preventDefault()" type="more"
|
<a-dropdown :trigger="['click']">
|
||||||
:style="{ fontSize: '16px', textDecoration: 'bold' }"></a-icon>
|
<a-button shape="circle" size="small" class="outbound-action-btn"
|
||||||
<a-menu slot="overlay" :theme="themeSwitcher.currentTheme">
|
@click="e => e.preventDefault()">
|
||||||
<a-menu-item v-if="index>0"
|
<a-icon type="more"></a-icon>
|
||||||
@click="setFirstOutbound(index)">
|
</a-button>
|
||||||
<a-icon type="vertical-align-top"></a-icon>
|
<a-menu slot="overlay" :theme="themeSwitcher.currentTheme">
|
||||||
<span>{{ i18n "pages.xray.rules.first"}}</span>
|
<a-menu-item v-if="index>0"
|
||||||
</a-menu-item>
|
@click="setFirstOutbound(index)">
|
||||||
<a-menu-item @click="editOutbound(index)">
|
<a-icon type="vertical-align-top"></a-icon>
|
||||||
<a-icon type="edit"></a-icon>
|
<span>{{ i18n "pages.xray.rules.first"}}</span>
|
||||||
<span>{{ i18n "edit" }}</span>
|
</a-menu-item>
|
||||||
</a-menu-item>
|
<a-menu-item @click="editOutbound(index)">
|
||||||
<a-menu-item @click="resetOutboundTraffic(index)">
|
<a-icon type="edit"></a-icon>
|
||||||
<span>
|
<span>{{ i18n "edit" }}</span>
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item @click="resetOutboundTraffic(index)">
|
||||||
<a-icon type="retweet"></a-icon>
|
<a-icon type="retweet"></a-icon>
|
||||||
<span>{{ i18n "pages.inbounds.resetTraffic"}}</span>
|
<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>
|
||||||
|
</template>
|
||||||
|
<template slot="identity" slot-scope="text, outbound">
|
||||||
|
<div class="outbound-identity-cell">
|
||||||
|
<a-tooltip :title="outbound.tag" :overlay-class-name="themeSwitcher.currentTheme">
|
||||||
|
<span class="outbound-tag">[[ outbound.tag ]]</span>
|
||||||
|
</a-tooltip>
|
||||||
|
<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>
|
||||||
</a-menu-item>
|
<span class="outbound-pill"
|
||||||
<a-menu-item @click="deleteOutbound(index)">
|
:class="outboundSecurityTone(outbound.streamSettings.security)"
|
||||||
<span :style="{ color: '#FF4D4F' }">
|
v-if="isOutboundSecurityVisible(outbound.streamSettings.security)">
|
||||||
<a-icon type="delete"></a-icon>
|
[[ outbound.streamSettings.security ]]
|
||||||
<span>{{ i18n "delete"}}</span>
|
|
||||||
</span>
|
</span>
|
||||||
</a-menu-item>
|
</template>
|
||||||
</a-menu>
|
</div>
|
||||||
</a-dropdown>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template slot="address" slot-scope="text, outbound, index">
|
<template slot="address" slot-scope="text, outbound">
|
||||||
<p :style="{ margin: '0 5px' }"
|
<div class="outbound-address-list">
|
||||||
v-for="addr in findOutboundAddress(outbound)">[[ addr ]]</p>
|
<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>
|
||||||
|
<span class="outbound-address-empty"
|
||||||
|
v-if="outboundAddresses(outbound).length === 0">—</span>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template slot="protocol" slot-scope="text, outbound, index">
|
<template slot="traffic" slot-scope="text, outbound">
|
||||||
<a-tag :style="{ margin: '0' }" color="purple">[[ outbound.protocol
|
<div class="outbound-traffic-cell">
|
||||||
]]</a-tag>
|
<span class="outbound-traffic-up" :title='`{{ i18n "pages.inbounds.upload" }}`'>
|
||||||
<template
|
<a-icon type="arrow-up"></a-icon>
|
||||||
v-if="[Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(outbound.protocol)">
|
[[ SizeFormatter.sizeFormat(findOutboundUp(outbound)) ]]
|
||||||
<a-tag :style="{ margin: '0' }" color="blue">[[
|
</span>
|
||||||
outbound.streamSettings.network ]]</a-tag>
|
<span class="outbound-traffic-sep" aria-hidden="true"></span>
|
||||||
<a-tag :style="{ margin: '0' }"
|
<span class="outbound-traffic-down" :title='`{{ i18n "pages.inbounds.download" }}`'>
|
||||||
v-if="outbound.streamSettings.security=='tls'"
|
<a-icon type="arrow-down"></a-icon>
|
||||||
color="green">tls</a-tag>
|
[[ SizeFormatter.sizeFormat(findOutboundDown(outbound)) ]]
|
||||||
<a-tag :style="{ margin: '0' }"
|
</span>
|
||||||
v-if="outbound.streamSettings.security=='reality'"
|
</div>
|
||||||
color="green">reality</a-tag>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
<template slot="traffic" slot-scope="text, outbound, index">
|
|
||||||
<a-tag color="green">[[ findOutboundTraffic(outbound) ]]</a-tag>
|
|
||||||
</template>
|
</template>
|
||||||
<template slot="test" slot-scope="text, outbound, index">
|
<template slot="test" slot-scope="text, outbound, index">
|
||||||
<a-tooltip>
|
<a-tooltip>
|
||||||
<template slot="title">{{ i18n "pages.xray.outbound.test"
|
<template slot="title">{{ i18n "pages.xray.outbound.test" }}</template>
|
||||||
}}</template>
|
|
||||||
<a-button
|
<a-button
|
||||||
type="primary"
|
type="primary"
|
||||||
shape="circle"
|
shape="circle"
|
||||||
icon="thunderbolt"
|
icon="thunderbolt"
|
||||||
:loading="outboundTestStates[index] && outboundTestStates[index].testing"
|
class="outbound-test-btn"
|
||||||
|
:loading="isOutboundTesting(index)"
|
||||||
@click="testOutbound(index)"
|
@click="testOutbound(index)"
|
||||||
:disabled="(outbound.protocol === 'blackhole' || outbound.tag === 'blocked') || (outboundTestStates[index] && outboundTestStates[index].testing)">
|
:disabled="isOutboundUntestable(outbound) || isOutboundTesting(index)">
|
||||||
</a-button>
|
</a-button>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
</template>
|
</template>
|
||||||
<template slot="testResult" slot-scope="text, outbound, index">
|
<template slot="testResult" slot-scope="text, outbound, index">
|
||||||
<div
|
<div class="outbound-result-cell" v-if="outboundTestResult(index)">
|
||||||
v-if="outboundTestStates[index] && outboundTestStates[index].result">
|
<span v-if="outboundTestResult(index).success"
|
||||||
<a-tag v-if="outboundTestStates[index].result.success"
|
class="outbound-result-pill outbound-result-ok">
|
||||||
color="green">
|
<a-icon type="check-circle" theme="filled"></a-icon>
|
||||||
[[ outboundTestStates[index].result.delay ]]ms
|
[[ outboundTestResult(index).delay ]] ms
|
||||||
<span v-if="outboundTestStates[index].result.statusCode">
|
<span class="outbound-result-status"
|
||||||
([[ outboundTestStates[index].result.statusCode
|
v-if="outboundTestResult(index).statusCode">
|
||||||
]])</span>
|
· [[ outboundTestResult(index).statusCode ]]
|
||||||
</a-tag>
|
</span>
|
||||||
|
</span>
|
||||||
<a-tooltip v-else
|
<a-tooltip v-else
|
||||||
:title="outboundTestStates[index].result.error">
|
:title="outboundTestResult(index).error"
|
||||||
<a-tag color="red">
|
:overlay-class-name="themeSwitcher.currentTheme">
|
||||||
Failed
|
<span class="outbound-result-pill outbound-result-fail">
|
||||||
</a-tag>
|
<a-icon type="close-circle" theme="filled"></a-icon>
|
||||||
|
{{ i18n "pages.xray.outbound.testFailed" }}
|
||||||
|
</span>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span class="outbound-result-loading" v-else-if="isOutboundTesting(index)">
|
||||||
v-else-if="outboundTestStates[index] && outboundTestStates[index].testing">
|
<a-icon type="loading"></a-icon>
|
||||||
<a-icon type="loading" />
|
|
||||||
</span>
|
</span>
|
||||||
<span v-else>-</span>
|
<span class="outbound-result-idle" v-else>—</span>
|
||||||
</template>
|
</template>
|
||||||
</a-table>
|
</a-table>
|
||||||
</a-space>
|
</a-space>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
|
||||||
|
|
@ -202,13 +202,16 @@
|
||||||
];
|
];
|
||||||
|
|
||||||
const outboundColumns = [
|
const outboundColumns = [
|
||||||
{ title: "#", align: 'center', width: 60, scopedSlots: { customRender: 'action' } },
|
{ title: '#', align: 'center', width: 70, scopedSlots: { customRender: 'action' } },
|
||||||
{ title: '{{ i18n "pages.xray.outbound.tag"}}', dataIndex: 'tag', align: 'center', width: 50 },
|
// Combined "Tag / Protocol" — saves a column. Tag stays on top, protocol +
|
||||||
{ title: '{{ i18n "protocol"}}', align: 'center', width: 50, scopedSlots: { customRender: 'protocol' } },
|
// network + security pills sit underneath it. Width chosen so the three
|
||||||
{ title: '{{ i18n "pages.xray.outbound.address"}}', align: 'center', width: 50, scopedSlots: { customRender: 'address' } },
|
// longest tonal pills (e.g. vless + httpupgrade + reality) fit on a
|
||||||
{ title: '{{ i18n "pages.inbounds.traffic" }}', align: 'center', width: 180, scopedSlots: { customRender: 'traffic' } },
|
// single line without wrapping.
|
||||||
{ title: '{{ i18n "pages.xray.outbound.testResult" }}', align: 'center', width: 120, scopedSlots: { customRender: 'testResult' } },
|
{ title: '{{ i18n "pages.xray.outbound.tag"}}', align: 'left', width: 280, scopedSlots: { customRender: 'identity' } },
|
||||||
{ title: '{{ i18n "pages.xray.outbound.test" }}', align: 'center', width: 60, scopedSlots: { customRender: 'test' } },
|
{ title: '{{ i18n "pages.xray.outbound.address"}}', align: 'left', scopedSlots: { customRender: 'address' } },
|
||||||
|
{ 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.test" }}', align: 'center', width: 70, scopedSlots: { customRender: 'test' } },
|
||||||
];
|
];
|
||||||
|
|
||||||
const reverseColumns = [
|
const reverseColumns = [
|
||||||
|
|
@ -556,13 +559,79 @@
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
findOutboundTraffic(o) {
|
// outboundTrafficFor returns {up, down} for an outbound by tag,
|
||||||
for (const otraffic of this.outboundsTraffic) {
|
// defaulting to zeros when no traffic row has been reported yet.
|
||||||
if (otraffic.tag == o.tag) {
|
// Templates use the up/down accessors below — keeping the lookup in
|
||||||
return `↑ ${SizeFormatter.sizeFormat(otraffic.up)} / ${SizeFormatter.sizeFormat(otraffic.down)} ↓`
|
// one place avoids drift if the data shape changes.
|
||||||
}
|
outboundTrafficFor(o) {
|
||||||
|
const t = this.outboundsTraffic.find(t => t.tag == o.tag);
|
||||||
|
return { up: t ? t.up : 0, down: t ? t.down : 0 };
|
||||||
|
},
|
||||||
|
findOutboundUp(o) { return this.outboundTrafficFor(o).up; },
|
||||||
|
findOutboundDown(o) { return this.outboundTrafficFor(o).down; },
|
||||||
|
// Visual class for the protocol pill — keeps the colour palette consistent
|
||||||
|
// across rows and matches the tone used elsewhere in the panel.
|
||||||
|
outboundProtocolTone(protocol) {
|
||||||
|
switch ((protocol || '').toLowerCase()) {
|
||||||
|
case 'freedom': return 'tone-emerald';
|
||||||
|
case 'blackhole': return 'tone-violet';
|
||||||
|
case 'wireguard': return 'tone-cyan';
|
||||||
|
case 'vmess': return 'tone-blue';
|
||||||
|
case 'vless': return 'tone-sky';
|
||||||
|
case 'trojan': return 'tone-amber';
|
||||||
|
case 'shadowsocks': return 'tone-pink';
|
||||||
|
case 'http':
|
||||||
|
case 'socks': return 'tone-slate';
|
||||||
|
case 'dns': return 'tone-indigo';
|
||||||
|
default: return 'tone-slate';
|
||||||
}
|
}
|
||||||
return `${SizeFormatter.sizeFormat(0)} / ${SizeFormatter.sizeFormat(0)}`
|
},
|
||||||
|
// Distinct palette for the transport (network) pill so it never collides
|
||||||
|
// with the protocol tone in the same row.
|
||||||
|
outboundNetworkTone(network) {
|
||||||
|
switch ((network || '').toLowerCase()) {
|
||||||
|
case 'tcp': return 'tone-slate';
|
||||||
|
case 'ws': return 'tone-cyan';
|
||||||
|
case 'xhttp': return 'tone-amber';
|
||||||
|
case 'h2':
|
||||||
|
case 'http': return 'tone-indigo';
|
||||||
|
case 'grpc': return 'tone-pink';
|
||||||
|
case 'quic': return 'tone-violet';
|
||||||
|
case 'kcp': return 'tone-emerald';
|
||||||
|
case 'httpupgrade': return 'tone-rose';
|
||||||
|
default: return 'tone-slate';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// TLS / reality / none tone — visually tied to the security model used.
|
||||||
|
outboundSecurityTone(security) {
|
||||||
|
switch ((security || '').toLowerCase()) {
|
||||||
|
case 'tls': return 'tone-emerald';
|
||||||
|
case 'reality': return 'tone-mint';
|
||||||
|
default: return 'tone-slate';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Whether the security label is one we render as a pill in the table.
|
||||||
|
isOutboundSecurityVisible(security) {
|
||||||
|
return security === 'tls' || security === 'reality';
|
||||||
|
},
|
||||||
|
// Null-safe accessor for the address list — collapses null/undefined
|
||||||
|
// returns from findOutboundAddress() into an empty array so the template
|
||||||
|
// can rely on .length and v-for without extra guards.
|
||||||
|
outboundAddresses(o) {
|
||||||
|
return this.findOutboundAddress(o) || [];
|
||||||
|
},
|
||||||
|
// Test-state accessors — sparse arrays + per-row state make raw checks
|
||||||
|
// verbose; these helpers keep the template readable and consistent.
|
||||||
|
isOutboundTesting(index) {
|
||||||
|
const s = this.outboundTestStates[index];
|
||||||
|
return !!(s && s.testing);
|
||||||
|
},
|
||||||
|
outboundTestResult(index) {
|
||||||
|
const s = this.outboundTestStates[index];
|
||||||
|
return s ? s.result : null;
|
||||||
|
},
|
||||||
|
isOutboundUntestable(outbound) {
|
||||||
|
return outbound.protocol === 'blackhole' || outbound.tag === 'blocked';
|
||||||
},
|
},
|
||||||
findOutboundAddress(o) {
|
findOutboundAddress(o) {
|
||||||
serverObj = null;
|
serverObj = null;
|
||||||
|
|
@ -1626,4 +1695,275 @@
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
<style>
|
||||||
|
/* ───────── Modern outbounds table ─────────
|
||||||
|
Visual goals:
|
||||||
|
• flat surface, no inner cell borders, only subtle row dividers
|
||||||
|
• rounded pill badges for protocol / tag / addresses
|
||||||
|
• dual-arrow traffic widget that aligns across rows
|
||||||
|
• consistent hover/loading/result states
|
||||||
|
Scoped under .xray-page .outbounds-modern so it doesn't bleed into other tables. */
|
||||||
|
|
||||||
|
.xray-page .outbounds-modern { width: 100%; }
|
||||||
|
.xray-page .outbounds-toolbar-right { text-align: right; }
|
||||||
|
|
||||||
|
/* Table chrome */
|
||||||
|
.xray-page .outbounds-table .ant-table {
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.xray-page .outbounds-table .ant-table-thead > tr > th {
|
||||||
|
background: rgba(255, 255, 255, 0.025);
|
||||||
|
color: rgba(255, 255, 255, 0.55);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
white-space: nowrap;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
padding: 14px 18px;
|
||||||
|
}
|
||||||
|
.light .xray-page .outbounds-table .ant-table-thead > tr > th {
|
||||||
|
background: rgba(0, 0, 0, 0.02);
|
||||||
|
color: rgba(0, 0, 0, 0.55);
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
.xray-page .outbounds-table .ant-table-tbody > tr > td {
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||||||
|
padding: 16px 18px;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
/* Force every cell to honour its column width — long content (especially
|
||||||
|
long tags) must clip via cell-level ellipsis instead of pushing the row
|
||||||
|
taller. */
|
||||||
|
.xray-page .outbounds-table .ant-table-tbody > tr > td,
|
||||||
|
.xray-page .outbounds-table .ant-table-thead > tr > th {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.light .xray-page .outbounds-table .ant-table-tbody > tr > td {
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
.xray-page .outbounds-table .ant-table-tbody > tr:last-child > td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.xray-page .outbounds-table .ant-table-tbody > tr:hover > td {
|
||||||
|
background: rgba(255, 255, 255, 0.035) !important;
|
||||||
|
}
|
||||||
|
.light .xray-page .outbounds-table .ant-table-tbody > tr:hover > td {
|
||||||
|
background: rgba(0, 0, 0, 0.025) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Index + actions column */
|
||||||
|
.xray-page .outbound-action-cell {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.xray-page .outbound-index {
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
min-width: 18px;
|
||||||
|
text-align: end;
|
||||||
|
}
|
||||||
|
.light .xray-page .outbound-index { color: rgba(0, 0, 0, 0.7); }
|
||||||
|
.xray-page .outbound-action-btn {
|
||||||
|
border: none;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: rgba(255, 255, 255, 0.75);
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
.xray-page .outbound-action-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.light .xray-page .outbound-action-btn {
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
color: rgba(0, 0, 0, 0.75);
|
||||||
|
}
|
||||||
|
.light .xray-page .outbound-action-btn:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Identity cell — tag on top, protocol/network/security pills underneath.
|
||||||
|
Combining the two columns lets the table fit common viewports without
|
||||||
|
a horizontal scrollbar. */
|
||||||
|
.xray-page .outbound-identity-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
/* Tag — monospace, single line, ellipsis on overflow.
|
||||||
|
A long tag (e.g. "vless_jphttp-ksjpnggl") would otherwise wrap and inflate
|
||||||
|
the row's height; the inline tooltip surfaces the full value on hover. */
|
||||||
|
.xray-page .outbound-tag {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, "JetBrains Mono", Menlo, monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(255, 255, 255, 0.92);
|
||||||
|
font-weight: 500;
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.light .xray-page .outbound-tag { color: rgba(0, 0, 0, 0.85); }
|
||||||
|
|
||||||
|
/* Address pills (monospace, monoline) */
|
||||||
|
.xray-page .outbound-address-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.xray-page .outbound-address-pill {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, "JetBrains Mono", Menlo, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.045);
|
||||||
|
color: rgba(255, 255, 255, 0.78);
|
||||||
|
line-height: 1.5;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 240px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.light .xray-page .outbound-address-pill {
|
||||||
|
background: rgba(0, 0, 0, 0.035);
|
||||||
|
color: rgba(0, 0, 0, 0.78);
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
.xray-page .outbound-address-empty {
|
||||||
|
color: rgba(255, 255, 255, 0.3);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Protocol/network/tls pills — shared "outbound-pill" with tonal modifiers.
|
||||||
|
The pill row stays on a single line; if the column is somehow too narrow
|
||||||
|
for all pills it overflows out of view (rare — column width is sized to
|
||||||
|
fit the worst case) but never pushes the row taller. */
|
||||||
|
.xray-page .outbound-protocol-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.xray-page .outbound-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 22px;
|
||||||
|
padding: 0 9px;
|
||||||
|
border-radius: 11px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.xray-page .outbound-pill.tone-emerald { background: rgba(0, 191, 165, 0.14); color: #4dd4be; border-color: rgba(0, 191, 165, 0.28); }
|
||||||
|
.xray-page .outbound-pill.tone-mint { background: rgba(72, 222, 128, 0.14); color: #7ee2a4; border-color: rgba(72, 222, 128, 0.30); }
|
||||||
|
.xray-page .outbound-pill.tone-violet { background: rgba(155, 89, 219, 0.16); color: #b489e8; border-color: rgba(155, 89, 219, 0.32); }
|
||||||
|
.xray-page .outbound-pill.tone-cyan { background: rgba(34, 184, 207, 0.16); color: #6ed3e3; border-color: rgba(34, 184, 207, 0.32); }
|
||||||
|
.xray-page .outbound-pill.tone-sky { background: rgba(56, 189, 248, 0.16); color: #7dd3fc; border-color: rgba(56, 189, 248, 0.32); }
|
||||||
|
.xray-page .outbound-pill.tone-blue { background: rgba(56, 116, 230, 0.16); color: #82a7ee; border-color: rgba(56, 116, 230, 0.32); }
|
||||||
|
.xray-page .outbound-pill.tone-amber { background: rgba(231, 154, 47, 0.16); color: #f0b56a; border-color: rgba(231, 154, 47, 0.32); }
|
||||||
|
.xray-page .outbound-pill.tone-pink { background: rgba(225, 79, 153, 0.16); color: #ec85b6; border-color: rgba(225, 79, 153, 0.32); }
|
||||||
|
.xray-page .outbound-pill.tone-rose { background: rgba(244, 63, 94, 0.14); color: #fb7185; border-color: rgba(244, 63, 94, 0.30); }
|
||||||
|
.xray-page .outbound-pill.tone-slate { background: rgba(160, 174, 192, 0.14); color: #b8c2d0; border-color: rgba(160, 174, 192, 0.26); }
|
||||||
|
.xray-page .outbound-pill.tone-indigo { background: rgba(99, 102, 241, 0.16); color: #9ea0ee; border-color: rgba(99, 102, 241, 0.32); }
|
||||||
|
|
||||||
|
/* Traffic — dual arrow widget, fixed columns so all rows align */
|
||||||
|
.xray-page .outbound-traffic-cell {
|
||||||
|
display: inline-grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 5px 12px;
|
||||||
|
border-radius: 100px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-size: 13px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.light .xray-page .outbound-traffic-cell {
|
||||||
|
background: rgba(0, 0, 0, 0.035);
|
||||||
|
}
|
||||||
|
.xray-page .outbound-traffic-up,
|
||||||
|
.xray-page .outbound-traffic-down {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.xray-page .outbound-traffic-up { justify-content: flex-end; color: #4dd4be; }
|
||||||
|
.xray-page .outbound-traffic-down { justify-content: flex-start; color: #82a7ee; }
|
||||||
|
.xray-page .outbound-traffic-up .anticon,
|
||||||
|
.xray-page .outbound-traffic-down .anticon { font-size: 11px; }
|
||||||
|
.xray-page .outbound-traffic-sep {
|
||||||
|
width: 1px;
|
||||||
|
height: 14px;
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: 1px;
|
||||||
|
}
|
||||||
|
.light .xray-page .outbound-traffic-sep { background: rgba(0, 0, 0, 0.12); }
|
||||||
|
|
||||||
|
/* Test result pills */
|
||||||
|
.xray-page .outbound-result-cell { display: inline-flex; }
|
||||||
|
.xray-page .outbound-result-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 100px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.xray-page .outbound-result-pill .anticon { font-size: 12px; }
|
||||||
|
.xray-page .outbound-result-ok {
|
||||||
|
background: rgba(0, 191, 165, 0.14);
|
||||||
|
color: #4dd4be;
|
||||||
|
border-color: rgba(0, 191, 165, 0.28);
|
||||||
|
}
|
||||||
|
.xray-page .outbound-result-fail {
|
||||||
|
background: rgba(255, 77, 79, 0.14);
|
||||||
|
color: #ff7a7c;
|
||||||
|
border-color: rgba(255, 77, 79, 0.32);
|
||||||
|
}
|
||||||
|
.xray-page .outbound-result-status { opacity: 0.75; }
|
||||||
|
.xray-page .outbound-result-loading,
|
||||||
|
.xray-page .outbound-result-idle {
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.light .xray-page .outbound-result-loading,
|
||||||
|
.light .xray-page .outbound-result-idle { color: rgba(0, 0, 0, 0.4); }
|
||||||
|
|
||||||
|
/* Test button — sleek circular with subtle glow */
|
||||||
|
.xray-page .outbound-test-btn {
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 191, 165, 0.18);
|
||||||
|
transition: transform 0.12s ease, box-shadow 0.18s ease;
|
||||||
|
}
|
||||||
|
.xray-page .outbound-test-btn:hover:not([disabled]) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 14px rgba(0, 191, 165, 0.32);
|
||||||
|
}
|
||||||
|
.xray-page .outbound-test-btn[disabled] {
|
||||||
|
box-shadow: none;
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
{{ template "page/body_end" .}}
|
{{ template "page/body_end" .}}
|
||||||
Loading…
Reference in a new issue