feat(frontend): navy dark theme + rounded inbound/client corners

Dark theme picks up a refined navy palette (page #0a1426, cards
#142340, sider #0d1d33) so the sidebar blends with the rest of the
surface; ultra-dark stays neutral black. Resolves the previous mismatch
where AD-Vue 4 hardcoded #001529 / #002140 for the sider, trigger and
dark Menu items via Layout.colorBgHeader / colorBgTrigger and Menu's
colorItemBg — overrides go through the component-token map now.

Round the inbound table's outer corners (header start/end + last row
end) and wrap the client expand-row grid in a 1px / 8px-radius border
so the list reads as a contained block instead of a flush rectangle.

Linter-driven whitespace cleanup across inbounds/*.vue rolled into the
same commit since it can't be split out cleanly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
MHSanaei 2026-05-08 21:05:14 +02:00
parent cedc46a14d
commit 90792e0f43
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
10 changed files with 587 additions and 638 deletions

View file

@ -27,8 +27,15 @@ export const currentTheme = computed(() => (theme.isDark ? 'dark' : 'light'));
// AD-Vue 4 theme config consumed by every page's <a-config-provider>.
// Three modes — light / dark / ultra-dark — all share AD-Vue's vanilla
// blue primary. Ultra-dark layers deeper background tokens on top of
// darkAlgorithm so layouts/cards/popups all darken together.
// blue primary. Dark uses a navy palette across page/cards/modals so
// the sidebar blends with the rest of the surface; ultra-dark stays
// neutral black on top of darkAlgorithm.
const DARK_TOKENS = {
colorBgBase: '#0a1426',
colorBgLayout: '#0a1426',
colorBgContainer: '#142340',
colorBgElevated: '#1a2c4d',
};
const ULTRA_DARK_TOKENS = {
colorBgBase: '#000',
colorBgLayout: '#000',
@ -36,13 +43,45 @@ const ULTRA_DARK_TOKENS = {
colorBgElevated: '#141414',
};
// AD-Vue 4 hardcodes navy `#001529` / `#002140` as the Layout sider
// + trigger backgrounds and `#001529` / `#000c17` as the dark Menu item
// backgrounds (see node_modules/ant-design-vue/es/{layout,menu}/style/
// index.js). Override at the component-token level so the sider blends
// with darkAlgorithm's neutral surfaces.
// Dark theme uses a refined navy for the sidebar — distinct from the
// neutral ultra-dark and warmer than AD-Vue's stock #001529.
const DARK_LAYOUT_TOKENS = {
colorBgHeader: '#0d1d33',
colorBgTrigger: '#15294a',
colorBgBody: '#000',
};
const ULTRA_DARK_LAYOUT_TOKENS = {
colorBgHeader: '#0a0a0a',
colorBgTrigger: '#141414',
colorBgBody: '#000',
};
const DARK_MENU_TOKENS = {
colorItemBg: '#0d1d33',
colorSubItemBg: '#08142a',
menuSubMenuBg: '#0d1d33',
};
const ULTRA_DARK_MENU_TOKENS = {
colorItemBg: '#0a0a0a',
colorSubItemBg: '#000',
menuSubMenuBg: '#0a0a0a',
};
export const antdThemeConfig = computed(() => {
if (!theme.isDark) {
return { algorithm: antdTheme.defaultAlgorithm };
}
return {
algorithm: antdTheme.darkAlgorithm,
token: theme.isUltra ? ULTRA_DARK_TOKENS : undefined,
token: theme.isUltra ? ULTRA_DARK_TOKENS : DARK_TOKENS,
components: {
Layout: theme.isUltra ? ULTRA_DARK_LAYOUT_TOKENS : DARK_LAYOUT_TOKENS,
Menu: theme.isUltra ? ULTRA_DARK_MENU_TOKENS : DARK_MENU_TOKENS,
},
};
});

View file

@ -172,22 +172,9 @@ async function submit() {
</script>
<template>
<a-modal
:open="open"
:title="t('pages.client.bulk')"
:ok-text="t('create')"
:cancel-text="t('close')"
:confirm-loading="saving"
:mask-closable="false"
@ok="submit"
@cancel="close"
>
<a-form
v-if="inbound"
:colon="false"
:label-col="{ md: { span: 8 } }"
:wrapper-col="{ md: { span: 14 } }"
>
<a-modal :open="open" :title="t('pages.client.bulk')" :ok-text="t('create')" :cancel-text="t('close')"
:confirm-loading="saving" :mask-closable="false" @ok="submit" @cancel="close">
<a-form v-if="inbound" :colon="false" :label-col="{ md: { span: 8 } }" :wrapper-col="{ md: { span: 14 } }">
<a-form-item :label="t('pages.client.method')">
<a-select v-model:value="form.emailMethod">
<a-select-option :value="0">Random</a-select-option>
@ -251,10 +238,7 @@ async function submit() {
</a-form-item>
<a-form-item :label="t('pages.client.delayedStart')">
<a-switch
v-model:checked="delayedStart"
@click="form.expiryTime = 0"
/>
<a-switch v-model:checked="delayedStart" @click="form.expiryTime = 0" />
</a-form-item>
<a-form-item v-if="delayedStart" :label="t('pages.client.expireDays')">
@ -263,14 +247,11 @@ async function submit() {
<a-form-item v-else>
<template #label>
<a-tooltip :title="t('pages.inbounds.leaveBlankToNeverExpire')">{{ t('pages.inbounds.expireDate') }}</a-tooltip>
<a-tooltip :title="t('pages.inbounds.leaveBlankToNeverExpire')">{{ t('pages.inbounds.expireDate')
}}</a-tooltip>
</template>
<a-date-picker
v-model:value="expiryDate"
:show-time="{ format: 'HH:mm:ss' }"
format="YYYY-MM-DD HH:mm:ss"
:style="{ width: '100%' }"
/>
<a-date-picker v-model:value="expiryDate" :show-time="{ format: 'HH:mm:ss' }" format="YYYY-MM-DD HH:mm:ss"
:style="{ width: '100%' }" />
</a-form-item>
<a-form-item v-if="form.expiryTime !== 0">

View file

@ -234,59 +234,47 @@ const title = computed(() =>
</script>
<template>
<a-modal
:open="open"
:title="title"
:ok-text="mode === 'edit' ? t('pages.client.submitEdit') : t('pages.client.submitAdd')"
:cancel-text="t('close')"
:confirm-loading="saving"
:mask-closable="false"
@ok="submit"
@cancel="close"
>
<a-tag
v-if="mode === 'edit' && (isExpired || isTrafficExhausted)"
color="red"
class="status-banner"
>
<a-modal :open="open" :title="title"
:ok-text="mode === 'edit' ? t('pages.client.submitEdit') : t('pages.client.submitAdd')" :cancel-text="t('close')"
:confirm-loading="saving" :mask-closable="false" @ok="submit" @cancel="close">
<a-tag v-if="mode === 'edit' && (isExpired || isTrafficExhausted)" color="red" class="status-banner">
{{ t('depleted') }}
</a-tag>
<a-form
v-if="client && inbound"
layout="horizontal"
:colon="false"
:label-col="{ md: { span: 8 } }"
:wrapper-col="{ md: { span: 14 } }"
>
<a-form v-if="client && inbound" layout="horizontal" :colon="false" :label-col="{ md: { span: 8 } }"
:wrapper-col="{ md: { span: 14 } }">
<a-form-item :label="t('enable')">
<a-switch v-model:checked="client.enable" />
</a-form-item>
<a-form-item>
<template #label>
{{ t('pages.inbounds.email') }} <SyncOutlined class="random-icon" @click="randomEmail" />
{{ t('pages.inbounds.email') }}
<SyncOutlined class="random-icon" @click="randomEmail" />
</template>
<a-input v-model:value="client.email" />
</a-form-item>
<a-form-item v-if="isTrojanOrSS">
<template #label>
{{ t('password') }} <SyncOutlined class="random-icon" @click="randomPassword" />
{{ t('password') }}
<SyncOutlined class="random-icon" @click="randomPassword" />
</template>
<a-input v-model:value="client.password" />
</a-form-item>
<a-form-item v-if="protocol === Protocols.HYSTERIA">
<template #label>
{{ t('password') }} <SyncOutlined class="random-icon" @click="randomAuth" />
{{ t('password') }}
<SyncOutlined class="random-icon" @click="randomAuth" />
</template>
<a-input v-model:value="client.auth" />
</a-form-item>
<a-form-item v-if="isVmessOrVless">
<template #label>
ID <SyncOutlined class="random-icon" @click="randomId" />
ID
<SyncOutlined class="random-icon" @click="randomId" />
</template>
<a-input v-model:value="client.id" />
</a-form-item>
@ -301,7 +289,8 @@ const title = computed(() =>
<a-form-item v-if="client.email && subEnable">
<template #label>
{{ t('subscription.title') }} <SyncOutlined class="random-icon" @click="randomSubId" />
{{ t('subscription.title') }}
<SyncOutlined class="random-icon" @click="randomSubId" />
</template>
<a-input v-model:value="client.subId" />
</a-form-item>
@ -318,19 +307,14 @@ const title = computed(() =>
<a-input-number v-model:value="client.limitIp" :min="0" />
</a-form-item>
<a-form-item
v-if="ipLimitEnable && client.limitIp > 0 && client.email && mode === 'edit'"
:label="t('pages.inbounds.IPLimitlog')"
>
<a-textarea
v-model:value="clientIpsText"
readonly
:placeholder="t('pages.inbounds.IPLimitlogDesc')"
:auto-size="{ minRows: 3, maxRows: 8 }"
@click="loadClientIps"
/>
<a-form-item v-if="ipLimitEnable && client.limitIp > 0 && client.email && mode === 'edit'"
:label="t('pages.inbounds.IPLimitlog')">
<a-textarea v-model:value="clientIpsText" readonly :placeholder="t('pages.inbounds.IPLimitlogDesc')"
:auto-size="{ minRows: 3, maxRows: 8 }" @click="loadClientIps" />
<a-button type="link" size="small" danger @click="clearClientIps">
<template #icon><DeleteOutlined /></template>
<template #icon>
<DeleteOutlined />
</template>
{{ t('pages.inbounds.IPLimitlogclear') }}
</a-button>
</a-form-item>
@ -367,10 +351,7 @@ const title = computed(() =>
</a-form-item>
<a-form-item :label="t('pages.client.delayedStart')">
<a-switch
v-model:checked="delayedStart"
@click="client.expiryTime = 0"
/>
<a-switch v-model:checked="delayedStart" @click="client.expiryTime = 0" />
</a-form-item>
<a-form-item v-if="delayedStart" :label="t('pages.client.expireDays')">
@ -379,14 +360,11 @@ const title = computed(() =>
<a-form-item v-else>
<template #label>
<a-tooltip :title="t('pages.inbounds.leaveBlankToNeverExpire')">{{ t('pages.inbounds.expireDate') }}</a-tooltip>
<a-tooltip :title="t('pages.inbounds.leaveBlankToNeverExpire')">{{ t('pages.inbounds.expireDate')
}}</a-tooltip>
</template>
<a-date-picker
v-model:value="expiryDate"
:show-time="{ format: 'HH:mm:ss' }"
format="YYYY-MM-DD HH:mm:ss"
:style="{ width: '100%' }"
/>
<a-date-picker v-model:value="expiryDate" :show-time="{ format: 'HH:mm:ss' }" format="YYYY-MM-DD HH:mm:ss"
:style="{ width: '100%' }" />
<a-tag v-if="mode === 'edit' && isExpired" color="red">{{ t('depleted') }}</a-tag>
</a-form-item>

View file

@ -221,11 +221,8 @@ function rowKey(client) {
<!-- Enable switch (hidden on mobile, lives in dropdown) -->
<div v-if="!isMobile" class="cell cell-enable">
<a-switch
:checked="client.enable"
size="small"
@change="(next) => emit('toggle-enable-client', { dbInbound, client, next })"
/>
<a-switch :checked="client.enable" size="small"
@change="(next) => emit('toggle-enable-client', { dbInbound, client, next })" />
</div>
<!-- Online tag (desktop only) -->
@ -277,21 +274,11 @@ function rowKey(client) {
</template>
<div class="usage-bar">
<span class="usage-text">{{ SizeFormatter.sizeFormat(getSum(client.email)) }}</span>
<a-progress
v-if="!client.enable"
:stroke-color="isDarkTheme ? 'rgb(72,84,105)' : '#bcbcbc'"
:show-info="false"
:percent="statsProgress(client.email)"
size="small"
/>
<a-progress
v-else-if="client.totalGB > 0"
:stroke-color="clientStatsColor(client.email)"
:show-info="false"
:status="isClientDepleted(client.email) ? 'exception' : ''"
:percent="statsProgress(client.email)"
size="small"
/>
<a-progress v-if="!client.enable" :stroke-color="isDarkTheme ? 'rgb(72,84,105)' : '#bcbcbc'"
:show-info="false" :percent="statsProgress(client.email)" size="small" />
<a-progress v-else-if="client.totalGB > 0" :stroke-color="clientStatsColor(client.email)" :show-info="false"
:status="isClientDepleted(client.email) ? 'exception' : ''" :percent="statsProgress(client.email)"
size="small" />
<a-progress v-else :show-info="false" :percent="100" stroke-color="#722ed1" size="small" />
<span class="usage-text">
<InfinityIcon v-if="isUnlimitedTotal(client)" />
@ -316,12 +303,8 @@ function rowKey(client) {
</template>
<div class="usage-bar">
<span class="usage-text">{{ IntlUtil.formatRelativeTime(client.expiryTime) }}</span>
<a-progress
:show-info="false"
:status="isClientDepleted(client.email) ? 'exception' : ''"
:percent="expireProgress(client.expiryTime, client.reset)"
size="small"
/>
<a-progress :show-info="false" :status="isClientDepleted(client.email) ? 'exception' : ''"
:percent="expireProgress(client.expiryTime, client.reset)" size="small" />
<span class="usage-text">{{ client.reset }}d</span>
</div>
</a-popover>
@ -331,19 +314,13 @@ function rowKey(client) {
<span v-if="client.expiryTime < 0">{{ t('pages.client.delayedStart') }}</span>
<span v-else>{{ IntlUtil.formatDate(client.expiryTime) }}</span>
</template>
<a-tag
:style="{ minWidth: '50px', border: 'none' }"
:color="ColorUtils.userExpiryColor(expireDiff, client, isDarkTheme)"
>
<a-tag :style="{ minWidth: '50px', border: 'none' }"
:color="ColorUtils.userExpiryColor(expireDiff, client, isDarkTheme)">
{{ IntlUtil.formatRelativeTime(client.expiryTime) }}
</a-tag>
</a-popover>
<a-tag
v-else
:color="ColorUtils.userExpiryColor(expireDiff, client, isDarkTheme)"
:style="{ border: 'none' }"
class="infinite-tag"
>
<a-tag v-else :color="ColorUtils.userExpiryColor(expireDiff, client, isDarkTheme)" :style="{ border: 'none' }"
class="infinite-tag">
<InfinityIcon />
</a-tag>
</div>
@ -378,7 +355,9 @@ function rowKey(client) {
<a-tag v-else-if="client.expiryTime < 0" color="green">
{{ -client.expiryTime / 86400000 }}d ({{ t('pages.client.delayedStart') }})
</a-tag>
<a-tag v-else color="purple"><InfinityIcon /></a-tag>
<a-tag v-else color="purple">
<InfinityIcon />
</a-tag>
</td>
</tr>
</tbody>
@ -402,21 +381,30 @@ function rowKey(client) {
.client-row {
display: grid;
grid-template-columns:
140px /* actions */
60px /* enable */
80px /* online */
minmax(160px, 2fr) /* client identity */
minmax(160px, 2fr) /* traffic */
130px /* all-time */
140px; /* expiry */
140px
/* actions */
60px
/* enable */
80px
/* online */
minmax(160px, 2fr)
/* client identity */
minmax(160px, 2fr)
/* traffic */
130px
/* all-time */
140px;
/* expiry */
gap: 12px;
align-items: center;
padding: 8px 16px;
border-top: 1px solid rgba(128, 128, 128, 0.12);
}
.client-row:last-child {
border-bottom: 1px solid rgba(128, 128, 128, 0.12);
}
.client-list-header {
font-weight: 500;
font-size: 12px;
@ -435,8 +423,10 @@ function rowKey(client) {
}
.cell {
min-width: 0; /* allow grid children to shrink instead of overflowing */
min-width: 0;
/* allow grid children to shrink instead of overflowing */
}
.cell-actions,
.cell-enable,
.cell-online,
@ -449,22 +439,27 @@ function rowKey(client) {
gap: 6px;
flex-wrap: wrap;
}
.cell-actions {
justify-content: flex-start;
}
.cell-client {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.cell-traffic,
.cell-expiry {
text-align: center;
}
.client-list-header .cell {
text-align: center;
}
.client-list-header .cell-actions,
.client-list-header .cell-client {
text-align: left;
@ -478,13 +473,18 @@ function rowKey(client) {
color: inherit;
transition: color 120ms ease;
}
.row-icon:hover {
color: var(--ant-color-primary, #1677ff);
}
.row-icon.danger {
color: #ff4d4f;
}
.danger { color: #ff4d4f; }
.danger {
color: #ff4d4f;
}
/* Client identity stack (badge + email + comment) */
.client-id-stack {
@ -494,6 +494,7 @@ function rowKey(client) {
min-width: 0;
overflow: hidden;
}
.client-email {
font-weight: 500;
white-space: nowrap;
@ -501,6 +502,7 @@ function rowKey(client) {
text-overflow: ellipsis;
display: inline-block;
}
.client-comment {
font-size: 11px;
opacity: 0.7;
@ -517,10 +519,12 @@ function rowKey(client) {
align-items: center;
gap: 6px;
}
.usage-text {
font-size: 12px;
white-space: nowrap;
}
.usage-bar :deep(.ant-progress) {
margin: 0;
line-height: 1;
@ -534,8 +538,15 @@ function rowKey(client) {
}
/* Mobile popover content table */
.text-center { text-align: center; }
.num-cell { text-align: right; font-size: 12px; padding: 2px 6px; }
.text-center {
text-align: center;
}
.num-cell {
text-align: right;
font-size: 12px;
padding: 2px 6px;
}
/* Strip AD-Vue's default expanded-cell padding so the grid sits
* flush against the inbound row's left/right edges. */

View file

@ -502,17 +502,8 @@ watch(
</script>
<template>
<a-modal
:open="open"
:title="title"
:ok-text="okText"
:cancel-text="t('close')"
:confirm-loading="saving"
:mask-closable="false"
width="780px"
@ok="submit"
@cancel="close"
>
<a-modal :open="open" :title="title" :ok-text="okText" :cancel-text="t('close')" :confirm-loading="saving"
:mask-closable="false" width="780px" @ok="submit" @cancel="close">
<a-tabs v-if="inbound && dbForm" default-active-key="basic">
<!-- ============================== BASICS ============================== -->
<a-tab-pane key="basic" :tab="t('pages.xray.basicTemplate')">
@ -549,14 +540,11 @@ watch(
</a-form-item>
<a-form-item>
<template #label>
<a-tooltip :title="t('pages.inbounds.leaveBlankToNeverExpire')">{{ t('pages.inbounds.expireDate') }}</a-tooltip>
<a-tooltip :title="t('pages.inbounds.leaveBlankToNeverExpire')">{{ t('pages.inbounds.expireDate')
}}</a-tooltip>
</template>
<a-date-picker
v-model:value="expiryDate"
:show-time="{ format: 'HH:mm:ss' }"
format="YYYY-MM-DD HH:mm:ss"
:style="{ width: '100%' }"
/>
<a-date-picker v-model:value="expiryDate" :show-time="{ format: 'HH:mm:ss' }" format="YYYY-MM-DD HH:mm:ss"
:style="{ width: '100%' }" />
</a-form-item>
</a-form>
</a-tab-pane>
@ -575,7 +563,8 @@ watch(
<a-form-item>
<template #label>
<a-tooltip title="Friendly identifier">
Email <SyncOutlined class="random-icon" @click="randomEmail(firstClient)" />
Email
<SyncOutlined class="random-icon" @click="randomEmail(firstClient)" />
</a-tooltip>
</template>
<a-input v-model:value="firstClient.email" />
@ -584,7 +573,8 @@ watch(
<a-form-item v-if="protocol === Protocols.VMESS || protocol === Protocols.VLESS">
<template #label>
<a-tooltip title="Reset to a fresh UUID">
ID <SyncOutlined class="random-icon" @click="randomUuid(firstClient)" />
ID
<SyncOutlined class="random-icon" @click="randomUuid(firstClient)" />
</a-tooltip>
</template>
<a-input v-model:value="firstClient.id" />
@ -600,16 +590,9 @@ watch(
<template #label>
<a-tooltip title="Reset to a fresh random value">
Password
<SyncOutlined
v-if="protocol === Protocols.SHADOWSOCKS"
class="random-icon"
@click="randomSSPassword(firstClient)"
/>
<SyncOutlined
v-else
class="random-icon"
@click="randomPasswordSeq(firstClient)"
/>
<SyncOutlined v-if="protocol === Protocols.SHADOWSOCKS" class="random-icon"
@click="randomSSPassword(firstClient)" />
<SyncOutlined v-else class="random-icon" @click="randomPasswordSeq(firstClient)" />
</a-tooltip>
</template>
<a-input v-model:value="firstClient.password" />
@ -617,7 +600,9 @@ watch(
<a-form-item v-if="protocol === Protocols.HYSTERIA">
<template #label>
<a-tooltip title="Reset"><span>Auth password</span> <SyncOutlined class="random-icon" @click="randomAuth(firstClient)" /></a-tooltip>
<a-tooltip title="Reset"><span>Auth password</span>
<SyncOutlined class="random-icon" @click="randomAuth(firstClient)" />
</a-tooltip>
</template>
<a-input v-model:value="firstClient.auth" />
</a-form-item>
@ -646,27 +631,22 @@ watch(
</a-form-item>
<a-form-item label="Expiry">
<a-date-picker
v-model:value="clientExpiryDate"
:show-time="{ format: 'HH:mm:ss' }"
format="YYYY-MM-DD HH:mm:ss"
:style="{ width: '100%' }"
/>
<a-date-picker v-model:value="clientExpiryDate" :show-time="{ format: 'HH:mm:ss' }"
format="YYYY-MM-DD HH:mm:ss" :style="{ width: '100%' }" />
</a-form-item>
</a-form>
</a-collapse-panel>
</a-collapse>
<a-collapse v-else>
<a-collapse-panel
key="summary"
:header="`Clients: ${clientsArray.length}`"
>
<a-collapse-panel key="summary" :header="`Clients: ${clientsArray.length}`">
<table class="client-summary">
<thead>
<tr>
<th>Email</th>
<th>{{ protocol === Protocols.TROJAN || protocol === Protocols.SHADOWSOCKS ? 'Password' : (protocol === Protocols.HYSTERIA ? 'Auth' : 'ID') }}</th>
<th>{{ protocol === Protocols.TROJAN || protocol === Protocols.SHADOWSOCKS ? 'Password' : (protocol
===
Protocols.HYSTERIA ? 'Auth' : 'ID') }}</th>
</tr>
</thead>
<tbody>
@ -681,13 +661,8 @@ watch(
</template>
<!-- VLess decryption / encryption -->
<a-form
v-if="protocol === Protocols.VLESS"
:colon="false"
:label-col="{ md: { span: 8 } }"
:wrapper-col="{ md: { span: 14 } }"
class="mt-12"
>
<a-form v-if="protocol === Protocols.VLESS" :colon="false" :label-col="{ md: { span: 8 } }"
:wrapper-col="{ md: { span: 14 } }" class="mt-12">
<a-form-item label="Decryption">
<a-input v-model:value="inbound.settings.decryption" />
</a-form-item>
@ -708,13 +683,8 @@ watch(
</a-form>
<!-- Shadowsocks shared fields (method/network/ivCheck) -->
<a-form
v-if="protocol === Protocols.SHADOWSOCKS"
:colon="false"
:label-col="{ md: { span: 8 } }"
:wrapper-col="{ md: { span: 14 } }"
class="mt-12"
>
<a-form v-if="protocol === Protocols.SHADOWSOCKS" :colon="false" :label-col="{ md: { span: 8 } }"
:wrapper-col="{ md: { span: 14 } }" class="mt-12">
<a-form-item label="Encryption method">
<a-select v-model:value="inbound.settings.method" @change="onSSMethodChange">
<a-select-option v-for="(m, k) in SSMethods" :key="k" :value="m">{{ k }}</a-select-option>
@ -740,21 +710,15 @@ watch(
</a-form>
<!-- HTTP / Mixed accounts -->
<a-form
v-if="protocol === Protocols.HTTP || protocol === Protocols.MIXED"
:colon="false"
:label-col="{ md: { span: 8 } }"
:wrapper-col="{ md: { span: 14 } }"
class="mt-12"
>
<a-form v-if="protocol === Protocols.HTTP || protocol === Protocols.MIXED" :colon="false"
:label-col="{ md: { span: 8 } }" :wrapper-col="{ md: { span: 14 } }" class="mt-12">
<a-form-item label="Accounts">
<a-button
size="small"
@click="protocol === Protocols.HTTP
? inbound.settings.addAccount(new Inbound.HttpSettings.HttpAccount())
: inbound.settings.addAccount(new Inbound.MixedSettings.SocksAccount())"
>
<template #icon><PlusOutlined /></template>
<a-button size="small" @click="protocol === Protocols.HTTP
? inbound.settings.addAccount(new Inbound.HttpSettings.HttpAccount())
: inbound.settings.addAccount(new Inbound.MixedSettings.SocksAccount())">
<template #icon>
<PlusOutlined />
</template>
Add
</a-button>
</a-form-item>
@ -765,7 +729,9 @@ watch(
</a-input>
<a-input :style="{ width: '45%' }" v-model:value="account.pass" placeholder="Password" />
<a-button @click="inbound.settings.delAccount(idx)">
<template #icon><MinusOutlined /></template>
<template #icon>
<MinusOutlined />
</template>
</a-button>
</a-input-group>
</a-form-item>
@ -789,13 +755,8 @@ watch(
</a-form>
<!-- Tunnel -->
<a-form
v-if="protocol === Protocols.TUNNEL"
:colon="false"
:label-col="{ md: { span: 8 } }"
:wrapper-col="{ md: { span: 14 } }"
class="mt-12"
>
<a-form v-if="protocol === Protocols.TUNNEL" :colon="false" :label-col="{ md: { span: 8 } }"
:wrapper-col="{ md: { span: 14 } }" class="mt-12">
<a-form-item label="Address">
<a-input v-model:value="inbound.settings.address" />
</a-form-item>
@ -815,16 +776,12 @@ watch(
</a-form>
<!-- WireGuard -->
<a-form
v-if="protocol === Protocols.WIREGUARD"
:colon="false"
:label-col="{ md: { span: 8 } }"
:wrapper-col="{ md: { span: 14 } }"
class="mt-12"
>
<a-form v-if="protocol === Protocols.WIREGUARD" :colon="false" :label-col="{ md: { span: 8 } }"
:wrapper-col="{ md: { span: 14 } }" class="mt-12">
<a-form-item>
<template #label>
Secret key <SyncOutlined class="random-icon" @click="regenInboundWg" />
Secret key
<SyncOutlined class="random-icon" @click="regenInboundWg" />
</template>
<a-input v-model:value="inbound.settings.secretKey" />
</a-form-item>
@ -839,22 +796,22 @@ watch(
</a-form-item>
<a-form-item label="Peers">
<a-button size="small" @click="inbound.settings.addPeer()">
<template #icon><PlusOutlined /></template>
<template #icon>
<PlusOutlined />
</template>
Add peer
</a-button>
</a-form-item>
<div v-for="(peer, idx) in inbound.settings.peers" :key="idx" class="wg-peer">
<a-divider style="margin: 8px 0">
Peer {{ idx + 1 }}
<DeleteOutlined
v-if="inbound.settings.peers.length > 1"
class="danger-icon"
@click="inbound.settings.delPeer(idx)"
/>
<DeleteOutlined v-if="inbound.settings.peers.length > 1" class="danger-icon"
@click="inbound.settings.delPeer(idx)" />
</a-divider>
<a-form-item>
<template #label>
Secret key <SyncOutlined class="random-icon" @click="regenWgKeypair(peer)" />
Secret key
<SyncOutlined class="random-icon" @click="regenWgKeypair(peer)" />
</template>
<a-input v-model:value="peer.privateKey" />
</a-form-item>
@ -866,17 +823,16 @@ watch(
</a-form-item>
<a-form-item label="Allowed IPs">
<a-button size="small" @click="peer.allowedIPs.push('')">
<template #icon><PlusOutlined /></template>
<template #icon>
<PlusOutlined />
</template>
</a-button>
<a-input
v-for="(_ip, j) in peer.allowedIPs"
:key="j"
v-model:value="peer.allowedIPs[j]"
class="mt-4"
>
<a-input v-for="(_ip, j) in peer.allowedIPs" :key="j" v-model:value="peer.allowedIPs[j]" class="mt-4">
<template #addonAfter>
<a-button v-if="peer.allowedIPs.length > 1" size="small" @click="peer.allowedIPs.splice(j, 1)">
<template #icon><MinusOutlined /></template>
<template #icon>
<MinusOutlined />
</template>
</a-button>
</template>
</a-input>
@ -892,25 +848,21 @@ watch(
<a-divider style="margin: 12px 0" />
<div class="fallbacks-header">
<a-tooltip
title="Route incoming TLS traffic to a backend when it doesn't match a valid VLESS/Trojan handshake. Match by SNI, ALPN, and HTTP path; the most precise rule wins. Fallbacks require TCP+TLS transport."
>
title="Route incoming TLS traffic to a backend when it doesn't match a valid VLESS/Trojan handshake. Match by SNI, ALPN, and HTTP path; the most precise rule wins. Fallbacks require TCP+TLS transport.">
<span class="fallbacks-title">
Fallbacks ({{ inbound.settings.fallbacks.length }})
</span>
</a-tooltip>
<a-button type="primary" size="small" @click="addFallback">
<template #icon><PlusOutlined /></template>
<template #icon>
<PlusOutlined />
</template>
Add
</a-button>
</div>
<a-form
v-for="(fallback, idx) in inbound.settings.fallbacks"
:key="idx"
:colon="false"
:label-col="{ md: { span: 8 } }"
:wrapper-col="{ md: { span: 14 } }"
>
<a-form v-for="(fallback, idx) in inbound.settings.fallbacks" :key="idx" :colon="false"
:label-col="{ md: { span: 8 } }" :wrapper-col="{ md: { span: 14 } }">
<a-divider style="margin: 0">
Fallback {{ idx + 1 }}
<DeleteOutlined class="danger-icon" @click="delFallback(idx)" />
@ -928,8 +880,7 @@ watch(
<a-form-item>
<template #label>
<a-tooltip
title="Match TLS ALPN. 'any' = no ALPN constraint. Use h2/http/1.1 split when the inbound advertises both."
>
title="Match TLS ALPN. 'any' = no ALPN constraint. Use h2/http/1.1 split when the inbound advertises both.">
ALPN
</a-tooltip>
</template>
@ -940,42 +891,32 @@ watch(
</a-select>
</a-form-item>
<a-form-item
:validate-status="fallback.path && !fallback.path.startsWith('/') ? 'error' : ''"
:help="fallback.path && !fallback.path.startsWith('/') ? 'Path must start with /' : ''"
>
<a-form-item :validate-status="fallback.path && !fallback.path.startsWith('/') ? 'error' : ''"
:help="fallback.path && !fallback.path.startsWith('/') ? 'Path must start with /' : ''">
<template #label>
<a-tooltip
title="Match the HTTP request path of the first packet. Must start with '/'. Leave empty to match any."
>
title="Match the HTTP request path of the first packet. Must start with '/'. Leave empty to match any.">
Path
</a-tooltip>
</template>
<a-input v-model:value.trim="fallback.path" placeholder="any (leave empty) or /ws" />
</a-form-item>
<a-form-item
:validate-status="!fallback.dest ? 'error' : ''"
:help="!fallback.dest ? 'Destination is required' : ''"
>
<a-form-item :validate-status="!fallback.dest ? 'error' : ''"
:help="!fallback.dest ? 'Destination is required' : ''">
<template #label>
<a-tooltip
title="Where matching traffic is forwarded. Accepts a port number (80), an addr:port (127.0.0.1:8080), or a Unix socket path (/dev/shm/x.sock or @abstract)."
>
title="Where matching traffic is forwarded. Accepts a port number (80), an addr:port (127.0.0.1:8080), or a Unix socket path (/dev/shm/x.sock or @abstract).">
Destination
</a-tooltip>
</template>
<a-input
v-model:value.trim="fallback.dest"
placeholder="80 | 127.0.0.1:8080 | /dev/shm/x.sock"
/>
<a-input v-model:value.trim="fallback.dest" placeholder="80 | 127.0.0.1:8080 | /dev/shm/x.sock" />
</a-form-item>
<a-form-item>
<template #label>
<a-tooltip
title="PROXY protocol version sent to the destination. Off (0) for plain TCP; v1/v2 to preserve client IP if the backend supports it."
>
title="PROXY protocol version sent to the destination. Off (0) for plain TCP; v1/v2 to preserve client IP if the backend supports it.">
PROXY
</a-tooltip>
</template>
@ -990,8 +931,8 @@ watch(
</a-tab-pane>
<!-- ============================== STREAM ============================== -->
<a-tab-pane v-if="canEnableStream" key="stream" tab="Stream"
><!-- "Stream" stays literal it's a wire-format identifier -->
<a-tab-pane v-if="canEnableStream" key="stream"
tab="Stream"><!-- "Stream" stays literal it's a wire-format identifier -->
<a-form :colon="false" :label-col="{ md: { span: 8 } }" :wrapper-col="{ md: { span: 14 } }">
<a-form-item v-if="protocol !== Protocols.HYSTERIA" label="Transmission">
<a-select v-model:value="network" :style="{ width: '75%' }">
@ -1010,10 +951,8 @@ watch(
<a-switch v-model:checked="inbound.stream.tcp.acceptProxyProtocol" />
</a-form-item>
<a-form-item :label="`HTTP ${t('camouflage')}`">
<a-switch
:checked="inbound.stream.tcp.type === 'http'"
@change="(v) => (inbound.stream.tcp.type = v ? 'http' : 'none')"
/>
<a-switch :checked="inbound.stream.tcp.type === 'http'"
@change="(v) => (inbound.stream.tcp.type = v ? 'http' : 'none')" />
</a-form-item>
<template v-if="inbound.stream.tcp.type === 'http'">
@ -1028,19 +967,21 @@ watch(
<a-form-item>
<template #label>
{{ t('pages.inbounds.stream.tcp.path') }}
<a-button size="small" :style="{ marginLeft: '6px' }" @click="inbound.stream.tcp.request.addPath('/')">
<template #icon><PlusOutlined /></template>
<a-button size="small" :style="{ marginLeft: '6px' }"
@click="inbound.stream.tcp.request.addPath('/')">
<template #icon>
<PlusOutlined />
</template>
</a-button>
</template>
<template v-for="(_p, idx) in inbound.stream.tcp.request.path" :key="`tcp-path-${idx}`">
<a-input v-model:value="inbound.stream.tcp.request.path[idx]" class="mb-4">
<template #addonAfter>
<a-button
v-if="inbound.stream.tcp.request.path.length > 1"
size="small"
@click="inbound.stream.tcp.request.removePath(idx)"
>
<template #icon><MinusOutlined /></template>
<a-button v-if="inbound.stream.tcp.request.path.length > 1" size="small"
@click="inbound.stream.tcp.request.removePath(idx)">
<template #icon>
<MinusOutlined />
</template>
</a-button>
</template>
</a-input>
@ -1048,17 +989,24 @@ watch(
</a-form-item>
<a-form-item :label="t('pages.inbounds.stream.tcp.requestHeader')">
<a-button size="small" @click="inbound.stream.tcp.request.addHeader('Host', '')">
<template #icon><PlusOutlined /></template>
<template #icon>
<PlusOutlined />
</template>
</a-button>
</a-form-item>
<a-form-item :wrapper-col="{ span: 24 }">
<a-input-group v-for="(h, idx) in inbound.stream.tcp.request.headers" :key="`tcp-rh-${idx}`" compact class="mb-8">
<a-input :style="{ width: '45%' }" v-model:value="h.name" :placeholder="t('pages.inbounds.stream.general.name')">
<a-input-group v-for="(h, idx) in inbound.stream.tcp.request.headers" :key="`tcp-rh-${idx}`" compact
class="mb-8">
<a-input :style="{ width: '45%' }" v-model:value="h.name"
:placeholder="t('pages.inbounds.stream.general.name')">
<template #addonBefore>{{ idx + 1 }}</template>
</a-input>
<a-input :style="{ width: '45%' }" v-model:value="h.value" :placeholder="t('pages.inbounds.stream.general.value')" />
<a-input :style="{ width: '45%' }" v-model:value="h.value"
:placeholder="t('pages.inbounds.stream.general.value')" />
<a-button @click="inbound.stream.tcp.request.removeHeader(idx)">
<template #icon><MinusOutlined /></template>
<template #icon>
<MinusOutlined />
</template>
</a-button>
</a-input-group>
</a-form-item>
@ -1075,18 +1023,26 @@ watch(
<a-input v-model:value="inbound.stream.tcp.response.reason" />
</a-form-item>
<a-form-item :label="t('pages.inbounds.stream.tcp.responseHeader')">
<a-button size="small" @click="inbound.stream.tcp.response.addHeader('Content-Type', 'application/octet-stream')">
<template #icon><PlusOutlined /></template>
<a-button size="small"
@click="inbound.stream.tcp.response.addHeader('Content-Type', 'application/octet-stream')">
<template #icon>
<PlusOutlined />
</template>
</a-button>
</a-form-item>
<a-form-item :wrapper-col="{ span: 24 }">
<a-input-group v-for="(h, idx) in inbound.stream.tcp.response.headers" :key="`tcp-rsh-${idx}`" compact class="mb-8">
<a-input :style="{ width: '45%' }" v-model:value="h.name" :placeholder="t('pages.inbounds.stream.general.name')">
<a-input-group v-for="(h, idx) in inbound.stream.tcp.response.headers" :key="`tcp-rsh-${idx}`" compact
class="mb-8">
<a-input :style="{ width: '45%' }" v-model:value="h.name"
:placeholder="t('pages.inbounds.stream.general.name')">
<template #addonBefore>{{ idx + 1 }}</template>
</a-input>
<a-input :style="{ width: '45%' }" v-model:value="h.value" :placeholder="t('pages.inbounds.stream.general.value')" />
<a-input :style="{ width: '45%' }" v-model:value="h.value"
:placeholder="t('pages.inbounds.stream.general.value')" />
<a-button @click="inbound.stream.tcp.response.removeHeader(idx)">
<template #icon><MinusOutlined /></template>
<template #icon>
<MinusOutlined />
</template>
</a-button>
</a-input-group>
</a-form-item>
@ -1131,17 +1087,23 @@ watch(
</a-form-item>
<a-form-item :label="t('pages.inbounds.stream.tcp.requestHeader')">
<a-button size="small" @click="inbound.stream.ws.addHeader('', '')">
<template #icon><PlusOutlined /></template>
<template #icon>
<PlusOutlined />
</template>
</a-button>
</a-form-item>
<a-form-item :wrapper-col="{ span: 24 }">
<a-input-group v-for="(h, idx) in inbound.stream.ws.headers" :key="`ws-h-${idx}`" compact class="mb-8">
<a-input :style="{ width: '45%' }" v-model:value="h.name" :placeholder="t('pages.inbounds.stream.general.name')">
<a-input :style="{ width: '45%' }" v-model:value="h.name"
:placeholder="t('pages.inbounds.stream.general.name')">
<template #addonBefore>{{ idx + 1 }}</template>
</a-input>
<a-input :style="{ width: '45%' }" v-model:value="h.value" :placeholder="t('pages.inbounds.stream.general.value')" />
<a-input :style="{ width: '45%' }" v-model:value="h.value"
:placeholder="t('pages.inbounds.stream.general.value')" />
<a-button @click="inbound.stream.ws.removeHeader(idx)">
<template #icon><MinusOutlined /></template>
<template #icon>
<MinusOutlined />
</template>
</a-button>
</a-input-group>
</a-form-item>
@ -1173,17 +1135,24 @@ watch(
</a-form-item>
<a-form-item :label="t('pages.inbounds.stream.tcp.requestHeader')">
<a-button size="small" @click="inbound.stream.httpupgrade.addHeader('', '')">
<template #icon><PlusOutlined /></template>
<template #icon>
<PlusOutlined />
</template>
</a-button>
</a-form-item>
<a-form-item :wrapper-col="{ span: 24 }">
<a-input-group v-for="(h, idx) in inbound.stream.httpupgrade.headers" :key="`hu-h-${idx}`" compact class="mb-8">
<a-input :style="{ width: '45%' }" v-model:value="h.name" :placeholder="t('pages.inbounds.stream.general.name')">
<a-input-group v-for="(h, idx) in inbound.stream.httpupgrade.headers" :key="`hu-h-${idx}`" compact
class="mb-8">
<a-input :style="{ width: '45%' }" v-model:value="h.name"
:placeholder="t('pages.inbounds.stream.general.name')">
<template #addonBefore>{{ idx + 1 }}</template>
</a-input>
<a-input :style="{ width: '45%' }" v-model:value="h.value" :placeholder="t('pages.inbounds.stream.general.value')" />
<a-input :style="{ width: '45%' }" v-model:value="h.value"
:placeholder="t('pages.inbounds.stream.general.value')" />
<a-button @click="inbound.stream.httpupgrade.removeHeader(idx)">
<template #icon><MinusOutlined /></template>
<template #icon>
<MinusOutlined />
</template>
</a-button>
</a-input-group>
</a-form-item>
@ -1199,17 +1168,23 @@ watch(
</a-form-item>
<a-form-item :label="t('pages.inbounds.stream.tcp.requestHeader')">
<a-button size="small" @click="inbound.stream.xhttp.addHeader('', '')">
<template #icon><PlusOutlined /></template>
<template #icon>
<PlusOutlined />
</template>
</a-button>
</a-form-item>
<a-form-item :wrapper-col="{ span: 24 }">
<a-input-group v-for="(h, idx) in inbound.stream.xhttp.headers" :key="`xh-h-${idx}`" compact class="mb-8">
<a-input :style="{ width: '45%' }" v-model:value="h.name" :placeholder="t('pages.inbounds.stream.general.name')">
<a-input :style="{ width: '45%' }" v-model:value="h.name"
:placeholder="t('pages.inbounds.stream.general.name')">
<template #addonBefore>{{ idx + 1 }}</template>
</a-input>
<a-input :style="{ width: '45%' }" v-model:value="h.value" :placeholder="t('pages.inbounds.stream.general.value')" />
<a-input :style="{ width: '45%' }" v-model:value="h.value"
:placeholder="t('pages.inbounds.stream.general.value')" />
<a-button @click="inbound.stream.xhttp.removeHeader(idx)">
<template #icon><MinusOutlined /></template>
<template #icon>
<MinusOutlined />
</template>
</a-button>
</a-input-group>
</a-form-item>
@ -1228,7 +1203,8 @@ watch(
<a-input v-model:value="inbound.stream.xhttp.scStreamUpServerSecs" />
</a-form-item>
<a-form-item label="Server Max Header Bytes">
<a-input-number v-model:value="inbound.stream.xhttp.serverMaxHeaderBytes" :min="0" placeholder="0 (default)" />
<a-input-number v-model:value="inbound.stream.xhttp.serverMaxHeaderBytes" :min="0"
placeholder="0 (default)" />
</a-form-item>
<a-form-item label="Padding Bytes">
<a-input v-model:value="inbound.stream.xhttp.xPaddingBytes" />
@ -1271,8 +1247,7 @@ watch(
</a-form-item>
<a-form-item
v-if="inbound.stream.xhttp.sessionPlacement && inbound.stream.xhttp.sessionPlacement !== 'path'"
label="Session Key"
>
label="Session Key">
<a-input v-model:value="inbound.stream.xhttp.sessionKey" placeholder="x_session" />
</a-form-item>
<a-form-item label="Sequence Placement">
@ -1284,10 +1259,8 @@ watch(
<a-select-option value="query">query</a-select-option>
</a-select>
</a-form-item>
<a-form-item
v-if="inbound.stream.xhttp.seqPlacement && inbound.stream.xhttp.seqPlacement !== 'path'"
label="Sequence Key"
>
<a-form-item v-if="inbound.stream.xhttp.seqPlacement && inbound.stream.xhttp.seqPlacement !== 'path'"
label="Sequence Key">
<a-input v-model:value="inbound.stream.xhttp.seqKey" placeholder="x_seq" />
</a-form-item>
<a-form-item v-if="inbound.stream.xhttp.mode === 'packet-up'" label="Uplink Data Placement">
@ -1301,8 +1274,7 @@ watch(
</a-form-item>
<a-form-item
v-if="inbound.stream.xhttp.mode === 'packet-up' && inbound.stream.xhttp.uplinkDataPlacement && inbound.stream.xhttp.uplinkDataPlacement !== 'body'"
label="Uplink Data Key"
>
label="Uplink Data Key">
<a-input v-model:value="inbound.stream.xhttp.uplinkDataKey" placeholder="x_data" />
</a-form-item>
<a-form-item label="No SSE Header">
@ -1327,7 +1299,8 @@ watch(
<a-form-item label="Cipher Suites">
<a-select v-model:value="inbound.stream.tls.cipherSuites">
<a-select-option value="">Auto</a-select-option>
<a-select-option v-for="[label, val] in CIPHER_SUITES" :key="val" :value="val">{{ label }}</a-select-option>
<a-select-option v-for="[label, val] in CIPHER_SUITES" :key="val" :value="val">{{ label
}}</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Min/Max Version">
@ -1347,12 +1320,8 @@ watch(
</a-select>
</a-form-item>
<a-form-item label="ALPN">
<a-select
v-model:value="inbound.stream.tls.alpn"
mode="multiple"
:style="{ width: '100%' }"
:token-separators="[',']"
>
<a-select v-model:value="inbound.stream.tls.alpn" mode="multiple" :style="{ width: '100%' }"
:token-separators="[',']">
<a-select-option v-for="a in ALPNS" :key="a" :value="a">{{ a }}</a-select-option>
</a-select>
</a-form-item>
@ -1379,15 +1348,15 @@ watch(
<a-form-item label=" ">
<a-space>
<a-button v-if="idx === 0" type="primary" size="small" @click="inbound.stream.tls.addCert()">
<template #icon><PlusOutlined /></template>
<template #icon>
<PlusOutlined />
</template>
</a-button>
<a-button
v-if="inbound.stream.tls.certs.length > 1"
type="primary"
size="small"
@click="inbound.stream.tls.removeCert(idx)"
>
<template #icon><MinusOutlined /></template>
<a-button v-if="inbound.stream.tls.certs.length > 1" type="primary" size="small"
@click="inbound.stream.tls.removeCert(idx)">
<template #icon>
<MinusOutlined />
</template>
</a-button>
</a-space>
</a-form-item>
@ -1488,7 +1457,8 @@ watch(
<a-input v-model:value="inbound.stream.reality.settings.spiderX" />
</a-form-item>
<a-form-item :label="t('pages.inbounds.publicKey')">
<a-textarea v-model:value="inbound.stream.reality.settings.publicKey" :auto-size="{ minRows: 1, maxRows: 4 }" />
<a-textarea v-model:value="inbound.stream.reality.settings.publicKey"
:auto-size="{ minRows: 1, maxRows: 4 }" />
</a-form-item>
<a-form-item :label="t('pages.inbounds.privatekey')">
<a-textarea v-model:value="inbound.stream.reality.privateKey" :auto-size="{ minRows: 1, maxRows: 4 }" />
@ -1503,7 +1473,8 @@ watch(
<a-textarea v-model:value="inbound.stream.reality.mldsa65Seed" :auto-size="{ minRows: 2, maxRows: 6 }" />
</a-form-item>
<a-form-item label="mldsa65 Verify">
<a-textarea v-model:value="inbound.stream.reality.settings.mldsa65Verify" :auto-size="{ minRows: 2, maxRows: 6 }" />
<a-textarea v-model:value="inbound.stream.reality.settings.mldsa65Verify"
:auto-size="{ minRows: 2, maxRows: 6 }" />
</a-form-item>
<a-form-item label=" ">
<a-space>
@ -1517,23 +1488,16 @@ watch(
<a-divider :style="{ margin: '5px 0 0' }" />
<a-form-item label="External Proxy">
<a-switch v-model:checked="externalProxy" />
<a-button
v-if="externalProxy"
size="small"
type="primary"
:style="{ marginLeft: '10px' }"
@click="inbound.stream.externalProxy.push({ forceTls: 'same', dest: '', port: 443, remark: '' })"
>
<template #icon><PlusOutlined /></template>
<a-button v-if="externalProxy" size="small" type="primary" :style="{ marginLeft: '10px' }"
@click="inbound.stream.externalProxy.push({ forceTls: 'same', dest: '', port: 443, remark: '' })">
<template #icon>
<PlusOutlined />
</template>
</a-button>
</a-form-item>
<a-form-item v-if="externalProxy" :wrapper-col="{ span: 24 }">
<a-input-group
v-for="(row, idx) in inbound.stream.externalProxy"
:key="`ep-${idx}`"
compact
:style="{ margin: '8px 0' }"
>
<a-input-group v-for="(row, idx) in inbound.stream.externalProxy" :key="`ep-${idx}`" compact
:style="{ margin: '8px 0' }">
<a-tooltip title="Force TLS">
<a-select v-model:value="row.forceTls" :style="{ width: '20%' }">
<a-select-option value="same">{{ t('pages.inbounds.same') }}</a-select-option>
@ -1616,12 +1580,8 @@ watch(
<a-input v-model:value="inbound.stream.sockopt.interfaceName" />
</a-form-item>
<a-form-item label="Trusted X-Forwarded-For">
<a-select
v-model:value="inbound.stream.sockopt.trustedXForwardedFor"
mode="tags"
:style="{ width: '100%' }"
:token-separators="[',']"
>
<a-select v-model:value="inbound.stream.sockopt.trustedXForwardedFor" mode="tags"
:style="{ width: '100%' }" :token-separators="[',']">
<a-select-option value="CF-Connecting-IP">CF-Connecting-IP</a-select-option>
<a-select-option value="X-Real-IP">X-Real-IP</a-select-option>
<a-select-option value="True-Client-IP">True-Client-IP</a-select-option>
@ -1636,8 +1596,7 @@ watch(
</a-tab-pane>
<!-- ============================== SNIFFING ============================== -->
<a-tab-pane key="sniffing" tab="Sniffing"
><!-- "Sniffing" stays literal xray config term -->
<a-tab-pane key="sniffing" tab="Sniffing"><!-- "Sniffing" stays literal xray config term -->
<a-form :colon="false" :label-col="{ md: { span: 8 } }" :wrapper-col="{ md: { span: 14 } }">
<a-form-item label="Enabled">
<a-switch v-model:checked="inbound.sniffing.enabled" />
@ -1655,22 +1614,12 @@ watch(
<a-switch v-model:checked="inbound.sniffing.routeOnly" />
</a-form-item>
<a-form-item label="IPs excluded">
<a-select
v-model:value="inbound.sniffing.ipsExcluded"
mode="tags"
:token-separators="[',']"
placeholder="IP/CIDR/geoip:*/ext:*"
:style="{ width: '100%' }"
/>
<a-select v-model:value="inbound.sniffing.ipsExcluded" mode="tags" :token-separators="[',']"
placeholder="IP/CIDR/geoip:*/ext:*" :style="{ width: '100%' }" />
</a-form-item>
<a-form-item label="Domains excluded">
<a-select
v-model:value="inbound.sniffing.domainsExcluded"
mode="tags"
:token-separators="[',']"
placeholder="domain:*/ext:*"
:style="{ width: '100%' }"
/>
<a-select v-model:value="inbound.sniffing.domainsExcluded" mode="tags" :token-separators="[',']"
placeholder="domain:*/ext:*" :style="{ width: '100%' }" />
</a-form-item>
</template>
</a-form>
@ -1678,28 +1627,17 @@ watch(
<!-- ============================== ADVANCED ============================== -->
<a-tab-pane key="advanced" :tab="t('pages.xray.advancedTemplate')">
<a-alert
type="info"
show-icon
<a-alert type="info" show-icon
message="Edit raw stream JSON to access advanced fields we don't yet expose through the form."
class="mb-12"
/>
class="mb-12" />
<a-form layout="vertical">
<a-form-item label="streamSettings">
<a-textarea
v-model:value="advancedJson.stream"
:auto-size="{ minRows: 10, maxRows: 24 }"
spellcheck="false"
class="json-editor"
/>
<a-textarea v-model:value="advancedJson.stream" :auto-size="{ minRows: 10, maxRows: 24 }" spellcheck="false"
class="json-editor" />
</a-form-item>
<a-form-item label="sniffing (overrides the Sniffing tab when set)">
<a-textarea
v-model:value="advancedJson.sniffing"
:auto-size="{ minRows: 6, maxRows: 16 }"
spellcheck="false"
class="json-editor"
/>
<a-textarea v-model:value="advancedJson.sniffing" :auto-size="{ minRows: 6, maxRows: 16 }"
spellcheck="false" class="json-editor" />
</a-form-item>
</a-form>
</a-tab-pane>
@ -1708,12 +1646,29 @@ watch(
</template>
<style scoped>
.mt-4 { margin-top: 4px; }
.mt-8 { margin-top: 8px; }
.mt-12 { margin-top: 12px; }
.mb-4 { margin-bottom: 4px; }
.mb-8 { margin-bottom: 8px; }
.mb-12 { margin-bottom: 12px; }
.mt-4 {
margin-top: 4px;
}
.mt-8 {
margin-top: 8px;
}
.mt-12 {
margin-top: 12px;
}
.mb-4 {
margin-bottom: 4px;
}
.mb-8 {
margin-bottom: 8px;
}
.mb-12 {
margin-bottom: 12px;
}
.random-icon {
margin-left: 4px;
@ -1736,6 +1691,7 @@ watch(
width: 100%;
border-collapse: collapse;
}
.client-summary th,
.client-summary td {
padding: 4px 8px;
@ -1749,6 +1705,7 @@ watch(
gap: 8px;
margin: 8px 0;
}
.fallbacks-title {
font-weight: 500;
flex: 1;

View file

@ -259,13 +259,7 @@ const showSubscriptionTab = computed(
</script>
<template>
<a-modal
:open="open"
:title="t('pages.inbounds.inboundData')"
:footer="null"
width="640px"
@cancel="close"
>
<a-modal :open="open" :title="t('pages.inbounds.inboundData')" :footer="null" width="640px" @cancel="close">
<template v-if="dbInbound && inbound">
<a-tabs v-model:active-key="activeTab">
<!-- ============================================================
@ -336,7 +330,9 @@ const showSubscriptionTab = computed(
<code class="value-code">{{ encryptionLabel }}</code>
<a-tooltip :title="t('copy')">
<a-button size="small" class="value-copy" @click="copyText(encryptionLabel)">
<template #icon><CopyOutlined /></template>
<template #icon>
<CopyOutlined />
</template>
</a-button>
</a-tooltip>
</dd>
@ -487,15 +483,16 @@ const showSubscriptionTab = computed(
<a-tag color="green">Peer {{ idx + 1 }} config</a-tag>
<a-tooltip :title="t('copy')">
<a-button size="small" @click="copyText(wireguardConfigs[idx])">
<template #icon><CopyOutlined /></template>
<template #icon>
<CopyOutlined />
</template>
</a-button>
</a-tooltip>
<a-tooltip :title="t('download')">
<a-button
size="small"
@click="downloadText(wireguardConfigs[idx], `peer-${idx + 1}.conf`)"
>
<template #icon><DownloadOutlined /></template>
<a-button size="small" @click="downloadText(wireguardConfigs[idx], `peer-${idx + 1}.conf`)">
<template #icon>
<DownloadOutlined />
</template>
</a-button>
</a-tooltip>
</div>
@ -510,7 +507,9 @@ const showSubscriptionTab = computed(
<a-tag color="green">Peer {{ idx + 1 }} link</a-tag>
<a-tooltip :title="t('copy')">
<a-button size="small" @click="copyText(wireguardLinks[idx])">
<template #icon><CopyOutlined /></template>
<template #icon>
<CopyOutlined />
</template>
</a-button>
</a-tooltip>
</div>
@ -525,16 +524,14 @@ const showSubscriptionTab = computed(
<!-- Single-user SS share link (no QR) -->
<template v-if="dbInbound.isSS && !inbound.isSSMultiUser && links.length > 0">
<a-divider>{{ t('pages.inbounds.copyLink') }}</a-divider>
<div
v-for="(link, idx) in links"
:key="idx"
class="link-panel"
>
<div v-for="(link, idx) in links" :key="idx" class="link-panel">
<div class="link-panel-header">
<a-tag color="green">{{ link.remark || `Link ${idx + 1}` }}</a-tag>
<a-tooltip :title="t('copy')">
<a-button size="small" @click="copyText(link.link)">
<template #icon><CopyOutlined /></template>
<template #icon>
<CopyOutlined />
</template>
</a-button>
</a-tooltip>
</div>
@ -626,12 +623,8 @@ const showSubscriptionTab = computed(
<td>
<div class="ip-log">
<div v-if="clientIpsArray.length > 0">
<a-tag
v-for="(item, idx) in clientIpsArray"
:key="idx"
color="blue"
class="ip-log-row"
>{{ item }}</a-tag>
<a-tag v-for="(item, idx) in clientIpsArray" :key="idx" color="blue" class="ip-log-row">{{ item
}}</a-tag>
</div>
<a-tag v-else>{{ clientIpsText || t('tgbot.noIpRecord') }}</a-tag>
</div>
@ -658,27 +651,26 @@ const showSubscriptionTab = computed(
<tbody>
<tr>
<td>
<a-tag
v-if="clientStats && clientSettings.totalGB > 0"
:color="statsColor(clientStats)"
>{{ getRemainingStats() }}</a-tag>
<a-tag v-if="clientStats && clientSettings.totalGB > 0" :color="statsColor(clientStats)">{{
getRemainingStats() }}</a-tag>
</td>
<td>
<a-tag
v-if="clientSettings.totalGB > 0"
:color="clientStats ? statsColor(clientStats) : 'default'"
>{{ SizeFormatter.sizeFormat(clientSettings.totalGB) }}</a-tag>
<a-tag v-else color="purple"><InfinityIcon /></a-tag>
<a-tag v-if="clientSettings.totalGB > 0" :color="clientStats ? statsColor(clientStats) : 'default'">{{
SizeFormatter.sizeFormat(clientSettings.totalGB) }}</a-tag>
<a-tag v-else color="purple">
<InfinityIcon />
</a-tag>
</td>
<td>
<a-tag
v-if="clientSettings.expiryTime > 0"
:color="ColorUtils.usageColor(Date.now(), expireDiff, clientSettings.expiryTime)"
>{{ IntlUtil.formatDate(clientSettings.expiryTime) }}</a-tag>
<a-tag v-if="clientSettings.expiryTime > 0"
:color="ColorUtils.usageColor(Date.now(), expireDiff, clientSettings.expiryTime)">{{
IntlUtil.formatDate(clientSettings.expiryTime) }}</a-tag>
<a-tag v-else-if="clientSettings.expiryTime < 0" color="green">
{{ clientSettings.expiryTime / -86400000 }} {{ t('day') }}
</a-tag>
<a-tag v-else color="purple"><InfinityIcon /></a-tag>
<a-tag v-else color="purple">
<InfinityIcon />
</a-tag>
</td>
</tr>
</tbody>
@ -691,7 +683,9 @@ const showSubscriptionTab = computed(
<a-tag color="blue">{{ clientSettings.tgId }}</a-tag>
<a-tooltip :title="t('copy')">
<a-button size="small" @click="copyText(clientSettings.tgId)">
<template #icon><CopyOutlined /></template>
<template #icon>
<CopyOutlined />
</template>
</a-button>
</a-tooltip>
</div>
@ -700,16 +694,14 @@ const showSubscriptionTab = computed(
<!-- Per-client share links (no QR) -->
<template v-if="dbInbound.hasLink() && links.length > 0">
<a-divider>{{ t('pages.inbounds.copyLink') }}</a-divider>
<div
v-for="(link, idx) in links"
:key="idx"
class="link-panel"
>
<div v-for="(link, idx) in links" :key="idx" class="link-panel">
<div class="link-panel-header">
<a-tag color="green">{{ link.remark || `Link ${idx + 1}` }}</a-tag>
<a-tooltip :title="t('copy')">
<a-button size="small" @click="copyText(link.link)">
<template #icon><CopyOutlined /></template>
<template #icon>
<CopyOutlined />
</template>
</a-button>
</a-tooltip>
</div>
@ -727,16 +719,13 @@ const showSubscriptionTab = computed(
<a-tag color="green">{{ t('subscription.title') }}</a-tag>
<a-tooltip :title="t('copy')">
<a-button size="small" @click="copyText(subLink)">
<template #icon><CopyOutlined /></template>
<template #icon>
<CopyOutlined />
</template>
</a-button>
</a-tooltip>
</div>
<a
:href="subLink"
target="_blank"
rel="noopener noreferrer"
class="link-panel-anchor"
>{{ subLink }}</a>
<a :href="subLink" target="_blank" rel="noopener noreferrer" class="link-panel-anchor">{{ subLink }}</a>
</div>
<div v-if="subSettings.subJsonEnable && subJsonLink" class="link-panel">
@ -744,16 +733,14 @@ const showSubscriptionTab = computed(
<a-tag color="green">JSON</a-tag>
<a-tooltip :title="t('copy')">
<a-button size="small" @click="copyText(subJsonLink)">
<template #icon><CopyOutlined /></template>
<template #icon>
<CopyOutlined />
</template>
</a-button>
</a-tooltip>
</div>
<a
:href="subJsonLink"
target="_blank"
rel="noopener noreferrer"
class="link-panel-anchor"
>{{ subJsonLink }}</a>
<a :href="subJsonLink" target="_blank" rel="noopener noreferrer" class="link-panel-anchor">{{ subJsonLink
}}</a>
</div>
</a-tab-pane>
</a-tabs>
@ -766,14 +753,17 @@ const showSubscriptionTab = computed(
width: 100%;
border-collapse: collapse;
}
.info-table.block {
margin-bottom: 10px;
}
.info-table td,
.info-table th {
padding: 4px 8px;
vertical-align: top;
}
.info-table th {
text-align: center;
font-weight: 500;
@ -795,6 +785,7 @@ const showSubscriptionTab = computed(
display: flex;
flex-direction: column;
}
.info-row {
display: grid;
grid-template-columns: 140px minmax(0, 1fr);
@ -803,30 +794,36 @@ const showSubscriptionTab = computed(
padding: 6px 0;
border-bottom: 1px solid rgba(128, 128, 128, 0.12);
}
.info-row:last-child {
border-bottom: none;
}
.info-row dt {
margin: 0;
font-size: 13px;
opacity: 0.75;
}
.info-row dd {
margin: 0;
min-width: 0;
}
.value-tag {
max-width: 100%;
white-space: normal;
word-break: break-all;
display: inline-block;
}
.value-block {
display: flex;
align-items: flex-start;
gap: 6px;
min-width: 0;
}
.value-code {
flex: 1;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
@ -839,9 +836,11 @@ const showSubscriptionTab = computed(
user-select: all;
min-width: 0;
}
:global(body.dark) .value-code {
background: rgba(255, 255, 255, 0.05);
}
.value-copy {
flex-shrink: 0;
}
@ -853,6 +852,7 @@ const showSubscriptionTab = computed(
gap: 6px;
margin: 8px 0;
}
.security-line span {
font-size: 13px;
opacity: 0.75;
@ -875,12 +875,14 @@ const showSubscriptionTab = computed(
overflow-y: auto;
text-align: left;
}
.ip-log-row {
display: block;
margin: 2px 0;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 11px;
}
.ip-log-actions {
display: flex;
gap: 12px;
@ -907,12 +909,14 @@ const showSubscriptionTab = computed(
flex-direction: column;
gap: 6px;
}
.link-panel-header {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.link-panel-text {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 11px;
@ -923,9 +927,11 @@ const showSubscriptionTab = computed(
border-radius: 4px;
user-select: all;
}
:global(body.dark) .link-panel-text {
background: rgba(255, 255, 255, 0.05);
}
.link-panel-anchor {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 11px;
@ -938,13 +944,16 @@ const showSubscriptionTab = computed(
text-decoration-color: rgba(22, 119, 255, 0.4);
transition: background 120ms ease, text-decoration-color 120ms ease;
}
.link-panel-anchor:hover {
background: rgba(22, 119, 255, 0.08);
text-decoration-color: var(--ant-color-primary, #1677ff);
}
:global(body.dark) .link-panel-anchor {
background: rgba(255, 255, 255, 0.05);
}
:global(body.dark) .link-panel-anchor:hover {
background: rgba(22, 119, 255, 0.16);
}

View file

@ -216,12 +216,16 @@ function showQrCodeMenu(dbInbound) {
<template #title>
<a-space direction="horizontal">
<a-button type="primary" @click="emit('add-inbound')">
<template #icon><PlusOutlined /></template>
<template #icon>
<PlusOutlined />
</template>
<template v-if="!isMobile">{{ t('pages.inbounds.addInbound') }}</template>
</a-button>
<a-dropdown :trigger="['click']">
<a-button type="primary">
<template #icon><MenuOutlined /></template>
<template #icon>
<MenuOutlined />
</template>
<template v-if="!isMobile">{{ t('pages.inbounds.generalActions') }}</template>
</a-button>
<template #overlay>
@ -253,7 +257,9 @@ function showQrCodeMenu(dbInbound) {
<template #extra>
<a-button-group>
<a-button :loading="refreshing" @click="emit('refresh')">
<template #icon><SyncOutlined /></template>
<template #icon>
<SyncOutlined />
</template>
</a-button>
<a-popover placement="bottomRight" trigger="click">
<template #title>
@ -265,11 +271,7 @@ function showQrCodeMenu(dbInbound) {
<template #content>
<a-space direction="vertical">
<span>{{ t('pages.inbounds.autoRefreshInterval') }}</span>
<a-select
v-model:value="refreshIntervalMs"
:disabled="!isRefreshEnabled"
:style="{ width: '100%' }"
>
<a-select v-model:value="refreshIntervalMs" :disabled="!isRefreshEnabled" :style="{ width: '100%' }">
<a-select-option v-for="key in [5, 10, 30, 60]" :key="key" :value="key * 1000">
{{ key }}s
</a-select-option>
@ -277,7 +279,9 @@ function showQrCodeMenu(dbInbound) {
</a-space>
</template>
<a-button>
<template #icon><DownOutlined /></template>
<template #icon>
<DownOutlined />
</template>
</a-button>
</a-popover>
</a-button-group>
@ -287,23 +291,17 @@ function showQrCodeMenu(dbInbound) {
<!-- Search / filter toolbar -->
<div :class="isMobile ? 'filter-bar mobile' : 'filter-bar'">
<a-switch v-model:checked="enableFilter" @change="onToggleFilter">
<template #checkedChildren><SearchOutlined /></template>
<template #unCheckedChildren><FilterOutlined /></template>
<template #checkedChildren>
<SearchOutlined />
</template>
<template #unCheckedChildren>
<FilterOutlined />
</template>
</a-switch>
<a-input
v-if="!enableFilter"
v-model:value="searchKey"
:placeholder="t('search')"
autofocus
:size="isMobile ? 'small' : 'middle'"
:style="{ maxWidth: '300px' }"
/>
<a-radio-group
v-if="enableFilter"
v-model:value="filterBy"
button-style="solid"
:size="isMobile ? 'small' : 'middle'"
>
<a-input v-if="!enableFilter" v-model:value="searchKey" :placeholder="t('search')" autofocus
:size="isMobile ? 'small' : 'middle'" :style="{ maxWidth: '300px' }" />
<a-radio-group v-if="enableFilter" v-model:value="filterBy" button-style="solid"
:size="isMobile ? 'small' : 'middle'">
<a-radio-button value="">{{ t('none') }}</a-radio-button>
<a-radio-button value="active">{{ t('subscription.active') }}</a-radio-button>
<a-radio-button value="deactive">{{ t('disabled') }}</a-radio-button>
@ -313,36 +311,21 @@ function showQrCodeMenu(dbInbound) {
</a-radio-group>
</div>
<a-table
:columns="columns"
:data-source="visibleInbounds"
:row-key="(r) => r.id"
:pagination="paginationFor(visibleInbounds)"
:scroll="isMobile ? {} : { x: 1000 }"
:style="{ marginTop: '10px' }"
size="small"
:row-class-name="(r) => (r.isMultiUser() ? '' : 'hide-expand-icon')"
>
<a-table :columns="columns" :data-source="visibleInbounds" :row-key="(r) => r.id"
:pagination="paginationFor(visibleInbounds)" :scroll="isMobile ? {} : { x: 1000 }"
:style="{ marginTop: '10px' }" size="small"
:row-class-name="(r) => (r.isMultiUser() ? '' : 'hide-expand-icon')">
<!-- Per-inbound client list, expanded by clicking the row's
default expand chevron. Hidden via row-class-name for
non-multi-user inbounds (matches legacy behavior). -->
<template #expandedRowRender="{ record }">
<ClientRowTable
v-if="record.isMultiUser()"
:db-inbound="record"
:is-mobile="isMobile"
:traffic-diff="trafficDiff"
:expire-diff="expireDiff"
:online-clients="onlineClients"
:last-online-map="lastOnlineMap"
:is-dark-theme="isDarkTheme"
@edit-client="(p) => emit('edit-client', p)"
@qrcode-client="(p) => emit('qrcode-client', p)"
@info-client="(p) => emit('info-client', p)"
<ClientRowTable v-if="record.isMultiUser()" :db-inbound="record" :is-mobile="isMobile"
:traffic-diff="trafficDiff" :expire-diff="expireDiff" :online-clients="onlineClients"
:last-online-map="lastOnlineMap" :is-dark-theme="isDarkTheme" @edit-client="(p) => emit('edit-client', p)"
@qrcode-client="(p) => emit('qrcode-client', p)" @info-client="(p) => emit('info-client', p)"
@reset-traffic-client="(p) => emit('reset-traffic-client', p)"
@delete-client="(p) => emit('delete-client', p)"
@toggle-enable-client="(p) => emit('toggle-enable-client', p)"
/>
@toggle-enable-client="(p) => emit('toggle-enable-client', p)" />
</template>
<template #bodyCell="{ column, record }">
@ -352,16 +335,28 @@ function showQrCodeMenu(dbInbound) {
<MoreOutlined class="row-action-trigger" @click.prevent />
<template #overlay>
<a-menu @click="(a) => emit('row-action', { key: a.key, dbInbound: record })">
<a-menu-item key="edit"><EditOutlined /> {{ t('edit') }}</a-menu-item>
<a-menu-item key="edit">
<EditOutlined /> {{ t('edit') }}
</a-menu-item>
<a-menu-item v-if="showQrCodeMenu(record)" key="qrcode">
<QrcodeOutlined /> {{ t('qrCode') }}
</a-menu-item>
<template v-if="record.isMultiUser()">
<a-menu-item key="addClient"><UserAddOutlined /> {{ t('pages.client.add') }}</a-menu-item>
<a-menu-item key="addBulkClient"><UsergroupAddOutlined /> {{ t('pages.client.bulk') }}</a-menu-item>
<a-menu-item key="copyClients"><CopyOutlined /> {{ t('pages.client.copyFromInbound') }}</a-menu-item>
<a-menu-item key="resetClients"><FileDoneOutlined /> {{ t('pages.inbounds.resetInboundClientTraffics') }}</a-menu-item>
<a-menu-item key="export"><ExportOutlined /> {{ t('pages.inbounds.export') }}</a-menu-item>
<a-menu-item key="addClient">
<UserAddOutlined /> {{ t('pages.client.add') }}
</a-menu-item>
<a-menu-item key="addBulkClient">
<UsergroupAddOutlined /> {{ t('pages.client.bulk') }}
</a-menu-item>
<a-menu-item key="copyClients">
<CopyOutlined /> {{ t('pages.client.copyFromInbound') }}
</a-menu-item>
<a-menu-item key="resetClients">
<FileDoneOutlined /> {{ t('pages.inbounds.resetInboundClientTraffics') }}
</a-menu-item>
<a-menu-item key="export">
<ExportOutlined /> {{ t('pages.inbounds.export') }}
</a-menu-item>
<a-menu-item v-if="subEnable" key="subs">
<ExportOutlined /> {{ t('pages.inbounds.export') }} {{ t('pages.settings.subSettings') }}
</a-menu-item>
@ -370,11 +365,19 @@ function showQrCodeMenu(dbInbound) {
</a-menu-item>
</template>
<template v-else>
<a-menu-item key="showInfo"><InfoCircleOutlined /> {{ t('info') }}</a-menu-item>
<a-menu-item key="showInfo">
<InfoCircleOutlined /> {{ t('info') }}
</a-menu-item>
</template>
<a-menu-item key="clipboard"><CopyOutlined /> {{ t('pages.inbounds.exportInbound') }}</a-menu-item>
<a-menu-item key="resetTraffic"><RetweetOutlined /> {{ t('pages.inbounds.resetTraffic') }}</a-menu-item>
<a-menu-item key="clone"><BlockOutlined /> {{ t('pages.inbounds.clone') }}</a-menu-item>
<a-menu-item key="clipboard">
<CopyOutlined /> {{ t('pages.inbounds.exportInbound') }}
</a-menu-item>
<a-menu-item key="resetTraffic">
<RetweetOutlined /> {{ t('pages.inbounds.resetTraffic') }}
</a-menu-item>
<a-menu-item key="clone">
<BlockOutlined /> {{ t('pages.inbounds.clone') }}
</a-menu-item>
<a-menu-item key="delete" class="danger-item">
<DeleteOutlined /> {{ t('delete') }}
</a-menu-item>
@ -385,10 +388,7 @@ function showQrCodeMenu(dbInbound) {
<!-- ============== Enable switch (desktop) ============== -->
<template v-else-if="column.key === 'enable'">
<a-switch
:checked="record.enable"
@change="(next) => onSwitchEnable(record, next)"
/>
<a-switch :checked="record.enable" @change="(next) => onSwitchEnable(record, next)" />
</template>
<!-- ============== Protocol tags ============== -->
@ -417,13 +417,15 @@ function showQrCodeMenu(dbInbound) {
<template #content>
<div v-for="email in clientCount[record.id].depleted" :key="email">{{ email }}</div>
</template>
<a-tag color="red" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].depleted.length }}</a-tag>
<a-tag color="red" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].depleted.length
}}</a-tag>
</a-popover>
<a-popover v-if="clientCount[record.id].expiring.length" :title="t('depletingSoon')">
<template #content>
<div v-for="email in clientCount[record.id].expiring" :key="email">{{ email }}</div>
</template>
<a-tag color="orange" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].expiring.length }}</a-tag>
<a-tag color="orange" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].expiring.length
}}</a-tag>
</a-popover>
<a-popover v-if="clientCount[record.id].online.length" :title="t('online')">
<template #content>
@ -468,14 +470,13 @@ function showQrCodeMenu(dbInbound) {
<template v-else-if="column.key === 'expiryTime'">
<a-popover v-if="record.expiryTime > 0">
<template #content>{{ IntlUtil.formatDate(record.expiryTime) }}</template>
<a-tag
:color="ColorUtils.usageColor(Date.now(), expireDiff, record._expiryTime)"
style="min-width: 50px"
>
<a-tag :color="ColorUtils.usageColor(Date.now(), expireDiff, record._expiryTime)" style="min-width: 50px">
{{ IntlUtil.formatRelativeTime(record.expiryTime) }}
</a-tag>
</a-popover>
<a-tag v-else color="purple"><InfinityIcon /></a-tag>
<a-tag v-else color="purple">
<InfinityIcon />
</a-tag>
</template>
<!-- ============== Mobile info popover ============== -->
@ -510,7 +511,9 @@ function showQrCodeMenu(dbInbound) {
<td>{{ t('pages.inbounds.expireDate') }}</td>
<td>
<a-tag v-if="record.expiryTime > 0">{{ IntlUtil.formatRelativeTime(record.expiryTime) }}</a-tag>
<a-tag v-else color="purple"><InfinityIcon /></a-tag>
<a-tag v-else color="purple">
<InfinityIcon />
</a-tag>
</td>
</tr>
</tbody>
@ -537,10 +540,12 @@ function showQrCodeMenu(dbInbound) {
align-items: center;
gap: 8px;
}
.filter-bar.mobile {
display: block;
}
.filter-bar.mobile > * {
.filter-bar.mobile>* {
margin-bottom: 4px;
}
@ -565,4 +570,33 @@ function showQrCodeMenu(dbInbound) {
:deep(.hide-expand-icon .ant-table-row-expand-icon) {
visibility: hidden;
}
/* Round the table's outer corners AD-Vue gives .ant-table the radius
* token, but the inner header strip and footer touch the edges, so clip
* them here. */
:deep(.ant-table) {
border-radius: 8px;
overflow: hidden;
}
:deep(.ant-table-container) {
border-radius: 8px;
overflow: hidden;
}
:deep(.ant-table-thead > tr:first-child > *:first-child) {
border-start-start-radius: 8px;
}
:deep(.ant-table-thead > tr:first-child > *:last-child) {
border-start-end-radius: 8px;
}
:deep(.ant-table-tbody > tr:last-child > *:first-child) {
border-end-start-radius: 8px;
}
:deep(.ant-table-tbody > tr:last-child > *:last-child) {
border-end-end-radius: 8px;
}
</style>

View file

@ -512,10 +512,7 @@ function onRowAction({ key, dbInbound }) {
<template>
<a-config-provider :theme="antdThemeConfig">
<a-layout
class="inbounds-page"
:class="{ 'is-dark': themeState.isDark, 'is-ultra': themeState.isUltra }"
>
<a-layout class="inbounds-page" :class="{ 'is-dark': themeState.isDark, 'is-ultra': themeState.isUltra }">
<AppSidebar :base-path="basePath" :request-uri="requestUri" />
<a-layout class="content-shell">
@ -529,32 +526,34 @@ function onRowAction({ key, dbInbound }) {
<a-card size="small" hoverable class="summary-card">
<a-row :gutter="[16, 12]">
<a-col :sm="12" :md="5">
<CustomStatistic
:title="t('pages.inbounds.totalDownUp')"
:value="`${SizeFormatter.sizeFormat(totals.up)} / ${SizeFormatter.sizeFormat(totals.down)}`"
>
<template #prefix><SwapOutlined /></template>
<CustomStatistic :title="t('pages.inbounds.totalDownUp')"
:value="`${SizeFormatter.sizeFormat(totals.up)} / ${SizeFormatter.sizeFormat(totals.down)}`">
<template #prefix>
<SwapOutlined />
</template>
</CustomStatistic>
</a-col>
<a-col :sm="12" :md="5">
<CustomStatistic
:title="t('pages.inbounds.totalUsage')"
:value="SizeFormatter.sizeFormat(totals.up + totals.down)"
>
<template #prefix><PieChartOutlined /></template>
<CustomStatistic :title="t('pages.inbounds.totalUsage')"
:value="SizeFormatter.sizeFormat(totals.up + totals.down)">
<template #prefix>
<PieChartOutlined />
</template>
</CustomStatistic>
</a-col>
<a-col :sm="12" :md="5">
<CustomStatistic
:title="t('pages.inbounds.allTimeTrafficUsage')"
:value="SizeFormatter.sizeFormat(totals.allTime)"
>
<template #prefix><HistoryOutlined /></template>
<CustomStatistic :title="t('pages.inbounds.allTimeTrafficUsage')"
:value="SizeFormatter.sizeFormat(totals.allTime)">
<template #prefix>
<HistoryOutlined />
</template>
</CustomStatistic>
</a-col>
<a-col :sm="12" :md="5">
<CustomStatistic :title="t('pages.inbounds.inboundCount')" :value="String(dbInbounds.length)">
<template #prefix><BarsOutlined /></template>
<template #prefix>
<BarsOutlined />
</template>
</CustomStatistic>
</a-col>
<a-col :sm="24" :md="4">
@ -576,94 +575,35 @@ function onRowAction({ key, dbInbound }) {
<!-- Inbound list toolbar, search/filter, columns, row actions -->
<a-col :span="24">
<InboundList
:db-inbounds="dbInbounds"
:client-count="clientCount"
:online-clients="onlineClients"
:last-online-map="lastOnlineMap"
:is-dark-theme="themeState.isDark"
:refreshing="refreshing"
:expire-diff="expireDiff"
:traffic-diff="trafficDiff"
:page-size="pageSize"
:is-mobile="isMobile"
:sub-enable="subSettings.enable"
@refresh="refresh"
@add-inbound="onAddInbound"
@general-action="onGeneralAction"
@row-action="onRowAction"
@edit-client="onEditClient"
@qrcode-client="onQrcodeClient"
@info-client="onInfoClient"
@reset-traffic-client="onResetTrafficClient"
@delete-client="onDeleteClient"
@toggle-enable-client="onToggleEnableClient"
/>
<InboundList :db-inbounds="dbInbounds" :client-count="clientCount" :online-clients="onlineClients"
:last-online-map="lastOnlineMap" :is-dark-theme="themeState.isDark" :refreshing="refreshing"
:expire-diff="expireDiff" :traffic-diff="trafficDiff" :page-size="pageSize" :is-mobile="isMobile"
:sub-enable="subSettings.enable" @refresh="refresh" @add-inbound="onAddInbound"
@general-action="onGeneralAction" @row-action="onRowAction" @edit-client="onEditClient"
@qrcode-client="onQrcodeClient" @info-client="onInfoClient"
@reset-traffic-client="onResetTrafficClient" @delete-client="onDeleteClient"
@toggle-enable-client="onToggleEnableClient" />
</a-col>
</a-row>
</a-spin>
</a-layout-content>
</a-layout>
<InboundFormModal
v-model:open="formOpen"
:mode="formMode"
:db-inbound="formDbInbound"
@saved="refresh"
/>
<ClientFormModal
v-model:open="clientOpen"
:mode="clientMode"
:db-inbound="clientDbInbound"
:client-index="clientIndex"
:sub-enable="subSettings.enable"
:tg-bot-enable="tgBotEnable"
:ip-limit-enable="ipLimitEnable"
:traffic-diff="trafficDiff"
@saved="refresh"
/>
<ClientBulkModal
v-model:open="bulkOpen"
:db-inbound="bulkDbInbound"
:sub-enable="subSettings.enable"
:tg-bot-enable="tgBotEnable"
:ip-limit-enable="ipLimitEnable"
@saved="refresh"
/>
<InboundInfoModal
v-model:open="infoOpen"
:db-inbound="infoDbInbound"
:client-index="infoClientIndex"
:remark-model="remarkModel"
:expire-diff="expireDiff"
:traffic-diff="trafficDiff"
:ip-limit-enable="ipLimitEnable"
:tg-bot-enable="tgBotEnable"
:sub-settings="subSettings"
:last-online-map="lastOnlineMap"
/>
<QrCodeModal
v-model:open="qrOpen"
:db-inbound="qrDbInbound"
:client="qrClient"
:remark-model="remarkModel"
/>
<InboundFormModal v-model:open="formOpen" :mode="formMode" :db-inbound="formDbInbound" @saved="refresh" />
<ClientFormModal v-model:open="clientOpen" :mode="clientMode" :db-inbound="clientDbInbound"
:client-index="clientIndex" :sub-enable="subSettings.enable" :tg-bot-enable="tgBotEnable"
:ip-limit-enable="ipLimitEnable" :traffic-diff="trafficDiff" @saved="refresh" />
<ClientBulkModal v-model:open="bulkOpen" :db-inbound="bulkDbInbound" :sub-enable="subSettings.enable"
:tg-bot-enable="tgBotEnable" :ip-limit-enable="ipLimitEnable" @saved="refresh" />
<InboundInfoModal v-model:open="infoOpen" :db-inbound="infoDbInbound" :client-index="infoClientIndex"
:remark-model="remarkModel" :expire-diff="expireDiff" :traffic-diff="trafficDiff"
:ip-limit-enable="ipLimitEnable" :tg-bot-enable="tgBotEnable" :sub-settings="subSettings"
:last-online-map="lastOnlineMap" />
<QrCodeModal v-model:open="qrOpen" :db-inbound="qrDbInbound" :client="qrClient" :remark-model="remarkModel" />
<TextModal
v-model:open="textOpen"
:title="textTitle"
:content="textContent"
:file-name="textFileName"
/>
<PromptModal
v-model:open="promptOpen"
:title="promptTitle"
:ok-text="promptOkText"
:type="promptType"
:initial-value="promptInitial"
:loading="promptLoading"
@confirm="onPromptConfirm"
/>
<TextModal v-model:open="textOpen" :title="textTitle" :content="textContent" :file-name="textFileName" />
<PromptModal v-model:open="promptOpen" :title="promptTitle" :ok-text="promptOkText" :type="promptType"
:initial-value="promptInitial" :loading="promptLoading" @confirm="onPromptConfirm" />
</a-layout>
</a-config-provider>
</template>
@ -692,10 +632,17 @@ function onRowAction({ key, dbInbound }) {
background: transparent;
}
.content-shell { background: transparent; }
.content-area { padding: 24px; }
.content-shell {
background: transparent;
}
.loading-spacer { min-height: calc(100vh - 120px); }
.content-area {
padding: 24px;
}
.loading-spacer {
min-height: calc(100vh - 120px);
}
.summary-card {
padding: 16px;

View file

@ -52,23 +52,11 @@ function close() {
<template>
<a-modal :open="open" :title="t('qrCode')" :footer="null" width="420px" @cancel="close">
<template v-if="dbInbound">
<QrPanel
v-for="(link, idx) in links"
:key="`l${idx}`"
:value="link.link"
:remark="link.remark || `Link ${idx + 1}`"
/>
<QrPanel v-for="(link, idx) in links" :key="`l${idx}`" :value="link.link"
:remark="link.remark || `Link ${idx + 1}`" />
<template v-for="(cfg, idx) in wireguardConfigs" :key="`w${idx}`">
<QrPanel
:value="cfg"
:remark="`Peer ${idx + 1} config`"
:download-name="`peer-${idx + 1}.conf`"
/>
<QrPanel
v-if="wireguardLinks[idx]"
:value="wireguardLinks[idx]"
:remark="`Peer ${idx + 1} link`"
/>
<QrPanel :value="cfg" :remark="`Peer ${idx + 1} config`" :download-name="`peer-${idx + 1}.conf`" />
<QrPanel v-if="wireguardLinks[idx]" :value="wireguardLinks[idx]" :remark="`Peer ${idx + 1} link`" />
</template>
</template>
</a-modal>

View file

@ -65,12 +65,16 @@ function download() {
<a-tag color="green" class="qr-remark">{{ remark }}</a-tag>
<a-tooltip :title="t('copy')">
<a-button size="small" @click="copy">
<template #icon><CopyOutlined /></template>
<template #icon>
<CopyOutlined />
</template>
</a-button>
</a-tooltip>
<a-tooltip v-if="downloadName" :title="t('download')">
<a-button size="small" @click="download">
<template #icon><DownloadOutlined /></template>
<template #icon>
<DownloadOutlined />
</template>
</a-button>
</a-tooltip>
</div>
@ -108,6 +112,7 @@ function download() {
justify-content: center;
padding: 6px 0;
}
.qr-panel-canvas canvas {
cursor: pointer;
background: #fff;