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>. // 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 // Three modes — light / dark / ultra-dark — all share AD-Vue's vanilla
// blue primary. Ultra-dark layers deeper background tokens on top of // blue primary. Dark uses a navy palette across page/cards/modals so
// darkAlgorithm so layouts/cards/popups all darken together. // 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 = { const ULTRA_DARK_TOKENS = {
colorBgBase: '#000', colorBgBase: '#000',
colorBgLayout: '#000', colorBgLayout: '#000',
@ -36,13 +43,45 @@ const ULTRA_DARK_TOKENS = {
colorBgElevated: '#141414', 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(() => { export const antdThemeConfig = computed(() => {
if (!theme.isDark) { if (!theme.isDark) {
return { algorithm: antdTheme.defaultAlgorithm }; return { algorithm: antdTheme.defaultAlgorithm };
} }
return { return {
algorithm: antdTheme.darkAlgorithm, 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> </script>
<template> <template>
<a-modal <a-modal :open="open" :title="t('pages.client.bulk')" :ok-text="t('create')" :cancel-text="t('close')"
:open="open" :confirm-loading="saving" :mask-closable="false" @ok="submit" @cancel="close">
:title="t('pages.client.bulk')" <a-form v-if="inbound" :colon="false" :label-col="{ md: { span: 8 } }" :wrapper-col="{ md: { span: 14 } }">
: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-form-item :label="t('pages.client.method')">
<a-select v-model:value="form.emailMethod"> <a-select v-model:value="form.emailMethod">
<a-select-option :value="0">Random</a-select-option> <a-select-option :value="0">Random</a-select-option>
@ -251,10 +238,7 @@ async function submit() {
</a-form-item> </a-form-item>
<a-form-item :label="t('pages.client.delayedStart')"> <a-form-item :label="t('pages.client.delayedStart')">
<a-switch <a-switch v-model:checked="delayedStart" @click="form.expiryTime = 0" />
v-model:checked="delayedStart"
@click="form.expiryTime = 0"
/>
</a-form-item> </a-form-item>
<a-form-item v-if="delayedStart" :label="t('pages.client.expireDays')"> <a-form-item v-if="delayedStart" :label="t('pages.client.expireDays')">
@ -263,14 +247,11 @@ async function submit() {
<a-form-item v-else> <a-form-item v-else>
<template #label> <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> </template>
<a-date-picker <a-date-picker v-model:value="expiryDate" :show-time="{ format: 'HH:mm:ss' }" format="YYYY-MM-DD HH:mm:ss"
v-model:value="expiryDate" :style="{ width: '100%' }" />
:show-time="{ format: 'HH:mm:ss' }"
format="YYYY-MM-DD HH:mm:ss"
:style="{ width: '100%' }"
/>
</a-form-item> </a-form-item>
<a-form-item v-if="form.expiryTime !== 0"> <a-form-item v-if="form.expiryTime !== 0">

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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