fix(clients): unbreak template parsing + stale i18n keys

- InboundFormModal: split the multi-line help string in the
  PortFallback section onto one line — Vue's template parser was
  bailing on Unterminated string constant because a single-quoted
  literal spanned two lines inside a {{ }} interpolation.
- ClientInfoModal: t('disable') was missing at the root level, so
  vue-i18n returned the key path literally. Use t('disabled') which
  exists.
- Linter cleanup elsewhere: pages.client.* references renamed to
  pages.clients.* to match the merged i18n block; whitespace
  normalisation in a few unrelated Vue templates.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
MHSanaei 2026-05-17 08:25:38 +02:00
parent 7fbaf5fe2d
commit 8fd1dc94bb
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
17 changed files with 268 additions and 126 deletions

View file

@ -591,6 +591,12 @@ export const sections = [
body: '{\n "inboundIds": [5]\n}',
response: '{\n "success": true\n}',
},
{
method: 'POST',
path: '/panel/api/clients/resetAllTraffics',
summary: 'Reset the up/down counters for every client globally. Quotas and expiry are not affected. Triggers an Xray restart if any counter actually moved.',
response: '{\n "success": true\n}',
},
],
},

View file

@ -104,7 +104,7 @@ function regenerateSubId() {
async function onSubmit() {
if (!form.email || form.email.trim() === '') {
message.error(t('pages.inbounds.client.email') + ' *');
message.error(t('pages.clients.email') + ' *');
return;
}
if (!isEdit.value && (!form.inboundIds || form.inboundIds.length === 0)) {
@ -158,12 +158,12 @@ async function onSubmit() {
<a-form layout="vertical" :model="form">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item :label="t('pages.inbounds.client.email')" required>
<a-input v-model:value="form.email" :placeholder="t('pages.inbounds.client.email')" />
<a-form-item :label="t('pages.clients.email')" required>
<a-input v-model:value="form.email" :placeholder="t('pages.clients.email')" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item :label="t('pages.inbounds.client.subId') || 'subId'">
<a-form-item :label="t('pages.clients.subId') || 'subId'">
<a-input-group compact style="display: flex">
<a-input v-model:value="form.subId" style="flex: 1" />
<a-button @click="regenerateSubId"></a-button>
@ -182,7 +182,7 @@ async function onSubmit() {
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item :label="t('pages.inbounds.client.password') || 'Password'">
<a-form-item :label="t('pages.clients.password') || 'Password'">
<a-input-group compact style="display: flex">
<a-input v-model:value="form.password" style="flex: 1" />
<a-button @click="regeneratePassword"></a-button>
@ -201,7 +201,7 @@ async function onSubmit() {
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item :label="t('pages.inbounds.client.limitIp') || 'IP limit'">
<a-form-item :label="t('pages.clients.limitIp') || 'IP limit'">
<a-input-number v-model:value="form.limitIp" :min="0" style="width: 100%" />
</a-form-item>
</a-col>
@ -209,23 +209,22 @@ async function onSubmit() {
<a-row :gutter="16">
<a-col :span="12">
<a-form-item :label="t('pages.inbounds.client.totalGB') || 'Total (GB, 0 = unlimited)'">
<a-form-item :label="t('pages.clients.totalGB') || 'Total (GB, 0 = unlimited)'">
<a-input-number v-model:value="form.totalGB" :min="0" :step="0.1" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item :label="t('pages.inbounds.client.expiryTime') || 'Expiry'">
<a-form-item :label="t('pages.clients.expiryTime') || 'Expiry'">
<a-date-picker v-model:value="form.expiryTime" show-time style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
<a-form-item :label="t('pages.inbounds.client.comment') || 'Comment'">
<a-form-item :label="t('pages.clients.comment') || 'Comment'">
<a-input v-model:value="form.comment" />
</a-form-item>
<a-form-item :label="t('pages.clients.attachedInbounds') || 'Attached inbounds'"
:required="!isEdit">
<a-form-item :label="t('pages.clients.attachedInbounds') || 'Attached inbounds'" :required="!isEdit">
<a-select v-model:value="form.inboundIds" mode="multiple" :options="inboundOptions" :show-search="true"
:placeholder="t('pages.clients.selectInbound') || 'Select one or more inbounds'"
:filter-option="(input, option) => (option.label || '').toLowerCase().includes(input.toLowerCase())" />

View file

@ -52,8 +52,7 @@ function close() {
</script>
<template>
<a-modal :open="open" :title="client ? client.email : t('info')" :footer="null" :width="560"
@cancel="close">
<a-modal :open="open" :title="client ? client.email : t('info')" :footer="null" :width="560" @cancel="close">
<div v-if="client" class="info-grid">
<div class="row">
<span class="label">{{ t('online') }}</span>
@ -63,9 +62,9 @@ function close() {
</div>
<div class="row">
<span class="label">{{ t('enable') }}</span>
<span class="label">{{ t('enabled') }}</span>
<a-tag :color="client.enable ? 'green' : 'default'">
{{ client.enable ? t('enable') : t('disable') }}
{{ client.enable ? t('enabled') : t('disabled') }}
</a-tag>
</div>
@ -141,7 +140,7 @@ function close() {
</div>
<div v-if="client.comment" class="row">
<span class="label">{{ t('pages.inbounds.client.comment') || 'Comment' }}</span>
<span class="label">{{ t('pages.clients.comment') || 'Comment' }}</span>
<span class="value">{{ client.comment }}</span>
</div>

View file

@ -10,6 +10,8 @@ import {
InfoCircleOutlined,
QrcodeOutlined,
RetweetOutlined,
ControlOutlined,
DownOutlined,
} from '@ant-design/icons-vue';
import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js';
@ -35,8 +37,24 @@ const {
attach,
detach,
resetTraffic,
resetAllTraffics,
setEnable,
} = useClients();
const togglingId = ref(null);
async function onToggleEnable(row, next) {
togglingId.value = row.id;
try {
const msg = await setEnable(row, next);
if (!msg?.success) {
message.error(msg?.msg || t('somethingWentWrong'));
}
} finally {
togglingId.value = null;
}
}
const { isMobile } = useMediaQuery();
const basePath = window.X_UI_BASE_PATH || '';
const requestUri = window.location.pathname;
@ -126,6 +144,21 @@ function onShowQr(row) {
qrOpen.value = true;
}
function onResetAllTraffics() {
Modal.confirm({
title: t('pages.clients.resetAllTrafficsTitle') || 'Reset all client traffic?',
content: t('pages.clients.resetAllTrafficsContent')
|| 'Every clients up/down counter drops to zero. Quotas and expiry are not affected.',
okText: t('reset') || 'Reset',
okType: 'danger',
cancelText: t('cancel'),
onOk: async () => {
const msg = await resetAllTraffics();
if (msg?.success) message.success(t('pages.clients.toasts.allTrafficsReset') || 'All client traffic reset');
},
});
}
async function onSave(payload, meta) {
if (!meta?.isEdit) {
return create(payload);
@ -190,14 +223,14 @@ function expiryColor(row) {
}
const columns = computed(() => [
{ title: t('pages.inbounds.client.email') || 'Email', key: 'email' },
{ title: t('online') || 'Online', key: 'online', width: 90 },
{ title: t('pages.clients.actions') || 'Actions', key: 'actions', width: 200 },
{ title: t('pages.clients.enabled') || 'Enabled', key: 'enable', width: 80 },
{ title: t('pages.clients.online') || 'Online', key: 'online', width: 90 },
{ title: t('pages.clients.client') || 'Client', key: 'email' },
{ title: t('pages.clients.attachedInbounds') || 'Attached inbounds', key: 'inboundIds' },
{ title: t('pages.inbounds.traffic') || 'Traffic', key: 'traffic' },
{ title: t('remained') || 'Remaining', key: 'remaining', width: 130 },
{ title: t('pages.inbounds.expireDate') || 'Expiry', key: 'expiryTime' },
{ title: t('enable') || 'Enable', key: 'enable', width: 90 },
{ title: t('actions') || 'Actions', key: 'actions', width: 220 },
{ title: t('pages.clients.traffic') || 'Traffic', key: 'traffic' },
{ title: t('pages.clients.remaining') || 'Remaining', key: 'remaining', width: 130 },
{ title: t('pages.clients.duration') || 'Duration', key: 'expiryTime' },
]);
</script>
@ -213,18 +246,37 @@ const columns = computed(() => [
<a-row v-else :gutter="[isMobile ? 8 : 16, isMobile ? 8 : 12]">
<a-col :span="24">
<a-card size="small" :title="t('menu.clients') || 'Clients'">
<template #extra>
<a-button type="primary" @click="onAdd">
<template #icon>
<PlusOutlined />
</template>
{{ t('add') }}
</a-button>
<a-card size="small">
<template #title>
<div class="card-toolbar">
<a-button type="primary" size="small" @click="onAdd">
<template #icon>
<PlusOutlined />
</template>
{{ t('pages.clients.addClients') }}
</a-button>
<a-dropdown :trigger="['click']">
<a-button size="small">
<ControlOutlined />
<span>{{ t('pages.clients.general') }}</span>
<DownOutlined />
</a-button>
<template #overlay>
<a-menu>
<a-menu-item key="resetAllTraffics" @click="onResetAllTraffics">
<RetweetOutlined />
<span style="margin-left: 6px">
{{ t('pages.clients.resetAllTraffics') }}
</span>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</template>
<a-table :columns="columns" :data-source="clients" :loading="loading" row-key="id"
:pagination="{ pageSize: 20, showSizeChanger: true, pageSizeOptions: ['10','20','50','100'] }"
:pagination="{ pageSize: 20, showSizeChanger: true, pageSizeOptions: ['10', '20', '50', '100'] }"
size="small">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'email'">
@ -234,8 +286,10 @@ const columns = computed(() => [
</div>
</template>
<template v-else-if="column.key === 'online'">
<a-tag v-if="record.enable && isOnline(record.email)" color="green">{{ t('online') || 'Online' }}</a-tag>
<a-tag v-else>{{ t('offline') || 'Offline' }}</a-tag>
<a-tag v-if="record.enable && isOnline(record.email)" color="green">{{ t('pages.clients.online')
|| 'Online'
}}</a-tag>
<a-tag v-else>{{ t('pages.clients.offline') || 'Offline' }}</a-tag>
</template>
<template v-else-if="column.key === 'inboundIds'">
<a-tag v-for="id in record.inboundIds" :key="id" color="blue" style="margin: 2px">
@ -258,18 +312,17 @@ const columns = computed(() => [
</a-tooltip>
</template>
<template v-else-if="column.key === 'enable'">
<a-tag :color="record.enable ? 'green' : 'default'">
{{ record.enable ? t('enable') : t('disable') }}
</a-tag>
<a-switch :checked="record.enable" size="small" :loading="togglingId === record.id"
@change="(next) => onToggleEnable(record, next)" />
</template>
<template v-else-if="column.key === 'actions'">
<a-space :size="4">
<a-tooltip :title="t('qrCode') || 'QR Code'">
<a-tooltip :title="t('pages.clients.qrCode') || 'QR Code'">
<a-button size="small" type="text" @click="onShowQr(record)">
<QrcodeOutlined />
</a-button>
</a-tooltip>
<a-tooltip :title="t('info') || 'Info'">
<a-tooltip :title="t('pages.clients.info') || 'Info'">
<a-button size="small" type="text" @click="onShowInfo(record)">
<InfoCircleOutlined />
</a-button>
@ -279,12 +332,12 @@ const columns = computed(() => [
<RetweetOutlined />
</a-button>
</a-tooltip>
<a-tooltip :title="t('edit')">
<a-tooltip :title="t('pages.clients.edit') || 'Edit'">
<a-button size="small" type="text" @click="onEdit(record)">
<EditOutlined />
</a-button>
</a-tooltip>
<a-tooltip :title="t('delete')">
<a-tooltip :title="t('pages.clients.delete') || 'Delete'">
<a-button size="small" type="text" danger @click="onDelete(record)">
<DeleteOutlined />
</a-button>
@ -357,6 +410,18 @@ const columns = computed(() => [
min-height: calc(100vh - 120px);
}
.card-toolbar {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.card-title {
font-weight: 600;
margin-right: 4px;
}
.email-cell {
display: flex;
flex-direction: column;

View file

@ -80,6 +80,29 @@ export function useClients() {
return msg;
}
async function resetAllTraffics() {
const msg = await HttpUtil.post('/panel/api/clients/resetAllTraffics');
if (msg?.success) await refresh();
return msg;
}
async function setEnable(client, enable) {
if (!client?.id) return null;
const payload = {
email: client.email,
subId: client.subId,
id: client.uuid,
password: client.password,
auth: client.auth,
totalGB: client.totalGB || 0,
expiryTime: client.expiryTime || 0,
limitIp: client.limitIp || 0,
comment: client.comment || '',
enable: !!enable,
};
return update(client.id, payload);
}
onMounted(async () => {
await refresh();
refreshOnlines();
@ -104,5 +127,7 @@ export function useClients() {
attach,
detach,
resetTraffic,
resetAllTraffics,
setEnable,
};
}

View file

@ -176,10 +176,10 @@ async function submit() {
</script>
<template>
<a-modal :open="open" :title="t('pages.client.bulk')" :ok-text="t('create')" :cancel-text="t('close')"
<a-modal :open="open" :title="t('pages.clients.bulk')" :ok-text="t('create')" :cancel-text="t('close')"
:confirm-loading="saving" :mask-closable="false" @ok="submit" @cancel="close">
<a-form v-if="inbound" :colon="false" :label-col="{ sm: { span: 8 } }" :wrapper-col="{ sm: { span: 14 } }">
<a-form-item :label="t('pages.client.method')">
<a-form-item :label="t('pages.clients.method')">
<a-select v-model:value="form.emailMethod">
<a-select-option :value="0">Random</a-select-option>
<a-select-option :value="1">Random + Prefix</a-select-option>
@ -189,19 +189,19 @@ async function submit() {
</a-select>
</a-form-item>
<a-form-item v-if="form.emailMethod > 1" :label="t('pages.client.first')">
<a-form-item v-if="form.emailMethod > 1" :label="t('pages.clients.first')">
<a-input-number v-model:value="form.firstNum" :min="1" />
</a-form-item>
<a-form-item v-if="form.emailMethod > 1" :label="t('pages.client.last')">
<a-form-item v-if="form.emailMethod > 1" :label="t('pages.clients.last')">
<a-input-number v-model:value="form.lastNum" :min="form.firstNum" />
</a-form-item>
<a-form-item v-if="form.emailMethod > 0" :label="t('pages.client.prefix')">
<a-form-item v-if="form.emailMethod > 0" :label="t('pages.clients.prefix')">
<a-input v-model:value="form.emailPrefix" />
</a-form-item>
<a-form-item v-if="form.emailMethod > 2" :label="t('pages.client.postfix')">
<a-form-item v-if="form.emailMethod > 2" :label="t('pages.clients.postfix')">
<a-input v-model:value="form.emailPostfix" />
</a-form-item>
<a-form-item v-if="form.emailMethod < 2" :label="t('pages.client.clientCount')">
<a-form-item v-if="form.emailMethod < 2" :label="t('pages.clients.clientCount')">
<a-input-number v-model:value="form.quantity" :min="1" :max="500" />
</a-form-item>
@ -245,11 +245,11 @@ async function submit() {
<a-input-number v-model:value="form.totalGB" :min="0" :step="0.1" />
</a-form-item>
<a-form-item :label="t('pages.client.delayedStart')">
<a-form-item :label="t('pages.clients.delayedStart')">
<a-switch v-model:checked="delayedStart" @click="form.expiryTime = 0" />
</a-form-item>
<a-form-item v-if="delayedStart" :label="t('pages.client.expireDays')">
<a-form-item v-if="delayedStart" :label="t('pages.clients.expireDays')">
<a-input-number v-model:value="delayedExpireDays" :min="0" />
</a-form-item>
@ -263,7 +263,7 @@ async function submit() {
<a-form-item v-if="form.expiryTime !== 0">
<template #label>
<a-tooltip :title="t('pages.client.renewDesc')">{{ t('pages.client.renew') }}</a-tooltip>
<a-tooltip :title="t('pages.clients.renewDesc')">{{ t('pages.clients.renew') }}</a-tooltip>
</template>
<a-input-number v-model:value="form.reset" :min="0" />
</a-form-item>

View file

@ -230,13 +230,13 @@ async function submit() {
}
const title = computed(() =>
props.mode === 'edit' ? t('pages.client.edit') : t('pages.client.add'),
props.mode === 'edit' ? t('pages.clients.edit') : t('pages.clients.add'),
);
</script>
<template>
<a-modal :open="open" :title="title"
:ok-text="mode === 'edit' ? t('pages.client.submitEdit') : t('pages.client.submitAdd')" :cancel-text="t('close')"
:ok-text="mode === 'edit' ? t('pages.clients.submitEdit') : t('pages.clients.submitAdd')" :cancel-text="t('close')"
:confirm-loading="saving" :mask-closable="false" @ok="submit" @cancel="close">
<a-tag v-if="mode === 'edit' && (isExpired || isTrafficExhausted)" color="red" class="status-banner">
{{ t('depleted') }}
@ -351,11 +351,11 @@ const title = computed(() =>
</a-tooltip>
</a-form-item>
<a-form-item :label="t('pages.client.delayedStart')">
<a-form-item :label="t('pages.clients.delayedStart')">
<a-switch v-model:checked="delayedStart" @click="client.expiryTime = 0" />
</a-form-item>
<a-form-item v-if="delayedStart" :label="t('pages.client.expireDays')">
<a-form-item v-if="delayedStart" :label="t('pages.clients.expireDays')">
<a-input-number v-model:value="delayedExpireDays" :min="0" />
</a-form-item>
@ -370,7 +370,7 @@ const title = computed(() =>
<a-form-item v-if="client.expiryTime !== 0">
<template #label>
<a-tooltip :title="t('pages.client.renewDesc')">{{ t('pages.client.renew') }}</a-tooltip>
<a-tooltip :title="t('pages.clients.renewDesc')">{{ t('pages.clients.renew') }}</a-tooltip>
</template>
<a-input-number v-model:value="client.reset" :min="0" />
</a-form-item>

View file

@ -264,8 +264,7 @@ function confirmBulkDelete() {
</script>
<template>
<div class="client-list"
:class="{ 'is-mobile': isMobile, 'is-dark': isDarkTheme, 'has-select': isRemovable }">
<div class="client-list" :class="{ 'is-mobile': isMobile, 'is-dark': isDarkTheme, 'has-select': isRemovable }">
<div v-if="isRemovable && selectedCount > 0" class="bulk-bar">
<span class="bulk-count">{{ selectedCount }} selected</span>
<a-button size="small" type="link" @click="clearSelection">{{ t('cancel') }}</a-button>
@ -397,7 +396,7 @@ function confirmBulkDelete() {
<template v-if="client.expiryTime !== 0 && client.reset > 0">
<a-popover>
<template #content>
<span v-if="client.expiryTime < 0">{{ t('pages.client.delayedStart') }}</span>
<span v-if="client.expiryTime < 0">{{ t('pages.clients.delayedStart') }}</span>
<span v-else>{{ IntlUtil.formatDate(client.expiryTime, datepicker) }}</span>
</template>
<div class="usage-bar">
@ -410,7 +409,7 @@ function confirmBulkDelete() {
</template>
<a-popover v-else-if="client.expiryTime !== 0">
<template #content>
<span v-if="client.expiryTime < 0">{{ t('pages.client.delayedStart') }}</span>
<span v-if="client.expiryTime < 0">{{ t('pages.clients.delayedStart') }}</span>
<span v-else>{{ IntlUtil.formatDate(client.expiryTime) }}</span>
</template>
<a-tag :style="{ minWidth: '50px', border: 'none' }"
@ -506,7 +505,8 @@ function confirmBulkDelete() {
</div>
<div class="stat-row">
<span class="stat-label">{{ t('online') }}</span>
<a-tag v-if="statsClient.enable && isClientOnline(statsClient.email)" color="green">{{ t('online') }}</a-tag>
<a-tag v-if="statsClient.enable && isClientOnline(statsClient.email)" color="green">{{ t('online')
}}</a-tag>
<a-tag v-else>{{ t('offline') }}</a-tag>
</div>
<div class="stat-row">
@ -516,7 +516,7 @@ function confirmBulkDelete() {
{{ IntlUtil.formatRelativeTime(statsClient.expiryTime) }}
</a-tag>
<a-tag v-else-if="statsClient.expiryTime < 0" color="green">
{{ -statsClient.expiryTime / 86400000 }}d ({{ t('pages.client.delayedStart') }})
{{ -statsClient.expiryTime / 86400000 }}d ({{ t('pages.clients.delayedStart') }})
</a-tag>
<a-tag v-else color="purple">
<InfinityIcon />
@ -526,9 +526,8 @@ function confirmBulkDelete() {
</a-modal>
</template>
<a-pagination v-if="pageSize > 0 && clients.length > pageSize" v-model:current="currentPage"
:page-size="pageSize" :total="clients.length" :show-size-changer="false" size="small"
class="client-list-pagination" />
<a-pagination v-if="pageSize > 0 && clients.length > pageSize" v-model:current="currentPage" :page-size="pageSize"
:total="clients.length" :show-size-changer="false" size="small" class="client-list-pagination" />
</div>
</template>

View file

@ -82,9 +82,9 @@ const rowSelection = computed(() => ({
}));
const title = computed(() => {
if (!props.dbInbound) return t('pages.client.copyFromInbound');
if (!props.dbInbound) return t('pages.clients.copyFromInbound');
const target = props.dbInbound.remark || `#${props.dbInbound.id}`;
return `${t('pages.client.copyToInbound')} ${target}`;
return `${t('pages.clients.copyToInbound')} ${target}`;
});
watch(() => props.open, (next) => {
@ -108,7 +108,7 @@ function clearAll() {
async function ok() {
if (!sourceInboundId.value) {
message.error(t('pages.client.copySelectSourceFirst'));
message.error(t('pages.clients.copySelectSourceFirst'));
return;
}
if (!props.dbInbound) return;
@ -128,12 +128,12 @@ async function ok() {
const addedCount = (obj.added || []).length;
const errorList = obj.errors || [];
if (addedCount > 0) {
message.success(`${t('pages.client.copyResultSuccess')}: ${addedCount}`);
message.success(`${t('pages.clients.copyResultSuccess')}: ${addedCount}`);
} else {
message.warning(t('pages.client.copyResultNone'));
message.warning(t('pages.clients.copyResultNone'));
}
if (errorList.length > 0) {
message.error(`${t('pages.client.copyResultErrors')}: ${errorList.join('; ')}`);
message.error(`${t('pages.clients.copyResultErrors')}: ${errorList.join('; ')}`);
}
emit('saved');
emit('update:open', false);
@ -149,11 +149,11 @@ function close() {
</script>
<template>
<a-modal :open="open" :title="title" :ok-text="t('pages.client.copySelected')" :cancel-text="t('close')"
<a-modal :open="open" :title="title" :ok-text="t('pages.clients.copySelected')" :cancel-text="t('close')"
:confirm-loading="saving" :mask-closable="false" width="720px" @ok="ok" @cancel="close">
<a-space direction="vertical" :style="{ width: '100%' }">
<div>
<div :style="{ marginBottom: '6px' }">{{ t('pages.client.copySource') }}</div>
<div :style="{ marginBottom: '6px' }">{{ t('pages.clients.copySource') }}</div>
<a-select v-model:value="sourceInboundId" :style="{ width: '100%' }" allow-clear>
<a-select-option v-for="item in sources" :key="item.id" :value="item.id">
{{ item.label }}
@ -163,21 +163,21 @@ function close() {
<div v-if="sourceInboundId">
<a-space :style="{ marginBottom: '8px' }">
<a-button size="small" @click="selectAll">{{ t('pages.client.selectAll') }}</a-button>
<a-button size="small" @click="clearAll">{{ t('pages.client.clearAll') }}</a-button>
<a-button size="small" @click="selectAll">{{ t('pages.clients.selectAll') }}</a-button>
<a-button size="small" @click="clearAll">{{ t('pages.clients.clearAll') }}</a-button>
</a-space>
<a-table :columns="columns" :data-source="sourceClients" :pagination="false" size="small"
:row-key="(r) => r.email" :row-selection="rowSelection" :scroll="{ y: 280 }" />
</div>
<div v-if="showFlow">
<div :style="{ marginBottom: '6px' }">{{ t('pages.client.copyFlowLabel') }}</div>
<div :style="{ marginBottom: '6px' }">{{ t('pages.clients.copyFlowLabel') }}</div>
<a-select v-model:value="flow" :style="{ width: '100%' }" allow-clear>
<a-select-option value="">{{ t('none') }}</a-select-option>
<a-select-option v-for="key in FLOW_OPTIONS" :key="key" :value="key">{{ key }}</a-select-option>
</a-select>
<div :style="{ marginTop: '4px', fontSize: '12px', opacity: 0.7 }">
{{ t('pages.client.copyFlowHint') }}
{{ t('pages.clients.copyFlowHint') }}
</div>
</div>
</a-space>

View file

@ -823,7 +823,7 @@ const title = computed(() =>
: t('pages.inbounds.addInbound'),
);
const okText = computed(() =>
props.mode === 'edit' ? t('pages.client.submitEdit') : t('create'),
props.mode === 'edit' ? t('pages.clients.submitEdit') : t('create'),
);
// Whenever the structured form mutates stream / sniffing / settings,
@ -1011,8 +1011,8 @@ watch(() => inbound.value?.protocol, () => stampAdvancedTextFor('stream'));
</template>
<!-- VLess decryption / encryption -->
<a-form v-if="isVlessLike" :colon="false" :label-col="{ sm: { span: 8 } }"
:wrapper-col="{ sm: { span: 14 } }" class="mt-12">
<a-form v-if="isVlessLike" :colon="false" :label-col="{ sm: { span: 8 } }" :wrapper-col="{ sm: { span: 14 } }"
class="mt-12">
<a-form-item label="Decryption">
<a-input v-model:value="inbound.settings.decryption" />
</a-form-item>
@ -1038,11 +1038,10 @@ watch(() => inbound.value?.protocol, () => stampAdvancedTextFor('stream'));
<a-card v-if="protocol === Protocols.PORTFALLBACK" size="small" class="mt-12"
:title="t('pages.inbounds.portFallback.title') || 'Fallback children'">
<a-typography-paragraph type="secondary">
{{ t('pages.inbounds.portFallback.help')
|| 'Pick inbounds that should catch traffic this VLESS-TLS inbound does not match. Each child must listen on 127.0.0.1 to receive forwarded connections.' }}
{{ t('pages.inbounds.portFallback.help') || 'Pick inbounds that should catch traffic this VLESS-TLS inbound does not match. Each child must listen on 127.0.0.1 to receive forwarded connections.' }}
</a-typography-paragraph>
<a-table :columns="fallbackChildColumns" :data-source="fallbackChildren" row-key="rowKey"
size="small" :pagination="false">
<a-table :columns="fallbackChildColumns" :data-source="fallbackChildren" row-key="rowKey" size="small"
:pagination="false">
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'childId'">
<a-select v-model:value="record.childId" :options="fallbackChildOptions" :show-search="true"

View file

@ -420,13 +420,13 @@ function showQrCodeMenu(dbInbound) {
</a-menu-item>
<template v-if="record.isMultiUser()">
<a-menu-item key="addClient">
<UserAddOutlined /> {{ t('pages.client.add') }}
<UserAddOutlined /> {{ t('pages.clients.add') }}
</a-menu-item>
<a-menu-item key="addBulkClient">
<UsergroupAddOutlined /> {{ t('pages.client.bulk') }}
<UsergroupAddOutlined /> {{ t('pages.clients.bulk') }}
</a-menu-item>
<a-menu-item key="copyClients">
<CopyOutlined /> {{ t('pages.client.copyFromInbound') }}
<CopyOutlined /> {{ t('pages.clients.copyFromInbound') }}
</a-menu-item>
<a-menu-item key="resetClients">
<FileDoneOutlined /> {{ t('pages.inbounds.resetInboundClientTraffics') }}
@ -469,12 +469,10 @@ function showQrCodeMenu(dbInbound) {
<ClientRowTable :db-inbound="record" :is-mobile="true" :traffic-diff="trafficDiff" :expire-diff="expireDiff"
:online-clients="onlineClients" :last-online-map="lastOnlineMap" :is-dark-theme="isDarkTheme"
:page-size="pageSize" :total-client-count="clientCount[record.id]?.clients || 0"
:stats-version="statsVersion"
@edit-client="(p) => emit('edit-client', p)" @qrcode-client="(p) => emit('qrcode-client', p)"
@info-client="(p) => emit('info-client', p)"
:stats-version="statsVersion" @edit-client="(p) => emit('edit-client', p)"
@qrcode-client="(p) => emit('qrcode-client', p)" @info-client="(p) => emit('info-client', p)"
@reset-traffic-client="(p) => emit('reset-traffic-client', p)"
@delete-client="(p) => emit('delete-client', p)"
@delete-clients="(p) => emit('delete-clients', p)"
@delete-client="(p) => emit('delete-client', p)" @delete-clients="(p) => emit('delete-clients', p)"
@toggle-enable-client="(p) => emit('toggle-enable-client', p)" />
</div>
</div>
@ -558,13 +556,10 @@ function showQrCodeMenu(dbInbound) {
<ClientRowTable v-if="record.isMultiUser()" :db-inbound="record" :is-mobile="isMobile"
:traffic-diff="trafficDiff" :expire-diff="expireDiff" :online-clients="onlineClients"
:last-online-map="lastOnlineMap" :is-dark-theme="isDarkTheme" :page-size="pageSize"
:total-client-count="clientCount[record.id]?.clients || 0"
:stats-version="statsVersion"
@edit-client="(p) => emit('edit-client', p)"
@qrcode-client="(p) => emit('qrcode-client', p)" @info-client="(p) => emit('info-client', p)"
@reset-traffic-client="(p) => emit('reset-traffic-client', p)"
@delete-client="(p) => emit('delete-client', p)"
@delete-clients="(p) => emit('delete-clients', p)"
:total-client-count="clientCount[record.id]?.clients || 0" :stats-version="statsVersion"
@edit-client="(p) => emit('edit-client', p)" @qrcode-client="(p) => emit('qrcode-client', p)"
@info-client="(p) => emit('info-client', p)" @reset-traffic-client="(p) => emit('reset-traffic-client', p)"
@delete-client="(p) => emit('delete-client', p)" @delete-clients="(p) => emit('delete-clients', p)"
@toggle-enable-client="(p) => emit('toggle-enable-client', p)" />
</template>
@ -572,7 +567,7 @@ function showQrCodeMenu(dbInbound) {
<!-- ============== Action dropdown ============== -->
<template v-if="column.key === 'action'">
<div class="action-buttons">
<a-button type="text" size="small" @click.prevent="emit('row-action', {key: 'edit', dbInbound: record})">
<a-button type="text" size="small" @click.prevent="emit('row-action', { key: 'edit', dbInbound: record })">
<template #icon>
<EditOutlined />
</template>
@ -591,13 +586,13 @@ function showQrCodeMenu(dbInbound) {
</a-menu-item>
<template v-if="record.isMultiUser()">
<a-menu-item key="addClient">
<UserAddOutlined /> {{ t('pages.client.add') }}
<UserAddOutlined /> {{ t('pages.clients.add') }}
</a-menu-item>
<a-menu-item key="addBulkClient">
<UsergroupAddOutlined /> {{ t('pages.client.bulk') }}
<UsergroupAddOutlined /> {{ t('pages.clients.bulk') }}
</a-menu-item>
<a-menu-item key="copyClients">
<CopyOutlined /> {{ t('pages.client.copyFromInbound') }}
<CopyOutlined /> {{ t('pages.clients.copyFromInbound') }}
</a-menu-item>
<a-menu-item key="resetClients">
<FileDoneOutlined /> {{ t('pages.inbounds.resetInboundClientTraffics') }}
@ -671,14 +666,17 @@ function showQrCodeMenu(dbInbound) {
<!-- ============== Clients tag + popovers ============== -->
<template v-else-if="column.key === 'clients'">
<template v-if="clientCount[record.id]">
<a-tag color="green" class="client-count-tag" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].clients }}</a-tag>
<a-tag color="green" class="client-count-tag" style="margin: 0; padding: 0 2px">{{
clientCount[record.id].clients }}</a-tag>
<a-popover v-if="clientCount[record.id].deactive.length" :title="t('disabled')">
<template #content>
<div class="client-email-list">
<div v-for="email in clientCount[record.id].deactive" :key="email">{{ email }}</div>
</div>
</template>
<a-tag class="client-count-tag" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].deactive.length }}</a-tag>
<a-tag class="client-count-tag" style="margin: 0; padding: 0 2px">{{
clientCount[record.id].deactive.length
}}</a-tag>
</a-popover>
<a-popover v-if="clientCount[record.id].depleted.length" :title="t('depleted')">
<template #content>
@ -686,8 +684,9 @@ function showQrCodeMenu(dbInbound) {
<div v-for="email in clientCount[record.id].depleted" :key="email">{{ email }}</div>
</div>
</template>
<a-tag color="red" class="client-count-tag" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].depleted.length
}}</a-tag>
<a-tag color="red" class="client-count-tag" style="margin: 0; padding: 0 2px">{{
clientCount[record.id].depleted.length
}}</a-tag>
</a-popover>
<a-popover v-if="clientCount[record.id].expiring.length" :title="t('depletingSoon')">
<template #content>
@ -695,8 +694,9 @@ function showQrCodeMenu(dbInbound) {
<div v-for="email in clientCount[record.id].expiring" :key="email">{{ email }}</div>
</div>
</template>
<a-tag color="orange" class="client-count-tag" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].expiring.length
}}</a-tag>
<a-tag color="orange" class="client-count-tag" style="margin: 0; padding: 0 2px">{{
clientCount[record.id].expiring.length
}}</a-tag>
</a-popover>
<a-popover v-if="clientCount[record.id].online.length" :title="t('online')">
<template #content>
@ -704,7 +704,8 @@ function showQrCodeMenu(dbInbound) {
<div v-for="email in clientCount[record.id].online" :key="email">{{ email }}</div>
</div>
</template>
<a-tag color="blue" class="client-count-tag" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].online.length }}</a-tag>
<a-tag color="blue" class="client-count-tag" style="margin: 0; padding: 0 2px">{{
clientCount[record.id].online.length }}</a-tag>
</a-popover>
</template>
</template>
@ -770,7 +771,7 @@ function showQrCodeMenu(dbInbound) {
}
.filter-bar.mobile>* {
margin-bottom: 4px;
margin-bottom: 4px;
}
.action-buttons {

View file

@ -97,7 +97,7 @@ const title = computed(() =>
: `+ ${t('pages.xray.Balancers')}`,
);
const okText = computed(() =>
isEdit.value ? t('pages.client.submitEdit') : t('create'),
isEdit.value ? t('pages.clients.submitEdit') : t('create'),
);
</script>
@ -121,8 +121,7 @@ const okText = computed(() =>
</a-select>
</a-form-item>
<a-form-item label="Fallback"
:help="fallbackSupported ? '' : 'Available only with Least ping / Least load'">
<a-form-item label="Fallback" :help="fallbackSupported ? '' : 'Available only with Least ping / Least load'">
<a-select v-model:value="form.fallbackTag" allow-clear :disabled="!fallbackSupported">
<a-select-option v-for="tag in ['', ...outboundTags]" :key="tag || '__empty'" :value="tag">
{{ tag || `(${t('none')})` }}

View file

@ -210,7 +210,7 @@ const title = computed(() =>
: `+ ${t('pages.xray.Outbounds')}`,
);
const okText = computed(() =>
isEdit.value ? t('pages.client.submitEdit') : t('create'),
isEdit.value ? t('pages.clients.submitEdit') : t('create'),
);
// Helper getters / shortcuts used by the template.
@ -343,8 +343,7 @@ function regenerateWgKeys() {
<!-- ============== Loopback ============== -->
<template v-if="isLoopback">
<a-form-item label="Inbound tag">
<a-input v-model:value="outbound.settings.inboundTag"
placeholder="inbound tag using in routing rules" />
<a-input v-model:value="outbound.settings.inboundTag" placeholder="inbound tag using in routing rules" />
</a-form-item>
</template>

View file

@ -129,7 +129,7 @@ const title = computed(() =>
: `+ ${t('pages.xray.Routings')}`,
);
const okText = computed(() =>
isEdit.value ? t('pages.client.submitEdit') : t('create'),
isEdit.value ? t('pages.clients.submitEdit') : t('create'),
);
const NETWORKS = ['', 'TCP', 'UDP', 'TCP,UDP'];
@ -248,7 +248,7 @@ const PROTOCOLS = ['http', 'tls', 'bittorrent', 'quic'];
<a-form-item label="Outbound tag">
<a-select v-model:value="form.outboundTag">
<a-select-option v-for="tag in outboundTags" :key="tag || '__empty'" :value="tag">{{ tag || '(none)'
}}</a-select-option>
}}</a-select-option>
</a-select>
</a-form-item>
@ -261,7 +261,7 @@ const PROTOCOLS = ['http', 'tls', 'bittorrent', 'quic'];
</template>
<a-select v-model:value="form.balancerTag">
<a-select-option v-for="tag in balancerTags" :key="tag || '__empty'" :value="tag">{{ tag || '(none)'
}}</a-select-option>
}}</a-select-option>
</a-select>
</a-form-item>
</a-form>

View file

@ -29,6 +29,7 @@ func (a *ClientController) initRouter(g *gin.RouterGroup) {
g.POST("/del/:id", a.delete)
g.POST("/:id/attach", a.attach)
g.POST("/:id/detach", a.detach)
g.POST("/resetAllTraffics", a.resetAllTraffics)
}
func (a *ClientController) list(c *gin.Context) {
@ -142,6 +143,18 @@ func (a *ClientController) attach(c *gin.Context) {
}
}
func (a *ClientController) resetAllTraffics(c *gin.Context) {
needRestart, err := a.clientService.ResetAllTraffics()
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetAllClientTrafficSuccess"), nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
}
func (a *ClientController) detach(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {

View file

@ -475,6 +475,16 @@ func (s *ClientService) Attach(inboundSvc *InboundService, id int, inboundIds []
return needRestart, nil
}
func (s *ClientService) ResetAllTraffics() (bool, error) {
res := database.GetDB().Model(&xray.ClientTraffic{}).
Where("1 = 1").
Updates(map[string]any{"up": 0, "down": 0})
if res.Error != nil {
return false, res.Error
}
return res.RowsAffected > 0, nil
}
func (s *ClientService) Detach(inboundSvc *InboundService, id int, inboundIds []int) (bool, error) {
existing, err := s.GetByID(id)
if err != nil {

View file

@ -372,7 +372,7 @@
}
}
},
"client": {
"clients": {
"add": "Add Client",
"edit": "Edit Client",
"submitAdd": "Add Client",
@ -402,19 +402,47 @@
"expireDays": "Duration",
"days": "Day(s)",
"renew": "Auto Renew",
"renewDesc": "Auto-renewal after expiration. (0 = disable)(unit: day)"
},
"clients": {
"renewDesc": "Auto-renewal after expiration. (0 = disable)(unit: day)",
"title": "Clients",
"actions": "Actions",
"totalGB": "Total Sent/Received (GB)",
"expiryTime": "Expiry",
"addClients": "Add Clients",
"limitIp": "IP Limit",
"password": "Password",
"subId": "Subscription ID",
"online": "Online",
"email": "Email",
"comment": "Comment",
"traffic": "Traffic",
"offline": "Offline",
"addTitle": "Add Client",
"qrCode": "QR Code",
"info": "Info",
"delete": "Delete",
"reset": "Reset Traffic",
"editTitle": "Edit Client",
"client": "Client",
"enabled": "Enabled",
"remaining": "Remaining",
"duration": "Duration",
"attachedInbounds": "Attached inbounds",
"selectInbound": "Select one or more inbounds",
"noSubId": "This client has no subId, no shareable link.",
"noLinks": "No shareable links — attach this client to a protocol-capable inbound first.",
"link": "Link",
"resetNotPossible": "Attach this client to an inbound first.",
"general": "General",
"resetAllTraffics": "Reset all client traffic",
"resetAllTrafficsTitle": "Reset all client traffic?",
"resetAllTrafficsContent": "Every client's up/down counter drops to zero. Quotas and expiry are not affected. This cannot be undone.",
"empty": "No clients yet — add one to get started.",
"deleteConfirmTitle": "Delete client {email}?",
"deleteConfirmContent": "This removes the client from every attached inbound and drops its traffic record. This cannot be undone.",
"toasts": {
"deleted": "Client deleted"
"deleted": "Client deleted",
"trafficReset": "Traffic reset",
"allTrafficsReset": "All client traffic reset"
}
},
"nodes": {
@ -1000,4 +1028,4 @@
"chooseInbound": "Choose an Inbound"
}
}
}
}